From 856683832b03ce3d0239f3d8e40dba3e85ed93e1 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 14:44:06 +1000 Subject: [PATCH] =?UTF-8?q?feat(machine):=20colour-keyed=20gate=20?= =?UTF-8?q?=E2=80=94=20paint=20decides=20your=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createColorGate: a portcullis that stays shut unless an approaching blob carries enough of a keyed colour (integration injects `qualifies()`, so the part never imports the paint lane), then slides into the floor and closes behind. GDD §5.4 colour-keyed shortcut — closes the core loop solo: the course paints you, and being painted opens your route. Placed a GREEN gate on the centre lane before the finish with short flank walls (clear of the fork exits): roll over the green reward mine upstream → arrive green → the gate opens for a straight shot; arrive un-green → detour around. Door animation lives in the FIXED step, not onFrame — the door is a collider, and onFrame stalls in a hidden tab (the lesson telegraph.ts/BucketDump already learned; hit and fixed here). Browser-verified all three cases: opens on green (59-74%), stays shut for a non-green blob in the zone, closes when the blob leaves. typecheck + build green. Co-Authored-By: Claude Opus 4.8 --- src/game.ts | 27 ++++++++++- src/machine/index.ts | 2 + src/machine/parts.ts | 105 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/game.ts b/src/game.ts index f058de6..8080b78 100644 --- a/src/game.ts +++ b/src/game.ts @@ -11,7 +11,7 @@ import { createBlobController } from './blob/controller' import { createBlobFeel } from './blob/feel' import { createFollowCamera } from './blob/camera' import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index' -import { createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine } from './machine/index' +import { createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate } from './machine/index' import { installZones } from './course/zones' import { assets } from './assets/registry' import { hasOverrides } from './assets/idb' @@ -93,6 +93,31 @@ export function installGame(world: World) { impulse: 7, direction: [0.8, 0.5, 0], size: [3, 3], }) + // ---- colour-keyed gate: a GREEN shortcut through the centre lane, just + // before the finish. Roll over the green reward mine upstream and you arrive + // green enough for the gate to sink into the floor — a straight blast to the + // line. Arrive un-green and it stays shut, so you steer around the short + // flank walls (a detour). Closes the core loop solo: paint decides your route. + // Walls only cover the centre (|x|<4) so the fork exits (boot x-6, tunnel + // x6.5) are untouched. ---- + { + const gz = -59 + const wallMat = new THREE.MeshStandardMaterial({ color: '#5b6b7a', roughness: 0.85 }) + for (const x of [-3, 3] as const) { // flank the centre gate gap (gate is x[-2,2]) + const wall = new THREE.Mesh(new THREE.BoxGeometry(2, 3, 0.8), wallMat) + wall.position.set(x, 1.5, gz) + wall.castShadow = true + wall.receiveShadow = true + world.scene.add(wall) + world.physics.createCollider( + world.rapier.ColliderDesc.cuboid(1, 1.5, 0.4).setTranslation(x, 1.5, gz)) + } + createColorGate(world, { + id: 'gate-green', position: [0, 0.02, gz], color: 'green', size: [4, 3, 0.6], + qualifies: () => skin.coverage().byColor.green >= 0.4, + }) + } + world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin })) installPaint(world, { mesh: blob.mesh, paint: skin }) diff --git a/src/machine/index.ts b/src/machine/index.ts index ba0751a..b72abd1 100644 --- a/src/machine/index.ts +++ b/src/machine/index.ts @@ -22,6 +22,7 @@ export { createBubbleArch, createFan, createPaintMine, + createColorGate, } from './parts' export type { Vec3, @@ -34,4 +35,5 @@ export type { BubbleArchConfig, FanConfig, PaintMineConfig, + ColorGateConfig, } from './parts' diff --git a/src/machine/parts.ts b/src/machine/parts.ts index 4afe6b3..e429d7f 100644 --- a/src/machine/parts.ts +++ b/src/machine/parts.ts @@ -947,3 +947,108 @@ export function createPaintMine(world: World, cfg: PaintMineConfig): MachinePart return { id: cfg.id, group } } + +// --------------------------------------------------------------------------- +// ColorGate — a portcullis that stays SHUT unless an approaching blob carries +// enough of a keyed colour, then slides into the floor to let it through and +// closes behind. This is the GDD §5.4 colour-keyed shortcut: the course paints +// you, and being painted opens your route. The door colour IS the key (a green +// door needs green), matching the pink-tunnel signage language. +// +// The part never imports the paint lane — integration injects `qualifies()` +// (e.g. `() => skin.coverage().byColor.green >= 0.4`), so the seam stays clean +// and this works for whatever "enough colour" the course wants. +// --------------------------------------------------------------------------- +export interface ColorGateConfig { + id: string + position: Vec3 + /** Which colour keys the gate — sets the door tint (the visible signage). */ + color: PaintColor + /** Door slab [width, height, depth]. Default [4, 3, 0.6]. */ + size?: Vec3 + /** Approach-zone half-extents on the +Z (racer) side. Default derived from size. */ + detectHalf?: Vec3 + /** True when the approaching blob carries enough of `color`. Injected by + * integration to keep this part decoupled from the paint lane. */ + qualifies: () => boolean + /** Signal emitted on the shut→open edge (further chaining). */ + emits?: string +} + +export function createColorGate(world: World, cfg: ColorGateConfig): MachinePart { + const { physics, rapier, scene } = world + const pos = asVec3(cfg.position) + const [w, h, d] = cfg.size ?? [4, 3, 0.6] + const hex = PALETTE[cfg.color] + + const group = new THREE.Group() + group.position.copy(pos) + scene.add(group) + + // cosmetic frame: two posts + a coloured lintel so the gate reads as a gate. + const postMat = new THREE.MeshStandardMaterial({ color: '#37474f', metalness: 0.4, roughness: 0.5 }) + for (const sx of [-1, 1]) { + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, h + 0.8, 12), postMat) + post.position.set(sx * (w / 2 + 0.3), (h + 0.8) / 2, 0) + post.castShadow = true + group.add(post) + } + const lintel = new THREE.Mesh( + new THREE.BoxGeometry(w + 1.0, 0.4, d + 0.3), + new THREE.MeshStandardMaterial({ color: hex, emissive: hex, emissiveIntensity: 0.35, roughness: 0.5 }), + ) + lintel.position.y = h + 0.6 + group.add(lintel) + + // the door — colour is the key. Fixed body + collider we slide into the floor. + const doorMat = new THREE.MeshStandardMaterial({ + color: hex, roughness: 0.45, metalness: 0.1, + emissive: hex, emissiveIntensity: 0.25, transparent: true, opacity: 0.92, + }) + const door = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), doorMat) + door.castShadow = true + door.receiveShadow = true + group.add(door) + + const closedY = pos.y + h / 2 + const openY = closedY - (h + 0.3) // sink fully into the ground + const doorBody = physics.createRigidBody( + rapier.RigidBodyDesc.fixed().setTranslation(pos.x, closedY, pos.z), + ) + physics.createCollider(rapier.ColliderDesc.cuboid(w / 2, h / 2, d / 2), doorBody) + + const dh = cfg.detectHalf ?? [w / 2 + 1, h / 2 + 0.6, 3] + // detector on the +Z approach side (racers run toward -Z through the course) + const detCenter = { x: pos.x, y: pos.y + h / 2, z: pos.z + dh[2] } + const detHalf = { x: dh[0], y: dh[1], z: dh[2] } + + let open = false + let curY = closedY + let targetY = closedY + + // Everything runs in the FIXED step, including the door's slide. The door is a + // COLLIDER (gameplay) — if its motion lived in onFrame it would freeze in a + // hidden tab and leave a half-open gate anyone could walk through (the lesson + // telegraph.ts and BucketDump already learned). Mesh + glow ride along here at + // 60Hz, which is smooth enough for a door. + world.addSystem({ + update(dt) { + const near = dynamicBodiesInBox(world, detCenter, detHalf).size > 0 + const shouldOpen = near && cfg.qualifies() + if (shouldOpen && !open) { + open = true + targetY = openY + if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits }) + } else if (!near && open) { + open = false + targetY = closedY + } + curY = lerp(curY, targetY, Math.min(1, dt * 6)) + door.position.y = curY - pos.y // mesh is a child of the group at pos + doorBody.setTranslation({ x: pos.x, y: curY, z: pos.z }, true) + doorMat.emissiveIntensity = open ? 0.7 : 0.25 + }, + }) + + return { id: cfg.id, group } +}