diff --git a/assets/fx/engine-fast.mp3 b/assets/fx/engine-fast.mp3 new file mode 100644 index 0000000..f4239bb Binary files /dev/null and b/assets/fx/engine-fast.mp3 differ diff --git a/assets/fx/engine-heavy.mp3 b/assets/fx/engine-heavy.mp3 new file mode 100644 index 0000000..b7eff3f Binary files /dev/null and b/assets/fx/engine-heavy.mp3 differ diff --git a/assets/fx/engine-medium.mp3 b/assets/fx/engine-medium.mp3 new file mode 100644 index 0000000..2465195 Binary files /dev/null and b/assets/fx/engine-medium.mp3 differ diff --git a/assets/fx/engine-rev.mp3 b/assets/fx/engine-rev.mp3 new file mode 100644 index 0000000..744c8ac Binary files /dev/null and b/assets/fx/engine-rev.mp3 differ diff --git a/assets/fx/engine-start.mp3 b/assets/fx/engine-start.mp3 new file mode 100644 index 0000000..d353fb3 Binary files /dev/null and b/assets/fx/engine-start.mp3 differ diff --git a/assets/fx/kart-coin.mp3 b/assets/fx/kart-coin.mp3 new file mode 100644 index 0000000..4b5e7bd Binary files /dev/null and b/assets/fx/kart-coin.mp3 differ diff --git a/assets/fx/kart-flatten.mp3 b/assets/fx/kart-flatten.mp3 new file mode 100644 index 0000000..dc65e09 Binary files /dev/null and b/assets/fx/kart-flatten.mp3 differ diff --git a/assets/fx/kart-hit.mp3 b/assets/fx/kart-hit.mp3 new file mode 100644 index 0000000..cc588cb Binary files /dev/null and b/assets/fx/kart-hit.mp3 differ diff --git a/assets/fx/kart-shell.mp3 b/assets/fx/kart-shell.mp3 new file mode 100644 index 0000000..fa6770e Binary files /dev/null and b/assets/fx/kart-shell.mp3 differ diff --git a/assets/fx/kart-shrink.mp3 b/assets/fx/kart-shrink.mp3 new file mode 100644 index 0000000..9f1b1ce Binary files /dev/null and b/assets/fx/kart-shrink.mp3 differ diff --git a/assets/fx/kart-spin.mp3 b/assets/fx/kart-spin.mp3 new file mode 100644 index 0000000..0c6e0e1 Binary files /dev/null and b/assets/fx/kart-spin.mp3 differ diff --git a/assets/fx/kart-star.mp3 b/assets/fx/kart-star.mp3 new file mode 100644 index 0000000..38015df Binary files /dev/null and b/assets/fx/kart-star.mp3 differ diff --git a/assets/fx/kart-thump.mp3 b/assets/fx/kart-thump.mp3 new file mode 100644 index 0000000..bf2f752 Binary files /dev/null and b/assets/fx/kart-thump.mp3 differ diff --git a/src/games/superkart/SuperKartGame.js b/src/games/superkart/SuperKartGame.js index 5bea6b9..e744663 100644 --- a/src/games/superkart/SuperKartGame.js +++ b/src/games/superkart/SuperKartGame.js @@ -28,6 +28,7 @@ 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'; @@ -35,7 +36,7 @@ const GAMEDATA = 'assets/gamedata/superkart'; const D = { backdrop: -2, ground: -1, world: 10, playerKart: 900, hud: 1000, - banner: 1100, view: 1200, debug: 1500, + banner: 1100, overlay: 1150, 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; @@ -46,6 +47,18 @@ 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'); } @@ -62,6 +75,14 @@ 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; } create() { @@ -211,6 +232,9 @@ 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(); + }); } readInputs() { @@ -245,6 +269,14 @@ 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', { @@ -467,6 +499,9 @@ 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]; } @@ -486,6 +521,11 @@ 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() { @@ -618,6 +658,12 @@ 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(); @@ -639,7 +685,13 @@ export default class SuperKartGame extends Phaser.Scene { renderRace() { const state = this.raceState; const player = this.playerKartState(); - this.mode7.follow(player); + 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.backdrop.update(this.mode7.cam.angle); // Static + kart world sprites. @@ -703,13 +755,145 @@ export default class SuperKartGame extends Phaser.Scene { } for (let i = di; i < this.dynamicPool.length; i += 1) this.dynamicPool[i].setVisible(false); - this.renderPlayerKart(player); - this.renderHud(state, player); + if (this.victoryCamActive) this.renderPlayerKartCinematic(player); + else this.renderPlayerKart(player); + if (!this.raceResultsShown) 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(); @@ -818,6 +1002,22 @@ 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; @@ -826,19 +1026,44 @@ export default class SuperKartGame extends Phaser.Scene { case 'go': playSound(this, SFX.COUNTDOWN_GO); 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); 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 '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 'wallhit': if (ev.kart === pIdx) playSound(this, SFX.EIGHTBIT_MOVE); 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 '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 '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); + if (ev.kart === pIdx) { + playSound(this, ev.place <= 3 ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE); + this.stopEngineSound(); + if (state.mode !== 'tt') this.startVictoryCam(); + } break; case 'player-position': if (ev.to > ev.from) this.tauntFromLeader(ev.to); @@ -893,10 +1118,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 }))); @@ -904,6 +1129,8 @@ 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); @@ -917,76 +1144,257 @@ export default class SuperKartGame extends Phaser.Scene { return; } - // 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; + // 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(); this.text(cx, 110, `${this.raceEntry.name} — RESULTS`, 48, '#ffd028'); const header = (x, s) => this.text(x, 180, s, 22, COLORS.mutedHex); - header(cx - 420, 'POS'); - header(cx - 240, 'RACER'); - header(cx + 90, 'TIME'); - header(cx + 260, 'PTS'); - header(cx + 400, 'TOTAL'); + header(cx - 380, 'POS'); + header(cx - 200, 'RACER'); + header(cx + 140, 'TIME'); + header(cx + 340, 'PTS'); + results.forEach((r, i) => { const y = 226 + i * 52; - const racer = this.racers.find((rc) => rc.id === r.racerId); - const isP = r.racerId === this.playerRacer().id; + const isP = r.racerId === playerId; const color = isP ? '#ffd028' : COLORS.textHex; - this.text(cx - 420, y, String(r.position), 28, color); + 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); if (this.textures.exists('opponents')) { - this.vAdd(this.add.image(cx - 330, y, 'opponents', this.opponentById[r.racerId]?.spriteIndex ?? 0) - .setDisplaySize(44, 44).setDepth(D.view)); + row.add(this.add.image(cx - 290, 0, 'opponents', this.opponentById[r.racerId]?.spriteIndex ?? 0) + .setDisplaySize(44, 44)); } - 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; + 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.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 })); + + 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', () => { + 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 }); + } + + // 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(), + }); + } } 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). + // Podium top 3 with live portraits (video/mood if assets exist). Revealed + // 3rd → 2nd → 1st, staggered, for a bit of suspense before the winner. const podX = [cx, cx - 330, cx + 330]; const podY = [340, 400, 430]; - standings.slice(0, 3).forEach(([id], i) => { + const tintFor = [0xffd028, 0xc8ccd4, 0xcd8a4a]; // gold / silver / bronze + + const revealSlot = (i) => { + const [id, pts] = standings[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 }); } - 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); } + 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); } + } + }; - 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); - }); + [2, 1, 0].forEach((podiumIdx, k) => this.time.delayedCall(k * 900, () => revealSlot(podiumIdx))); - 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' })); + 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 }); + }); } } diff --git a/src/games/superkart/SuperKartLogic.js b/src/games/superkart/SuperKartLogic.js index f8e3a68..c96b0c9 100644 --- a/src/games/superkart/SuperKartLogic.js +++ b/src/games/superkart/SuperKartLogic.js @@ -14,6 +14,10 @@ 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; @@ -248,7 +252,10 @@ function aiSkillOf(state, kart) { function computeAiInputs(state, kart) { const inputs = kartInputsNeutral(); - if (kart.finished && state.phase === 'finished') return inputs; + // 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). const { model, physics } = state; const skill = aiSkillOf(state, kart); @@ -653,13 +660,15 @@ 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 }); + emit(state, 'emp', { kart: kart.index, targets }); break; } case 'coins': @@ -768,8 +777,13 @@ 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; - else if (b.empMs > 0 && a.empMs <= 0 && a.squashMs <= 0 && b.squashMs <= 0) b.squashMs = physics.squashMs; + 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 }); + } emit(state, 'bump', { a: a.index, b: b.index }); } } @@ -809,11 +823,15 @@ export function step(state, playerInputs = kartInputsNeutral()) { for (const kart of state.karts) { // Fresh racing line each lap keeps the AI field from single-filing. - if (!kart.isPlayer && kart.lap !== kart.aiLaneLap) { + // (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) { kart.aiLaneLap = kart.lap; kart.aiLane = (state.rng() * 2 - 1) * 0.5; } - const inputs = kart.isPlayer ? playerInputs : computeAiInputs(state, kart); + // 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); stepKart(state, kart, inputs, dt); } stepCollisions(state); @@ -827,9 +845,11 @@ export function step(state, playerInputs = kartInputsNeutral()) { } else if (state.karts.every((k) => k.finished)) { state.phase = 'finished'; } else if (player?.finished) { - // Player done: let the field coast briefly, then call it. + // 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. state.postFinishMs = (state.postFinishMs ?? 0) + STEP_MS; - if (state.postFinishMs > 3500) state.phase = 'finished'; + if (state.postFinishMs > POST_FINISH_SAFETY_MS) state.phase = 'finished'; } } return state; diff --git a/src/games/superkart/SuperKartMode7.js b/src/games/superkart/SuperKartMode7.js index 7ed5923..d8a70a5 100644 --- a/src/games/superkart/SuperKartMode7.js +++ b/src/games/superkart/SuperKartMode7.js @@ -84,7 +84,9 @@ 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 }; + this.cam = { + x: worldSize / 2, y: worldSize / 2, angle: 0, height: MODE7.camHeight, focal: MODE7.focal, + }; const base = new Phaser.Display.BaseShader('SuperKartMode7', FRAG, undefined, { uQuad: { type: '2f', value: { x: GAME_WIDTH, y: groundH } }, @@ -103,6 +105,9 @@ 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; @@ -113,14 +118,19 @@ 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(); } - // Free-fly debug camera (arrow keys in the scene's debug mode). - setCamera(x, y, angle) { + // 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) { this.cam.x = x; this.cam.y = y; this.cam.angle = angle; + this.cam.height = height; + this.cam.focal = focal; this.syncUniforms(); } @@ -129,6 +139,8 @@ 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) { @@ -148,12 +160,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 * MODE7.focal) / zc; + const px = GAME_WIDTH / 2 + (xc * this.cam.focal) / zc; if (px < -160 || px > GAME_WIDTH + 160) return null; return { x: px, - y: this.horizonY + (MODE7.camHeight * MODE7.focal) / zc, - scale: (MODE7.spriteBase / 64) * (MODE7.focal / zc), + y: this.horizonY + (this.cam.height * this.cam.focal) / zc, + scale: (MODE7.spriteBase / 64) * (this.cam.focal / zc), z: zc, fog: Phaser.Math.Clamp((zc - MODE7.fogStart) / (MODE7.fogEnd - MODE7.fogStart), 0, 1), }; diff --git a/src/games/superkart/SuperKartVictoryCam.js b/src/games/superkart/SuperKartVictoryCam.js new file mode 100644 index 0000000..80686ad --- /dev/null +++ b/src/games/superkart/SuperKartVictoryCam.js @@ -0,0 +1,140 @@ +// 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); + } +} diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 55f232c..5d8adbf 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -67,6 +67,19 @@ 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-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'); diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index 99eefba..d24da56 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -76,6 +76,19 @@ 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', }; export function playSound(scene, key) { diff --git a/tools/verifySuperKart.js b/tools/verifySuperKart.js index 9c8ae0c..4d08a7f 100644 --- a/tools/verifySuperKart.js +++ b/tools/verifySuperKart.js @@ -270,6 +270,39 @@ 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);