bubbles / commit
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 } /**