bubbles / files / src / rendering / WaterRenderer.ts
WaterRenderer.ts
dcc4757f 2026-04-09 Pavel Safronov | 1 | import { Water } from '../physics/Water.js'; |
92e94bb0 2026-04-10 Pavel Safronov | 2 | import { Vector2 } from '../physics/Vector2.js'; |
dcc4757f 2026-04-09 Pavel Safronov | 3 | |
| 4 | export class WaterRenderer { | |
| 5 | private shimmerOffset = 0; | |
| 6 | private refractOffset = 0; | |
92e94bb0 2026-04-10 Pavel Safronov | 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); | |
dcc4757f 2026-04-09 Pavel Safronov | 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 { | |
92e94bb0 2026-04-10 Pavel Safronov | 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 { | |
dcc4757f 2026-04-09 Pavel Safronov | 46 | const ctx = this.ctx; |
| 47 | const w = this.water; | |
92e94bb0 2026-04-10 Pavel Safronov | 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 | }; | |
dcc4757f 2026-04-09 Pavel Safronov | 90 | |
92e94bb0 2026-04-10 Pavel Safronov | 91 | ctx.save(); |
| 92 | ctx.translate(cx, cy); | |
| 93 | ctx.rotate(-angle); | |
dcc4757f 2026-04-09 Pavel Safronov | 94 | |
92e94bb0 2026-04-10 Pavel Safronov | 95 | // ── Water polygon ──────────────────────────────────────────────────── |
| 96 | // Extends to full glass extent so tilted corners are filled. | |
| 97 | // Flat extrapolation from edge columns fills the corner triangles. | |
775e32f9 2026-04-09 Pavel Safronov | 98 | ctx.beginPath(); |
92e94bb0 2026-04-10 Pavel Safronov | 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); | |
dcc4757f 2026-04-09 Pavel Safronov | 111 | ctx.fillStyle = grad; |
| 112 | ctx.fill(); | |
| 113 | ||
775e32f9 2026-04-09 Pavel Safronov | 114 | // ── Shimmer band ───────────────────────────────────────────────────── |
92e94bb0 2026-04-10 Pavel Safronov | 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); | |
dcc4757f 2026-04-09 Pavel Safronov | 118 | shimGrad.addColorStop(0, 'rgba(255,255,255,0.0)'); |
92e94bb0 2026-04-10 Pavel Safronov | 119 | shimGrad.addColorStop(0.5, 'rgba(255,255,255,0.11)'); |
dcc4757f 2026-04-09 Pavel Safronov | 120 | shimGrad.addColorStop(1, 'rgba(255,255,255,0.0)'); |
| 121 | ||
| 122 | ctx.save(); | |
| 123 | ctx.beginPath(); | |
92e94bb0 2026-04-10 Pavel Safronov | 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(); | |
775e32f9 2026-04-09 Pavel Safronov | 130 | ctx.clip(); |
| 131 | ctx.fillStyle = shimGrad; | |
92e94bb0 2026-04-10 Pavel Safronov | 132 | ctx.fillRect(-ext, shimY - halfBand, ext * 2, halfBand * 2); |
775e32f9 2026-04-09 Pavel Safronov | 133 | ctx.restore(); |
| 134 | ||
| 135 | // ── Surface highlight line ──────────────────────────────────────────── | |
| 136 | ctx.beginPath(); | |
92e94bb0 2026-04-10 Pavel Safronov | 137 | ctx.moveTo(-ext, surfL); // extend left to glass corner |
| 138 | drawSurface(); | |
| 139 | ctx.lineTo(ext, surfR); // extend right to glass corner | |
775e32f9 2026-04-09 Pavel Safronov | 140 | ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)'; |
| 141 | ctx.lineWidth = 2; | |
| 142 | ctx.stroke(); | |
| 143 | ||
92e94bb0 2026-04-10 Pavel Safronov | 144 | ctx.restore(); // un-rotate |
dcc4757f 2026-04-09 Pavel Safronov | 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 | } |