feat(superkart): add post-race victory cam, engine audio, and polished results screens
- VictoryCamDirector: cinematic post-race camera system with shuffled shot types (chase, orbit, front, trackside) and hard-cut transitions; player can press ENTER to skip. - Engine audio: per-racer engine hum (heavy/fast/medium) with pitch tracking based on kart speed; engine-start sound at green light. - Star power loop sound that stops when star expires. - Positional audio: AI kart sounds (spin, hit, flatten) scale volume with distance from player. - Enhanced event sounds: item-use, boost, EMP, squash, bump, coin, star now play appropriate SFX. - Player kart autopiloted after finishing for victory-lap vignette; race phase waits for entire field (up to 90s safety cap). - EMP now emits target list for per-target shrink sounds; squash collision emits event. - Animated race results screen with staggered row reveals. - Animated cup standings with running point counter and position arrows; tweens rows to new ranks. - Podium reveal: 3rd → 2nd → 1st staggered with confetti, count-up, and CRT pulse; winner speech plays. - Mode7 camera now tracks height and focal length; setCamera accepts arbitrary poses for cinematic shots. - New audio assets: engine-start/heavy/fast/medium/rev, kart-coin, kart-flatten, kart-thump, kart-shrink, kart-spin, kart-hit, kart-shell, kart-star.
This commit is contained in:
parent
54a9614464
commit
e4a4531401
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -28,6 +28,7 @@ import {
|
||||||
buildBackdropCanvas, buildDecorSheetCanvas,
|
buildBackdropCanvas, buildDecorSheetCanvas,
|
||||||
} from './SuperKartRaster.js';
|
} from './SuperKartRaster.js';
|
||||||
import { SuperKartMode7, Mode7Backdrop, MODE7, KART_ROT_FRAMES } from './SuperKartMode7.js';
|
import { SuperKartMode7, Mode7Backdrop, MODE7, KART_ROT_FRAMES } from './SuperKartMode7.js';
|
||||||
|
import { VictoryCamDirector } from './SuperKartVictoryCam.js';
|
||||||
|
|
||||||
const FONT = 'm6x11, "Julius Sans One"';
|
const FONT = 'm6x11, "Julius Sans One"';
|
||||||
const TIMES_KEY = 'superkart-times';
|
const TIMES_KEY = 'superkart-times';
|
||||||
|
|
@ -35,7 +36,7 @@ const GAMEDATA = 'assets/gamedata/superkart';
|
||||||
|
|
||||||
const D = {
|
const D = {
|
||||||
backdrop: -2, ground: -1, world: 10, playerKart: 900, hud: 1000,
|
backdrop: -2, ground: -1, world: 10, playerKart: 900, hud: 1000,
|
||||||
banner: 1100, view: 1200, debug: 1500,
|
banner: 1100, overlay: 1150, view: 1200, debug: 1500,
|
||||||
};
|
};
|
||||||
const ITEM_FRAME = { bolt: 0, seeker: 1, oil: 2, turbo: 3, overdrive: 4, emp: 5, coins: 6 };
|
const ITEM_FRAME = { bolt: 0, seeker: 1, oil: 2, turbo: 3, overdrive: 4, emp: 5, coins: 6 };
|
||||||
const BOX_FRAME = 7;
|
const BOX_FRAME = 7;
|
||||||
|
|
@ -46,6 +47,18 @@ const BOX_FRAME = 7;
|
||||||
const PLAYER_KART_SCALE = (MODE7.spriteBase / 64) * (MODE7.focal / MODE7.followDist);
|
const PLAYER_KART_SCALE = (MODE7.spriteBase / 64) * (MODE7.focal / MODE7.followDist);
|
||||||
const COIN_FRAME = 8;
|
const COIN_FRAME = 8;
|
||||||
|
|
||||||
|
// Player engine loop: default (idle) playback rate 1.0, climbing toward
|
||||||
|
// ENGINE_RATE_MAX as the kart approaches its top speed.
|
||||||
|
const ENGINE_RATE_MIN = 1.0;
|
||||||
|
const ENGINE_RATE_MAX = 2.2;
|
||||||
|
const ENGINE_VOLUME = 0.55;
|
||||||
|
const ENGINE_SFX_BY_RACER = {
|
||||||
|
smasher: 'ENGINE_HEAVY', mario: 'ENGINE_HEAVY', blackwind: 'ENGINE_HEAVY',
|
||||||
|
kona: 'ENGINE_FAST', fireball: 'ENGINE_FAST', gerome: 'ENGINE_FAST',
|
||||||
|
croc: 'ENGINE_MEDIUM', 'dv-8-2303': 'ENGINE_MEDIUM', zanthor: 'ENGINE_MEDIUM',
|
||||||
|
};
|
||||||
|
const KART_STAR_VOLUME = 0.6;
|
||||||
|
|
||||||
export default class SuperKartGame extends Phaser.Scene {
|
export default class SuperKartGame extends Phaser.Scene {
|
||||||
constructor() { super('SuperKartGame'); }
|
constructor() { super('SuperKartGame'); }
|
||||||
|
|
||||||
|
|
@ -62,6 +75,14 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
this.debugOn = false;
|
this.debugOn = false;
|
||||||
this.accum = 0;
|
this.accum = 0;
|
||||||
this.lastTauntMs = 0;
|
this.lastTauntMs = 0;
|
||||||
|
this.victoryCamActive = false;
|
||||||
|
this.victoryCam = null;
|
||||||
|
this.victoryCamStartMs = 0;
|
||||||
|
this.skipPrompt = null;
|
||||||
|
this.engineSound = null;
|
||||||
|
this.engineStartSound = null;
|
||||||
|
this.engineTopSpeed = 0;
|
||||||
|
this.starSound = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
|
@ -211,6 +232,9 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
this.input.keyboard.on('keydown-ESC', () => {
|
this.input.keyboard.on('keydown-ESC', () => {
|
||||||
if (this.raceState) this.exitRace();
|
if (this.raceState) this.exitRace();
|
||||||
});
|
});
|
||||||
|
this.input.keyboard.on('keydown-ENTER', () => {
|
||||||
|
if (this.victoryCamActive) this.skipVictoryCam();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
readInputs() {
|
readInputs() {
|
||||||
|
|
@ -245,6 +269,14 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
return this.vAdd(t);
|
return this.vAdd(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dims whatever's behind a text-heavy view — used when the live race scene
|
||||||
|
// (not the static menu background) is still rendering behind results /
|
||||||
|
// standings, so text stays legible over the moving footage.
|
||||||
|
scrim(alpha = 0.55) {
|
||||||
|
return this.vAdd(this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, alpha)
|
||||||
|
.setDepth(D.overlay));
|
||||||
|
}
|
||||||
|
|
||||||
showWebGLNotice() {
|
showWebGLNotice() {
|
||||||
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40,
|
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40,
|
||||||
'SUPER KART NEEDS WEBGL', {
|
'SUPER KART NEEDS WEBGL', {
|
||||||
|
|
@ -467,6 +499,9 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
this.buildRaceHud(model, entry, cls);
|
this.buildRaceHud(model, entry, cls);
|
||||||
this.mode7.follow(this.playerKartState(), true);
|
this.mode7.follow(this.playerKartState(), true);
|
||||||
playSound(this, SFX.SCIFI_RISER);
|
playSound(this, SFX.SCIFI_RISER);
|
||||||
|
// Engine fires up right away, before the stoplight countdown even
|
||||||
|
// starts — the idle loop kicks in once engine-start.mp3 finishes.
|
||||||
|
this.startEngineAudio(this.playerKartState());
|
||||||
}
|
}
|
||||||
|
|
||||||
playerKartState() { return this.raceState.karts[this.raceState.playerIndex]; }
|
playerKartState() { return this.raceState.karts[this.raceState.playerIndex]; }
|
||||||
|
|
@ -486,6 +521,11 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
this.debugG?.destroy();
|
this.debugG?.destroy();
|
||||||
this.debugG = null;
|
this.debugG = null;
|
||||||
this.minimapDots = null;
|
this.minimapDots = null;
|
||||||
|
this.victoryCamActive = false;
|
||||||
|
this.victoryCam = null;
|
||||||
|
this.hideSkipPrompt();
|
||||||
|
this.stopEngineSound();
|
||||||
|
this.stopStarLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
exitRace() {
|
exitRace() {
|
||||||
|
|
@ -618,6 +658,12 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
this.renderRace();
|
this.renderRace();
|
||||||
if (this.raceState.phase === 'finished' && !this.raceResultsShown) {
|
if (this.raceState.phase === 'finished' && !this.raceResultsShown) {
|
||||||
this.raceResultsShown = true;
|
this.raceResultsShown = true;
|
||||||
|
// Keep the victory cam cutting through the race scene behind the
|
||||||
|
// results/standings screens instead of tearing down to the static
|
||||||
|
// menu background — only the skip prompt (nothing left to skip) and
|
||||||
|
// the now-stale race HUD go away.
|
||||||
|
this.hideSkipPrompt();
|
||||||
|
this.hud?.setVisible(false);
|
||||||
this.time.delayedCall(900, () => this.showResults());
|
this.time.delayedCall(900, () => this.showResults());
|
||||||
} else if (prevPhase === 'countdown') {
|
} else if (prevPhase === 'countdown') {
|
||||||
this.renderCountdown();
|
this.renderCountdown();
|
||||||
|
|
@ -639,7 +685,13 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
renderRace() {
|
renderRace() {
|
||||||
const state = this.raceState;
|
const state = this.raceState;
|
||||||
const player = this.playerKartState();
|
const player = this.playerKartState();
|
||||||
this.mode7.follow(player);
|
if (this.victoryCamActive) {
|
||||||
|
const pose = this.victoryCam.update(state.timeMs - this.victoryCamStartMs, player);
|
||||||
|
this.mode7.setCamera(pose.x, pose.y, pose.angle, pose.height, pose.focal);
|
||||||
|
if (this.victoryCam.justCut) this.crt?.pulse(0.35, 120);
|
||||||
|
} else {
|
||||||
|
this.mode7.follow(player);
|
||||||
|
}
|
||||||
this.backdrop.update(this.mode7.cam.angle);
|
this.backdrop.update(this.mode7.cam.angle);
|
||||||
|
|
||||||
// Static + kart world sprites.
|
// Static + kart world sprites.
|
||||||
|
|
@ -703,13 +755,145 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
for (let i = di; i < this.dynamicPool.length; i += 1) this.dynamicPool[i].setVisible(false);
|
for (let i = di; i < this.dynamicPool.length; i += 1) this.dynamicPool[i].setVisible(false);
|
||||||
|
|
||||||
this.renderPlayerKart(player);
|
if (this.victoryCamActive) this.renderPlayerKartCinematic(player);
|
||||||
this.renderHud(state, player);
|
else this.renderPlayerKart(player);
|
||||||
|
if (!this.raceResultsShown) this.renderHud(state, player);
|
||||||
if (this.debugOn) this.renderDebug(state);
|
if (this.debugOn) this.renderDebug(state);
|
||||||
|
this.updateEngineSound(player);
|
||||||
|
this.updateStarSound(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Player engine sound ──────────────────────────────────────────────────
|
||||||
|
// engine-start.mp3 plays once at the green light, then a looping
|
||||||
|
// per-racer engine hum takes over with its pitch tracking kart.speed —
|
||||||
|
// stopped the instant the player crosses the finish line.
|
||||||
|
|
||||||
|
engineKeyForRacer(racerId) {
|
||||||
|
return SFX[ENGINE_SFX_BY_RACER[racerId] ?? 'ENGINE_MEDIUM'];
|
||||||
|
}
|
||||||
|
|
||||||
|
startEngineAudio(kart) {
|
||||||
|
this.stopEngineSound();
|
||||||
|
try {
|
||||||
|
const s = this.sound.add(SFX.ENGINE_START);
|
||||||
|
this.engineStartSound = s;
|
||||||
|
s.once('complete', () => {
|
||||||
|
this.engineStartSound = null;
|
||||||
|
if (this.raceState) this.startEngineLoop(kart);
|
||||||
|
});
|
||||||
|
s.play();
|
||||||
|
} catch (_) {
|
||||||
|
this.startEngineLoop(kart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startEngineLoop(kart) {
|
||||||
|
try {
|
||||||
|
this.engineSound = this.sound.add(this.engineKeyForRacer(kart.racer.id), {
|
||||||
|
loop: true, rate: ENGINE_RATE_MIN, volume: ENGINE_VOLUME,
|
||||||
|
});
|
||||||
|
this.engineSound.play();
|
||||||
|
} catch (_) {
|
||||||
|
this.engineSound = null;
|
||||||
|
}
|
||||||
|
const physics = this.raceState?.physics;
|
||||||
|
this.engineTopSpeed = physics
|
||||||
|
? topSpeedOf(kart.racer.stats, physics, this.raceState.engineClass?.speedMult ?? 1) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEngineSound(kart) {
|
||||||
|
if (!this.engineSound) return;
|
||||||
|
const frac = this.engineTopSpeed > 0 ? Phaser.Math.Clamp(Math.abs(kart.speed) / this.engineTopSpeed, 0, 1) : 0;
|
||||||
|
this.engineSound.setRate(ENGINE_RATE_MIN + (ENGINE_RATE_MAX - ENGINE_RATE_MIN) * frac);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopEngineSound() {
|
||||||
|
if (this.engineStartSound) {
|
||||||
|
try { this.engineStartSound.stop(); this.engineStartSound.destroy(); } catch (_) { /* noop */ }
|
||||||
|
this.engineStartSound = null;
|
||||||
|
}
|
||||||
|
if (this.engineSound) {
|
||||||
|
try { this.engineSound.stop(); this.engineSound.destroy(); } catch (_) { /* noop */ }
|
||||||
|
this.engineSound = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Player star-power loop ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
startStarLoop() {
|
||||||
|
if (this.starSound) return;
|
||||||
|
try {
|
||||||
|
this.starSound = this.sound.add(SFX.KART_STAR, { loop: true, volume: KART_STAR_VOLUME });
|
||||||
|
this.starSound.play();
|
||||||
|
} catch (_) {
|
||||||
|
this.starSound = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStarSound(kart) {
|
||||||
|
if (this.starSound && kart.starMs <= 0) this.stopStarLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopStarLoop() {
|
||||||
|
if (!this.starSound) return;
|
||||||
|
try { this.starSound.stop(); this.starSound.destroy(); } catch (_) { /* noop */ }
|
||||||
|
this.starSound = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Victory cam (post-race AI takeover) ─────────────────────────────────
|
||||||
|
|
||||||
|
startVictoryCam() {
|
||||||
|
this.victoryCamActive = true;
|
||||||
|
this.victoryCam = new VictoryCamDirector(this.raceState.seed ^ 0x5eed);
|
||||||
|
this.victoryCam.start(this.playerKartState());
|
||||||
|
this.victoryCamStartMs = this.raceState.timeMs;
|
||||||
|
this.showSkipPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
skipVictoryCam() {
|
||||||
|
if (!this.victoryCamActive) return;
|
||||||
|
this.raceState.phase = 'finished'; // the natural end-of-race path in update() takes it from here
|
||||||
|
}
|
||||||
|
|
||||||
|
showSkipPrompt() {
|
||||||
|
const btn = new Button(this, GAME_WIDTH - 150, GAME_HEIGHT - 60, 'SKIP ▶', () => this.skipVictoryCam(),
|
||||||
|
{ width: 220, height: 52, fontSize: 20, variant: 'ghost' });
|
||||||
|
btn.setDepth(D.banner).setAlpha(0);
|
||||||
|
this.tweens.add({ targets: btn, alpha: 1, duration: 300 });
|
||||||
|
const caption = this.add.text(GAME_WIDTH - 150, GAME_HEIGHT - 100, 'Waiting for the field to finish…', {
|
||||||
|
fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.banner).setAlpha(0);
|
||||||
|
this.tweens.add({ targets: caption, alpha: 1, duration: 300, delay: 200 });
|
||||||
|
this.skipPrompt = [btn, caption];
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSkipPrompt() {
|
||||||
|
for (const o of this.skipPrompt ?? []) o.destroy();
|
||||||
|
this.skipPrompt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects the player kart through the Mode-7 camera like an AI kart,
|
||||||
|
// instead of the normal screen-fixed rig — used only while victoryCamActive.
|
||||||
|
renderPlayerKartCinematic(kart) {
|
||||||
|
const s = this.playerSprite;
|
||||||
|
const frame = this.mode7.angleFrame(kart.spinMs > 0 ? kart.heading + kart.spinSpin : kart.heading, KART_ROT_FRAMES);
|
||||||
|
const p = this.mode7.project(kart.x, kart.y);
|
||||||
|
if (!p) { s.setVisible(false); return; }
|
||||||
|
s.setVisible(true).setAngle(0).setFrame(frame);
|
||||||
|
let scale = p.scale;
|
||||||
|
if (kart.empMs > 0) scale *= 0.55;
|
||||||
|
if (kart.squashMs > 0) s.setScale(scale * 1.25, scale * 0.45);
|
||||||
|
else s.setScale(scale);
|
||||||
|
s.setPosition(p.x, p.y - s.displayHeight * 0.42);
|
||||||
|
s.setDepth(D.world + Math.max(0, 800 - p.z));
|
||||||
|
s.setAlpha(kart.invulnMs > 0 ? (Math.floor(this.raceState.timeMs / 80) % 2 ? 0.35 : 1) : 1);
|
||||||
|
if (kart.starMs > 0) s.setTint(starTint(this.raceState.timeMs));
|
||||||
|
else s.clearTint();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPlayerKart(kart) {
|
renderPlayerKart(kart) {
|
||||||
const s = this.playerSprite;
|
const s = this.playerSprite;
|
||||||
|
s.setDepth(D.playerKart); // reset in case cinematic mode last touched depth
|
||||||
const sheet = this.textures.get(this.kartTexKey(kart.racer.id));
|
const sheet = this.textures.get(this.kartTexKey(kart.racer.id));
|
||||||
const hasLean = sheet.frameTotal > KART_ROT_FRAMES + 1;
|
const hasLean = sheet.frameTotal > KART_ROT_FRAMES + 1;
|
||||||
const inputs = this.readInputs();
|
const inputs = this.readInputs();
|
||||||
|
|
@ -818,6 +1002,22 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
|
|
||||||
// ── Events → sound + speech ───────────────────────────────────────────────
|
// ── Events → sound + speech ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Full volume for the player's own kart; for any other kart, volume falls
|
||||||
|
// off linearly with distance from the player, down to silent past maxDist.
|
||||||
|
playPositionalSound(key, kartIdx, maxDist = 1100) {
|
||||||
|
const state = this.raceState;
|
||||||
|
let volume = 1;
|
||||||
|
if (kartIdx !== state.playerIndex) {
|
||||||
|
const kart = state.karts[kartIdx];
|
||||||
|
const player = this.playerKartState();
|
||||||
|
if (!kart || !player) return;
|
||||||
|
const dist = Math.hypot(kart.x - player.x, kart.y - player.y);
|
||||||
|
volume = Phaser.Math.Clamp(1 - dist / maxDist, 0, 1);
|
||||||
|
if (volume <= 0.02) return;
|
||||||
|
}
|
||||||
|
try { this.sound.play(key, { volume }); } catch (_) { /* audio locked */ }
|
||||||
|
}
|
||||||
|
|
||||||
processEvents(events) {
|
processEvents(events) {
|
||||||
const state = this.raceState;
|
const state = this.raceState;
|
||||||
const pIdx = state.playerIndex;
|
const pIdx = state.playerIndex;
|
||||||
|
|
@ -826,19 +1026,44 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
case 'go': playSound(this, SFX.COUNTDOWN_GO); break;
|
case 'go': playSound(this, SFX.COUNTDOWN_GO); break;
|
||||||
case 'item-box': if (ev.kart === pIdx) playSound(this, SFX.UI_FLIP); 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-get': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break;
|
||||||
case 'item-use': if (ev.kart === pIdx) playSound(this, SFX.WOOSH); break;
|
case 'item-use':
|
||||||
case 'boost': if (ev.kart === pIdx) playSound(this, SFX.SCIFI_WOOSH); break;
|
if (ev.kart === pIdx) {
|
||||||
case 'star': playSound(this, SFX.EIGHTBIT_ACTIVATE); break;
|
playSound(this, SFX.WOOSH);
|
||||||
case 'emp': playSound(this, SFX.LASER_ZAP); break;
|
if (ev.item === 'turbo') playSound(this, SFX.ENGINE_REV);
|
||||||
case 'spin': if (ev.kart === pIdx) playSound(this, SFX.SQUISH); this.taunt(ev.kart, ev.kart === pIdx); break;
|
if (ev.item === 'bolt' || ev.item === 'seeker') playSound(this, SFX.KART_SHELL);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'boost':
|
||||||
|
if (ev.kart === pIdx) {
|
||||||
|
playSound(this, SFX.SCIFI_WOOSH);
|
||||||
|
if (ev.pad) playSound(this, SFX.ENGINE_REV);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'star':
|
||||||
|
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||||||
|
if (ev.kart === pIdx) this.startStarLoop();
|
||||||
|
break;
|
||||||
|
case 'emp':
|
||||||
|
playSound(this, SFX.LASER_ZAP);
|
||||||
|
for (let i = 0; i < (ev.targets ?? []).length; i += 1) playSound(this, SFX.KART_SHRINK);
|
||||||
|
break;
|
||||||
|
case 'squash': this.playPositionalSound(SFX.KART_FLATTEN, ev.kart); break;
|
||||||
|
case 'spin':
|
||||||
|
this.playPositionalSound(ev.cause === 'bolt' || ev.cause === 'seeker' ? SFX.KART_HIT : SFX.KART_SPIN, ev.kart);
|
||||||
|
this.taunt(ev.kart, ev.kart === pIdx);
|
||||||
|
break;
|
||||||
case 'wallhit': if (ev.kart === pIdx) playSound(this, SFX.EIGHTBIT_MOVE); break;
|
case '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 'bump': if (ev.a === pIdx || ev.b === pIdx) playSound(this, SFX.KART_THUMP); break;
|
||||||
case 'coin': if (ev.kart === pIdx) playSound(this, SFX.COINS); break;
|
case 'coin': if (ev.kart === pIdx) playSound(this, SFX.KART_COIN); break;
|
||||||
case 'lap': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break;
|
case 'lap': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break;
|
||||||
case 'splash': if (ev.kart === pIdx) playSound(this, 'sfx-water-splash'); break;
|
case 'splash': if (ev.kart === pIdx) playSound(this, 'sfx-water-splash'); break;
|
||||||
case 'shatter': playSound(this, SFX.EIGHTBIT_EXPLODE); break;
|
case 'shatter': playSound(this, SFX.EIGHTBIT_EXPLODE); break;
|
||||||
case 'finish':
|
case 'finish':
|
||||||
if (ev.kart === pIdx) playSound(this, ev.place <= 3 ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE);
|
if (ev.kart === pIdx) {
|
||||||
|
playSound(this, ev.place <= 3 ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE);
|
||||||
|
this.stopEngineSound();
|
||||||
|
if (state.mode !== 'tt') this.startVictoryCam();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'player-position':
|
case 'player-position':
|
||||||
if (ev.to > ev.from) this.tauntFromLeader(ev.to);
|
if (ev.to > ev.from) this.tauntFromLeader(ev.to);
|
||||||
|
|
@ -893,10 +1118,10 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
const cls = this.rules.engineClasses[this.chosen.classIdx];
|
const cls = this.rules.engineClasses[this.chosen.classIdx];
|
||||||
const player = this.playerKartState();
|
const player = this.playerKartState();
|
||||||
const cx = GAME_WIDTH / 2;
|
const cx = GAME_WIDTH / 2;
|
||||||
this.teardownRace();
|
|
||||||
this.clearView();
|
|
||||||
|
|
||||||
if (this.returnToEditor) {
|
if (this.returnToEditor) {
|
||||||
|
this.teardownRace();
|
||||||
|
this.clearView();
|
||||||
this.text(cx, 240, 'TEST COMPLETE', 64, '#ffd028');
|
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.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 })));
|
this.vAdd(new Button(this, cx, 460, 'Back to Editor', () => this.scene.start('SuperKartEditor', { resume: true })));
|
||||||
|
|
@ -904,6 +1129,8 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.mode === 'tt') {
|
if (state.mode === 'tt') {
|
||||||
|
this.teardownRace();
|
||||||
|
this.clearView();
|
||||||
const ms = player.finishTimeMs - COUNTDOWN_MS;
|
const ms = player.finishTimeMs - COUNTDOWN_MS;
|
||||||
const isRecord = this.recordTime(this.raceEntry.id, cls.id, 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, 180, isRecord ? 'NEW RECORD!' : 'RUN COMPLETE', 64, isRecord ? '#ffd028' : COLORS.textHex);
|
||||||
|
|
@ -917,76 +1144,257 @@ export default class SuperKartGame extends Phaser.Scene {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grand Prix race results + cumulative standings.
|
// GP: keep the race scene (ground/backdrop/karts + victory cam) alive
|
||||||
for (const r of results) this.gp.points[r.racerId] += r.points;
|
// behind the results/standings screens instead of tearing it down —
|
||||||
const lastRace = this.gp.raceIdx >= this.gp.cup.tracks.length - 1;
|
// teardownRace() happens later, at showPodium() or the next startRace().
|
||||||
|
this.clearView();
|
||||||
|
this.showRaceResultsScreen(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This race's results only (no cup totals — those are revealed, animated,
|
||||||
|
// on the next screen). Disappears after a fixed 5s pause.
|
||||||
|
showRaceResultsScreen(results) {
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const playerId = this.playerRacer().id;
|
||||||
|
const playerResult = results.find((r) => r.racerId === playerId);
|
||||||
|
|
||||||
|
this.scrim();
|
||||||
this.text(cx, 110, `${this.raceEntry.name} — RESULTS`, 48, '#ffd028');
|
this.text(cx, 110, `${this.raceEntry.name} — RESULTS`, 48, '#ffd028');
|
||||||
const header = (x, s) => this.text(x, 180, s, 22, COLORS.mutedHex);
|
const header = (x, s) => this.text(x, 180, s, 22, COLORS.mutedHex);
|
||||||
header(cx - 420, 'POS');
|
header(cx - 380, 'POS');
|
||||||
header(cx - 240, 'RACER');
|
header(cx - 200, 'RACER');
|
||||||
header(cx + 90, 'TIME');
|
header(cx + 140, 'TIME');
|
||||||
header(cx + 260, 'PTS');
|
header(cx + 340, 'PTS');
|
||||||
header(cx + 400, 'TOTAL');
|
|
||||||
results.forEach((r, i) => {
|
results.forEach((r, i) => {
|
||||||
const y = 226 + i * 52;
|
const y = 226 + i * 52;
|
||||||
const racer = this.racers.find((rc) => rc.id === r.racerId);
|
const isP = r.racerId === playerId;
|
||||||
const isP = r.racerId === this.playerRacer().id;
|
|
||||||
const color = isP ? '#ffd028' : COLORS.textHex;
|
const color = isP ? '#ffd028' : COLORS.textHex;
|
||||||
this.text(cx - 420, y, String(r.position), 28, color);
|
const row = this.add.container(0, y).setDepth(D.view).setAlpha(0);
|
||||||
|
this.vAdd(row);
|
||||||
|
const rowText = (x, s, size, origin = 0.5) => {
|
||||||
|
const t = this.add.text(x, 0, s, { fontFamily: FONT, fontSize: `${size}px`, color }).setOrigin(origin);
|
||||||
|
row.add(t);
|
||||||
|
};
|
||||||
|
rowText(cx - 380, String(r.position), 28);
|
||||||
if (this.textures.exists('opponents')) {
|
if (this.textures.exists('opponents')) {
|
||||||
this.vAdd(this.add.image(cx - 330, y, 'opponents', this.opponentById[r.racerId]?.spriteIndex ?? 0)
|
row.add(this.add.image(cx - 290, 0, 'opponents', this.opponentById[r.racerId]?.spriteIndex ?? 0)
|
||||||
.setDisplaySize(44, 44).setDepth(D.view));
|
.setDisplaySize(44, 44));
|
||||||
}
|
}
|
||||||
this.text(cx - 240, y, (this.opponentById[r.racerId]?.name ?? r.racerId).toUpperCase(), 26, color, { x: 0, y: 0.5 });
|
rowText(cx - 200, (this.opponentById[r.racerId]?.name ?? r.racerId).toUpperCase(), 26, { x: 0, y: 0.5 });
|
||||||
this.text(cx + 90, y, r.finished ? fmtTime(r.timeMs - COUNTDOWN_MS) : '—', 24, color);
|
rowText(cx + 140, r.finished ? fmtTime(r.timeMs - COUNTDOWN_MS) : '—', 24);
|
||||||
this.text(cx + 260, y, `+${r.points}`, 24, color);
|
rowText(cx + 340, `+${r.points}`, 24);
|
||||||
this.text(cx + 400, y, String(this.gp.points[r.racerId]), 26, color);
|
this.tweens.add({
|
||||||
void racer;
|
targets: row, alpha: 1, duration: 260, delay: i * 90, ease: 'Cubic.easeOut',
|
||||||
|
onComplete: () => { if (isP && r.position <= 3) this.crt?.pulse(0.3, 100); },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
this.vAdd(new Button(this, cx, GAME_HEIGHT - 90, lastRace ? 'Final Standings' : 'Next Race', () => {
|
|
||||||
if (lastRace) this.showPodium();
|
if (playerResult && playerResult.position <= 3) playSound(this, SFX.FIREWORK);
|
||||||
else { this.gp.raceIdx += 1; this.startCupRace(); }
|
|
||||||
}, { width: 340 }));
|
this.time.delayedCall(5000, () => {
|
||||||
|
this.clearView();
|
||||||
|
this.showCupStandings(results);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated cup-standings re-rank: draws rows in their PRE-race order, then
|
||||||
|
// tweens each row (position + running point total) to its post-race slot.
|
||||||
|
// This is the only place this.gp.points gets mutated for this race.
|
||||||
|
showCupStandings(results) {
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const rowH = 62;
|
||||||
|
const topY = 220;
|
||||||
|
const playerId = this.playerRacer().id;
|
||||||
|
|
||||||
|
const before = Object.entries(this.gp.points).sort((a, b) => b[1] - a[1]).map(([id]) => id);
|
||||||
|
const prevPoints = { ...this.gp.points };
|
||||||
|
for (const r of results) this.gp.points[r.racerId] += r.points;
|
||||||
|
const after = Object.entries(this.gp.points).sort((a, b) => b[1] - a[1]).map(([id]) => id);
|
||||||
|
|
||||||
|
this.scrim();
|
||||||
|
this.text(cx, 100, `${this.gp.cup.name} — STANDINGS`, 44, '#ffd028');
|
||||||
|
const header = (x, s) => this.text(x, topY - 40, s, 20, COLORS.mutedHex);
|
||||||
|
header(cx - 380, 'POS');
|
||||||
|
header(cx - 260, 'RACER');
|
||||||
|
header(cx + 340, 'PTS');
|
||||||
|
playSound(this, SFX.SCIFI_RISER);
|
||||||
|
|
||||||
|
const rows = {};
|
||||||
|
before.forEach((id, i) => {
|
||||||
|
const isP = id === playerId;
|
||||||
|
const color = isP ? '#ffd028' : COLORS.textHex;
|
||||||
|
const row = this.add.container(0, topY + i * rowH).setDepth(D.view);
|
||||||
|
this.vAdd(row);
|
||||||
|
const posText = this.add.text(cx - 380, 0, String(i + 1), { fontFamily: FONT, fontSize: '26px', color }).setOrigin(0.5);
|
||||||
|
const name = this.add.text(cx - 260, 0, (this.opponentById[id]?.name ?? id).toUpperCase(), {
|
||||||
|
fontFamily: FONT, fontSize: '24px', color,
|
||||||
|
}).setOrigin(0, 0.5);
|
||||||
|
const counter = { v: prevPoints[id] };
|
||||||
|
const ptsText = this.add.text(cx + 340, 0, String(Math.round(counter.v)), {
|
||||||
|
fontFamily: FONT, fontSize: '26px', color,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
const arrow = this.add.text(cx + 290, 0, '', { fontFamily: FONT, fontSize: '22px', color: '#38b048' })
|
||||||
|
.setOrigin(0.5).setAlpha(0);
|
||||||
|
row.add([posText, name, ptsText, arrow]);
|
||||||
|
rows[id] = {
|
||||||
|
row, posText, ptsText, arrow, counter,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let pending = before.length * 2; // row-move + count-up per racer
|
||||||
|
let done = false;
|
||||||
|
const settleRow = (id) => {
|
||||||
|
pending -= 1;
|
||||||
|
if (id === playerId) this.crt?.pulse(0.4, 150);
|
||||||
|
if (pending === 0 && !done) {
|
||||||
|
done = true;
|
||||||
|
playSound(this, SFX.UI_CHIME);
|
||||||
|
this.showStandingsButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.time.delayedCall(700, () => {
|
||||||
|
const animMs = 900;
|
||||||
|
const tickCounter = { v: 0 };
|
||||||
|
const tickTimer = this.time.addEvent({
|
||||||
|
delay: 70,
|
||||||
|
loop: true,
|
||||||
|
callback: () => {
|
||||||
|
const p = Math.min(1, tickCounter.v);
|
||||||
|
try { this.sound.play(SFX.SCIFI_PLINK, { rate: 0.9 + 1.3 * p, volume: 0.5 }); } catch (_) { /* audio locked */ }
|
||||||
|
if (p >= 1) tickTimer.remove();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.tweens.add({ targets: tickCounter, v: 1, duration: animMs });
|
||||||
|
|
||||||
|
before.forEach((id, i) => {
|
||||||
|
const newIdx = after.indexOf(id);
|
||||||
|
const targetY = topY + newIdx * rowH;
|
||||||
|
const delta = i - newIdx; // positive = moved up toward 1st
|
||||||
|
const r = rows[id];
|
||||||
|
if (delta !== 0) {
|
||||||
|
r.arrow.setText(delta > 0 ? '▲' : '▼').setColor(delta > 0 ? '#38b048' : '#e06c75').setAlpha(0);
|
||||||
|
this.tweens.add({ targets: r.arrow, alpha: 1, duration: 200, delay: 150 });
|
||||||
|
}
|
||||||
|
this.tweens.add({
|
||||||
|
targets: r.row, y: targetY, duration: animMs, ease: 'Cubic.easeInOut',
|
||||||
|
onComplete: () => {
|
||||||
|
r.posText.setText(String(newIdx + 1));
|
||||||
|
if (r.arrow.alpha > 0) this.tweens.add({ targets: r.arrow, alpha: 0, duration: 400, delay: 400 });
|
||||||
|
settleRow(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.tweens.add({
|
||||||
|
targets: r.counter, v: this.gp.points[id], duration: animMs, ease: 'Cubic.easeOut',
|
||||||
|
onUpdate: () => r.ptsText.setText(String(Math.round(r.counter.v))),
|
||||||
|
onComplete: () => settleRow(id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showStandingsButton() {
|
||||||
|
const lastRace = this.gp.raceIdx >= this.gp.cup.tracks.length - 1;
|
||||||
|
const btn = this.vAdd(new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 90,
|
||||||
|
lastRace ? 'Final Standings' : 'Next Race', () => {
|
||||||
|
if (lastRace) this.showPodium();
|
||||||
|
else { this.gp.raceIdx += 1; this.startCupRace(); }
|
||||||
|
}, { width: 340 }));
|
||||||
|
btn.setAlpha(0);
|
||||||
|
this.tweens.add({ targets: btn, alpha: 1, duration: 300 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small outward-bursting rectangle particles — shared by showCupStandings'
|
||||||
|
// player-row payoff and showPodium's reveals. Self-destroying, not tracked
|
||||||
|
// via vAdd since they're a short-lived effect, not a view object.
|
||||||
|
confetti(x, y, tint = 0xffd028) {
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
const piece = this.add.rectangle(x, y, 6, 10, tint).setDepth(D.banner).setAngle(Math.random() * 360);
|
||||||
|
const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9;
|
||||||
|
const dist = 60 + Math.random() * 90;
|
||||||
|
this.tweens.add({
|
||||||
|
targets: piece,
|
||||||
|
x: x + Math.cos(angle) * dist,
|
||||||
|
y: y + Math.sin(angle) * dist + 40,
|
||||||
|
angle: piece.angle + (Math.random() - 0.5) * 480,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 700 + Math.random() * 400,
|
||||||
|
ease: 'Cubic.easeOut',
|
||||||
|
onComplete: () => piece.destroy(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showPodium() {
|
showPodium() {
|
||||||
|
// The cup-final screen goes back to the static menu background rather
|
||||||
|
// than the live race scene — this is where that finally gets torn down
|
||||||
|
// (results/standings kept it alive for the victory-cam backdrop).
|
||||||
|
this.teardownRace();
|
||||||
this.clearView();
|
this.clearView();
|
||||||
const cx = GAME_WIDTH / 2;
|
const cx = GAME_WIDTH / 2;
|
||||||
const standings = Object.entries(this.gp.points)
|
const standings = Object.entries(this.gp.points).sort((a, b) => b[1] - a[1]);
|
||||||
.sort((a, b) => b[1] - a[1]);
|
|
||||||
const playerId = this.playerRacer().id;
|
const playerId = this.playerRacer().id;
|
||||||
const playerPlace = standings.findIndex(([id]) => id === playerId) + 1;
|
const playerPlace = standings.findIndex(([id]) => id === playerId) + 1;
|
||||||
|
|
||||||
this.text(cx, 100, `${this.gp.cup.name} — FINAL STANDINGS`, 48, '#ffd028');
|
this.text(cx, 100, `${this.gp.cup.name} — FINAL STANDINGS`, 48, '#ffd028');
|
||||||
this.text(cx, 170, playerPlace === 1 ? 'CHAMPION!' : `YOU FINISHED ${ordinal(playerPlace).toUpperCase()}`, 34,
|
|
||||||
playerPlace === 1 ? '#ffd028' : COLORS.textHex);
|
|
||||||
|
|
||||||
// Podium top 3 with live portraits (video/mood if assets exist).
|
// Podium top 3 with live portraits (video/mood if assets exist). Revealed
|
||||||
|
// 3rd → 2nd → 1st, staggered, for a bit of suspense before the winner.
|
||||||
const podX = [cx, cx - 330, cx + 330];
|
const podX = [cx, cx - 330, cx + 330];
|
||||||
const podY = [340, 400, 430];
|
const podY = [340, 400, 430];
|
||||||
standings.slice(0, 3).forEach(([id], i) => {
|
const tintFor = [0xffd028, 0xc8ccd4, 0xcd8a4a]; // gold / silver / bronze
|
||||||
|
|
||||||
|
const revealSlot = (i) => {
|
||||||
|
const [id, pts] = standings[i];
|
||||||
const op = this.opponentById[id];
|
const op = this.opponentById[id];
|
||||||
|
const isP = id === playerId;
|
||||||
if (op) {
|
if (op) {
|
||||||
const portrait = createOpponentPortrait(this, op, podX[i], podY[i], i === 0 ? 110 : 85, D.view, { playIntro: false });
|
const portrait = createOpponentPortrait(this, op, podX[i], podY[i], i === 0 ? 110 : 85, D.view, { playIntro: false });
|
||||||
this.viewObjs.push(portrait);
|
this.viewObjs.push(portrait);
|
||||||
|
portrait.setAlpha?.(0);
|
||||||
|
this.tweens.add({ targets: portrait, alpha: 1, duration: 260 });
|
||||||
}
|
}
|
||||||
this.text(podX[i], podY[i] + (i === 0 ? 150 : 125), `${i + 1}. ${(op?.name ?? id).toUpperCase()}`, 28,
|
const label = this.text(podX[i], podY[i] + (i === 0 ? 150 : 125), `${i + 1}. ${(op?.name ?? id).toUpperCase()}`, 28,
|
||||||
id === playerId ? '#ffd028' : COLORS.textHex);
|
isP ? '#ffd028' : COLORS.textHex).setAlpha(0);
|
||||||
this.text(podX[i], podY[i] + (i === 0 ? 186 : 161), `${this.gp.points[id]} PTS`, 22, COLORS.mutedHex);
|
this.tweens.add({ targets: label, alpha: 1, duration: 260 });
|
||||||
});
|
const counter = { v: 0 };
|
||||||
const winner = this.opponentById[standings[0][0]];
|
const ptsText = this.text(podX[i], podY[i] + (i === 0 ? 186 : 161), '0 PTS', 22, COLORS.mutedHex).setAlpha(0);
|
||||||
const clip = winner?.speech?.happy?.[0];
|
this.tweens.add({ targets: ptsText, alpha: 1, duration: 200 });
|
||||||
if (clip) { resetQueue(); enqueueSpeech(clip); }
|
this.tweens.add({
|
||||||
|
targets: counter, v: pts, duration: 700, ease: 'Cubic.easeOut',
|
||||||
|
onUpdate: () => ptsText.setText(`${Math.round(counter.v)} PTS`),
|
||||||
|
});
|
||||||
|
this.confetti(podX[i], podY[i], tintFor[i]);
|
||||||
|
playSound(this, SFX.SCIFI_REVEAL);
|
||||||
|
if (i === 0) {
|
||||||
|
this.crt?.pulse(0.6, 200);
|
||||||
|
playSound(this, playerPlace === 1 ? SFX.CASINO_WIN : SFX.VICTORY_SHORT);
|
||||||
|
const subtitle = this.text(cx, 170, playerPlace === 1 ? 'CHAMPION!' : `YOU FINISHED ${ordinal(playerPlace).toUpperCase()}`,
|
||||||
|
34, playerPlace === 1 ? '#ffd028' : COLORS.textHex).setAlpha(0);
|
||||||
|
this.tweens.add({ targets: subtitle, alpha: 1, duration: 300 });
|
||||||
|
const winner = this.opponentById[id];
|
||||||
|
const clip = winner?.speech?.happy?.[0];
|
||||||
|
if (clip) { resetQueue(); enqueueSpeech(clip); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
standings.slice(3).forEach(([id, pts], i) => {
|
[2, 1, 0].forEach((podiumIdx, k) => this.time.delayedCall(k * 900, () => revealSlot(podiumIdx)));
|
||||||
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.time.delayedCall(2700, () => {
|
||||||
this.vAdd(new Button(this, cx - 180, GAME_HEIGHT - 70, 'Race Again', () => this.showCupSelect(), { width: 260 }));
|
standings.slice(3).forEach(([id, pts], i) => {
|
||||||
this.vAdd(new Button(this, cx + 180, GAME_HEIGHT - 70, 'Main Menu', () => this.showMenu(), { width: 260, variant: 'ghost' }));
|
const t = this.text(cx - 200 + Math.floor(i / 3) * 400, 660 + (i % 3) * 40,
|
||||||
|
`${i + 4}. ${(this.opponentById[id]?.name ?? id).toUpperCase()} ${pts} PTS`, 22,
|
||||||
|
id === playerId ? '#ffd028' : COLORS.mutedHex).setAlpha(0);
|
||||||
|
this.tweens.add({ targets: t, alpha: 1, duration: 220, delay: i * 40 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn1 = this.vAdd(new Button(this, cx - 180, GAME_HEIGHT - 70, 'Race Again', () => this.showCupSelect(), { width: 260 }));
|
||||||
|
const btn2 = this.vAdd(new Button(this, cx + 180, GAME_HEIGHT - 70, 'Main Menu', () => this.showMenu(), { width: 260, variant: 'ghost' }));
|
||||||
|
btn1.setAlpha(0);
|
||||||
|
btn2.setAlpha(0);
|
||||||
|
this.tweens.add({ targets: [btn1, btn2], alpha: 1, duration: 300, delay: 300 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import {
|
||||||
const TAU = Math.PI * 2;
|
const TAU = Math.PI * 2;
|
||||||
export const STEP_MS = 1000 / 60;
|
export const STEP_MS = 1000 / 60;
|
||||||
export const COUNTDOWN_MS = 3400;
|
export const COUNTDOWN_MS = 3400;
|
||||||
|
// Once the player finishes, the field is given this long (sim time) to finish
|
||||||
|
// too before the race is forced to end — a safety net against a stuck AI,
|
||||||
|
// not the normal path (state.karts.every(finished) ends it sooner).
|
||||||
|
const POST_FINISH_SAFETY_MS = 90000;
|
||||||
|
|
||||||
export function mulberry32(seed) {
|
export function mulberry32(seed) {
|
||||||
let a = seed >>> 0;
|
let a = seed >>> 0;
|
||||||
|
|
@ -248,7 +252,10 @@ function aiSkillOf(state, kart) {
|
||||||
|
|
||||||
function computeAiInputs(state, kart) {
|
function computeAiInputs(state, kart) {
|
||||||
const inputs = kartInputsNeutral();
|
const inputs = kartInputsNeutral();
|
||||||
if (kart.finished && state.phase === 'finished') return inputs;
|
// Finished karts (including the autopiloted player) keep actively driving
|
||||||
|
// laps through the results/standings screens rather than coasting to a
|
||||||
|
// stop — the scene only stops stepping this state once it tears the race
|
||||||
|
// down (next race start, or the cup-final podium).
|
||||||
const { model, physics } = state;
|
const { model, physics } = state;
|
||||||
const skill = aiSkillOf(state, kart);
|
const skill = aiSkillOf(state, kart);
|
||||||
|
|
||||||
|
|
@ -653,13 +660,15 @@ function useItem(state, kart) {
|
||||||
emit(state, 'star', { kart: kart.index });
|
emit(state, 'star', { kart: kart.index });
|
||||||
break;
|
break;
|
||||||
case 'emp': {
|
case 'emp': {
|
||||||
|
const targets = [];
|
||||||
for (const other of state.karts) {
|
for (const other of state.karts) {
|
||||||
if (other === kart || other.finished) continue;
|
if (other === kart || other.finished) continue;
|
||||||
if (other.starMs > 0 || other.invulnMs > 0) continue;
|
if (other.starMs > 0 || other.invulnMs > 0) continue;
|
||||||
other.empMs = (params.ms ?? 4200) + other.racer.stats.weight * (params.weightExtraMs ?? 1600);
|
other.empMs = (params.ms ?? 4200) + other.racer.stats.weight * (params.weightExtraMs ?? 1600);
|
||||||
other.drifting = false;
|
other.drifting = false;
|
||||||
|
targets.push(other.index);
|
||||||
}
|
}
|
||||||
emit(state, 'emp', { kart: kart.index });
|
emit(state, 'emp', { kart: kart.index, targets });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'coins':
|
case 'coins':
|
||||||
|
|
@ -768,8 +777,13 @@ function stepCollisions(state) {
|
||||||
if (a.starMs > 0 && b.starMs <= 0) startSpin(state, b, 'star');
|
if (a.starMs > 0 && b.starMs <= 0) startSpin(state, b, 'star');
|
||||||
else if (b.starMs > 0 && a.starMs <= 0) startSpin(state, a, 'star');
|
else if (b.starMs > 0 && a.starMs <= 0) startSpin(state, a, 'star');
|
||||||
// A heavy kart running over an EMP-shrunk one squashes it.
|
// A heavy kart running over an EMP-shrunk one squashes it.
|
||||||
if (a.empMs > 0 && b.empMs <= 0 && b.squashMs <= 0 && a.squashMs <= 0) a.squashMs = physics.squashMs;
|
if (a.empMs > 0 && b.empMs <= 0 && b.squashMs <= 0 && a.squashMs <= 0) {
|
||||||
else if (b.empMs > 0 && a.empMs <= 0 && a.squashMs <= 0 && b.squashMs <= 0) b.squashMs = physics.squashMs;
|
a.squashMs = physics.squashMs;
|
||||||
|
emit(state, 'squash', { kart: a.index });
|
||||||
|
} else if (b.empMs > 0 && a.empMs <= 0 && a.squashMs <= 0 && b.squashMs <= 0) {
|
||||||
|
b.squashMs = physics.squashMs;
|
||||||
|
emit(state, 'squash', { kart: b.index });
|
||||||
|
}
|
||||||
emit(state, 'bump', { a: a.index, b: b.index });
|
emit(state, 'bump', { a: a.index, b: b.index });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -809,11 +823,15 @@ export function step(state, playerInputs = kartInputsNeutral()) {
|
||||||
|
|
||||||
for (const kart of state.karts) {
|
for (const kart of state.karts) {
|
||||||
// Fresh racing line each lap keeps the AI field from single-filing.
|
// Fresh racing line each lap keeps the AI field from single-filing.
|
||||||
if (!kart.isPlayer && kart.lap !== kart.aiLaneLap) {
|
// (Also applies once the player's kart is finished and autopiloted, so
|
||||||
|
// it doesn't robotically hug the centerline during the victory lap.)
|
||||||
|
if ((!kart.isPlayer || kart.finished) && kart.lap !== kart.aiLaneLap) {
|
||||||
kart.aiLaneLap = kart.lap;
|
kart.aiLaneLap = kart.lap;
|
||||||
kart.aiLane = (state.rng() * 2 - 1) * 0.5;
|
kart.aiLane = (state.rng() * 2 - 1) * 0.5;
|
||||||
}
|
}
|
||||||
const inputs = kart.isPlayer ? playerInputs : computeAiInputs(state, kart);
|
// A finished player kart is handed off to the AI driver for the post-race
|
||||||
|
// vignette — computeAiInputs already works for any kart unmodified.
|
||||||
|
const inputs = (kart.isPlayer && !kart.finished) ? playerInputs : computeAiInputs(state, kart);
|
||||||
stepKart(state, kart, inputs, dt);
|
stepKart(state, kart, inputs, dt);
|
||||||
}
|
}
|
||||||
stepCollisions(state);
|
stepCollisions(state);
|
||||||
|
|
@ -827,9 +845,11 @@ export function step(state, playerInputs = kartInputsNeutral()) {
|
||||||
} else if (state.karts.every((k) => k.finished)) {
|
} else if (state.karts.every((k) => k.finished)) {
|
||||||
state.phase = 'finished';
|
state.phase = 'finished';
|
||||||
} else if (player?.finished) {
|
} else if (player?.finished) {
|
||||||
// Player done: let the field coast briefly, then call it.
|
// Player done: wait for the rest of the field to finish too (the
|
||||||
|
// victory-lap vignette plays during this window), with a generous
|
||||||
|
// safety cap in case an AI kart never gets home.
|
||||||
state.postFinishMs = (state.postFinishMs ?? 0) + STEP_MS;
|
state.postFinishMs = (state.postFinishMs ?? 0) + STEP_MS;
|
||||||
if (state.postFinishMs > 3500) state.phase = 'finished';
|
if (state.postFinishMs > POST_FINISH_SAFETY_MS) state.phase = 'finished';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,9 @@ export class SuperKartMode7 {
|
||||||
this.horizonY = Math.round(GAME_HEIGHT * MODE7.horizonFrac);
|
this.horizonY = Math.round(GAME_HEIGHT * MODE7.horizonFrac);
|
||||||
const groundH = GAME_HEIGHT - this.horizonY;
|
const groundH = GAME_HEIGHT - this.horizonY;
|
||||||
|
|
||||||
this.cam = { x: worldSize / 2, y: worldSize / 2, angle: 0 };
|
this.cam = {
|
||||||
|
x: worldSize / 2, y: worldSize / 2, angle: 0, height: MODE7.camHeight, focal: MODE7.focal,
|
||||||
|
};
|
||||||
|
|
||||||
const base = new Phaser.Display.BaseShader('SuperKartMode7', FRAG, undefined, {
|
const base = new Phaser.Display.BaseShader('SuperKartMode7', FRAG, undefined, {
|
||||||
uQuad: { type: '2f', value: { x: GAME_WIDTH, y: groundH } },
|
uQuad: { type: '2f', value: { x: GAME_WIDTH, y: groundH } },
|
||||||
|
|
@ -103,6 +105,9 @@ export class SuperKartMode7 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow camera: smoothly chase the kart's heading, sit followDist behind.
|
// Follow camera: smoothly chase the kart's heading, sit followDist behind.
|
||||||
|
// Always resets height/focal to the standard values, so normal gameplay
|
||||||
|
// stays identical regardless of what a prior cinematic camera pose left
|
||||||
|
// this.cam in (see setCamera()).
|
||||||
follow(kart, snap = false) {
|
follow(kart, snap = false) {
|
||||||
const lerp = snap ? 1 : MODE7.camLerp;
|
const lerp = snap ? 1 : MODE7.camLerp;
|
||||||
let diff = (kart.heading - this.cam.angle) % TAU;
|
let diff = (kart.heading - this.cam.angle) % TAU;
|
||||||
|
|
@ -113,14 +118,19 @@ export class SuperKartMode7 {
|
||||||
const fy = Math.sin(this.cam.angle);
|
const fy = Math.sin(this.cam.angle);
|
||||||
this.cam.x = kart.x - fx * MODE7.followDist;
|
this.cam.x = kart.x - fx * MODE7.followDist;
|
||||||
this.cam.y = kart.y - fy * MODE7.followDist;
|
this.cam.y = kart.y - fy * MODE7.followDist;
|
||||||
|
this.cam.height = MODE7.camHeight;
|
||||||
|
this.cam.focal = MODE7.focal;
|
||||||
this.syncUniforms();
|
this.syncUniforms();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free-fly debug camera (arrow keys in the scene's debug mode).
|
// Arbitrary camera pose — used by the post-race victory-cam director for
|
||||||
setCamera(x, y, angle) {
|
// cinematic shots (also the free-fly debug camera hook).
|
||||||
|
setCamera(x, y, angle, height = MODE7.camHeight, focal = MODE7.focal) {
|
||||||
this.cam.x = x;
|
this.cam.x = x;
|
||||||
this.cam.y = y;
|
this.cam.y = y;
|
||||||
this.cam.angle = angle;
|
this.cam.angle = angle;
|
||||||
|
this.cam.height = height;
|
||||||
|
this.cam.focal = focal;
|
||||||
this.syncUniforms();
|
this.syncUniforms();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,6 +139,8 @@ export class SuperKartMode7 {
|
||||||
u.uCam.value.x = this.cam.x;
|
u.uCam.value.x = this.cam.x;
|
||||||
u.uCam.value.y = this.cam.y;
|
u.uCam.value.y = this.cam.y;
|
||||||
u.uAngle.value = this.cam.angle;
|
u.uAngle.value = this.cam.angle;
|
||||||
|
u.uCamH.value = this.cam.height;
|
||||||
|
u.uFocal.value = this.cam.focal;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFlipY(flip) {
|
setFlipY(flip) {
|
||||||
|
|
@ -148,12 +160,12 @@ export class SuperKartMode7 {
|
||||||
const zc = dx * fx + dy * fy;
|
const zc = dx * fx + dy * fy;
|
||||||
if (zc < MODE7.nearZ || zc > MODE7.farZ) return null;
|
if (zc < MODE7.nearZ || zc > MODE7.farZ) return null;
|
||||||
const xc = dx * rx + dy * ry;
|
const xc = dx * rx + dy * ry;
|
||||||
const px = GAME_WIDTH / 2 + (xc * MODE7.focal) / zc;
|
const px = GAME_WIDTH / 2 + (xc * this.cam.focal) / zc;
|
||||||
if (px < -160 || px > GAME_WIDTH + 160) return null;
|
if (px < -160 || px > GAME_WIDTH + 160) return null;
|
||||||
return {
|
return {
|
||||||
x: px,
|
x: px,
|
||||||
y: this.horizonY + (MODE7.camHeight * MODE7.focal) / zc,
|
y: this.horizonY + (this.cam.height * this.cam.focal) / zc,
|
||||||
scale: (MODE7.spriteBase / 64) * (MODE7.focal / zc),
|
scale: (MODE7.spriteBase / 64) * (this.cam.focal / zc),
|
||||||
z: zc,
|
z: zc,
|
||||||
fog: Phaser.Math.Clamp((zc - MODE7.fogStart) / (MODE7.fogEnd - MODE7.fogStart), 0, 1),
|
fog: Phaser.Math.Clamp((zc - MODE7.fogStart) / (MODE7.fogEnd - MODE7.fogStart), 0, 1),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
// Post-race victory-cam shot director: pure camera-choreography math, no
|
||||||
|
// Phaser dependencies. Companion to SuperKartMode7.js (the camera
|
||||||
|
// *mechanism* — an arbitrary x/y/angle/height/focal pose) — this module is
|
||||||
|
// the camera *choreography*: given elapsed time and the AI-autopiloted
|
||||||
|
// player kart's live world pose, it picks which of a handful of cinematic
|
||||||
|
// shots is playing and returns the pose to feed straight into
|
||||||
|
// SuperKartMode7#setCamera(x, y, angle, height, focal).
|
||||||
|
//
|
||||||
|
// Shots hard-cut (no cross-fade/blend) every couple of seconds, in a
|
||||||
|
// shuffled order that's reshuffled each time it's exhausted, always opening
|
||||||
|
// on the gentlest shot ('chase'). Distances/heights/focal below are
|
||||||
|
// deliberately hand-tuned starting points — expect to retune by eye.
|
||||||
|
|
||||||
|
import { mulberry32 } from './SuperKartLogic.js';
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
function shuffle(arr, rng) {
|
||||||
|
const a = arr.slice();
|
||||||
|
for (let i = a.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aimAt(camX, camY, kart) {
|
||||||
|
return Math.atan2(kart.y - camY, kart.x - camX);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOTS = {
|
||||||
|
// Pulled-back, elevated version of the normal chase cam — a graceful
|
||||||
|
// establishing shot of the kart cruising its victory lap.
|
||||||
|
chase: {
|
||||||
|
ms: 2400,
|
||||||
|
begin: () => ({}),
|
||||||
|
compute: (t, ms, kart) => {
|
||||||
|
const dist = 92;
|
||||||
|
const fx = Math.cos(kart.heading);
|
||||||
|
const fy = Math.sin(kart.heading);
|
||||||
|
return {
|
||||||
|
x: kart.x - fx * dist, y: kart.y - fy * dist, angle: kart.heading, height: 88, focal: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Camera orbits the kart's live position at a fixed radius, sweeping
|
||||||
|
// ~200° over the shot, always aimed back at the kart.
|
||||||
|
orbit: {
|
||||||
|
ms: 2600,
|
||||||
|
begin: (kart, rng) => ({ startAngle: rng() * TAU, dir: rng() < 0.5 ? 1 : -1 }),
|
||||||
|
compute: (t, ms, kart, anchor) => {
|
||||||
|
const radius = 72;
|
||||||
|
const sweep = Math.PI * 1.15 * anchor.dir;
|
||||||
|
const orbitAngle = anchor.startAngle + (t / ms) * sweep;
|
||||||
|
const x = kart.x + Math.cos(orbitAngle) * radius;
|
||||||
|
const y = kart.y + Math.sin(orbitAngle) * radius;
|
||||||
|
return {
|
||||||
|
x, y, angle: aimAt(x, y, kart), height: 22, focal: 300,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Low, wide-lens shot from ahead of the kart on its current heading,
|
||||||
|
// looking back — the kart looms toward camera as it approaches. Recomputed
|
||||||
|
// fresh every frame from the kart's live heading so it can't freeze into a
|
||||||
|
// near-plane pop as the kart turns.
|
||||||
|
front: {
|
||||||
|
ms: 2000,
|
||||||
|
begin: () => ({}),
|
||||||
|
compute: (t, ms, kart) => {
|
||||||
|
const aheadDist = 260;
|
||||||
|
const x = kart.x + Math.cos(kart.heading) * aheadDist;
|
||||||
|
const y = kart.y + Math.sin(kart.heading) * aheadDist;
|
||||||
|
return {
|
||||||
|
x, y, angle: aimAt(x, y, kart), height: 14, focal: 250,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Classic fixed trackside camera: a point off to the side of the track,
|
||||||
|
// snapshotted once when the shot begins (pulled slightly behind the kart
|
||||||
|
// so it visibly approaches, passes, and recedes), continuously re-aimed at
|
||||||
|
// the kart as it drives by.
|
||||||
|
trackside: {
|
||||||
|
ms: 2300,
|
||||||
|
begin: (kart, rng) => {
|
||||||
|
const side = rng() < 0.5 ? 1 : -1;
|
||||||
|
const perp = kart.heading + Math.PI / 2;
|
||||||
|
const sideOffset = 150 * side;
|
||||||
|
const behindPull = 90;
|
||||||
|
return {
|
||||||
|
x: kart.x + Math.cos(perp) * sideOffset - Math.cos(kart.heading) * behindPull,
|
||||||
|
y: kart.y + Math.sin(perp) * sideOffset - Math.sin(kart.heading) * behindPull,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
compute: (t, ms, kart, anchor) => ({
|
||||||
|
x: anchor.x, y: anchor.y, angle: aimAt(anchor.x, anchor.y, kart), height: 30, focal: undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHOT_POOL = ['chase', 'orbit', 'front', 'trackside'];
|
||||||
|
|
||||||
|
export class VictoryCamDirector {
|
||||||
|
constructor(seed = Date.now()) {
|
||||||
|
this.rng = mulberry32(seed >>> 0);
|
||||||
|
this.queue = ['chase']; // always open on the gentlest shot
|
||||||
|
this.curIdx = 0;
|
||||||
|
this.curStartMs = 0;
|
||||||
|
this.anchor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(kart) {
|
||||||
|
this.curIdx = 0;
|
||||||
|
this.curStartMs = 0;
|
||||||
|
this.anchor = SHOTS[this.queue[0]].begin(kart, this.rng);
|
||||||
|
this.justCut = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// elapsedMs: time since start(). kart: live player kart sim state
|
||||||
|
// ({x, y, heading, ...}). Returns { x, y, angle, height, focal }.
|
||||||
|
// Sets this.justCut = true for the one update() call a hard cut lands on,
|
||||||
|
// so the scene can fire a stinger effect (screen flash/shake) on the cut.
|
||||||
|
update(elapsedMs, kart) {
|
||||||
|
this.justCut = false;
|
||||||
|
let def = SHOTS[this.queue[this.curIdx]];
|
||||||
|
let localT = elapsedMs - this.curStartMs;
|
||||||
|
while (localT >= def.ms) {
|
||||||
|
if (this.queue.length - (this.curIdx + 1) <= 0) this.queue.push(...shuffle(SHOT_POOL, this.rng));
|
||||||
|
this.curIdx += 1;
|
||||||
|
this.curStartMs += def.ms;
|
||||||
|
localT = elapsedMs - this.curStartMs;
|
||||||
|
def = SHOTS[this.queue[this.curIdx]];
|
||||||
|
this.anchor = def.begin(kart, this.rng);
|
||||||
|
this.justCut = true;
|
||||||
|
}
|
||||||
|
return def.compute(localT, def.ms, kart, this.anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,19 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
|
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
|
||||||
this.load.json('star-control-ships', 'data/star-control-ships.json');
|
this.load.json('star-control-ships', 'data/star-control-ships.json');
|
||||||
|
|
||||||
|
this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3');
|
||||||
|
this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3');
|
||||||
|
this.load.audio('sfx-engine-fast', 'assets/fx/engine-fast.mp3');
|
||||||
|
this.load.audio('sfx-engine-medium', 'assets/fx/engine-medium.mp3');
|
||||||
|
this.load.audio('sfx-engine-rev', 'assets/fx/engine-rev.mp3');
|
||||||
|
this.load.audio('sfx-kart-coin', 'assets/fx/kart-coin.mp3');
|
||||||
|
this.load.audio('sfx-kart-flatten', 'assets/fx/kart-flatten.mp3');
|
||||||
|
this.load.audio('sfx-kart-thump', 'assets/fx/kart-thump.mp3');
|
||||||
|
this.load.audio('sfx-kart-shrink', 'assets/fx/kart-shrink.mp3');
|
||||||
|
this.load.audio('sfx-kart-spin', 'assets/fx/kart-spin.mp3');
|
||||||
|
this.load.audio('sfx-kart-hit', 'assets/fx/kart-hit.mp3');
|
||||||
|
this.load.audio('sfx-kart-shell', 'assets/fx/kart-shell.mp3');
|
||||||
|
this.load.audio('sfx-kart-star', 'assets/fx/kart-star.mp3');
|
||||||
this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3');
|
this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3');
|
||||||
this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3');
|
this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3');
|
||||||
this.load.audio('sfx-water-raise', 'assets/fx/water-raise.mp3');
|
this.load.audio('sfx-water-raise', 'assets/fx/water-raise.mp3');
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,19 @@ export const SFX = {
|
||||||
ENERGY_HUM: 'sfx-energy-hum',
|
ENERGY_HUM: 'sfx-energy-hum',
|
||||||
LASER_ZAP: 'sfx-laser-zap',
|
LASER_ZAP: 'sfx-laser-zap',
|
||||||
REWIND: 'sfx-rewind',
|
REWIND: 'sfx-rewind',
|
||||||
|
ENGINE_START: 'sfx-engine-start',
|
||||||
|
ENGINE_HEAVY: 'sfx-engine-heavy',
|
||||||
|
ENGINE_FAST: 'sfx-engine-fast',
|
||||||
|
ENGINE_MEDIUM: 'sfx-engine-medium',
|
||||||
|
ENGINE_REV: 'sfx-engine-rev',
|
||||||
|
KART_COIN: 'sfx-kart-coin',
|
||||||
|
KART_FLATTEN: 'sfx-kart-flatten',
|
||||||
|
KART_THUMP: 'sfx-kart-thump',
|
||||||
|
KART_SHRINK: 'sfx-kart-shrink',
|
||||||
|
KART_SPIN: 'sfx-kart-spin',
|
||||||
|
KART_HIT: 'sfx-kart-hit',
|
||||||
|
KART_SHELL: 'sfx-kart-shell',
|
||||||
|
KART_STAR: 'sfx-kart-star',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function playSound(scene, key) {
|
export function playSound(scene, key) {
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,39 @@ for (const [ci, engineClass] of rules.engineClasses.entries()) {
|
||||||
check('deterministic for identical seed', run() === run());
|
check('deterministic for identical seed', run() === run());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 7. Player autopilot after finishing (post-race victory-lap window) ─────
|
||||||
|
// Nothing above exercises playerIndex >= 0 at all — this is the only check
|
||||||
|
// that drives the new "AI takes over the player's kart once it's finished"
|
||||||
|
// dispatch path and the "wait for the whole field" phase-transition change.
|
||||||
|
{
|
||||||
|
const state = createRace({
|
||||||
|
trackModel: model1, rules, engineClass: cls, racers, playerIndex: 0, mode: 'gp', seed: 777,
|
||||||
|
});
|
||||||
|
while (state.phase === 'countdown') step(state, kartInputsNeutral());
|
||||||
|
|
||||||
|
const player = state.karts[0];
|
||||||
|
// Simulate the player crossing the line on the spot, mirroring what the
|
||||||
|
// checkpoint/lap code does at the real finish transition.
|
||||||
|
player.finished = true;
|
||||||
|
player.finishTimeMs = state.timeMs;
|
||||||
|
state.finishOrder.push(player.index);
|
||||||
|
const x0 = player.x;
|
||||||
|
const y0 = player.y;
|
||||||
|
|
||||||
|
let ticks = 0;
|
||||||
|
const cap = 60 * 90; // matches POST_FINISH_SAFETY_MS as an outer bound
|
||||||
|
while (ticks < cap && state.phase === 'racing') {
|
||||||
|
step(state, kartInputsNeutral());
|
||||||
|
ticks += 1;
|
||||||
|
}
|
||||||
|
const moved = Math.hypot(player.x - x0, player.y - y0);
|
||||||
|
check('autopiloted player kart keeps driving after finishing', moved > 50, `moved ${moved.toFixed(1)} units`);
|
||||||
|
check('field is allowed to finish rather than being cut off early',
|
||||||
|
state.karts.every((k) => k.finished),
|
||||||
|
`${state.karts.filter((k) => k.finished).length}/9 finished after ${Math.round(ticks / 60)}s, phase=${state.phase}`);
|
||||||
|
check('race phase ends up finished', state.phase === 'finished', `phase=${state.phase}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Countdown export used by the scene HUD timer.
|
// Countdown export used by the scene HUD timer.
|
||||||
check('COUNTDOWN_MS sane', COUNTDOWN_MS > 2000 && COUNTDOWN_MS < 6000);
|
check('COUNTDOWN_MS sane', COUNTDOWN_MS > 2000 && COUNTDOWN_MS < 6000);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue