From ecfdf4c59bc9160be1ea616847fab2815556169f Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 22 Jul 2026 13:00:12 -0600 Subject: [PATCH] feat(superkart): add missed-checkpoint correction for shortcut detection Detect when a player shortcuts across geometry and skips checkpoint gates by monitoring the spline projection delta. When the player jumps past the configured skip threshold, they are teleported back to the last legitimate checkpoint, shown a blinking "CHECKPOINT MISSED" banner, and given a brief invulnerability window. - Add checkpointMissSkip and checkpointMissMs config values - Implement delta-based gate-skipping detection in kart logic - Add HUD banner and sound feedback for missed checkpoints - Make player sprite blink during the correction window - Add verification tests for detection, correction, and clean-race false positives - Make Tetris Attack portrait frame half-transparent --- data/superkart-rules.json | 2 + src/games/superkart/SuperKartGame.js | 14 +++- src/games/superkart/SuperKartLogic.js | 31 +++++++- src/games/tetrisattack/TetrisAttackScreens.js | 3 +- tools/verifySuperKart.js | 75 ++++++++++++++++++- 5 files changed, 117 insertions(+), 8 deletions(-) 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]);