bubbles / files / src / levels / WaterLevel.ts
WaterLevel.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 | import { IconRenderer } from '../rendering/IconRenderer.js'; | |
| 9 | import { 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 | */ | |
| 15 | export 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 Safronov | 50 | this.waterRend.gravity = gravity; |
775e32f9 2026-04-09 Pavel Safronov | 51 | 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 | } |