fuzzyworld

bubbles / commit

reworked water physics

92e94bb0aeb40f166ad832310a449312ccc0f5b3 · Pavel Safronov · 2026-04-09 17:05:48 -0700 · parents 775e32f99a83

reworked water physics
 src/levels/SandWaterLevel.ts   |   1 +
 src/levels/ShampooLevel.ts     |   1 +
 src/levels/WaterLevel.ts       |   2 +-
 src/physics/Water.ts           |   8 ++
 src/rendering/WaterRenderer.ts | 203 ++++++++++++++++++++---------------------
 5 files changed, 110 insertions(+), 105 deletions(-)

diff --git a/src/levels/SandWaterLevel.ts b/src/levels/SandWaterLevel.ts
index d27d5bb..03d50c7 100644
--- a/src/levels/SandWaterLevel.ts
+++ b/src/levels/SandWaterLevel.ts
@@ -48,6 +48,7 @@ export class SandWaterLevel implements ILevel {
     step(dt: number, gravity: Vector2): void {
         this.world.gravity = gravity;
         this.world.step(dt);
+        this.waterRend.gravity = gravity;
         this.applySandFriction();
     }
 
diff --git a/src/levels/ShampooLevel.ts b/src/levels/ShampooLevel.ts
index bb1949a..9a3fbc6 100644
--- a/src/levels/ShampooLevel.ts
+++ b/src/levels/ShampooLevel.ts
@@ -48,6 +48,7 @@ export class ShampooLevel implements ILevel {
     step(dt: number, gravity: Vector2): void {
         this.world.gravity = gravity;
         this.world.step(dt);
+        this.waterRend.gravity = gravity;
         this.shimmerOffset = (this.shimmerOffset + dt * 0.3) % (Math.PI * 2);
         this.mergeSplitBubbles();
     }
diff --git a/src/levels/WaterLevel.ts b/src/levels/WaterLevel.ts
index dc7a9fd..2b99593 100644
--- a/src/levels/WaterLevel.ts
+++ b/src/levels/WaterLevel.ts
@@ -47,7 +47,7 @@ export class WaterLevel implements ILevel {
     step(dt: number, gravity: Vector2): void {
         this.world.gravity = gravity;
         this.world.step(dt);
-        this.shimmerOffset = (this.shimmerOffset + dt * 0.4) % (Math.PI * 2);
+        this.waterRend.gravity = gravity;
         this.refractOffset = (this.refractOffset + dt * 1.2) % (Math.PI * 2);
     }
 
diff --git a/src/physics/Water.ts b/src/physics/Water.ts
index cc0cab1..bfab9c0 100644
--- a/src/physics/Water.ts
+++ b/src/physics/Water.ts
@@ -59,6 +59,14 @@ export class Water {
     /** True when gravity points toward the ceiling (phone held upside-down). */
     get isFlipped(): boolean { return this._isFlipped; }
 
+    /**
+     * Height of the water column at index i, measured from the gravity wall
+     * (floor when upright, ceiling when flipped).
+     */
+    getColumnHeight(i: number): number {
+        return this.h[Math.max(0, Math.min(this.columns - 1, i))]!;
+    }
+
     step(dt: number, lateralG: number, verticalG: number): void {
         // ── Detect gravity direction flip (phone turned upside-down) ────────
         const vSign = verticalG >= 0 ? 1 : -1;
diff --git a/src/rendering/WaterRenderer.ts b/src/rendering/WaterRenderer.ts
index 4d6dacc..043c664 100644
--- a/src/rendering/WaterRenderer.ts
+++ b/src/rendering/WaterRenderer.ts
@@ -1,8 +1,14 @@
 import { Water } from '../physics/Water.js';
+import { Vector2 } from '../physics/Vector2.js';
 
 export class WaterRenderer {
     private shimmerOffset = 0;
     private refractOffset = 0;
+    /**
+     * Device gravity vector (px/s²).  Set by the level each step so draw()
+     * can orient the water surface perpendicular to gravity.
+     */
+    gravity: Vector2 = new Vector2(0, 980);
 
     constructor(
         private readonly ctx: CanvasRenderingContext2D,
@@ -19,134 +25,123 @@ export class WaterRenderer {
     }
 
     draw(): void {
+        this.drawWithColors(
+            'rgba(56, 189, 248, 0.72)',
+            'rgba(14, 116, 144, 0.85)',
+            'rgba(8,  51,  68, 0.95)',
+        );
+    }
+
+    /**
+     * Like draw() but with custom gradient colors.  Used by ShampooLevel.
+     * top/mid/bot are CSS color strings ordered from surface to gravity wall.
+     */
+    drawTinted(top: string, mid: string, bot: string): void {
+        this.drawWithColors(top, mid, bot);
+    }
+
+    // ── Private ─────────────────────────────────────────────────────────────────────
+
+    private drawWithColors(topColor: string, midColor: string, botColor: string): void {
         const ctx = this.ctx;
         const w = this.water;
-        const gx = this.glassX;
-        const gy = this.glassY;
-        const gh = w.glassH;
-        const flipped = w.isFlipped;
+        const W = w.glassW;
+        const H = w.glassH;
+        const N = w.columns;
+        const cx = this.glassX + W / 2;
+        const cy = this.glassY + H / 2;
+
+        // Rotate the canvas so gravity always points straight down (+Y).
+        // Everything is then drawn in axis-aligned "gravity space":
+        //   floor at y = +H/2, surface at y = H/2 − h[i], walls at x = ±W/2
+        const g = this.gravity;
+        const gMag = Math.sqrt(g.x * g.x + g.y * g.y) || 980;
+        const angle = Math.atan2(g.x / gMag, g.y / gMag); // how much to un-rotate
+
+        // The glass rectangle in rotated (gravity) space is a diamond whose extents are:
+        //   x: ±(W/2·|cosA| + H/2·|sinA|)
+        //   y: ±(H/2·|cosA| + W/2·|sinA|)
+        // We draw the polygon with these full extents; the glass clip trims the corners.
+        const cosA = Math.abs(Math.cos(angle));
+        const sinA = Math.abs(Math.sin(angle));
+        const ext = W / 2 * cosA + H / 2 * sinA;   // half-width of glass in rotated space
+        const deep = H / 2 * cosA + W / 2 * sinA;   // max depth of glass in rotated space
+
+        const surfL = H / 2 - w.getColumnHeight(0);
+        const surfR = H / 2 - w.getColumnHeight(N - 1);
+
+        /** Draws the surface bezier path from left wall to right wall */
+        const drawSurface = (): void => {
+            ctx.lineTo(-W / 2, surfL);
+            for (let i = 0; i < N; i++) {
+                const hi = w.getColumnHeight(i);
+                const x = (i + 0.5) / N * W - W / 2;
+                const y = H / 2 - hi;
+                if (i === 0) {
+                    ctx.lineTo(x, y);
+                } else {
+                    const hp = w.getColumnHeight(i - 1);
+                    const xm = i / N * W - W / 2;
+                    ctx.bezierCurveTo(xm, H / 2 - hp, xm, H / 2 - hi, x, y);
+                }
+            }
+            ctx.lineTo(W / 2, surfR);
+        };
 
-        // far edge = the solid wall on the opposite side from the wave surface
-        const far = flipped ? gy : gy + gh;
+        ctx.save();
+        ctx.translate(cx, cy);
+        ctx.rotate(-angle);
 
-        // ── Main fill ────────────────────────────────────────────────────────
+        // ── Water polygon ────────────────────────────────────────────────────
+        // Extends to full glass extent so tilted corners are filled.
+        // Flat extrapolation from edge columns fills the corner triangles.
         ctx.beginPath();
-        this.buildWaterPolygon(ctx, far);
-
-        // Gradient: light near the wave surface (where light refracts), dark away from it
-        const [gradY0, gradY1] = flipped
-            ? [gy + gh, gy]   // flipped: surface at bottom of water → light there
-            : [gy, gy + gh];
-        const grad = ctx.createLinearGradient(gx, gradY0, gx, gradY1);
-        grad.addColorStop(0.0, 'rgba(56, 189, 248, 0.72)');
-        grad.addColorStop(0.5, 'rgba(14, 116, 144, 0.85)');
-        grad.addColorStop(1.0, 'rgba(8,  51,  68, 0.95)');
+        ctx.moveTo(-ext, deep);      // far bottom-left
+        ctx.lineTo(-ext, surfL);     // up the left corner
+        drawSurface();               // across the surface
+        ctx.lineTo(ext, surfR);      // across the right corner
+        ctx.lineTo(ext, deep);       // down to far bottom-right
+        ctx.closePath();             // across the bottom
+
+        // Gradient: light near surface, dark at gravity wall
+        const grad = ctx.createLinearGradient(0, -deep, 0, deep);
+        grad.addColorStop(0.0, topColor);
+        grad.addColorStop(0.5, midColor);
+        grad.addColorStop(1.0, botColor);
         ctx.fillStyle = grad;
         ctx.fill();
 
         // ── Shimmer band ─────────────────────────────────────────────────────
-        const shimmerY = gy + gh * 0.25 + Math.sin(this.shimmerOffset) * gh * 0.12;
-        const shimGrad = ctx.createLinearGradient(gx, shimmerY - 30, gx, shimmerY + 30);
+        const shimY = H / 2 - H * (0.30 + Math.sin(this.shimmerOffset) * 0.10);
+        const halfBand = 28;
+        const shimGrad = ctx.createLinearGradient(0, shimY - halfBand, 0, shimY + halfBand);
         shimGrad.addColorStop(0, 'rgba(255,255,255,0.0)');
-        shimGrad.addColorStop(0.5, 'rgba(255,255,255,0.12)');
+        shimGrad.addColorStop(0.5, 'rgba(255,255,255,0.11)');
         shimGrad.addColorStop(1, 'rgba(255,255,255,0.0)');
 
         ctx.save();
         ctx.beginPath();
-        this.buildWaterPolygon(ctx, far);
+        ctx.moveTo(-ext, deep);
+        ctx.lineTo(-ext, surfL);
+        drawSurface();
+        ctx.lineTo(ext, surfR);
+        ctx.lineTo(ext, deep);
+        ctx.closePath();
         ctx.clip();
         ctx.fillStyle = shimGrad;
-        ctx.fillRect(gx, shimmerY - 30, w.glassW, 60);
+        ctx.fillRect(-ext, shimY - halfBand, ext * 2, halfBand * 2);
         ctx.restore();
 
         // ── Surface highlight line ────────────────────────────────────────────
         ctx.beginPath();
-        this.buildSurfaceLine(ctx);
+        ctx.moveTo(-ext, surfL);          // extend left to glass corner
+        drawSurface();
+        ctx.lineTo(ext, surfR);           // extend right to glass corner
         ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)';
         ctx.lineWidth = 2;
         ctx.stroke();
-    }
-
-    /** Build the closed water polygon. `far` is the Y of the opposite wall. */
-    private buildWaterPolygon(ctx: CanvasRenderingContext2D, far: number): void {
-        const w = this.water;
-        const n = w.columns;
-        const colW = w.columnWidth;
-        const gx = this.glassX;
-        const gy = this.glassY;
-
-        ctx.moveTo(gx, far);
-        ctx.lineTo(gx, gy + w.getSurfaceY(0));
-        for (let i = 0; i < n; i++) {
-            const x = gx + i * colW + colW / 2;
-            const sy = gy + w.getSurfaceY(i * colW + colW / 2);
-            if (i === 0) {
-                ctx.lineTo(x, sy);
-            } else {
-                const prevX = gx + (i - 1) * colW + colW / 2;
-                const prevSY = gy + w.getSurfaceY((i - 1) * colW + colW / 2);
-                const cpX = (prevX + x) / 2;
-                ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy);
-            }
-        }
-        ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1));
-        ctx.lineTo(gx + w.glassW, far);
-        ctx.closePath();
-    }
-
-    /** Draw just the wave surface line (used for the highlight stroke). */
-    private buildSurfaceLine(ctx: CanvasRenderingContext2D): void {
-        const w = this.water;
-        const n = w.columns;
-        const colW = w.columnWidth;
-        const gx = this.glassX;
-        const gy = this.glassY;
-
-        ctx.moveTo(gx, gy + w.getSurfaceY(0));
-        for (let i = 0; i < n; i++) {
-            const x = gx + i * colW + colW / 2;
-            const sy = gy + w.getSurfaceY(i * colW + colW / 2);
-            if (i === 0) {
-                ctx.lineTo(x, sy);
-            } else {
-                const prevX = gx + (i - 1) * colW + colW / 2;
-                const prevSY = gy + w.getSurfaceY((i - 1) * colW + colW / 2);
-                const cpX = (prevX + x) / 2;
-                ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy);
-            }
-        }
-        ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1));
-    }
-
-    /**
-     * Like draw() but with custom gradient colors. Used by ShampooLevel.
-     * top/mid/bot are CSS color strings for the fill gradient.
-     */
-    drawTinted(top: string, mid: string, bot: string): void {
-        const ctx = this.ctx;
-        const w = this.water;
-        const gx = this.glassX;
-        const gy = this.glassY;
-        const gh = w.glassH;
-        const flipped = w.isFlipped;
-        const far = flipped ? gy : gy + gh;
-        const [gradY0, gradY1] = flipped ? [gy + gh, gy] : [gy, gy + gh];
 
-        ctx.beginPath();
-        this.buildWaterPolygon(ctx, far);
-        const grad = ctx.createLinearGradient(gx, gradY0, gx, gradY1);
-        grad.addColorStop(0.0, top);
-        grad.addColorStop(0.5, mid);
-        grad.addColorStop(1.0, bot);
-        ctx.fillStyle = grad;
-        ctx.fill();
-
-        // Surface highlight
-        ctx.beginPath();
-        this.buildSurfaceLine(ctx);
-        ctx.strokeStyle = 'rgba(255, 210, 100, 0.5)';
-        ctx.lineWidth = 2;
-        ctx.stroke();
+        ctx.restore(); // un-rotate
     }
 
     /**