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 {
|
import {
|
||||||
createPaintMine, createColorGate, createSpringBoot, createSeeSaw, createFan,
|
createPaintMine, createColorGate, createSpringBoot, createSeeSaw, createFan,
|
||||||
createBucketDump, createBubbleArch, createConveyorBelt, createPressurePlate,
|
createBucketDump, createBubbleArch, createConveyorBelt, createPressurePlate,
|
||||||
applySurface,
|
createPaintBubbles, 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,
|
||||||
SurfaceName,
|
PaintBubblesConfig, 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'
|
||||||
@ -52,6 +52,7 @@ export type MachineSpec =
|
|||||||
| { kind: 'bubble'; cfg: BubbleArchConfig }
|
| { kind: 'bubble'; cfg: BubbleArchConfig }
|
||||||
| { kind: 'plate'; cfg: PressurePlateConfig }
|
| { kind: 'plate'; cfg: PressurePlateConfig }
|
||||||
| { kind: 'conveyor'; cfg: ConveyorBeltConfig }
|
| { kind: 'conveyor'; cfg: ConveyorBeltConfig }
|
||||||
|
| { kind: 'bubbles'; cfg: PaintBubblesConfig }
|
||||||
| { 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 {
|
||||||
@ -130,6 +131,7 @@ export function buildCourseFromSpec(
|
|||||||
case 'bubble': createBubbleArch(world, m.cfg); break
|
case 'bubble': createBubbleArch(world, m.cfg); break
|
||||||
case 'plate': createPressurePlate(world, m.cfg); break
|
case 'plate': createPressurePlate(world, m.cfg); break
|
||||||
case 'conveyor': createConveyorBelt(world, m.cfg); break
|
case 'conveyor': createConveyorBelt(world, m.cfg); break
|
||||||
|
case 'bubbles': createPaintBubbles(world, m.cfg); 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
|
||||||
|
|||||||
@ -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 } },
|
{ 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
|
// 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] } },
|
{ 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
|
// 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)
|
// 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 },
|
{ 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 { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
|
||||||
import {
|
import {
|
||||||
createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate,
|
createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate,
|
||||||
SURFACES, applySurface,
|
createPaintBubbles, SURFACES, applySurface,
|
||||||
} from './machine/index'
|
} from './machine/index'
|
||||||
import { installZones } from './course/zones'
|
import { installZones } from './course/zones'
|
||||||
import { buildCourseFromSpec, COURSES, type FinishBox } from './course/spec'
|
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
|
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 ----
|
// ---- paint-mines: first-through floor panels on the centre line ----
|
||||||
// REWARD: roll over it and it launches you forward + coats you GREEN (GRIP) —
|
// 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,
|
// a shortcut booster for the runner who takes the middle. HAZARD: further down,
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export {
|
|||||||
createFan,
|
createFan,
|
||||||
createPaintMine,
|
createPaintMine,
|
||||||
createColorGate,
|
createColorGate,
|
||||||
|
createPaintBubbles,
|
||||||
} from './parts'
|
} from './parts'
|
||||||
export type {
|
export type {
|
||||||
Vec3,
|
Vec3,
|
||||||
@ -36,4 +37,5 @@ export type {
|
|||||||
FanConfig,
|
FanConfig,
|
||||||
PaintMineConfig,
|
PaintMineConfig,
|
||||||
ColorGateConfig,
|
ColorGateConfig,
|
||||||
|
PaintBubblesConfig,
|
||||||
} from './parts'
|
} from './parts'
|
||||||
|
|||||||
@ -1072,3 +1072,172 @@ export function createColorGate(world: World, cfg: ColorGateConfig): MachinePart
|
|||||||
|
|
||||||
return { id: cfg.id, group }
|
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