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:
parent
2cf2d15623
commit
e71176d067
@ -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
|
||||
|
||||
@ -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 },
|
||||
|
||||
10
src/game.ts
10
src/game.ts
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user