1011 lines
42 KiB
JavaScript
1011 lines
42 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, KART_ROT_FRAMES } 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;
|
|
// 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;
|
|
|
|
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();
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
}
|
|
|
|
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;
|
|
// 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);
|
|
}
|
|
|
|
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(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;
|
|
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, 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);
|
|
|
|
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 > 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 ───────────────────────────────────────────────
|
|
|
|
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];
|
|
}
|