feat(paint): METEOR blend + yellow ZAP — the palette is complete
Three GDD §6 features land together, plus the design bug their verification
exposed.
METEOR (red+orange both >=20%): "smash light barriers" made literal. New
createSmashWall part — a brick-seamed light barrier, solid to everyone, but a
METEOR blob that RAMS it (>=5 u/s) shatters it into 8 real dynamic brick
shards that carry your momentum; rebuilds on race:respawn. Spec kind
'smashwall' with needColors for data courses. HUD shows "☄ METEOR" replacing
the pair's BURN/BOUNCE rows (GDD: the blend replaces its pair) and the ember
trail burns 2.2x while blended.
ZAP (yellow >=20%): "trigger colour-keyed machinery at range". New
machine/zap.ts — a charged blob pulses every 3s and fires any declared zap
target's machine:signal within radius, with an expanding arc ring. Breakfast
declares the fork's spring boot: purple earns it by WEIGHT on the plate,
yellow HOTWIRES it — verified the full arc: pulse -> boot telegraph -> kick
vy 10.6 -> lands on the finish podium. Yellow bubbles by the fork are the
source. Spec courses declare `zapTargets`. HUD shows "⚡ ZAP".
THE BUG THE WALL EXPOSED: the finale gate sat at z-59, but the finish box
reaches z-57.2 (podium-face bonks must count) and the podium collider face is
at z-58 — so in a LIVE race you finished before ever touching the gate; it
only ever blocked test blobs with the race idle. The whole gauntlet now lives
at z-56, in FRONT of the line: centre = green gate, left = METEOR smash wall,
right gap plugged (the MINI tunnel roof already gates x3..10), far left = open
detour. The cleanse arch moves to x-6.5/z-54.5 — at x-4.5 its zone overlapped
the METEOR approach and stripped the blend one step before the wall.
Verified in-browser (atomic calls; the wall-clock coverage cache and the
background sim poisoned split observations): clean blob bonks with race
running; METEOR blob shatters (8 shards, signal, drives through rubble to
"you finished 29% ORANGE"); wall rebuilds on R; zap fires exactly once per
cooldown. hasMeteor/hasZap are pure + unit-tested (58 assertions, 10 suites).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2777a580c9
commit
190a6e27d3
@ -12,16 +12,17 @@
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import type { World, PaintColor } from '../contracts'
|
import type { World, PaintColor } from '../contracts'
|
||||||
import type { PaintSkin } from '../paint/skin'
|
import type { PaintSkin } from '../paint/skin'
|
||||||
import { PaintCannon } from '../paint/index'
|
import { PaintCannon, ACTIVATE } from '../paint/index'
|
||||||
import {
|
import {
|
||||||
createPaintMine, createColorGate, createSpringBoot, createSeeSaw, createFan,
|
createPaintMine, createColorGate, createSpringBoot, createSeeSaw, createFan,
|
||||||
createBucketDump, createBubbleArch, createConveyorBelt, createPressurePlate,
|
createBucketDump, createBubbleArch, createConveyorBelt, createPressurePlate,
|
||||||
createPaintBubbles, createDominoChain, applySurface,
|
createPaintBubbles, createDominoChain, createSmashWall, installZap,
|
||||||
|
applySurface,
|
||||||
} from '../machine/index'
|
} from '../machine/index'
|
||||||
import type {
|
import type {
|
||||||
PaintMineConfig, ColorGateConfig, SpringBootConfig, SeeSawConfig, FanConfig,
|
PaintMineConfig, ColorGateConfig, SpringBootConfig, SeeSawConfig, FanConfig,
|
||||||
BucketDumpConfig, BubbleArchConfig, ConveyorBeltConfig, PressurePlateConfig,
|
BucketDumpConfig, BubbleArchConfig, ConveyorBeltConfig, PressurePlateConfig,
|
||||||
PaintBubblesConfig, DominoChainConfig, SurfaceName,
|
PaintBubblesConfig, DominoChainConfig, SmashWallConfig, ZapTarget, SurfaceName,
|
||||||
} from '../machine/index'
|
} from '../machine/index'
|
||||||
import { TOASTER_ALLEY } from './toaster-alley'
|
import { TOASTER_ALLEY } from './toaster-alley'
|
||||||
import { SEESAW_LAB } from './seesaw-lab'
|
import { SEESAW_LAB } from './seesaw-lab'
|
||||||
@ -55,6 +56,7 @@ export type MachineSpec =
|
|||||||
| { kind: 'conveyor'; cfg: ConveyorBeltConfig }
|
| { kind: 'conveyor'; cfg: ConveyorBeltConfig }
|
||||||
| { kind: 'bubbles'; cfg: PaintBubblesConfig }
|
| { kind: 'bubbles'; cfg: PaintBubblesConfig }
|
||||||
| { kind: 'dominoes'; cfg: DominoChainConfig }
|
| { kind: 'dominoes'; cfg: DominoChainConfig }
|
||||||
|
| { kind: 'smashwall'; cfg: Omit<SmashWallConfig, 'qualifies'>; needColors: [PaintColor, PaintColor] }
|
||||||
| { kind: 'oil'; pos: [number, number, number]; half: [number, number, number] }
|
| { kind: 'oil'; pos: [number, number, number]; half: [number, number, number] }
|
||||||
|
|
||||||
export interface CannonSpec {
|
export interface CannonSpec {
|
||||||
@ -73,6 +75,8 @@ export interface CourseSpec {
|
|||||||
cannons?: CannonSpec[]
|
cannons?: CannonSpec[]
|
||||||
/** Fall-respawn points, in course order (see course/checkpoints.ts). */
|
/** Fall-respawn points, in course order (see course/checkpoints.ts). */
|
||||||
checkpoints?: Array<[number, number, number]>
|
checkpoints?: Array<[number, number, number]>
|
||||||
|
/** Machinery a YELLOW/ZAP blob can hotwire at range (see machine/zap.ts). */
|
||||||
|
zapTargets?: ZapTarget[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildCtx {
|
export interface BuildCtx {
|
||||||
@ -137,6 +141,17 @@ export function buildCourseFromSpec(
|
|||||||
case 'conveyor': createConveyorBelt(world, m.cfg); break
|
case 'conveyor': createConveyorBelt(world, m.cfg); break
|
||||||
case 'bubbles': createPaintBubbles(world, m.cfg); break
|
case 'bubbles': createPaintBubbles(world, m.cfg); break
|
||||||
case 'dominoes': createDominoChain(world, m.cfg); break
|
case 'dominoes': createDominoChain(world, m.cfg); break
|
||||||
|
case 'smashwall': {
|
||||||
|
const [a, b] = m.needColors
|
||||||
|
createSmashWall(world, {
|
||||||
|
...m.cfg,
|
||||||
|
qualifies: () => {
|
||||||
|
const by = ctx.skin.coverage().byColor
|
||||||
|
return by[a] >= ACTIVATE && by[b] >= ACTIVATE
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'oil': {
|
case 'oil': {
|
||||||
const [cx, cy, cz] = m.pos
|
const [cx, cy, cz] = m.pos
|
||||||
const [hx, hy, hz] = m.half
|
const [hx, hy, hz] = m.half
|
||||||
@ -169,6 +184,13 @@ export function buildCourseFromSpec(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (spec.zapTargets?.length) {
|
||||||
|
installZap(world, {
|
||||||
|
charged: () => ctx.skin.coverage().byColor.yellow >= ACTIVATE,
|
||||||
|
targets: spec.zapTargets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { spawn: new THREE.Vector3(...spec.spawn), finish: spec.finish }
|
return { spawn: new THREE.Vector3(...spec.spawn), finish: spec.finish }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,8 @@ export interface ZonesHandle {
|
|||||||
plateId: string
|
plateId: string
|
||||||
plateThreshold: number
|
plateThreshold: number
|
||||||
bootSignal: string
|
bootSignal: string
|
||||||
|
/** Where the boot stands — so integration can declare it as a ZAP target. */
|
||||||
|
bootPosition: Vec3
|
||||||
tunnel: TunnelInfo
|
tunnel: TunnelInfo
|
||||||
totalStamps(): number
|
totalStamps(): number
|
||||||
}
|
}
|
||||||
@ -168,6 +170,7 @@ export function installZones(world: World, blob: Blob, skin: PaintSkin): ZonesHa
|
|||||||
plateId: PLATE_ID,
|
plateId: PLATE_ID,
|
||||||
plateThreshold,
|
plateThreshold,
|
||||||
bootSignal: BOOT_SIGNAL,
|
bootSignal: BOOT_SIGNAL,
|
||||||
|
bootPosition: BOOT_POS,
|
||||||
tunnel: { roofUndersideY: TUNNEL_CENTER[1] - TUNNEL_HALF[1], center: TUNNEL_CENTER, half: TUNNEL_HALF },
|
tunnel: { roofUndersideY: TUNNEL_CENTER[1] - TUNNEL_HALF[1], center: TUNNEL_CENTER, half: TUNNEL_HALF },
|
||||||
totalStamps: puddlesHandle.totalStamps,
|
totalStamps: puddlesHandle.totalStamps,
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/game.ts
50
src/game.ts
@ -12,11 +12,11 @@ import { createBlob, type BlobHandle } from './blob/createBlob'
|
|||||||
import { createBlobController } from './blob/controller'
|
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, dominantColor } from './paint/index'
|
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint, dominantColor, hasMeteor, hasZap } from './paint/index'
|
||||||
import { PALETTE, type PaintColor } from './contracts'
|
import { PALETTE, type PaintColor } from './contracts'
|
||||||
import {
|
import {
|
||||||
createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate,
|
createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate,
|
||||||
createPaintBubbles, SURFACES, applySurface,
|
createPaintBubbles, createSmashWall, installZap, SURFACES, applySurface,
|
||||||
} from './machine/index'
|
} from './machine/index'
|
||||||
import { installZones } from './course/zones'
|
import { installZones } from './course/zones'
|
||||||
import { buildCourseFromSpec, COURSES, COURSE_META, type FinishBox } from './course/spec'
|
import { buildCourseFromSpec, COURSES, COURSE_META, type FinishBox } from './course/spec'
|
||||||
@ -81,7 +81,12 @@ function buildBreakfastRush(world: World, blob: BlobHandle, skin: PaintSkin): Fi
|
|||||||
size: [3, 0.5, 10], velocity: [0, 0, -3],
|
size: [3, 0.5, 10], velocity: [0, 0, -3],
|
||||||
})
|
})
|
||||||
createBubbleArch(world, {
|
createBubbleArch(world, {
|
||||||
id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6, // purple-lane exit: cleansing punishes a failed MEGA attempt
|
// purple-lane exit: cleansing punishes a failed MEGA attempt. z-54.5 keeps
|
||||||
|
// it clear of the finale gauntlet plane (z-56), and x-6.5 keeps its cleanse
|
||||||
|
// zone (±2 => x-4.5..-8.5) OFF the METEOR lane at x-2..-4 — at x-4.5 the
|
||||||
|
// zone overlapped the smash approach and stripped the blend one step before
|
||||||
|
// the wall, so the wall "mysteriously" refused qualified blobs.
|
||||||
|
id: 'arch-1', position: [-6.5, 0.12, -54.5], fraction: 0.6,
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- paint bubbles: blue soap bubbles drifting over the milk-river approach.
|
// ---- paint bubbles: blue soap bubbles drifting over the milk-river approach.
|
||||||
@ -149,23 +154,50 @@ function buildBreakfastRush(world: World, blob: BlobHandle, skin: PaintSkin): Fi
|
|||||||
// Walls only cover the centre (|x|<4) so the fork exits (boot x-6, tunnel
|
// Walls only cover the centre (|x|<4) so the fork exits (boot x-6, tunnel
|
||||||
// x6.5) are untouched. ----
|
// x6.5) are untouched. ----
|
||||||
{
|
{
|
||||||
const gz = -59
|
// z-56, NOT -59: the finish box reaches to z-57.2 (a bonk on the podium's
|
||||||
|
// face at z-58 must count — the podium is unclimbable from the floor), so
|
||||||
|
// anything placed at -59 sits BEHIND the finish line. The original gate did
|
||||||
|
// exactly that: in a live race you finished ~2u before touching it, and it
|
||||||
|
// only ever blocked test blobs shoved at it with the race idle. The whole
|
||||||
|
// finale gauntlet lives at -56 now, in front of the trigger plane.
|
||||||
|
// Lane read: centre = green gate · left-of-centre = METEOR smash wall
|
||||||
|
// (GDD §6 "smash light barriers" — ram it with red+orange both active) ·
|
||||||
|
// right gap is plugged (the MINI tunnel roof already gates x3..10) · far
|
||||||
|
// left stays the open, slow detour.
|
||||||
|
const gz = -56
|
||||||
|
createSmashWall(world, {
|
||||||
|
id: 'smash-gate', position: [-3, 0, gz], size: [2, 3, 0.8],
|
||||||
|
qualifies: () => hasMeteor(skin.coverage()),
|
||||||
|
emits: 'smash-gate-wall',
|
||||||
|
})
|
||||||
const wallMat = new THREE.MeshStandardMaterial({ color: '#5b6b7a', roughness: 0.85 })
|
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(1, 3, 0.8), wallMat)
|
||||||
const wall = new THREE.Mesh(new THREE.BoxGeometry(2, 3, 0.8), wallMat)
|
wall.position.set(2.4, 1.5, gz) // plugs the gate→tunnel gap without poking the roof
|
||||||
wall.position.set(x, 1.5, gz)
|
|
||||||
wall.castShadow = true
|
wall.castShadow = true
|
||||||
wall.receiveShadow = true
|
wall.receiveShadow = true
|
||||||
world.scene.add(wall)
|
world.scene.add(wall)
|
||||||
world.physics.createCollider(
|
world.physics.createCollider(
|
||||||
world.rapier.ColliderDesc.cuboid(1, 1.5, 0.4).setTranslation(x, 1.5, gz))
|
world.rapier.ColliderDesc.cuboid(0.5, 1.5, 0.4).setTranslation(2.4, 1.5, gz))
|
||||||
}
|
|
||||||
createColorGate(world, {
|
createColorGate(world, {
|
||||||
id: 'gate-green', position: [0, 0.02, gz], color: 'green', size: [4, 3, 0.6],
|
id: 'gate-green', position: [0, 0.02, gz], color: 'green', size: [4, 3, 0.6],
|
||||||
qualifies: () => skin.coverage().byColor.green >= 0.4,
|
qualifies: () => skin.coverage().byColor.green >= 0.4,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ZAP (yellow): yellow bubbles float over the LEFT fork lane, and the
|
||||||
|
// fork's spring boot is a zap target — a charged blob standing near it fires
|
||||||
|
// it WITHOUT the plate's mass requirement. Purple earns the boot by weight;
|
||||||
|
// yellow hotwires it. Two colours, two philosophies, same machine. ----
|
||||||
|
createPaintBubbles(world, {
|
||||||
|
id: 'bubbles-zap', position: [-6, 1.2, -44], color: 'yellow', radius: 0.6,
|
||||||
|
splats: 2, count: 5, area: [2.5, 0.8, 2], drift: [0, 0.25, 0],
|
||||||
|
bubbleRadius: 0.55, impulse: 1,
|
||||||
|
})
|
||||||
|
installZap(world, {
|
||||||
|
charged: () => hasZap(skin.coverage()),
|
||||||
|
targets: [{ signal: zones.bootSignal, position: zones.bootPosition, radius: 4 }],
|
||||||
|
})
|
||||||
|
|
||||||
// ---- gap-recovery trampoline: falling in the gap jump must cost time, not
|
// ---- gap-recovery trampoline: falling in the gap jump must cost time, not
|
||||||
// soft-lock you under the green slope (straight-line drivers wedged at z≈-16.6).
|
// soft-lock you under the green slope (straight-line drivers wedged at z≈-16.6).
|
||||||
// A deterministic launcher bounces fallers back to shelf height. ----
|
// A deterministic launcher bounces fallers back to shelf height. ----
|
||||||
|
|||||||
@ -25,7 +25,10 @@ export {
|
|||||||
createColorGate,
|
createColorGate,
|
||||||
createPaintBubbles,
|
createPaintBubbles,
|
||||||
createDominoChain,
|
createDominoChain,
|
||||||
|
createSmashWall,
|
||||||
} from './parts'
|
} from './parts'
|
||||||
|
export { installZap } from './zap'
|
||||||
|
export type { ZapTarget, ZapOptions } from './zap'
|
||||||
export type {
|
export type {
|
||||||
Vec3,
|
Vec3,
|
||||||
MachinePart,
|
MachinePart,
|
||||||
@ -40,4 +43,5 @@ export type {
|
|||||||
ColorGateConfig,
|
ColorGateConfig,
|
||||||
PaintBubblesConfig,
|
PaintBubblesConfig,
|
||||||
DominoChainConfig,
|
DominoChainConfig,
|
||||||
|
SmashWallConfig,
|
||||||
} from './parts'
|
} from './parts'
|
||||||
|
|||||||
@ -1358,3 +1358,143 @@ export function createDominoChain(world: World, cfg: DominoChainConfig): Machine
|
|||||||
|
|
||||||
return { id: cfg.id, group }
|
return { id: cfg.id, group }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SmashWall — a light barrier (GDD §6 METEOR: "smash light barriers"). Solid
|
||||||
|
// to everyone... unless `qualifies()` says the rammer is a METEOR blob AND it
|
||||||
|
// arrives fast: then the wall SHATTERS into real dynamic brick shards, the
|
||||||
|
// collider vanishes, and the line is open. Integration injects `qualifies` (a
|
||||||
|
// coverage read) the same way ColorGate gets its key, so this part never
|
||||||
|
// imports the paint lane. Rebuilds solid on race:respawn.
|
||||||
|
//
|
||||||
|
// The cracked-plaster tint + brick seams are the signage: this wall looks
|
||||||
|
// flimsier than course walls, because for the right blob it is.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface SmashWallConfig {
|
||||||
|
id: string
|
||||||
|
/** Wall centre (base sits on the floor under it). */
|
||||||
|
position: Vec3
|
||||||
|
/** [width, height, thickness]. Default [2, 3, 0.8]. */
|
||||||
|
size?: Vec3
|
||||||
|
/** True when the approaching blob may smash (e.g. METEOR coverage). */
|
||||||
|
qualifies: () => boolean
|
||||||
|
/** Min horizontal speed to smash — you must RAM it. Default 5. */
|
||||||
|
minSpeed?: number
|
||||||
|
/** Signal emitted once on the smash (chaining + the free clank). */
|
||||||
|
emits?: string
|
||||||
|
/** Plaster tint. Default warm off-white. */
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSmashWall(world: World, cfg: SmashWallConfig): MachinePart {
|
||||||
|
const { physics, rapier, scene } = world
|
||||||
|
const pos = asVec3(cfg.position)
|
||||||
|
const [w, h, d] = cfg.size ?? [2, 3, 0.8]
|
||||||
|
const minSpeed = cfg.minSpeed ?? 5
|
||||||
|
|
||||||
|
const group = new THREE.Group()
|
||||||
|
group.position.copy(pos)
|
||||||
|
scene.add(group)
|
||||||
|
|
||||||
|
// brick-seamed panel: a base slab + darker seam lines so it reads "flimsy"
|
||||||
|
const wallMat = new THREE.MeshStandardMaterial({ color: cfg.color ?? '#e8ddc8', roughness: 0.85 })
|
||||||
|
const wall = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), wallMat)
|
||||||
|
wall.position.y = h / 2
|
||||||
|
wall.castShadow = true
|
||||||
|
wall.receiveShadow = true
|
||||||
|
group.add(wall)
|
||||||
|
const seamMat = new THREE.MeshStandardMaterial({ color: '#c8b89a', roughness: 0.9 })
|
||||||
|
const seams: THREE.Mesh[] = []
|
||||||
|
for (let i = 1; i < 4; i++) {
|
||||||
|
const seam = new THREE.Mesh(new THREE.BoxGeometry(w + 0.02, 0.05, d + 0.02), seamMat)
|
||||||
|
seam.position.y = (h * i) / 4
|
||||||
|
group.add(seam)
|
||||||
|
seams.push(seam)
|
||||||
|
}
|
||||||
|
|
||||||
|
let collider: RAPIER.Collider | null = physics.createCollider(
|
||||||
|
rapier.ColliderDesc.cuboid(w / 2, h / 2, d / 2).setTranslation(pos.x, pos.y + h / 2, pos.z),
|
||||||
|
)
|
||||||
|
let smashed = false
|
||||||
|
|
||||||
|
// flying brick shards — real dynamic bodies, cleaned up after a beat
|
||||||
|
interface Shard { body: RAPIER.RigidBody; mesh: THREE.Mesh; life: number }
|
||||||
|
const shards: Shard[] = []
|
||||||
|
|
||||||
|
const shatter = (blobVel: { x: number; z: number }) => {
|
||||||
|
if (collider) { physics.removeCollider(collider, true); collider = null }
|
||||||
|
wall.visible = false
|
||||||
|
for (const s of seams) s.visible = false
|
||||||
|
const bw = w / 2, bh = h / 4, bd = d
|
||||||
|
for (let ix = 0; ix < 2; ix++) {
|
||||||
|
for (let iy = 0; iy < 4; iy++) {
|
||||||
|
const bx = pos.x + (ix - 0.5) * bw
|
||||||
|
const by = pos.y + bh / 2 + iy * bh
|
||||||
|
const body = physics.createRigidBody(
|
||||||
|
rapier.RigidBodyDesc.dynamic().setTranslation(bx, by, pos.z).setAngularDamping(0.5),
|
||||||
|
)
|
||||||
|
physics.createCollider(
|
||||||
|
rapier.ColliderDesc.cuboid(bw * 0.45, bh * 0.45, bd * 0.45).setDensity(0.25), body,
|
||||||
|
)
|
||||||
|
// carry the rammer's momentum plus scatter, so bricks fly THROUGH
|
||||||
|
body.applyImpulse({
|
||||||
|
x: blobVel.x * 0.12 + (Math.random() - 0.5) * 1.2,
|
||||||
|
y: 1.2 + Math.random() * 1.6,
|
||||||
|
z: blobVel.z * 0.12 + (Math.random() - 0.5) * 1.2,
|
||||||
|
}, true)
|
||||||
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(bw * 0.9, bh * 0.9, bd * 0.9), wallMat)
|
||||||
|
mesh.castShadow = true
|
||||||
|
scene.add(mesh)
|
||||||
|
shards.push({ body, mesh, life: 2.5 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
||||||
|
}
|
||||||
|
|
||||||
|
const detHalf = { x: w / 2 + 0.9, y: h / 2, z: d / 2 + 0.9 }
|
||||||
|
world.addSystem({
|
||||||
|
update(dt) {
|
||||||
|
// shard lifecycle (fixed-step: they're real bodies)
|
||||||
|
for (let i = shards.length - 1; i >= 0; i--) {
|
||||||
|
const s = shards[i]
|
||||||
|
s.life -= dt
|
||||||
|
const t = s.body.translation()
|
||||||
|
const r = s.body.rotation()
|
||||||
|
s.mesh.position.set(t.x, t.y, t.z)
|
||||||
|
s.mesh.quaternion.set(r.x, r.y, r.z, r.w)
|
||||||
|
if (s.life <= 0) {
|
||||||
|
physics.removeRigidBody(s.body)
|
||||||
|
scene.remove(s.mesh)
|
||||||
|
s.mesh.geometry.dispose()
|
||||||
|
shards.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (smashed) return
|
||||||
|
// a qualifying blob moving fast into the wall's slab → shatter
|
||||||
|
const bodies = dynamicBodiesInBox(world,
|
||||||
|
{ x: pos.x, y: pos.y + h / 2, z: pos.z }, detHalf)
|
||||||
|
for (const b of bodies.values()) {
|
||||||
|
const v = b.linvel()
|
||||||
|
if (Math.hypot(v.x, v.z) >= minSpeed && cfg.qualifies()) {
|
||||||
|
smashed = true
|
||||||
|
shatter({ x: v.x, z: v.z })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
world.events.on('race:respawn', () => {
|
||||||
|
if (!smashed) return
|
||||||
|
smashed = false
|
||||||
|
wall.visible = true
|
||||||
|
for (const s of seams) s.visible = true
|
||||||
|
if (!collider) {
|
||||||
|
collider = physics.createCollider(
|
||||||
|
rapier.ColliderDesc.cuboid(w / 2, h / 2, d / 2).setTranslation(pos.x, pos.y + h / 2, pos.z),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { id: cfg.id, group }
|
||||||
|
}
|
||||||
|
|||||||
85
src/machine/zap.ts
Normal file
85
src/machine/zap.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* ZAP (GDD §6, YELLOW): "trigger colour-keyed machinery at range". A charged
|
||||||
|
* blob (yellow ≥ activation — injected as `charged()`, this module never
|
||||||
|
* imports the paint lane) pulses a static arc every few seconds: any declared
|
||||||
|
* zap target within range gets its machine:signal fired, exactly as if the
|
||||||
|
* machine's own trigger tripped. Concretely: stand near the spring boot and it
|
||||||
|
* fires WITHOUT the pressure plate's mass requirement — yellow is the colour
|
||||||
|
* that operates machinery without holding its key.
|
||||||
|
*
|
||||||
|
* Targets are course DATA ({signal, position, radius}), so each course decides
|
||||||
|
* what yellow can hotwire. The pulse ring + the bus's own clank are the
|
||||||
|
* telegraph: you see the arc, then the machine winds up as normal (boots and
|
||||||
|
* buckets still play their own ~0.5s telegraph before firing — readable chaos
|
||||||
|
* is preserved end-to-end).
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import type { World } from '../contracts'
|
||||||
|
|
||||||
|
export interface ZapTarget {
|
||||||
|
/** The machine:signal id to fire — the same string the machine listens on. */
|
||||||
|
signal: string
|
||||||
|
position: [number, number, number]
|
||||||
|
/** Blob must be within this range of `position`. */
|
||||||
|
radius: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZapOptions {
|
||||||
|
/** True while the blob carries enough YELLOW (integration injects this). */
|
||||||
|
charged: () => boolean
|
||||||
|
targets: ZapTarget[]
|
||||||
|
/** Seconds between pulses. Default 3. */
|
||||||
|
cooldown?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installZap(world: World, opts: ZapOptions): void {
|
||||||
|
if (opts.targets.length === 0) return
|
||||||
|
const cooldown = opts.cooldown ?? 3
|
||||||
|
let timer = 0
|
||||||
|
|
||||||
|
// expanding arc ring, spawned at the blob on each pulse (visual only)
|
||||||
|
const rings: Array<{ mesh: THREE.Mesh; t: number }> = []
|
||||||
|
const ringGeo = new THREE.TorusGeometry(1, 0.06, 8, 32)
|
||||||
|
const spawnRing = (at: { x: number; y: number; z: number }): void => {
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
color: '#FFD60A', transparent: true, opacity: 0.9, depthWrite: false,
|
||||||
|
})
|
||||||
|
const mesh = new THREE.Mesh(ringGeo, mat)
|
||||||
|
mesh.position.set(at.x, at.y + 0.1, at.z)
|
||||||
|
mesh.rotation.x = -Math.PI / 2
|
||||||
|
mesh.scale.setScalar(0.4)
|
||||||
|
world.scene.add(mesh)
|
||||||
|
rings.push({ mesh, t: 0 })
|
||||||
|
}
|
||||||
|
world.onFrame((dt) => {
|
||||||
|
for (let i = rings.length - 1; i >= 0; i--) {
|
||||||
|
const r = rings[i]
|
||||||
|
r.t += dt
|
||||||
|
const k = r.t / 0.6
|
||||||
|
r.mesh.scale.setScalar(0.4 + k * 3.2)
|
||||||
|
;(r.mesh.material as THREE.MeshBasicMaterial).opacity = Math.max(0, 0.9 * (1 - k))
|
||||||
|
if (k >= 1) {
|
||||||
|
world.scene.remove(r.mesh)
|
||||||
|
;(r.mesh.material as THREE.Material).dispose()
|
||||||
|
rings.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
world.addSystem({
|
||||||
|
update(dt) {
|
||||||
|
timer = Math.max(0, timer - dt)
|
||||||
|
if (timer > 0 || !world.blob || !opts.charged()) return
|
||||||
|
const p = world.blob.body.translation()
|
||||||
|
for (const t of opts.targets) {
|
||||||
|
const [tx, ty, tz] = t.position
|
||||||
|
if (Math.hypot(p.x - tx, p.y - ty, p.z - tz) <= t.radius) {
|
||||||
|
world.events.emit('machine:signal', { id: t.signal })
|
||||||
|
spawnRing(p)
|
||||||
|
timer = cooldown
|
||||||
|
break // one arc per pulse — nearest-declared wins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
buffStrength,
|
buffStrength,
|
||||||
computeModifiers,
|
computeModifiers,
|
||||||
dominantColor,
|
dominantColor,
|
||||||
|
hasMeteor,
|
||||||
superColor,
|
superColor,
|
||||||
} from './coverage-math'
|
} from './coverage-math'
|
||||||
|
|
||||||
@ -89,7 +90,7 @@ export class BuffSystem implements System {
|
|||||||
m.waterproof = mods.waterproof
|
m.waterproof = mods.waterproof
|
||||||
m.glow = mods.glow
|
m.glow = mods.glow
|
||||||
|
|
||||||
this.updateEmbers(dt, cov.byColor.red)
|
this.updateEmbers(dt, cov.byColor.red, hasMeteor(cov))
|
||||||
this.updateGlow(mods.glow, superColor(cov) ?? dominantColor(cov))
|
this.updateGlow(mods.glow, superColor(cov) ?? dominantColor(cov))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,11 +102,11 @@ export class BuffSystem implements System {
|
|||||||
|
|
||||||
// ---- ember trail (RED BURN) ---------------------------------------------
|
// ---- ember trail (RED BURN) ---------------------------------------------
|
||||||
|
|
||||||
private updateEmbers(dt: number, red: number): void {
|
private updateEmbers(dt: number, red: number, meteor = false): void {
|
||||||
const strength = buffStrength(red) // 0 below 20%
|
const strength = buffStrength(red) // 0 below 20%
|
||||||
if (red >= ACTIVATE) {
|
if (red >= ACTIVATE) {
|
||||||
// spawn rate scales with how hot the blob is
|
// spawn rate scales with how hot the blob is; METEOR burns twice as hard
|
||||||
const spawn = strength * 3
|
const spawn = strength * 3 * (meteor ? 2.2 : 1)
|
||||||
let n = Math.floor(spawn) + (Math.random() < spawn % 1 ? 1 : 0)
|
let n = Math.floor(spawn) + (Math.random() < spawn % 1 ? 1 : 0)
|
||||||
this.blob.mesh.getWorldPosition(this._spawnAt)
|
this.blob.mesh.getWorldPosition(this._spawnAt)
|
||||||
while (n-- > 0) this.spawnEmber(this._spawnAt)
|
while (n-- > 0) this.spawnEmber(this._spawnAt)
|
||||||
|
|||||||
@ -19,6 +19,8 @@ import {
|
|||||||
computeModifiers,
|
computeModifiers,
|
||||||
countBuckets,
|
countBuckets,
|
||||||
coverageReportFromCounts,
|
coverageReportFromCounts,
|
||||||
|
hasMeteor,
|
||||||
|
hasZap,
|
||||||
colorIndex,
|
colorIndex,
|
||||||
} from './coverage-math'
|
} from './coverage-math'
|
||||||
|
|
||||||
@ -130,6 +132,14 @@ near(buffStrength(0.45), 0.15 + 0.85 * 0.5, 'strength ramps linearly at midpoint
|
|||||||
near(sup.jumpMul, 1 + BOUNCE_JUMP, 'orange 70% jumpMul maxed')
|
near(sup.jumpMul, 1 + BOUNCE_JUMP, 'orange 70% jumpMul maxed')
|
||||||
near(sup.glow, 1, 'orange 70% is super (glow)')
|
near(sup.glow, 1, 'orange 70% is super (glow)')
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// METEOR = red+orange both ≥ ACTIVATE; ZAP = yellow ≥ ACTIVATE
|
||||||
|
ok(!hasMeteor(cov({ red: 0.5 })), 'red alone is not METEOR')
|
||||||
|
ok(!hasMeteor(cov({ red: 0.5, orange: 0.19 })), 'orange below threshold is not METEOR')
|
||||||
|
ok(hasMeteor(cov({ red: 0.2, orange: 0.2 })), 'red+orange at threshold IS METEOR')
|
||||||
|
ok(!hasZap(cov({ yellow: 0.19 })), 'yellow 19% not ZAP')
|
||||||
|
ok(hasZap(cov({ yellow: 0.2 })), 'yellow 20% is ZAP')
|
||||||
|
}
|
||||||
{
|
{
|
||||||
// massMul clamps at 1.8 when fully painted (no purple/pink → base only)
|
// massMul clamps at 1.8 when fully painted (no purple/pink → base only)
|
||||||
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
|
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
|
||||||
|
|||||||
@ -99,6 +99,20 @@ export function superColor(cov: CoverageReport): PaintColor | null {
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Blends & abilities (GDD §5.1/§6): adjacent pairs, both ≥ ACTIVATE -----
|
||||||
|
|
||||||
|
/** METEOR = RED + ORANGE both active (GDD §6): flaming cannonball — smashes
|
||||||
|
* light barriers (see createSmashWall) and burns hotter (BuffSystem embers). */
|
||||||
|
export function hasMeteor(cov: CoverageReport): boolean {
|
||||||
|
return cov.byColor.red >= ACTIVATE && cov.byColor.orange >= ACTIVATE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ZAP = YELLOW active (GDD §6): static charge — triggers signal-driven
|
||||||
|
* machinery at range (ZapSystem pulses nearby machines' signals). */
|
||||||
|
export function hasZap(cov: CoverageReport): boolean {
|
||||||
|
return cov.byColor.yellow >= ACTIVATE
|
||||||
|
}
|
||||||
|
|
||||||
/** Dominant (highest-coverage) colour, or null if nothing is painted. */
|
/** Dominant (highest-coverage) colour, or null if nothing is painted. */
|
||||||
export function dominantColor(cov: CoverageReport): PaintColor | null {
|
export function dominantColor(cov: CoverageReport): PaintColor | null {
|
||||||
let best: PaintColor | null = null
|
let best: PaintColor | null = null
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
|
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
|
||||||
import { PALETTE } from '../contracts'
|
import { PALETTE } from '../contracts'
|
||||||
import { COLOR_ORDER, ACTIVATE, SUPER } from './coverage-math'
|
import { COLOR_ORDER, ACTIVATE, SUPER, hasMeteor, hasZap } from './coverage-math'
|
||||||
|
|
||||||
export class PaintHUD {
|
export class PaintHUD {
|
||||||
readonly root: HTMLDivElement
|
readonly root: HTMLDivElement
|
||||||
@ -102,8 +102,16 @@ export class PaintHUD {
|
|||||||
|
|
||||||
private buffText(cov: CoverageReport, mods: BlobModifiers): string {
|
private buffText(cov: CoverageReport, mods: BlobModifiers): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
if (hasMeteor(cov)) {
|
||||||
|
// the blend REPLACES its pair's rows (GDD §5.1: "blend effect replaces/joins")
|
||||||
|
parts.push(
|
||||||
|
`<b style="color:${PALETTE.orange};text-shadow:0 0 6px ${PALETTE.red}">☄ METEOR</b>` +
|
||||||
|
` ×${mods.speedMul.toFixed(2)}/×${mods.jumpMul.toFixed(2)}`)
|
||||||
|
} else {
|
||||||
if (cov.byColor.red >= ACTIVATE) parts.push(`<b style="color:${PALETTE.red}">BURN</b> ×${mods.speedMul.toFixed(2)}`)
|
if (cov.byColor.red >= ACTIVATE) parts.push(`<b style="color:${PALETTE.red}">BURN</b> ×${mods.speedMul.toFixed(2)}`)
|
||||||
if (cov.byColor.orange >= ACTIVATE) parts.push(`<b style="color:${PALETTE.orange}">BOUNCE</b> ×${mods.jumpMul.toFixed(2)}`)
|
if (cov.byColor.orange >= ACTIVATE) parts.push(`<b style="color:${PALETTE.orange}">BOUNCE</b> ×${mods.jumpMul.toFixed(2)}`)
|
||||||
|
}
|
||||||
|
if (hasZap(cov)) parts.push(`<b style="color:${PALETTE.yellow}">⚡ ZAP</b>`)
|
||||||
if (cov.byColor.green >= ACTIVATE) parts.push(`<b style="color:${PALETTE.green}">GRIP</b> ${mods.grip.toFixed(2)}`)
|
if (cov.byColor.green >= ACTIVATE) parts.push(`<b style="color:${PALETTE.green}">GRIP</b> ${mods.grip.toFixed(2)}`)
|
||||||
if (cov.byColor.blue >= ACTIVATE) parts.push(`<b style="color:${PALETTE.blue}">SLICK</b> waterproof`)
|
if (cov.byColor.blue >= ACTIVATE) parts.push(`<b style="color:${PALETTE.blue}">SLICK</b> waterproof`)
|
||||||
if (cov.byColor.purple >= ACTIVATE) parts.push(`<b style="color:${PALETTE.purple}">MEGA</b> ×${mods.size.toFixed(2)}`)
|
if (cov.byColor.purple >= ACTIVATE) parts.push(`<b style="color:${PALETTE.purple}">MEGA</b> ×${mods.size.toFixed(2)}`)
|
||||||
|
|||||||
@ -24,6 +24,8 @@ export {
|
|||||||
ACTIVATE,
|
ACTIVATE,
|
||||||
SUPER,
|
SUPER,
|
||||||
BOUNCE_JUMP,
|
BOUNCE_JUMP,
|
||||||
|
hasMeteor,
|
||||||
|
hasZap,
|
||||||
} from './coverage-math'
|
} from './coverage-math'
|
||||||
|
|
||||||
export interface PaintEventTarget {
|
export interface PaintEventTarget {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user