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:
type-two 2026-07-28 22:22:24 +10:00
parent 2777a580c9
commit 190a6e27d3
11 changed files with 344 additions and 23 deletions

View File

@ -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 }
} }

View File

@ -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,
} }

View File

@ -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(0.5, 1.5, 0.4).setTranslation(2.4, 1.5, gz))
world.rapier.ColliderDesc.cuboid(1, 1.5, 0.4).setTranslation(x, 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. ----

View File

@ -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'

View File

@ -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
View 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
}
}
},
})
}

View File

@ -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)

View File

@ -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 }))

View File

@ -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

View File

@ -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 (cov.byColor.red >= ACTIVATE) parts.push(`<b style="color:${PALETTE.red}">BURN</b> ×${mods.speedMul.toFixed(2)}`) if (hasMeteor(cov)) {
if (cov.byColor.orange >= ACTIVATE) parts.push(`<b style="color:${PALETTE.orange}">BOUNCE</b> ×${mods.jumpMul.toFixed(2)}`) // 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.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)}`)

View File

@ -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 {