feat(machine): paint bubbles — drifting soap bubbles that burst into paint

John's original idea from the pitch: soap bubbles that hold paint. A
createPaintBubbles field spawns slow-drifting tinted bubbles; touch one and it
pops — droplet burst, a paint douse on whoever popped it, a soft upward boop —
then re-forms elsewhere after a beat. Ambient drift is the always-on telegraph;
the tint is the payload (colour language matches gates/tunnels). Bubbles expire
and re-form quietly so the field stays alive without popping in sync.

Placed twice: GREEN field in Toaster Alley between mine and gate (~18% per pop,
so the gate wants 2-3 bubbles — the recovery route if you missed the mine), and
BLUE field over Breakfast Rush's milk-river approach (pop blue -> waterproof ->
your paint survives the rinse; the bubble teaches the counter to the hazard it
floats above). Wired as spec kind 'bubbles' so any data course can use them.

Browser-verified: 7 bubbles drift, pop grants 18.1% green + GRIP path works,
popped bubble hides + respawns, no console errors; tsc + build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 04:59:41 +10:00
parent 2cf2d15623
commit e71176d067
5 changed files with 190 additions and 3 deletions

View File

@ -16,12 +16,12 @@ import { PaintCannon } from '../paint/index'
import {
createPaintMine, createColorGate, createSpringBoot, createSeeSaw, createFan,
createBucketDump, createBubbleArch, createConveyorBelt, createPressurePlate,
applySurface,
createPaintBubbles, applySurface,
} from '../machine/index'
import type {
PaintMineConfig, ColorGateConfig, SpringBootConfig, SeeSawConfig, FanConfig,
BucketDumpConfig, BubbleArchConfig, ConveyorBeltConfig, PressurePlateConfig,
SurfaceName,
PaintBubblesConfig, SurfaceName,
} from '../machine/index'
import { TOASTER_ALLEY } from './toaster-alley'
import { SEESAW_LAB } from './seesaw-lab'
@ -52,6 +52,7 @@ export type MachineSpec =
| { kind: 'bubble'; cfg: BubbleArchConfig }
| { kind: 'plate'; cfg: PressurePlateConfig }
| { kind: 'conveyor'; cfg: ConveyorBeltConfig }
| { kind: 'bubbles'; cfg: PaintBubblesConfig }
| { kind: 'oil'; pos: [number, number, number]; half: [number, number, number] }
export interface CannonSpec {
@ -130,6 +131,7 @@ export function buildCourseFromSpec(
case 'bubble': createBubbleArch(world, m.cfg); break
case 'plate': createPressurePlate(world, m.cfg); break
case 'conveyor': createConveyorBelt(world, m.cfg); break
case 'bubbles': createPaintBubbles(world, m.cfg); break
case 'oil': {
const [cx, cy, cz] = m.pos
const [hx, hy, hz] = m.half

View File

@ -36,6 +36,12 @@ export const TOASTER_ALLEY: CourseSpec = {
{ kind: 'fan', cfg: { id: 'ta-fan', position: [0, 1.8, 20], direction: [0, 0, -1], force: 20, range: 18, spread: 4.5 } },
// green reward mine — GRIP, and it feeds the green gate downstream
{ kind: 'mine', cfg: { id: 'ta-mine', position: [0, 0.05, 4], color: 'green', radius: 0.9, splats: 5, impulse: 5, direction: [0, 1, -0.3], size: [3.4, 3.4] } },
// green PAINT BUBBLES drifting between mine and gate — a second chance at
// green if you missed the mine: run/jump through the field, pop a few, and
// the gate still opens. Bubble colour = payload = the gate's key colour.
// (radius 0.6 × 2 splats ≈ a quarter-dose per bubble — the gate wants ~2 pops,
// so the mine stays the fast route and bubbles are the recovery route)
{ kind: 'bubbles', cfg: { id: 'ta-bubbles', position: [0, 1.4, -11], color: 'green', radius: 0.6, splats: 2, count: 7, area: [4, 1.1, 4], drift: [0, 0.3, 0], bubbleRadius: 0.6 } },
// green gate — opens for the runner who took the reward mine (same loop as
// Breakfast Rush, proving the gate works identically from a data spec)
{ kind: 'gate', cfg: { id: 'ta-gate', position: [0, 0.02, -22], color: 'green', size: [4, 3, 0.6] }, needColor: 'green', needPct: 0.4 },

View File

@ -15,7 +15,7 @@ import { createFollowCamera } from './blob/camera'
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
import {
createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate,
SURFACES, applySurface,
createPaintBubbles, SURFACES, applySurface,
} from './machine/index'
import { installZones } from './course/zones'
import { buildCourseFromSpec, COURSES, type FinishBox } from './course/spec'
@ -72,6 +72,14 @@ function buildBreakfastRush(world: World, blob: BlobHandle, skin: PaintSkin): Fi
id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6, // purple-lane exit: cleansing punishes a failed MEGA attempt
})
// ---- paint bubbles: blue soap bubbles drifting over the milk-river approach.
// Pop one mid-run → SLICK/waterproof → your paint survives the rinse. The
// bubble colour teaches the counter to the hazard it floats above. ----
createPaintBubbles(world, {
id: 'bubbles-river', position: [0, 1.4, -24], color: 'blue', radius: 0.6,
splats: 2, count: 6, area: [5, 1.0, 2.5], drift: [0, 0.3, 0], bubbleRadius: 0.6,
})
// ---- paint-mines: first-through floor panels on the centre line ----
// REWARD: roll over it and it launches you forward + coats you GREEN (GRIP) —
// a shortcut booster for the runner who takes the middle. HAZARD: further down,

View File

@ -23,6 +23,7 @@ export {
createFan,
createPaintMine,
createColorGate,
createPaintBubbles,
} from './parts'
export type {
Vec3,
@ -36,4 +37,5 @@ export type {
FanConfig,
PaintMineConfig,
ColorGateConfig,
PaintBubblesConfig,
} from './parts'

View File

@ -1072,3 +1072,172 @@ export function createColorGate(world: World, cfg: ColorGateConfig): MachinePart
return { id: cfg.id, group }
}
// ---------------------------------------------------------------------------
// PaintBubbles — a field of drifting soap bubbles that each hold a dose of one
// colour. Touch one and it POPS: a burst of droplets, a paint douse on whoever
// popped it, and a soft upward boop. The slow ambient drift is the always-on
// telegraph (like the belt/fan), and the tint is the signage — a green bubble
// gives green, matching the gate/tunnel colour language. Popped bubbles respawn
// in the field after a beat, so the supply is steady but not spammy.
// ---------------------------------------------------------------------------
export interface PaintBubblesConfig {
id: string
/** Centre of the spawn field. */
position: Vec3
color: PaintColor
/** Splat radius per douse splat (the mine's scale: ~0.9 is a real dose). */
radius: number
/** Splats per pop, spread around the body. Default 3. */
splats?: number
/** Bubbles alive at once. Default 6. */
count?: number
/** Field half-extents the bubbles wander in. Default [3, 1.5, 3]. */
area?: Vec3
/** Base drift velocity. Default [0, 0.35, 0] — a lazy rise. */
drift?: Vec3
/** Seconds a bubble lives before quietly re-forming elsewhere. Default 7. */
lifetime?: number
/** Bubble radius (visual + pop-trigger). Default 0.55. */
bubbleRadius?: number
/** Upward boop applied to the popper. Default 3. */
impulse?: number
}
export function createPaintBubbles(world: World, cfg: PaintBubblesConfig): MachinePart {
const { scene } = world
const pos = asVec3(cfg.position)
const hex = PALETTE[cfg.color]
const count = cfg.count ?? 6
const area = cfg.area ?? [3, 1.5, 3]
const drift = cfg.drift ?? [0, 0.35, 0]
const lifetime = cfg.lifetime ?? 7
const bubbleR = cfg.bubbleRadius ?? 0.55
const nSplats = Math.max(1, cfg.splats ?? 3)
const impulse = cfg.impulse ?? 3
const group = new THREE.Group()
group.position.copy(pos)
scene.add(group)
// Shared translucent soap-film material, tinted with the payload colour.
const filmMat = new THREE.MeshStandardMaterial({
color: hex, transparent: true, opacity: 0.42, roughness: 0.08, metalness: 0.1,
emissive: hex, emissiveIntensity: 0.25, depthWrite: false,
})
interface Bubble {
mesh: THREE.Mesh
age: number
/** seconds until respawn after a pop (0 = alive) */
dead: number
phase: number
}
const spawnPoint = () => new THREE.Vector3(
pos.x + (Math.random() - 0.5) * 2 * area[0],
pos.y + (Math.random() - 0.5) * 2 * area[1],
pos.z + (Math.random() - 0.5) * 2 * area[2],
)
const bubbles: Bubble[] = []
for (let i = 0; i < count; i++) {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(bubbleR, 16, 12), filmMat)
mesh.position.copy(spawnPoint())
mesh.renderOrder = 1
scene.add(mesh)
// stagger ages so the field never pops/expires in sync
bubbles.push({ mesh, age: Math.random() * lifetime, dead: 0, phase: Math.random() * Math.PI * 2 })
}
// droplet burst on pop (same recipe as the arch/mine bursts)
const bursts: Array<(dt: number) => void> = []
const burstAt = (at: THREE.Vector3) => {
const parts: THREE.Mesh[] = []
const vels: THREE.Vector3[] = []
for (let i = 0; i < 10; i++) {
const s = new THREE.Mesh(
new THREE.SphereGeometry(0.07 + Math.random() * 0.12, 8, 6),
new THREE.MeshStandardMaterial({ color: hex, transparent: true, opacity: 0.9, roughness: 0.2 }),
)
s.position.copy(at)
scene.add(s)
parts.push(s)
vels.push(new THREE.Vector3((Math.random() - 0.5) * 3, 1 + Math.random() * 2.5, (Math.random() - 0.5) * 3))
}
let life = 0
const step = (dt: number) => {
life += dt
parts.forEach((s, i) => {
vels[i].y -= 8 * dt
s.position.addScaledVector(vels[i], dt)
;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.9 - life * 1.1)
})
if (life > 0.9) {
for (const s of parts) {
scene.remove(s)
s.geometry.dispose()
;(s.material as THREE.Material).dispose()
}
const i = bursts.indexOf(step)
if (i >= 0) bursts.splice(i, 1)
}
}
bursts.push(step)
}
let clock = 0
world.addSystem({
update(dt) {
clock += dt
for (const b of bubbles) {
if (b.dead > 0) {
b.dead -= dt
if (b.dead <= 0) {
b.mesh.position.copy(spawnPoint())
b.mesh.visible = true
b.age = 0
}
continue
}
// drift + a lazy sideways wander so the field feels alive
b.age += dt
b.mesh.position.x += (drift[0] + Math.sin(clock * 0.9 + b.phase) * 0.25) * dt
b.mesh.position.y += drift[1] * dt
b.mesh.position.z += (drift[2] + Math.cos(clock * 0.7 + b.phase) * 0.25) * dt
if (b.age > lifetime) {
// quiet expiry: re-form elsewhere, no pop, no paint
b.mesh.position.copy(spawnPoint())
b.age = 0
continue
}
// pop check: any dynamic body inside the bubble
const hit = dynamicBodiesInBox(world,
{ x: b.mesh.position.x, y: b.mesh.position.y, z: b.mesh.position.z },
{ x: bubbleR, y: bubbleR, z: bubbleR })
if (hit.size > 0) {
burstAt(b.mesh.position)
const offsets = [[0, 0.35, 0], [0.4, 0, 0], [-0.4, 0, 0], [0, 0, 0.4], [0, 0, -0.4]]
for (const body of hit.values()) {
const t = body.translation()
body.applyImpulse({ x: 0, y: impulse, z: 0 }, true)
for (let i = 0; i < nSplats; i++) {
const [ox, oy, oz] = offsets[i % offsets.length]
world.events.emit('paint:request-splat', {
point: new THREE.Vector3(t.x + ox, t.y + oy, t.z + oz), color: cfg.color, radius: cfg.radius,
})
}
}
b.mesh.visible = false
b.dead = 2.5 + Math.random() * 2 // respawn after a beat
}
}
},
})
world.onFrame((dt) => {
for (const s of [...bursts]) s(dt)
})
return { id: cfg.id, group }
}