feat(race): checkpoints — a fall costs time, not the run

New course/checkpoints.ts: slim post-pairs flanking the lane that light gold
when you roll between them. Falling into the void mid-run now soft-respawns you
at the last pair crossed with the CLOCK STILL RUNNING and your PAINT INTACT —
time is the penalty, paint is progress already earned. R / gamepad Start /
post-finish remain full restarts to the line (and re-arm every pair via
race:respawn). Re-crossing an earlier pair moves the respawn point there.
'race:checkpoint' rings the existing audio tick.

Breakfast Rush gets two pairs (gap-jump landing shelf, fork entrance) — both
deliberately on dry open ground: a mid-course pair was tried and cut because
the slope's volume ejects a respawner and the river's rinse strips the paint a
checkpoint exists to preserve. Toaster Alley gets one (past the mine); spec
courses declare theirs via `checkpoints:`.

Also moved the fall check from onFrame to a fixed-step system — it gates
gameplay, and onFrame stalls in hidden tabs (same lesson as telegraph/bucket).

Browser-verified: crossing lights the pair + fires the event, fall respawns at
the pair with clock running (4.78→4.98s) and red paint identical (15.3%), R
resets fully. tsc + build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 16:55:00 +10:00
parent e71176d067
commit 639de3adf8
5 changed files with 146 additions and 2 deletions

View File

@ -53,6 +53,7 @@ export function installAudio(world: World): AudioHandle {
world.events.on('paint:request-cleanse', () => sparkle(engine))
world.events.on('machine:signal', () => clank(engine))
world.events.on('race:started', () => tick(engine))
world.events.on('race:checkpoint', () => tick(engine))
world.events.on('race:finished', (p: { time: number; best: number | null }) =>
fanfare(engine, isNewBest(p)),
)

110
src/course/checkpoints.ts Normal file
View File

@ -0,0 +1,110 @@
/**
* CHECKPOINTS falling off the course costs TIME, not the whole run.
*
* Each checkpoint is a pair of slim posts flanking the lane. Roll between them
* and they light gold: from then on a fall respawns you HERE with the clock
* still running and your paint intact (time is the penalty; paint is progress
* you already earned). A full restart (R / gamepad Start / after a finish) is
* the only thing that sends you back to the start line game.ts owns that and
* resets us via 'race:respawn'.
*
* Linear-course simple: no ordering rule, the most recently crossed pair wins.
* Emits 'race:checkpoint' {index} on the crossing edge (audio can hook it).
*/
import * as THREE from 'three'
import type { World, Blob } from '../contracts'
export type CheckpointList = Array<[number, number, number]>
export interface CheckpointsHandle {
/** Respawn point of the last checkpoint crossed this run, or null. */
last(): THREE.Vector3 | null
}
const POST_H = 2.2
const HALF_GAP = 4 // posts sit at x±HALF_GAP around the point
export function installCheckpoints(
world: World,
blob: Blob,
points: CheckpointList,
): CheckpointsHandle {
interface Cp {
at: THREE.Vector3
mats: THREE.MeshStandardMaterial[]
lit: boolean
pulse: number
}
const cps: Cp[] = points.map(([x, y, z]) => {
const mats: THREE.MeshStandardMaterial[] = []
for (const sx of [-1, 1]) {
const mat = new THREE.MeshStandardMaterial({
color: '#cfd8dc', roughness: 0.5, metalness: 0.2,
emissive: '#cfd8dc', emissiveIntensity: 0.12,
})
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.16, POST_H, 10), mat)
post.position.set(x + sx * HALF_GAP, y + POST_H / 2, z)
post.castShadow = true
world.scene.add(post)
mats.push(mat)
}
return { at: new THREE.Vector3(x, y, z), mats, lit: false, pulse: 0 }
})
let lastLit: Cp | null = null
world.addSystem({
update() {
const t = blob.body.translation()
for (let i = 0; i < cps.length; i++) {
const cp = cps[i]
if (
Math.abs(t.x - cp.at.x) < HALF_GAP &&
Math.abs(t.z - cp.at.z) < 1.6 &&
t.y - cp.at.y > -1 && t.y - cp.at.y < 4
) {
// re-crossing an already-lit pair still moves the respawn point here
// (matters after a soft respawn sends you back through the course)
lastLit = cp
if (!cp.lit) {
cp.lit = true
cp.pulse = 1
world.events.emit('race:checkpoint', { index: i })
}
}
}
},
})
// gold when lit, with a brief pop of glow on the crossing
world.onFrame((dt) => {
for (const cp of cps) {
if (!cp.lit) continue
cp.pulse = Math.max(0, cp.pulse - dt * 1.5)
for (const m of cp.mats) {
m.color.set('#FFD60A')
m.emissive.set('#FFD60A')
m.emissiveIntensity = 0.5 + cp.pulse * 1.6
}
}
})
// full restart → all checkpoints re-arm
world.events.on('race:respawn', () => {
lastLit = null
for (const cp of cps) {
cp.lit = false
cp.pulse = 0
for (const m of cp.mats) {
m.color.set('#cfd8dc')
m.emissive.set('#cfd8dc')
m.emissiveIntensity = 0.12
}
}
})
return {
last: () => (lastLit ? lastLit.at : null),
}
}

