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:
parent
fef136dfbc
commit
856683832b
27
src/game.ts
27
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 })
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user