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
This commit is contained in:
parent
cf646282a0
commit
ecfdf4c59b
|
|
@ -51,6 +51,8 @@
|
|||
"bumpImpulse": 190,
|
||||
"respawnMs": 1600,
|
||||
"offroadRescueMs": 6000,
|
||||
"checkpointMissSkip": 2,
|
||||
"checkpointMissMs": 2500,
|
||||
"gridSpacing": 46,
|
||||
"itemBoxRespawnMs": 2200,
|
||||
"itemBoxRadius": 20,
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue