fuzzyworld

bubbles / files / src / levels / WaterLevel.ts

WaterLevel.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 { WaterRenderer } from '../rendering/WaterRenderer.js';
7import { GlassRenderer } from '../rendering/GlassRenderer.js';
8import { IconRenderer } from '../rendering/IconRenderer.js';
9import { ICONS } from '../icons/registry.js';
10
11/**
12 * The original water-glass level.
13 * Sealed container, full 360° gravity, emoji objects that float/sink.
14 */
15export class WaterLevel implements ILevel {
16 readonly name = 'Water';
17
18 private water: Water;
19 private world: World;
20 private waterRend: WaterRenderer;
21 private glassRend: GlassRenderer;
22 private iconRend: IconRenderer;
23
24 private shimmerOffset = 0;
25 private refractOffset = 0;
26
27 constructor(
28 private readonly ctx: CanvasRenderingContext2D,
29 glassX: number,
30 glassY: number,
31 glassW: number,
32 glassH: number,
33 qrTexture?: HTMLCanvasElement,
34 ) {
35 this.water = new Water(80, glassW, glassH, 0.55);
36 this.world = new World(this.water, glassX, glassY);
37
38 this.waterRend = new WaterRenderer(ctx, this.water, glassX, glassY);
39 this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH);
40 this.iconRend = new IconRenderer(ctx, this.water, glassX, glassY);
41
42 if (qrTexture) this.spawnQRBody(qrTexture);
43 }
44
45 // ── ILevel ──────────────────────────────────────────────────────────────
46
47 step(dt: number, gravity: Vector2): void {
48 this.world.gravity = gravity;
49 this.world.step(dt);
92e94bb0 2026-04-10 Pavel Safronov50 this.waterRend.gravity = gravity;
775e32f9 2026-04-09 Pavel Safronov51 this.refractOffset = (this.refractOffset + dt * 1.2) % (Math.PI * 2);
52 }
53
54 draw(
55 ctx: CanvasRenderingContext2D,
56 glassX: number,
57 glassY: number,
58 _glassW: number,
59 _glassH: number,
60 ): void {
61 this.glassRend.beginClip();
62
63 // Icons behind water
64 this.iconRend.draw(this.world.bodies, (x) => this.getRefractionDx(x));
65
66 // Water
67 this.waterRend.draw();
68
69 // Textured bodies (QR card) on top of water
70 for (const body of this.world.bodies) {
71 if (!body.texture) continue;
72 const s = body.radius * 2;
73 ctx.save();
74 ctx.translate(body.position.x, body.position.y);
75 ctx.rotate(body.angle);
76 ctx.fillStyle = '#ffffff';
77 const corner = 4;
78 ctx.beginPath();
79 ctx.roundRect(-s / 2, -s / 2, s, s, corner);
80 ctx.fill();
81 const pad = s * 0.06;
82 ctx.drawImage(body.texture, -s / 2 + pad, -s / 2 + pad, s - pad * 2, s - pad * 2);
83 ctx.restore();
84 }
85
86 this.glassRend.endClip();
87 this.glassRend.draw();
88 }
89
90 onTouch(localX: number, localY: number, _gravity: Vector2): void {
91 const glassX = this.world.glassX;
92 const glassY = this.world.glassY;
93 const gw = this.world.glassW;
94 const gh = this.world.glassH;
95
96 const icon = ICONS[Math.floor(Math.random() * ICONS.length)]!;
97 const r = icon.radius;
98
99 const spawnX = Math.max(glassX + r, Math.min(glassX + gw - r, glassX + localX));
100 const spawnY = Math.max(glassY + r, Math.min(glassY + gh - r, glassY + localY));
101
102 const body = new RigidBody({
103 position: new Vector2(spawnX, spawnY),
104 radius: r,
105 density: icon.density,
106 label: icon.label,
107 });
108 body.velocity.x = (Math.random() - 0.5) * 80;
109 body.angularVelocity = (Math.random() - 0.5) * 4;
110 this.world.addBody(body);
111 }
112
113 resize(glassW: number, glassH: number): void {
114 const glassX = this.world.glassX;
115 const glassY = this.world.glassY;
116 this.water.resize(glassW, glassH);
117 this.waterRend.glassX = glassX;
118 this.waterRend.glassY = glassY;
119 this.iconRend.glassX = glassX;
120 this.iconRend.glassY = glassY;
121 this.glassRend = new GlassRenderer(this.ctx, glassX, glassY, glassW, glassH);
122 }
123
124 /** Update glassX/Y when the window resizes. Called by App before resize(). */
125 setGlassOrigin(glassX: number, glassY: number): void {
126 this.world.glassX = glassX;
127 this.world.glassY = glassY;
128 this.waterRend.glassX = glassX;
129 this.waterRend.glassY = glassY;
130 this.iconRend.glassX = glassX;
131 this.iconRend.glassY = glassY;
132 }
133
134 reset(): void {
135 this.world.bodies = this.world.bodies.filter(b => b.permanent);
136 this.water.resetFill(0.55);
137 }
138
139 // ── Public helpers ───────────────────────────────────────────────────────
140
141 /** Expose World so App can spawn/access permanent bodies (e.g. QR). */
142 get worldRef(): World { return this.world; }
143
144 spawnQRBody(qrTexture: HTMLCanvasElement): void {
145 const r = 40;
146 const x = this.world.glassX + this.world.glassW / 2;
147 const y = this.world.glassY + r + 4;
148 const body = new RigidBody({
149 position: new Vector2(x, y),
150 radius: r,
151 density: 0.5,
152 label: '',
153 texture: qrTexture,
154 permanent: true,
155 });
156 this.world.addBody(body);
157 }
158
159 private getRefractionDx(canvasX: number): number {
160 return Math.sin(this.refractOffset + canvasX * 0.04) * 3;
161 }
162}