bubbles / files / src / rendering / Renderer.ts
Renderer.ts
dcc4757f 2026-04-09 Pavel Safronov | 1 | import { GlassRenderer } from './GlassRenderer.js'; |
| 2 | import { WaterRenderer } from './WaterRenderer.js'; | |
| 3 | import { IconRenderer } from './IconRenderer.js'; | |
| 4 | import { World } from '../physics/World.js'; | |
| 5 | ||
| 6 | /** | |
| 7 | * Top-level rendering coordinator. | |
| 8 | * Manages canvas sizing and the draw order: | |
| 9 | * 1. Clear | |
| 10 | * 2. Glass clip → Water fill | |
| 11 | * 3. Icons (with refraction for submerged) | |
| 12 | * 4. Glass outline (drawn on top to mask overflow) | |
| 13 | */ | |
| 14 | export class Renderer { | |
| 15 | readonly canvas: HTMLCanvasElement; | |
| 16 | readonly ctx: CanvasRenderingContext2D; | |
| 17 | ||
| 18 | private glass: GlassRenderer; | |
| 19 | private waterRend: WaterRenderer; | |
| 20 | private iconRend: IconRenderer; | |
| 21 | ||
| 22 | constructor(canvas: HTMLCanvasElement, private world: World) { | |
| 23 | this.canvas = canvas; | |
| 24 | const ctx = canvas.getContext('2d'); | |
| 25 | if (!ctx) throw new Error('Could not get 2D context'); | |
| 26 | this.ctx = ctx; | |
| 27 | ||
| 28 | this.glass = this.makeGlassRenderer(); | |
| 29 | this.waterRend = new WaterRenderer(this.ctx, world.water, world.glassX, world.glassY); | |
| 30 | this.iconRend = new IconRenderer(this.ctx, world.water, world.glassX, world.glassY); | |
| 31 | ||
| 32 | this.resize(); | |
| 33 | window.addEventListener('resize', this.resize); | |
| 34 | } | |
| 35 | ||
| 36 | private readonly resize = (): void => { | |
| 37 | const dpr = window.devicePixelRatio || 1; | |
| 38 | const W = window.innerWidth; | |
| 39 | const H = window.innerHeight; | |
| 40 | ||
| 41 | this.canvas.width = W * dpr; | |
| 42 | this.canvas.height = H * dpr; | |
| 43 | this.canvas.style.width = `${W}px`; | |
| 44 | this.canvas.style.height = `${H}px`; | |
| 45 | this.ctx.scale(dpr, dpr); | |
| 46 | ||
| 47 | // Recompute glass geometry | |
| 48 | const { gx, gy, gw, gh } = this.computeGlassDims(W, H); | |
| 49 | ||
| 50 | this.world.glassX = gx; | |
| 51 | this.world.glassY = gy; | |
| 52 | this.world.water.resize(gw, gh); | |
| 53 | ||
| 54 | this.glass = this.makeGlassRenderer(); | |
| 55 | this.waterRend.glassX = gx; | |
| 56 | this.waterRend.glassY = gy; | |
| 57 | this.iconRend.glassX = gx; | |
| 58 | this.iconRend.glassY = gy; | |
| 59 | }; | |
| 60 | ||
| 61 | private computeGlassDims(W: number, H: number): { gx: number; gy: number; gw: number; gh: number } { | |
| 62 | // Glass occupies most of the viewport, centred, portrait-friendly | |
| 63 | const glassW = Math.min(W * 0.82, 420); | |
| 64 | const glassH = H * 0.72; | |
| 65 | const gx = (W - glassW) / 2; | |
| 66 | const gy = (H - glassH) / 2 - H * 0.02; | |
| 67 | return { gx, gy, gw: glassW, gh: glassH }; | |
| 68 | } | |
| 69 | ||
| 70 | private makeGlassRenderer(): GlassRenderer { | |
| 71 | const W = window.innerWidth; | |
| 72 | const H = window.innerHeight; | |
| 73 | const { gx, gy, gw, gh } = this.computeGlassDims(W, H); | |
| 74 | return new GlassRenderer(this.ctx, gx, gy, gw, gh); | |
| 75 | } | |
| 76 | ||
| 77 | update(dt: number): void { | |
| 78 | this.waterRend.update(dt); | |
| 79 | } | |
| 80 | ||
fc6e1fbf 2026-04-09 Pavel Safronov | 81 | /** Register a pre-rendered QR canvas to be drawn on top of water. */ |
| 82 | setQRTexture(tex: HTMLCanvasElement): void { | |
| 83 | this._qrTexture = tex; | |
| 84 | } | |
| 85 | private _qrTexture: HTMLCanvasElement | undefined; | |
| 86 | ||
dcc4757f 2026-04-09 Pavel Safronov | 87 | draw(): void { |
| 88 | const ctx = this.ctx; | |
| 89 | const W = this.canvas.width / (window.devicePixelRatio || 1); | |
| 90 | const H = this.canvas.height / (window.devicePixelRatio || 1); | |
| 91 | ||
| 92 | // Clear | |
| 93 | ctx.clearRect(0, 0, W, H); | |
| 94 | ||
| 95 | // Background | |
| 96 | ctx.fillStyle = '#0f172a'; | |
| 97 | ctx.fillRect(0, 0, W, H); | |
| 98 | ||
fc6e1fbf 2026-04-09 Pavel Safronov | 99 | // Clip to glass interior |
dcc4757f 2026-04-09 Pavel Safronov | 100 | this.glass.beginClip(); |
| 101 | ||
fc6e1fbf 2026-04-09 Pavel Safronov | 102 | // Icons first (behind water) |
dcc4757f 2026-04-09 Pavel Safronov | 103 | this.iconRend.draw(this.world.bodies, (x) => this.waterRend.getRefractionDx(x)); |
fc6e1fbf 2026-04-09 Pavel Safronov | 104 | |
| 105 | // Water on top of icons | |
| 106 | this.waterRend.draw(); | |
| 107 | ||
| 108 | // Textured bodies (QR card) drawn on top of water, inside glass clip | |
| 109 | for (const body of this.world.bodies) { | |
| 110 | if (!body.texture) continue; | |
| 111 | const ctx = this.ctx; | |
| 112 | const s = body.radius * 2; | |
| 113 | ctx.save(); | |
| 114 | ctx.translate(body.position.x, body.position.y); | |
| 115 | ctx.rotate(body.angle); | |
| 116 | // White card background | |
| 117 | ctx.fillStyle = '#ffffff'; | |
| 118 | const r = 4; | |
| 119 | ctx.beginPath(); | |
| 120 | ctx.roundRect(-s / 2, -s / 2, s, s, r); | |
| 121 | ctx.fill(); | |
| 122 | // QR image centred with small padding | |
| 123 | const pad = s * 0.06; | |
| 124 | ctx.drawImage(body.texture, -s / 2 + pad, -s / 2 + pad, s - pad * 2, s - pad * 2); | |
| 125 | ctx.restore(); | |
| 126 | } | |
| 127 | ||
dcc4757f 2026-04-09 Pavel Safronov | 128 | this.glass.endClip(); |
| 129 | ||
| 130 | // Glass outline on top | |
| 131 | this.glass.draw(); | |
| 132 | } | |
| 133 | ||
| 134 | destroy(): void { | |
| 135 | window.removeEventListener('resize', this.resize); | |
| 136 | } | |
| 137 | } |