View File

@ -69,6 +69,8 @@ export interface CourseSpec {
boxes: BoxSpec[]
machines?: MachineSpec[]
cannons?: CannonSpec[]
/** Fall-respawn points, in course order (see course/checkpoints.ts). */
checkpoints?: Array<[number, number, number]>
}
export interface BuildCtx {

View File

@ -51,4 +51,6 @@ export const TOASTER_ALLEY: CourseSpec = {
{ color: 'red', position: [-9, 3.5, 12], interval: 1.8 },
{ color: 'red', position: [9, 3.5, 12], interval: 1.8 },
],
// one checkpoint past the mine — a fall (or a bad gate bonk) costs seconds, not the run
checkpoints: [[0, 0, -8]],
}

View File

@ -19,6 +19,7 @@ import {
} from './machine/index'
import { installZones } from './course/zones'
import { buildCourseFromSpec, COURSES, type FinishBox } from './course/spec'
import { installCheckpoints, type CheckpointList } from './course/checkpoints'
import { assets } from './assets/registry'
import { hasOverrides } from './assets/idb'
import { installAudio } from './audio/index'
@ -28,6 +29,16 @@ import { installTitle } from './ui/title'
/** Breakfast Rush spawn — matches buildGreybox()'s recommended spawn plateau. */
const BREAKFAST_SPAWN = new THREE.Vector3(0, 3.5, 30)
/** Breakfast Rush checkpoints: landing shelf, slope bottom, fork entrance.
* y is the local floor top at that spot (posts stand on it; respawn is +0.8). */
const BREAKFAST_CHECKPOINTS: CheckpointList = [
// Two, both on dry open ground. No mid-course one: the slope's volume would
// eject a respawner and the river's rinse strips the paint a checkpoint is
// supposed to preserve — a fall there honestly costs you the river re-run.
[0, 3.5, -6], // gap-jump landing shelf
[0, 0.24, -42], // machine district / fork entrance
]
/**
* Build the hand-tuned flagship course onto the world and return its finish box.
* (Everything here used to live inline in installGame; it is course-specific.)
@ -229,6 +240,9 @@ export function installGame(world: World) {
? buildCourseFromSpec(world, spec, { blob, skin }).finish
: buildBreakfastRush(world, blob, skin)
// ---- checkpoints: a fall costs time, not the run (see course/checkpoints) ----
const cps = installCheckpoints(world, blob, spec ? spec.checkpoints ?? [] : BREAKFAST_CHECKPOINTS)
world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
installPaint(world, { mesh: blob.mesh, paint: skin })
@ -309,8 +323,23 @@ export function installGame(world: World) {
banner.style.display = 'none'
world.events.emit('race:respawn', {})
}
world.onFrame(() => {
if (blob.body.translation().y < -25) respawn()
// Fixed-step, not onFrame: the fall check GATES gameplay, and onFrame stalls
// in hidden tabs (same lesson as telegraph/bucket — found at integration).
world.addSystem({
update() {
if (blob.body.translation().y < -25) {
// Mid-run with a checkpoint lit → soft respawn there: clock keeps running
// (the fall's cost is time) and paint stays (progress already earned).
// Otherwise it's a full restart to the line.
const cp = raceState === 'running' ? cps.last() : null
if (cp) {
blob.body.setTranslation({ x: cp.x, y: cp.y + 0.8, z: cp.z }, true)
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
} else {
respawn()
}
}
},
})
addEventListener('keydown', (e) => {
if (e.code === 'KeyR') respawn()