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 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<SmashWallConfig, 'qualifies'>; 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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
58
src/game.ts
58
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. ----
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(`<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 (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.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.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)}`)
|
||||
|
||||
@ -24,6 +24,8 @@ export {
|
||||
ACTIVATE,
|
||||
SUPER,
|
||||
BOUNCE_JUMP,
|
||||
hasMeteor,
|
||||
hasZap,
|
||||
} from './coverage-math'
|
||||
|
||||
export interface PaintEventTarget {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user