diff --git a/src/game.ts b/src/game.ts index c550747..f058de6 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 } from './machine/index' +import { createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine } from './machine/index' import { installZones } from './course/zones' import { assets } from './assets/registry' import { hasOverrides } from './assets/idb' @@ -78,6 +78,21 @@ export function installGame(world: World) { id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6, // purple-lane exit: cleansing punishes a failed MEGA attempt }) + // ---- paint-mines: first-through floor panels on the centre line ---- + // REWARD: roll over it and it launches you forward + coats you GREEN (GRIP) — + // a shortcut booster for the runner who takes the middle. HAZARD: further down, + // dumps heavy PURPLE (MEGA) and shoves you toward the pink tunnel lane where a + // grown blob bonks the roof — the greedy straight-liner pays for it. Both + // re-arm each run (race:respawn) so the time-trial stays repeatable. + createPaintMine(world, { + id: 'mine-reward', position: [0, 0.06, -34], color: 'green', radius: 0.9, splats: 5, + impulse: 8, direction: [0, 1, -0.6], size: [3.4, 3.4], + }) + createPaintMine(world, { + id: 'mine-hazard', position: [0, 0.24, -44], color: 'purple', radius: 0.8, splats: 4, + impulse: 7, direction: [0.8, 0.5, 0], size: [3, 3], + }) + 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 99c4e7c..ba0751a 100644 --- a/src/machine/index.ts +++ b/src/machine/index.ts @@ -21,6 +21,7 @@ export { createConveyorBelt, createBubbleArch, createFan, + createPaintMine, } from './parts' export type { Vec3, @@ -32,4 +33,5 @@ export type { ConveyorBeltConfig, BubbleArchConfig, FanConfig, + PaintMineConfig, } from './parts' diff --git a/src/machine/parts.ts b/src/machine/parts.ts index 294bb9a..4afe6b3 100644 --- a/src/machine/parts.ts +++ b/src/machine/parts.ts @@ -770,3 +770,180 @@ export function createFan(world: World, cfg: FanConfig): MachinePart { return { id: cfg.id, group } } + +// --------------------------------------------------------------------------- +// PaintMine — a floor panel that arms, and when a body rolls onto it TELEGRAPHs +// (~0.35s snap windup) then bursts: it coats whoever is on it (paint:request- +// splat aimed at each struck body, so installPaint lands it on that blob) and +// pops them with an impulse. Reward vs hazard is pure config: a fat splat of a +// GOOD colour + an upward launch is a reward; a heavy/unwanted colour + a +// sideways shove is a hazard. +// +// `once` (default true) is the first-through-only trap John asked for: after it +// fires it reads as SPENT and won't re-fire — so in a pack the leader trips it +// clean and everyone behind sees a used panel (GDD §5.4 persistent course +// state). It re-arms on `race:respawn` so every fresh run gets a live panel. +// `once:false` re-arms the instant the panel clears (a repeatable hazard). +// --------------------------------------------------------------------------- +export interface PaintMineConfig { + id: string + position: Vec3 + color: PaintColor + /** Splat radius requested from the paint lane — bigger = more coverage. */ + radius: number + /** How many splats to douse the body with, spread around it. Default 1. + * A single disc only wraps one side; 4-5 reliably crosses the buff threshold. */ + splats?: number + /** Pop impulse applied to each struck body. Default 6. */ + impulse?: number + /** Pop direction (auto-normalised). Default straight up. */ + direction?: Vec3 + /** Panel footprint [x, z]. Default [3, 3]. */ + size?: [number, number] + /** First-through-only: fire once, stay spent until race:respawn. Default true. */ + once?: boolean + /** Also fire on this machine:signal (chaining), on top of step-on. */ + onSignal?: string + /** Signal emitted right after it bursts (further chaining). */ + emits?: string +} + +export function createPaintMine(world: World, cfg: PaintMineConfig): MachinePart { + const { scene } = world + const pos = asVec3(cfg.position) + const [w, l] = cfg.size ?? [3, 3] + const paintHex = PALETTE[cfg.color] + const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 1, 0)).normalize() + const impulse = cfg.impulse ?? 6 + const once = cfg.once !== false + + const group = new THREE.Group() + group.position.copy(pos) + scene.add(group) + + // dark warning rim under a live colour panel proud of it. NO collider: the + // course floor already carries the blob, so a panel can never open a hole or + // grow an invisible wall — it's a decal + a trigger volume just above it. + const rimMat = new THREE.MeshStandardMaterial({ color: '#1a1a1f', roughness: 0.85 }) + const rim = new THREE.Mesh(new THREE.BoxGeometry(w + 0.28, 0.16, l + 0.28), rimMat) + rim.position.y = 0.05 + rim.receiveShadow = true + group.add(rim) + + const panelMat = new THREE.MeshStandardMaterial({ + color: paintHex, roughness: 0.5, metalness: 0.1, + emissive: paintHex, emissiveIntensity: 0.35, + }) + const panel = new THREE.Mesh(new THREE.BoxGeometry(w, 0.12, l), panelMat) + panel.position.y = 0.13 + panel.receiveShadow = true + group.add(panel) + + const detCenter = { x: pos.x, y: pos.y + 0.6, z: pos.z } + const detHalf = { x: w * 0.5, y: 0.6, z: l * 0.5 } + + // paint-coloured particle burst (same shape as the bubble arch's burst) + const bursts: Array<(dt: number) => void> = [] + const burst = () => { + const parts: THREE.Mesh[] = [] + const vels: THREE.Vector3[] = [] + for (let i = 0; i < 16; i++) { + const s = new THREE.Mesh( + new THREE.SphereGeometry(0.1 + Math.random() * 0.16, 8, 6), + new THREE.MeshStandardMaterial({ color: paintHex, transparent: true, opacity: 0.95, roughness: 0.3 }), + ) + s.position.set(pos.x + (Math.random() - 0.5) * w, pos.y + 0.3, pos.z + (Math.random() - 0.5) * l) + scene.add(s) + parts.push(s) + vels.push(new THREE.Vector3((Math.random() - 0.5) * 3, 2 + Math.random() * 3, (Math.random() - 0.5) * 3)) + } + let life = 0 + const step = (dt: number) => { + life += dt + parts.forEach((s, i) => { + vels[i].y -= 9 * dt + s.position.addScaledVector(vels[i], dt) + ;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.95 - life * 0.9) + }) + if (life > 1.1) { + for (const s of parts) { + scene.remove(s) + s.geometry.dispose() + ;(s.material as THREE.Material).dispose() + } + const idx = bursts.indexOf(step) + if (idx >= 0) bursts.splice(idx, 1) + } + } + bursts.push(step) + } + + let fired = false + let armed = true + + const markLive = () => { panelMat.color.set(paintHex); panelMat.emissive.set(paintHex); panelMat.emissiveIntensity = 0.35 } + const markSpent = () => { panelMat.color.set('#6b6b6b'); panelMat.emissive.setHex(0x000000); panelMat.emissiveIntensity = 0 } + + const fire = () => { + if ((once && fired) || isTelegraphing(world, group)) return + // Capture who's on the panel NOW (windup start): a fast roller has left the + // trigger box by the time the ~0.35s telegraph completes, so we pop/coat the + // bodies that tripped it (at their live position) rather than re-scanning an + // empty box at fire time. + const struck = dynamicBodiesInBox(world, detCenter, detHalf) + telegraph(world, group, { + duration: 0.35, + flashColor: paintHex, + scalePulse: 0.2, + shake: 0.06, + onFire: () => { + // Douse offsets: centre + around the body, so a burst wraps more than one + // UV face. splatAtPoint stamps where the world point meets the body, so + // offsetting the point hits different sides (installPaint's proximity gate + // still passes — offsets are well inside worldRadius + splat radius). + const offsets = [[0, 0.35, 0], [0.4, 0, 0], [-0.4, 0, 0], [0, 0, 0.4], [0, 0, -0.4]] + const n = Math.max(1, cfg.splats ?? 1) + for (const b of struck.values()) { + const t = b.translation() + b.applyImpulse({ x: dir.x * impulse, y: dir.y * impulse, z: dir.z * impulse }, true) + for (let i = 0; i < n; i++) { + const [ox, oy, oz] = offsets[i % offsets.length] + world.events.emit('paint:request-splat', { + point: new THREE.Vector3(t.x + ox, t.y + oy, t.z + oz), color: cfg.color, radius: cfg.radius, + }) + } + } + burst() + if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits }) + if (once) { fired = true; markSpent() } + }, + }) + } + + if (cfg.onSignal) { + world.events.on('machine:signal', ({ id }: { id: string }) => { + if (id === cfg.onSignal) fire() + }) + } + + // Fresh panel every run: a spent one-shot re-arms when the race restarts. + world.events.on('race:respawn', () => { fired = false; armed = true; markLive() }) + + world.addSystem({ + update() { + const occupied = dynamicBodiesInBox(world, detCenter, detHalf).size > 0 + if (occupied && armed) { + armed = false + fire() + } else if (!occupied && !once) { + armed = true // repeatable hazard re-arms on clear; a one-shot never does + } + }, + }) + + world.onFrame((dt) => { + for (const b of [...bursts]) b(dt) + }) + + return { id: cfg.id, group } +}