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:
parent
e71176d067
commit
639de3adf8
@ -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
110
src/course/checkpoints.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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]],
|
||||
}
|
||||
|
||||
33
src/game.ts
33
src/game.ts
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user