diff --git a/data/superkart-rules.json b/data/superkart-rules.json index 061d14d..ff5e8cc 100644 --- a/data/superkart-rules.json +++ b/data/superkart-rules.json @@ -51,6 +51,8 @@ "bumpImpulse": 190, "respawnMs": 1600, "offroadRescueMs": 6000, + "checkpointMissSkip": 2, + "checkpointMissMs": 2500, "gridSpacing": 46, "itemBoxRespawnMs": 2200, "itemBoxRadius": 20, diff --git a/src/games/superkart/SuperKartGame.js b/src/games/superkart/SuperKartGame.js index eb7d7c4..1654425 100644 --- a/src/games/superkart/SuperKartGame.js +++ b/src/games/superkart/SuperKartGame.js @@ -644,6 +644,8 @@ export default class SuperKartGame extends Phaser.Scene { return l; }); this.hudGo = t(GAME_WIDTH / 2, 200, '', 90, '#38b048'); + // Missed-checkpoint banner (text driven from the player's cpMissMs timer). + this.hudMiss = t(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 60, '', 76, '#e06c75'); 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)); @@ -891,7 +893,7 @@ export default class SuperKartGame extends Phaser.Scene { 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); + s.setAlpha(kart.invulnMs > 0 || kart.cpMissMs > 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(); } @@ -921,7 +923,8 @@ export default class SuperKartGame extends Phaser.Scene { 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); + s.setAlpha(kart.rescueMs > 0 ? 0.25 + : kart.invulnMs > 0 || kart.cpMissMs > 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(); } @@ -967,6 +970,12 @@ export default class SuperKartGame extends Phaser.Scene { }); } + if (this.hudMiss) { + const missing = player.cpMissMs > 0; + this.hudMiss.setText(missing ? 'CHECKPOINT MISSED' : ''); + if (missing) this.hudMiss.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(state.timeMs / 140))); + } + if (state.phase !== 'countdown' && this.hudLights) { if (state.timeMs - COUNTDOWN_MS < 900) { this.hudLights.forEach((l) => l.setFillStyle(0x38b048)); @@ -1063,6 +1072,7 @@ export default class SuperKartGame extends Phaser.Scene { case 'bump': if (ev.a === pIdx || ev.b === pIdx) playSound(this, SFX.KART_THUMP); break; case 'coin': if (ev.kart === pIdx) playSound(this, SFX.KART_COIN); break; case 'lap': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break; + case 'checkpoint-miss': if (ev.kart === pIdx) playSound(this, SFX.KART_HIT); break; case 'splash': if (ev.kart === pIdx) playSound(this, 'sfx-water-splash'); break; case 'shatter': playSound(this, SFX.EIGHTBIT_EXPLODE); break; case 'finish': diff --git a/src/games/superkart/SuperKartLogic.js b/src/games/superkart/SuperKartLogic.js index c96b0c9..b6344ca 100644 --- a/src/games/superkart/SuperKartLogic.js +++ b/src/games/superkart/SuperKartLogic.js @@ -123,6 +123,7 @@ function makeKart(index, racer, isPlayer, slot, physics) { lapStartMs: 0, bestLapMs: 0, rescueMs: 0, + cpMissMs: 0, // blink/banner window after a missed-checkpoint correction stuckMs: 0, reverseMs: 0, deepMs: 0, @@ -354,7 +355,7 @@ function nextKartAhead(state, kart) { function stepKart(state, kart, inputs, dt) { const { physics, model } = state; - for (const key of ['spinMs', 'squashMs', 'invulnMs', 'boostMs', 'starMs', 'empMs', 'hopCooldown', 'wallHitCooldown']) { + for (const key of ['spinMs', 'squashMs', 'invulnMs', 'boostMs', 'starMs', 'empMs', 'hopCooldown', 'wallHitCooldown', 'cpMissMs']) { if (kart[key] > 0) kart[key] = Math.max(0, kart[key] - STEP_MS); } @@ -527,12 +528,36 @@ function stepKart(state, kart, inputs, dt) { const proj = projectToSplineNear(model, kart.x, kart.y, kart.splineS); kart.splineS = proj.s; const L = model.totalLength; - const sRel = ((proj.s - model.startS) % L + L) % L; + let sRel = ((proj.s - model.startS) % L + L) % L; kart.sRel = sRel; const cps = model.checkpoints.length; const cpSize = L / cps; const cp = Math.floor(sRel / cpSize) % cps; - if (cp === (kart.cpIndex + 1) % cps) { + // How far ahead of the last validated gate we are, going forward round the + // ring. 0 = same gate, 1 = the normal advance. A big forward jump means the + // kart shortcut across geometry and the spline projection snapped branches; + // anything past the halfway mark is the kart *behind* its own cpIndex + // (driving backwards, or sitting on the pre-start grid) and is left alone. + const delta = (cp - kart.cpIndex + cps) % cps; + if (delta >= physics.checkpointMissSkip && delta < cps / 2) { + // Missed checkpoint: stop the player dead and put them back on the last + // gate they legitimately crossed — i.e. just behind the one they skipped — + // instead of leaving the lap chain silently stalled until they guess which + // gate it was and drive backwards to it. cpIndex is deliberately untouched, + // so the next step reads delta 0 and this can't retrigger in a loop; a miss + // straddling the start line still owes the player the line crossing. + if (kart.isPlayer && state.phase === 'racing' && !kart.finished + && kart.cpMissMs <= 0 && kart.rescueMs <= 0) { + const missed = (kart.cpIndex + 1) % cps; + respawnAtCheckpoint(state, kart); + kart.cpMissMs = physics.checkpointMissMs; + // Re-derive arc-length from the teleported anchor so this frame's + // progress reflects where the kart actually is, not the skipped-to spot. + sRel = ((kart.splineS - model.startS) % L + L) % L; + kart.sRel = sRel; + emit(state, 'checkpoint-miss', { kart: kart.index, checkpoint: missed }); + } + } else if (delta === 1) { kart.cpIndex = cp; if (cp === 0) { kart.lap += 1; diff --git a/src/games/tetrisattack/TetrisAttackScreens.js b/src/games/tetrisattack/TetrisAttackScreens.js index 50a6b6d..ba9c850 100644 --- a/src/games/tetrisattack/TetrisAttackScreens.js +++ b/src/games/tetrisattack/TetrisAttackScreens.js @@ -284,7 +284,8 @@ export function showResult(scene, { title, lines = [], buttons = [], round = nul // ── Host portrait (in-game HUD) ─────────────────────────────────────────────── export function createHostPortrait(scene, x, y, round, index = scene.roundIndex) { - const frameBg = scene.add.rectangle(x, y, 300, 380, 0x0e1526, 1).setStrokeStyle(5, 0x4a6ea8).setDepth(20); + // half-transparent fill so the stage background reads through the portrait box + const frameBg = scene.add.rectangle(x, y, 300, 380, 0x0e1526, 0.5).setStrokeStyle(5, 0x4a6ea8).setDepth(20); const img = buildPortraitImage(scene, round, index, 'neutral', x, y, 1.0); img.setDepth(21); return { diff --git a/tools/verifySuperKart.js b/tools/verifySuperKart.js index 4d08a7f..c4fe32a 100644 --- a/tools/verifySuperKart.js +++ b/tools/verifySuperKart.js @@ -10,7 +10,7 @@ import { readFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { - buildTrackModel, validateTrack, surfaceAt, SURFACE, + buildTrackModel, validateTrack, surfaceAt, SURFACE, normAngle, } from '../src/games/superkart/SuperKartTrack.js'; import { createRace, step, finalizeRace, kartInputsNeutral, mulberry32, rollItem, @@ -70,7 +70,8 @@ for (let col = 0; col < 9; col += 1) { check(`weight column ${col + 1} sums > 0`, total > 0); } const physKeys = ['baseTopSpeed', 'baseAccel', 'turnRateBase', 'hopMs', 'offroadMultMin', - 'boostPadMult', 'spinMs', 'gridSpacing', 'rouletteMs', 'rubberBandGain', 'rubberBandRange']; + 'boostPadMult', 'spinMs', 'gridSpacing', 'rouletteMs', 'rubberBandGain', 'rubberBandRange', + 'checkpointMissSkip', 'checkpointMissMs']; for (const k of physKeys) check(`physics.${k} present`, typeof rules.physics[k] === 'number'); check('3 cups', cups.cups.length === 3); @@ -167,6 +168,76 @@ check('deep is a crawl', surfaceMult(SURFACE.DEEP, mario.stats, rules.physics) < check('lighter kart displaced more in a bump', bMove > aMove, `smasher ${aMove.toFixed(1)} vs kona ${bMove.toFixed(1)}`); } +// Missed-checkpoint correction: a player who shortcuts past gates is stopped +// and put back on the last gate they legitimately crossed. +{ + const state = createRace({ + trackModel: model1, rules, engineClass: cls, racers, playerIndex: 0, mode: 'gp', seed: 11, + }); + let missedInCountdown = 0; + while (state.phase === 'countdown') { + step(state, kartInputsNeutral()); + missedInCountdown += state.events.filter((e) => e.type === 'checkpoint-miss').length; + } + check('no checkpoint miss on the starting grid / countdown', missedInCountdown === 0); + + const player = state.karts[0]; + const cpsArr = model1.checkpoints; + const from = player.cpIndex; + const ahead = cpsArr[(from + 6) % cpsArr.length]; + player.x = ahead.x; + player.y = ahead.y; + player.splineS = ahead.s; // as if the spline projection snapped across the cut + player.speed = 400; + step(state, kartInputsNeutral()); + const ev = state.events.find((e) => e.type === 'checkpoint-miss'); + check('shortcutting gates emits checkpoint-miss', !!ev); + check('checkpoint-miss names the first gate skipped', ev?.checkpoint === (from + 1) % cpsArr.length, + `got ${ev?.checkpoint} want ${(from + 1) % cpsArr.length}`); + check('missed checkpoint zeroes the player speed', player.speed === 0, String(player.speed)); + const home = cpsArr[from]; + check('player moved back to the gate behind the one missed', + Math.hypot(player.x - home.x, player.y - home.y) < 1, + `${Math.hypot(player.x - home.x, player.y - home.y).toFixed(1)} units off`); + check('missed checkpoint starts the blink window', player.cpMissMs === rules.physics.checkpointMissMs); + check('lap chain left intact for re-driving', player.cpIndex === from); + // Settled back in its own bucket, so the correction must not retrigger. + step(state, kartInputsNeutral()); + check('checkpoint miss does not retrigger next step', + !state.events.some((e) => e.type === 'checkpoint-miss')); +} + +// Clean racing must never trip the miss detector. The detector only arms for +// the player seat, so the player is driven round the centerline by a simple +// lookahead autopilot (the AI's own inputs aren't exported). +{ + const autopilot = (model, kart) => { + const idx = Math.round(kart.splineS / model.step) % model.samples.length; + const target = model.samples[(idx + 8) % model.samples.length]; + const want = Math.atan2(target.y - kart.y, target.x - kart.x); + return { + steer: Math.max(-1, Math.min(1, 3.4 * normAngle(want - kart.heading))), + accel: true, brake: false, hop: false, item: false, + }; + }; + let spurious = 0; + let lapsDriven = 0; + for (const tid of ['track-001', 'track-005', 'track-009']) { + const model = models.get(tid); + const state = createRace({ + trackModel: model, rules, engineClass: cls, racers, playerIndex: 0, mode: 'gp', seed: 5, + }); + const player = state.karts[0]; + for (let i = 0; i < 60 * 200 && !player.finished; i += 1) { + step(state, autopilot(model, player)); + spurious += state.events.filter((e) => e.type === 'checkpoint-miss').length; + } + lapsDriven += player.lap - 1; + } + check('autopilot actually drove full laps', lapsDriven >= 9, `${lapsDriven} laps`); + check('clean racing never trips the checkpoint-miss detector', spurious === 0, `${spurious} fired`); +} + // pointsFor sanity. check('winner gets top points', pointsFor(1, rules) === rules.pointsTable[0]); check('9th gets bottom points', pointsFor(9, rules) === rules.pointsTable[8]);