fuzzyworld

bubbles / files / src / levels / SandWaterLevel.ts

SandWaterLevel.ts

7.8 KB · history · blame

1import { 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 { WaterRenderer } from '../rendering/WaterRenderer.js';
7import { GlassRenderer } from '../rendering/GlassRenderer.js';
8
9const SAND_COUNT = 38;
10const SAND_RADIUS = 5;
11const SAND_DENSITY = 2.5;
12const AIR_COUNT = 2;
13const AIR_DENSITY = 0.001;
14const AIR_RADIUS = 24;
15// Sand particles that move slower than this for several ticks are considered at rest
16const REST_SPEED = 8; // px/s
17const 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 */
23export 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);
51 this.waterRend.gravity = gravity;
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}