feat(machine): colour-keyed gate — paint decides your route

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 14:44:06 +10:00
parent fef136dfbc
commit 856683832b
3 changed files with 133 additions and 1 deletions

View File

@ -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 })

View File

@ -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'

View File

@ -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 }
}