Compare commits
No commits in common. "e1a5c21180150c0cd11015bf6a2ed367531ae366" and "54a96144642425a45474ab0946d33d525766c01d" have entirely different histories.
e1a5c21180
...
54a9614464
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -100,10 +100,5 @@
|
|||
"artist": "Relaxin' Playing Fertig Games",
|
||||
"title": "Sublime"
|
||||
}
|
||||
],
|
||||
"soundtracks": {
|
||||
"main_menu": [
|
||||
{ "file": "mainMenu.mp3" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"tracks": [
|
||||
{
|
||||
"file": "nintendo-track01.mp3",
|
||||
"artist": "Fireball",
|
||||
"title": "Supernova Around the Sun"
|
||||
},
|
||||
{
|
||||
"file": "nintendo-track02.mp3",
|
||||
"artist": "Kona",
|
||||
"title": "Pet me in the Scruffy"
|
||||
}
|
||||
],
|
||||
"volume": 0.8
|
||||
}
|
||||
|
|
@ -56,5 +56,5 @@
|
|||
"speedway": { "key": "superkart-backdrop-speedway", "path": "assets/images/superkart/backdrop-speedway.png" },
|
||||
"nightcity": { "key": "superkart-backdrop-nightcity", "path": "assets/images/superkart/backdrop-nightcity.png" }
|
||||
},
|
||||
"itemSheet": { "key": "superkart-items", "path": "assets/images/superkart/icons.png", "frameWidth": 48, "frameHeight": 48 }
|
||||
"itemSheet": { "key": "superkart-items", "path": null, "frameWidth": 48, "frameHeight": 48 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,16 +47,6 @@ function imagesFrom(scene, jsonKey) {
|
|||
.map((a) => ({ type: 'image', key: a.key, path: a.path }));
|
||||
}
|
||||
|
||||
// Drop-in per-game soundtrack tracks declared in a cached `<name>-music.json`
|
||||
// (see services/soundtrack.js). Audio bytes lazy-load only when the owning
|
||||
// game is entered — never part of the shared default-soundtrack preload.
|
||||
function musicFrom(scene, jsonKey) {
|
||||
const data = scene.cache.json.get(jsonKey);
|
||||
return (data?.tracks ?? [])
|
||||
.filter((t) => t && t.file)
|
||||
.map((t, i) => ({ type: 'audio', key: `${jsonKey}-${i}`, path: `assets/music/${t.file}` }));
|
||||
}
|
||||
|
||||
const sheet = (key, path, frameWidth, frameHeight) => ({ type: 'spritesheet', key, path, frameWidth, frameHeight });
|
||||
const image = (key, path) => ({ type: 'image', key, path });
|
||||
|
||||
|
|
@ -190,9 +180,6 @@ export const MANIFEST = {
|
|||
.filter((b) => b && b.key && b.path)
|
||||
.map((b) => image(b.key, b.path));
|
||||
},
|
||||
// nintendo soundtrack (see services/soundtrack.js) — lazy-loaded here so
|
||||
// its audio only downloads once Super Kart is actually entered.
|
||||
(scene) => musicFrom(scene, 'nintendo-music'),
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
|
||||
import { enqueue as enqueueSpeech, resetQueue } from '../../ui/SpeechQueue.js';
|
||||
|
|
@ -29,7 +28,6 @@ import {
|
|||
buildBackdropCanvas, buildDecorSheetCanvas,
|
||||
} from './SuperKartRaster.js';
|
||||
import { SuperKartMode7, Mode7Backdrop, MODE7, KART_ROT_FRAMES } from './SuperKartMode7.js';
|
||||
import { VictoryCamDirector } from './SuperKartVictoryCam.js';
|
||||
|
||||
const FONT = 'm6x11, "Julius Sans One"';
|
||||
const TIMES_KEY = 'superkart-times';
|
||||
|
|
@ -37,7 +35,7 @@ const GAMEDATA = 'assets/gamedata/superkart';
|
|||
|
||||
const D = {
|
||||
backdrop: -2, ground: -1, world: 10, playerKart: 900, hud: 1000,
|
||||
banner: 1100, overlay: 1150, view: 1200, debug: 1500,
|
||||
banner: 1100, view: 1200, debug: 1500,
|
||||
};
|
||||
const ITEM_FRAME = { bolt: 0, seeker: 1, oil: 2, turbo: 3, overdrive: 4, emp: 5, coins: 6 };
|
||||
const BOX_FRAME = 7;
|
||||
|
|
@ -48,18 +46,6 @@ const BOX_FRAME = 7;
|
|||
const PLAYER_KART_SCALE = (MODE7.spriteBase / 64) * (MODE7.focal / MODE7.followDist);
|
||||
const COIN_FRAME = 8;
|
||||
|
||||
// Player engine loop: default (idle) playback rate 1.0, climbing toward
|
||||
// ENGINE_RATE_MAX as the kart approaches its top speed.
|
||||
const ENGINE_RATE_MIN = 1.0;
|
||||
const ENGINE_RATE_MAX = 2.2;
|
||||
const ENGINE_VOLUME = 0.55;
|
||||
const ENGINE_SFX_BY_RACER = {
|
||||
smasher: 'ENGINE_HEAVY', mario: 'ENGINE_HEAVY', blackwind: 'ENGINE_HEAVY',
|
||||
kona: 'ENGINE_FAST', fireball: 'ENGINE_FAST', gerome: 'ENGINE_FAST',
|
||||
croc: 'ENGINE_MEDIUM', 'dv-8-2303': 'ENGINE_MEDIUM', zanthor: 'ENGINE_MEDIUM',
|
||||
};
|
||||
const KART_STAR_VOLUME = 0.6;
|
||||
|
||||
export default class SuperKartGame extends Phaser.Scene {
|
||||
constructor() { super('SuperKartGame'); }
|
||||
|
||||
|
|
@ -76,15 +62,6 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
this.debugOn = false;
|
||||
this.accum = 0;
|
||||
this.lastTauntMs = 0;
|
||||
this.victoryCamActive = false;
|
||||
this.victoryCam = null;
|
||||
this.victoryCamStartMs = 0;
|
||||
this.skipPrompt = null;
|
||||
this.engineSound = null;
|
||||
this.engineStartSound = null;
|
||||
this.engineTopSpeed = 0;
|
||||
this.starSound = null;
|
||||
this.advanceBtn = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -94,8 +71,8 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
this.cups = this.cache.json.get('superkart-cups') ?? { cups: [], tracks: [] };
|
||||
|
||||
try {
|
||||
const { tracks, volume } = getGameSoundtrack(this);
|
||||
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
|
||||
} catch (_) { /* optional */ }
|
||||
|
||||
this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xffd028, curveAmount: 0.35 });
|
||||
|
|
@ -234,11 +211,6 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
this.input.keyboard.on('keydown-ESC', () => {
|
||||
if (this.raceState) this.exitRace();
|
||||
});
|
||||
this.input.keyboard.on('keydown-ENTER', () => {
|
||||
if (this.victoryCamActive) this.skipVictoryCam();
|
||||
});
|
||||
this.input.keyboard.on('keydown-SPACE', () => this.advanceBtn?.emit('pointerup'));
|
||||
this.input.keyboard.on('keydown-X', () => this.advanceBtn?.emit('pointerup'));
|
||||
}
|
||||
|
||||
readInputs() {
|
||||
|
|
@ -257,7 +229,6 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
clearView() {
|
||||
for (const o of this.viewObjs) o?.destroy();
|
||||
this.viewObjs = [];
|
||||
this.advanceBtn = null;
|
||||
}
|
||||
|
||||
vAdd(obj) {
|
||||
|
|
@ -274,14 +245,6 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
return this.vAdd(t);
|
||||
}
|
||||
|
||||
// Dims whatever's behind a text-heavy view — used when the live race scene
|
||||
// (not the static menu background) is still rendering behind results /
|
||||
// standings, so text stays legible over the moving footage.
|
||||
scrim(alpha = 0.55) {
|
||||
return this.vAdd(this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, alpha)
|
||||
.setDepth(D.overlay));
|
||||
}
|
||||
|
||||
showWebGLNotice() {
|
||||
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40,
|
||||
'SUPER KART NEEDS WEBGL', {
|
||||
|
|
@ -504,9 +467,6 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
this.buildRaceHud(model, entry, cls);
|
||||
this.mode7.follow(this.playerKartState(), true);
|
||||
playSound(this, SFX.SCIFI_RISER);
|
||||
// Engine fires up right away, before the stoplight countdown even
|
||||
// starts — the idle loop kicks in once engine-start.mp3 finishes.
|
||||
this.startEngineAudio(this.playerKartState());
|
||||
}
|
||||
|
||||
playerKartState() { return this.raceState.karts[this.raceState.playerIndex]; }
|
||||
|
|
@ -526,11 +486,6 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
this.debugG?.destroy();
|
||||
this.debugG = null;
|
||||
this.minimapDots = null;
|
||||
this.victoryCamActive = false;
|
||||
this.victoryCam = null;
|
||||
this.hideSkipPrompt();
|
||||
this.stopEngineSound();
|
||||
this.stopStarLoop();
|
||||
}
|
||||
|
||||
exitRace() {
|
||||
|
|
@ -663,12 +618,6 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
this.renderRace();
|
||||
if (this.raceState.phase === 'finished' && !this.raceResultsShown) {
|
||||
this.raceResultsShown = true;
|
||||
// Keep the victory cam cutting through the race scene behind the
|
||||
// results/standings screens instead of tearing down to the static
|
||||
// menu background — only the skip prompt (nothing left to skip) and
|
||||
// the now-stale race HUD go away.
|
||||
this.hideSkipPrompt();
|
||||
this.hud?.setVisible(false);
|
||||
this.time.delayedCall(900, () => this.showResults());
|
||||
} else if (prevPhase === 'countdown') {
|
||||
this.renderCountdown();
|
||||
|
|
@ -690,13 +639,7 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
renderRace() {
|
||||
const state = this.raceState;
|
||||
const player = this.playerKartState();
|
||||
if (this.victoryCamActive) {
|
||||
const pose = this.victoryCam.update(state.timeMs - this.victoryCamStartMs, player);
|
||||
this.mode7.setCamera(pose.x, pose.y, pose.angle, pose.height, pose.focal);
|
||||
if (this.victoryCam.justCut) this.crt?.pulse(0.35, 120);
|
||||
} else {
|
||||
this.mode7.follow(player);
|
||||
}
|
||||
this.mode7.follow(player);
|
||||
this.backdrop.update(this.mode7.cam.angle);
|
||||
|
||||
// Static + kart world sprites.
|
||||
|
|
@ -760,145 +703,13 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
}
|
||||
for (let i = di; i < this.dynamicPool.length; i += 1) this.dynamicPool[i].setVisible(false);
|
||||
|
||||
if (this.victoryCamActive) this.renderPlayerKartCinematic(player);
|
||||
else this.renderPlayerKart(player);
|
||||
if (!this.raceResultsShown) this.renderHud(state, player);
|
||||
this.renderPlayerKart(player);
|
||||
this.renderHud(state, player);
|
||||
if (this.debugOn) this.renderDebug(state);
|
||||
this.updateEngineSound(player);
|
||||
this.updateStarSound(player);
|
||||
}
|
||||
|
||||
// ── Player engine sound ──────────────────────────────────────────────────
|
||||
// engine-start.mp3 plays once at the green light, then a looping
|
||||
// per-racer engine hum takes over with its pitch tracking kart.speed —
|
||||
// stopped the instant the player crosses the finish line.
|
||||
|
||||
engineKeyForRacer(racerId) {
|
||||
return SFX[ENGINE_SFX_BY_RACER[racerId] ?? 'ENGINE_MEDIUM'];
|
||||
}
|
||||
|
||||
startEngineAudio(kart) {
|
||||
this.stopEngineSound();
|
||||
try {
|
||||
const s = this.sound.add(SFX.ENGINE_START);
|
||||
this.engineStartSound = s;
|
||||
s.once('complete', () => {
|
||||
this.engineStartSound = null;
|
||||
if (this.raceState) this.startEngineLoop(kart);
|
||||
});
|
||||
s.play();
|
||||
} catch (_) {
|
||||
this.startEngineLoop(kart);
|
||||
}
|
||||
}
|
||||
|
||||
startEngineLoop(kart) {
|
||||
try {
|
||||
this.engineSound = this.sound.add(this.engineKeyForRacer(kart.racer.id), {
|
||||
loop: true, rate: ENGINE_RATE_MIN, volume: ENGINE_VOLUME,
|
||||
});
|
||||
this.engineSound.play();
|
||||
} catch (_) {
|
||||
this.engineSound = null;
|
||||
}
|
||||
const physics = this.raceState?.physics;
|
||||
this.engineTopSpeed = physics
|
||||
? topSpeedOf(kart.racer.stats, physics, this.raceState.engineClass?.speedMult ?? 1) : 0;
|
||||
}
|
||||
|
||||
updateEngineSound(kart) {
|
||||
if (!this.engineSound) return;
|
||||
const frac = this.engineTopSpeed > 0 ? Phaser.Math.Clamp(Math.abs(kart.speed) / this.engineTopSpeed, 0, 1) : 0;
|
||||
this.engineSound.setRate(ENGINE_RATE_MIN + (ENGINE_RATE_MAX - ENGINE_RATE_MIN) * frac);
|
||||
}
|
||||
|
||||
stopEngineSound() {
|
||||
if (this.engineStartSound) {
|
||||
try { this.engineStartSound.stop(); this.engineStartSound.destroy(); } catch (_) { /* noop */ }
|
||||
this.engineStartSound = null;
|
||||
}
|
||||
if (this.engineSound) {
|
||||
try { this.engineSound.stop(); this.engineSound.destroy(); } catch (_) { /* noop */ }
|
||||
this.engineSound = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Player star-power loop ───────────────────────────────────────────────
|
||||
|
||||
startStarLoop() {
|
||||
if (this.starSound) return;
|
||||
try {
|
||||
this.starSound = this.sound.add(SFX.KART_STAR, { loop: true, volume: KART_STAR_VOLUME });
|
||||
this.starSound.play();
|
||||
} catch (_) {
|
||||
this.starSound = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateStarSound(kart) {
|
||||
if (this.starSound && kart.starMs <= 0) this.stopStarLoop();
|
||||
}
|
||||
|
||||
stopStarLoop() {
|
||||
if (!this.starSound) return;
|
||||
try { this.starSound.stop(); this.starSound.destroy(); } catch (_) { /* noop */ }
|
||||
this.starSound = null;
|
||||
}
|
||||
|
||||
// ── Victory cam (post-race AI takeover) ─────────────────────────────────
|
||||
|
||||
startVictoryCam() {
|
||||
this.victoryCamActive = true;
|
||||
this.victoryCam = new VictoryCamDirector(this.raceState.seed ^ 0x5eed);
|
||||
this.victoryCam.start(this.playerKartState());
|
||||
this.victoryCamStartMs = this.raceState.timeMs;
|
||||
this.showSkipPrompt();
|
||||
}
|
||||
|
||||
skipVictoryCam() {
|
||||
if (!this.victoryCamActive) return;
|
||||
this.raceState.phase = 'finished'; // the natural end-of-race path in update() takes it from here
|
||||
}
|
||||
|
||||
showSkipPrompt() {
|
||||
const btn = new Button(this, GAME_WIDTH - 150, GAME_HEIGHT - 60, 'SKIP ▶', () => this.skipVictoryCam(),
|
||||
{ width: 220, height: 52, fontSize: 20, variant: 'ghost' });
|
||||
btn.setDepth(D.banner).setAlpha(0);
|
||||
this.tweens.add({ targets: btn, alpha: 1, duration: 300 });
|
||||
const caption = this.add.text(GAME_WIDTH - 150, GAME_HEIGHT - 100, 'Waiting for the field to finish…', {
|
||||
fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.banner).setAlpha(0);
|
||||
this.tweens.add({ targets: caption, alpha: 1, duration: 300, delay: 200 });
|
||||
this.skipPrompt = [btn, caption];
|
||||
}
|
||||
|
||||
hideSkipPrompt() {
|
||||
for (const o of this.skipPrompt ?? []) o.destroy();
|
||||
this.skipPrompt = null;
|
||||
}
|
||||
|
||||
// Projects the player kart through the Mode-7 camera like an AI kart,
|
||||
// instead of the normal screen-fixed rig — used only while victoryCamActive.
|
||||
renderPlayerKartCinematic(kart) {
|
||||
const s = this.playerSprite;
|
||||
const frame = this.mode7.angleFrame(kart.spinMs > 0 ? kart.heading + kart.spinSpin : kart.heading, KART_ROT_FRAMES);
|
||||
const p = this.mode7.project(kart.x, kart.y);
|
||||
if (!p) { s.setVisible(false); return; }
|
||||
s.setVisible(true).setAngle(0).setFrame(frame);
|
||||
let scale = p.scale;
|
||||
if (kart.empMs > 0) scale *= 0.55;
|
||||
if (kart.squashMs > 0) s.setScale(scale * 1.25, scale * 0.45);
|
||||
else s.setScale(scale);
|
||||
s.setPosition(p.x, p.y - s.displayHeight * 0.42);
|
||||
s.setDepth(D.world + Math.max(0, 800 - p.z));
|
||||
s.setAlpha(kart.invulnMs > 0 ? (Math.floor(this.raceState.timeMs / 80) % 2 ? 0.35 : 1) : 1);
|
||||
if (kart.starMs > 0) s.setTint(starTint(this.raceState.timeMs));
|
||||
else s.clearTint();
|
||||
}
|
||||
|
||||
renderPlayerKart(kart) {
|
||||
const s = this.playerSprite;
|
||||
s.setDepth(D.playerKart); // reset in case cinematic mode last touched depth
|
||||
const sheet = this.textures.get(this.kartTexKey(kart.racer.id));
|
||||
const hasLean = sheet.frameTotal > KART_ROT_FRAMES + 1;
|
||||
const inputs = this.readInputs();
|
||||
|
|
@ -1007,70 +818,27 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
|
||||
// ── Events → sound + speech ───────────────────────────────────────────────
|
||||
|
||||
// Full volume for the player's own kart; for any other kart, volume falls
|
||||
// off linearly with distance from the player, down to silent past maxDist.
|
||||
playPositionalSound(key, kartIdx, maxDist = 1100) {
|
||||
const state = this.raceState;
|
||||
let volume = 1;
|
||||
if (kartIdx !== state.playerIndex) {
|
||||
const kart = state.karts[kartIdx];
|
||||
const player = this.playerKartState();
|
||||
if (!kart || !player) return;
|
||||
const dist = Math.hypot(kart.x - player.x, kart.y - player.y);
|
||||
volume = Phaser.Math.Clamp(1 - dist / maxDist, 0, 1);
|
||||
if (volume <= 0.02) return;
|
||||
}
|
||||
try { this.sound.play(key, { volume }); } catch (_) { /* audio locked */ }
|
||||
}
|
||||
|
||||
processEvents(events) {
|
||||
const state = this.raceState;
|
||||
const pIdx = state.playerIndex;
|
||||
for (const ev of events) {
|
||||
switch (ev.type) {
|
||||
case 'go': playSound(this, SFX.COUNTDOWN_GO); break;
|
||||
case 'item-box':
|
||||
if (ev.kart === pIdx) { playSound(this, SFX.UI_FLIP); playSound(this, SFX.KART_SPINNER); }
|
||||
break;
|
||||
case 'item-box': if (ev.kart === pIdx) playSound(this, SFX.UI_FLIP); break;
|
||||
case 'item-get': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break;
|
||||
case 'item-use':
|
||||
if (ev.kart === pIdx) {
|
||||
playSound(this, SFX.WOOSH);
|
||||
if (ev.item === 'turbo') playSound(this, SFX.ENGINE_REV);
|
||||
if (ev.item === 'bolt' || ev.item === 'seeker') playSound(this, SFX.KART_SHELL);
|
||||
}
|
||||
break;
|
||||
case 'boost':
|
||||
if (ev.kart === pIdx) {
|
||||
playSound(this, SFX.SCIFI_WOOSH);
|
||||
if (ev.pad) playSound(this, SFX.ENGINE_REV);
|
||||
}
|
||||
break;
|
||||
case 'star':
|
||||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||||
if (ev.kart === pIdx) this.startStarLoop();
|
||||
break;
|
||||
case 'emp':
|
||||
playSound(this, SFX.LASER_ZAP);
|
||||
for (let i = 0; i < (ev.targets ?? []).length; i += 1) playSound(this, SFX.KART_SHRINK);
|
||||
break;
|
||||
case 'squash': this.playPositionalSound(SFX.KART_FLATTEN, ev.kart); break;
|
||||
case 'spin':
|
||||
this.playPositionalSound(ev.cause === 'bolt' || ev.cause === 'seeker' ? SFX.KART_HIT : SFX.KART_SPIN, ev.kart);
|
||||
this.taunt(ev.kart, ev.kart === pIdx);
|
||||
break;
|
||||
case 'item-use': if (ev.kart === pIdx) playSound(this, SFX.WOOSH); break;
|
||||
case 'boost': if (ev.kart === pIdx) playSound(this, SFX.SCIFI_WOOSH); break;
|
||||
case 'star': playSound(this, SFX.EIGHTBIT_ACTIVATE); break;
|
||||
case 'emp': playSound(this, SFX.LASER_ZAP); break;
|
||||
case 'spin': if (ev.kart === pIdx) playSound(this, SFX.SQUISH); this.taunt(ev.kart, ev.kart === pIdx); break;
|
||||
case 'wallhit': if (ev.kart === pIdx) playSound(this, SFX.EIGHTBIT_MOVE); break;
|
||||
case 'bump': if (ev.a === pIdx || ev.b === pIdx) playSound(this, SFX.KART_THUMP); break;
|
||||
case 'coin': if (ev.kart === pIdx) playSound(this, SFX.KART_COIN); break;
|
||||
case 'bump': if (ev.a === pIdx || ev.b === pIdx) playSound(this, SFX.PIECE_CLICK); break;
|
||||
case 'coin': if (ev.kart === pIdx) playSound(this, SFX.COINS); break;
|
||||
case 'lap': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break;
|
||||
case 'splash': if (ev.kart === pIdx) playSound(this, 'sfx-water-splash'); break;
|
||||
case 'shatter': playSound(this, SFX.EIGHTBIT_EXPLODE); break;
|
||||
case 'finish':
|
||||
if (ev.kart === pIdx) {
|
||||
playSound(this, ev.place <= 3 ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE);
|
||||
this.stopEngineSound();
|
||||
if (state.mode !== 'tt') this.startVictoryCam();
|
||||
}
|
||||
if (ev.kart === pIdx) playSound(this, ev.place <= 3 ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE);
|
||||
break;
|
||||
case 'player-position':
|
||||
if (ev.to > ev.from) this.tauntFromLeader(ev.to);
|
||||
|
|
@ -1125,10 +893,10 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
const cls = this.rules.engineClasses[this.chosen.classIdx];
|
||||
const player = this.playerKartState();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
this.teardownRace();
|
||||
this.clearView();
|
||||
|
||||
if (this.returnToEditor) {
|
||||
this.teardownRace();
|
||||
this.clearView();
|
||||
this.text(cx, 240, 'TEST COMPLETE', 64, '#ffd028');
|
||||
this.text(cx, 330, `TIME ${fmtTime(player.finishTimeMs - COUNTDOWN_MS)} BEST LAP ${fmtTime(player.bestLapMs)}`, 30);
|
||||
this.vAdd(new Button(this, cx, 460, 'Back to Editor', () => this.scene.start('SuperKartEditor', { resume: true })));
|
||||
|
|
@ -1136,8 +904,6 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
if (state.mode === 'tt') {
|
||||
this.teardownRace();
|
||||
this.clearView();
|
||||
const ms = player.finishTimeMs - COUNTDOWN_MS;
|
||||
const isRecord = this.recordTime(this.raceEntry.id, cls.id, ms);
|
||||
this.text(cx, 180, isRecord ? 'NEW RECORD!' : 'RUN COMPLETE', 64, isRecord ? '#ffd028' : COLORS.textHex);
|
||||
|
|
@ -1151,259 +917,76 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
return;
|
||||
}
|
||||
|
||||
// GP: keep the race scene (ground/backdrop/karts + victory cam) alive
|
||||
// behind the results/standings screens instead of tearing it down —
|
||||
// teardownRace() happens later, at showPodium() or the next startRace().
|
||||
this.clearView();
|
||||
this.showRaceResultsScreen(results);
|
||||
}
|
||||
|
||||
// This race's results only (no cup totals — those are revealed, animated,
|
||||
// on the next screen). Disappears after a fixed 5s pause.
|
||||
showRaceResultsScreen(results) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const playerId = this.playerRacer().id;
|
||||
const playerResult = results.find((r) => r.racerId === playerId);
|
||||
|
||||
this.scrim();
|
||||
// Grand Prix race results + cumulative standings.
|
||||
for (const r of results) this.gp.points[r.racerId] += r.points;
|
||||
const lastRace = this.gp.raceIdx >= this.gp.cup.tracks.length - 1;
|
||||
this.text(cx, 110, `${this.raceEntry.name} — RESULTS`, 48, '#ffd028');
|
||||
const header = (x, s) => this.text(x, 180, s, 22, COLORS.mutedHex);
|
||||
header(cx - 380, 'POS');
|
||||
header(cx - 200, 'RACER');
|
||||
header(cx + 140, 'TIME');
|
||||
header(cx + 340, 'PTS');
|
||||
|
||||
header(cx - 420, 'POS');
|
||||
header(cx - 240, 'RACER');
|
||||
header(cx + 90, 'TIME');
|
||||
header(cx + 260, 'PTS');
|
||||
header(cx + 400, 'TOTAL');
|
||||
results.forEach((r, i) => {
|
||||
const y = 226 + i * 52;
|
||||
const isP = r.racerId === playerId;
|
||||
const racer = this.racers.find((rc) => rc.id === r.racerId);
|
||||
const isP = r.racerId === this.playerRacer().id;
|
||||
const color = isP ? '#ffd028' : COLORS.textHex;
|
||||
const row = this.add.container(0, y).setDepth(D.view).setAlpha(0);
|
||||
this.vAdd(row);
|
||||
const rowText = (x, s, size, origin = 0.5) => {
|
||||
const t = this.add.text(x, 0, s, { fontFamily: FONT, fontSize: `${size}px`, color }).setOrigin(origin);
|
||||
row.add(t);
|
||||
};
|
||||
rowText(cx - 380, String(r.position), 28);
|
||||
this.text(cx - 420, y, String(r.position), 28, color);
|
||||
if (this.textures.exists('opponents')) {
|
||||
row.add(this.add.image(cx - 290, 0, 'opponents', this.opponentById[r.racerId]?.spriteIndex ?? 0)
|
||||
.setDisplaySize(44, 44));
|
||||
this.vAdd(this.add.image(cx - 330, y, 'opponents', this.opponentById[r.racerId]?.spriteIndex ?? 0)
|
||||
.setDisplaySize(44, 44).setDepth(D.view));
|
||||
}
|
||||
rowText(cx - 200, (this.opponentById[r.racerId]?.name ?? r.racerId).toUpperCase(), 26, { x: 0, y: 0.5 });
|
||||
rowText(cx + 140, r.finished ? fmtTime(r.timeMs - COUNTDOWN_MS) : '—', 24);
|
||||
rowText(cx + 340, `+${r.points}`, 24);
|
||||
this.tweens.add({
|
||||
targets: row, alpha: 1, duration: 260, delay: i * 90, ease: 'Cubic.easeOut',
|
||||
onComplete: () => { if (isP && r.position <= 3) this.crt?.pulse(0.3, 100); },
|
||||
});
|
||||
this.text(cx - 240, y, (this.opponentById[r.racerId]?.name ?? r.racerId).toUpperCase(), 26, color, { x: 0, y: 0.5 });
|
||||
this.text(cx + 90, y, r.finished ? fmtTime(r.timeMs - COUNTDOWN_MS) : '—', 24, color);
|
||||
this.text(cx + 260, y, `+${r.points}`, 24, color);
|
||||
this.text(cx + 400, y, String(this.gp.points[r.racerId]), 26, color);
|
||||
void racer;
|
||||
});
|
||||
|
||||
if (playerResult && playerResult.position <= 3) playSound(this, SFX.FIREWORK);
|
||||
|
||||
this.time.delayedCall(5000, () => {
|
||||
this.clearView();
|
||||
this.showCupStandings(results);
|
||||
});
|
||||
}
|
||||
|
||||
// Animated cup-standings re-rank: draws rows in their PRE-race order, then
|
||||
// tweens each row (position + running point total) to its post-race slot.
|
||||
// This is the only place this.gp.points gets mutated for this race.
|
||||
showCupStandings(results) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const rowH = 62;
|
||||
const topY = 220;
|
||||
const playerId = this.playerRacer().id;
|
||||
|
||||
const before = Object.entries(this.gp.points).sort((a, b) => b[1] - a[1]).map(([id]) => id);
|
||||
const prevPoints = { ...this.gp.points };
|
||||
for (const r of results) this.gp.points[r.racerId] += r.points;
|
||||
const after = Object.entries(this.gp.points).sort((a, b) => b[1] - a[1]).map(([id]) => id);
|
||||
|
||||
this.scrim();
|
||||
this.text(cx, 100, `${this.gp.cup.name} — STANDINGS`, 44, '#ffd028');
|
||||
const header = (x, s) => this.text(x, topY - 40, s, 20, COLORS.mutedHex);
|
||||
header(cx - 380, 'POS');
|
||||
header(cx - 260, 'RACER');
|
||||
header(cx + 340, 'PTS');
|
||||
playSound(this, SFX.SCIFI_RISER);
|
||||
|
||||
const rows = {};
|
||||
before.forEach((id, i) => {
|
||||
const isP = id === playerId;
|
||||
const color = isP ? '#ffd028' : COLORS.textHex;
|
||||
const row = this.add.container(0, topY + i * rowH).setDepth(D.view);
|
||||
this.vAdd(row);
|
||||
const posText = this.add.text(cx - 380, 0, String(i + 1), { fontFamily: FONT, fontSize: '26px', color }).setOrigin(0.5);
|
||||
const name = this.add.text(cx - 260, 0, (this.opponentById[id]?.name ?? id).toUpperCase(), {
|
||||
fontFamily: FONT, fontSize: '24px', color,
|
||||
}).setOrigin(0, 0.5);
|
||||
const counter = { v: prevPoints[id] };
|
||||
const ptsText = this.add.text(cx + 340, 0, String(Math.round(counter.v)), {
|
||||
fontFamily: FONT, fontSize: '26px', color,
|
||||
}).setOrigin(0.5);
|
||||
const arrow = this.add.text(cx + 290, 0, '', { fontFamily: FONT, fontSize: '22px', color: '#38b048' })
|
||||
.setOrigin(0.5).setAlpha(0);
|
||||
row.add([posText, name, ptsText, arrow]);
|
||||
rows[id] = {
|
||||
row, posText, ptsText, arrow, counter,
|
||||
};
|
||||
});
|
||||
|
||||
let pending = before.length * 2; // row-move + count-up per racer
|
||||
let done = false;
|
||||
const settleRow = (id) => {
|
||||
pending -= 1;
|
||||
if (id === playerId) this.crt?.pulse(0.4, 150);
|
||||
if (pending === 0 && !done) {
|
||||
done = true;
|
||||
playSound(this, SFX.UI_CHIME);
|
||||
this.showStandingsButton();
|
||||
}
|
||||
};
|
||||
|
||||
this.time.delayedCall(700, () => {
|
||||
const animMs = 900;
|
||||
const tickCounter = { v: 0 };
|
||||
const tickTimer = this.time.addEvent({
|
||||
delay: 70,
|
||||
loop: true,
|
||||
callback: () => {
|
||||
const p = Math.min(1, tickCounter.v);
|
||||
try { this.sound.play(SFX.SCIFI_PLINK, { rate: 0.9 + 1.3 * p, volume: 0.5 }); } catch (_) { /* audio locked */ }
|
||||
if (p >= 1) tickTimer.remove();
|
||||
},
|
||||
});
|
||||
this.tweens.add({ targets: tickCounter, v: 1, duration: animMs });
|
||||
|
||||
before.forEach((id, i) => {
|
||||
const newIdx = after.indexOf(id);
|
||||
const targetY = topY + newIdx * rowH;
|
||||
const delta = i - newIdx; // positive = moved up toward 1st
|
||||
const r = rows[id];
|
||||
if (delta !== 0) {
|
||||
r.arrow.setText(delta > 0 ? '▲' : '▼').setColor(delta > 0 ? '#38b048' : '#e06c75').setAlpha(0);
|
||||
this.tweens.add({ targets: r.arrow, alpha: 1, duration: 200, delay: 150 });
|
||||
}
|
||||
this.tweens.add({
|
||||
targets: r.row, y: targetY, duration: animMs, ease: 'Cubic.easeInOut',
|
||||
onComplete: () => {
|
||||
r.posText.setText(String(newIdx + 1));
|
||||
if (r.arrow.alpha > 0) this.tweens.add({ targets: r.arrow, alpha: 0, duration: 400, delay: 400 });
|
||||
settleRow(id);
|
||||
},
|
||||
});
|
||||
this.tweens.add({
|
||||
targets: r.counter, v: this.gp.points[id], duration: animMs, ease: 'Cubic.easeOut',
|
||||
onUpdate: () => r.ptsText.setText(String(Math.round(r.counter.v))),
|
||||
onComplete: () => settleRow(id),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showStandingsButton() {
|
||||
const lastRace = this.gp.raceIdx >= this.gp.cup.tracks.length - 1;
|
||||
const btn = this.vAdd(new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 90,
|
||||
lastRace ? 'Final Standings' : 'Next Race', () => {
|
||||
this.advanceBtn = null;
|
||||
if (lastRace) this.showPodium();
|
||||
else { this.gp.raceIdx += 1; this.startCupRace(); }
|
||||
}, { width: 340 }));
|
||||
btn.setAlpha(0);
|
||||
this.tweens.add({ targets: btn, alpha: 1, duration: 300 });
|
||||
this.advanceBtn = btn;
|
||||
}
|
||||
|
||||
// Small outward-bursting rectangle particles — shared by showCupStandings'
|
||||
// player-row payoff and showPodium's reveals. Self-destroying, not tracked
|
||||
// via vAdd since they're a short-lived effect, not a view object.
|
||||
confetti(x, y, tint = 0xffd028) {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const piece = this.add.rectangle(x, y, 6, 10, tint).setDepth(D.banner).setAngle(Math.random() * 360);
|
||||
const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9;
|
||||
const dist = 60 + Math.random() * 90;
|
||||
this.tweens.add({
|
||||
targets: piece,
|
||||
x: x + Math.cos(angle) * dist,
|
||||
y: y + Math.sin(angle) * dist + 40,
|
||||
angle: piece.angle + (Math.random() - 0.5) * 480,
|
||||
alpha: 0,
|
||||
duration: 700 + Math.random() * 400,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => piece.destroy(),
|
||||
});
|
||||
}
|
||||
this.vAdd(new Button(this, cx, GAME_HEIGHT - 90, lastRace ? 'Final Standings' : 'Next Race', () => {
|
||||
if (lastRace) this.showPodium();
|
||||
else { this.gp.raceIdx += 1; this.startCupRace(); }
|
||||
}, { width: 340 }));
|
||||
}
|
||||
|
||||
showPodium() {
|
||||
// The cup-final screen goes back to the static menu background rather
|
||||
// than the live race scene — this is where that finally gets torn down
|
||||
// (results/standings kept it alive for the victory-cam backdrop).
|
||||
this.teardownRace();
|
||||
this.clearView();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const standings = Object.entries(this.gp.points).sort((a, b) => b[1] - a[1]);
|
||||
const standings = Object.entries(this.gp.points)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
const playerId = this.playerRacer().id;
|
||||
const playerPlace = standings.findIndex(([id]) => id === playerId) + 1;
|
||||
|
||||
this.text(cx, 100, `${this.gp.cup.name} — FINAL STANDINGS`, 48, '#ffd028');
|
||||
this.text(cx, 170, playerPlace === 1 ? 'CHAMPION!' : `YOU FINISHED ${ordinal(playerPlace).toUpperCase()}`, 34,
|
||||
playerPlace === 1 ? '#ffd028' : COLORS.textHex);
|
||||
|
||||
// Podium top 3 with live portraits (video/mood if assets exist). Revealed
|
||||
// 3rd → 2nd → 1st, staggered, for a bit of suspense before the winner.
|
||||
// Podium top 3 with live portraits (video/mood if assets exist).
|
||||
const podX = [cx, cx - 330, cx + 330];
|
||||
const podY = [340, 400, 430];
|
||||
const tintFor = [0xffd028, 0xc8ccd4, 0xcd8a4a]; // gold / silver / bronze
|
||||
|
||||
const revealSlot = (i) => {
|
||||
const [id, pts] = standings[i];
|
||||
standings.slice(0, 3).forEach(([id], i) => {
|
||||
const op = this.opponentById[id];
|
||||
const isP = id === playerId;
|
||||
if (op) {
|
||||
const portrait = createOpponentPortrait(this, op, podX[i], podY[i], i === 0 ? 110 : 85, D.view, { playIntro: false });
|
||||
this.viewObjs.push(portrait);
|
||||
portrait.setAlpha?.(0);
|
||||
this.tweens.add({ targets: portrait, alpha: 1, duration: 260 });
|
||||
}
|
||||
const label = this.text(podX[i], podY[i] + (i === 0 ? 150 : 125), `${i + 1}. ${(op?.name ?? id).toUpperCase()}`, 28,
|
||||
isP ? '#ffd028' : COLORS.textHex).setAlpha(0);
|
||||
this.tweens.add({ targets: label, alpha: 1, duration: 260 });
|
||||
const counter = { v: 0 };
|
||||
const ptsText = this.text(podX[i], podY[i] + (i === 0 ? 186 : 161), '0 PTS', 22, COLORS.mutedHex).setAlpha(0);
|
||||
this.tweens.add({ targets: ptsText, alpha: 1, duration: 200 });
|
||||
this.tweens.add({
|
||||
targets: counter, v: pts, duration: 700, ease: 'Cubic.easeOut',
|
||||
onUpdate: () => ptsText.setText(`${Math.round(counter.v)} PTS`),
|
||||
});
|
||||
this.confetti(podX[i], podY[i], tintFor[i]);
|
||||
playSound(this, SFX.SCIFI_REVEAL);
|
||||
if (i === 0) {
|
||||
this.crt?.pulse(0.6, 200);
|
||||
playSound(this, playerPlace === 1 ? SFX.CASINO_WIN : SFX.VICTORY_SHORT);
|
||||
const subtitle = this.text(cx, 170, playerPlace === 1 ? 'CHAMPION!' : `YOU FINISHED ${ordinal(playerPlace).toUpperCase()}`,
|
||||
34, playerPlace === 1 ? '#ffd028' : COLORS.textHex).setAlpha(0);
|
||||
this.tweens.add({ targets: subtitle, alpha: 1, duration: 300 });
|
||||
const winner = this.opponentById[id];
|
||||
const clip = winner?.speech?.happy?.[0];
|
||||
if (clip) { resetQueue(); enqueueSpeech(clip); }
|
||||
}
|
||||
};
|
||||
|
||||
[2, 1, 0].forEach((podiumIdx, k) => this.time.delayedCall(k * 900, () => revealSlot(podiumIdx)));
|
||||
|
||||
this.time.delayedCall(2700, () => {
|
||||
standings.slice(3).forEach(([id, pts], i) => {
|
||||
const t = this.text(cx - 200 + Math.floor(i / 3) * 400, 660 + (i % 3) * 40,
|
||||
`${i + 4}. ${(this.opponentById[id]?.name ?? id).toUpperCase()} ${pts} PTS`, 22,
|
||||
id === playerId ? '#ffd028' : COLORS.mutedHex).setAlpha(0);
|
||||
this.tweens.add({ targets: t, alpha: 1, duration: 220, delay: i * 40 });
|
||||
});
|
||||
|
||||
const btn1 = this.vAdd(new Button(this, cx - 180, GAME_HEIGHT - 70, 'Race Again', () => this.showCupSelect(), { width: 260 }));
|
||||
const btn2 = this.vAdd(new Button(this, cx + 180, GAME_HEIGHT - 70, 'Main Menu', () => this.showMenu(), { width: 260, variant: 'ghost' }));
|
||||
btn1.setAlpha(0);
|
||||
btn2.setAlpha(0);
|
||||
this.tweens.add({ targets: [btn1, btn2], alpha: 1, duration: 300, delay: 300 });
|
||||
this.text(podX[i], podY[i] + (i === 0 ? 150 : 125), `${i + 1}. ${(op?.name ?? id).toUpperCase()}`, 28,
|
||||
id === playerId ? '#ffd028' : COLORS.textHex);
|
||||
this.text(podX[i], podY[i] + (i === 0 ? 186 : 161), `${this.gp.points[id]} PTS`, 22, COLORS.mutedHex);
|
||||
});
|
||||
const winner = this.opponentById[standings[0][0]];
|
||||
const clip = winner?.speech?.happy?.[0];
|
||||
if (clip) { resetQueue(); enqueueSpeech(clip); }
|
||||
|
||||
standings.slice(3).forEach(([id, pts], i) => {
|
||||
this.text(cx - 200 + Math.floor(i / 3) * 400, 660 + (i % 3) * 40,
|
||||
`${i + 4}. ${(this.opponentById[id]?.name ?? id).toUpperCase()} ${pts} PTS`, 22,
|
||||
id === playerId ? '#ffd028' : COLORS.mutedHex);
|
||||
});
|
||||
|
||||
playSound(this, playerPlace === 1 ? SFX.CASINO_WIN : SFX.VICTORY_SHORT);
|
||||
this.vAdd(new Button(this, cx - 180, GAME_HEIGHT - 70, 'Race Again', () => this.showCupSelect(), { width: 260 }));
|
||||
this.vAdd(new Button(this, cx + 180, GAME_HEIGHT - 70, 'Main Menu', () => this.showMenu(), { width: 260, variant: 'ghost' }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@ import {
|
|||
const TAU = Math.PI * 2;
|
||||
export const STEP_MS = 1000 / 60;
|
||||
export const COUNTDOWN_MS = 3400;
|
||||
// Once the player finishes, the field is given this long (sim time) to finish
|
||||
// too before the race is forced to end — a safety net against a stuck AI,
|
||||
// not the normal path (state.karts.every(finished) ends it sooner).
|
||||
const POST_FINISH_SAFETY_MS = 90000;
|
||||
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
|
|
@ -252,10 +248,7 @@ function aiSkillOf(state, kart) {
|
|||
|
||||
function computeAiInputs(state, kart) {
|
||||
const inputs = kartInputsNeutral();
|
||||
// Finished karts (including the autopiloted player) keep actively driving
|
||||
// laps through the results/standings screens rather than coasting to a
|
||||
// stop — the scene only stops stepping this state once it tears the race
|
||||
// down (next race start, or the cup-final podium).
|
||||
if (kart.finished && state.phase === 'finished') return inputs;
|
||||
const { model, physics } = state;
|
||||
const skill = aiSkillOf(state, kart);
|
||||
|
||||
|
|
@ -660,15 +653,13 @@ function useItem(state, kart) {
|
|||
emit(state, 'star', { kart: kart.index });
|
||||
break;
|
||||
case 'emp': {
|
||||
const targets = [];
|
||||
for (const other of state.karts) {
|
||||
if (other === kart || other.finished) continue;
|
||||
if (other.starMs > 0 || other.invulnMs > 0) continue;
|
||||
other.empMs = (params.ms ?? 4200) + other.racer.stats.weight * (params.weightExtraMs ?? 1600);
|
||||
other.drifting = false;
|
||||
targets.push(other.index);
|
||||
}
|
||||
emit(state, 'emp', { kart: kart.index, targets });
|
||||
emit(state, 'emp', { kart: kart.index });
|
||||
break;
|
||||
}
|
||||
case 'coins':
|
||||
|
|
@ -777,13 +768,8 @@ function stepCollisions(state) {
|
|||
if (a.starMs > 0 && b.starMs <= 0) startSpin(state, b, 'star');
|
||||
else if (b.starMs > 0 && a.starMs <= 0) startSpin(state, a, 'star');
|
||||
// A heavy kart running over an EMP-shrunk one squashes it.
|
||||
if (a.empMs > 0 && b.empMs <= 0 && b.squashMs <= 0 && a.squashMs <= 0) {
|
||||
a.squashMs = physics.squashMs;
|
||||
emit(state, 'squash', { kart: a.index });
|
||||
} else if (b.empMs > 0 && a.empMs <= 0 && a.squashMs <= 0 && b.squashMs <= 0) {
|
||||
b.squashMs = physics.squashMs;
|
||||
emit(state, 'squash', { kart: b.index });
|
||||
}
|
||||
if (a.empMs > 0 && b.empMs <= 0 && b.squashMs <= 0 && a.squashMs <= 0) a.squashMs = physics.squashMs;
|
||||
else if (b.empMs > 0 && a.empMs <= 0 && a.squashMs <= 0 && b.squashMs <= 0) b.squashMs = physics.squashMs;
|
||||
emit(state, 'bump', { a: a.index, b: b.index });
|
||||
}
|
||||
}
|
||||
|
|
@ -823,15 +809,11 @@ export function step(state, playerInputs = kartInputsNeutral()) {
|
|||
|
||||
for (const kart of state.karts) {
|
||||
// Fresh racing line each lap keeps the AI field from single-filing.
|
||||
// (Also applies once the player's kart is finished and autopiloted, so
|
||||
// it doesn't robotically hug the centerline during the victory lap.)
|
||||
if ((!kart.isPlayer || kart.finished) && kart.lap !== kart.aiLaneLap) {
|
||||
if (!kart.isPlayer && kart.lap !== kart.aiLaneLap) {
|
||||
kart.aiLaneLap = kart.lap;
|
||||
kart.aiLane = (state.rng() * 2 - 1) * 0.5;
|
||||
}
|
||||
// A finished player kart is handed off to the AI driver for the post-race
|
||||
// vignette — computeAiInputs already works for any kart unmodified.
|
||||
const inputs = (kart.isPlayer && !kart.finished) ? playerInputs : computeAiInputs(state, kart);
|
||||
const inputs = kart.isPlayer ? playerInputs : computeAiInputs(state, kart);
|
||||
stepKart(state, kart, inputs, dt);
|
||||
}
|
||||
stepCollisions(state);
|
||||
|
|
@ -845,11 +827,9 @@ export function step(state, playerInputs = kartInputsNeutral()) {
|
|||
} else if (state.karts.every((k) => k.finished)) {
|
||||
state.phase = 'finished';
|
||||
} else if (player?.finished) {
|
||||
// Player done: wait for the rest of the field to finish too (the
|
||||
// victory-lap vignette plays during this window), with a generous
|
||||
// safety cap in case an AI kart never gets home.
|
||||
// Player done: let the field coast briefly, then call it.
|
||||
state.postFinishMs = (state.postFinishMs ?? 0) + STEP_MS;
|
||||
if (state.postFinishMs > POST_FINISH_SAFETY_MS) state.phase = 'finished';
|
||||
if (state.postFinishMs > 3500) state.phase = 'finished';
|
||||
}
|
||||
}
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -84,9 +84,7 @@ export class SuperKartMode7 {
|
|||
this.horizonY = Math.round(GAME_HEIGHT * MODE7.horizonFrac);
|
||||
const groundH = GAME_HEIGHT - this.horizonY;
|
||||
|
||||
this.cam = {
|
||||
x: worldSize / 2, y: worldSize / 2, angle: 0, height: MODE7.camHeight, focal: MODE7.focal,
|
||||
};
|
||||
this.cam = { x: worldSize / 2, y: worldSize / 2, angle: 0 };
|
||||
|
||||
const base = new Phaser.Display.BaseShader('SuperKartMode7', FRAG, undefined, {
|
||||
uQuad: { type: '2f', value: { x: GAME_WIDTH, y: groundH } },
|
||||
|
|
@ -105,9 +103,6 @@ export class SuperKartMode7 {
|
|||
}
|
||||
|
||||
// Follow camera: smoothly chase the kart's heading, sit followDist behind.
|
||||
// Always resets height/focal to the standard values, so normal gameplay
|
||||
// stays identical regardless of what a prior cinematic camera pose left
|
||||
// this.cam in (see setCamera()).
|
||||
follow(kart, snap = false) {
|
||||
const lerp = snap ? 1 : MODE7.camLerp;
|
||||
let diff = (kart.heading - this.cam.angle) % TAU;
|
||||
|
|
@ -118,19 +113,14 @@ export class SuperKartMode7 {
|
|||
const fy = Math.sin(this.cam.angle);
|
||||
this.cam.x = kart.x - fx * MODE7.followDist;
|
||||
this.cam.y = kart.y - fy * MODE7.followDist;
|
||||
this.cam.height = MODE7.camHeight;
|
||||
this.cam.focal = MODE7.focal;
|
||||
this.syncUniforms();
|
||||
}
|
||||
|
||||
// Arbitrary camera pose — used by the post-race victory-cam director for
|
||||
// cinematic shots (also the free-fly debug camera hook).
|
||||
setCamera(x, y, angle, height = MODE7.camHeight, focal = MODE7.focal) {
|
||||
// Free-fly debug camera (arrow keys in the scene's debug mode).
|
||||
setCamera(x, y, angle) {
|
||||
this.cam.x = x;
|
||||
this.cam.y = y;
|
||||
this.cam.angle = angle;
|
||||
this.cam.height = height;
|
||||
this.cam.focal = focal;
|
||||
this.syncUniforms();
|
||||
}
|
||||
|
||||
|
|
@ -139,8 +129,6 @@ export class SuperKartMode7 {
|
|||
u.uCam.value.x = this.cam.x;
|
||||
u.uCam.value.y = this.cam.y;
|
||||
u.uAngle.value = this.cam.angle;
|
||||
u.uCamH.value = this.cam.height;
|
||||
u.uFocal.value = this.cam.focal;
|
||||
}
|
||||
|
||||
setFlipY(flip) {
|
||||
|
|
@ -160,12 +148,12 @@ export class SuperKartMode7 {
|
|||
const zc = dx * fx + dy * fy;
|
||||
if (zc < MODE7.nearZ || zc > MODE7.farZ) return null;
|
||||
const xc = dx * rx + dy * ry;
|
||||
const px = GAME_WIDTH / 2 + (xc * this.cam.focal) / zc;
|
||||
const px = GAME_WIDTH / 2 + (xc * MODE7.focal) / zc;
|
||||
if (px < -160 || px > GAME_WIDTH + 160) return null;
|
||||
return {
|
||||
x: px,
|
||||
y: this.horizonY + (this.cam.height * this.cam.focal) / zc,
|
||||
scale: (MODE7.spriteBase / 64) * (this.cam.focal / zc),
|
||||
y: this.horizonY + (MODE7.camHeight * MODE7.focal) / zc,
|
||||
scale: (MODE7.spriteBase / 64) * (MODE7.focal / zc),
|
||||
z: zc,
|
||||
fog: Phaser.Math.Clamp((zc - MODE7.fogStart) / (MODE7.fogEnd - MODE7.fogStart), 0, 1),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
// Post-race victory-cam shot director: pure camera-choreography math, no
|
||||
// Phaser dependencies. Companion to SuperKartMode7.js (the camera
|
||||
// *mechanism* — an arbitrary x/y/angle/height/focal pose) — this module is
|
||||
// the camera *choreography*: given elapsed time and the AI-autopiloted
|
||||
// player kart's live world pose, it picks which of a handful of cinematic
|
||||
// shots is playing and returns the pose to feed straight into
|
||||
// SuperKartMode7#setCamera(x, y, angle, height, focal).
|
||||
//
|
||||
// Shots hard-cut (no cross-fade/blend) every couple of seconds, in a
|
||||
// shuffled order that's reshuffled each time it's exhausted, always opening
|
||||
// on the gentlest shot ('chase'). Distances/heights/focal below are
|
||||
// deliberately hand-tuned starting points — expect to retune by eye.
|
||||
|
||||
import { mulberry32 } from './SuperKartLogic.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
function shuffle(arr, rng) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function aimAt(camX, camY, kart) {
|
||||
return Math.atan2(kart.y - camY, kart.x - camX);
|
||||
}
|
||||
|
||||
const SHOTS = {
|
||||
// Pulled-back, elevated version of the normal chase cam — a graceful
|
||||
// establishing shot of the kart cruising its victory lap.
|
||||
chase: {
|
||||
ms: 2400,
|
||||
begin: () => ({}),
|
||||
compute: (t, ms, kart) => {
|
||||
const dist = 92;
|
||||
const fx = Math.cos(kart.heading);
|
||||
const fy = Math.sin(kart.heading);
|
||||
return {
|
||||
x: kart.x - fx * dist, y: kart.y - fy * dist, angle: kart.heading, height: 88, focal: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Camera orbits the kart's live position at a fixed radius, sweeping
|
||||
// ~200° over the shot, always aimed back at the kart.
|
||||
orbit: {
|
||||
ms: 2600,
|
||||
begin: (kart, rng) => ({ startAngle: rng() * TAU, dir: rng() < 0.5 ? 1 : -1 }),
|
||||
compute: (t, ms, kart, anchor) => {
|
||||
const radius = 72;
|
||||
const sweep = Math.PI * 1.15 * anchor.dir;
|
||||
const orbitAngle = anchor.startAngle + (t / ms) * sweep;
|
||||
const x = kart.x + Math.cos(orbitAngle) * radius;
|
||||
const y = kart.y + Math.sin(orbitAngle) * radius;
|
||||
return {
|
||||
x, y, angle: aimAt(x, y, kart), height: 22, focal: 300,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Low, wide-lens shot from ahead of the kart on its current heading,
|
||||
// looking back — the kart looms toward camera as it approaches. Recomputed
|
||||
// fresh every frame from the kart's live heading so it can't freeze into a
|
||||
// near-plane pop as the kart turns.
|
||||
front: {
|
||||
ms: 2000,
|
||||
begin: () => ({}),
|
||||
compute: (t, ms, kart) => {
|
||||
const aheadDist = 260;
|
||||
const x = kart.x + Math.cos(kart.heading) * aheadDist;
|
||||
const y = kart.y + Math.sin(kart.heading) * aheadDist;
|
||||
return {
|
||||
x, y, angle: aimAt(x, y, kart), height: 14, focal: 250,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Classic fixed trackside camera: a point off to the side of the track,
|
||||
// snapshotted once when the shot begins (pulled slightly behind the kart
|
||||
// so it visibly approaches, passes, and recedes), continuously re-aimed at
|
||||
// the kart as it drives by.
|
||||
trackside: {
|
||||
ms: 2300,
|
||||
begin: (kart, rng) => {
|
||||
const side = rng() < 0.5 ? 1 : -1;
|
||||
const perp = kart.heading + Math.PI / 2;
|
||||
const sideOffset = 150 * side;
|
||||
const behindPull = 90;
|
||||
return {
|
||||
x: kart.x + Math.cos(perp) * sideOffset - Math.cos(kart.heading) * behindPull,
|
||||
y: kart.y + Math.sin(perp) * sideOffset - Math.sin(kart.heading) * behindPull,
|
||||
};
|
||||
},
|
||||
compute: (t, ms, kart, anchor) => ({
|
||||
x: anchor.x, y: anchor.y, angle: aimAt(anchor.x, anchor.y, kart), height: 30, focal: undefined,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const SHOT_POOL = ['chase', 'orbit', 'front', 'trackside'];
|
||||
|
||||
export class VictoryCamDirector {
|
||||
constructor(seed = Date.now()) {
|
||||
this.rng = mulberry32(seed >>> 0);
|
||||
this.queue = ['chase']; // always open on the gentlest shot
|
||||
this.curIdx = 0;
|
||||
this.curStartMs = 0;
|
||||
this.anchor = null;
|
||||
}
|
||||
|
||||
start(kart) {
|
||||
this.curIdx = 0;
|
||||
this.curStartMs = 0;
|
||||
this.anchor = SHOTS[this.queue[0]].begin(kart, this.rng);
|
||||
this.justCut = false;
|
||||
}
|
||||
|
||||
// elapsedMs: time since start(). kart: live player kart sim state
|
||||
// ({x, y, heading, ...}). Returns { x, y, angle, height, focal }.
|
||||
// Sets this.justCut = true for the one update() call a hard cut lands on,
|
||||
// so the scene can fire a stinger effect (screen flash/shake) on the cut.
|
||||
update(elapsedMs, kart) {
|
||||
this.justCut = false;
|
||||
let def = SHOTS[this.queue[this.curIdx]];
|
||||
let localT = elapsedMs - this.curStartMs;
|
||||
while (localT >= def.ms) {
|
||||
if (this.queue.length - (this.curIdx + 1) <= 0) this.queue.push(...shuffle(SHOT_POOL, this.rng));
|
||||
this.curIdx += 1;
|
||||
this.curStartMs += def.ms;
|
||||
localT = elapsedMs - this.curStartMs;
|
||||
def = SHOTS[this.queue[this.curIdx]];
|
||||
this.anchor = def.begin(kart, this.rng);
|
||||
this.justCut = true;
|
||||
}
|
||||
return def.compute(localT, def.ms, kart, this.anchor);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { getGame } from '../data/gamesRegistry.js';
|
||||
import { stopMenuMusic, configureMainMenuTrack } from '../ui/MenuMusic.js';
|
||||
import { stopMenuMusic } from '../ui/MenuMusic.js';
|
||||
|
||||
export default class PreloadScene extends Phaser.Scene {
|
||||
constructor() { super('Preload'); }
|
||||
|
|
@ -43,7 +43,6 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('colored-playfields', 'data/colored-playfields.json');
|
||||
this.load.json('card-backs', 'data/card-backs.json');
|
||||
this.load.json('music', 'data/music.json');
|
||||
this.load.json('nintendo-music', 'data/nintendo-music.json');
|
||||
this.load.json('rushhour', 'data/rushhour.json');
|
||||
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
|
||||
this.load.json('shift-artwork', 'data/shift-artwork.json');
|
||||
|
|
@ -68,20 +67,6 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
|
||||
this.load.json('star-control-ships', 'data/star-control-ships.json');
|
||||
|
||||
this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3');
|
||||
this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3');
|
||||
this.load.audio('sfx-engine-fast', 'assets/fx/engine-fast.mp3');
|
||||
this.load.audio('sfx-engine-medium', 'assets/fx/engine-medium.mp3');
|
||||
this.load.audio('sfx-engine-rev', 'assets/fx/engine-rev.mp3');
|
||||
this.load.audio('sfx-kart-coin', 'assets/fx/kart-coin.mp3');
|
||||
this.load.audio('sfx-kart-flatten', 'assets/fx/kart-flatten.mp3');
|
||||
this.load.audio('sfx-kart-thump', 'assets/fx/kart-thump.mp3');
|
||||
this.load.audio('sfx-kart-shrink', 'assets/fx/kart-shrink.mp3');
|
||||
this.load.audio('sfx-kart-spin', 'assets/fx/kart-spin.mp3');
|
||||
this.load.audio('sfx-kart-hit', 'assets/fx/kart-hit.mp3');
|
||||
this.load.audio('sfx-kart-shell', 'assets/fx/kart-shell.mp3');
|
||||
this.load.audio('sfx-kart-star', 'assets/fx/kart-star.mp3');
|
||||
this.load.audio('sfx-kart-spinner', 'assets/fx/kart-spinner.mp3');
|
||||
this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3');
|
||||
this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3');
|
||||
this.load.audio('sfx-water-raise', 'assets/fx/water-raise.mp3');
|
||||
|
|
@ -176,10 +161,6 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
// thumbnails in OpponentSelect, so they load at startup. Per-game drop-in
|
||||
// artwork (shift/slots/spireclimb/dungeonboss/swdbg/balatro) is handled by
|
||||
// data/assetManifest.js and lazy-loaded on game entry instead.
|
||||
const musicData = this.cache.json.get('music');
|
||||
const menuTrack = musicData?.soundtracks?.main_menu?.[0]?.file;
|
||||
if (menuTrack) configureMainMenuTrack(menuTrack);
|
||||
|
||||
const pfd = this.cache.json.get('playfields');
|
||||
const cbd = this.cache.json.get('card-backs');
|
||||
const toLoad = [
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
// Per-game music soundtrack overrides. Empty by default — every game plays
|
||||
// the shared `default` soundtrack (data/music.json's `tracks` array),
|
||||
// preloaded eagerly in PreloadScene exactly as before. To give a specific
|
||||
// game its own soundtrack instead:
|
||||
// 1. Add data/<name>-music.json: { "tracks": [{file, artist, title}, ...],
|
||||
// "volume": 0.15 } — "volume" is optional (0-1, MusicPlayer's own
|
||||
// default applies if omitted) and only affects this named soundtrack;
|
||||
// the default soundtrack's volume is never touched by this mechanism.
|
||||
// Actual mp3 files go at assets/music/<name>/...
|
||||
// 2. Eager-load that JSON in PreloadScene (tiny metadata, mirrors the
|
||||
// existing *-artwork.json convention) under cache key `<name>-music`.
|
||||
// 3. In src/data/assetManifest.js, add `musicFrom(scene, '<name>-music')`
|
||||
// to that game's MANIFEST entry so its audio bytes lazy-load only when
|
||||
// the game is entered — never part of the shared default preload.
|
||||
// 4. Add `<slug>: '<name>'` to GAME_SOUNDTRACK_OVERRIDES below.
|
||||
// 5. In that game's scene, swap its MusicPlayer track-source line for:
|
||||
// const { tracks, volume } = getGameSoundtrack(this);
|
||||
// if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
|
||||
export const GAME_SOUNDTRACK_OVERRIDES = {
|
||||
superkart: 'nintendo',
|
||||
};
|
||||
|
||||
// Resolve the track list (and optional volume override) a game scene's
|
||||
// MusicPlayer should shuffle through. `volume` is undefined for the default
|
||||
// soundtrack, so MusicPlayer's own default kicks in unchanged.
|
||||
export function getGameSoundtrack(scene) {
|
||||
const name = GAME_SOUNDTRACK_OVERRIDES[scene.gameDef?.slug];
|
||||
if (!name) return { tracks: scene.cache.json.get('music')?.tracks ?? [], volume: undefined };
|
||||
const data = scene.cache.json.get(`${name}-music`);
|
||||
return { tracks: data?.tracks ?? [], volume: data?.volume };
|
||||
}
|
||||
|
|
@ -1,16 +1,8 @@
|
|||
let _audio = null;
|
||||
let _trackFile = 'mainMenu.mp3';
|
||||
|
||||
// Sourced from data/music.json's `soundtracks.main_menu` entry (see
|
||||
// PreloadScene) so the menu theme is a real, named soundtrack rather than a
|
||||
// hardcoded path. Falls back to the historical default if never configured.
|
||||
export function configureMainMenuTrack(file) {
|
||||
_trackFile = file;
|
||||
}
|
||||
|
||||
function _getAudio() {
|
||||
if (!_audio) {
|
||||
_audio = new Audio(`assets/music/${_trackFile}`);
|
||||
_audio = new Audio('assets/music/mainMenu.mp3');
|
||||
_audio.loop = true;
|
||||
_audio.volume = 0.6;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,11 @@ function shuffle(arr) {
|
|||
}
|
||||
|
||||
export class MusicPlayer {
|
||||
constructor(scene, tracks, volume = 0.15) {
|
||||
constructor(scene, tracks) {
|
||||
this.scene = scene;
|
||||
this.tracks = shuffle(tracks);
|
||||
this.idx = 0;
|
||||
this.muted = false;
|
||||
this.volume = volume;
|
||||
this._audio = null;
|
||||
this._objs = [];
|
||||
|
||||
|
|
@ -99,7 +98,7 @@ export class MusicPlayer {
|
|||
const track = this.tracks[idx];
|
||||
this._audio = new Audio(`assets/music/${track.file}`);
|
||||
this._audio.muted = this.muted;
|
||||
this._audio.volume = this.volume;
|
||||
this._audio.volume = 0.15;
|
||||
this._audio.play().catch(() => { });
|
||||
this._audio.onended = () => this.next();
|
||||
this._updateInfo();
|
||||
|
|
|
|||
|
|
@ -76,20 +76,6 @@ export const SFX = {
|
|||
ENERGY_HUM: 'sfx-energy-hum',
|
||||
LASER_ZAP: 'sfx-laser-zap',
|
||||
REWIND: 'sfx-rewind',
|
||||
ENGINE_START: 'sfx-engine-start',
|
||||
ENGINE_HEAVY: 'sfx-engine-heavy',
|
||||
ENGINE_FAST: 'sfx-engine-fast',
|
||||
ENGINE_MEDIUM: 'sfx-engine-medium',
|
||||
ENGINE_REV: 'sfx-engine-rev',
|
||||
KART_COIN: 'sfx-kart-coin',
|
||||
KART_FLATTEN: 'sfx-kart-flatten',
|
||||
KART_THUMP: 'sfx-kart-thump',
|
||||
KART_SHRINK: 'sfx-kart-shrink',
|
||||
KART_SPIN: 'sfx-kart-spin',
|
||||
KART_HIT: 'sfx-kart-hit',
|
||||
KART_SHELL: 'sfx-kart-shell',
|
||||
KART_STAR: 'sfx-kart-star',
|
||||
KART_SPINNER: 'sfx-kart-spinner',
|
||||
};
|
||||
|
||||
export function playSound(scene, key) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function _playNext() {
|
|||
const entry = _queue.shift();
|
||||
_currentCbs = entry;
|
||||
_currentAudio = new Audio(`assets/speech/${entry.filename}.mp3`);
|
||||
_currentAudio.volume = 1;
|
||||
_currentAudio.volume = 0.8;
|
||||
const done = () => {
|
||||
entry.onEnd?.();
|
||||
_playing = false;
|
||||
|
|
|
|||
|
|
@ -270,39 +270,6 @@ for (const [ci, engineClass] of rules.engineClasses.entries()) {
|
|||
check('deterministic for identical seed', run() === run());
|
||||
}
|
||||
|
||||
// ── 7. Player autopilot after finishing (post-race victory-lap window) ─────
|
||||
// Nothing above exercises playerIndex >= 0 at all — this is the only check
|
||||
// that drives the new "AI takes over the player's kart once it's finished"
|
||||
// dispatch path and the "wait for the whole field" phase-transition change.
|
||||
{
|
||||
const state = createRace({
|
||||
trackModel: model1, rules, engineClass: cls, racers, playerIndex: 0, mode: 'gp', seed: 777,
|
||||
});
|
||||
while (state.phase === 'countdown') step(state, kartInputsNeutral());
|
||||
|
||||
const player = state.karts[0];
|
||||
// Simulate the player crossing the line on the spot, mirroring what the
|
||||
// checkpoint/lap code does at the real finish transition.
|
||||
player.finished = true;
|
||||
player.finishTimeMs = state.timeMs;
|
||||
state.finishOrder.push(player.index);
|
||||
const x0 = player.x;
|
||||
const y0 = player.y;
|
||||
|
||||
let ticks = 0;
|
||||
const cap = 60 * 90; // matches POST_FINISH_SAFETY_MS as an outer bound
|
||||
while (ticks < cap && state.phase === 'racing') {
|
||||
step(state, kartInputsNeutral());
|
||||
ticks += 1;
|
||||
}
|
||||
const moved = Math.hypot(player.x - x0, player.y - y0);
|
||||
check('autopiloted player kart keeps driving after finishing', moved > 50, `moved ${moved.toFixed(1)} units`);
|
||||
check('field is allowed to finish rather than being cut off early',
|
||||
state.karts.every((k) => k.finished),
|
||||
`${state.karts.filter((k) => k.finished).length}/9 finished after ${Math.round(ticks / 60)}s, phase=${state.phase}`);
|
||||
check('race phase ends up finished', state.phase === 'finished', `phase=${state.phase}`);
|
||||
}
|
||||
|
||||
// Countdown export used by the scene HUD timer.
|
||||
check('COUNTDOWN_MS sane', COUNTDOWN_MS > 2000 && COUNTDOWN_MS < 6000);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue