fuzzyworld

bubbles / files / PLAN.md

PLAN.md

Blame: the commit that last changed each line · file · history

775e32f9 2026-04-09 Pavel Safronov1# Bubbles — Multi-Level Plan
2
3## Overview
4
5Four changes are in scope:
6
71. **Sealed container + full 360° gravity** — foundational, affects all levels
82. **Level: Water glass** — current level, updated for sealed physics
93. **Level: Lava lamp** — metaball blobs, two densities, thermal cycling
104. **Level: Sand · Water · Air** — three-phase layered simulation
115. **Level: Shampoo bottle** — high-viscosity fluid with merging air bubbles
12
13---
14
15## 1. Sealed Container + Full 360° Gravity
16
17### What changes
18
19**`TiltInput`** — currently only uses `gamma` (left/right tilt) and clamps to ±88°.
20Full rotation means also using `beta` (front/back) to detect upside-down orientation.
21Output stays `gravity: Vector2` but now truly reflects 3D device orientation projected onto the screen plane:
22
23```
24gx = sin(gamma) * cos(beta) * 980
25gy = sin(beta) * 980 (negative = phone tilted face-down = gravity pulls "up")
26```
27
28When the phone is fully vertical face-down, `gy ≈ −980` and liquid should rest at the top of the container.
29
30**`Water` (SWE)** — remove rim spill (no permanent volume loss). Add a top-wall clamp symmetrically to the floor clamp. The lateral body-force term already handles any sign of `lateralG`. Negative `verticalG` is the upside-down case — buoyancy reversal needs to happen in `World`, not `Water`.
31
32**`World`** — buoyancy force direction is currently hardcoded to oppose `g.y`. Must change to oppose the full `g` vector so objects float in the direction opposite to gravity. When upside-down, dense objects float and light objects sink.
33
34**`GlassRenderer`** — container is sealed: render top wall the same as side walls (no open top).
35
36**No other API surface changes** — `gravity: Vector2` stays the same shape.
37
38### Risk: low. The SWE floor/ceiling clamping is already symmetric; this is mostly removing the spill path and fixing TiltInput.
39
40---
41
42## 2. Level Architecture
43
44### New structure
45
46```
47src/levels/
48 ILevel.ts — interface
49 WaterLevel.ts — current water glass (sealed)
50 LavaLampLevel.ts
51 SandWaterLevel.ts
52 ShampooLevel.ts
53```
54
55### `ILevel` interface
56
57```ts
58interface ILevel {
59 /** Called once per physics tick. gravity is always the device gravity vector. */
60 step(dt: number, gravity: Vector2): void;
61 /** Called once per rAF frame. */
62 draw(ctx: CanvasRenderingContext2D, containerRect: DOMRect): void;
63 /** Called on touch/pointer event inside container. */
64 onTouch(localX: number, localY: number): void;
65 /** Called when container size changes. */
66 resize(w: number, h: number): void;
67 /** Reset to initial state. */
68 reset(): void;
69 readonly name: string;
70}
71```
72
73### `App.ts` changes
74
75- Holds an `ILevel` (starts with `WaterLevel`)
76- `nextLevel()` / `prevLevel()` cycle through the four levels
77- Swipe gesture (horizontal swipe on the glass) triggers level change
78- Level name shown briefly as a fade-in/out toast overlay (no persistent UI)
79
80### Level picker UI
81
82Single swipe left/right on the glass switches levels. A small dot-row indicator (4 dots, current dot filled) appears at the bottom of the glass for 2 seconds after a level change, then fades out. No separate level-select screen needed.
83
84---
85
86## 3. Level: Water Glass (updated)
87
88No new simulation. Changes from current:
89- Sealed (top wall, no spill)
90- Full 360° gravity
91- Touch spawns emoji objects (same as today)
92- QR body stays permanent
93
94---
95
96## 4. Level: Lava Lamp
97
98### Concept
99
100~10 blobs split between two "wax" types:
101- **Hot wax** (density 0.92, warm orange-red): rises when near the bottom heat source
102- **Cool wax** (density 1.08, deep red-purple): sinks when near the top
103
104Tilting moves blobs laterally. No interaction with `Water` — this level has no water.
105
106### Simulation approach: soft-body blobs
107
108Each blob is a `Vector2` position + velocity (not a `RigidBody`). Integration:
109
1101. **Thermal density**: each blob tracks a "temperature" scalar that drifts toward a target based on distance from the simulated heat source (always opposite gravity direction = "floor"). Hot → lower density → upward buoyancy. Cool → higher density → downward.
1112. **Buoyancy**: `F_buoy = (fluidDensity − blobDensity) × volume × g`
1123. **Blob–blob repulsion**: when blobs of *any* type overlap, push apart (incompressibility)
1134. **Same-type soft attraction**: gentle pull between blobs of same type within 2× radius (creates the "merging" feel without actual merge/split logic)
1145. **Wall collision**: elastic bounce off all four walls
1156. **Damping**: high fluid drag (lava is viscous)
116
117### Rendering
118
119Metaballs via Canvas 2D:
120- Draw each blob as an offscreen radial gradient: opaque center → transparent edge at ~1.5× radius
121- Use `ctx.filter = 'blur(Xpx)'` on an offscreen canvas, then `ctx.filter = 'contrast(Y)'` on the main canvas when compositing. This is the classic CSS metaball trick, runs fine on mobile.
122- Color: lerp between hot color and cool color based on blob temperature
123
124### Touch
125
126Tap creates a gentle gravity-perpendicular impulse in the blob nearest the touch point.
127
128### Risk: medium. Metaball filter trick needs profiling on mobile — blur radius must stay small (≤8px). No TypeScript complexity.
129
130---
131
132## 5. Level: Sand · Water · Air
133
134### Concept
135
136Three materials layered by density, reacting to tilt:
137- **Sand** (~40 particles, radius 4px, density 2.5) — settles at "bottom"
138- **Water** (SWE 1D, same as WaterLevel) — middle layer
139- **Air bubble** (1–3 bodies, radius 20–35px, density 0.001) — floats at "top"
140
141### Simulation approach
142
143Reuse `Water` (SWE) + `World` (RigidBody). Sand and air are both `RigidBody` instances with extreme densities. The buoyancy code in `World` already handles them correctly:
144- Sand (density 2.5): sinks, rests on floor
145- Air bubble (density 0.001): strong upward buoyancy, presses against ceiling (sealed top wall)
146
147**Sand–sand collision**: existing `resolveBodyCollision` handles it. 40 particles = 780 pair checks per frame — acceptable.
148
149**Sand resting**: add static friction when a sand particle's speed < threshold and it's resting on the floor or another particle.
150
151**Air bubble rendering**: white/translucent circle with a bright specular highlight arc (not emoji — just canvas arcs).
152
153**Water rendering**: same SWE renderer, but water only fills the middle volume. The initial fill fraction accounts for the volume displaced by sand at the bottom.
154
155### Touch
156
157Tap disturbs water surface (same `disturb()` call) and scatters nearby sand particles via an impulse.
158
159### Risk: medium-high. 40 RigidBodies × pair collision = O(N²) = 780 pairs — should be fine. The tricky part is sand settling stably without jitter; static friction threshold needs tuning.
160
161---
162
163## 6. Level: Shampoo Bottle
164
165### Concept
166
167A sealed container full of thick, slow-moving fluid with 3–5 trapped air bubbles that:
168- Rise toward the "top" (opposite gravity)
169- Merge when they touch (become one larger bubble, conservation of volume)
170- Split when shaken hard (large velocity impulse → one bubble splits into two)
171
172### Simulation approach
173
174**Fluid**: reuse `Water` SWE but with:
175- High `K_DAMP` (≈ 5.0 instead of 1.5) → very slow wave propagation
176- Lower wave speed feel: reduce effective `G_SW` by 60% (shampoo is more viscous, slower pressure propagation)
177- Color: amber/pearlescent gradient
178
179**Bubbles**: `RigidBody` with density 0.001, but rendered as soft spheres (not emoji). Bubble–bubble interaction uses the same `resolveBodyCollision` but with high restitution.
180
181**Merge logic** (in `World` or a `ShampooLevel` post-step hook):
182- After collision resolution, if two bubbles overlap by > 60% of the smaller radius → merge
183- Remove both, spawn one bubble with `r_new = ∛(r₁³ + r₂³)` (volume conservation)
184
185**Split logic**:
186- If a bubble's speed exceeds a threshold (e.g., 400 px/s) → split into two with radii scaled by `∛(0.5)`
187
188**Rendering**: each bubble is a white circle with `globalAlpha ≈ 0.6`, a bright arc highlight at 10–2 o'clock, and a light shadow at 6 o'clock.
189
190### Touch
191
192Tap near a bubble gives it a strong impulse (may trigger split). Tap on fluid disturbs the SWE surface.
193
194### Risk: low-medium. Merge/split logic is the only novel code. Volume conservation via cube-root radius is straightforward.
195
196---
197
198## Implementation Order
199
2001. ~~`TiltInput` — full 360° gravity (prerequisite for everything)~~ ✅
2012. ~~Sealed container in `Water`, `World`, `GlassRenderer`~~ ✅
2023. ~~`ILevel` interface + level-switching in `App`~~ ✅
2034. ~~`WaterLevel` — wrap current logic~~ ✅
2045. ~~`LavaLampLevel` — self-contained, no SWE dependency~~ ✅
2056. ~~`ShampooLevel` — extends SWE + merge/split~~ ✅
2067. ~~`SandWaterLevel` — most complex, do last~~ ✅
207
208---
209
210## What stays the same
211
212- `Vector2`, `RigidBody`, `TiltInput` structure (only internals change)
213- `vite.config.ts`, `package.json`, CI — no changes
214- `main.ts` — minor: wire swipe gesture + level dots indicator
215- `WaterRenderer`, `GlassRenderer` — reusable by `WaterLevel` and `ShampooLevel`
216- All rendering is still Canvas 2D, one class per file, no barrel files