bubbles / files / src / levels / SandWaterLevel.ts
SandWaterLevel.ts
775e32f9 2026-04-09 Pavel Safronov | 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 { WaterRenderer } from '../rendering/WaterRenderer.js'; | |
| 7 | import { GlassRenderer } from '../rendering/GlassRenderer.js'; | |
| 8 | ||
| 9 | const SAND_COUNT = 38; | |
| 10 | const SAND_RADIUS = 5; | |
| 11 | const SAND_DENSITY = 2.5; | |
| 12 | const AIR_COUNT = 2; | |
| 13 | const AIR_DENSITY = 0.001; | |
| 14 | const AIR_RADIUS = 24; | |
| 15 | // Sand particles that move slower than this for several ticks are considered at rest | |
| 16 | const REST_SPEED = 8; // px/s | |
| 17 | const STATIC_FRIC = 0.85; // extra velocity damping when resting | |
| 18 | ||
| 19 | /** | |
| 20 | * Three-phase level: sand at the bottom, water in the middle, air bubbles at the top. | |
| 21 | * Sand and air are RigidBodies; water is the SWE simulation. | |
| 22 | */ | |
| 23 | export class SandWaterLevel implements ILevel { | |
| 24 | readonly name = 'Sand & Water'; | |
| 25 | ||
| 26 | private water: Water; | |
| 27 | private world: World; | |
| 28 | private waterRend: WaterRenderer; | |
| 29 | private glassRend: GlassRenderer; | |
| 30 | ||
| 31 | constructor( | |
| 32 | private readonly ctx: CanvasRenderingContext2D, | |
| 33 | private glassX: number, | |
| 34 | private glassY: number, | |
| 35 | glassW: number, | |
| 36 | glassH: number, | |
| 37 | ) { | |
| 38 | // Water starts at 50%, accounting for sand volume at the bottom | |
| 39 | this.water = new Water(80, glassW, glassH, 0.50); | |
| 40 | this.world = new World(this.water, glassX, glassY, SAND_COUNT + AIR_COUNT + 2); | |
| 41 | this.waterRend = new WaterRenderer(ctx, this.water, glassX, glassY); | |
| 42 | this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH); | |
| 43 | this.spawnParticles(); | |
| 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 Safronov | 51 | this.waterRend.gravity = gravity; |
775e32f9 2026-04-09 Pavel Safronov | 52 | this.applySandFriction(); |
| 53 | } | |
| 54 | ||
| 55 | draw( | |
| 56 | ctx: CanvasRenderingContext2D, | |
| 57 | _glassX: number, | |
| 58 | _glassY: number, | |
| 59 | _glassW: number, | |
| 60 | _glassH: number, | |
| 61 | ): void { | |
| 62 | this.glassRend.beginClip(); | |
| 63 | ||
| 64 | // Sand drawn first (bottom layer, behind water) | |
| 65 | this.drawSand(ctx); | |
| 66 | ||
| 67 | // Air drawn before water (behind water surface) | |
| 68 | this.drawAir(ctx); | |
| 69 | ||
| 70 | // Water | |
| 71 | this.waterRend.draw(); | |
| 72 | ||
| 73 | this.glassRend.endClip(); | |
| 74 | this.glassRend.draw(); | |
| 75 | } | |
| 76 | ||
| 77 | onTouch(localX: number, localY: number, _gravity: Vector2): void { | |
| 78 | const wx = this.glassX + localX; | |
| 79 | const wy = this.glassY + localY; | |
| 80 | // Disturb water surface | |
| 81 | this.water.disturb(localX, 40, 200); | |
| 82 | // Scatter nearby sand particles | |
| 83 | for (const body of this.world.bodies) { | |
| 84 | if (body.density < 1.0) continue; // skip air bubbles | |
| 85 | const d = Math.hypot(body.position.x - wx, body.position.y - wy); | |
| 86 | if (d < 60) { | |
| 87 | const dx = body.position.x - wx; | |
| 88 | const dy = body.position.y - wy; | |
| 89 | const len = Math.hypot(dx, dy) + 1; | |
| 90 | body.velocity.x += (dx / len) * 300; | |
| 91 | body.velocity.y += (dy / len) * 300; | |
| 92 | } | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | resize(glassW: number, glassH: number): void { | |
| 97 | this.water.resize(glassW, glassH); | |
| 98 | this.waterRend.glassX = this.glassX; | |
| 99 | this.waterRend.glassY = this.glassY; | |
| 100 | this.glassRend = new GlassRenderer(this.ctx, this.glassX, this.glassY, glassW, glassH); | |
| 101 | } | |
| 102 | ||
| 103 | reset(): void { | |
| 104 | this.world.bodies = []; | |
| 105 | this.water.resetFill(0.50); | |
| 106 | this.spawnParticles(); | |
| 107 | } | |
| 108 | ||
| 109 | // ── Private ────────────────────────────────────────────────────────────── | |
| 110 | ||
| 111 | private spawnParticles(): void { | |
| 112 | const gx = this.world.glassX; | |
| 113 | const gy = this.world.glassY; | |
| 114 | const gw = this.world.glassW; | |
| 115 | const gh = this.world.glassH; | |
| 116 | ||
| 117 | // Sand: pack into lower ~20% of glass | |
| 118 | const sandZoneH = gh * 0.20; | |
| 119 | const cols = Math.floor(gw / (SAND_RADIUS * 2 + 2)); | |
| 120 | for (let i = 0; i < SAND_COUNT; i++) { | |
| 121 | const col = i % cols; | |
| 122 | const row = Math.floor(i / cols); | |
| 123 | const x = gx + SAND_RADIUS + col * (SAND_RADIUS * 2 + 2) + (Math.random() - 0.5) * 3; | |
| 124 | const y = gy + gh - SAND_RADIUS - row * (SAND_RADIUS * 2 + 1); | |
| 125 | if (y - SAND_RADIUS < gy + gh - sandZoneH * 1.5) { | |
| 126 | // Toss remaining in random positions | |
| 127 | const body = new RigidBody({ | |
| 128 | position: new Vector2( | |
| 129 | gx + SAND_RADIUS + Math.random() * (gw - SAND_RADIUS * 2), | |
| 130 | gy + gh - SAND_RADIUS - Math.random() * sandZoneH, | |
| 131 | ), | |
| 132 | radius: SAND_RADIUS, | |
| 133 | density: SAND_DENSITY, | |
| 134 | label: '', | |
| 135 | }); | |
| 136 | this.world.addBody(body); | |
| 137 | continue; | |
| 138 | } | |
| 139 | const body = new RigidBody({ | |
| 140 | position: new Vector2(x, y), | |
| 141 | radius: SAND_RADIUS, | |
| 142 | density: SAND_DENSITY, | |
| 143 | label: '', | |
| 144 | }); | |
| 145 | this.world.addBody(body); | |
| 146 | } | |
| 147 | ||
| 148 | // Air bubbles: spawn near the top | |
| 149 | for (let i = 0; i < AIR_COUNT; i++) { | |
| 150 | const x = gx + AIR_RADIUS + Math.random() * (gw - AIR_RADIUS * 2); | |
| 151 | const y = gy + AIR_RADIUS + Math.random() * gh * 0.25; | |
| 152 | this.world.addBody(new RigidBody({ | |
| 153 | position: new Vector2(x, y), | |
| 154 | radius: AIR_RADIUS, | |
| 155 | density: AIR_DENSITY, | |
| 156 | label: '', | |
| 157 | })); | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | /** Extra static friction to prevent sand jitter when settled. */ | |
| 162 | private applySandFriction(): void { | |
| 163 | for (const body of this.world.bodies) { | |
| 164 | if (body.density < 1.0) continue; // air bodies — skip | |
| 165 | const speed = body.velocity.magnitude(); | |
| 166 | if (speed < REST_SPEED) { | |
| 167 | body.velocity.x *= STATIC_FRIC; | |
| 168 | body.velocity.y *= STATIC_FRIC; | |
| 169 | } | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | private drawSand(ctx: CanvasRenderingContext2D): void { | |
| 174 | for (const body of this.world.bodies) { | |
| 175 | if (body.density < 1.0) continue; // air — not sand | |
| 176 | const { x, y } = body.position; | |
| 177 | const r = body.radius; | |
| 178 | ||
| 179 | ctx.save(); | |
| 180 | ctx.beginPath(); | |
| 181 | ctx.arc(x, y, r, 0, Math.PI * 2); | |
| 182 | // Sandy color — slightly varied per body id | |
| 183 | const hue = 38 + (body.id % 8) - 4; | |
| 184 | const lit = 52 + (body.id % 10) - 5; | |
| 185 | ctx.fillStyle = `hsl(${hue}, 60%, ${lit}%)`; | |
| 186 | ctx.fill(); | |
| 187 | ctx.restore(); | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | private drawAir(ctx: CanvasRenderingContext2D): void { | |
| 192 | for (const body of this.world.bodies) { | |
| 193 | if (body.density >= 1.0) continue; // sand — not air | |
| 194 | const { x, y } = body.position; | |
| 195 | const r = body.radius; | |
| 196 | ||
| 197 | ctx.save(); | |
| 198 | ctx.beginPath(); | |
| 199 | ctx.arc(x, y, r, 0, Math.PI * 2); | |
| 200 | const grad = ctx.createRadialGradient( | |
| 201 | x - r * 0.3, y - r * 0.3, r * 0.1, | |
| 202 | x, y, r, | |
| 203 | ); | |
| 204 | grad.addColorStop(0, 'rgba(255,255,255,0.80)'); | |
| 205 | grad.addColorStop(0.5, 'rgba(200,230,255,0.30)'); | |
| 206 | grad.addColorStop(1, 'rgba(150,200,255,0.05)'); | |
| 207 | ctx.fillStyle = grad; | |
| 208 | ctx.fill(); | |
| 209 | // Highlight arc | |
| 210 | ctx.beginPath(); | |
| 211 | ctx.arc(x, y, r * 0.6, Math.PI * 1.2, Math.PI * 1.8); | |
| 212 | ctx.strokeStyle = 'rgba(255,255,255,0.6)'; | |
| 213 | ctx.lineWidth = r * 0.1; | |
| 214 | ctx.stroke(); | |
| 215 | ctx.restore(); | |
| 216 | } | |
| 217 | } | |
| 218 | } |