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

998 lines
41 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 { 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 } from './SuperKartMode7.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, 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;
const COIN_FRAME = 8;
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;
}
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 music = this.cache.json.get('music');
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xffd028, curveAmount: 0.35 });
this.events.once('shutdown', () => {
this.crt.destroy();
resetQueue();
this.teardownRace();
});
this.bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018)
.setDepth(-10);
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();
});
}
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 = [];
}
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);
}
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;
this.text(cx, 200, 'SUPER KART', 110, '#ffd028');
this.text(cx, 290, 'MODE 7 KART RACING • STARRING THE FERTIG GAMES CREW', 24, COLORS.mutedHex);
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);
}
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;
}
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(3.1);
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;
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();
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);
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);
this.renderPlayerKart(player);
this.renderHud(state, player);
if (this.debugOn) this.renderDebug(state);
}
renderPlayerKart(kart) {
const s = this.playerSprite;
const sheet = this.textures.get(this.kartTexKey(kart.racer.id));
const hasLean = sheet.frameTotal > 17;
const inputs = this.readInputs();
let frame = 0;
let angle = 0;
if (kart.spinMs > 0) {
frame = Math.floor((this.raceState.timeMs / 60)) % 16;
} else if (kart.drifting) {
frame = kart.driftDir > 0 ? 1 : 15;
angle = kart.driftDir * 6;
} else if (inputs.steer !== 0 && hasLean) {
frame = inputs.steer < 0 ? 16 : 17;
} else if (inputs.steer !== 0) {
angle = inputs.steer * 5;
}
s.setFrame(frame);
s.setAngle(angle);
let scale = 3.1;
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 ───────────────────────────────────────────────
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); 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 '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 '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);
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;
this.teardownRace();
this.clearView();
if (this.returnToEditor) {
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') {
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;
}
// Grand Prix race results + cumulative standings.
for (const r of results) this.gp.points[r.racerId] += r.points;
const lastRace = this.gp.raceIdx >= this.gp.cup.tracks.length - 1;
this.text(cx, 110, `${this.raceEntry.name} — RESULTS`, 48, '#ffd028');
const header = (x, s) => this.text(x, 180, s, 22, COLORS.mutedHex);
header(cx - 420, 'POS');
header(cx - 240, 'RACER');
header(cx + 90, 'TIME');
header(cx + 260, 'PTS');
header(cx + 400, 'TOTAL');
results.forEach((r, i) => {
const y = 226 + i * 52;
const racer = this.racers.find((rc) => rc.id === r.racerId);
const isP = r.racerId === this.playerRacer().id;
const color = isP ? '#ffd028' : COLORS.textHex;
this.text(cx - 420, y, String(r.position), 28, color);
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));
}
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;
});
this.vAdd(new Button(this, cx, GAME_HEIGHT - 90, lastRace ? 'Final Standings' : 'Next Race', () => {
if (lastRace) this.showPodium();
else { this.gp.raceIdx += 1; this.startCupRace(); }
}, { width: 340 }));
}
showPodium() {
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');
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).
const podX = [cx, cx - 330, cx + 330];
const podY = [340, 400, 430];
standings.slice(0, 3).forEach(([id], i) => {
const op = this.opponentById[id];
if (op) {
const portrait = createOpponentPortrait(this, op, podX[i], podY[i], i === 0 ? 110 : 85, D.view, { playIntro: false });
this.viewObjs.push(portrait);
}
this.text(podX[i], podY[i] + (i === 0 ? 150 : 125), `${i + 1}. ${(op?.name ?? id).toUpperCase()}`, 28,
id === playerId ? '#ffd028' : COLORS.textHex);
this.text(podX[i], podY[i] + (i === 0 ? 186 : 161), `${this.gp.points[id]} PTS`, 22, COLORS.mutedHex);
});
const winner = this.opponentById[standings[0][0]];
const clip = winner?.speech?.happy?.[0];
if (clip) { resetQueue(); enqueueSpeech(clip); }
standings.slice(3).forEach(([id, pts], i) => {
this.text(cx - 200 + Math.floor(i / 3) * 400, 660 + (i % 3) * 40,
`${i + 4}. ${(this.opponentById[id]?.name ?? id).toUpperCase()} ${pts} PTS`, 22,
id === playerId ? '#ffd028' : COLORS.mutedHex);
});
playSound(this, playerPlace === 1 ? SFX.CASINO_WIN : SFX.VICTORY_SHORT);
this.vAdd(new Button(this, cx - 180, GAME_HEIGHT - 70, 'Race Again', () => this.showCupSelect(), { width: 260 }));
this.vAdd(new Button(this, cx + 180, GAME_HEIGHT - 70, 'Main Menu', () => this.showMenu(), { width: 260, variant: 'ghost' }));
}
}
// ── 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];
}