fuzzyworld

bubbles / files / src / levels / ShampooLevel.ts

ShampooLevel.ts

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

775e32f9 2026-04-09 Pavel Safronov1import { ILevel } from './ILevel.js';
2import { Vector2 } from '../physics/Vector2.js';
3import { Water } from '../physics/Water.js';
4import { World } from '../physics/World.js';
5import { RigidBody } from '../physics/RigidBody.js';
6import { GlassRenderer } from '../rendering/GlassRenderer.js';
7import { WaterRenderer } from '../rendering/WaterRenderer.js';
8
9const INITIAL_BUBBLES = 4;
10const BUBBLE_DENSITY = 0.001; // extremely light — strong upward buoyancy
11const MIN_BUBBLE_R = 14;
12const MAX_BUBBLE_R = 36;
13// Merge when overlap > this fraction of smaller radius
14const MERGE_THRESHOLD = 0.55;
15// Split when speed exceeds this (px/s)
16const SPLIT_SPEED = 380;
17
18/**
19 * Shampoo bottle level.
20 * High-viscosity amber fluid with air bubbles that merge and split.
21 */
22export class ShampooLevel implements ILevel {
23 readonly name = 'Shampoo';
24
25 private water: Water;
26 private world: World;
27 private waterRend: WaterRenderer;
28 private glassRend: GlassRenderer;
29 private shimmerOffset = 0;
30
31 constructor(
32 private readonly ctx: CanvasRenderingContext2D,
33 private glassX: number,
34 private glassY: number,
35 glassW: number,
36 glassH: number,
37 ) {
38 // Shampoo: same SWE but viscous feel — high damping, slower pressure
39 this.water = new Water(80, glassW, glassH, 0.88, 400, 5.0);
40 this.world = new World(this.water, glassX, glassY);
41 this.waterRend = new WaterRenderer(ctx, this.water, glassX, glassY);
42 this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH);
43 this.spawnInitialBubbles();
44 }
45
46 // ── ILevel ───────────────────────────────────────────────────────────────
47
48 step(dt: number, gravity: Vector2): void {
49 this.world.gravity = gravity;
50 this.world.step(dt);
92e94bb0 2026-04-10 Pavel Safronov51 this.waterRend.gravity = gravity;
775e32f9 2026-04-09 Pavel Safronov52 this.shimmerOffset = (this.shimmerOffset + dt * 0.3) % (Math.PI * 2);
53 this.mergeSplitBubbles();
54 }
55
56 draw(
57 ctx: CanvasRenderingContext2D,
58 glassX: number,
59 glassY: number,
60 _glassW: number,
61 _glassH: number,
62 ): void {
63 this.glassRend.beginClip();
64
65 // Bubbles behind fluid
66 this.drawBubbles(ctx);
67
68 // Amber shampoo fluid
69 this.waterRend.drawTinted(
70 'rgba(255, 170, 30, 0.78)',
71 'rgba(180, 90, 10, 0.90)',
72 'rgba(100, 40, 5, 0.96)',
73 );
74
75 this.glassRend.endClip();
76 this.glassRend.draw();
77 }
78
79 onTouch(localX: number, localY: number, _gravity: Vector2): void {
80 const gx = this.world.glassX;
81 const gy = this.world.glassY;
82 const wx = gx + localX;
83 const wy = gy + localY;
84 // Give nearby bubbles a strong impulse (may trigger split)
85 for (const body of this.world.bodies) {
86 const d = Math.hypot(body.position.x - wx, body.position.y - wy);
87 if (d < body.radius * 3) {
88 const dx = body.position.x - wx;
89 const dy = body.position.y - wy;
90 const len = Math.hypot(dx, dy) + 1;
91 body.velocity.x += (dx / len) * 500;
92 body.velocity.y += (dy / len) * 500;
93 }
94 }
95 // Also disturb fluid surface
96 this.water.disturb(localX, 30, 150);
97 }
98
99 resize(glassW: number, glassH: number): void {
100 this.water.resize(glassW, glassH);
101 this.waterRend.glassX = this.glassX;
102 this.waterRend.glassY = this.glassY;
103 this.glassRend = new GlassRenderer(this.ctx, this.glassX, this.glassY, glassW, glassH);
104 }
105
106 reset(): void {
107 this.world.bodies = [];
108 this.water.resetFill(0.88);
109 this.spawnInitialBubbles();
110 }
111
112 // ── Private ──────────────────────────────────────────────────────────────
113
114 private spawnInitialBubbles(): void {
115 const gx = this.world.glassX;
116 const gy = this.world.glassY;
117 const gw = this.world.glassW;
118 const gh = this.world.glassH;
119 for (let i = 0; i < INITIAL_BUBBLES; i++) {
120 const r = MIN_BUBBLE_R + Math.random() * (MAX_BUBBLE_R - MIN_BUBBLE_R);
121 const x = gx + r + Math.random() * (gw - r * 2);
122 const y = gy + gh * 0.3 + Math.random() * gh * 0.5;
123 this.world.addBody(new RigidBody({
124 position: new Vector2(x, y),
125 radius: r,
126 density: BUBBLE_DENSITY,
127 label: '',
128 }));
129 }
130 }
131
132 private mergeSplitBubbles(): void {
133 const bodies = this.world.bodies;
134
135 // Split fast bubbles first
136 const toAdd: RigidBody[] = [];
137 const toRemove = new Set<number>();
138 for (const body of bodies) {
139 if (toRemove.has(body.id)) continue;
140 const speed = body.velocity.magnitude();
141 if (speed > SPLIT_SPEED && body.radius > MIN_BUBBLE_R * 1.5) {
142 toRemove.add(body.id);
143 const rNew = body.radius / Math.cbrt(2);
144 const perp = new Vector2(-body.velocity.y, body.velocity.x).scale(
145 1 / (speed + 0.001),
146 );
147 for (const sign of [-1, 1]) {
148 const pos = body.position.add(perp.scale(sign * rNew * 0.8));
149 const b = new RigidBody({
150 position: pos, radius: rNew,
151 density: BUBBLE_DENSITY, label: '',
152 });
153 b.velocity.x = body.velocity.x * 0.6;
154 b.velocity.y = body.velocity.y * 0.6;
155 toAdd.push(b);
156 }
157 }
158 }
159
160 // Merge overlapping bubbles
161 for (let i = 0; i < bodies.length; i++) {
162 const a = bodies[i]!;
163 if (toRemove.has(a.id)) continue;
164 for (let j = i + 1; j < bodies.length; j++) {
165 const b = bodies[j]!;
166 if (toRemove.has(b.id)) continue;
167 const dist = a.position.sub(b.position).magnitude();
168 const minR = Math.min(a.radius, b.radius);
169 if (dist < a.radius + b.radius - minR * MERGE_THRESHOLD) {
170 // Volume-conserving merge: r = ∛(r₁³ + r₂³)
171 const rNew = Math.cbrt(a.radius ** 3 + b.radius ** 3);
172 if (rNew > MAX_BUBBLE_R * 1.5) continue; // cap size
173 toRemove.add(a.id);
174 toRemove.add(b.id);
175 const totalMass = a.mass + b.mass;
176 const merged = new RigidBody({
177 position: a.position.scale(a.mass / totalMass).add(
178 b.position.scale(b.mass / totalMass),
179 ),
180 radius: rNew,
181 density: BUBBLE_DENSITY,
182 label: '',
183 });
184 merged.velocity.x = (a.velocity.x * a.mass + b.velocity.x * b.mass) / totalMass;
185 merged.velocity.y = (a.velocity.y * a.mass + b.velocity.y * b.mass) / totalMass;
186 toAdd.push(merged);
187 break;
188 }
189 }
190 }
191
192 if (toRemove.size > 0) {
193 this.world.bodies = bodies.filter(b => !toRemove.has(b.id));
194 }
195 for (const b of toAdd) this.world.addBody(b);
196
197 // If all bubbles are gone, spawn a fresh one
198 if (this.world.bodies.length === 0) this.spawnInitialBubbles();
199 }
200
201 private drawBubbles(ctx: CanvasRenderingContext2D): void {
202 for (const body of this.world.bodies) {
203 const { x, y } = body.position;
204 const r = body.radius;
205
206 // Main translucent white sphere
207 ctx.save();
208 ctx.beginPath();
209 ctx.arc(x, y, r, 0, Math.PI * 2);
210 const grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.1, x, y, r);
211 grad.addColorStop(0, 'rgba(255,255,255,0.85)');
212 grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
213 grad.addColorStop(1, 'rgba(255,255,255,0.05)');
214 ctx.fillStyle = grad;
215 ctx.fill();
216
217 // Specular highlight arc (10–2 o'clock)
218 ctx.beginPath();
219 ctx.arc(x, y, r * 0.65, Math.PI * 1.2, Math.PI * 1.8);
220 ctx.strokeStyle = 'rgba(255,255,255,0.7)';
221 ctx.lineWidth = r * 0.12;
222 ctx.stroke();
223 ctx.restore();
224 }
225 }
226}
227
228
229