diff --git a/src/audio/install.ts b/src/audio/install.ts index 91a8bc3..801d9c6 100644 --- a/src/audio/install.ts +++ b/src/audio/install.ts @@ -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)), ) diff --git a/src/course/checkpoints.ts b/src/course/checkpoints.ts new file mode 100644 index 0000000..d164d0f --- /dev/null +++ b/src/course/checkpoints.ts @@ -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), + } +} diff --git a/src/course/spec.ts b/src/course/spec.ts index 96eb900..8ae8c84 100644 --- a/src/course/spec.ts +++ b/src/course/spec.ts @@ -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 { diff --git a/src/course/toaster-alley.ts b/src/course/toaster-alley.ts index baabb9a..0bf19f2 100644 --- a/src/course/toaster-alley.ts +++ b/src/course/toaster-alley.ts @@ -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]], } diff --git a/src/game.ts b/src/game.ts index b8463c2..944132f 100644 --- a/src/game.ts +++ b/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()