bubbles / files / src / rendering / WaterRenderer.ts
WaterRenderer.ts
| 1 | import { Water } from '../physics/Water.js'; |
| 2 | import { Vector2 } from '../physics/Vector2.js'; |
| 3 | |
| 4 | export class WaterRenderer { |
| 5 | private shimmerOffset = 0; |
| 6 | private refractOffset = 0; |
| 7 | /** |
| 8 | * Device gravity vector (px/s²). Set by the level each step so draw() |
| 9 | * can orient the water surface perpendicular to gravity. |
| 10 | */ |
| 11 | gravity: Vector2 = new Vector2(0, 980); |
| 12 | |
| 13 | constructor( |
| 14 | private readonly ctx: CanvasRenderingContext2D, |
| 15 | private readonly water: Water, |
| 16 | /** Canvas X of glass interior left edge */ |
| 17 | public glassX: number, |
| 18 | /** Canvas Y of glass interior top edge */ |
| 19 | public glassY: number, |
| 20 | ) { } |
| 21 | |
| 22 | update(dt: number): void { |
| 23 | this.shimmerOffset = (this.shimmerOffset + dt * 0.4) % (Math.PI * 2); |
| 24 | this.refractOffset = (this.refractOffset + dt * 1.2) % (Math.PI * 2); |
| 25 | } |
| 26 | |
| 27 | draw(): void { |
| 28 | this.drawWithColors( |
| 29 | 'rgba(56, 189, 248, 0.72)', |
| 30 | 'rgba(14, 116, 144, 0.85)', |
| 31 | 'rgba(8, 51, 68, 0.95)', |
| 32 | ); |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * Like draw() but with custom gradient colors. Used by ShampooLevel. |
| 37 | * top/mid/bot are CSS color strings ordered from surface to gravity wall. |
| 38 | */ |
| 39 | drawTinted(top: string, mid: string, bot: string): void { |
| 40 | this.drawWithColors(top, mid, bot); |
| 41 | } |
| 42 | |
| 43 | // ── Private ───────────────────────────────────────────────────────────────────── |
| 44 | |
| 45 | private drawWithColors(topColor: string, midColor: string, botColor: string): void { |
| 46 | const ctx = this.ctx; |
| 47 | const w = this.water; |
| 48 | const W = w.glassW; |
| 49 | const H = w.glassH; |
| 50 | const N = w.columns; |
| 51 | const cx = this.glassX + W / 2; |
| 52 | const cy = this.glassY + H / 2; |
| 53 | |
| 54 | // Rotate the canvas so gravity always points straight down (+Y). |
| 55 | // Everything is then drawn in axis-aligned "gravity space": |
| 56 | // floor at y = +H/2, surface at y = H/2 − h[i], walls at x = ±W/2 |
| 57 | const g = this.gravity; |
| 58 | const gMag = Math.sqrt(g.x * g.x + g.y * g.y) || 980; |
| 59 | const angle = Math.atan2(g.x / gMag, g.y / gMag); // how much to un-rotate |
| 60 | |
| 61 | // The glass rectangle in rotated (gravity) space is a diamond whose extents are: |
| 62 | // x: ±(W/2·|cosA| + H/2·|sinA|) |
| 63 | // y: ±(H/2·|cosA| + W/2·|sinA|) |
| 64 | // We draw the polygon with these full extents; the glass clip trims the corners. |
| 65 | const cosA = Math.abs(Math.cos(angle)); |
| 66 | const sinA = Math.abs(Math.sin(angle)); |
| 67 | const ext = W / 2 * cosA + H / 2 * sinA; // half-width of glass in rotated space |
| 68 | const deep = H / 2 * cosA + W / 2 * sinA; // max depth of glass in rotated space |
| 69 | |
| 70 | const surfL = H / 2 - w.getColumnHeight(0); |
| 71 | const surfR = H / 2 - w.getColumnHeight(N - 1); |
| 72 | |
| 73 | /** Draws the surface bezier path from left wall to right wall */ |
| 74 | const drawSurface = (): void => { |
| 75 | ctx.lineTo(-W / 2, surfL); |
| 76 | for (let i = 0; i < N; i++) { |
| 77 | const hi = w.getColumnHeight(i); |
| 78 | const x = (i + 0.5) / N * W - W / 2; |
| 79 | const y = H / 2 - hi; |
| 80 | if (i === 0) { |
| 81 | ctx.lineTo(x, y); |
| 82 | } else { |
| 83 | const hp = w.getColumnHeight(i - 1); |
| 84 | const xm = i / N * W - W / 2; |
| 85 | ctx.bezierCurveTo(xm, H / 2 - hp, xm, H / 2 - hi, x, y); |
| 86 | } |
| 87 | } |
| 88 | ctx.lineTo(W / 2, surfR); |
| 89 | }; |
| 90 | |
| 91 | ctx.save(); |
| 92 | ctx.translate(cx, cy); |
| 93 | ctx.rotate(-angle); |
| 94 | |
| 95 | // ── Water polygon ──────────────────────────────────────────────────── |
| 96 | // Extends to full glass extent so tilted corners are filled. |
| 97 | // Flat extrapolation from edge columns fills the corner triangles. |
| 98 | ctx.beginPath(); |
| 99 | ctx.moveTo(-ext, deep); // far bottom-left |
| 100 | ctx.lineTo(-ext, surfL); // up the left corner |
| 101 | drawSurface(); // across the surface |
| 102 | ctx.lineTo(ext, surfR); // across the right corner |
| 103 | ctx.lineTo(ext, deep); // down to far bottom-right |
| 104 | ctx.closePath(); // across the bottom |
| 105 | |
| 106 | // Gradient: light near surface, dark at gravity wall |
| 107 | const grad = ctx.createLinearGradient(0, -deep, 0, deep); |
| 108 | grad.addColorStop(0.0, topColor); |
| 109 | grad.addColorStop(0.5, midColor); |
| 110 | grad.addColorStop(1.0, botColor); |
| 111 | ctx.fillStyle = grad; |
| 112 | ctx.fill(); |
| 113 | |
| 114 | // ── Shimmer band ───────────────────────────────────────────────────── |
| 115 | const shimY = H / 2 - H * (0.30 + Math.sin(this.shimmerOffset) * 0.10); |
| 116 | const halfBand = 28; |
| 117 | const shimGrad = ctx.createLinearGradient(0, shimY - halfBand, 0, shimY + halfBand); |
| 118 | shimGrad.addColorStop(0, 'rgba(255,255,255,0.0)'); |
| 119 | shimGrad.addColorStop(0.5, 'rgba(255,255,255,0.11)'); |
| 120 | shimGrad.addColorStop(1, 'rgba(255,255,255,0.0)'); |
| 121 | |
| 122 | ctx.save(); |
| 123 | ctx.beginPath(); |
| 124 | ctx.moveTo(-ext, deep); |
| 125 | ctx.lineTo(-ext, surfL); |
| 126 | drawSurface(); |
| 127 | ctx.lineTo(ext, surfR); |
| 128 | ctx.lineTo(ext, deep); |
| 129 | ctx.closePath(); |
| 130 | ctx.clip(); |
| 131 | ctx.fillStyle = shimGrad; |
| 132 | ctx.fillRect(-ext, shimY - halfBand, ext * 2, halfBand * 2); |
| 133 | ctx.restore(); |
| 134 | |
| 135 | // ── Surface highlight line ──────────────────────────────────────────── |
| 136 | ctx.beginPath(); |
| 137 | ctx.moveTo(-ext, surfL); // extend left to glass corner |
| 138 | drawSurface(); |
| 139 | ctx.lineTo(ext, surfR); // extend right to glass corner |
| 140 | ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)'; |
| 141 | ctx.lineWidth = 2; |
| 142 | ctx.stroke(); |
| 143 | |
| 144 | ctx.restore(); // un-rotate |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Returns the refraction horizontal offset (px) to apply when rendering |
| 149 | * objects below the water surface, as a function of their x position and time. |
| 150 | */ |
| 151 | getRefractionDx(canvasX: number): number { |
| 152 | return Math.sin(this.refractOffset + canvasX * 0.04) * 3; |
| 153 | } |
| 154 | } |