bubbles / files / src / rendering / Renderer.ts
Renderer.ts
| 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 | |
| 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 | |
| 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 | |
| 99 | // Clip to glass interior |
| 100 | this.glass.beginClip(); |
| 101 | |
| 102 | // Icons first (behind water) |
| 103 | this.iconRend.draw(this.world.bodies, (x) => this.waterRend.getRefractionDx(x)); |
| 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 | |
| 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 | } |