diff --git a/src/course/spec.ts b/src/course/spec.ts index 0dc3243..96eb900 100644 --- a/src/course/spec.ts +++ b/src/course/spec.ts @@ -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 diff --git a/src/course/toaster-alley.ts b/src/course/toaster-alley.ts index 07104c9..baabb9a 100644 --- a/src/course/toaster-alley.ts +++ b/src/course/toaster-alley.ts @@ -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 }, diff --git a/src/game.ts b/src/game.ts index 7690ac8..b8463c2 100644 --- a/src/game.ts +++ b/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, diff --git a/src/machine/index.ts b/src/machine/index.ts index b72abd1..fbd8587 100644 --- a/src/machine/index.ts +++ b/src/machine/index.ts @@ -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' diff --git a/src/machine/parts.ts b/src/machine/parts.ts index f788bb4..a6e2bfb 100644 --- a/src/machine/parts.ts +++ b/src/machine/parts.ts @@ -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 } +}