bubbles / commit
added some more levels and lots of ui changes
.github/copilot-instructions.md | 2 + PLAN.md | 216 ++++++++++++++++++++++++++++++ index.html | 34 ++++- src/App.ts | 177 +++++++++++++++++-------- src/input/TiltInput.ts | 24 ++-- src/levels/ILevel.ts | 36 +++++ src/levels/LavaLampLevel.ts | 286 ++++++++++++++++++++++++++++++++++++++++ src/levels/SandWaterLevel.ts | 217 ++++++++++++++++++++++++++++++ src/levels/ShampooLevel.ts | 228 ++++++++++++++++++++++++++++++++ src/levels/WaterLevel.ts | 162 +++++++++++++++++++++++ src/main.ts | 96 +++++++++++++- src/physics/Water.ts | 86 +++++++----- src/physics/World.ts | 97 +++++++------- src/rendering/GlassRenderer.ts | 33 ++--- src/rendering/WaterRenderer.ts | 122 ++++++++++------- 15 files changed, 1601 insertions(+), 215 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e260674..6b1dbb4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -34,6 +34,8 @@ scripts/ — Node.js tooling (sharp asset pipeline) - Prefer approaches that stay ≥ 30fps on a mid-range phone; profile before committing to a heavier model. - max 12 `RigidBody` objects by default; `World.addBody()` enforces this by dropping oldest non-permanent. - Fixed physics timestep: `1/60` s (set in `App.ts`). +- Container is **sealed** — no spilling, volume is always conserved. +- Gravity is a full 360° `Vector2` (device beta + gamma via `TiltInput`). Buoyancy, constraints, and `Water.getSurfaceY()` all work correctly when the phone is upside-down (`water.isFlipped`). ## Rendering Conventions - `Renderer` always draws in this order: clear → glass clip → water → icons → glass outline. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..fd1a403 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,216 @@ +# Bubbles — Multi-Level Plan + +## Overview + +Four changes are in scope: + +1. **Sealed container + full 360° gravity** — foundational, affects all levels +2. **Level: Water glass** — current level, updated for sealed physics +3. **Level: Lava lamp** — metaball blobs, two densities, thermal cycling +4. **Level: Sand · Water · Air** — three-phase layered simulation +5. **Level: Shampoo bottle** — high-viscosity fluid with merging air bubbles + +--- + +## 1. Sealed Container + Full 360° Gravity + +### What changes + +**`TiltInput`** — currently only uses `gamma` (left/right tilt) and clamps to ±88°. +Full rotation means also using `beta` (front/back) to detect upside-down orientation. +Output stays `gravity: Vector2` but now truly reflects 3D device orientation projected onto the screen plane: + +``` +gx = sin(gamma) * cos(beta) * 980 +gy = sin(beta) * 980 (negative = phone tilted face-down = gravity pulls "up") +``` + +When the phone is fully vertical face-down, `gy ≈ −980` and liquid should rest at the top of the container. + +**`Water` (SWE)** — remove rim spill (no permanent volume loss). Add a top-wall clamp symmetrically to the floor clamp. The lateral body-force term already handles any sign of `lateralG`. Negative `verticalG` is the upside-down case — buoyancy reversal needs to happen in `World`, not `Water`. + +**`World`** — buoyancy force direction is currently hardcoded to oppose `g.y`. Must change to oppose the full `g` vector so objects float in the direction opposite to gravity. When upside-down, dense objects float and light objects sink. + +**`GlassRenderer`** — container is sealed: render top wall the same as side walls (no open top). + +**No other API surface changes** — `gravity: Vector2` stays the same shape. + +### Risk: low. The SWE floor/ceiling clamping is already symmetric; this is mostly removing the spill path and fixing TiltInput. + +--- + +## 2. Level Architecture + +### New structure + +``` +src/levels/ + ILevel.ts — interface + WaterLevel.ts — current water glass (sealed) + LavaLampLevel.ts + SandWaterLevel.ts + ShampooLevel.ts +``` + +### `ILevel` interface + +```ts +interface ILevel { + /** Called once per physics tick. gravity is always the device gravity vector. */ + step(dt: number, gravity: Vector2): void; + /** Called once per rAF frame. */ + draw(ctx: CanvasRenderingContext2D, containerRect: DOMRect): void; + /** Called on touch/pointer event inside container. */ + onTouch(localX: number, localY: number): void; + /** Called when container size changes. */ + resize(w: number, h: number): void; + /** Reset to initial state. */ + reset(): void; + readonly name: string; +} +``` + +### `App.ts` changes + +- Holds an `ILevel` (starts with `WaterLevel`) +- `nextLevel()` / `prevLevel()` cycle through the four levels +- Swipe gesture (horizontal swipe on the glass) triggers level change +- Level name shown briefly as a fade-in/out toast overlay (no persistent UI) + +### Level picker UI + +Single swipe left/right on the glass switches levels. A small dot-row indicator (4 dots, current dot filled) appears at the bottom of the glass for 2 seconds after a level change, then fades out. No separate level-select screen needed. + +--- + +## 3. Level: Water Glass (updated) + +No new simulation. Changes from current: +- Sealed (top wall, no spill) +- Full 360° gravity +- Touch spawns emoji objects (same as today) +- QR body stays permanent + +--- + +## 4. Level: Lava Lamp + +### Concept + +~10 blobs split between two "wax" types: +- **Hot wax** (density 0.92, warm orange-red): rises when near the bottom heat source +- **Cool wax** (density 1.08, deep red-purple): sinks when near the top + +Tilting moves blobs laterally. No interaction with `Water` — this level has no water. + +### Simulation approach: soft-body blobs + +Each blob is a `Vector2` position + velocity (not a `RigidBody`). Integration: + +1. **Thermal density**: each blob tracks a "temperature" scalar that drifts toward a target based on distance from the simulated heat source (always opposite gravity direction = "floor"). Hot → lower density → upward buoyancy. Cool → higher density → downward. +2. **Buoyancy**: `F_buoy = (fluidDensity − blobDensity) × volume × g` +3. **Blob–blob repulsion**: when blobs of *any* type overlap, push apart (incompressibility) +4. **Same-type soft attraction**: gentle pull between blobs of same type within 2× radius (creates the "merging" feel without actual merge/split logic) +5. **Wall collision**: elastic bounce off all four walls +6. **Damping**: high fluid drag (lava is viscous) + +### Rendering + +Metaballs via Canvas 2D: +- Draw each blob as an offscreen radial gradient: opaque center → transparent edge at ~1.5× radius +- Use `ctx.filter = 'blur(Xpx)'` on an offscreen canvas, then `ctx.filter = 'contrast(Y)'` on the main canvas when compositing. This is the classic CSS metaball trick, runs fine on mobile. +- Color: lerp between hot color and cool color based on blob temperature + +### Touch + +Tap creates a gentle gravity-perpendicular impulse in the blob nearest the touch point. + +### Risk: medium. Metaball filter trick needs profiling on mobile — blur radius must stay small (≤8px). No TypeScript complexity. + +--- + +## 5. Level: Sand · Water · Air + +### Concept + +Three materials layered by density, reacting to tilt: +- **Sand** (~40 particles, radius 4px, density 2.5) — settles at "bottom" +- **Water** (SWE 1D, same as WaterLevel) — middle layer +- **Air bubble** (1–3 bodies, radius 20–35px, density 0.001) — floats at "top" + +### Simulation approach + +Reuse `Water` (SWE) + `World` (RigidBody). Sand and air are both `RigidBody` instances with extreme densities. The buoyancy code in `World` already handles them correctly: +- Sand (density 2.5): sinks, rests on floor +- Air bubble (density 0.001): strong upward buoyancy, presses against ceiling (sealed top wall) + +**Sand–sand collision**: existing `resolveBodyCollision` handles it. 40 particles = 780 pair checks per frame — acceptable. + +**Sand resting**: add static friction when a sand particle's speed < threshold and it's resting on the floor or another particle. + +**Air bubble rendering**: white/translucent circle with a bright specular highlight arc (not emoji — just canvas arcs). + +**Water rendering**: same SWE renderer, but water only fills the middle volume. The initial fill fraction accounts for the volume displaced by sand at the bottom. + +### Touch + +Tap disturbs water surface (same `disturb()` call) and scatters nearby sand particles via an impulse. + +### Risk: medium-high. 40 RigidBodies × pair collision = O(N²) = 780 pairs — should be fine. The tricky part is sand settling stably without jitter; static friction threshold needs tuning. + +--- + +## 6. Level: Shampoo Bottle + +### Concept + +A sealed container full of thick, slow-moving fluid with 3–5 trapped air bubbles that: +- Rise toward the "top" (opposite gravity) +- Merge when they touch (become one larger bubble, conservation of volume) +- Split when shaken hard (large velocity impulse → one bubble splits into two) + +### Simulation approach + +**Fluid**: reuse `Water` SWE but with: +- High `K_DAMP` (≈ 5.0 instead of 1.5) → very slow wave propagation +- Lower wave speed feel: reduce effective `G_SW` by 60% (shampoo is more viscous, slower pressure propagation) +- Color: amber/pearlescent gradient + +**Bubbles**: `RigidBody` with density 0.001, but rendered as soft spheres (not emoji). Bubble–bubble interaction uses the same `resolveBodyCollision` but with high restitution. + +**Merge logic** (in `World` or a `ShampooLevel` post-step hook): +- After collision resolution, if two bubbles overlap by > 60% of the smaller radius → merge +- Remove both, spawn one bubble with `r_new = ∛(r₁³ + r₂³)` (volume conservation) + +**Split logic**: +- If a bubble's speed exceeds a threshold (e.g., 400 px/s) → split into two with radii scaled by `∛(0.5)` + +**Rendering**: each bubble is a white circle with `globalAlpha ≈ 0.6`, a bright arc highlight at 10–2 o'clock, and a light shadow at 6 o'clock. + +### Touch + +Tap near a bubble gives it a strong impulse (may trigger split). Tap on fluid disturbs the SWE surface. + +### Risk: low-medium. Merge/split logic is the only novel code. Volume conservation via cube-root radius is straightforward. + +--- + +## Implementation Order + +1. ~~`TiltInput` — full 360° gravity (prerequisite for everything)~~ ✅ +2. ~~Sealed container in `Water`, `World`, `GlassRenderer`~~ ✅ +3. ~~`ILevel` interface + level-switching in `App`~~ ✅ +4. ~~`WaterLevel` — wrap current logic~~ ✅ +5. ~~`LavaLampLevel` — self-contained, no SWE dependency~~ ✅ +6. ~~`ShampooLevel` — extends SWE + merge/split~~ ✅ +7. ~~`SandWaterLevel` — most complex, do last~~ ✅ + +--- + +## What stays the same + +- `Vector2`, `RigidBody`, `TiltInput` structure (only internals change) +- `vite.config.ts`, `package.json`, CI — no changes +- `main.ts` — minor: wire swipe gesture + level dots indicator +- `WaterRenderer`, `GlassRenderer` — reusable by `WaterLevel` and `ShampooLevel` +- All rendering is still Canvas 2D, one class per file, no barrel files diff --git a/index.html b/index.html index b1294de..3ed6807 100644 --- a/index.html +++ b/index.html @@ -183,6 +183,37 @@ height: 20px; fill: currentColor; } + + /* ── Level dot indicator ──────────────────────────────────────── */ + #level-dots { + position: absolute; + bottom: 68px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + align-items: center; + opacity: 0; + transition: opacity 0.3s; + pointer-events: none; + } + + #level-dots.visible { + opacity: 1; + } + + #level-dots .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(226, 232, 240, 0.25); + transition: background 0.2s, transform 0.2s; + } + + #level-dots .dot.active { + background: rgba(56, 189, 248, 0.9); + transform: scale(1.4); + } </style> </head> @@ -213,7 +244,8 @@ d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" /> </svg> </button> - <p id="hint">tap to drop · tilt to slosh</p> + <div id="level-dots"></div> + <p id="hint">tap to drop · tilt to slosh · swipe to switch</p> <footer> <a href="https://gitlab.com/PavelSafronov/bubbles" target="_blank" rel="noopener">gitlab</a> · diff --git a/src/App.ts b/src/App.ts index 4f1a293..493f72a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,59 +1,97 @@ -import { World } from './physics/World.js'; -import { Water } from './physics/Water.js'; -import { RigidBody } from './physics/RigidBody.js'; +import { ILevel } from './levels/ILevel.js'; +import { WaterLevel } from './levels/WaterLevel.js'; import { Vector2 } from './physics/Vector2.js'; -import { Renderer } from './rendering/Renderer.js'; import { TiltInput } from './input/TiltInput.js'; -import { TouchInput } from './input/TouchInput.js'; const FIXED_STEP = 1 / 60; // 60 Hz physics export class App { - private readonly world: World; - private readonly renderer: Renderer; private readonly tilt: TiltInput; - private readonly touch: TouchInput; - private qrBody: import('./physics/RigidBody.js').RigidBody | undefined; + private readonly levels: ILevel[]; + private _levelIndex = 0; private rafId = 0; private lastTime = 0; private accumulator = 0; private permissionRequested = false; + readonly canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + + // Geometry — kept in sync with resize events + private glassX = 0; + private glassY = 0; + private glassW = 0; + private glassH = 0; + + /** Callback fired when the level name changes (for UI dot updates). */ + onLevelChange: ((index: number, total: number, name: string) => void) | undefined; + constructor(canvas: HTMLCanvasElement) { - // Glass dimensions are set properly by Renderer.resize() via the initial - // call in its constructor, but we need placeholder values for World first. - const W = window.innerWidth; - const H = window.innerHeight; - const glassW = Math.min(W * 0.82, 420); - const glassH = H * 0.72; - const glassX = (W - glassW) / 2; - const glassY = (H - glassH) / 2 - H * 0.02; + this.canvas = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Could not get 2D context'); + this.ctx = ctx; - const water = new Water(80, glassW, glassH, 0.55); - this.world = new World(water, glassX, glassY); + this.computeGlassDims(); - // Renderer sets up canvas sizing and overwrites world.glassX/Y on first resize - this.renderer = new Renderer(canvas, this.world); + // WaterLevel is the only level constructed immediately — others are lazy + // so they don't allocate until first visited. + const wl = new WaterLevel( + this.ctx, this.glassX, this.glassY, this.glassW, this.glassH, + ); + this.levels = [wl]; this.tilt = new TiltInput(canvas); - this.touch = new TouchInput(canvas, this.world, () => this.requestPermissionOnce()); + + window.addEventListener('resize', this.handleResize); + this.handleResize(); } - /** Call after the QR canvas has been generated to spawn the QR body. */ + // ── Public API ─────────────────────────────────────────────────────────── + + get currentLevel(): ILevel { return this.levels[this._levelIndex]!; } + get levelCount(): number { return this.levels.length; } + get levelIndex(): number { return this._levelIndex; } + + /** Register all levels (called once after construction with lazy factories). */ + setLevels(levels: ILevel[]): void { + (this.levels as ILevel[]).length = 0; + for (const l of levels) (this.levels as ILevel[]).push(l); + this._levelIndex = Math.min(this._levelIndex, this.levels.length - 1); + } + + nextLevel(): void { + this._levelIndex = (this._levelIndex + 1) % this.levels.length; + this.onLevelChange?.(this._levelIndex, this.levels.length, this.currentLevel.name); + } + + prevLevel(): void { + this._levelIndex = (this._levelIndex - 1 + this.levels.length) % this.levels.length; + this.onLevelChange?.(this._levelIndex, this.levels.length, this.currentLevel.name); + } + + /** Called after the QR canvas has been generated. */ spawnQRBody(qrTexture: HTMLCanvasElement): void { - const r = 40; - const x = this.world.glassX + this.world.glassW / 2; - const y = this.world.glassY + r + 4; - this.qrBody = new RigidBody({ - position: new Vector2(x, y), - radius: r, - density: 0.5, - label: '', - texture: qrTexture, - permanent: true, - }); - this.world.addBody(this.qrBody); + const wl = this.levels[0]; + if (wl instanceof WaterLevel) wl.spawnQRBody(qrTexture); + } + + /** Deliver a tap at canvas coordinates. Routed to the active level. */ + touch(clientX: number, clientY: number): void { + const rect = this.canvas.getBoundingClientRect(); + const canvasX = clientX - rect.left; + const canvasY = clientY - rect.top; + const localX = canvasX - this.glassX; + const localY = canvasY - this.glassY; + this.currentLevel.onTouch(localX, localY, this.tilt.gravity); + } + + /** Request iOS device orientation permission (must be in user gesture). */ + async requestPermissionOnce(): Promise<void> { + if (this.permissionRequested) return; + this.permissionRequested = true; + await this.tilt.requestPermission(); } start(): void { @@ -62,42 +100,75 @@ export class App { } reset(): void { - // Keep only the permanent QR body - this.world.bodies = this.world.bodies.filter(b => b.permanent); - this.world.water.resetFill(0.55); + this.currentLevel.reset(); } stop(): void { cancelAnimationFrame(this.rafId); - this.renderer.destroy(); this.tilt.destroy(); - this.touch.destroy(); + window.removeEventListener('resize', this.handleResize); } + // ── Private ────────────────────────────────────────────────────────────── + private readonly loop = (now: number): void => { - const rawDt = Math.min((now - this.lastTime) / 1000, 0.1); // cap at 100ms + const rawDt = Math.min((now - this.lastTime) / 1000, 0.1); this.lastTime = now; this.accumulator += rawDt; - // Update world gravity from tilt - this.world.gravity = this.tilt.gravity; + const gravity = this.tilt.gravity; - // Fixed-step physics while (this.accumulator >= FIXED_STEP) { - this.world.step(FIXED_STEP); + this.currentLevel.step(FIXED_STEP, gravity); this.accumulator -= FIXED_STEP; } - // Render - this.renderer.update(rawDt); - this.renderer.draw(); - + this.renderFrame(rawDt); this.rafId = requestAnimationFrame(this.loop); }; - private async requestPermissionOnce(): Promise<void> { - if (this.permissionRequested) return; - this.permissionRequested = true; - await this.tilt.requestPermission(); + private renderFrame(_dt: number): void { + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.width / dpr; + const H = this.canvas.height / dpr; + + this.ctx.clearRect(0, 0, W, H); + this.ctx.fillStyle = '#0f172a'; + this.ctx.fillRect(0, 0, W, H); + + this.currentLevel.draw( + this.ctx, this.glassX, this.glassY, this.glassW, this.glassH, + ); + } + + private computeGlassDims(): void { + const W = window.innerWidth; + const H = window.innerHeight; + this.glassW = Math.min(W * 0.82, 420); + this.glassH = H * 0.72; + this.glassX = (W - this.glassW) / 2; + this.glassY = (H - this.glassH) / 2 - H * 0.02; } + + private readonly handleResize = (): void => { + const dpr = window.devicePixelRatio || 1; + const W = window.innerWidth; + const H = window.innerHeight; + + this.canvas.width = W * dpr; + this.canvas.height = H * dpr; + this.canvas.style.width = `${W}px`; + this.canvas.style.height = `${H}px`; + this.ctx.scale(dpr, dpr); + + this.computeGlassDims(); + + for (const level of this.levels) { + if (level instanceof WaterLevel) { + level.setGlassOrigin(this.glassX, this.glassY); + } + level.resize(this.glassW, this.glassH); + } + }; } + diff --git a/src/input/TiltInput.ts b/src/input/TiltInput.ts index fcd509a..4200700 100644 --- a/src/input/TiltInput.ts +++ b/src/input/TiltInput.ts @@ -58,19 +58,23 @@ export class TiltInput { } private readonly handleOrientation = (e: DeviceOrientationEvent): void => { - if (e.gamma === null) return; - // gamma: left/right tilt (-90…+90) when phone is held upright in portrait. - // We derive BOTH gravity components from gamma so that: - // gamma=0 → gravity=(0, 980) — phone upright, water flat - // gamma=30 → gravity=(490, 849) — tilted right, water shifts right - // Using beta for gravity.y was wrong: when upright beta≈90°, cos(90°)≈0 - // which zeroed out vertical gravity entirely. - const gammaDeg = Math.min(88, Math.max(-88, e.gamma)); - const gammaRad = (gammaDeg * Math.PI) / 180; + if (e.gamma === null || e.beta === null) return; + // Full 360° gravity using both gamma (left/right tilt, −90…+90) and beta + // (front/back tilt, −180…+180) projected onto the screen plane: + // + // gx = sin(gamma) * 980 + // gy = sin(beta) * cos(gamma) * 980 + // + // Upright portrait (beta≈90, gamma=0): gx=0, gy=+980 (normal down) + // Upside-down (beta≈−90, gamma=0): gx=0, gy=−980 (gravity up) + // Tilted right 30° (beta≈90, gamma=30): gx≈490, gy≈849 + // Flat face-up (beta=0, gamma=0): gx=0, gy=0 + const gammaRad = (e.gamma * Math.PI) / 180; + const betaRad = (e.beta * Math.PI) / 180; this._gravity = new Vector2( Math.sin(gammaRad) * 980, - Math.cos(gammaRad) * 980, + Math.sin(betaRad) * Math.cos(gammaRad) * 980, ); }; diff --git a/src/levels/ILevel.ts b/src/levels/ILevel.ts new file mode 100644 index 0000000..deac028 --- /dev/null +++ b/src/levels/ILevel.ts @@ -0,0 +1,36 @@ +import { Vector2 } from '../physics/Vector2.js'; + +/** + * Interface every level must implement. + * + * The App game loop calls step() and draw() every tick/frame. + * Levels own all simulation state; App just drives the clock. + */ +export interface ILevel { + readonly name: string; + + /** Advance simulation by dt seconds. gravity is the device gravity vector. */ + step(dt: number, gravity: Vector2): void; + + /** Render everything into ctx for this frame. */ + draw( + ctx: CanvasRenderingContext2D, + /** Left edge X of glass interior, canvas px */ + glassX: number, + /** Top edge Y of glass interior, canvas px */ + glassY: number, + /** Glass interior width, canvas px */ + glassW: number, + /** Glass interior height, canvas px */ + glassH: number, + ): void; + + /** Called when user taps inside the container. localX/Y are glass-space coords. */ + onTouch(localX: number, localY: number, gravity: Vector2): void; + + /** Called whenever the canvas / container is resized. */ + resize(glassW: number, glassH: number): void; + + /** Reset simulation to initial state. */ + reset(): void; +} diff --git a/src/levels/LavaLampLevel.ts b/src/levels/LavaLampLevel.ts new file mode 100644 index 0000000..23e7fba --- /dev/null +++ b/src/levels/LavaLampLevel.ts @@ -0,0 +1,286 @@ +import { ILevel } from './ILevel.js'; +import { Vector2 } from '../physics/Vector2.js'; +import { GlassRenderer } from '../rendering/GlassRenderer.js'; + +/** A single lava-lamp blob. */ +interface Blob { + x: number; + y: number; + vx: number; + vy: number; + radius: number; + /** 0 = cold (sinks), 1 = hot (rises) */ + temp: number; + /** 0 = cool wax, 1 = hot wax */ + type: 0 | 1; +} + +const BLOB_COUNT = 10; +const FLUID_DENSITY = 1.0; +// Hot wax (type 1) at max heat: density 0.90 → rises +// Cool wax (type 0) at max cool: density 1.10 → sinks +// Density interpolated by temp: +// type 0: lerp(1.10, 0.96, temp) +// type 1: lerp(1.04, 0.90, temp) +const THERMAL_RATE = 0.6; // how fast temp changes (1/s) +const DRAG = 4.0; // linear drag coefficient +const REPULSION = 18000; // blob–blob separation force scale +const ATTRACTION = 800; // same-type soft-attraction force scale +const WALL_BOUNCE = 0.35; + +/** + * Lava lamp level. + * Two types of "wax" blobs circulate due to simulated thermal density changes. + * Rendered as metaballs using the CSS blur+contrast Canvas 2D trick. + */ +export class LavaLampLevel implements ILevel { + readonly name = 'Lava Lamp'; + + private blobs: Blob[] = []; + private glassRend: GlassRenderer; + /** Offscreen canvas for metaball compositing. */ + private offscreen: OffscreenCanvas | HTMLCanvasElement; + private offCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + + constructor( + private readonly ctx: CanvasRenderingContext2D, + private glassX: number, + private glassY: number, + private glassW: number, + private glassH: number, + ) { + this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH); + const { off, offCtx } = this.makeOffscreen(glassW, glassH); + this.offscreen = off; + this.offCtx = offCtx; + this.initBlobs(); + } + + // ── ILevel ─────────────────────────────────────────────────────────────── + + step(dt: number, gravity: Vector2): void { + const W = this.glassW; + const H = this.glassH; + const gMag = gravity.magnitude(); + // Gravity direction (unit vector pointing "down") + const gx = gMag > 0 ? gravity.x / gMag : 0; + const gy = gMag > 0 ? gravity.y / gMag : 1; + + for (let i = 0; i < this.blobs.length; i++) { + const b = this.blobs[i]!; + + // 1. Thermal update: type-1 heats near the gravity wall, type-0 cools + // "near the gravity wall" = dot(pos_from_center, gDir) > 0 + const cx = b.x - W / 2; + const cy = b.y - H / 2; + const wallProx = (cx * gx + cy * gy) / (H / 2); // -1 (anti-grav wall) … +1 (grav wall) + + let targetTemp: number; + if (b.type === 1) { + // Hot wax heats up near the gravity wall (bottom when upright) + targetTemp = (wallProx + 1) / 2; + } else { + // Cool wax cools down near the anti-gravity wall (top when upright) + targetTemp = 1 - (wallProx + 1) / 2; + } + b.temp += (targetTemp - b.temp) * Math.min(1, dt * THERMAL_RATE); + + // 2. Density → buoyancy + const density = b.type === 1 + ? 1.04 - 0.14 * b.temp // 1.04 → 0.90 + : 1.10 - 0.14 * b.temp; // 1.10 → 0.96 + const buoyancyAcc = (FLUID_DENSITY - density) * gMag; + // Buoyancy opposes gravity direction + const bax = -gx * buoyancyAcc; + const bay = -gy * buoyancyAcc; + + // 3. Body force from gravity + const fax = gx * gMag; + const fay = gy * gMag; + + // 4. Net acceleration + let ax = fax + bax - DRAG * b.vx; + let ay = fay + bay - DRAG * b.vy; + + // 5. Blob–blob forces + for (let j = 0; j < this.blobs.length; j++) { + if (i === j) continue; + const o = this.blobs[j]!; + const dx = b.x - o.x; + const dy = b.y - o.y; + const d2 = dx * dx + dy * dy + 0.01; + const minD = b.radius + o.radius; + // Repulsion: always push apart when close + if (d2 < (minD * 2) * (minD * 2)) { + const d = Math.sqrt(d2); + const f = REPULSION / (d2); + ax += (dx / d) * f; + ay += (dy / d) * f; + } + // Soft attraction: same type, within range + if (b.type === o.type && d2 < (minD * 5) * (minD * 5)) { + const d = Math.sqrt(d2); + const f = ATTRACTION / (d2 + 1); + ax -= (dx / d) * f; + ay -= (dy / d) * f; + } + } + + b.vx += ax * dt; + b.vy += ay * dt; + + b.x += b.vx * dt; + b.y += b.vy * dt; + + // 6. Wall bounce (all four walls — sealed) + if (b.x - b.radius < 0) { + b.x = b.radius; + b.vx = Math.abs(b.vx) * WALL_BOUNCE; + } + if (b.x + b.radius > W) { + b.x = W - b.radius; + b.vx = -Math.abs(b.vx) * WALL_BOUNCE; + } + if (b.y - b.radius < 0) { + b.y = b.radius; + b.vy = Math.abs(b.vy) * WALL_BOUNCE; + } + if (b.y + b.radius > H) { + b.y = H - b.radius; + b.vy = -Math.abs(b.vy) * WALL_BOUNCE; + } + } + } + + draw( + ctx: CanvasRenderingContext2D, + glassX: number, + glassY: number, + glassW: number, + glassH: number, + ): void { + // Render metaballs into offscreen canvas, then composite with blur+contrast + const off = this.offCtx; + const W = glassW; + const H = glassH; + + off.clearRect(0, 0, W, H); + + // Dark background on offscreen (contrast trick needs opaque BG) + off.fillStyle = '#1a0a1a'; + off.fillRect(0, 0, W, H); + + // Draw each blob as a radial gradient + for (const b of this.blobs) { + const hot = b.type === 1 ? b.temp : (1 - b.temp); + const r = b.radius * 1.8; // gradient extends beyond physics radius + const grad = off.createRadialGradient(b.x, b.y, 0, b.x, b.y, r); + + // Color: interpolate between cool (deep red-purple) and hot (orange-red) + const cCool = [120, 20, 60]; + const cHot = [255, 80, 20]; + const cr = Math.round(cCool[0]! + (cHot[0]! - cCool[0]!) * hot); + const cg = Math.round(cCool[1]! + (cHot[1]! - cCool[1]!) * hot); + const cb = Math.round(cCool[2]! + (cHot[2]! - cCool[2]!) * hot); + + grad.addColorStop(0, `rgba(${cr},${cg},${cb},1)`); + grad.addColorStop(0.5, `rgba(${cr},${cg},${cb},0.7)`); + grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`); + off.fillStyle = grad; + off.beginPath(); + off.arc(b.x, b.y, r, 0, Math.PI * 2); + off.fill(); + } + + // Clip to glass, composite with blur + contrast for metaball merge effect + this.glassRend.beginClip(); + + ctx.save(); + // blur: blends blob edges; contrast: sharpens merged boundary + ctx.filter = 'blur(12px) contrast(18)'; + ctx.drawImage( + this.offscreen as CanvasImageSource, + glassX, glassY, W, H, + glassX, glassY, W, H, + ); + ctx.filter = 'none'; + ctx.restore(); + + // Warm amber glow fill for background fluid (lamp oil) + ctx.fillStyle = 'rgba(40, 15, 5, 0.65)'; + ctx.fillRect(glassX, glassY, W, H); + + this.glassRend.endClip(); + this.glassRend.draw(); + } + + onTouch(localX: number, localY: number, _gravity: Vector2): void { + // Find nearest blob and give it an impulse outward from the tap + let nearest: Blob | undefined; + let nearDist = Infinity; + for (const b of this.blobs) { + const d = Math.hypot(b.x - localX, b.y - localY); + if (d < nearDist) { nearDist = d; nearest = b; } + } + if (nearest) { + const dx = nearest.x - localX; + const dy = nearest.y - localY; + const d = Math.hypot(dx, dy) + 1; + nearest.vx += (dx / d) * 200; + nearest.vy += (dy / d) * 200; + } + } + + resize(glassW: number, glassH: number): void { + const scaleX = glassW / this.glassW; + const scaleY = glassH / this.glassH; + for (const b of this.blobs) { + b.x *= scaleX; + b.y *= scaleY; + } + this.glassW = glassW; + this.glassH = glassH; + this.glassRend = new GlassRenderer(this.ctx, this.glassX, this.glassY, glassW, glassH); + const { off, offCtx } = this.makeOffscreen(glassW, glassH); + this.offscreen = off; + this.offCtx = offCtx; + } + + reset(): void { + this.initBlobs(); + } + + // ── Private ────────────────────────────────────────────────────────────── + + private initBlobs(): void { + const W = this.glassW; + const H = this.glassH; + this.blobs = []; + for (let i = 0; i < BLOB_COUNT; i++) { + const type = (i % 2) as 0 | 1; + this.blobs.push({ + x: W * (0.2 + Math.random() * 0.6), + y: H * (0.2 + Math.random() * 0.6), + vx: (Math.random() - 0.5) * 20, + vy: (Math.random() - 0.5) * 20, + radius: 22 + Math.random() * 14, + temp: type === 1 ? 0.8 : 0.2, + type, + }); + } + } + + private makeOffscreen(w: number, h: number): { + off: OffscreenCanvas | HTMLCanvasElement; + offCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + } { + if (typeof OffscreenCanvas !== 'undefined') { + const off = new OffscreenCanvas(w, h); + return { off, offCtx: off.getContext('2d')! }; + } + const off = document.createElement('canvas'); + off.width = w; + off.height = h; + return { off, offCtx: off.getContext('2d')! }; + } +} diff --git a/src/levels/SandWaterLevel.ts b/src/levels/SandWaterLevel.ts new file mode 100644 index 0000000..d27d5bb --- /dev/null +++ b/src/levels/SandWaterLevel.ts @@ -0,0 +1,217 @@ +import { ILevel } from './ILevel.js'; +import { Vector2 } from '../physics/Vector2.js'; +import { Water } from '../physics/Water.js'; +import { World } from '../physics/World.js'; +import { RigidBody } from '../physics/RigidBody.js'; +import { WaterRenderer } from '../rendering/WaterRenderer.js'; +import { GlassRenderer } from '../rendering/GlassRenderer.js'; + +const SAND_COUNT = 38; +const SAND_RADIUS = 5; +const SAND_DENSITY = 2.5; +const AIR_COUNT = 2; +const AIR_DENSITY = 0.001; +const AIR_RADIUS = 24; +// Sand particles that move slower than this for several ticks are considered at rest +const REST_SPEED = 8; // px/s +const STATIC_FRIC = 0.85; // extra velocity damping when resting + +/** + * Three-phase level: sand at the bottom, water in the middle, air bubbles at the top. + * Sand and air are RigidBodies; water is the SWE simulation. + */ +export class SandWaterLevel implements ILevel { + readonly name = 'Sand & Water'; + + private water: Water; + private world: World; + private waterRend: WaterRenderer; + private glassRend: GlassRenderer; + + constructor( + private readonly ctx: CanvasRenderingContext2D, + private glassX: number, + private glassY: number, + glassW: number, + glassH: number, + ) { + // Water starts at 50%, accounting for sand volume at the bottom + this.water = new Water(80, glassW, glassH, 0.50); + this.world = new World(this.water, glassX, glassY, SAND_COUNT + AIR_COUNT + 2); + this.waterRend = new WaterRenderer(ctx, this.water, glassX, glassY); + this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH); + this.spawnParticles(); + } + + // ── ILevel ─────────────────────────────────────────────────────────────── + + step(dt: number, gravity: Vector2): void { + this.world.gravity = gravity; + this.world.step(dt); + this.applySandFriction(); + } + + draw( + ctx: CanvasRenderingContext2D, + _glassX: number, + _glassY: number, + _glassW: number, + _glassH: number, + ): void { + this.glassRend.beginClip(); + + // Sand drawn first (bottom layer, behind water) + this.drawSand(ctx); + + // Air drawn before water (behind water surface) + this.drawAir(ctx); + + // Water + this.waterRend.draw(); + + this.glassRend.endClip(); + this.glassRend.draw(); + } + + onTouch(localX: number, localY: number, _gravity: Vector2): void { + const wx = this.glassX + localX; + const wy = this.glassY + localY; + // Disturb water surface + this.water.disturb(localX, 40, 200); + // Scatter nearby sand particles + for (const body of this.world.bodies) { + if (body.density < 1.0) continue; // skip air bubbles + const d = Math.hypot(body.position.x - wx, body.position.y - wy); + if (d < 60) { + const dx = body.position.x - wx; + const dy = body.position.y - wy; + const len = Math.hypot(dx, dy) + 1; + body.velocity.x += (dx / len) * 300; + body.velocity.y += (dy / len) * 300; + } + } + } + + resize(glassW: number, glassH: number): void { + this.water.resize(glassW, glassH); + this.waterRend.glassX = this.glassX; + this.waterRend.glassY = this.glassY; + this.glassRend = new GlassRenderer(this.ctx, this.glassX, this.glassY, glassW, glassH); + } + + reset(): void { + this.world.bodies = []; + this.water.resetFill(0.50); + this.spawnParticles(); + } + + // ── Private ────────────────────────────────────────────────────────────── + + private spawnParticles(): void { + const gx = this.world.glassX; + const gy = this.world.glassY; + const gw = this.world.glassW; + const gh = this.world.glassH; + + // Sand: pack into lower ~20% of glass + const sandZoneH = gh * 0.20; + const cols = Math.floor(gw / (SAND_RADIUS * 2 + 2)); + for (let i = 0; i < SAND_COUNT; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const x = gx + SAND_RADIUS + col * (SAND_RADIUS * 2 + 2) + (Math.random() - 0.5) * 3; + const y = gy + gh - SAND_RADIUS - row * (SAND_RADIUS * 2 + 1); + if (y - SAND_RADIUS < gy + gh - sandZoneH * 1.5) { + // Toss remaining in random positions + const body = new RigidBody({ + position: new Vector2( + gx + SAND_RADIUS + Math.random() * (gw - SAND_RADIUS * 2), + gy + gh - SAND_RADIUS - Math.random() * sandZoneH, + ), + radius: SAND_RADIUS, + density: SAND_DENSITY, + label: '', + }); + this.world.addBody(body); + continue; + } + const body = new RigidBody({ + position: new Vector2(x, y), + radius: SAND_RADIUS, + density: SAND_DENSITY, + label: '', + }); + this.world.addBody(body); + } + + // Air bubbles: spawn near the top + for (let i = 0; i < AIR_COUNT; i++) { + const x = gx + AIR_RADIUS + Math.random() * (gw - AIR_RADIUS * 2); + const y = gy + AIR_RADIUS + Math.random() * gh * 0.25; + this.world.addBody(new RigidBody({ + position: new Vector2(x, y), + radius: AIR_RADIUS, + density: AIR_DENSITY, + label: '', + })); + } + } + + /** Extra static friction to prevent sand jitter when settled. */ + private applySandFriction(): void { + for (const body of this.world.bodies) { + if (body.density < 1.0) continue; // air bodies — skip + const speed = body.velocity.magnitude(); + if (speed < REST_SPEED) { + body.velocity.x *= STATIC_FRIC; + body.velocity.y *= STATIC_FRIC; + } + } + } + + private drawSand(ctx: CanvasRenderingContext2D): void { + for (const body of this.world.bodies) { + if (body.density < 1.0) continue; // air — not sand + const { x, y } = body.position; + const r = body.radius; + + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + // Sandy color — slightly varied per body id + const hue = 38 + (body.id % 8) - 4; + const lit = 52 + (body.id % 10) - 5; + ctx.fillStyle = `hsl(${hue}, 60%, ${lit}%)`; + ctx.fill(); + ctx.restore(); + } + } + + private drawAir(ctx: CanvasRenderingContext2D): void { + for (const body of this.world.bodies) { + if (body.density >= 1.0) continue; // sand — not air + const { x, y } = body.position; + const r = body.radius; + + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + const grad = ctx.createRadialGradient( + x - r * 0.3, y - r * 0.3, r * 0.1, + x, y, r, + ); + grad.addColorStop(0, 'rgba(255,255,255,0.80)'); + grad.addColorStop(0.5, 'rgba(200,230,255,0.30)'); + grad.addColorStop(1, 'rgba(150,200,255,0.05)'); + ctx.fillStyle = grad; + ctx.fill(); + // Highlight arc + ctx.beginPath(); + ctx.arc(x, y, r * 0.6, Math.PI * 1.2, Math.PI * 1.8); + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; + ctx.lineWidth = r * 0.1; + ctx.stroke(); + ctx.restore(); + } + } +} diff --git a/src/levels/ShampooLevel.ts b/src/levels/ShampooLevel.ts new file mode 100644 index 0000000..bb1949a --- /dev/null +++ b/src/levels/ShampooLevel.ts @@ -0,0 +1,228 @@ +import { ILevel } from './ILevel.js'; +import { Vector2 } from '../physics/Vector2.js'; +import { Water } from '../physics/Water.js'; +import { World } from '../physics/World.js'; +import { RigidBody } from '../physics/RigidBody.js'; +import { GlassRenderer } from '../rendering/GlassRenderer.js'; +import { WaterRenderer } from '../rendering/WaterRenderer.js'; + +const INITIAL_BUBBLES = 4; +const BUBBLE_DENSITY = 0.001; // extremely light — strong upward buoyancy +const MIN_BUBBLE_R = 14; +const MAX_BUBBLE_R = 36; +// Merge when overlap > this fraction of smaller radius +const MERGE_THRESHOLD = 0.55; +// Split when speed exceeds this (px/s) +const SPLIT_SPEED = 380; + +/** + * Shampoo bottle level. + * High-viscosity amber fluid with air bubbles that merge and split. + */ +export class ShampooLevel implements ILevel { + readonly name = 'Shampoo'; + + private water: Water; + private world: World; + private waterRend: WaterRenderer; + private glassRend: GlassRenderer; + private shimmerOffset = 0; + + constructor( + private readonly ctx: CanvasRenderingContext2D, + private glassX: number, + private glassY: number, + glassW: number, + glassH: number, + ) { + // Shampoo: same SWE but viscous feel — high damping, slower pressure + this.water = new Water(80, glassW, glassH, 0.88, 400, 5.0); + this.world = new World(this.water, glassX, glassY); + this.waterRend = new WaterRenderer(ctx, this.water, glassX, glassY); + this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH); + this.spawnInitialBubbles(); + } + + // ── ILevel ─────────────────────────────────────────────────────────────── + + step(dt: number, gravity: Vector2): void { + this.world.gravity = gravity; + this.world.step(dt); + this.shimmerOffset = (this.shimmerOffset + dt * 0.3) % (Math.PI * 2); + this.mergeSplitBubbles(); + } + + draw( + ctx: CanvasRenderingContext2D, + glassX: number, + glassY: number, + _glassW: number, + _glassH: number, + ): void { + this.glassRend.beginClip(); + + // Bubbles behind fluid + this.drawBubbles(ctx); + + // Amber shampoo fluid + this.waterRend.drawTinted( + 'rgba(255, 170, 30, 0.78)', + 'rgba(180, 90, 10, 0.90)', + 'rgba(100, 40, 5, 0.96)', + ); + + this.glassRend.endClip(); + this.glassRend.draw(); + } + + onTouch(localX: number, localY: number, _gravity: Vector2): void { + const gx = this.world.glassX; + const gy = this.world.glassY; + const wx = gx + localX; + const wy = gy + localY; + // Give nearby bubbles a strong impulse (may trigger split) + for (const body of this.world.bodies) { + const d = Math.hypot(body.position.x - wx, body.position.y - wy); + if (d < body.radius * 3) { + const dx = body.position.x - wx; + const dy = body.position.y - wy; + const len = Math.hypot(dx, dy) + 1; + body.velocity.x += (dx / len) * 500; + body.velocity.y += (dy / len) * 500; + } + } + // Also disturb fluid surface + this.water.disturb(localX, 30, 150); + } + + resize(glassW: number, glassH: number): void { + this.water.resize(glassW, glassH); + this.waterRend.glassX = this.glassX; + this.waterRend.glassY = this.glassY; + this.glassRend = new GlassRenderer(this.ctx, this.glassX, this.glassY, glassW, glassH); + } + + reset(): void { + this.world.bodies = []; + this.water.resetFill(0.88); + this.spawnInitialBubbles(); + } + + // ── Private ────────────────────────────────────────────────────────────── + + private spawnInitialBubbles(): void { + const gx = this.world.glassX; + const gy = this.world.glassY; + const gw = this.world.glassW; + const gh = this.world.glassH; + for (let i = 0; i < INITIAL_BUBBLES; i++) { + const r = MIN_BUBBLE_R + Math.random() * (MAX_BUBBLE_R - MIN_BUBBLE_R); + const x = gx + r + Math.random() * (gw - r * 2); + const y = gy + gh * 0.3 + Math.random() * gh * 0.5; + this.world.addBody(new RigidBody({ + position: new Vector2(x, y), + radius: r, + density: BUBBLE_DENSITY, + label: '', + })); + } + } + + private mergeSplitBubbles(): void { + const bodies = this.world.bodies; + + // Split fast bubbles first + const toAdd: RigidBody[] = []; + const toRemove = new Set<number>(); + for (const body of bodies) { + if (toRemove.has(body.id)) continue; + const speed = body.velocity.magnitude(); + if (speed > SPLIT_SPEED && body.radius > MIN_BUBBLE_R * 1.5) { + toRemove.add(body.id); + const rNew = body.radius / Math.cbrt(2); + const perp = new Vector2(-body.velocity.y, body.velocity.x).scale( + 1 / (speed + 0.001), + ); + for (const sign of [-1, 1]) { + const pos = body.position.add(perp.scale(sign * rNew * 0.8)); + const b = new RigidBody({ + position: pos, radius: rNew, + density: BUBBLE_DENSITY, label: '', + }); + b.velocity.x = body.velocity.x * 0.6; + b.velocity.y = body.velocity.y * 0.6; + toAdd.push(b); + } + } + } + + // Merge overlapping bubbles + for (let i = 0; i < bodies.length; i++) { + const a = bodies[i]!; + if (toRemove.has(a.id)) continue; + for (let j = i + 1; j < bodies.length; j++) { + const b = bodies[j]!; + if (toRemove.has(b.id)) continue; + const dist = a.position.sub(b.position).magnitude(); + const minR = Math.min(a.radius, b.radius); + if (dist < a.radius + b.radius - minR * MERGE_THRESHOLD) { + // Volume-conserving merge: r = ∛(r₁³ + r₂³) + const rNew = Math.cbrt(a.radius ** 3 + b.radius ** 3); + if (rNew > MAX_BUBBLE_R * 1.5) continue; // cap size + toRemove.add(a.id); + toRemove.add(b.id); + const totalMass = a.mass + b.mass; + const merged = new RigidBody({ + position: a.position.scale(a.mass / totalMass).add( + b.position.scale(b.mass / totalMass), + ), + radius: rNew, + density: BUBBLE_DENSITY, + label: '', + }); + merged.velocity.x = (a.velocity.x * a.mass + b.velocity.x * b.mass) / totalMass; + merged.velocity.y = (a.velocity.y * a.mass + b.velocity.y * b.mass) / totalMass; + toAdd.push(merged); + break; + } + } + } + + if (toRemove.size > 0) { + this.world.bodies = bodies.filter(b => !toRemove.has(b.id)); + } + for (const b of toAdd) this.world.addBody(b); + + // If all bubbles are gone, spawn a fresh one + if (this.world.bodies.length === 0) this.spawnInitialBubbles(); + } + + private drawBubbles(ctx: CanvasRenderingContext2D): void { + for (const body of this.world.bodies) { + const { x, y } = body.position; + const r = body.radius; + + // Main translucent white sphere + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + const grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.1, x, y, r); + grad.addColorStop(0, 'rgba(255,255,255,0.85)'); + grad.addColorStop(0.5, 'rgba(255,255,255,0.35)'); + grad.addColorStop(1, 'rgba(255,255,255,0.05)'); + ctx.fillStyle = grad; + ctx.fill(); + + // Specular highlight arc (10–2 o'clock) + ctx.beginPath(); + ctx.arc(x, y, r * 0.65, Math.PI * 1.2, Math.PI * 1.8); + ctx.strokeStyle = 'rgba(255,255,255,0.7)'; + ctx.lineWidth = r * 0.12; + ctx.stroke(); + ctx.restore(); + } + } +} + + + diff --git a/src/levels/WaterLevel.ts b/src/levels/WaterLevel.ts new file mode 100644 index 0000000..dc7a9fd --- /dev/null +++ b/src/levels/WaterLevel.ts @@ -0,0 +1,162 @@ +import { ILevel } from './ILevel.js'; +import { Vector2 } from '../physics/Vector2.js'; +import { Water } from '../physics/Water.js'; +import { World } from '../physics/World.js'; +import { RigidBody } from '../physics/RigidBody.js'; +import { WaterRenderer } from '../rendering/WaterRenderer.js'; +import { GlassRenderer } from '../rendering/GlassRenderer.js'; +import { IconRenderer } from '../rendering/IconRenderer.js'; +import { ICONS } from '../icons/registry.js'; + +/** + * The original water-glass level. + * Sealed container, full 360° gravity, emoji objects that float/sink. + */ +export class WaterLevel implements ILevel { + readonly name = 'Water'; + + private water: Water; + private world: World; + private waterRend: WaterRenderer; + private glassRend: GlassRenderer; + private iconRend: IconRenderer; + + private shimmerOffset = 0; + private refractOffset = 0; + + constructor( + private readonly ctx: CanvasRenderingContext2D, + glassX: number, + glassY: number, + glassW: number, + glassH: number, + qrTexture?: HTMLCanvasElement, + ) { + this.water = new Water(80, glassW, glassH, 0.55); + this.world = new World(this.water, glassX, glassY); + + this.waterRend = new WaterRenderer(ctx, this.water, glassX, glassY); + this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH); + this.iconRend = new IconRenderer(ctx, this.water, glassX, glassY); + + if (qrTexture) this.spawnQRBody(qrTexture); + } + + // ── 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.refractOffset = (this.refractOffset + dt * 1.2) % (Math.PI * 2); + } + + draw( + ctx: CanvasRenderingContext2D, + glassX: number, + glassY: number, + _glassW: number, + _glassH: number, + ): void { + this.glassRend.beginClip(); + + // Icons behind water + this.iconRend.draw(this.world.bodies, (x) => this.getRefractionDx(x)); + + // Water + this.waterRend.draw(); + + // Textured bodies (QR card) on top of water + for (const body of this.world.bodies) { + if (!body.texture) continue; + const s = body.radius * 2; + ctx.save(); + ctx.translate(body.position.x, body.position.y); + ctx.rotate(body.angle); + ctx.fillStyle = '#ffffff'; + const corner = 4; + ctx.beginPath(); + ctx.roundRect(-s / 2, -s / 2, s, s, corner); + ctx.fill(); + const pad = s * 0.06; + ctx.drawImage(body.texture, -s / 2 + pad, -s / 2 + pad, s - pad * 2, s - pad * 2); + ctx.restore(); + } + + this.glassRend.endClip(); + this.glassRend.draw(); + } + + onTouch(localX: number, localY: number, _gravity: Vector2): void { + const glassX = this.world.glassX; + const glassY = this.world.glassY; + const gw = this.world.glassW; + const gh = this.world.glassH; + + const icon = ICONS[Math.floor(Math.random() * ICONS.length)]!; + const r = icon.radius; + + const spawnX = Math.max(glassX + r, Math.min(glassX + gw - r, glassX + localX)); + const spawnY = Math.max(glassY + r, Math.min(glassY + gh - r, glassY + localY)); + + const body = new RigidBody({ + position: new Vector2(spawnX, spawnY), + radius: r, + density: icon.density, + label: icon.label, + }); + body.velocity.x = (Math.random() - 0.5) * 80; + body.angularVelocity = (Math.random() - 0.5) * 4; + this.world.addBody(body); + } + + resize(glassW: number, glassH: number): void { + const glassX = this.world.glassX; + const glassY = this.world.glassY; + this.water.resize(glassW, glassH); + this.waterRend.glassX = glassX; + this.waterRend.glassY = glassY; + this.iconRend.glassX = glassX; + this.iconRend.glassY = glassY; + this.glassRend = new GlassRenderer(this.ctx, glassX, glassY, glassW, glassH); + } + + /** Update glassX/Y when the window resizes. Called by App before resize(). */ + setGlassOrigin(glassX: number, glassY: number): void { + this.world.glassX = glassX; + this.world.glassY = glassY; + this.waterRend.glassX = glassX; + this.waterRend.glassY = glassY; + this.iconRend.glassX = glassX; + this.iconRend.glassY = glassY; + } + + reset(): void { + this.world.bodies = this.world.bodies.filter(b => b.permanent); + this.water.resetFill(0.55); + } + + // ── Public helpers ─────────────────────────────────────────────────────── + + /** Expose World so App can spawn/access permanent bodies (e.g. QR). */ + get worldRef(): World { return this.world; } + + spawnQRBody(qrTexture: HTMLCanvasElement): void { + const r = 40; + const x = this.world.glassX + this.world.glassW / 2; + const y = this.world.glassY + r + 4; + const body = new RigidBody({ + position: new Vector2(x, y), + radius: r, + density: 0.5, + label: '', + texture: qrTexture, + permanent: true, + }); + this.world.addBody(body); + } + + private getRefractionDx(canvasX: number): number { + return Math.sin(this.refractOffset + canvasX * 0.04) * 3; + } +} diff --git a/src/main.ts b/src/main.ts index c289bbb..7890562 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,8 @@ import { App } from './App.js'; +import { WaterLevel } from './levels/WaterLevel.js'; +import { LavaLampLevel } from './levels/LavaLampLevel.js'; +import { ShampooLevel } from './levels/ShampooLevel.js'; +import { SandWaterLevel } from './levels/SandWaterLevel.js'; import QRCode from 'qrcode'; const canvas = document.getElementById('canvas') as HTMLCanvasElement | null; @@ -30,26 +34,43 @@ async function enterFullscreen(): Promise<void> { await tryLockOrientation(); } -// Re-apply lock if fullscreen change drops it document.addEventListener('fullscreenchange', () => { if (document.fullscreenElement && !orientationLocked) { void tryLockOrientation(); } }); -// Re-apply lock if OS overrides it mid-session screen.orientation.addEventListener('change', () => { if (orientationLocked) void tryLockOrientation(); }); -// ── Start button ────────────────────────────────────────────────────────── +// ── App + levels ────────────────────────────────────────────────────────── const app = new App(canvas); -const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement; -resetBtn.addEventListener('click', () => app.reset()); +// Build all levels sharing the same ctx/glass geometry from app +// (App exposes canvas; levels read glass dims on construction via glassX/Y/W/H) +const ctx = app.canvas.getContext('2d')!; + +function makeAllLevels(): void { + const W = window.innerWidth; + const H = window.innerHeight; + const glassW = Math.min(W * 0.82, 420); + const glassH = H * 0.72; + const glassX = (W - glassW) / 2; + const glassY = (H - glassH) / 2 - H * 0.02; + + app.setLevels([ + new WaterLevel(ctx, glassX, glassY, glassW, glassH), + new LavaLampLevel(ctx, glassX, glassY, glassW, glassH), + new ShampooLevel(ctx, glassX, glassY, glassW, glassH), + new SandWaterLevel(ctx, glassX, glassY, glassW, glassH), + ]); +} +makeAllLevels(); + +// ── QR body ─────────────────────────────────────────────────────────────── -// ── QR body — generate once and spawn into the glass ────────────────────── void (async () => { const qrCanvas = document.createElement('canvas'); await QRCode.toCanvas(qrCanvas, window.location.href, { @@ -60,9 +81,72 @@ void (async () => { app.spawnQRBody(qrCanvas); })(); +// ── Reset button ────────────────────────────────────────────────────────── + +const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement; +resetBtn.addEventListener('click', () => app.reset()); + +// ── Level dot indicator ─────────────────────────────────────────────────── + +const dotsEl = document.getElementById('level-dots') as HTMLDivElement; +let dotsTimeout = 0; + +function updateDots(index: number, total: number): void { + dotsEl.innerHTML = ''; + for (let i = 0; i < total; i++) { + const dot = document.createElement('span'); + dot.className = 'dot' + (i === index ? ' active' : ''); + dotsEl.appendChild(dot); + } + dotsEl.classList.add('visible'); + clearTimeout(dotsTimeout); + dotsTimeout = window.setTimeout(() => dotsEl.classList.remove('visible'), 2000); +} + +app.onLevelChange = (index, total) => updateDots(index, total); + +// ── Touch: tap to drop, swipe to switch level ───────────────────────────── + +let pointerStartX = 0; +let pointerStartY = 0; +let pointerMoved = false; + +canvas.addEventListener('pointerdown', async (e: PointerEvent) => { + pointerStartX = e.clientX; + pointerStartY = e.clientY; + pointerMoved = false; + await app.requestPermissionOnce(); +}); + +canvas.addEventListener('pointermove', (e: PointerEvent) => { + if (Math.abs(e.clientX - pointerStartX) > 8 || Math.abs(e.clientY - pointerStartY) > 8) { + pointerMoved = true; + } +}); + +canvas.addEventListener('pointerup', (e: PointerEvent) => { + const dx = e.clientX - pointerStartX; + const dy = e.clientY - pointerStartY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist > 40 && Math.abs(dx) > Math.abs(dy) * 1.5) { + // Horizontal swipe + if (dx < 0) app.nextLevel(); else app.prevLevel(); + updateDots(app.levelIndex, app.levelCount); + } else if (!pointerMoved || dist < 12) { + // Tap + app.touch(e.clientX, e.clientY); + } + pointerMoved = false; +}); + +// ── Start button ────────────────────────────────────────────────────────── + startBtn.addEventListener('click', async () => { await enterFullscreen(); startScreen.classList.add('hidden'); + updateDots(app.levelIndex, app.levelCount); app.start(); }, { once: true }); + diff --git a/src/physics/Water.ts b/src/physics/Water.ts index 5e341c6..cc0cab1 100644 --- a/src/physics/Water.ts +++ b/src/physics/Water.ts @@ -3,16 +3,19 @@ * * Grid layout: * h[i] column height at cell centre i = 0 … N−1 + * measured from the "gravity wall" (the wall gravity pulls toward). * u[j] horizontal velocity at face j = 0 … N - * u[0] = u[N] = 0 (solid walls) + * u[0] = u[N] = 0 (solid walls, sealed container) * - * Governing equations (linearised around mean depth H̄): - * ∂h/∂t + H̄ · ∂u/∂x = 0 (mass continuity, nonlinear flux used) + * Governing equations: + * ∂h/∂t + H̄ · ∂u/∂x = 0 (mass continuity, nonlinear flux) * ∂u/∂t = −g_sw · ∂h/∂x + aₓ (depth-averaged momentum) * - * Wave speed c = √(g_sw · H̄). - * With g_sw = 980 and H̄ ≈ 150 px → c ≈ 383 px/s, - * giving a natural slosh period T ≈ 2W/c ≈ 1.6 s for a 300 px-wide glass. + * Full 360° support: when the device is flipped upside-down (verticalG < 0), + * h[i] is reinterpreted as measuring from the ceiling rather than the floor. + * getSurfaceY() accounts for this so callers need no special-casing. + * + * The container is sealed — no spilling. Volume is conserved permanently. */ export class Water { /** Column heights (cell centres). */ @@ -22,19 +25,28 @@ export class Water { /** Scratch buffer for continuity update — avoids in-place aliasing errors. */ private readonly hNext: Float32Array; - /** Conserved water volume [px²]. Decreases permanently on rim spill. */ + /** Conserved water volume [px²]. Never changes — sealed container. */ private volume: number; + /** Whether gravity currently points toward the ceiling (phone upside-down). */ + private _isFlipped = false; + private _lastVerticalSign = 1; - private static readonly G_SW = 980; // shallow-water gravity [px/s²] - private static readonly K_DAMP = 1.5; // linear velocity damping coefficient [1/s] - private static readonly CFL = 0.85; // target CFL number (< 1 required) + private readonly gSW: number; + private readonly kDamp: number; + private static readonly CFL = 0.85; constructor( public readonly columns: number = 80, public glassW: number = 300, public glassH: number = 500, fillFrac: number = 0.55, + /** Override wave gravity (default 980). Lower = slower/more viscous feel. */ + gSW: number = 980, + /** Override damping coefficient (default 1.5). Higher = more viscous. */ + kDamp: number = 1.5, ) { + this.gSW = gSW; + this.kDamp = kDamp; const h0 = glassH * fillFrac; this.h = new Float32Array(columns).fill(h0); this.hNext = new Float32Array(columns); @@ -43,30 +55,38 @@ export class Water { } get columnWidth(): number { return this.glassW / this.columns; } - get averageHeight(): number { return this.volume / this.glassW; } + /** True when gravity points toward the ceiling (phone held upside-down). */ + get isFlipped(): boolean { return this._isFlipped; } + + step(dt: number, lateralG: number, verticalG: number): void { + // ── Detect gravity direction flip (phone turned upside-down) ──────── + const vSign = verticalG >= 0 ? 1 : -1; + if (vSign !== this._lastVerticalSign) { + this._lastVerticalSign = vSign; + this._isFlipped = !this._isFlipped; + const h0 = this.volume / this.glassW; + this.h.fill(h0); + this.u.fill(0); + } - step(dt: number, lateralG: number, _verticalG: number): void { const N = this.columns; const dx = this.glassW / N; - const g = Water.G_SW; // ── Adaptive sub-steps: keep CFL = dt_sub/dx * c_max < CFL_target ──── let maxH = 1; for (let i = 0; i < N; i++) if (this.h[i]! > maxH) maxH = this.h[i]!; - const cMax = Math.sqrt(g * maxH); + const cMax = Math.sqrt(this.gSW * maxH); const subSteps = Math.max(1, Math.ceil(dt * cMax / (dx * Water.CFL))); const dtS = dt / subSteps; - const dampFactor = Math.exp(-Water.K_DAMP * dtS); - - let spilled = 0; + const dampFactor = Math.exp(-this.kDamp * dtS); for (let s = 0; s < subSteps; s++) { // ── 1. Momentum: pressure gradient + tilt body-force + damping ──── for (let j = 1; j < N; j++) { this.u[j] = ( this.u[j]! - - g * (dtS / dx) * (this.h[j]! - this.h[j - 1]!) + - this.gSW * (dtS / dx) * (this.h[j]! - this.h[j - 1]!) + lateralG * dtS ) * dampFactor; } @@ -76,32 +96,22 @@ export class Water { // ── 2. Continuity: upwind nonlinear flux into scratch buffer ────── for (let i = 0; i < N; i++) { - // Face j = i+1 (right face of cell i) const uR = this.u[i + 1]!; const hR = uR >= 0 ? this.h[i]! : (i + 1 < N ? this.h[i + 1]! : this.h[i]!); - // Face j = i (left face of cell i) const uL = this.u[i]!; const hL = uL <= 0 ? this.h[i]! : (i > 0 ? this.h[i - 1]! : this.h[i]!); this.hNext[i] = this.h[i]! - (dtS / dx) * (uR * hR - uL * hL); } - // Swap h ← hNext this.h.set(this.hNext); - // ── 3. Clamp: floor and rim ──────────────────────────────────────── + // ── 3. Clamp: gravity wall and opposite wall (sealed) ───────────── for (let i = 0; i < N; i++) { - if (this.h[i]! < 0) { - this.h[i] = 0; - } else if (this.h[i]! > this.glassH) { - spilled += (this.h[i]! - this.glassH) * dx; - this.h[i] = this.glassH; - } + if (this.h[i]! < 0) this.h[i] = 0; + else if (this.h[i]! > this.glassH) this.h[i] = this.glassH; } } - // Permanently reduce stored volume by whatever spilled over the rim - this.volume = Math.max(0, this.volume - spilled); - - // ── 4. Volume correction: recover any transient floor-clamp loss ────── + // ── 4. Volume correction (sealed — no spill, only clamp rounding) ──── let sum = 0; for (let i = 0; i < N; i++) sum += this.h[i]!; const actualVol = sum * dx; @@ -113,9 +123,19 @@ export class Water { } } + /** + * Y coordinate (in glass space: 0 = top, glassH = bottom) of the + * water–air interface at horizontal position x. + * + * When not flipped: surface is above the water (water fills below). + * When flipped: surface is below the water (water fills above). + * Callers can use `water.isFlipped` to know which side the water is on. + */ getSurfaceY(x: number): number { const col = Math.max(0, Math.min(this.columns - 1, Math.floor(x / this.columnWidth))); - return this.glassH - this.h[col]!; + // isFlipped: h[col] measures from ceiling → surface is that far from top + // normal: h[col] measures from floor → surface is (glassH − h) from top + return this._isFlipped ? this.h[col]! : this.glassH - this.h[col]!; } /** diff --git a/src/physics/World.ts b/src/physics/World.ts index 47ac49b..b7b2a35 100644 --- a/src/physics/World.ts +++ b/src/physics/World.ts @@ -3,32 +3,31 @@ import { Water } from './Water.js'; import { RigidBody } from './RigidBody.js'; const WATER_DENSITY = 1.0; -const RESTITUTION = 0.35; // bounciness for wall/floor collisions -const FRICTION = 0.72; // tangential damping on wall bounce +const RESTITUTION = 0.35; +const FRICTION = 0.72; const BODY_RESTITUTION = 0.4; -const MAX_BODIES = 12; const ANGULAR_DAMPING = 0.96; export class World { bodies: RigidBody[] = []; - /** Gravity vector (px/s²). +y is DOWN. Magnitude ~980 (≈ real 9.8 m/s² at 100px/m). */ gravity: Vector2 = new Vector2(0, 980); + readonly maxBodies: number; constructor( public water: Water, - /** Left edge X of glass interior in canvas space */ public glassX: number, - /** Top edge Y of glass interior in canvas space */ public glassY: number, - ) { } + maxBodies = 12, + ) { + this.maxBodies = maxBodies; + } get glassW(): number { return this.water.glassW; } get glassH(): number { return this.water.glassH; } addBody(body: RigidBody): void { this.bodies.push(body); - if (this.bodies.length > MAX_BODIES) { - // Evict the oldest non-permanent body + if (this.bodies.length > this.maxBodies) { const idx = this.bodies.findIndex(b => !b.permanent); if (idx !== -1) this.bodies.splice(idx, 1); } @@ -37,14 +36,15 @@ export class World { step(dt: number): void { const g = this.gravity; - // Advance water simulation - this.water.step(dt, g.x, Math.abs(g.y)); + // Advance water simulation (signed verticalG so Water can detect flips) + this.water.step(dt, g.x, g.y); for (const body of this.bodies) { // --- Buoyancy --- const submergedFrac = this.submergedFraction(body); const bodyArea = Math.PI * body.radius * body.radius; - const buoyancyMag = WATER_DENSITY * Math.abs(g.y) * bodyArea * submergedFrac; + // Buoyancy magnitude uses |g| so it works at any orientation + const buoyancyMag = WATER_DENSITY * g.magnitude() * bodyArea * submergedFrac; // Net force = gravity - buoyancy (both along Y axis primarily) const gravityForce = g.scale(body.mass); @@ -75,38 +75,35 @@ export class World { this.constrainToGlass(body); } - // --- Body-body collisions --- + // Body-body collisions for (let i = 0; i < this.bodies.length; i++) { for (let j = i + 1; j < this.bodies.length; j++) { this.resolveBodyCollision(this.bodies[i]!, this.bodies[j]!); } } - - // Remove non-permanent bodies that have fully escaped above the glass rim - this.bodies = this.bodies.filter(b => - b.permanent || (b.position.y + b.radius) > this.glassY - ); + // Sealed container — no escape culling. } private submergedFraction(body: RigidBody): number { - // Body position is in canvas space; glass interior origin is (glassX, glassY) const localX = body.position.x - this.glassX; const localY = body.position.y - this.glassY; const r = body.radius; - const centerDepthFromFloor = this.glassH - localY; // how far above floor center is const surfaceY = this.water.getSurfaceY(Math.max(0, Math.min(this.glassW, localX))); - // surfaceY is distance from top of glass → convert to distance from floor - const surfaceHeightFromFloor = this.glassH - surfaceY; - // Distance from center to surface (positive = below surface) - const d = surfaceHeightFromFloor - centerDepthFromFloor; + // d > 0 → body center is on the water side of the surface → submerged + // d < 0 → body center is on the air side + // + // Normal: water is BELOW surfaceY → submerged when localY > surfaceY + // Flipped: water is ABOVE surfaceY → submerged when localY < surfaceY + const d = this.water.isFlipped + ? surfaceY - localY // positive when center is above surface (in water) + : localY - surfaceY; // positive when center is below surface (in water) - if (d >= r) return 1.0; // fully submerged - if (d <= -r) return 0.0; // fully above water + if (d >= r) return 1.0; + if (d <= -r) return 0.0; - // Partial submersion: circular segment area / total area - const h = d + r; // submerged depth (0 … 2r) + const h = d + r; const segmentArea = r * r * Math.acos((r - h) / r) - (r - h) * Math.sqrt(2 * r * h - h * h); return segmentArea / (Math.PI * r * r); } @@ -116,22 +113,25 @@ export class World { let lx = body.position.x - this.glassX; let ly = body.position.y - this.glassY; - // Only enforce walls below the rim (ly >= 0); above the rim bodies can fly free - if (ly + r > 0) { - // Left wall - if (lx - r < 0) { - lx = r; - body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION; - body.velocity.y *= FRICTION; - body.angularVelocity += body.velocity.y * 0.01; - } - // Right wall - if (lx + r > this.glassW) { - lx = this.glassW - r; - body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION; - body.velocity.y *= FRICTION; - body.angularVelocity -= body.velocity.y * 0.01; - } + // Left wall + if (lx - r < 0) { + lx = r; + body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION; + body.velocity.y *= FRICTION; + body.angularVelocity += body.velocity.y * 0.01; + } + // Right wall + if (lx + r > this.glassW) { + lx = this.glassW - r; + body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION; + body.velocity.y *= FRICTION; + body.angularVelocity -= body.velocity.y * 0.01; + } + // Ceiling (sealed top) + if (ly - r < 0) { + ly = r; + body.velocity.y = Math.abs(body.velocity.y) * RESTITUTION; + body.velocity.x *= FRICTION; } // Floor if (ly + r > this.glassH) { @@ -149,14 +149,13 @@ export class World { const localY = body.position.y - this.glassY; const surfaceY = this.water.getSurfaceY(Math.max(0, Math.min(this.glassW, localX))); - // Only disturb when the body center is within one radius of the surface const distToSurface = Math.abs(localY - surfaceY); if (distToSurface > body.radius * 1.5) return; - // Scale impulse by how fast the body is crossing the surface (Y component) - // and how much of it is in the surface zone - const velY = body.velocity.y; - if (Math.abs(velY) < 10) return; // ignore very slow drift + // Positive velY means moving toward gravity wall; negate when flipped so + // the disturbance always represents an impact on the water surface. + const velY = this.water.isFlipped ? -body.velocity.y : body.velocity.y; + if (Math.abs(velY) < 10) return; this.water.disturb(localX, body.radius, velY); } diff --git a/src/rendering/GlassRenderer.ts b/src/rendering/GlassRenderer.ts index ff6c945..e3a1da2 100644 --- a/src/rendering/GlassRenderer.ts +++ b/src/rendering/GlassRenderer.ts @@ -31,10 +31,10 @@ export class GlassRenderer { this.ctx.restore(); } - /** Draw the glass cup outline over everything. */ + /** Draw the sealed glass container outline over everything. */ draw(): void { const ctx = this.ctx; - const { x, y, w, h, wallThick: wt, rimOverhang: ro } = this; + const { x, y, w, h, wallThick: wt } = this; ctx.save(); @@ -42,29 +42,24 @@ export class GlassRenderer { ctx.fillStyle = 'rgba(186, 230, 253, 0.07)'; ctx.fillRect(x, y, w, h); - // Left wall + // Walls and top/bottom (sealed rectangle) ctx.fillStyle = 'rgba(186, 230, 253, 0.55)'; - ctx.fillRect(x - wt, y - ro, wt, h + ro); - + // Left wall (full height including top+bottom overlap) + ctx.fillRect(x - wt, y - wt, wt, h + wt * 2); // Right wall - ctx.fillRect(x + w, y - ro, wt, h + ro); - + ctx.fillRect(x + w, y - wt, wt, h + wt * 2); + // Top (sealed) + ctx.fillRect(x - wt, y - wt, w + wt * 2, wt); // Bottom ctx.fillRect(x - wt, y + h, w + wt * 2, wt); - // Inner highlight on left wall edge + // Inner highlight — left edge ctx.fillStyle = 'rgba(255, 255, 255, 0.18)'; - ctx.fillRect(x - wt, y - ro, 3, h + ro); - - // Inner highlight on right wall edge - ctx.fillRect(x + w + wt - 3, y - ro, 3, h + ro); - - // Rim top glow - const rimGrad = ctx.createLinearGradient(x - wt, y - ro, x - wt, y); - rimGrad.addColorStop(0, 'rgba(255,255,255,0.32)'); - rimGrad.addColorStop(1, 'rgba(255,255,255,0.0)'); - ctx.fillStyle = rimGrad; - ctx.fillRect(x - wt, y - ro, w + wt * 2, ro + 4); + ctx.fillRect(x - wt, y - wt, 3, h + wt * 2); + // Inner highlight — right edge + ctx.fillRect(x + w + wt - 3, y - wt, 3, h + wt * 2); + // Inner highlight — top edge + ctx.fillRect(x - wt, y - wt, w + wt * 2, 3); ctx.restore(); } diff --git a/src/rendering/WaterRenderer.ts b/src/rendering/WaterRenderer.ts index e6845f1..4d6dacc 100644 --- a/src/rendering/WaterRenderer.ts +++ b/src/rendering/WaterRenderer.ts @@ -21,47 +21,30 @@ export class WaterRenderer { draw(): void { const ctx = this.ctx; const w = this.water; - const n = w.columns; - const colW = w.columnWidth; const gx = this.glassX; const gy = this.glassY; const gh = w.glassH; + const flipped = w.isFlipped; - // Build wave polygon path (top surface + four corners closing the polygon) - ctx.beginPath(); - // Start at bottom-left - ctx.moveTo(gx, gy + gh); - - 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(gx, gy + w.getSurfaceY(0)); - ctx.lineTo(x, sy); - } else { - // Smooth bezier between columns - 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); - } - } + // far edge = the solid wall on the opposite side from the wave surface + const far = flipped ? gy : gy + gh; - // Close to bottom-right then bottom-left - ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1)); - ctx.lineTo(gx + w.glassW, gy + gh); - ctx.closePath(); + // ── Main fill ──────────────────────────────────────────────────────── + ctx.beginPath(); + this.buildWaterPolygon(ctx, far); - // --- Main fill gradient --- - const grad = ctx.createLinearGradient(gx, gy, gx, gy + gh); - grad.addColorStop(0.0, 'rgba(56, 189, 248, 0.72)'); // surface — a light sky blue - grad.addColorStop(0.5, 'rgba(14, 116, 144, 0.85)'); // mid - grad.addColorStop(1.0, 'rgba(8, 51, 68, 0.95)'); // deep + // 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.fillStyle = grad; ctx.fill(); - // --- Shimmer band (animated diagonal highlight) --- + // ── 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); shimGrad.addColorStop(0, 'rgba(255,255,255,0.0)'); @@ -70,13 +53,34 @@ export class WaterRenderer { ctx.save(); ctx.beginPath(); - // Re-clip to the same wave shape so shimmer stays inside water - ctx.moveTo(gx, gy + gh); + this.buildWaterPolygon(ctx, far); + ctx.clip(); + ctx.fillStyle = shimGrad; + ctx.fillRect(gx, shimmerY - 30, w.glassW, 60); + ctx.restore(); + + // ── Surface highlight line ──────────────────────────────────────────── + ctx.beginPath(); + this.buildSurfaceLine(ctx); + 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(gx, gy + w.getSurfaceY(0)); ctx.lineTo(x, sy); } else { const prevX = gx + (i - 1) * colW + colW / 2; @@ -86,21 +90,23 @@ export class WaterRenderer { } } ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1)); - ctx.lineTo(gx + w.glassW, gy + gh); + ctx.lineTo(gx + w.glassW, far); ctx.closePath(); - ctx.clip(); + } - ctx.fillStyle = shimGrad; - ctx.fillRect(gx, shimmerY - 30, w.glassW, 60); - ctx.restore(); + /** 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; - // --- Surface highlight line --- - ctx.beginPath(); + 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.moveTo(gx, gy + w.getSurfaceY(0)); ctx.lineTo(x, sy); } else { const prevX = gx + (i - 1) * colW + colW / 2; @@ -110,7 +116,35 @@ export class WaterRenderer { } } ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1)); - ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)'; + } + + /** + * 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(); }