fertig-classic-games/src/games/superkart/SuperKartGame.js

1428 lines
58 KiB
JavaScript

// Super Kart — SNES-style Mode 7 kart racing starring the site's opponent
// roster. Views: menu → engine class → racer select → cup/track select →
// race → results → podium, all in one scene (view-state pattern).
//
// Data: data/superkart-racers.json (roster+stats), data/superkart-rules.json
// (classes/physics/items/themes), data/superkart-artwork.json (drop-in art),
// assets/gamedata/superkart/cups.json + track-NNN.json (content).
// Headless sim in SuperKartLogic.js; track math in SuperKartTrack.js.
//
// Debug keys during a race: O = overhead debug overlay, F = flip the ground
// shader's Y orientation, C = swap the track texture for a checkerboard.
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';
import { createOpponentPortrait } from '../../ui/Portrait.js';
import { buildTrackModel } from './SuperKartTrack.js';
import {
createRace, step, finalizeRace, kartInputsNeutral, STEP_MS, COUNTDOWN_MS,
topSpeedOf, accelOf, turnRateOf, pointsFor,
} from './SuperKartLogic.js';
import {
rasterizeTrack, drawMinimapCanvas, buildKartSheetCanvas, buildItemSheetCanvas,
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';
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,
};
const ITEM_FRAME = { bolt: 0, seeker: 1, oil: 2, turbo: 3, overdrive: 4, emp: 5, coins: 6 };
const BOX_FRAME = 7;
// The player kart is a screen-fixed sprite rather than a projected world
// sprite, so its scale is pinned to what MODE7.project() would give an AI
// kart sitting at the camera's follow distance — keeps both the same size
// when a rival is drafting right alongside the player.
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'); }
init(data) {
this.gameDef = data.game ?? { slug: 'superkart', name: 'Super Kart' };
this.testTrack = data.testTrack ?? null; // editor test-play round trip
this.returnToEditor = !!data.returnToEditor;
this.trackCache = new Map(); // file -> track json
this.raceState = null;
this.mode7 = null;
this.gp = null;
this.chosen = { classIdx: 1, racerIdx: 0, mode: 'gp' };
this.viewObjs = [];
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() {
this.rules = this.cache.json.get('superkart-rules');
this.racers = this.cache.json.get('superkart-racers')?.racers ?? [];
this.artwork = this.cache.json.get('superkart-artwork') ?? {};
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);
} catch (_) { /* optional */ }
this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xffd028, curveAmount: 0.35 });
this.events.once('shutdown', () => {
this.crt.destroy();
resetQueue();
this.teardownRace();
});
// Menu-screen art: covers the whole canvas but is itself covered by the
// Mode 7 ground/backdrop (depths -1/-2) during a race, so it only shows
// through on the menu / select / results / podium views.
if (this.textures.exists('superkart-background')) {
this.bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'superkart-background')
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-10);
} else {
this.bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018)
.setDepth(-10);
}
this.bgShade = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0a0a12, 0.45)
.setDepth(-9);
if (this.renderer.type !== Phaser.WEBGL) {
this.showWebGLNotice();
return;
}
// Roster details (portraits/speech) come from the shared opponents file.
this.opponentById = {};
fetch('data/opponents.json')
.then((r) => r.json())
.then((d) => {
for (const op of d.opponents ?? []) this.opponentById[op.id] = op;
})
.catch(() => {});
this.buildFallbackTextures();
this.bindKeys();
if (this.testTrack) {
this.chosen = { classIdx: 1, racerIdx: 0, mode: 'test' };
this.startRace({ json: this.testTrack, entry: { id: this.testTrack.id ?? 'editor-track', name: this.testTrack.name ?? 'Editor Track' } });
} else {
this.showMenu();
}
}
// ── Textures ──────────────────────────────────────────────────────────────
ensureCanvasSheet(key, canvas, fw, fh) {
if (this.textures.exists(key)) return;
const tex = this.textures.addCanvas(key, canvas);
const cols = Math.floor(canvas.width / fw);
for (let i = 0; i < cols; i += 1) tex.add(i, 0, i * fw, 0, fw, fh);
}
buildFallbackTextures() {
for (const racer of this.racers) {
const art = this.artwork.racerSheets?.[racer.id];
if (!(art?.key && this.textures.exists(art.key))) {
this.ensureCanvasSheet(`superkart-kart-fb-${racer.id}`, buildKartSheetCanvas(racer.color ?? '#e04838'), 64, 64);
}
}
if (!(this.artwork.itemSheet?.key && this.textures.exists(this.artwork.itemSheet.key))) {
this.ensureCanvasSheet('superkart-items-fb', buildItemSheetCanvas(), 48, 48);
}
for (const [id, theme] of Object.entries(this.rules.themes ?? {})) {
if (id.startsWith('_')) continue;
const bd = this.artwork.backdrops?.[id];
if (!(bd?.key && this.textures.exists(bd.key)) && !this.textures.exists(`superkart-bd-fb-${id}`)) {
this.textures.addCanvas(`superkart-bd-fb-${id}`, buildBackdropCanvas(theme));
}
const ts = this.artwork.themeSheets?.[id];
if (!(ts?.key && this.textures.exists(ts.key))) {
this.ensureCanvasSheet(`superkart-decor-fb-${id}`, buildDecorSheetCanvas(theme), 64, 64);
}
}
// Checkerboard for shader debugging (C key).
if (!this.textures.exists('superkart-checker')) {
const c = document.createElement('canvas');
c.width = c.height = 1024;
const ctx = c.getContext('2d');
for (let y = 0; y < 16; y += 1) {
for (let x = 0; x < 16; x += 1) {
ctx.fillStyle = (x + y) % 2 === 0 ? '#d0d0d0' : '#282838';
ctx.fillRect(x * 64, y * 64, 64, 64);
}
}
ctx.fillStyle = '#d42a20';
ctx.fillRect(0, 0, 1024, 24); // north edge marker
this.textures.addCanvas('superkart-checker', c);
}
}
kartTexKey(racerId) {
const art = this.artwork.racerSheets?.[racerId];
if (art?.key && this.textures.exists(art.key)) return art.key;
return `superkart-kart-fb-${racerId}`;
}
itemTexKey() {
const art = this.artwork.itemSheet;
if (art?.key && this.textures.exists(art.key)) return art.key;
return 'superkart-items-fb';
}
backdropTexKey(themeId) {
const bd = this.artwork.backdrops?.[themeId];
if (bd?.key && this.textures.exists(bd.key)) return bd.key;
return `superkart-bd-fb-${themeId}`;
}
decorTexKey(themeId) {
const ts = this.artwork.themeSheets?.[themeId];
if (ts?.key && this.textures.exists(ts.key)) return ts.key;
return `superkart-decor-fb-${themeId}`;
}
// ── Input ─────────────────────────────────────────────────────────────────
bindKeys() {
const K = Phaser.Input.Keyboard.KeyCodes;
this.keys = this.input.keyboard.addKeys({
up: K.UP, down: K.DOWN, left: K.LEFT, right: K.RIGHT,
w: K.W, a: K.A, s: K.S, d2: K.D,
hop: K.SPACE, item: K.SHIFT, itemX: K.X,
debug: K.O, flip: K.F, checker: K.C, esc: K.ESC,
});
this.input.keyboard.on('keydown-O', () => { this.debugOn = !this.debugOn; this.debugG?.setVisible(this.debugOn); });
this.input.keyboard.on('keydown-F', () => this.mode7?.setFlipY(!(this.mode7.ground.uniforms.uFlipY.value > 0)));
this.input.keyboard.on('keydown-C', () => {
if (!this.mode7) return;
this.checkerOn = !this.checkerOn;
this.mode7.ground.setChannel0(this.checkerOn ? 'superkart-checker' : this.trackTexKey);
});
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() {
const k = this.keys;
const inputs = kartInputsNeutral();
inputs.accel = k.up.isDown || k.w.isDown;
inputs.brake = k.down.isDown || k.s.isDown;
inputs.steer = (k.left.isDown || k.a.isDown ? -1 : 0) + (k.right.isDown || k.d2.isDown ? 1 : 0);
inputs.hop = k.hop.isDown;
inputs.item = k.item.isDown || k.itemX.isDown;
return inputs;
}
// ── View plumbing ─────────────────────────────────────────────────────────
clearView() {
for (const o of this.viewObjs) o?.destroy();
this.viewObjs = [];
this.advanceBtn = null;
}
vAdd(obj) {
this.viewObjs.push(obj);
return obj;
}
text(x, y, str, size, color = COLORS.textHex, origin = 0.5) {
const t = this.add.text(x, y, str, {
fontFamily: FONT, fontSize: `${size}px`, color,
}).setDepth(D.view);
if (typeof origin === 'object') t.setOrigin(origin.x, origin.y);
else t.setOrigin(origin);
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', {
fontFamily: FONT, fontSize: '48px', color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20,
'The Mode 7 track renderer requires a WebGL-capable browser.', {
fontFamily: FONT, fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5);
new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 120, 'Leave', () => this.scene.start('GameMenu'));
}
// ── Menu / class / racer / cup / track views ──────────────────────────────
showMenu() {
this.teardownRace();
this.clearView();
const cx = GAME_WIDTH / 2;
// Title/tagline are baked into superkart-background.png — no text needed here.
this.text(cx, 380,
'ARROWS / WASD DRIVE • SPACE HOP+DRIFT • SHIFT OR X FIRES ITEMS', 22, COLORS.mutedHex);
this.vAdd(new Button(this, cx, 520, 'Grand Prix', () => { this.chosen.mode = 'gp'; this.showClassSelect(); }, { width: 380 }));
this.vAdd(new Button(this, cx, 610, 'Time Trial', () => { this.chosen.mode = 'tt'; this.showClassSelect(); }, { width: 380 }));
this.vAdd(new Button(this, cx, 740, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost' }));
}
showClassSelect() {
this.clearView();
const cx = GAME_WIDTH / 2;
this.text(cx, 180, 'CHOOSE ENGINE CLASS', 56, '#ffd028');
this.rules.engineClasses.forEach((cls, i) => {
const y = 340 + i * 130;
this.vAdd(new Button(this, cx, y, cls.label, () => {
this.chosen.classIdx = i;
playSound(this, SFX.UI_PICK);
this.showRacerSelect();
}, { width: 480, height: 84, fontSize: 34 }));
this.text(cx, y + 56, cls.desc, 20, COLORS.mutedHex);
});
this.vAdd(new Button(this, cx, 820, 'Back', () => this.showMenu(), { variant: 'ghost' }));
}
showRacerSelect() {
this.clearView();
const cx = GAME_WIDTH / 2;
this.text(cx, 120, 'CHOOSE YOUR RACER', 56, '#ffd028');
const cell = 250;
const gx0 = cx - cell;
const gy0 = 320;
const detail = { name: null, bars: null };
const drawDetail = (racer) => {
detail.name?.destroy();
detail.bars?.destroy();
const c = this.add.container(0, 0).setDepth(D.view);
this.vAdd(c);
detail.bars = c;
detail.name = this.text(cx, GAME_HEIGHT - 232, `${racer.id === this.playerRacer()?.id ? '' : ''}${(this.opponentById[racer.id]?.name ?? racer.id).toUpperCase()}${racer.archetype.toUpperCase()}`, 32, '#ffd028');
const stats = [
['SPEED', racer.stats.topSpeed], ['ACCEL', racer.stats.accel], ['TURN', racer.stats.handling],
['WEIGHT', racer.stats.weight], ['DRIFT', racer.stats.drift], ['OFFROAD', racer.stats.offroad],
];
stats.forEach(([label, v], i) => {
const bx = cx - 460 + (i % 3) * 320;
const by = GAME_HEIGHT - 180 + Math.floor(i / 3) * 44;
c.add(this.add.text(bx, by, label, { fontFamily: FONT, fontSize: '20px', color: COLORS.mutedHex }).setOrigin(0, 0.5));
c.add(this.add.rectangle(bx + 110, by, 160, 14, 0x2a2a34).setOrigin(0, 0.5));
c.add(this.add.rectangle(bx + 110, by, 160 * v, 14, 0xffd028).setOrigin(0, 0.5));
});
c.add(this.add.text(cx, GAME_HEIGHT - 90, racer.blurb, {
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5));
};
this.racerTiles = this.racers.map((racer, i) => {
const x = gx0 + (i % 3) * cell;
const y = gy0 + Math.floor(i / 3) * cell * 0.86;
const ring = this.vAdd(this.add.rectangle(x, y, 196, 196, 0x1c1c26)
.setStrokeStyle(3, i === this.chosen.racerIdx ? 0xffd028 : 0x3a3a46).setDepth(D.view - 1));
const op = this.opponentById[racer.id];
let face;
if (this.textures.exists('opponents')) {
face = this.add.image(x, y, 'opponents', op?.spriteIndex ?? 0).setDisplaySize(180, 180);
} else {
face = this.add.rectangle(x, y, 180, 180, Phaser.Display.Color.HexStringToColor(racer.color ?? '#888').color);
}
this.vAdd(face.setDepth(D.view));
const zone = this.vAdd(this.add.rectangle(x, y, 196, 196, 0xffffff, 0.001)
.setInteractive({ useHandCursor: true }).setDepth(D.view + 1));
zone.on('pointerover', () => drawDetail(racer));
zone.on('pointerdown', () => {
this.chosen.racerIdx = i;
playSound(this, SFX.UI_PICK);
this.racerTiles.forEach((t, j) => t.ring.setStrokeStyle(3, j === i ? 0xffd028 : 0x3a3a46));
drawDetail(racer);
const clip = op?.speech?.pick?.[0];
if (clip) { resetQueue(); enqueueSpeech(clip); }
});
return { ring };
});
drawDetail(this.racers[this.chosen.racerIdx]);
this.vAdd(new Button(this, cx - 170, GAME_HEIGHT - 34, 'Back', () => this.showClassSelect(),
{ variant: 'ghost', width: 200, height: 52 }));
this.vAdd(new Button(this, cx + 170, GAME_HEIGHT - 34, 'Continue', () => {
if (this.chosen.mode === 'gp') this.showCupSelect();
else this.showTrackSelect();
}, { width: 200, height: 52 }));
}
playerRacer() { return this.racers[this.chosen.racerIdx]; }
showCupSelect() {
this.clearView();
const cx = GAME_WIDTH / 2;
this.text(cx, 150, 'CHOOSE A CUP', 56, '#ffd028');
this.cups.cups.forEach((cup, i) => {
const x = cx + (i - (this.cups.cups.length - 1) / 2) * 460;
this.vAdd(new Button(this, x, 320, cup.name, () => {
playSound(this, SFX.UI_ACTIVATE);
this.startGrandPrix(cup);
}, { width: 400, height: 90, fontSize: 32 }));
cup.tracks.forEach((tid, j) => {
const tr = this.trackEntry(tid);
this.text(x, 410 + j * 36, `${j + 1}. ${tr?.name ?? tid}`, 22, COLORS.mutedHex);
});
});
this.vAdd(new Button(this, cx, 820, 'Back', () => this.showRacerSelect(), { variant: 'ghost' }));
}
showTrackSelect() {
this.clearView();
const cx = GAME_WIDTH / 2;
this.text(cx, 120, 'TIME TRIAL — CHOOSE A TRACK', 48, '#ffd028');
const cls = this.rules.engineClasses[this.chosen.classIdx];
(this.cups.tracks ?? []).forEach((tr, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = cx + (col === 0 ? -420 : 420);
const y = 230 + row * 100;
this.vAdd(new Button(this, x, y, tr.name, () => this.startTimeTrial(tr),
{ width: 560, height: 70, fontSize: 26 }));
const best = this.bestTimes(tr.id, cls.id)[0];
this.text(x, y + 46, best ? `BEST ${fmtTime(best)}` : 'NO RECORD', 18,
best ? COLORS.goldHex : COLORS.mutedHex);
});
this.vAdd(new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.showRacerSelect(), { variant: 'ghost' }));
}
trackEntry(id) { return (this.cups.tracks ?? []).find((t) => t.id === id); }
// ── Race setup / teardown ─────────────────────────────────────────────────
startGrandPrix(cup) {
this.gp = { cup, raceIdx: 0, points: {} };
for (const r of this.racers) this.gp.points[r.id] = 0;
this.startCupRace();
}
startCupRace() {
const tid = this.gp.cup.tracks[this.gp.raceIdx];
const entry = this.trackEntry(tid);
this.startRace({ entry });
}
startTimeTrial(entry) {
this.gp = null;
this.startRace({ entry, tt: true });
}
async loadTrackJson(entry) {
if (this.trackCache.has(entry.file)) return this.trackCache.get(entry.file);
const res = await fetch(`${GAMEDATA}/${entry.file}`);
const json = await res.json();
this.trackCache.set(entry.file, json);
return json;
}
async startRace({ entry, json = null, tt = false }) {
this.clearView();
this.teardownRace();
const trackJson = json ?? await this.loadTrackJson(entry);
const model = buildTrackModel(trackJson);
const theme = this.rules.themes[model.theme] ?? Object.values(this.rules.themes).find((t) => typeof t === 'object');
const cls = this.rules.engineClasses[this.chosen.classIdx];
// Track texture (regenerated per race; canvas textures are cheap to drop).
this.trackTexKey = 'superkart-track';
if (this.textures.exists(this.trackTexKey)) this.textures.remove(this.trackTexKey);
this.textures.addCanvas(this.trackTexKey, rasterizeTrack(model, theme));
const fogC = Phaser.Display.Color.HexStringToColor(theme.fog);
this.mode7 = new SuperKartMode7(this, {
trackTextureKey: this.trackTexKey,
worldSize: model.world,
fogColor: { x: fogC.red / 255, y: fogC.green / 255, z: fogC.blue / 255 },
depthBase: D.ground,
});
this.backdrop = new Mode7Backdrop(this, this.backdropTexKey(model.theme), this.mode7.horizonY, D.backdrop);
this.checkerOn = false;
// Sim. Player's racer keeps their roster slot so GP fields are stable.
const mode = tt ? 'tt' : 'gp';
this.raceState = createRace({
trackModel: model,
rules: this.rules,
engineClass: cls,
racers: this.racers,
playerIndex: this.chosen.racerIdx,
mode,
seed: (Date.now() & 0xffff) | 1,
});
this.raceEntry = entry;
this.raceResultsShown = false;
this.accum = 0;
this.buildWorldSprites(model);
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]; }
teardownRace() {
this.raceState = null;
this.mode7?.destroy();
this.mode7 = null;
this.backdrop?.destroy();
this.backdrop = null;
for (const s of this.worldSprites ?? []) s.sprite.destroy();
this.worldSprites = null;
this.playerSprite?.destroy();
this.playerSprite = null;
this.hud?.destroy();
this.hud = null;
this.debugG?.destroy();
this.debugG = null;
this.minimapDots = null;
this.victoryCamActive = false;
this.victoryCam = null;
this.hideSkipPrompt();
this.stopEngineSound();
this.stopStarLoop();
}
exitRace() {
this.teardownRace();
if (this.returnToEditor) this.scene.start('SuperKartEditor', { resume: true });
else this.showMenu();
}
// ── World sprites (projected each frame) ─────────────────────────────────
buildWorldSprites(model) {
const itemTex = this.itemTexKey();
const decorTex = this.decorTexKey(model.theme);
const themeDecorList = this.rules.themes[model.theme]?.decor ?? [];
this.worldSprites = [];
const add = (wx, wy, tex, frame, sizeMult, tag, ref) => {
const sprite = this.add.sprite(0, 0, tex, frame).setDepth(D.world).setVisible(false);
this.worldSprites.push({ sprite, wx, wy, sizeMult, tag, ref });
return sprite;
};
// AI karts (player kart is the fixed bottom-center sprite instead).
for (const kart of this.raceState.karts) {
if (kart.isPlayer) continue;
add(kart.x, kart.y, this.kartTexKey(kart.racer.id), 0, 1.0, 'kart', kart);
}
for (const box of this.raceState.itemBoxes) add(box.x, box.y, itemTex, BOX_FRAME, 0.85, 'box', box);
for (const coin of this.raceState.coins) add(coin.x, coin.y, itemTex, COIN_FRAME, 0.6, 'coin', coin);
for (const dec of model.decor) {
const frame = Math.max(0, themeDecorList.indexOf(dec.sprite)) % 8;
add(dec.x, dec.y, decorTex, frame, 2.2, 'decor', dec);
}
// Projectiles and dropped hazards appear mid-race; pools grow on demand.
this.dynamicPool = [];
// Player kart, fixed at the bottom center like the SNES original.
const pk = this.playerKartState();
this.playerSprite = this.add.sprite(GAME_WIDTH / 2, GAME_HEIGHT * 0.75,
this.kartTexKey(pk.racer.id), 0).setDepth(D.playerKart).setScale(PLAYER_KART_SCALE);
this.debugG = this.add.graphics().setDepth(D.debug).setVisible(this.debugOn);
}
dynamicSprite(i, tex, frame) {
while (this.dynamicPool.length <= i) {
this.dynamicPool.push(this.add.sprite(0, 0, tex, frame).setDepth(D.world).setVisible(false));
}
const s = this.dynamicPool[i];
s.setTexture(tex, frame);
return s;
}
// ── HUD ───────────────────────────────────────────────────────────────────
buildRaceHud(model, entry, cls) {
this.hud = this.add.container(0, 0).setDepth(D.hud);
const t = (x, y, str, size, color = COLORS.textHex, origin = 0.5) => {
const obj = this.add.text(x, y, str, { fontFamily: FONT, fontSize: `${size}px`, color }).setOrigin(origin);
this.hud.add(obj);
return obj;
};
t(GAME_WIDTH / 2, 28, `${entry?.name ?? model.name}${cls.label}`, 24, COLORS.mutedHex);
this.hudLap = t(40, 40, 'LAP 1/5', 40, '#ffd028', 0);
this.hudTimer = t(40, 92, '0:00.00', 30, COLORS.textHex, 0);
this.hudCoins = t(40, 136, '', 26, COLORS.goldHex, 0);
// Item panel.
const ip = this.add.rectangle(120, 250, 110, 110, 0x14141c, 0.85).setStrokeStyle(3, 0xffd028);
this.hud.add(ip);
this.hudItem = this.add.sprite(120, 250, this.itemTexKey(), BOX_FRAME).setScale(1.7).setVisible(false);
this.hud.add(this.hudItem);
// Position numeral.
this.hudPos = t(GAME_WIDTH - 130, GAME_HEIGHT - 120, '1', 170, '#ffd028');
this.hudPosSuffix = t(GAME_WIDTH - 60, GAME_HEIGHT - 80, 'ST', 44, '#ffd028');
// Minimap + live dots.
const mm = drawMinimapCanvas(model, 230);
if (this.textures.exists('superkart-minimap')) this.textures.remove('superkart-minimap');
this.textures.addCanvas('superkart-minimap', mm.canvas);
this.minimapToMap = mm.toMap;
const mapImg = this.add.image(GAME_WIDTH - 140, 150, 'superkart-minimap').setAlpha(0.92);
this.hud.add(mapImg);
this.minimapOrigin = { x: GAME_WIDTH - 140 - 115, y: 150 - 115 };
this.minimapDots = this.raceState.karts.map((kart) => {
const color = Phaser.Display.Color.HexStringToColor(kart.racer.color ?? '#ffffff').color;
const dot = this.add.circle(0, 0, kart.isPlayer ? 6 : 4.5, color)
.setStrokeStyle(1.5, kart.isPlayer ? 0xffffff : 0x000000);
this.hud.add(dot);
return dot;
});
// Mini rankings column.
this.hudRank = this.raceState.karts.map((_, i) => {
const y = 300 + i * 30;
const chip = this.add.rectangle(GAME_WIDTH - 250, y, 16, 16, 0xffffff);
const name = this.add.text(GAME_WIDTH - 232, y, '', { fontFamily: FONT, fontSize: '19px', color: COLORS.textHex }).setOrigin(0, 0.5);
this.hud.add(chip);
this.hud.add(name);
return { chip, name };
});
if (this.raceState.mode === 'tt') this.hudRank.forEach((r) => { r.chip.setVisible(false); r.name.setVisible(false); });
// Countdown lights.
this.hudLights = [0, 1, 2].map((i) => {
const l = this.add.circle(GAME_WIDTH / 2 + (i - 1) * 90, 200, 32, 0x33333d).setStrokeStyle(4, 0x101014);
this.hud.add(l);
return l;
});
this.hudGo = t(GAME_WIDTH / 2, 200, '', 90, '#38b048');
this.countTicked = new Set();
this.hud.add(this.add.text(GAME_WIDTH - 24, 12, 'ESC QUITS', { fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex }).setOrigin(1, 0));
}
// ── Frame update ──────────────────────────────────────────────────────────
update(time, delta) {
if (!this.raceState) return;
this.accum += Math.min(delta, 100);
const prevPhase = this.raceState.phase;
while (this.accum >= STEP_MS) {
this.accum -= STEP_MS;
step(this.raceState, this.readInputs());
this.processEvents(this.raceState.events);
}
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();
}
}
renderCountdown() {
const ms = this.raceState.countdownMs;
const lit = ms < 2600 ? Math.min(3, Math.ceil((2600 - ms) / 800) + 0) : 0;
this.hudLights?.forEach((l, i) => l.setFillStyle(i < lit ? 0xd42a20 : 0x33333d));
for (const th of [2600, 1800, 1000]) {
if (ms < th && !this.countTicked.has(th)) {
this.countTicked.add(th);
playSound(this, SFX.COUNTDOWN_TICK);
}
}
}
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.backdrop.update(this.mode7.cam.angle);
// Static + kart world sprites.
for (const ws of this.worldSprites) {
const { sprite, tag, ref } = ws;
let wx = ws.wx;
let wy = ws.wy;
let visible = true;
let frame = null;
if (tag === 'kart') {
wx = ref.x; wy = ref.y;
frame = this.mode7.angleFrame(ref.spinMs > 0 ? ref.heading + ref.spinSpin : ref.heading, KART_ROT_FRAMES);
visible = ref.rescueMs <= 0;
} else if (tag === 'box') {
visible = ref.respawnAt <= state.timeMs;
} else if (tag === 'coin') {
visible = !ref.taken;
}
if (!visible) { sprite.setVisible(false); continue; }
const p = this.mode7.project(wx, wy);
if (!p) { sprite.setVisible(false); continue; }
sprite.setVisible(true);
let scale = p.scale * ws.sizeMult;
if (tag === 'kart') {
if (ref.empMs > 0) scale *= 0.55;
if (ref.squashMs > 0) sprite.setScale(scale * 1.25, scale * 0.45);
else sprite.setScale(scale);
sprite.setFrame(frame);
sprite.setAlpha(ref.invulnMs > 0 ? (Math.floor(state.timeMs / 80) % 2 ? 0.35 : 1) : 1);
if (ref.starMs > 0) sprite.setTint(starTint(state.timeMs));
else sprite.clearTint();
} else {
sprite.setScale(scale);
if (tag === 'box') sprite.setAngle(Math.sin(state.timeMs / 300 + wx) * 14);
sprite.setAlpha(1 - p.fog * 0.85);
}
sprite.setPosition(p.x, p.y - sprite.displayHeight * 0.42);
sprite.setDepth(D.world + Math.max(0, 800 - p.z));
}
// Projectiles + dropped hazards through the dynamic pool.
const itemTex = this.itemTexKey();
let di = 0;
for (const pr of state.projectiles) {
if (pr.dead) continue;
const p = this.mode7.project(pr.x, pr.y);
const s = this.dynamicSprite(di, itemTex, ITEM_FRAME[pr.id] ?? 0);
di += 1;
if (!p) { s.setVisible(false); continue; }
s.setVisible(true).setPosition(p.x, p.y - 10 * p.scale).setScale(p.scale * 0.7)
.setDepth(D.world + Math.max(0, 800 - p.z));
}
for (const hz of state.hazardsDropped) {
if (hz.dead) continue;
const p = this.mode7.project(hz.x, hz.y);
const s = this.dynamicSprite(di, itemTex, ITEM_FRAME.oil);
di += 1;
if (!p) { s.setVisible(false); continue; }
s.setVisible(true).setPosition(p.x, p.y).setScale(p.scale * 0.8, p.scale * 0.5)
.setDepth(D.world + Math.max(0, 800 - p.z));
}
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);
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();
let frame = 0;
let angle = 0;
if (kart.spinMs > 0) {
frame = Math.floor((this.raceState.timeMs / 60)) % KART_ROT_FRAMES;
} else if (kart.drifting) {
angle = kart.driftDir * 14;
} else if (inputs.steer !== 0 && hasLean) {
frame = inputs.steer < 0 ? KART_ROT_FRAMES : KART_ROT_FRAMES + 1;
} else if (inputs.steer !== 0) {
angle = inputs.steer * 5;
}
s.setFrame(frame);
s.setAngle(angle);
let scale = PLAYER_KART_SCALE;
if (kart.empMs > 0) scale *= 0.55;
if (kart.squashMs > 0) s.setScale(scale * 1.2, scale * 0.5);
else s.setScale(scale);
const hopH = kart.airborne ? Math.sin((1 - kart.hopMs / this.rules.physics.hopMs) * Math.PI) * 46 : 0;
s.setPosition(GAME_WIDTH / 2, GAME_HEIGHT * 0.75 - hopH);
s.setAlpha(kart.rescueMs > 0 ? 0.25 : 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();
}
renderHud(state, player) {
this.hudLap.setText(`LAP ${Math.min(player.lap, state.laps)}/${state.laps}`);
const raceMs = Math.max(0, state.timeMs - COUNTDOWN_MS);
this.hudTimer.setText(fmtTime(raceMs));
this.hudCoins.setText(state.mode === 'tt' ? '' : `COINS ${player.coins}`);
if (player.rouletteMs > 0) {
this.hudItem.setVisible(true).setFrame(Math.floor(state.timeMs / 80) % 7);
} else if (player.item) {
this.hudItem.setVisible(true).setFrame(ITEM_FRAME[player.item] ?? 0);
} else {
this.hudItem.setVisible(false);
}
const pos = player.position;
this.hudPos.setText(String(pos));
this.hudPosSuffix.setText(['ST', 'ND', 'RD'][pos - 1] ?? 'TH');
const posColor = pos === 1 ? '#ffd028' : pos <= 4 ? '#f2f2f2' : '#e06c75';
this.hudPos.setColor(posColor);
this.hudPosSuffix.setColor(posColor);
this.hudPos.setVisible(state.mode !== 'tt');
this.hudPosSuffix.setVisible(state.mode !== 'tt');
// Minimap dots.
state.karts.forEach((kart, i) => {
const [mx, my] = this.minimapToMap(kart.x, kart.y);
this.minimapDots[i].setPosition(this.minimapOrigin.x + mx, this.minimapOrigin.y + my);
});
// Rankings.
if (state.mode !== 'tt') {
const order = [...state.karts].sort((a, b) => a.position - b.position);
order.forEach((kart, i) => {
const row = this.hudRank[i];
row.chip.setFillStyle(Phaser.Display.Color.HexStringToColor(kart.racer.color ?? '#fff').color);
const nm = (this.opponentById[kart.racer.id]?.name ?? kart.racer.id).slice(0, 12).toUpperCase();
row.name.setText(`${i + 1} ${nm}${kart.isPlayer ? ' ◄' : ''}`);
row.name.setColor(kart.isPlayer ? '#ffd028' : COLORS.textHex);
});
}
if (state.phase !== 'countdown' && this.hudLights) {
if (state.timeMs - COUNTDOWN_MS < 900) {
this.hudLights.forEach((l) => l.setFillStyle(0x38b048));
this.hudGo.setText('GO!');
} else {
this.hudLights.forEach((l) => l.setVisible(false));
this.hudGo.setText('');
}
}
}
renderDebug(state) {
const g = this.debugG;
g.clear();
const sc = 0.16;
const ox = GAME_WIDTH / 2 - (state.model.world * sc) / 2;
const oy = GAME_HEIGHT / 2 - (state.model.world * sc) / 2;
g.fillStyle(0x000000, 0.55);
g.fillRect(ox, oy, state.model.world * sc, state.model.world * sc);
g.lineStyle(2, 0xffffff, 0.8);
g.beginPath();
state.model.samples.forEach((p, i) => {
if (i) g.lineTo(ox + p.x * sc, oy + p.y * sc);
else g.moveTo(ox + p.x * sc, oy + p.y * sc);
});
g.closePath();
g.strokePath();
for (const kart of state.karts) {
const color = Phaser.Display.Color.HexStringToColor(kart.racer.color ?? '#fff').color;
g.fillStyle(color, 1);
g.fillCircle(ox + kart.x * sc, oy + kart.y * sc, kart.isPlayer ? 6 : 4);
g.lineStyle(1.5, color, 1);
g.lineBetween(ox + kart.x * sc, oy + kart.y * sc,
ox + (kart.x + Math.cos(kart.heading) * 120) * sc,
oy + (kart.y + Math.sin(kart.heading) * 120) * sc);
}
}
// ── 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-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 '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 '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();
}
break;
case 'player-position':
if (ev.to > ev.from) this.tauntFromLeader(ev.to);
break;
default: break;
}
}
}
// A rival mouths off when the player gets passed / hit. Throttled hard.
taunt(kartIdx, playerWasVictim) {
if (!playerWasVictim || this.raceState.mode === 'tt') return;
this.tauntFromLeader(this.playerKartState().position);
}
tauntFromLeader(playerPos) {
const state = this.raceState;
if (state.timeMs - this.lastTauntMs < 10000) return;
const rival = state.karts.find((k) => !k.isPlayer && k.position === playerPos - 1);
const op = rival && this.opponentById[rival.racer.id];
const lines = op?.speech?.happy;
if (!lines?.length) return;
this.lastTauntMs = state.timeMs;
enqueueSpeech(lines[Math.floor(Math.random() * lines.length)]);
}
// ── Results / podium ──────────────────────────────────────────────────────
bestTimes(trackId, classId) {
try {
const all = JSON.parse(localStorage.getItem(TIMES_KEY) ?? '{}');
return all[`${trackId}|${classId}`] ?? [];
} catch (_) { return []; }
}
recordTime(trackId, classId, ms) {
try {
const all = JSON.parse(localStorage.getItem(TIMES_KEY) ?? '{}');
const key = `${trackId}|${classId}`;
const list = all[key] ?? [];
list.push(ms);
list.sort((a, b) => a - b);
all[key] = list.slice(0, 5);
localStorage.setItem(TIMES_KEY, JSON.stringify(all));
return list.indexOf(ms) === 0;
} catch (_) { return false; }
}
showResults() {
const state = this.raceState;
const results = finalizeRace(state);
const cls = this.rules.engineClasses[this.chosen.classIdx];
const player = this.playerKartState();
const cx = GAME_WIDTH / 2;
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 })));
return;
}
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);
this.text(cx, 270, `TIME ${fmtTime(ms)} BEST LAP ${fmtTime(player.bestLapMs)}`, 34);
const times = this.bestTimes(this.raceEntry.id, cls.id);
this.text(cx, 360, 'BEST TIMES', 28, COLORS.mutedHex);
times.forEach((tms, i) => this.text(cx, 410 + i * 40, `${i + 1}. ${fmtTime(tms)}`, 26,
tms === ms ? '#ffd028' : COLORS.textHex));
this.vAdd(new Button(this, cx - 170, 760, 'Retry', () => this.startTimeTrial(this.raceEntry), { width: 220 }));
this.vAdd(new Button(this, cx + 170, 760, 'Tracks', () => this.showTrackSelect(), { width: 220, variant: 'ghost' }));
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();
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');
results.forEach((r, i) => {
const y = 226 + i * 52;
const isP = r.racerId === playerId;
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);
if (this.textures.exists('opponents')) {
row.add(this.add.image(cx - 290, 0, 'opponents', this.opponentById[r.racerId]?.spriteIndex ?? 0)
.setDisplaySize(44, 44));
}
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); },
});
});
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(),
});
}
}
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 playerId = this.playerRacer().id;
const playerPlace = standings.findIndex(([id]) => id === playerId) + 1;
this.text(cx, 100, `${this.gp.cup.name} — FINAL STANDINGS`, 48, '#ffd028');
// 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];
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 });
}
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 });
});
}
}
// ── Small helpers ─────────────────────────────────────────────────────────
function fmtTime(ms) {
if (!ms || ms < 0) ms = 0;
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
const cs = Math.floor((ms % 1000) / 10);
return `${m}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
}
function ordinal(n) {
return `${n}${['st', 'nd', 'rd'][n - 1] ?? 'th'}`;
}
function starTint(timeMs) {
const hues = [0xffd028, 0xff6a90, 0x68d0ff, 0x8aff78];
return hues[Math.floor(timeMs / 90) % hues.length];
}