From 190a6e27d37c0e46c021be5c1f46e5bfa18a170e Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 28 Jul 2026 22:22:24 +1000 Subject: [PATCH] =?UTF-8?q?feat(paint):=20METEOR=20blend=20+=20yellow=20ZA?= =?UTF-8?q?P=20=E2=80=94=20the=20palette=20is=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/course/spec.ts | 28 ++++++- src/course/zones.ts | 3 + src/game.ts | 58 ++++++++++--- src/machine/index.ts | 4 + src/machine/parts.ts | 140 ++++++++++++++++++++++++++++++++ src/machine/zap.ts | 85 +++++++++++++++++++ src/paint/buffs.ts | 9 +- src/paint/coverage-math.test.ts | 10 +++ src/paint/coverage-math.ts | 14 ++++ src/paint/hud.ts | 14 +++- src/paint/index.ts | 2 + 11 files changed, 344 insertions(+), 23 deletions(-) create mode 100644 src/machine/zap.ts diff --git a/src/course/spec.ts b/src/course/spec.ts index ab12e90..f236ff0 100644 --- a/src/course/spec.ts +++ b/src/course/spec.ts @@ -12,16 +12,17 @@ import * as THREE from 'three' import type { World, PaintColor } from '../contracts' import type { PaintSkin } from '../paint/skin' -import { PaintCannon } from '../paint/index' +import { PaintCannon, ACTIVATE } from '../paint/index' import { createPaintMine, createColorGate, createSpringBoot, createSeeSaw, createFan, createBucketDump, createBubbleArch, createConveyorBelt, createPressurePlate, - createPaintBubbles, createDominoChain, applySurface, + createPaintBubbles, createDominoChain, createSmashWall, installZap, + applySurface, } from '../machine/index' import type { PaintMineConfig, ColorGateConfig, SpringBootConfig, SeeSawConfig, FanConfig, BucketDumpConfig, BubbleArchConfig, ConveyorBeltConfig, PressurePlateConfig, - PaintBubblesConfig, DominoChainConfig, SurfaceName, + PaintBubblesConfig, DominoChainConfig, SmashWallConfig, ZapTarget, SurfaceName, } from '../machine/index' import { TOASTER_ALLEY } from './toaster-alley' import { SEESAW_LAB } from './seesaw-lab' @@ -55,6 +56,7 @@ export type MachineSpec = | { kind: 'conveyor'; cfg: ConveyorBeltConfig } | { kind: 'bubbles'; cfg: PaintBubblesConfig } | { kind: 'dominoes'; cfg: DominoChainConfig } + | { kind: 'smashwall'; cfg: Omit; needColors: [PaintColor, PaintColor] } | { kind: 'oil'; pos: [number, number, number]; half: [number, number, number] } export interface CannonSpec { @@ -73,6 +75,8 @@ export interface CourseSpec { cannons?: CannonSpec[] /** Fall-respawn points, in course order (see course/checkpoints.ts). */ checkpoints?: Array<[number, number, number]> + /** Machinery a YELLOW/ZAP blob can hotwire at range (see machine/zap.ts). */ + zapTargets?: ZapTarget[] } export interface BuildCtx { @@ -137,6 +141,17 @@ export function buildCourseFromSpec( case 'conveyor': createConveyorBelt(world, m.cfg); break case 'bubbles': createPaintBubbles(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': { const [cx, cy, cz] = m.pos 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 } } diff --git a/src/course/zones.ts b/src/course/zones.ts index 058ca5b..3d424b4 100644 --- a/src/course/zones.ts +++ b/src/course/zones.ts @@ -62,6 +62,8 @@ export interface ZonesHandle { plateId: string plateThreshold: number bootSignal: string + /** Where the boot stands — so integration can declare it as a ZAP target. */ + bootPosition: Vec3 tunnel: TunnelInfo totalStamps(): number } @@ -168,6 +170,7 @@ export function installZones(world: World, blob: Blob, skin: PaintSkin): ZonesHa plateId: PLATE_ID, plateThreshold, bootSignal: BOOT_SIGNAL, + bootPosition: BOOT_POS, tunnel: { roofUndersideY: TUNNEL_CENTER[1] - TUNNEL_HALF[1], center: TUNNEL_CENTER, half: TUNNEL_HALF }, totalStamps: puddlesHandle.totalStamps, } diff --git a/src/game.ts b/src/game.ts index a0ea3d0..fd291bc 100644 --- a/src/game.ts +++ b/src/game.ts @@ -12,11 +12,11 @@ import { createBlob, type BlobHandle } from './blob/createBlob' import { createBlobController } from './blob/controller' import { createBlobFeel } from './blob/feel' 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 { createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate, - createPaintBubbles, SURFACES, applySurface, + createPaintBubbles, createSmashWall, installZap, SURFACES, applySurface, } from './machine/index' import { installZones } from './course/zones' 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], }) 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. @@ -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 // 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 }) - 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(2, 3, 0.8), wallMat) - wall.position.set(x, 1.5, gz) - wall.castShadow = true - wall.receiveShadow = true - world.scene.add(wall) - world.physics.createCollider( - world.rapier.ColliderDesc.cuboid(1, 1.5, 0.4).setTranslation(x, 1.5, gz)) - } + const wall = new THREE.Mesh(new THREE.BoxGeometry(1, 3, 0.8), wallMat) + wall.position.set(2.4, 1.5, gz) // plugs the gate→tunnel gap without poking the roof + wall.castShadow = true + wall.receiveShadow = true + world.scene.add(wall) + world.physics.createCollider( + world.rapier.ColliderDesc.cuboid(0.5, 1.5, 0.4).setTranslation(2.4, 1.5, gz)) createColorGate(world, { id: 'gate-green', position: [0, 0.02, gz], color: 'green', size: [4, 3, 0.6], 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 // soft-lock you under the green slope (straight-line drivers wedged at z≈-16.6). // A deterministic launcher bounces fallers back to shelf height. ---- diff --git a/src/machine/index.ts b/src/machine/index.ts index 75951fb..1ed3d54 100644 --- a/src/machine/index.ts +++ b/src/machine/index.ts @@ -25,7 +25,10 @@ export { createColorGate, createPaintBubbles, createDominoChain, + createSmashWall, } from './parts' +export { installZap } from './zap' +export type { ZapTarget, ZapOptions } from './zap' export type { Vec3, MachinePart, @@ -40,4 +43,5 @@ export type { ColorGateConfig, PaintBubblesConfig, DominoChainConfig, + SmashWallConfig, } from './parts' diff --git a/src/machine/parts.ts b/src/machine/parts.ts index 63b254b..6507d62 100644 --- a/src/machine/parts.ts +++ b/src/machine/parts.ts @@ -1358,3 +1358,143 @@ export function createDominoChain(world: World, cfg: DominoChainConfig): Machine 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 } +} diff --git a/src/machine/zap.ts b/src/machine/zap.ts new file mode 100644 index 0000000..0bf6bbf --- /dev/null +++ b/src/machine/zap.ts @@ -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 + } + } + }, + }) +} diff --git a/src/paint/buffs.ts b/src/paint/buffs.ts index abcd920..25e97f5 100644 --- a/src/paint/buffs.ts +++ b/src/paint/buffs.ts @@ -16,6 +16,7 @@ import { buffStrength, computeModifiers, dominantColor, + hasMeteor, superColor, } from './coverage-math' @@ -89,7 +90,7 @@ export class BuffSystem implements System { m.waterproof = mods.waterproof 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)) } @@ -101,11 +102,11 @@ export class BuffSystem implements System { // ---- 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% if (red >= ACTIVATE) { - // spawn rate scales with how hot the blob is - const spawn = strength * 3 + // spawn rate scales with how hot the blob is; METEOR burns twice as hard + const spawn = strength * 3 * (meteor ? 2.2 : 1) let n = Math.floor(spawn) + (Math.random() < spawn % 1 ? 1 : 0) this.blob.mesh.getWorldPosition(this._spawnAt) while (n-- > 0) this.spawnEmber(this._spawnAt) diff --git a/src/paint/coverage-math.test.ts b/src/paint/coverage-math.test.ts index 3fc0b26..947acab 100644 --- a/src/paint/coverage-math.test.ts +++ b/src/paint/coverage-math.test.ts @@ -19,6 +19,8 @@ import { computeModifiers, countBuckets, coverageReportFromCounts, + hasMeteor, + hasZap, colorIndex, } 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.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) const m = computeModifiers(cov({ red: 0.5, blue: 0.5 })) diff --git a/src/paint/coverage-math.ts b/src/paint/coverage-math.ts index 30a3b75..159cbbd 100644 --- a/src/paint/coverage-math.ts +++ b/src/paint/coverage-math.ts @@ -99,6 +99,20 @@ export function superColor(cov: CoverageReport): PaintColor | null { 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. */ export function dominantColor(cov: CoverageReport): PaintColor | null { let best: PaintColor | null = null diff --git a/src/paint/hud.ts b/src/paint/hud.ts index 883f428..43e1ff3 100644 --- a/src/paint/hud.ts +++ b/src/paint/hud.ts @@ -5,7 +5,7 @@ */ import type { BlobModifiers, CoverageReport, PaintColor } 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 { readonly root: HTMLDivElement @@ -102,8 +102,16 @@ export class PaintHUD { private buffText(cov: CoverageReport, mods: BlobModifiers): string { const parts: string[] = [] - if (cov.byColor.red >= ACTIVATE) parts.push(`BURN ×${mods.speedMul.toFixed(2)}`) - if (cov.byColor.orange >= ACTIVATE) parts.push(`BOUNCE ×${mods.jumpMul.toFixed(2)}`) + if (hasMeteor(cov)) { + // the blend REPLACES its pair's rows (GDD §5.1: "blend effect replaces/joins") + parts.push( + `☄ METEOR` + + ` ×${mods.speedMul.toFixed(2)}/×${mods.jumpMul.toFixed(2)}`) + } else { + if (cov.byColor.red >= ACTIVATE) parts.push(`BURN ×${mods.speedMul.toFixed(2)}`) + if (cov.byColor.orange >= ACTIVATE) parts.push(`BOUNCE ×${mods.jumpMul.toFixed(2)}`) + } + if (hasZap(cov)) parts.push(`⚡ ZAP`) if (cov.byColor.green >= ACTIVATE) parts.push(`GRIP ${mods.grip.toFixed(2)}`) if (cov.byColor.blue >= ACTIVATE) parts.push(`SLICK waterproof`) if (cov.byColor.purple >= ACTIVATE) parts.push(`MEGA ×${mods.size.toFixed(2)}`) diff --git a/src/paint/index.ts b/src/paint/index.ts index bc98fc3..de87ae1 100644 --- a/src/paint/index.ts +++ b/src/paint/index.ts @@ -24,6 +24,8 @@ export { ACTIVATE, SUPER, BOUNCE_JUMP, + hasMeteor, + hasZap, } from './coverage-math' export interface PaintEventTarget {