// 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]; }