feat(machine): paint-mine — first-through floor panels
A new createPaintMine contraption: a floor panel that arms, and on step-on TELEGRAPHs (~0.35s) then bursts — douses whoever's on it with a cluster of paint splats (reliably crossing the buff threshold) and pops them with an impulse. `once` (default) makes it a first-through-only trap that reads as spent afterward and re-arms on race:respawn, so the solo time-trial stays repeatable. Reward vs hazard is pure config. Placed two on the centre line of Breakfast Rush: a GREEN reward (launches you forward, ~59% grip douse) and a PURPLE hazard (grows you MEGA and shoves you off the fast line). Reuses the existing trigger (dynamicBodiesInBox), telegraph, and paint:request-splat wire — no new systems. Browser-verified: coat + pop + spent-look + re-arm all fire; typecheck + build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ad93d55de9
commit
fef136dfbc
17
src/game.ts
17
src/game.ts
@ -11,7 +11,7 @@ import { createBlobController } from './blob/controller'
|
|||||||
import { createBlobFeel } from './blob/feel'
|
import { createBlobFeel } from './blob/feel'
|
||||||
import { createFollowCamera } from './blob/camera'
|
import { createFollowCamera } from './blob/camera'
|
||||||
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
|
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 { installZones } from './course/zones'
|
||||||
import { assets } from './assets/registry'
|
import { assets } from './assets/registry'
|
||||||
import { hasOverrides } from './assets/idb'
|
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
|
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 }))
|
world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
|
||||||
installPaint(world, { mesh: blob.mesh, paint: skin })
|
installPaint(world, { mesh: blob.mesh, paint: skin })
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export {
|
|||||||
createConveyorBelt,
|
createConveyorBelt,
|
||||||
createBubbleArch,
|
createBubbleArch,
|
||||||
createFan,
|
createFan,
|
||||||
|
createPaintMine,
|
||||||
} from './parts'
|
} from './parts'
|
||||||
export type {
|
export type {
|
||||||
Vec3,
|
Vec3,
|
||||||
@ -32,4 +33,5 @@ export type {
|
|||||||
ConveyorBeltConfig,
|
ConveyorBeltConfig,
|
||||||
BubbleArchConfig,
|
BubbleArchConfig,
|
||||||
FanConfig,
|
FanConfig,
|
||||||
|
PaintMineConfig,
|
||||||
} from './parts'
|
} from './parts'
|
||||||
|
|||||||
@ -770,3 +770,180 @@ export function createFan(world: World, cfg: FanConfig): MachinePart {
|
|||||||
|
|
||||||
return { id: cfg.id, group }
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user