bubbles / files / src / levels / ShampooLevel.ts
ShampooLevel.ts
| 1 | import { ILevel } from './ILevel.js'; |
| 2 | import { Vector2 } from '../physics/Vector2.js'; |
| 3 | import { Water } from '../physics/Water.js'; |
| 4 | import { World } from '../physics/World.js'; |
| 5 | import { RigidBody } from '../physics/RigidBody.js'; |
| 6 | import { GlassRenderer } from '../rendering/GlassRenderer.js'; |
| 7 | import { WaterRenderer } from '../rendering/WaterRenderer.js'; |
| 8 | |
| 9 | const INITIAL_BUBBLES = 4; |
| 10 | const BUBBLE_DENSITY = 0.001; // extremely light — strong upward buoyancy |
| 11 | const MIN_BUBBLE_R = 14; |
| 12 | const MAX_BUBBLE_R = 36; |
| 13 | // Merge when overlap > this fraction of smaller radius |
| 14 | const MERGE_THRESHOLD = 0.55; |
| 15 | // Split when speed exceeds this (px/s) |
| 16 | const SPLIT_SPEED = 380; |
| 17 | |
| 18 | /** |
| 19 | * Shampoo bottle level. |
| 20 | * High-viscosity amber fluid with air bubbles that merge and split. |
| 21 | */ |
| 22 | export 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); |
| 51 | this.waterRend.gravity = gravity; |
| 52 | 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 |