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>
78 lines
2.3 KiB
TypeScript
78 lines
2.3 KiB
TypeScript
/**
|
|
* Lane B (Paint) public surface. Integration (game.ts) imports from here.
|
|
*/
|
|
import * as THREE from 'three'
|
|
import type { PaintColor, World } from '../contracts'
|
|
import { PaintSkin } from './skin'
|
|
|
|
export { PaintSkin } from './skin'
|
|
export type { PaintSkinOptions } from './skin'
|
|
export { PaintCannon, solveBallisticVelocity } from './cannon'
|
|
export type { PaintCannonConfig } from './cannon'
|
|
export { BuffSystem } from './buffs'
|
|
export type { BuffTarget } from './buffs'
|
|
export { PaintHUD } from './hud'
|
|
export {
|
|
COLOR_ORDER,
|
|
colorIndex,
|
|
countBuckets,
|
|
coverageReportFromCounts,
|
|
computeModifiers,
|
|
buffStrength,
|
|
superColor,
|
|
dominantColor,
|
|
ACTIVATE,
|
|
SUPER,
|
|
BOUNCE_JUMP,
|
|
hasMeteor,
|
|
hasZap,
|
|
} from './coverage-math'
|
|
|
|
export interface PaintEventTarget {
|
|
/** The paintable body — its world position is used for proximity gating. */
|
|
mesh: THREE.Object3D
|
|
paint: PaintSkin
|
|
}
|
|
|
|
/**
|
|
* Wire the inbound paint protocol from Lane C onto a blob's skin:
|
|
* 'paint:request-splat' {point, color, radius} — bucket dumps, sprays
|
|
* 'paint:request-cleanse' {fraction} — bubble arches, rinses
|
|
* request-splat is proximity-gated to the blob (ground decals are V1), and a
|
|
* matched splat re-emits 'paint:splatted' {target:'blob'} so downstream listeners
|
|
* (VFX, audio, netcode) see a single canonical splat event. Returns an unsub fn.
|
|
*/
|
|
export function installPaint(world: World, target: PaintEventTarget): () => void {
|
|
const center = new THREE.Vector3()
|
|
const scale = new THREE.Vector3()
|
|
|
|
const worldRadius = (): number => {
|
|
target.mesh.getWorldScale(scale)
|
|
return target.paint.boundingRadius * Math.max(scale.x, scale.y, scale.z)
|
|
}
|
|
|
|
const offSplat = world.events.on(
|
|
'paint:request-splat',
|
|
(p: { point: THREE.Vector3; color: PaintColor; radius: number }) => {
|
|
if (!p?.point) return
|
|
target.mesh.getWorldPosition(center)
|
|
if (center.distanceTo(p.point) <= worldRadius() + p.radius) {
|
|
target.paint.splatAtPoint(p.point, p.color, p.radius)
|
|
world.events.emit('paint:splatted', { color: p.color, target: 'blob' })
|
|
}
|
|
},
|
|
)
|
|
|
|
const offCleanse = world.events.on(
|
|
'paint:request-cleanse',
|
|
(p: { fraction: number }) => {
|
|
target.paint.cleanse(p?.fraction ?? 1)
|
|
},
|
|
)
|
|
|
|
return () => {
|
|
offSplat()
|
|
offCleanse()
|
|
}
|
|
}
|