bubbles / commit
Initial commit
.github/copilot-instructions.md | 50 ++ .gitignore | 6 + .gitlab-ci.yml | 19 + README.md | 91 +++ index.html | 101 ++++ package.json | 20 + pnpm-lock.yaml | 1270 +++++++++++++++++++++++++++++++++++++++ scripts/generate-assets.ts | 96 +++ src/App.ts | 78 +++ src/icons/registry.ts | 20 + src/input/TiltInput.ts | 93 +++ src/input/TouchInput.ts | 62 ++ src/main.ts | 7 + src/physics/RigidBody.ts | 38 ++ src/physics/Vector2.ts | 53 ++ src/physics/Water.ts | 137 +++++ src/physics/World.ts | 170 ++++++ src/rendering/GlassRenderer.ts | 71 +++ src/rendering/IconRenderer.ts | 54 ++ src/rendering/Renderer.ts | 108 ++++ src/rendering/WaterRenderer.ts | 122 ++++ tsconfig.json | 22 + vite.config.ts | 10 + wind.png | Bin 0 -> 11612 bytes 24 files changed, 2698 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..2baa8d7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +--- +applyTo: "**" +--- + +# Bubbles — Copilot Instructions + +## Project Overview +Silly web app: phone becomes a water glass. Canvas 2D physics, no framework, no server. +Hosted on GitLab Pages. TypeScript + Vite + pnpm. + +## Hard Rules +- **No UI framework** (no React, Vue, Svelte, etc.) — vanilla TypeScript only. +- **Canvas 2D only** — no WebGL, no Three.js, no PixiJS. +- **pnpm only** — never suggest npm or yarn commands. +- **No server** — everything must work as a static site. +- **`strict: true`** TypeScript — no `any` without explicit justification. + +## Architecture +``` +src/physics/ — pure simulation (no DOM, no canvas) +src/rendering/ — Canvas 2D drawing only (reads from physics, never writes) +src/input/ — DOM event adapters that produce values (no side effects on world directly) +src/icons/ — static data (icon registry) +src/App.ts — game loop: wires physics ← input, renderer ← physics +src/main.ts — DOM entry point only +scripts/ — Node.js tooling (sharp asset pipeline) +``` + +## Physics Conventions +- Coordinates: canvas space, origin top-left, +Y downward. +- Units: pixels for distance, seconds for time, px/s for velocity, px/s² for acceleration. +- `g ≈ 980 px/s²` (treats 100 px ≈ 1 metre). +- `Water` is a 1-D column simulation — never replace with SPH or particles. +- max 12 `RigidBody` objects; `World.addBody()` enforces this by dropping oldest. +- Fixed physics timestep: `1/60` s (set in `App.ts`). + +## Rendering Conventions +- `Renderer` always draws in this order: clear → glass clip → water → icons → glass outline. +- Shimmer and refraction are animated via `WaterRenderer.update(dt)` — call every frame. +- Emoji rendered with `ctx.fillText` — never rasterise to offscreen canvas unless profiling shows it's needed. + +## Input Conventions +- `TiltInput`: pure producer of a `gravity: Vector2` — no direct world mutation. +- `TouchInput`: spawns `RigidBody` via `World.addBody()` — the only input that mutates world state. +- iOS orientation permission must be requested inside a user-gesture handler. + +## File Conventions +- One class per file, filename matches class name. +- Physics files import from `./Vector2.js` etc. (`.js` extension required for ESM bundler resolution). +- No barrel (`index.ts`) files — import directly. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59ede54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.DS_Store +*.local +static/favicon.ico +static/og-image.png diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..45afd92 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,19 @@ +image: node:22-alpine + +stages: + - deploy + +pages: + stage: deploy + before_script: + - npm install -g pnpm + - pnpm install --frozen-lockfile + - pnpm gen-assets + script: + - pnpm build + - mv dist public + artifacts: + paths: + - public + rules: + - if: $CI_COMMIT_BRANCH == "main" diff --git a/README.md b/README.md new file mode 100644 index 0000000..49036c1 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Bubbles + +Your phone is a glass of water. + +Tilt to slosh. Tap to drop things in. Some float. Some sink. + +**Live demo:** https://pavelsafronov.gitlab.io/bubbles + +**Repository:** https://gitlab.com/PavelSafronov/bubbles + +--- + +## What it does + +| Action | Result | +| --------------------- | -------------------------------------------- | +| Tilt phone left/right | Water sloshes toward the low side | +| Tilt past ~45° | Water spills over the rim (permanently lost) | +| Tap anywhere | A random object drops into the water | +| Desktop | Move mouse left/right to simulate tilt | + +### Objects + +| Icon | Density | Behaviour | +| ----------- | ------- | -------------------------- | +| 🏓 Ping pong | 0.10 | Bobs wildly on the surface | +| 🦆 Duck | 0.50 | Floats high | +| 🍎 Apple | 0.80 | Floats | +| 🍋 Lemon | 0.85 | Floats | +| 🧊 Ice | 0.92 | Barely floats | +| 🐟 Fish | 1.05 | Barely sinks | +| 🪨 Rock | 2.60 | Sinks | +| 💎 Gem | 3.50 | Sinks | +| ⚓ Anchor | 7.50 | Sinks fast | +| 🔩 Bolt | 7.80 | Sinks fast | + +Max 12 objects at a time — the oldest is removed when you add a 13th. + +--- + +## Tech + +- TypeScript + Canvas 2D API (no framework, no WebGL) +- Vite for bundling +- pnpm for package management +- [GitLab Pages](https://docs.gitlab.com/ee/user/project/pages/) for hosting (static, no server) + +Physics: +- 1-D column wave simulation (80 columns, spring-coupled) +- Buoyancy via submerged fraction of circle cross-section +- Elastic damped circle vs. circle + wall collisions +- `DeviceOrientationEvent` for real tilt; mouse fallback on desktop + +--- + +## Development + +```sh +# Install dependencies +pnpm install + +# Generate favicons and og-image from wind.png +pnpm gen-assets + +# Start dev server +pnpm dev + +# Type-check + production build +pnpm build +``` + +Output goes to `dist/`. GitLab CI moves it to `public/` for Pages. + +--- + +## Deploy + +Push to `main` on GitLab. The CI pipeline (`.gitlab-ci.yml`) runs automatically: + +``` +pnpm install → pnpm gen-assets → pnpm build → mv dist public +``` + +The `public/` artifact is served by GitLab Pages at +https://pavelsafronov.gitlab.io/bubbles + +--- + +## Attribution + +Wind icon by [Icongeek26](https://www.flaticon.com/authors/icongeek26) from [Flaticon](https://www.flaticon.com/). diff --git a/index.html b/index.html new file mode 100644 index 0000000..5d05ecc --- /dev/null +++ b/index.html @@ -0,0 +1,101 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> + <meta name="apple-mobile-web-app-title" content="Bubbles" /> + <meta name="theme-color" content="#0ea5e9" /> + <meta name="description" content="Your phone is a glass of water. Tilt to slosh. Tap to drop things in." /> + <meta property="og:title" content="Bubbles" /> + <meta property="og:description" content="Your phone is a glass of water. Tilt to slosh. Tap to drop things in." /> + <meta property="og:image" content="og-image.png" /> + <meta property="og:url" content="https://pavelsafronov.gitlab.io/bubbles" /> + <meta property="og:type" content="website" /> + <title>Bubbles</title> + <link rel="icon" type="image/x-icon" href="favicon.ico" /> + <style> + *, + *::before, + *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html, + body { + width: 100%; + height: 100%; + overflow: hidden; + background: #0f172a; + font-family: system-ui, sans-serif; + color: #e2e8f0; + touch-action: none; + } + + #app { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + position: relative; + } + + canvas { + display: block; + touch-action: none; + } + + #hint { + position: absolute; + bottom: 48px; + left: 50%; + transform: translateX(-50%); + font-size: 13px; + color: rgba(226, 232, 240, 0.45); + white-space: nowrap; + pointer-events: none; + user-select: none; + } + + footer { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + color: rgba(226, 232, 240, 0.3); + white-space: nowrap; + text-align: center; + } + + footer a { + color: rgba(226, 232, 240, 0.45); + text-decoration: underline; + text-underline-offset: 2px; + } + </style> +</head> + +<body> + <div id="app"> + <canvas id="canvas"></canvas> + <p id="hint">tap to drop · tilt to slosh</p> + <footer> + <a href="https://gitlab.com/PavelSafronov/bubbles" target="_blank" rel="noopener">gitlab</a> + · + wind icon by <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" + rel="noopener">Icongeek26</a> + from <a href="https://www.flaticon.com/" target="_blank" rel="noopener">Flaticon</a> + </footer> + </div> + <script type="module" src="/src/main.ts"></script> +</body> + +</html> \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..c53eae4 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "bubbles", + "version": "0.1.0", + "private": true, + "description": "Silly water glass web app — tilt your phone to slosh water, tap to drop things in", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "gen-assets": "tsx scripts/generate-assets.ts" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^22.0.0", + "sharp": "^0.33.5", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vite": "^6.2.5" + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..94a0232 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1270 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + sharp: + specifier: ^0.33.5 + version: 0.33.5 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.2.5 + version: 6.4.2(@types/node@22.19.17)(tsx@4.21.0) + +packages: + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + engines: {node: ^10 || ^12 || >=14} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.9.2 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@types/estree@1.0.8': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + detect-libc@2.1.2: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + is-arrayish@0.3.4: {} + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.9: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + resolve-pkg-maps@1.0.0: {} + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + semver@7.7.4: {} + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.9 + rollup: 4.60.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + tsx: 4.21.0 diff --git a/scripts/generate-assets.ts b/scripts/generate-assets.ts new file mode 100644 index 0000000..9f44e07 --- /dev/null +++ b/scripts/generate-assets.ts @@ -0,0 +1,96 @@ +/** + * Asset generation script. + * Run with: pnpm gen-assets + * + * Reads wind.png from the repo root, applies a blue tint, and writes: + * static/favicon.ico (32×32 ICO via PNG fallback — modern browsers accept PNG as .ico) + * static/og-image.png (1200×630 social card with logo centred on a dark background) + */ + +import sharp from 'sharp'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const src = path.join(root, 'wind.png'); +const staticDir = path.join(root, 'static'); + +if (!fs.existsSync(staticDir)) fs.mkdirSync(staticDir, { recursive: true }); + +// ── Shared: tinted white→blue version of the logo ───────────────────────── + +async function makeTintedLogo(size: number): Promise<Buffer> { + // 1. Resize to target size + // 2. Ensure RGBA + // 3. Tint: the source is black-on-white; we invert so strokes are white, + // then composite a blue colour behind it. + const logoAlpha = await sharp(src) + .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .negate({ alpha: false }) // invert: black strokes → white strokes + .toColorspace('srgb') + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + // Build a solid blue background of the same size + const blue = await sharp({ + create: { width: size, height: size, channels: 4, background: { r: 14, g: 165, b: 233, alpha: 255 } }, + }) + .png() + .toBuffer(); + + // Composite white logo on blue background + return sharp(blue) + .composite([{ input: logoAlpha.data, raw: { width: size, height: size, channels: 4 }, blend: 'over' }]) + .png() + .toBuffer(); +} + +// ── favicon.ico (PNG saved as .ico — accepted by all modern browsers) ────── + +async function genFavicon(): Promise<void> { + const buf = await makeTintedLogo(32); + const out = path.join(staticDir, 'favicon.ico'); + fs.writeFileSync(out, buf); + console.log(`✓ favicon.ico (32×32)`); +} + +// ── og-image.png (1200×630) ─────────────────────────────────────────────── + +async function genOgImage(): Promise<void> { + const W = 1200, H = 630; + const logoSize = 220; + + const logo = await makeTintedLogo(logoSize); + + // Dark-blue background + const bg = await sharp({ + create: { width: W, height: H, channels: 3, background: { r: 15, g: 23, b: 42 } }, + }) + .png() + .toBuffer(); + + const out = await sharp(bg) + .composite([{ + input: logo, + left: Math.round((W - logoSize) / 2), + top: Math.round((H - logoSize) / 2) - 40, + }]) + .png() + .toBuffer(); + + fs.writeFileSync(path.join(staticDir, 'og-image.png'), out); + console.log(`✓ og-image.png (${W}×${H})`); +} + +// ── run ──────────────────────────────────────────────────────────────────── + +async function main(): Promise<void> { + await genFavicon(); + await genOgImage(); + console.log('Asset generation complete.'); +} + +main().catch((err: unknown) => { console.error(err); process.exit(1); }); diff --git a/src/App.ts b/src/App.ts new file mode 100644 index 0000000..3b9ca34 --- /dev/null +++ b/src/App.ts @@ -0,0 +1,78 @@ +import { World } from './physics/World.js'; +import { Water } from './physics/Water.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 rafId = 0; + private lastTime = 0; + private accumulator = 0; + private permissionRequested = false; + + 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; + + const water = new Water(80, glassW, glassH, 0.55); + this.world = new World(water, glassX, glassY); + + // Renderer sets up canvas sizing and overwrites world.glassX/Y on first resize + this.renderer = new Renderer(canvas, this.world); + + this.tilt = new TiltInput(canvas); + this.touch = new TouchInput(canvas, this.world, () => this.requestPermissionOnce()); + } + + start(): void { + this.lastTime = performance.now(); + this.rafId = requestAnimationFrame(this.loop); + } + + stop(): void { + cancelAnimationFrame(this.rafId); + this.renderer.destroy(); + this.tilt.destroy(); + this.touch.destroy(); + } + + private readonly loop = (now: number): void => { + const rawDt = Math.min((now - this.lastTime) / 1000, 0.1); // cap at 100ms + this.lastTime = now; + this.accumulator += rawDt; + + // Update world gravity from tilt + this.world.gravity = this.tilt.gravity; + + // Fixed-step physics + while (this.accumulator >= FIXED_STEP) { + this.world.step(FIXED_STEP); + this.accumulator -= FIXED_STEP; + } + + // Render + this.renderer.update(rawDt); + this.renderer.draw(); + + this.rafId = requestAnimationFrame(this.loop); + }; + + private async requestPermissionOnce(): Promise<void> { + if (this.permissionRequested) return; + this.permissionRequested = true; + await this.tilt.requestPermission(); + } +} diff --git a/src/icons/registry.ts b/src/icons/registry.ts new file mode 100644 index 0000000..a953619 --- /dev/null +++ b/src/icons/registry.ts @@ -0,0 +1,20 @@ +export interface IconDef { + label: string; + /** Density relative to water (1.0). < 1 floats, > 1 sinks. */ + density: number; + /** Collision radius in pixels */ + radius: number; +} + +export const ICONS: IconDef[] = [ + { label: '🏓', density: 0.10, radius: 22 }, // ping pong — bobs wildly + { label: '🦆', density: 0.50, radius: 28 }, // duck — floats high + { label: '🍎', density: 0.80, radius: 26 }, // apple — floats + { label: '🍋', density: 0.85, radius: 24 }, // lemon — floats + { label: '🧊', density: 0.92, radius: 26 }, // ice — barely floats + { label: '🐟', density: 1.05, radius: 26 }, // fish — barely sinks + { label: '🪨', density: 2.60, radius: 24 }, // rock — sinks + { label: '💎', density: 3.50, radius: 22 }, // gem — sinks + { label: '⚓', density: 7.50, radius: 26 }, // anchor — sinks fast + { label: '🔩', density: 7.80, radius: 20 }, // bolt — sinks fast +]; diff --git a/src/input/TiltInput.ts b/src/input/TiltInput.ts new file mode 100644 index 0000000..6d50c26 --- /dev/null +++ b/src/input/TiltInput.ts @@ -0,0 +1,93 @@ +import { Vector2 } from '../physics/Vector2.js'; + +type OrientationPermission = 'granted' | 'denied' | 'pending'; + +/** + * Reads device tilt and exposes it as a normalised gravity Vector2 + * (px/s² scale, ~980 = 1 g). Falls back to mouse position on desktop. + */ +export class TiltInput { + private _gravity: Vector2 = new Vector2(0, 980); + private _permission: OrientationPermission = 'pending'; + private _usingMouse = false; + + constructor(private readonly canvas: HTMLCanvasElement) { + this.tryAttachOrientation(); + this.attachMouseFallback(); + } + + get gravity(): Vector2 { + return this._gravity.clone(); + } + + /** + * Call this from a user-gesture handler (e.g. first touch) to trigger + * the iOS DeviceOrientationEvent permission dialog. + */ + async requestPermission(): Promise<void> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const DevOrEvent = (DeviceOrientationEvent as any); + if (typeof DevOrEvent.requestPermission === 'function') { + try { + const result: string = await DevOrEvent.requestPermission(); + if (result === 'granted') { + this._permission = 'granted'; + this._usingMouse = false; + window.addEventListener('deviceorientation', this.handleOrientation); + } else { + this._permission = 'denied'; + } + } catch { + this._permission = 'denied'; + } + } + } + + private tryAttachOrientation(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const DevOrEvent = (DeviceOrientationEvent as any); + if (typeof DevOrEvent.requestPermission === 'function') { + // iOS 13+ — need explicit permission, will be requested on first touch + this._usingMouse = true; + } else if ('DeviceOrientationEvent' in window) { + window.addEventListener('deviceorientation', this.handleOrientation); + this._permission = 'granted'; + } else { + this._usingMouse = true; + } + } + + private readonly handleOrientation = (e: DeviceOrientationEvent): void => { + if (e.gamma === null || e.beta === null) return; + // gamma: left/right tilt (-90…+90), beta: front/back (-180…+180) + // We use gamma as lateral tilt (maps to x gravity) and clamp to ±45° + const gammaRad = (Math.min(45, Math.max(-45, e.gamma)) * Math.PI) / 180; + const betaRad = (Math.min(90, Math.max(-90, e.beta)) * Math.PI) / 180; + + // Project onto x/y gravity components + this._gravity = new Vector2( + Math.sin(gammaRad) * 980, + Math.cos(betaRad) * 980, + ); + }; + + private attachMouseFallback(): void { + window.addEventListener('mousemove', (e: MouseEvent) => { + if (!this._usingMouse) return; + const rect = this.canvas.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dx = (e.clientX - cx) / (rect.width / 2); // -1…+1 + const dy = (e.clientY - cy) / (rect.height / 2); // -1…+1 + const tiltX = Math.max(-1, Math.min(1, dx)) * 45 * (Math.PI / 180); + this._gravity = new Vector2( + Math.sin(tiltX) * 980, + Math.cos(tiltX) * 980 * (1 - Math.abs(dy) * 0.2), + ); + }); + } + + destroy(): void { + window.removeEventListener('deviceorientation', this.handleOrientation); + } +} diff --git a/src/input/TouchInput.ts b/src/input/TouchInput.ts new file mode 100644 index 0000000..46c9288 --- /dev/null +++ b/src/input/TouchInput.ts @@ -0,0 +1,62 @@ +import { RigidBody } from '../physics/RigidBody.js'; +import { Vector2 } from '../physics/Vector2.js'; +import { World } from '../physics/World.js'; +import { ICONS } from '../icons/registry.js'; + +/** + * Listens for pointer-down events on the canvas and spawns a random icon + * body near the water surface at the tap position. + */ +export class TouchInput { + constructor( + private readonly canvas: HTMLCanvasElement, + private readonly world: World, + private readonly onRequestPermission: () => Promise<void>, + ) { + this.canvas.addEventListener('pointerdown', this.handlePointer); + } + + private readonly handlePointer = async (e: PointerEvent): Promise<void> => { + e.preventDefault(); + // Request iOS orientation permission on very first tap + await this.onRequestPermission(); + this.spawnAt(e.clientX, e.clientY); + }; + + private spawnAt(clientX: number, clientY: number): void { + const rect = this.canvas.getBoundingClientRect(); + const canvasX = (clientX - rect.left) * (this.canvas.width / rect.width); + const canvasY = (clientY - rect.top) * (this.canvas.height / rect.height); + + // Clamp to glass interior + const gx = this.world.glassX; + const gy = this.world.glassY; + const gw = this.world.glassW; + + const icon = ICONS[Math.floor(Math.random() * ICONS.length)]!; + const r = icon.radius; + + const spawnX = Math.max(gx + r, Math.min(gx + gw - r, canvasX)); + // Spawn near top of glass (just below the surface or at touch Y if that's lower) + const topOfGlass = gy + r; + const spawnY = Math.min(canvasY, topOfGlass + 20); + + const body = new RigidBody({ + position: new Vector2(spawnX, spawnY), + radius: r, + density: icon.density, + label: icon.label, + }); + + // Give a small random horizontal nudge for fun + body.velocity.x = (Math.random() - 0.5) * 120; + body.velocity.y = 60; + body.angularVelocity = (Math.random() - 0.5) * 4; + + this.world.addBody(body); + } + + destroy(): void { + this.canvas.removeEventListener('pointerdown', this.handlePointer); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..2f834e3 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,7 @@ +import { App } from './App.js'; + +const canvas = document.getElementById('canvas') as HTMLCanvasElement | null; +if (!canvas) throw new Error('No canvas element found'); + +const app = new App(canvas); +app.start(); diff --git a/src/physics/RigidBody.ts b/src/physics/RigidBody.ts new file mode 100644 index 0000000..bd12b4b --- /dev/null +++ b/src/physics/RigidBody.ts @@ -0,0 +1,38 @@ +import { Vector2 } from './Vector2.js'; + +export interface RigidBodyOptions { + position: Vector2; + radius: number; + /** g/cm³ equivalent — water = 1.0; < 1 floats, > 1 sinks */ + density: number; + /** Display label — emoji or short string */ + label: string; +} + +export class RigidBody { + position: Vector2; + velocity: Vector2; + readonly radius: number; + readonly density: number; + readonly label: string; + /** Rotation angle in radians, for visual wobble */ + angle: number = 0; + angularVelocity: number = 0; + /** Unique id for removal tracking */ + readonly id: number; + + private static _nextId = 0; + + constructor(opts: RigidBodyOptions) { + this.position = opts.position.clone(); + this.velocity = Vector2.zero(); + this.radius = opts.radius; + this.density = opts.density; + this.label = opts.label; + this.id = RigidBody._nextId++; + } + + get mass(): number { + return this.density * Math.PI * this.radius * this.radius; + } +} diff --git a/src/physics/Vector2.ts b/src/physics/Vector2.ts new file mode 100644 index 0000000..c8b913b --- /dev/null +++ b/src/physics/Vector2.ts @@ -0,0 +1,53 @@ +export class Vector2 { + constructor(public x: number, public y: number) { } + + static zero(): Vector2 { + return new Vector2(0, 0); + } + + static from(x: number, y: number): Vector2 { + return new Vector2(x, y); + } + + add(v: Vector2): Vector2 { + return new Vector2(this.x + v.x, this.y + v.y); + } + + sub(v: Vector2): Vector2 { + return new Vector2(this.x - v.x, this.y - v.y); + } + + scale(s: number): Vector2 { + return new Vector2(this.x * s, this.y * s); + } + + dot(v: Vector2): number { + return this.x * v.x + this.y * v.y; + } + + magnitude(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + normalize(): Vector2 { + const m = this.magnitude(); + if (m === 0) return Vector2.zero(); + return this.scale(1 / m); + } + + clone(): Vector2 { + return new Vector2(this.x, this.y); + } + + addMut(v: Vector2): this { + this.x += v.x; + this.y += v.y; + return this; + } + + scaleMut(s: number): this { + this.x *= s; + this.y *= s; + return this; + } +} diff --git a/src/physics/Water.ts b/src/physics/Water.ts new file mode 100644 index 0000000..e78bc26 --- /dev/null +++ b/src/physics/Water.ts @@ -0,0 +1,137 @@ +/** + * 1-D column-based shallow-water simulation. + * + * The glass interior is divided into N vertical columns. Each column tracks + * its current height (above the glass floor) and a vertical velocity. + * Adjacent columns are spring-coupled, producing wave propagation. + * A lateral gravity component shifts water mass toward the low side over time. + * If any column height exceeds the glass rim, that water is permanently lost + * (spills). + */ +export class Water { + /** Column height array (metres — maps 1:1 to canvas pixels via scale) */ + readonly heights: Float32Array; + /** Column velocity array */ + private readonly velocities: Float32Array; + /** Total water volume (sum of all column heights * column width) — conserved unless spilling */ + private volume: number; + + /** + * @param columns Number of simulation columns (default 80) + * @param glassW Glass interior width in px + * @param glassH Glass interior height in px + * @param fillFrac Initial fill fraction (0–1), default 0.55 + */ + constructor( + public columns: number = 80, + public glassW: number = 300, + public glassH: number = 500, + fillFrac: number = 0.55, + ) { + this.heights = new Float32Array(columns).fill(glassH * fillFrac); + this.velocities = new Float32Array(columns).fill(0); + this.volume = this.computeVolume(); + } + + get columnWidth(): number { + return this.glassW / this.columns; + } + + /** + * Advance the simulation by dt seconds. + * @param dt Time delta in seconds (typically ~0.016) + * @param lateralG Lateral gravity component (px/s²) — positive = tilt right + * @param verticalG Vertical gravity magnitude (px/s²) — positive downward + */ + step(dt: number, lateralG: number, verticalG: number): void { + const n = this.columns; + const k = 280; // spring stiffness between columns + const damping = 0.985; // velocity damping per step + const lateralK = 0.08; // how strongly lateral gravity pushes water + + // Compute target water level shift due to lateral gravity + // A positive lateralG means gravity pulls right → right columns get more water + const shift = lateralG * lateralK * dt; + + // Apply spring forces + damping + for (let i = 0; i < n; i++) { + const left = i > 0 ? this.heights[i - 1]! : this.heights[i]!; + const right = i < n - 1 ? this.heights[i + 1]! : this.heights[i]!; + const cur = this.heights[i]!; + this.velocities[i] = (this.velocities[i]! + k * (left + right - 2 * cur) * dt) * damping; + } + + // Apply lateral gravity as a linear tilt on velocities + for (let i = 0; i < n; i++) { + // Columns on the right get a nudge upward, left get downward — net mass shift + const normalised = (i / (n - 1)) - 0.5; // -0.5 … +0.5 + this.velocities[i] = (this.velocities[i]! + normalised * shift * verticalG); + } + + // Integrate heights + for (let i = 0; i < n; i++) { + this.heights[i] = Math.max(0, this.heights[i]! + this.velocities[i]! * dt); + } + + // Spill: clamp columns that exceed rim, lose the volume + let spilled = false; + for (let i = 0; i < n; i++) { + if (this.heights[i]! > this.glassH) { + this.heights[i] = this.glassH; + this.velocities[i] = 0; + spilled = true; + } + } + + if (spilled) { + this.volume = this.computeVolume(); + } else { + // Re-distribute to conserve volume + this.conserveVolume(); + } + } + + /** + * Returns the surface Y coordinate (from TOP of glass, i.e. small = high up) + * at a given X pixel position relative to the glass interior left edge. + */ + getSurfaceY(x: number): number { + const col = Math.min(this.columns - 1, Math.max(0, Math.floor(x / this.columnWidth))); + return this.glassH - this.heights[col]!; + } + + /** + * Returns average surface height (px from floor) — useful for spawning objects. + */ + get averageHeight(): number { + let sum = 0; + for (let i = 0; i < this.columns; i++) sum += this.heights[i]!; + return sum / this.columns; + } + + /** Resize glass dimensions (e.g. on canvas resize), scaling heights proportionally. */ + resize(newGlassW: number, newGlassH: number): void { + const scaleH = newGlassH / this.glassH; + for (let i = 0; i < this.columns; i++) { + this.heights[i] = this.heights[i]! * scaleH; + } + this.glassW = newGlassW; + this.glassH = newGlassH; + this.volume = this.computeVolume(); + } + + private computeVolume(): number { + let v = 0; + for (let i = 0; i < this.columns; i++) v += this.heights[i]!; + return v; + } + + private conserveVolume(): void { + const cur = this.computeVolume(); + if (cur <= 0) return; + const factor = this.volume / cur; + for (let i = 0; i < this.columns; i++) { + this.heights[i] = this.heights[i]! * factor; + } + } +} diff --git a/src/physics/World.ts b/src/physics/World.ts new file mode 100644 index 0000000..b5a08fa --- /dev/null +++ b/src/physics/World.ts @@ -0,0 +1,170 @@ +import { Vector2 } from './Vector2.js'; +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 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); + + 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, + ) { } + + 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) { + this.bodies.shift(); // remove oldest + } + } + + step(dt: number): void { + const g = this.gravity; + + // Advance water simulation + this.water.step(dt, g.x, Math.abs(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; + + // Net force = gravity - buoyancy (both along Y axis primarily) + const gravityForce = g.scale(body.mass); + // Buoyancy always opposes gravity direction + const gDir = g.magnitude() > 0 ? g.normalize() : new Vector2(0, 1); + const buoyancyForce = gDir.scale(-buoyancyMag); + + // Drag when submerged — opposes velocity + const dragCoeff = submergedFrac * 3.5; + const drag = body.velocity.scale(-dragCoeff * body.mass); + + const netForce = gravityForce.add(buoyancyForce).add(drag); + const acceleration = netForce.scale(1 / body.mass); + + body.velocity.addMut(acceleration.scale(dt)); + + // Integrate position + body.position.addMut(body.velocity.scale(dt)); + + // Angular + body.angularVelocity *= ANGULAR_DAMPING; + body.angle += body.angularVelocity * dt; + + // --- Glass wall / floor constraints (in local glass space) --- + this.constrainToGlass(body); + } + + // --- 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]!); + } + } + } + + 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; + + if (d >= r) return 1.0; // fully submerged + if (d <= -r) return 0.0; // fully above water + + // Partial submersion: circular segment area / total area + const h = d + r; // submerged depth (0 … 2r) + const segmentArea = r * r * Math.acos((r - h) / r) - (r - h) * Math.sqrt(2 * r * h - h * h); + return segmentArea / (Math.PI * r * r); + } + + private constrainToGlass(body: RigidBody): void { + const r = body.radius; + // Convert to local glass coords + let lx = body.position.x - this.glassX; + let ly = body.position.y - this.glassY; + + // 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; + } + // Floor + if (ly + r > this.glassH) { + ly = this.glassH - r; + body.velocity.y = -Math.abs(body.velocity.y) * RESTITUTION; + body.velocity.x *= FRICTION; + } + // Ceiling (shouldn't normally happen, but keep objects inside) + if (ly - r < 0) { + ly = r; + body.velocity.y = Math.abs(body.velocity.y) * RESTITUTION; + } + + body.position.x = lx + this.glassX; + body.position.y = ly + this.glassY; + } + + private resolveBodyCollision(a: RigidBody, b: RigidBody): void { + const delta = b.position.sub(a.position); + const dist = delta.magnitude(); + const minDist = a.radius + b.radius; + + if (dist >= minDist || dist === 0) return; + + const normal = delta.scale(1 / dist); + const overlap = minDist - dist; + + // Positional correction — split by mass + const totalMass = a.mass + b.mass; + a.position.addMut(normal.scale(-overlap * (b.mass / totalMass))); + b.position.addMut(normal.scale(overlap * (a.mass / totalMass))); + + // Velocity resolution + const relVel = b.velocity.sub(a.velocity); + const velAlongNormal = relVel.dot(normal); + if (velAlongNormal > 0) return; // already separating + + const e = BODY_RESTITUTION; + const j = -(1 + e) * velAlongNormal / (1 / a.mass + 1 / b.mass); + + a.velocity.addMut(normal.scale(-j / a.mass)); + b.velocity.addMut(normal.scale(j / b.mass)); + + // Small angular kick on collision + a.angularVelocity += (Math.random() - 0.5) * 0.8; + b.angularVelocity += (Math.random() - 0.5) * 0.8; + } +} diff --git a/src/rendering/GlassRenderer.ts b/src/rendering/GlassRenderer.ts new file mode 100644 index 0000000..ff6c945 --- /dev/null +++ b/src/rendering/GlassRenderer.ts @@ -0,0 +1,71 @@ +/** + * Draws the glass cup outline and establishes the clip region for the + * glass interior. Call beginClip() / endClip() around interior drawing. + * + * Glass geometry (all in canvas px): + * x, y — top-left of interior + * w, h — interior dimensions + * wallThick — wall / bottom thickness + * rimOverhang — extra px the rim extends above the interior top + */ +export class GlassRenderer { + constructor( + private readonly ctx: CanvasRenderingContext2D, + public x: number, + public y: number, + public w: number, + public h: number, + public wallThick: number = 8, + public rimOverhang: number = 4, + ) { } + + /** Set a rectangular clip path matching the glass interior. */ + beginClip(): void { + this.ctx.save(); + this.ctx.beginPath(); + this.ctx.rect(this.x, this.y, this.w, this.h); + this.ctx.clip(); + } + + endClip(): void { + this.ctx.restore(); + } + + /** Draw the glass cup outline over everything. */ + draw(): void { + const ctx = this.ctx; + const { x, y, w, h, wallThick: wt, rimOverhang: ro } = this; + + ctx.save(); + + // Glass tint fill (very subtle, mostly transparent) + ctx.fillStyle = 'rgba(186, 230, 253, 0.07)'; + ctx.fillRect(x, y, w, h); + + // Left wall + ctx.fillStyle = 'rgba(186, 230, 253, 0.55)'; + ctx.fillRect(x - wt, y - ro, wt, h + ro); + + // Right wall + ctx.fillRect(x + w, y - ro, wt, h + ro); + + // Bottom + ctx.fillRect(x - wt, y + h, w + wt * 2, wt); + + // Inner highlight on left wall 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.restore(); + } +} diff --git a/src/rendering/IconRenderer.ts b/src/rendering/IconRenderer.ts new file mode 100644 index 0000000..ba8d967 --- /dev/null +++ b/src/rendering/IconRenderer.ts @@ -0,0 +1,54 @@ +import { RigidBody } from '../physics/RigidBody.js'; +import { Water } from '../physics/Water.js'; + +const FONT_SCALE = 1.55; // emoji size relative to body radius + +export class IconRenderer { + constructor( + private readonly ctx: CanvasRenderingContext2D, + private readonly water: Water, + public glassX: number, + public glassY: number, + ) { } + + draw(bodies: RigidBody[], getRefractionDx: (x: number) => number): void { + const ctx = this.ctx; + + for (const body of bodies) { + const r = body.radius; + const size = r * FONT_SCALE * 2; + const bx = body.position.x; + const by = body.position.y; + + // Determine if center is below water surface + const localX = bx - this.glassX; + const surfaceY = this.glassY + this.water.getSurfaceY(Math.max(0, localX)); + const submerged = by > surfaceY; + + ctx.save(); + ctx.translate(bx + (submerged ? getRefractionDx(bx) : 0), by); + ctx.rotate(body.angle); + + // Draw shadow / glow ring + ctx.beginPath(); + ctx.arc(0, 0, r, 0, Math.PI * 2); + if (submerged) { + ctx.fillStyle = 'rgba(0, 80, 120, 0.25)'; + } else { + ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'; + } + ctx.fill(); + + // Draw emoji + ctx.font = `${size}px serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + if (submerged) { + ctx.globalAlpha = 0.82; + } + ctx.fillText(body.label, 0, 0); + + ctx.restore(); + } + } +} diff --git a/src/rendering/Renderer.ts b/src/rendering/Renderer.ts new file mode 100644 index 0000000..dcffbcc --- /dev/null +++ b/src/rendering/Renderer.ts @@ -0,0 +1,108 @@ +import { GlassRenderer } from './GlassRenderer.js'; +import { WaterRenderer } from './WaterRenderer.js'; +import { IconRenderer } from './IconRenderer.js'; +import { World } from '../physics/World.js'; + +/** + * Top-level rendering coordinator. + * Manages canvas sizing and the draw order: + * 1. Clear + * 2. Glass clip → Water fill + * 3. Icons (with refraction for submerged) + * 4. Glass outline (drawn on top to mask overflow) + */ +export class Renderer { + readonly canvas: HTMLCanvasElement; + readonly ctx: CanvasRenderingContext2D; + + private glass: GlassRenderer; + private waterRend: WaterRenderer; + private iconRend: IconRenderer; + + constructor(canvas: HTMLCanvasElement, private world: World) { + this.canvas = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Could not get 2D context'); + this.ctx = ctx; + + this.glass = this.makeGlassRenderer(); + this.waterRend = new WaterRenderer(this.ctx, world.water, world.glassX, world.glassY); + this.iconRend = new IconRenderer(this.ctx, world.water, world.glassX, world.glassY); + + this.resize(); + window.addEventListener('resize', this.resize); + } + + private readonly resize = (): 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); + + // Recompute glass geometry + const { gx, gy, gw, gh } = this.computeGlassDims(W, H); + + this.world.glassX = gx; + this.world.glassY = gy; + this.world.water.resize(gw, gh); + + this.glass = this.makeGlassRenderer(); + this.waterRend.glassX = gx; + this.waterRend.glassY = gy; + this.iconRend.glassX = gx; + this.iconRend.glassY = gy; + }; + + private computeGlassDims(W: number, H: number): { gx: number; gy: number; gw: number; gh: number } { + // Glass occupies most of the viewport, centred, portrait-friendly + const glassW = Math.min(W * 0.82, 420); + const glassH = H * 0.72; + const gx = (W - glassW) / 2; + const gy = (H - glassH) / 2 - H * 0.02; + return { gx, gy, gw: glassW, gh: glassH }; + } + + private makeGlassRenderer(): GlassRenderer { + const W = window.innerWidth; + const H = window.innerHeight; + const { gx, gy, gw, gh } = this.computeGlassDims(W, H); + return new GlassRenderer(this.ctx, gx, gy, gw, gh); + } + + update(dt: number): void { + this.waterRend.update(dt); + } + + draw(): void { + const ctx = this.ctx; + const W = this.canvas.width / (window.devicePixelRatio || 1); + const H = this.canvas.height / (window.devicePixelRatio || 1); + + // Clear + ctx.clearRect(0, 0, W, H); + + // Background + ctx.fillStyle = '#0f172a'; + ctx.fillRect(0, 0, W, H); + + // Clip to glass interior → draw water + this.glass.beginClip(); + this.waterRend.draw(); + + // Draw icons (refraction handled inside) + this.iconRend.draw(this.world.bodies, (x) => this.waterRend.getRefractionDx(x)); + this.glass.endClip(); + + // Glass outline on top + this.glass.draw(); + } + + destroy(): void { + window.removeEventListener('resize', this.resize); + } +} diff --git a/src/rendering/WaterRenderer.ts b/src/rendering/WaterRenderer.ts new file mode 100644 index 0000000..8862cd7 --- /dev/null +++ b/src/rendering/WaterRenderer.ts @@ -0,0 +1,122 @@ +import { Water } from '../physics/Water.js'; + +export class WaterRenderer { + private shimmerOffset = 0; + private refractOffset = 0; + + constructor( + private readonly ctx: CanvasRenderingContext2D, + private readonly water: Water, + /** Canvas X of glass interior left edge */ + public glassX: number, + /** Canvas Y of glass interior top edge */ + public glassY: number, + ) { } + + update(dt: number): void { + this.shimmerOffset = (this.shimmerOffset + dt * 0.4) % (Math.PI * 2); + this.refractOffset = (this.refractOffset + dt * 1.2) % (Math.PI * 2); + } + + 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; + + // 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); + } + } + + // Close to bottom-right then bottom-left + ctx.lineTo(gx + w.glassW, gy + gh); + ctx.closePath(); + + // --- 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 + ctx.fillStyle = grad; + ctx.fill(); + + // --- Shimmer band (animated diagonal highlight) --- + 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)'); + shimGrad.addColorStop(0.5, 'rgba(255,255,255,0.12)'); + shimGrad.addColorStop(1, 'rgba(255,255,255,0.0)'); + + ctx.save(); + ctx.beginPath(); + // Re-clip to the same wave shape so shimmer stays inside water + 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 { + 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 + gh); + ctx.closePath(); + ctx.clip(); + + ctx.fillStyle = shimGrad; + ctx.fillRect(gx, shimmerY - 30, w.glassW, 60); + ctx.restore(); + + // --- Surface highlight line --- + ctx.beginPath(); + 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; + const prevSY = gy + w.getSurfaceY((i - 1) * colW + colW / 2); + const cpX = (prevX + x) / 2; + ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy); + } + } + ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)'; + ctx.lineWidth = 2; + ctx.stroke(); + } + + /** + * Returns the refraction horizontal offset (px) to apply when rendering + * objects below the water surface, as a function of their x position and time. + */ + getRefractionDx(canvasX: number): number { + return Math.sin(this.refractOffset + canvasX * 0.04) * 3; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..84b2580 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true + }, + "include": [ + "src", + "scripts" + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..0d60b09 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + publicDir: 'static', + build: { + outDir: 'dist', + target: 'esnext', + sourcemap: true, + }, +}); diff --git a/wind.png b/wind.png new file mode 100644 index 0000000..4df5d0a Binary files /dev/null and b/wind.png differ