From 67921925f5ac09fae7b3ab8260d45a129ca25ef3 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 15:19:09 +1000 Subject: [PATCH] feat(course): data-driven courses + a second course (Toaster Alley) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A course is now a plain data object (boxes + machines + cannons + spawn/finish) built by buildCourseFromSpec, which dispatches to the greybox box builder and the Lane C machine factories. New courses are DATA, not a game.ts fork; load one with ?course=. Added COURSES.toaster = Toaster Alley, a flat, reliably completable second level that puts the never-placed FAN into play (tailwind) and reuses the mine→gate loop from a spec. game.ts refactor: Breakfast Rush's tuned build moves verbatim into buildBreakfastRush (returns its finish box); installGame picks the course by ?course=, parametrises the race loop's finish + respawn spawn, keeps a per-course best time, and adds a small on-screen course switcher. Breakfast Rush is untouched behaviourally (greybox now builds after the blob — colliders still exist before the first physics step). Deliberately NOT done: porting the finely-tuned Breakfast Rush to a spec (no gameplay gain, real regression risk), and placing a see-saw (it's a supported spec kind but wants an elevated bridge = deliberate level design). Browser-verified: Toaster Alley completes end-to-end (best 4.73s saved under its own key, green mine→gate loop opens the line); Breakfast Rush unchanged (spawns on the plateau, crosses the gap-jump, puddles paint it). tsc + vite build green; all 10 test suites pass (360 assertions). Co-Authored-By: Claude Opus 4.8 --- src/course/spec.ts | 171 ++++++++++++++++++++++++++++++++++++ src/course/toaster-alley.ts | 48 ++++++++++ src/game.ts | 124 +++++++++++++++++--------- 3 files changed, 300 insertions(+), 43 deletions(-) create mode 100644 src/course/spec.ts create mode 100644 src/course/toaster-alley.ts diff --git a/src/course/spec.ts b/src/course/spec.ts new file mode 100644 index 0000000..de3d421 --- /dev/null +++ b/src/course/spec.ts @@ -0,0 +1,171 @@ +/** + * DATA-DRIVEN COURSES — a course is a plain object (boxes + machines + cannons + + * spawn/finish), and `buildCourseFromSpec` turns it into a live course by + * dispatching to the existing greybox box builder and the Lane C machine + * factories. No new gameplay code: this is pure plumbing so a new level is + * *data*, not a game.ts fork. + * + * The flagship "Breakfast Rush" course stays hand-built (it carries a lot of + * integration tuning) — see game.ts. New courses live in COURSES below and load + * with `?course=`. + */ +import * as THREE from 'three' +import type { World, PaintColor } from '../contracts' +import type { PaintSkin } from '../paint/skin' +import { PaintCannon } from '../paint/index' +import { + createPaintMine, createColorGate, createSpringBoot, createSeeSaw, createFan, + createBucketDump, createBubbleArch, createConveyorBelt, createPressurePlate, + applySurface, +} from '../machine/index' +import type { + PaintMineConfig, ColorGateConfig, SpringBootConfig, SeeSawConfig, FanConfig, + BucketDumpConfig, BubbleArchConfig, ConveyorBeltConfig, PressurePlateConfig, + SurfaceName, +} from '../machine/index' +import { TOASTER_ALLEY } from './toaster-alley' + +export interface FinishBox { xMin: number; xMax: number; zMin: number; zMax: number; yMax: number } + +export interface BoxSpec { + pos: [number, number, number] + /** half-extents [hx, hy, hz]. */ + half: [number, number, number] + color: THREE.ColorRepresentation + rot?: [number, number, number] + surface?: SurfaceName + cast?: boolean + transparent?: number + roughness?: number +} + +/** A machine placement. `cfg` is the exact factory config; the gate's qualifies + * is derived from needColor/needPct so the data stays a plain object. */ +export type MachineSpec = + | { kind: 'mine'; cfg: PaintMineConfig } + | { kind: 'gate'; cfg: Omit; needColor: PaintColor; needPct: number } + | { kind: 'boot'; cfg: SpringBootConfig } + | { kind: 'seesaw'; cfg: SeeSawConfig } + | { kind: 'fan'; cfg: FanConfig } + | { kind: 'bucket'; cfg: BucketDumpConfig } + | { kind: 'bubble'; cfg: BubbleArchConfig } + | { kind: 'plate'; cfg: PressurePlateConfig } + | { kind: 'conveyor'; cfg: ConveyorBeltConfig } + | { kind: 'oil'; pos: [number, number, number]; half: [number, number, number] } + +export interface CannonSpec { + color: PaintColor + position: [number, number, number] + triggerId?: string + interval?: number +} + +export interface CourseSpec { + name: string + spawn: [number, number, number] + finish: FinishBox + boxes: BoxSpec[] + machines?: MachineSpec[] + cannons?: CannonSpec[] +} + +export interface BuildCtx { + blob: { mesh: THREE.Object3D; radius: number } + skin: PaintSkin +} + +/** Static box: THREE mesh + fixed cuboid collider, optional rotation + surface + * tag (mirrored onto the collider so the controller can resolve it). Mirrors + * greybox.ts's addBox so spec courses look identical to the hand-built one. */ +function addBox(world: World, b: BoxSpec): void { + const [cx, cy, cz] = b.pos + const [hx, hy, hz] = b.half + const mat = new THREE.MeshStandardMaterial({ + color: b.color, + roughness: b.roughness ?? 0.9, + metalness: 0, + transparent: b.transparent !== undefined, + opacity: b.transparent ?? 1, + }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), mat) + mesh.position.set(cx, cy, cz) + mesh.receiveShadow = true + mesh.castShadow = b.cast ?? false + if (b.rot) mesh.rotation.set(b.rot[0], b.rot[1], b.rot[2]) + world.scene.add(mesh) + + const desc = world.rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz) + if (b.rot) { + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(b.rot[0], b.rot[1], b.rot[2])) + desc.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }) + } + const col = world.physics.createCollider(desc) + if (b.surface) { + applySurface(world.rapier, col, b.surface) + ;(col as unknown as { userData?: unknown }).userData = { surface: b.surface } + } +} + +export function buildCourseFromSpec( + world: World, + spec: CourseSpec, + ctx: BuildCtx, +): { spawn: THREE.Vector3; finish: FinishBox } { + for (const b of spec.boxes) addBox(world, b) + + for (const m of spec.machines ?? []) { + switch (m.kind) { + case 'mine': createPaintMine(world, m.cfg); break + case 'gate': + createColorGate(world, { + ...m.cfg, + qualifies: () => ctx.skin.coverage().byColor[m.needColor] >= m.needPct, + }) + break + case 'boot': createSpringBoot(world, m.cfg); break + case 'seesaw': createSeeSaw(world, m.cfg); break + case 'fan': createFan(world, m.cfg); break + case 'bucket': createBucketDump(world, m.cfg); break + case 'bubble': createBubbleArch(world, m.cfg); break + case 'plate': createPressurePlate(world, m.cfg); break + case 'conveyor': createConveyorBelt(world, m.cfg); break + case 'oil': { + const [cx, cy, cz] = m.pos + const [hx, hy, hz] = m.half + const mat = new THREE.MeshStandardMaterial({ color: '#3a2f4a', roughness: 0.12, metalness: 0.35 }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), mat) + mesh.position.set(cx, cy, cz) + mesh.receiveShadow = true + world.scene.add(mesh) + const col = world.physics.createCollider( + world.rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz)) + applySurface(world.rapier, col, 'oil') + ;(col as unknown as { userData?: unknown }).userData = { surface: 'oil' } + break + } + } + } + + for (const c of spec.cannons ?? []) { + world.addSystem(new PaintCannon({ + world, + position: new THREE.Vector3(...c.position), + color: c.color, + target: ctx.blob.mesh, + paint: ctx.skin, + targetRadius: ctx.blob.radius, + triggerId: c.triggerId, + interval: c.interval ?? 2.2, + muzzleSpeed: 26, + splatRadius: 0.45, + })) + } + + return { spawn: new THREE.Vector3(...spec.spawn), finish: spec.finish } +} + +/** Selectable data-driven courses (the flagship Breakfast Rush is hand-built in + * game.ts and is the default). Load with `?course=`. */ +export const COURSES: Record = { + toaster: TOASTER_ALLEY, +} diff --git a/src/course/toaster-alley.ts b/src/course/toaster-alley.ts new file mode 100644 index 0000000..07104c9 --- /dev/null +++ b/src/course/toaster-alley.ts @@ -0,0 +1,48 @@ +/** + * TOASTER ALLEY — a second course, authored entirely as DATA (see spec.ts). + * Deliberately flat (one big catch-floor, no fall-outs and no elevated + * geometry) so it is reliably completable without the fine tuning Breakfast + * Rush needed. It also puts the FAN — a machine that shipped but was never + * placed — into play as a tailwind. + * + * Flow (+Z start → -Z finish): a fan sweeps you off the start pad; red cannons + * paint you for BURN speed; a green reward mine gives GRIP; a green colour-gate + * (fed by that mine) opens the straight line to the finish. + * + * NOTE: the SEE-SAW is also a supported spec kind (`{kind:'seesaw'}`), but a + * good see-saw needs an elevated bridge (approach platform + landing) tuned so + * the blob steps on cleanly — that's level design worth doing deliberately, so + * it's left out of this flat demo rather than shipped half-working. + */ +import type { CourseSpec } from './spec' + +export const TOASTER_ALLEY: CourseSpec = { + name: 'Toaster Alley', + spawn: [0, 1.8, 30], + finish: { xMin: -7, xMax: 7, zMin: -38, zMax: -26, yMax: 4 }, + boxes: [ + // base floor — spans the whole alley so nothing falls out + { pos: [0, -0.5, -5], half: [14, 0.5, 42], color: '#efe3c6', roughness: 1 }, + // start pad + { pos: [0, 0.4, 28], half: [6, 0.4, 5], color: '#f7edd2', cast: true }, + // gate flank walls (centre gap x[-2,2] is the gate) — clear side lanes remain + { pos: [-3, 1.5, -22], half: [1, 1.5, 0.4], color: '#5b6b7a', roughness: 0.85, cast: true }, + { pos: [3, 1.5, -22], half: [1, 1.5, 0.4], color: '#5b6b7a', roughness: 0.85, cast: true }, + // finish podium + { pos: [0, 0.6, -32], half: [7, 0.6, 5], color: '#FF6EB4', cast: true }, + ], + machines: [ + // FAN — a tailwind that sweeps you off the start pad down the alley + { kind: 'fan', cfg: { id: 'ta-fan', position: [0, 1.8, 20], direction: [0, 0, -1], force: 20, range: 18, spread: 4.5 } }, + // green reward mine — GRIP, and it feeds the green gate downstream + { kind: 'mine', cfg: { id: 'ta-mine', position: [0, 0.05, 4], color: 'green', radius: 0.9, splats: 5, impulse: 5, direction: [0, 1, -0.3], size: [3.4, 3.4] } }, + // green gate — opens for the runner who took the reward mine (same loop as + // Breakfast Rush, proving the gate works identically from a data spec) + { kind: 'gate', cfg: { id: 'ta-gate', position: [0, 0.02, -22], color: 'green', size: [4, 3, 0.6] }, needColor: 'green', needPct: 0.4 }, + ], + cannons: [ + // RED crossfire on the run-up — BURN speed to carry you down the alley + { color: 'red', position: [-9, 3.5, 12], interval: 1.8 }, + { color: 'red', position: [9, 3.5, 12], interval: 1.8 }, + ], +} diff --git a/src/game.ts b/src/game.ts index ac33ff1..7690ac8 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,12 +1,14 @@ /** * INTEGRATION SEAM — owned by integration, not by lanes. - * The playable slice: Lane A blob + course, Lane B paint + buffs, and (when - * lane-c merges) the machine chain on the course's machine area. + * The playable slice: a blob + paint + buffs, the shared race loop, and a + * COURSE. The flagship "Breakfast Rush" is hand-built (buildBreakfastRush — it + * carries a lot of integration tuning); extra courses are data (see course/spec) + * and load with `?course=`. */ import * as THREE from 'three' import type { World } from './contracts' import { buildGreybox } from './course/greybox' -import { createBlob } from './blob/createBlob' +import { createBlob, type BlobHandle } from './blob/createBlob' import { createBlobController } from './blob/controller' import { createBlobFeel } from './blob/feel' import { createFollowCamera } from './blob/camera' @@ -16,34 +18,24 @@ import { SURFACES, applySurface, } from './machine/index' import { installZones } from './course/zones' +import { buildCourseFromSpec, COURSES, type FinishBox } from './course/spec' import { assets } from './assets/registry' import { hasOverrides } from './assets/idb' import { installAudio } from './audio/index' import { installGhost } from './ghost/index' import { installTitle } from './ui/title' -export function installGame(world: World) { - const course = buildGreybox(world) +/** Breakfast Rush spawn — matches buildGreybox()'s recommended spawn plateau. */ +const BREAKFAST_SPAWN = new THREE.Vector3(0, 3.5, 30) - // ---- blob (Lane A) ---- - const blob = createBlob(world, { position: course.spawn }) - blob.paint = new PaintSkin(blob.mesh, { size: 256 }) - world.blob = blob - - world.addSystem(createBlobController(world, blob)) - - const feel = createBlobFeel(blob) - world.events.on('blob:jumped', () => feel.onJump()) - world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact)) - world.onFrame((dt) => feel.update(dt)) - - const camera = createFollowCamera(world, blob) - world.onFrame((dt) => camera.update(dt)) +/** + * 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.) + */ +function buildBreakfastRush(world: World, blob: BlobHandle, skin: PaintSkin): FinishBox { + buildGreybox(world) // ---- paint economy (Lane G): puddles + colour zones + MEGA/MINI fork ---- - // Zones build the fork's own plate/boot (purple lane) and the MINI tunnel - // (pink lane), and return cannon configs for the one cannon-staging path here. - const skin = blob.paint as PaintSkin const zones = installZones(world, blob, skin) for (const spec of zones.cannonConfigs) { world.addSystem(new PaintCannon({ @@ -65,7 +57,6 @@ export function installGame(world: World) { // CENTRE lane: un-forked runners get slow-carried under a purple dump — // a free taste of MEGA with no roof above (never dump grow-juice inside // the MINI tunnel: growing under the roof would wedge you). - const baseMass = blob.body.mass() createBucketDump(world, { id: 'bucket-1', position: [-1.4, 6, -50], color: 'purple', radius: 0.8, @@ -139,17 +130,9 @@ export function installGame(world: World) { }) } - world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin })) - installPaint(world, { mesh: blob.mesh, paint: skin }) - - const hud = new PaintHUD() - world.onFrame(() => { - hud.update(skin.coverage(), blob.modifiers) // coverage() self-caches at 250ms - }) - // ---- gap-recovery trampoline: falling in the gap jump must cost time, not // soft-lock you under the green slope (straight-line drivers wedged at z≈-16.6). - // Max-combine restitution bounces fallers back to shelf height. ---- + // A deterministic launcher bounces fallers back to shelf height. ---- { const tramp = new THREE.Mesh( new THREE.BoxGeometry(12, 0.5, 4), @@ -203,14 +186,55 @@ export function installGame(world: World) { }) } - // ---- race loop: first movement starts the clock, the pink pad ends it ---- // Finish pad volume, extended to z -57.2 so a blob bonking the pad's front - // face (it's a 1.2-high podium, unclimbable from the floor) still counts as - // touching the finish. Mid-air crossings count too — the boot is a legit finisher. - const FINISH = { xMin: -9, xMax: 9, zMin: -70, zMax: -57.2, yMax: 4 } + // face (a 1.2-high podium, unclimbable from the floor) still counts as a + // finish. Mid-air crossings count too — the boot is a legit finisher. + return { xMin: -9, xMax: 9, zMin: -70, zMax: -57.2, yMax: 4 } +} + +export function installGame(world: World) { + // ---- course selection: default = hand-built Breakfast Rush; ?course= + // loads a data-driven course from COURSES. Spawn is known before geometry. ---- + const which = new URLSearchParams(location.search).get('course') + const spec = which ? COURSES[which] : undefined + const spawn = spec ? new THREE.Vector3(...spec.spawn) : BREAKFAST_SPAWN.clone() + + // ---- blob (Lane A) ---- + const blob = createBlob(world, { position: spawn }) + blob.paint = new PaintSkin(blob.mesh, { size: 256 }) + world.blob = blob + + world.addSystem(createBlobController(world, blob)) + + const feel = createBlobFeel(blob) + world.events.on('blob:jumped', () => feel.onJump()) + world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact)) + world.onFrame((dt) => feel.update(dt)) + + const camera = createFollowCamera(world, blob) + world.onFrame((dt) => camera.update(dt)) + + const skin = blob.paint as PaintSkin + + // ---- build the selected course, get its finish box ---- + const finish = spec + ? buildCourseFromSpec(world, spec, { blob, skin }).finish + : buildBreakfastRush(world, blob, skin) + + world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin })) + installPaint(world, { mesh: blob.mesh, paint: skin }) + + const hud = new PaintHUD() + world.onFrame(() => { + hud.update(skin.coverage(), blob.modifiers) // coverage() self-caches at 250ms + }) + + // ---- race loop: first movement starts the clock, the finish box ends it ---- + // Per-course best time so switching courses doesn't compare apples to oranges. + const bestKey = `blobbo:best${spec ? ':' + which : ''}` let raceState: 'idle' | 'running' | 'finished' = 'idle' let raceT = 0 - let best: number | null = Number(localStorage.getItem('blobbo:best')) || null + let best: number | null = Number(localStorage.getItem(bestKey)) || null const raceUI = document.createElement('div') raceUI.style.cssText = @@ -240,14 +264,14 @@ export function installGame(world: World) { } else if (raceState === 'running') { raceT += dt const inFinish = - t.x >= FINISH.xMin && t.x <= FINISH.xMax && - t.z >= FINISH.zMin && t.z <= FINISH.zMax && t.y <= FINISH.yMax + t.x >= finish.xMin && t.x <= finish.xMax && + t.z >= finish.zMin && t.z <= finish.zMax && t.y <= finish.yMax if (inFinish) { raceState = 'finished' const isBest = best === null || raceT < best if (isBest) { best = raceT - localStorage.setItem('blobbo:best', String(raceT)) + localStorage.setItem(bestKey, String(raceT)) } banner.innerHTML = `
🏁 FINISH — ${fmt(raceT)}
` + @@ -269,7 +293,7 @@ export function installGame(world: World) { // ---- fall respawn + debug ---- const respawn = () => { - blob.body.setTranslation({ x: course.spawn.x, y: course.spawn.y, z: course.spawn.z }, true) + blob.body.setTranslation({ x: spawn.x, y: spawn.y, z: spawn.z }, true) blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true) world.events.emit('paint:request-cleanse', { fraction: 1 }) raceState = 'idle' @@ -311,6 +335,20 @@ export function installGame(world: World) { setTimeout(() => toast.remove(), 3200) }) + // ---- course switcher: a small link to hop between courses (URL param) ---- + { + const other = spec ? { href: 'index.html', label: '🍳 Breakfast Rush' } + : { href: '?course=toaster', label: '🍞 Toaster Alley' } + const link = document.createElement('a') + link.href = other.href + link.textContent = `▸ course: ${other.label}` + link.style.cssText = + 'position:fixed;top:56px;right:14px;font:12px ui-monospace,Menlo,monospace;' + + 'color:#0a2540;background:rgba(255,255,255,.82);padding:6px 11px;border-radius:9px;' + + 'text-decoration:none;z-index:15;box-shadow:0 1px 6px rgba(0,0,0,.14)' + document.body.appendChild(link) + } + // ---- custom-assets pill: a forgotten Workshop swap otherwise reads as a // broken game ("why is the boot a weird cylinder?"), with no way back. ---- void hasOverrides().then((on) => { @@ -331,7 +369,7 @@ export function installGame(world: World) { installTitle(world) // debug handle (dev builds are the only builds right now) - ;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, course } + ;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, spawn, finish } - return { course, blob } + return { blob, spawn, finish } }