fuzzyworld

bubbles / files / src / rendering / WaterRenderer.ts

WaterRenderer.ts

Blame: the commit that last changed each line · file · history

dcc4757f 2026-04-09 Pavel Safronov1import { Water } from '../physics/Water.js';
92e94bb0 2026-04-10 Pavel Safronov2import { Vector2 } from '../physics/Vector2.js';
dcc4757f 2026-04-09 Pavel Safronov3
4export class WaterRenderer {
5 private shimmerOffset = 0;
6 private refractOffset = 0;
92e94bb0 2026-04-10 Pavel Safronov7 /**
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 Safronov12
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 Safronov28 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 Safronov46 const ctx = this.ctx;
47 const w = this.water;
92e94bb0 2026-04-10 Pavel Safronov48 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 Safronov90
92e94bb0 2026-04-10 Pavel Safronov91 ctx.save();
92 ctx.translate(cx, cy);
93 ctx.rotate(-angle);
dcc4757f 2026-04-09 Pavel Safronov94
92e94bb0 2026-04-10 Pavel Safronov95 // ── 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 Safronov98 ctx.beginPath();
92e94bb0 2026-04-10 Pavel Safronov99 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 Safronov111 ctx.fillStyle = grad;
112 ctx.fill();
113
775e32f9 2026-04-09 Pavel Safronov114 // ── Shimmer band ─────────────────────────────────────────────────────
92e94bb0 2026-04-10 Pavel Safronov115 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 Safronov118 shimGrad.addColorStop(0, 'rgba(255,255,255,0.0)');
92e94bb0 2026-04-10 Pavel Safronov119 shimGrad.addColorStop(0.5, 'rgba(255,255,255,0.11)');
dcc4757f 2026-04-09 Pavel Safronov120 shimGrad.addColorStop(1, 'rgba(255,255,255,0.0)');
121
122 ctx.save();
123 ctx.beginPath();
92e94bb0 2026-04-10 Pavel Safronov124 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 Safronov130 ctx.clip();
131 ctx.fillStyle = shimGrad;
92e94bb0 2026-04-10 Pavel Safronov132 ctx.fillRect(-ext, shimY - halfBand, ext * 2, halfBand * 2);
775e32f9 2026-04-09 Pavel Safronov133 ctx.restore();
134
135 // ── Surface highlight line ────────────────────────────────────────────
136 ctx.beginPath();
92e94bb0 2026-04-10 Pavel Safronov137 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 Safronov140 ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)';
141 ctx.lineWidth = 2;
142 ctx.stroke();
143
92e94bb0 2026-04-10 Pavel Safronov144 ctx.restore(); // un-rotate
dcc4757f 2026-04-09 Pavel Safronov145 }
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}