bubbles / files / src / levels / WaterLevel.ts
WaterLevel.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 { 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); |
| 50 | this.waterRend.gravity = gravity; |
| 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 | } |