feat(superkart): add post-race victory cam, engine audio, and polished results screens

- VictoryCamDirector: cinematic post-race camera system with shuffled
  shot types (chase, orbit, front, trackside) and hard-cut transitions;
  player can press ENTER to skip.
- Engine audio: per-racer engine hum (heavy/fast/medium) with pitch
  tracking based on kart speed; engine-start sound at green light.
- Star power loop sound that stops when star expires.
- Positional audio: AI kart sounds (spin, hit, flatten) scale volume
  with distance from player.
- Enhanced event sounds: item-use, boost, EMP, squash, bump, coin,
  star now play appropriate SFX.
- Player kart autopiloted after finishing for victory-lap vignette;
  race phase waits for entire field (up to 90s safety cap).
- EMP now emits target list for per-target shrink sounds; squash
  collision emits event.
- Animated race results screen with staggered row reveals.
- Animated cup standings with running point counter and position
  arrows; tweens rows to new ranks.
- Podium reveal: 3rd → 2nd → 1st staggered with confetti, count-up,
  and CRT pulse; winner speech plays.
- Mode7 camera now tracks height and focal length; setCamera accepts
  arbitrary poses for cinematic shots.
- New audio assets: engine-start/heavy/fast/medium/rev, kart-coin,
  kart-flatten, kart-thump, kart-shrink, kart-spin, kart-hit,
  kart-shell, kart-star.
This commit is contained in:
Brian Fertig 2026-07-18 15:24:49 -06:00
parent 54a9614464
commit e4a4531401
20 changed files with 710 additions and 71 deletions

BIN
assets/fx/engine-fast.mp3 Normal file

Binary file not shown.

BIN
assets/fx/engine-heavy.mp3 Normal file

Binary file not shown.

BIN
assets/fx/engine-medium.mp3 Normal file

Binary file not shown.

BIN
assets/fx/engine-rev.mp3 Normal file

Binary file not shown.

BIN
assets/fx/engine-start.mp3 Normal file

Binary file not shown.

BIN
assets/fx/kart-coin.mp3 Normal file

Binary file not shown.

BIN
assets/fx/kart-flatten.mp3 Normal file

Binary file not shown.

BIN
assets/fx/kart-hit.mp3 Normal file

Binary file not shown.

BIN
assets/fx/kart-shell.mp3 Normal file

Binary file not shown.

BIN
assets/fx/kart-shrink.mp3 Normal file

Binary file not shown.

BIN
assets/fx/kart-spin.mp3 Normal file

Binary file not shown.

BIN
assets/fx/kart-star.mp3 Normal file

Binary file not shown.

BIN
assets/fx/kart-thump.mp3 Normal file

Binary file not shown.

View File

@ -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 });
});
}
}

View File

@ -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;

View File

@ -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),
};

View File

@ -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);
}
}

View File

@ -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');

View File

@ -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) {

View File

@ -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);