Compare commits
7 commits
f0fb4fc86b
...
2347ecd201
| Author | SHA1 | Date | |
|---|---|---|---|
| 2347ecd201 | |||
| 39b0160064 | |||
| 34ac200437 | |||
| cb1df6f29e | |||
| 4e92913925 | |||
| b1acdff594 | |||
| 6588930911 |
|
|
@ -27,18 +27,36 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint -- --check || true
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Typecheck browser tests
|
||||
run: npm run typecheck:e2e
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Browser tests
|
||||
run: npm run test:e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Copy build to host pages mount
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
apt update && apt install -y rsync
|
||||
mkdir -p /pages
|
||||
rsync -a --delete dist/ /pages/fleeting-garden
|
||||
|
|
|
|||
2
.gitignore
vendored
|
|
@ -5,6 +5,8 @@ ts-node--*/
|
|||
rss.xml
|
||||
|
||||
dist
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
|
|
|||
21
README.md
|
|
@ -1,15 +1,14 @@
|
|||
# Just a bunch of blobs
|
||||
# Fleeting Garden
|
||||
|
||||
[](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml)
|
||||
Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette,
|
||||
draw persistent coloured paths, spawn agents from those strokes, erase locally,
|
||||
and export the scene as a 4K wallpaper.
|
||||
|
||||
## todo
|
||||
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
|
||||
|
||||
- add info page description
|
||||
- add share link
|
||||
- settings page
|
||||
add reset link
|
||||
- shareable settings
|
||||
- graceful error messages when no support
|
||||
- fix up generation id automatically
|
||||
## Testing
|
||||
|
||||
Check out the [agent's logic](./src/pipelines/agents/agent.wgsl).
|
||||
- `npm test` runs the Vitest unit suite.
|
||||
- `npm run test:e2e` builds the production bundle and runs the Playwright Chromium
|
||||
smoke test.
|
||||
- `npx playwright install chromium` installs the local browser binary when needed.
|
||||
|
|
|
|||
10
assets/icons/download.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
|
||||
fill="none"
|
||||
stroke="black"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
3
assets/icons/sound.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
23
e2e/app.spec.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveTitle('Fleeting Garden');
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||
|
||||
await page.getByRole('button', { name: 'About' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||
});
|
||||
186
index.html
|
|
@ -6,16 +6,16 @@
|
|||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#b7455e" />
|
||||
<meta name="theme-color" content="#10151f" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
|
||||
content="Fleeting Garden is a joyful WebGPU drawing garden where your coloured paths bloom into moving organic trails."
|
||||
/>
|
||||
|
||||
<meta property="og:title" content="Just a bunch of blobs" />
|
||||
<meta property="og:title" content="Fleeting Garden" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
|
||||
content="Pick a vibe, draw coloured paths, and watch them grow into a living WebGPU garden."
|
||||
/>
|
||||
<meta property="og:url" content="https://schmelczer.dev" />
|
||||
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
|
||||
|
|
@ -27,55 +27,175 @@
|
|||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<title>Just a bunch of blobs</title>
|
||||
<title>Fleeting Garden</title>
|
||||
</head>
|
||||
<body>
|
||||
<body class="is-loading">
|
||||
<main class="canvas-container">
|
||||
<canvas></canvas>
|
||||
<canvas
|
||||
role="img"
|
||||
aria-label="Interactive generative garden canvas"
|
||||
aria-describedby="canvas-description"
|
||||
>
|
||||
Your browser cannot display the interactive WebGPU garden canvas. Use a browser
|
||||
with WebGPU support to draw coloured paths and watch the garden grow.
|
||||
</canvas>
|
||||
<p id="canvas-description" class="visually-hidden">
|
||||
Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene
|
||||
to paint coloured paths, then use the toolbar to change colours, erase, adjust the
|
||||
config overlay, export, restart, or open more information.
|
||||
</p>
|
||||
<div class="eraser-preview" aria-hidden="true"></div>
|
||||
<div class="garden-prompt" aria-live="polite"></div>
|
||||
|
||||
<div class="loading-indicator" role="status" aria-live="polite">
|
||||
<div class="loading-dots" aria-hidden="true">
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
</div>
|
||||
<div class="loading-status">Starting up…</div>
|
||||
<div
|
||||
class="loading-progress"
|
||||
role="progressbar"
|
||||
aria-label="Loading Fleeting Garden"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="0"
|
||||
>
|
||||
<div class="loading-progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="errors-container">
|
||||
<noscript>JavaScript is required for this website.</noscript>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside>
|
||||
<nav class="buttons">
|
||||
<button class="info" aria-label="About"></button>
|
||||
<button class="maximize-full-screen" aria-label="Enter fullscreen"></button>
|
||||
<button class="minimize-full-screen" aria-label="Exit fullscreen"></button>
|
||||
<button class="settings" aria-label="Settings"></button>
|
||||
<button class="restart" aria-label="Restart simulation"></button>
|
||||
</nav>
|
||||
|
||||
<main class="pages hidden info-page">
|
||||
<aside class="control-dock">
|
||||
<section id="info-panel" class="pages hidden info-page" aria-hidden="true" inert>
|
||||
<section>
|
||||
<h1>Just a bunch of blobs</h1>
|
||||
<h1>Fleeting Garden</h1>
|
||||
<p>
|
||||
A million autonomous agents wander a 2D field. Each one lays down a faint
|
||||
trail and follows trails it senses ahead. Two generations are competing for
|
||||
territory: the older one fades, the newer one spreads.
|
||||
A living sketchpad where each stroke becomes a trail that agents follow,
|
||||
branch from, and weave into the scene.
|
||||
</p>
|
||||
<p>
|
||||
Drag your finger or mouse anywhere on the canvas to paint a wall. Walls slow
|
||||
the new generation down and let the old one breathe a little longer. Open
|
||||
<em>Settings</em> to retune sensors, decay rates and aggression.
|
||||
Paint with the three colour swatches, carve space with the eraser, and raise
|
||||
the mirror control when you want radial patterns instead of a single line.
|
||||
</p>
|
||||
<p>
|
||||
Runs entirely on your GPU via WebGPU compute shaders — no servers, no
|
||||
tracking, no analytics. Source on
|
||||
Switch vibes to recolour the whole garden without clearing your drawing. Add
|
||||
or mute the generated piano, restart for a blank canvas, or export the current
|
||||
frame as a 4K image.
|
||||
</p>
|
||||
<p>
|
||||
Built with WebGPU and running locally in your browser. Source on
|
||||
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
|
||||
>GitHub</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="pages hidden settings-page">
|
||||
<section>
|
||||
<div class="settings-content"></div>
|
||||
<button id="apply-defaults" class="large-button">Apply defaults</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">
|
||||
<button
|
||||
class="previous-vibe vibe-button"
|
||||
aria-label="Previous vibe"
|
||||
title="Previous vibe"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<div class="toolbar-shell">
|
||||
<section class="garden-controls" aria-label="Garden controls">
|
||||
<div class="swatches" aria-label="Drawing colours">
|
||||
<button
|
||||
class="color-swatch"
|
||||
aria-label="Draw colour 1"
|
||||
title="Draw colour 1"
|
||||
></button>
|
||||
<button
|
||||
class="color-swatch"
|
||||
aria-label="Draw colour 2"
|
||||
title="Draw colour 2"
|
||||
></button>
|
||||
<button
|
||||
class="color-swatch"
|
||||
aria-label="Draw colour 3"
|
||||
title="Draw colour 3"
|
||||
></button>
|
||||
<label class="eraser-size-control" title="Erase and resize">
|
||||
<input
|
||||
class="eraser-size-slider"
|
||||
type="range"
|
||||
min="24"
|
||||
max="240"
|
||||
step="1"
|
||||
aria-label="Eraser size"
|
||||
/>
|
||||
</label>
|
||||
<label class="mirror-segment-control" title="Mirror off">
|
||||
<input
|
||||
class="mirror-segment-slider"
|
||||
type="range"
|
||||
min="1"
|
||||
max="12"
|
||||
step="1"
|
||||
aria-label="Mirror segments"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="buttons" aria-label="App controls">
|
||||
<button
|
||||
class="info"
|
||||
aria-label="About"
|
||||
aria-controls="info-panel"
|
||||
aria-expanded="false"
|
||||
title="About"
|
||||
></button>
|
||||
<button
|
||||
class="maximize-full-screen"
|
||||
aria-label="Enter fullscreen"
|
||||
title="Enter fullscreen"
|
||||
></button>
|
||||
<button
|
||||
class="minimize-full-screen"
|
||||
aria-label="Exit fullscreen"
|
||||
hidden
|
||||
title="Exit fullscreen"
|
||||
></button>
|
||||
<button
|
||||
class="settings"
|
||||
aria-label="Show config overlay"
|
||||
aria-expanded="false"
|
||||
title="Show config overlay"
|
||||
></button>
|
||||
<button
|
||||
class="sound"
|
||||
aria-label="Mute audio"
|
||||
aria-pressed="false"
|
||||
title="Mute audio"
|
||||
></button>
|
||||
<button
|
||||
class="export-4k"
|
||||
aria-label="Download 4K upscale image"
|
||||
title="Download 4K upscale of the live simulation"
|
||||
></button>
|
||||
<span class="export-status" aria-live="polite"></span>
|
||||
<button
|
||||
class="restart"
|
||||
aria-label="Restart simulation"
|
||||
title="Restart simulation"
|
||||
></button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<button class="next-vibe vibe-button" aria-label="Next vibe" title="Next vibe">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<script type="module" src="/src/index.ts"></script>
|
||||
</body>
|
||||
|
|
|
|||
103
package-lock.json
generated
|
|
@ -1,27 +1,31 @@
|
|||
{
|
||||
"name": "webgpu-seed",
|
||||
"name": "fleeting-garden",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "webgpu-seed",
|
||||
"name": "fleeting-garden",
|
||||
"version": "0.2.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"gl-matrix": "^3.4.4"
|
||||
"tweakpane": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tweakpane/core": "^2.0.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"browserslist": "^4.28.2",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"globals": "^17.6.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"npm-check-updates": "^22.1.0",
|
||||
|
|
@ -1258,6 +1262,22 @@
|
|||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@quansync/fs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
||||
|
|
@ -1560,6 +1580,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweakpane/core": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@tweakpane/core/-/core-2.0.5.tgz",
|
||||
"integrity": "sha512-punBgD5rKCF5vcNo6BsSOXiDR/NSs9VM7SG65QSLJIxfRaGgj54ree9zQW6bO3pNFf3AogiGgaNODUVQRk9YqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
|
@ -1874,6 +1901,19 @@
|
|||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-basic-ssl": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
|
||||
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||
|
|
@ -2740,6 +2780,7 @@
|
|||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
|
|
@ -3437,6 +3478,53 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||
|
|
@ -3831,6 +3919,15 @@
|
|||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tweakpane": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-4.0.5.tgz",
|
||||
"integrity": "sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cocopon"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
|
|
|||
24
package.json
|
|
@ -1,17 +1,25 @@
|
|||
{
|
||||
"name": "webgpu-seed",
|
||||
"name": "fleeting-garden",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "A WebGPU-powered slime-mold-meets-territory-control simulation.",
|
||||
"description": "A WebGPU drawing garden where coloured paths grow into organic agent trails.",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.{ts,scss,json,html}\"",
|
||||
"lint": "npm run lint:check",
|
||||
"lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\" && npm run unused:check",
|
||||
"lint:fix": "eslint --fix \"src/**/*.ts\"",
|
||||
"format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||
"format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "npm run build && playwright test",
|
||||
"test:e2e:ui": "npm run build && playwright test --ui",
|
||||
"test:watch": "vitest",
|
||||
"unused:check": "node scripts/check-unused-exports.mjs",
|
||||
"generate-icons": "pwa-assets-generator",
|
||||
"update": "ncu"
|
||||
},
|
||||
|
|
@ -33,20 +41,21 @@
|
|||
"browserslist": [
|
||||
"supports webgpu and last 2 years"
|
||||
],
|
||||
"dependencies": {
|
||||
"gl-matrix": "^3.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tweakpane/core": "^2.0.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"browserslist": "^4.28.2",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"globals": "^17.6.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"npm-check-updates": "^22.1.0",
|
||||
|
|
@ -57,5 +66,8 @@
|
|||
"vite": "^8.0.10",
|
||||
"vite-plugin-singlefile": "^2.3.3",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"tweakpane": "^4.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
playwright.config.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = 4173;
|
||||
const baseURL = `https://127.0.0.1:${port}`;
|
||||
const isCi = Boolean(process.env.CI);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCi,
|
||||
retries: isCi ? 2 : 0,
|
||||
workers: isCi ? 1 : undefined,
|
||||
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||
ignoreHTTPSErrors: true,
|
||||
reuseExistingServer: !isCi,
|
||||
timeout: 120_000,
|
||||
url: baseURL,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
Before Width: | Height: | Size: 908 B After Width: | Height: | Size: 1.2 KiB |
BIN
public/audio/piano/A0v12.m4a
Normal file
BIN
public/audio/piano/A1v12.m4a
Normal file
BIN
public/audio/piano/A2v12.m4a
Normal file
BIN
public/audio/piano/A3v12.m4a
Normal file
BIN
public/audio/piano/A4v12.m4a
Normal file
BIN
public/audio/piano/A5v12.m4a
Normal file
BIN
public/audio/piano/A6v12.m4a
Normal file
BIN
public/audio/piano/A7v12.m4a
Normal file
BIN
public/audio/piano/C1v12.m4a
Normal file
BIN
public/audio/piano/C2v12.m4a
Normal file
BIN
public/audio/piano/C3v12.m4a
Normal file
BIN
public/audio/piano/C4v12.m4a
Normal file
BIN
public/audio/piano/C5v12.m4a
Normal file
BIN
public/audio/piano/C6v12.m4a
Normal file
BIN
public/audio/piano/C7v12.m4a
Normal file
BIN
public/audio/piano/C8v12.m4a
Normal file
BIN
public/audio/piano/Dsharp1v12.m4a
Normal file
BIN
public/audio/piano/Dsharp2v12.m4a
Normal file
BIN
public/audio/piano/Dsharp3v12.m4a
Normal file
BIN
public/audio/piano/Dsharp4v12.m4a
Normal file
BIN
public/audio/piano/Dsharp5v12.m4a
Normal file
BIN
public/audio/piano/Dsharp6v12.m4a
Normal file
BIN
public/audio/piano/Dsharp7v12.m4a
Normal file
BIN
public/audio/piano/Fsharp1v12.m4a
Normal file
BIN
public/audio/piano/Fsharp2v12.m4a
Normal file
BIN
public/audio/piano/Fsharp3v12.m4a
Normal file
BIN
public/audio/piano/Fsharp4v12.m4a
Normal file
BIN
public/audio/piano/Fsharp5v12.m4a
Normal file
BIN
public/audio/piano/Fsharp6v12.m4a
Normal file
BIN
public/audio/piano/Fsharp7v12.m4a
Normal file
7
public/audio/piano/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
|
||||
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
|
||||
under CC BY 3.0.
|
||||
|
||||
Source package: @audio-samples/piano-velocity12
|
||||
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
||||
License: https://creativecommons.org/licenses/by/3.0/
|
||||
|
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 914 B |
|
|
@ -1,6 +1,31 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#b7455e" />
|
||||
<circle cx="22" cy="26" r="9" fill="#fff" opacity="0.95" />
|
||||
<circle cx="42" cy="32" r="11" fill="#fff" opacity="0.85" />
|
||||
<circle cx="28" cy="44" r="7" fill="#fff" opacity="0.75" />
|
||||
<defs>
|
||||
<clipPath id="icon-clip">
|
||||
<rect width="64" height="64" rx="14" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#icon-clip)">
|
||||
<rect width="64" height="64" fill="#10151f" />
|
||||
<path d="M0 64a32 32 0 0 1 64 0Z" fill="#ffd84d" />
|
||||
<path
|
||||
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
||||
fill="none"
|
||||
stroke="#10151f"
|
||||
stroke-linecap="round"
|
||||
stroke-width="8"
|
||||
/>
|
||||
<path
|
||||
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
||||
fill="none"
|
||||
stroke="#ff2fa3"
|
||||
stroke-linecap="round"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff2fa3" />
|
||||
<ellipse cx="48.5" cy="18" rx="6.4" ry="4.2" fill="#ff2fa3" />
|
||||
<ellipse cx="42" cy="24.5" rx="4.2" ry="6.4" fill="#ff2fa3" />
|
||||
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" fill="#ff2fa3" />
|
||||
<circle cx="42" cy="18" r="3.2" fill="#10151f" />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 950 B |
|
|
@ -1,38 +1,38 @@
|
|||
{
|
||||
"name": "Just a bunch of blobs",
|
||||
"short_name": "Blobs",
|
||||
"description": "A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"name": "Fleeting Garden",
|
||||
"short_name": "Garden",
|
||||
"description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "fullscreen",
|
||||
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"background_color": "#b7455e",
|
||||
"theme_color": "#b7455e",
|
||||
"background_color": "#10151f",
|
||||
"theme_color": "#10151f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-64x64.png",
|
||||
"src": "pwa-64x64.png",
|
||||
"sizes": "64x64",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-192x192.png",
|
||||
"src": "pwa-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-512x512.png",
|
||||
"src": "pwa-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-icon-512x512.png",
|
||||
"src": "maskable-icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 488 B After Width: | Height: | Size: 709 B |
185
scripts/check-unused-exports.mjs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const sourceRoot = path.join(projectRoot, 'src');
|
||||
|
||||
const toPosix = (value) => value.split(path.sep).join('/');
|
||||
|
||||
const listTypeScriptFiles = (directory) =>
|
||||
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return listTypeScriptFiles(entryPath);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')
|
||||
? [entryPath]
|
||||
: [];
|
||||
});
|
||||
|
||||
const files = listTypeScriptFiles(sourceRoot);
|
||||
const fileSet = new Set(files.map((file) => path.resolve(file)));
|
||||
|
||||
const resolveModule = (fromFile, specifier) => {
|
||||
if (!specifier.startsWith('.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base = path.resolve(path.dirname(fromFile), specifier);
|
||||
const candidates = [
|
||||
`${base}.ts`,
|
||||
path.join(base, 'index.ts'),
|
||||
base.endsWith('.ts') ? base : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null;
|
||||
};
|
||||
|
||||
const exportKey = (file, name) => `${path.resolve(file)}:${name}`;
|
||||
const isExported = (node) =>
|
||||
ts.canHaveModifiers(node) &&
|
||||
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
||||
const isDefaultExported = (node) =>
|
||||
ts.canHaveModifiers(node) &&
|
||||
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
|
||||
|
||||
const exportedDeclarations = new Map();
|
||||
const usedExports = new Set();
|
||||
const wildcardUsedFiles = new Set();
|
||||
|
||||
const markUsed = (fromFile, name) => {
|
||||
usedExports.add(exportKey(fromFile, name));
|
||||
};
|
||||
|
||||
const collectImportUsage = (file, sourceFile) => {
|
||||
sourceFile.forEachChild((node) => {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
||||
if (!resolved || !node.importClause) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.importClause.name) {
|
||||
markUsed(resolved, 'default');
|
||||
}
|
||||
|
||||
const namedBindings = node.importClause.namedBindings;
|
||||
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
||||
namedBindings.elements.forEach((element) => {
|
||||
markUsed(resolved, (element.propertyName ?? element.name).text);
|
||||
});
|
||||
} else if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
||||
wildcardUsedFiles.add(path.resolve(resolved));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.exportClause) {
|
||||
wildcardUsedFiles.add(path.resolve(resolved));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isNamedExports(node.exportClause)) {
|
||||
node.exportClause.elements.forEach((element) => {
|
||||
markUsed(resolved, (element.propertyName ?? element.name).text);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const collectExportedDeclarations = (file, sourceFile) => {
|
||||
if (file.endsWith('.test.ts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
sourceFile.forEachChild((node) => {
|
||||
if (ts.isVariableStatement(node) && isExported(node)) {
|
||||
node.declarationList.declarations.forEach((declaration) => {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
exportedDeclarations.set(exportKey(file, declaration.name.text), {
|
||||
file,
|
||||
name: declaration.name.text,
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ts.isFunctionDeclaration(node) ||
|
||||
ts.isClassDeclaration(node) ||
|
||||
ts.isInterfaceDeclaration(node) ||
|
||||
ts.isTypeAliasDeclaration(node) ||
|
||||
ts.isEnumDeclaration(node)) &&
|
||||
isExported(node)
|
||||
) {
|
||||
if (isDefaultExported(node)) {
|
||||
exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.name) {
|
||||
exportedDeclarations.set(exportKey(file, node.name.text), {
|
||||
file,
|
||||
name: node.name.text,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
!node.moduleSpecifier &&
|
||||
node.exportClause &&
|
||||
ts.isNamedExports(node.exportClause)
|
||||
) {
|
||||
node.exportClause.elements.forEach((element) => {
|
||||
exportedDeclarations.set(exportKey(file, element.name.text), {
|
||||
file,
|
||||
name: element.name.text,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const parsedFiles = files.map((file) => ({
|
||||
file,
|
||||
sourceFile: ts.createSourceFile(
|
||||
file,
|
||||
readFileSync(file, 'utf8'),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS
|
||||
),
|
||||
}));
|
||||
|
||||
parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile));
|
||||
parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile));
|
||||
|
||||
const unusedExports = Array.from(exportedDeclarations.entries())
|
||||
.filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file))
|
||||
.map(([, declaration]) => declaration)
|
||||
.sort((left, right) =>
|
||||
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
|
||||
);
|
||||
|
||||
if (unusedExports.length > 0) {
|
||||
console.error('Unused exported declarations found:');
|
||||
unusedExports.forEach(({ file, name }) => {
|
||||
console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`);
|
||||
});
|
||||
process.exitCode = 1;
|
||||
}
|
||||
71
src/audio/garden-audio-config.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor';
|
||||
|
||||
export interface GardenAudioChord {
|
||||
rootOffset: number;
|
||||
quality: GardenAudioChordQuality;
|
||||
}
|
||||
|
||||
interface GardenAudioColorVoice {
|
||||
scaleDegreeOffset: number;
|
||||
velocityMultiplier: number;
|
||||
panOffset: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeProfile {
|
||||
rootMidi: number;
|
||||
scale: Array<number>;
|
||||
brightness: number;
|
||||
delayTimeMultiplier: number;
|
||||
progression: Array<GardenAudioChord>;
|
||||
}
|
||||
|
||||
export interface GardenAudioConfig {
|
||||
masterVolume: number;
|
||||
fadeInSeconds: number;
|
||||
updateRampSeconds: number;
|
||||
highPassFrequencyHz: number;
|
||||
fallbackVibeId: string;
|
||||
compressor: {
|
||||
thresholdDb: number;
|
||||
kneeDb: number;
|
||||
ratio: number;
|
||||
attackSeconds: number;
|
||||
releaseSeconds: number;
|
||||
};
|
||||
delay: {
|
||||
timeSeconds: number;
|
||||
feedback: number;
|
||||
wetGain: number;
|
||||
};
|
||||
piano: {
|
||||
maxVoices: number;
|
||||
gain: number;
|
||||
sustainSeconds: number;
|
||||
sustainLevel: number;
|
||||
releaseSeconds: number;
|
||||
lowpassHz: number;
|
||||
};
|
||||
input: {
|
||||
pressureFallback: number;
|
||||
};
|
||||
rhythm: {
|
||||
bpm: number;
|
||||
stepsPerBeat: number;
|
||||
stepsPerBar: number;
|
||||
lookaheadSeconds: number;
|
||||
speedForFullEnergyPixelsPerSecond: number;
|
||||
sparseActivity: number;
|
||||
};
|
||||
eraser: {
|
||||
minIntervalSeconds: number;
|
||||
noiseGain: number;
|
||||
filterMinHz: number;
|
||||
filterMaxHz: number;
|
||||
};
|
||||
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
|
||||
vibes: Record<string, GardenAudioVibeProfile>;
|
||||
}
|
||||
|
||||
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
|
||||
48
src/audio/garden-audio-energy.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
|
||||
describe('GardenAudioEnergy', () => {
|
||||
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
||||
const energy = new GardenAudioEnergy();
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(0.8, 0.1);
|
||||
energy.update(0.1);
|
||||
energy.update(0.2);
|
||||
|
||||
const levelBeforeLift = energy.getLevel();
|
||||
expect(energy.getActivity()).toBeGreaterThan(0);
|
||||
|
||||
energy.endGesture();
|
||||
|
||||
expect(energy.getActivity()).toBe(0);
|
||||
expect(energy.getLevel()).toBe(levelBeforeLift);
|
||||
energy.update(0.3);
|
||||
expect(energy.getLevel()).toBeLessThan(levelBeforeLift);
|
||||
expect(energy.getLevel()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
||||
const energy = new GardenAudioEnergy();
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(1, 0.1);
|
||||
energy.update(0.1);
|
||||
energy.update(0.2);
|
||||
const activeLevel = energy.getActivity();
|
||||
|
||||
energy.update(1.2);
|
||||
|
||||
expect(energy.getActivity()).toBeLessThan(activeLevel);
|
||||
});
|
||||
|
||||
it('raises activity immediately when a stroke is recorded', () => {
|
||||
const energy = new GardenAudioEnergy();
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(0.12, 0.05);
|
||||
|
||||
expect(energy.getActivity()).toBeGreaterThan(0.09);
|
||||
});
|
||||
});
|
||||
83
src/audio/garden-audio-energy.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp01 } from '../utils/clamp';
|
||||
|
||||
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
|
||||
|
||||
export class GardenAudioEnergy {
|
||||
private isGestureActive = false;
|
||||
private energy = 0;
|
||||
private targetEnergy = 0;
|
||||
private lastEnergyUpdateAt = 0;
|
||||
|
||||
public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
|
||||
|
||||
public beginGesture(now: number): void {
|
||||
this.isGestureActive = true;
|
||||
this.lastEnergyUpdateAt = now;
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.isGestureActive = false;
|
||||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public recordStroke(strokeEnergy: number, now: number): void {
|
||||
const energy = clamp01(strokeEnergy);
|
||||
this.targetEnergy = Math.max(this.targetEnergy, energy);
|
||||
if (this.isGestureActive) {
|
||||
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
|
||||
}
|
||||
this.lastEnergyUpdateAt ||= now;
|
||||
}
|
||||
|
||||
public recordEraserStroke(): void {
|
||||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public silence(): void {
|
||||
this.targetEnergy = 0;
|
||||
this.energy = 0;
|
||||
}
|
||||
|
||||
public update(now: number): void {
|
||||
if (this.lastEnergyUpdateAt <= 0) {
|
||||
this.lastEnergyUpdateAt = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||
this.lastEnergyUpdateAt = now;
|
||||
this.targetEnergy *= Math.exp(
|
||||
-elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
|
||||
);
|
||||
|
||||
const target = this.isGestureActive ? this.targetEnergy : 0;
|
||||
let timeConstant = this.engineConfig.energy.decaySeconds;
|
||||
if (!this.isGestureActive) {
|
||||
timeConstant = this.engineConfig.energy.releaseSeconds;
|
||||
} else if (target > this.energy) {
|
||||
timeConstant = this.engineConfig.energy.attackSeconds;
|
||||
}
|
||||
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
||||
this.energy += (target - this.energy) * amount;
|
||||
}
|
||||
|
||||
public getActivity(): number {
|
||||
if (!this.isGestureActive) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.getLevel();
|
||||
}
|
||||
|
||||
public getLevel(): number {
|
||||
return clamp01(this.energy);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.isGestureActive = false;
|
||||
this.energy = 0;
|
||||
this.targetEnergy = 0;
|
||||
this.lastEnergyUpdateAt = 0;
|
||||
}
|
||||
}
|
||||
206
src/audio/garden-audio-graph.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp } from '../utils/clamp';
|
||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export class GardenAudioGraph {
|
||||
public context: AudioContext | null = null;
|
||||
public eventBus: GainNode | null = null;
|
||||
public delayInput: GainNode | null = null;
|
||||
public noiseBuffer: AudioBuffer | null = null;
|
||||
|
||||
private masterGain: GainNode | null = null;
|
||||
private delayNode: DelayNode | null = null;
|
||||
private delayFeedback: GainNode | null = null;
|
||||
private delayOutput: GainNode | null = null;
|
||||
private hasUnlocked = false;
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly engineConfig: GardenAudioEngineConfig
|
||||
) {}
|
||||
|
||||
public ensureContext(canCreate: boolean): AudioContext | null {
|
||||
if (this.context) {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
if (!canCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const context = new AudioContext({ latencyHint: 'interactive' });
|
||||
const masterGain = context.createGain();
|
||||
const highPass = context.createBiquadFilter();
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
|
||||
masterGain.gain.value = 0;
|
||||
highPass.type = 'highpass';
|
||||
highPass.frequency.value = this.config.highPassFrequencyHz;
|
||||
compressor.threshold.value = this.config.compressor.thresholdDb;
|
||||
compressor.knee.value = this.config.compressor.kneeDb;
|
||||
compressor.ratio.value = this.config.compressor.ratio;
|
||||
compressor.attack.value = this.config.compressor.attackSeconds;
|
||||
compressor.release.value = this.config.compressor.releaseSeconds;
|
||||
|
||||
masterGain.connect(highPass);
|
||||
highPass.connect(compressor);
|
||||
compressor.connect(context.destination);
|
||||
|
||||
this.context = context;
|
||||
this.masterGain = masterGain;
|
||||
this.noiseBuffer = this.createNoiseBuffer(context);
|
||||
this.createDelay(context, masterGain);
|
||||
this.createBuses(context, masterGain);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
|
||||
// a buffer source has been started inside a user-gesture handler. Calling
|
||||
// resume() alone leaves the context "running" but silent.
|
||||
public unlock(): void {
|
||||
if (!this.context || this.hasUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = this.context.createBuffer(
|
||||
1,
|
||||
this.engineConfig.graph.unlockBufferLength,
|
||||
this.engineConfig.graph.unlockSampleRate
|
||||
);
|
||||
const source = this.context.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.context.destination);
|
||||
source.start(0);
|
||||
this.hasUnlocked = true;
|
||||
}
|
||||
|
||||
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||
if (!this.context || !this.masterGain) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.masterGain.gain.setTargetAtTime(
|
||||
targetGain,
|
||||
this.context.currentTime,
|
||||
timeConstantSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
this.context.currentTime,
|
||||
this.engineConfig.graph.delayTimeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public updateDelay(profile: GardenAudioVibeProfile, activity: number): void {
|
||||
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
now,
|
||||
this.engineConfig.graph.delayTimeRampSeconds
|
||||
);
|
||||
this.delayFeedback.gain.setTargetAtTime(
|
||||
clamp(
|
||||
this.config.delay.feedback +
|
||||
activity * this.engineConfig.graph.delayActivityFeedbackWeight,
|
||||
this.engineConfig.graph.delayFeedbackMin,
|
||||
this.engineConfig.graph.delayFeedbackMax
|
||||
),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.delayOutput.gain.setTargetAtTime(
|
||||
this.config.delay.wetGain *
|
||||
(this.engineConfig.graph.delayOutputBase +
|
||||
activity * this.engineConfig.graph.delayOutputActivityWeight),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.masterGain && context.state !== 'closed') {
|
||||
this.masterGain.gain.setTargetAtTime(
|
||||
this.engineConfig.graph.closeGain,
|
||||
context.currentTime,
|
||||
this.engineConfig.graph.closeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
this.clearNodes();
|
||||
|
||||
if (context.state !== 'closed') {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
||||
const delayInput = context.createGain();
|
||||
const delayNode = context.createDelay(2);
|
||||
const delayFeedback = context.createGain();
|
||||
const delayOutput = context.createGain();
|
||||
|
||||
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
||||
delayFeedback.gain.value = this.config.delay.feedback;
|
||||
delayOutput.gain.value = this.config.delay.wetGain;
|
||||
|
||||
delayInput.connect(delayNode);
|
||||
delayNode.connect(delayFeedback);
|
||||
delayFeedback.connect(delayNode);
|
||||
delayNode.connect(delayOutput);
|
||||
delayOutput.connect(masterGain);
|
||||
|
||||
this.delayInput = delayInput;
|
||||
this.delayNode = delayNode;
|
||||
this.delayFeedback = delayFeedback;
|
||||
this.delayOutput = delayOutput;
|
||||
}
|
||||
|
||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||
this.eventBus = context.createGain();
|
||||
this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
|
||||
this.eventBus.connect(masterGain);
|
||||
}
|
||||
|
||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||
const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
data[index] =
|
||||
this.engineConfig.graph.noiseMin +
|
||||
Math.random() *
|
||||
(this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private clearNodes(): void {
|
||||
this.context = null;
|
||||
this.eventBus = null;
|
||||
this.delayInput = null;
|
||||
this.noiseBuffer = null;
|
||||
this.masterGain = null;
|
||||
this.delayNode = null;
|
||||
this.delayFeedback = null;
|
||||
this.delayOutput = null;
|
||||
this.hasUnlocked = false;
|
||||
}
|
||||
}
|
||||
75
src/audio/garden-audio-input.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
export interface GardenAudioStrokeMetrics {
|
||||
distancePixels: number;
|
||||
pressure: number;
|
||||
speedAmount: number;
|
||||
effectiveEnergy: number;
|
||||
}
|
||||
|
||||
export const getStrokeMetrics = (
|
||||
stroke: GardenAudioStroke,
|
||||
speedForFullEnergyPixelsPerSecond: number,
|
||||
fallbackPressure: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): GardenAudioStrokeMetrics => {
|
||||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
|
||||
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
|
||||
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
||||
const strokeEnergy = clamp01(
|
||||
inputConfig.strokeEnergyBase +
|
||||
speedAmount * inputConfig.strokeEnergySpeedWeight +
|
||||
pressure * inputConfig.strokeEnergyPressureWeight
|
||||
);
|
||||
const effectiveEnergy =
|
||||
strokeEnergy *
|
||||
(inputConfig.distanceEnergyBase +
|
||||
clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
|
||||
inputConfig.distanceEnergyScale);
|
||||
|
||||
return {
|
||||
distancePixels,
|
||||
pressure,
|
||||
speedAmount,
|
||||
effectiveEnergy,
|
||||
};
|
||||
};
|
||||
|
||||
const getStrokeVelocity = (
|
||||
stroke: GardenAudioStroke,
|
||||
distancePixels: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): number => {
|
||||
if (
|
||||
stroke.velocityPixelsPerSecond !== undefined &&
|
||||
Number.isFinite(stroke.velocityPixelsPerSecond) &&
|
||||
stroke.velocityPixelsPerSecond >= 0
|
||||
) {
|
||||
return stroke.velocityPixelsPerSecond;
|
||||
}
|
||||
|
||||
return distancePixels / inputConfig.fallbackFrameSeconds;
|
||||
};
|
||||
|
||||
const getPressureAmount = (
|
||||
stroke: GardenAudioStroke,
|
||||
fallbackPressure: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): number => {
|
||||
if (
|
||||
stroke.pressure !== undefined &&
|
||||
Number.isFinite(stroke.pressure) &&
|
||||
stroke.pressure > 0
|
||||
) {
|
||||
return clamp01(stroke.pressure);
|
||||
}
|
||||
|
||||
return stroke.pointerType === 'pen'
|
||||
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
|
||||
: clamp01(fallbackPressure);
|
||||
};
|
||||
39
src/audio/garden-audio-music.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
GardenAudioChord,
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import { GardenAudioColorIndex } from './garden-audio-types';
|
||||
|
||||
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
|
||||
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
|
||||
|
||||
export const getVibeProfile = (
|
||||
config: GardenAudioConfig,
|
||||
vibe: VibePreset
|
||||
): GardenAudioVibeProfile =>
|
||||
config.vibes[vibe.id] ??
|
||||
config.vibes[config.fallbackVibeId] ??
|
||||
Object.values(config.vibes)[0];
|
||||
|
||||
export const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
openVoicing: boolean
|
||||
): Array<number> => {
|
||||
if (openVoicing) {
|
||||
return chord.quality === 'major' ? [0, 7, 12, 16] : [0, 7, 12, 15];
|
||||
}
|
||||
|
||||
return chord.quality === 'major' ? [0, 4, 7, 12, 16] : [0, 3, 7, 12, 15];
|
||||
};
|
||||
|
||||
export const degreeToSemitone = (
|
||||
profile: GardenAudioVibeProfile,
|
||||
degree: number
|
||||
): number => {
|
||||
const scaleIndex =
|
||||
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
||||
const octave = Math.floor(degree / profile.scale.length);
|
||||
return profile.scale[scaleIndex] + octave * 12;
|
||||
};
|
||||
66
src/audio/garden-audio-types.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { VibePreset } from '../vibes';
|
||||
|
||||
export type GardenAudioColorIndex = 0 | 1 | 2;
|
||||
|
||||
export interface GardenAudioSnapshot {
|
||||
vibe: VibePreset;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
mirrorSegmentCount?: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStroke {
|
||||
vibe: VibePreset;
|
||||
from: ArrayLike<number>;
|
||||
to: ArrayLike<number>;
|
||||
canvasSize: ArrayLike<number>;
|
||||
colorIndex: number;
|
||||
isErasing: boolean;
|
||||
pressure?: number;
|
||||
velocityPixelsPerSecond?: number;
|
||||
eraserSizePixels?: number;
|
||||
mirrorSegmentCount?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
export interface GardenAudioTouchDown {
|
||||
vibe: VibePreset;
|
||||
colorIndex: number;
|
||||
mirrorSegmentCount?: number;
|
||||
pressure?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
export interface GardenAudioStartOptions {
|
||||
userGesture?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadedPianoSample {
|
||||
midi: number;
|
||||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioBufferSourceNode;
|
||||
startAt: number;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
export interface PianoNote {
|
||||
midi: number;
|
||||
velocity: number;
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
pan: number;
|
||||
delaySend?: number;
|
||||
lowpassHz?: number;
|
||||
}
|
||||
|
||||
export interface NoiseBurst {
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
gain: number;
|
||||
filterHz: number;
|
||||
pan: number;
|
||||
}
|
||||
153
src/audio/garden-audio.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { GardenAudio } from './garden-audio';
|
||||
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
||||
|
||||
const calls = {
|
||||
constructed: 0,
|
||||
resumed: 0,
|
||||
};
|
||||
|
||||
let contextState: AudioContextState = 'suspended';
|
||||
|
||||
class FakeAudioParam {
|
||||
public value = 0;
|
||||
public setTargetAtTime = vi.fn();
|
||||
public setValueAtTime = vi.fn();
|
||||
public exponentialRampToValueAtTime = vi.fn();
|
||||
public cancelScheduledValues = vi.fn();
|
||||
}
|
||||
|
||||
class FakeAudioNode {
|
||||
public readonly gain = new FakeAudioParam();
|
||||
public readonly frequency = new FakeAudioParam();
|
||||
public readonly threshold = new FakeAudioParam();
|
||||
public readonly knee = new FakeAudioParam();
|
||||
public readonly ratio = new FakeAudioParam();
|
||||
public readonly attack = new FakeAudioParam();
|
||||
public readonly release = new FakeAudioParam();
|
||||
public readonly delayTime = new FakeAudioParam();
|
||||
public type = '';
|
||||
public connect = vi.fn();
|
||||
public disconnect = vi.fn();
|
||||
}
|
||||
|
||||
class FakeAudioBuffer {
|
||||
private readonly data: Float32Array;
|
||||
|
||||
public constructor(length: number) {
|
||||
this.data = new Float32Array(length);
|
||||
}
|
||||
|
||||
public getChannelData(): Float32Array {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
public readonly currentTime = 1;
|
||||
public readonly sampleRate = 16;
|
||||
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
|
||||
|
||||
public constructor() {
|
||||
calls.constructed += 1;
|
||||
}
|
||||
|
||||
public get state(): AudioContextState {
|
||||
return contextState;
|
||||
}
|
||||
|
||||
public set state(state: AudioContextState) {
|
||||
contextState = state;
|
||||
}
|
||||
|
||||
public createGain(): GainNode {
|
||||
return new FakeAudioNode() as unknown as GainNode;
|
||||
}
|
||||
|
||||
public createBiquadFilter(): BiquadFilterNode {
|
||||
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
||||
}
|
||||
|
||||
public createDynamicsCompressor(): DynamicsCompressorNode {
|
||||
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
|
||||
}
|
||||
|
||||
public createDelay(): DelayNode {
|
||||
return new FakeAudioNode() as unknown as DelayNode;
|
||||
}
|
||||
|
||||
public createBuffer(_channels: number, length: number): AudioBuffer {
|
||||
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
||||
}
|
||||
|
||||
public createBufferSource(): AudioBufferSourceNode {
|
||||
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
|
||||
buffer: AudioBuffer | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
node.buffer = null;
|
||||
node.start = vi.fn();
|
||||
node.stop = vi.fn();
|
||||
return node;
|
||||
}
|
||||
|
||||
public async resume(): Promise<void> {
|
||||
calls.resumed += 1;
|
||||
contextState = 'running';
|
||||
}
|
||||
}
|
||||
|
||||
const makeConfig = (): GardenAudioConfig => ({
|
||||
...gardenAudioConfig,
|
||||
});
|
||||
|
||||
describe('GardenAudio startup policy', () => {
|
||||
beforeEach(() => {
|
||||
calls.constructed = 0;
|
||||
calls.resumed = 0;
|
||||
contextState = 'suspended';
|
||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not create an AudioContext from passive audio paths', () => {
|
||||
const audio = new GardenAudio(makeConfig());
|
||||
const vibe = VIBE_PRESETS[0];
|
||||
|
||||
audio.start(vibe);
|
||||
audio.stroke({
|
||||
vibe,
|
||||
from: [0, 0],
|
||||
to: [12, 0],
|
||||
canvasSize: [100, 100],
|
||||
colorIndex: 0,
|
||||
isErasing: false,
|
||||
});
|
||||
|
||||
expect(calls.constructed).toBe(0);
|
||||
});
|
||||
|
||||
it('only resumes a suspended context from a user gesture start', () => {
|
||||
const audio = new GardenAudio(makeConfig());
|
||||
const vibe = VIBE_PRESETS[0];
|
||||
|
||||
audio.start(vibe, { userGesture: true });
|
||||
|
||||
expect(calls.constructed).toBe(1);
|
||||
expect(calls.resumed).toBe(1);
|
||||
expect(contextState).toBe('running');
|
||||
|
||||
contextState = 'suspended';
|
||||
audio.start(vibe);
|
||||
audio.setMuted(false);
|
||||
|
||||
expect(calls.resumed).toBe(1);
|
||||
});
|
||||
});
|
||||
380
src/audio/garden-audio.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input';
|
||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||
import type {
|
||||
GardenAudioColorIndex,
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
GardenAudioTouchDown,
|
||||
} from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
export type {
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
GardenAudioTouchDown,
|
||||
} from './garden-audio-types';
|
||||
|
||||
export class GardenAudio {
|
||||
private readonly graph: GardenAudioGraph;
|
||||
private readonly piano: PianoSampler;
|
||||
private readonly noise: NoiseBurstPlayer;
|
||||
private readonly energy: GardenAudioEnergy;
|
||||
private readonly pianoEngine: GenerativePianoEngine;
|
||||
|
||||
private currentVibeId: string | null = null;
|
||||
private hasStarted = false;
|
||||
private isDestroyed = false;
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private selectedColorIndex: GardenAudioColorIndex = 0;
|
||||
private hasQueuedPianoLoad = false;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {
|
||||
this.graph = new GardenAudioGraph(config);
|
||||
this.piano = new PianoSampler(config, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(this.graph);
|
||||
this.energy = new GardenAudioEnergy();
|
||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||
}
|
||||
|
||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.ensureContext(options.userGesture === true);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.userGesture === true) {
|
||||
this.graph.unlock();
|
||||
}
|
||||
|
||||
if (context.state === 'suspended') {
|
||||
if (options.userGesture !== true) {
|
||||
return;
|
||||
}
|
||||
void context.resume().catch(() => undefined);
|
||||
}
|
||||
|
||||
this.hasStarted = true;
|
||||
this.applyVibe(vibe);
|
||||
this.pianoEngine.prime(context.currentTime);
|
||||
this.graph.setMasterGain(
|
||||
this.config.masterVolume,
|
||||
options.userGesture === true
|
||||
? appConfig.audioEngine.muteRampSeconds
|
||||
: this.config.fadeInSeconds
|
||||
);
|
||||
|
||||
if (!this.hasQueuedPianoLoad) {
|
||||
this.hasQueuedPianoLoad = true;
|
||||
void this.piano.load(context).then(() => {
|
||||
if (this.graph.context === context && !this.isDestroyed) {
|
||||
this.pianoEngine.cue(context.currentTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
const previousVibeId = this.currentVibeId;
|
||||
this.start(vibe, options);
|
||||
|
||||
const context = this.graph.context;
|
||||
if (
|
||||
context &&
|
||||
(context.state === 'running' || options.userGesture === true) &&
|
||||
!this.isMuted &&
|
||||
!this.isDestroyed &&
|
||||
previousVibeId !== null &&
|
||||
previousVibeId !== vibe.id
|
||||
) {
|
||||
this.playVibeChangeStinger(vibe);
|
||||
}
|
||||
}
|
||||
|
||||
public setMuted(isMuted: boolean): void {
|
||||
this.isMuted = isMuted;
|
||||
this.graph.setMasterGain(
|
||||
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
|
||||
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public beginGesture(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.isGestureActive = false;
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
}
|
||||
|
||||
public touchDown(touch: GardenAudioTouchDown): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
if (!context || !this.isGestureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
|
||||
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
|
||||
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
|
||||
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
|
||||
|
||||
this.energy.recordStroke(strength, context.currentTime);
|
||||
this.pianoEngine.recordTouchDown({
|
||||
vibe: touch.vibe,
|
||||
now: context.currentTime,
|
||||
strength,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
});
|
||||
}
|
||||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
const context = this.graph.context;
|
||||
if (!this.hasStarted || !context || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyVibe(snapshot.vibe);
|
||||
this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex);
|
||||
this.energy.update(context.currentTime);
|
||||
|
||||
if (snapshot.isErasing) {
|
||||
this.energy.silence();
|
||||
}
|
||||
|
||||
this.pianoEngine.renderLookahead({
|
||||
vibe: snapshot.vibe,
|
||||
now: context.currentTime,
|
||||
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
});
|
||||
this.updateDelay(snapshot);
|
||||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.start(stroke.vibe);
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
if (!this.isGestureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getStrokeMetrics(
|
||||
stroke,
|
||||
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||
this.config.input.pressureFallback
|
||||
);
|
||||
const now = context.currentTime;
|
||||
|
||||
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
||||
|
||||
if (stroke.isErasing) {
|
||||
this.energy.recordEraserStroke();
|
||||
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
|
||||
return;
|
||||
}
|
||||
|
||||
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
|
||||
const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount);
|
||||
this.energy.recordStroke(strokeEnergy, now);
|
||||
this.pianoEngine.recordStroke({
|
||||
vibe: stroke.vibe,
|
||||
now,
|
||||
activity: strokeEnergy,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.isDestroyed = true;
|
||||
await this.graph.close();
|
||||
|
||||
this.piano.reset();
|
||||
this.energy.reset();
|
||||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.hasStarted = false;
|
||||
this.isGestureActive = false;
|
||||
this.selectedColorIndex = 0;
|
||||
this.hasQueuedPianoLoad = false;
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
private playVibeChangeStinger(vibe: VibePreset): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
if (
|
||||
now - this.lastVibeStingerAt <
|
||||
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastVibeStingerAt = now;
|
||||
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||
}
|
||||
|
||||
private playEraser(
|
||||
stroke: GardenAudioStroke,
|
||||
speedAmount: number,
|
||||
pressure: number,
|
||||
now: number
|
||||
): void {
|
||||
if (!this.graph.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeAmount = clamp01(
|
||||
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
|
||||
Math.max(
|
||||
1,
|
||||
stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize
|
||||
)
|
||||
);
|
||||
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
||||
const filterHz =
|
||||
this.config.eraser.filterMinHz +
|
||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||
clamp01(
|
||||
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
|
||||
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
|
||||
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
|
||||
);
|
||||
|
||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||
this.lastEraserAt = now;
|
||||
this.noise.play({
|
||||
startTime: now,
|
||||
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
|
||||
gain:
|
||||
this.config.eraser.noiseGain *
|
||||
(appConfig.audioEngine.eraser.gainBase +
|
||||
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
|
||||
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
|
||||
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
|
||||
filterHz,
|
||||
pan: clamp(x * 2 - 1, -1, 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateDelay(snapshot: GardenAudioSnapshot): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||
const activity = snapshot.isErasing
|
||||
? appConfig.audioEngine.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(profile, activity);
|
||||
}
|
||||
|
||||
private applyVibe(vibe: VibePreset): void {
|
||||
if (!this.graph.context || this.currentVibeId === vibe.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
||||
this.pianoEngine.cue(this.graph.context.currentTime);
|
||||
}
|
||||
|
||||
private getMirrorAmount(mirrorSegmentCount: number): number {
|
||||
const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount);
|
||||
const segmentCount = clamp(
|
||||
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
|
||||
1,
|
||||
maxMirrorSegmentCount
|
||||
);
|
||||
|
||||
if (maxMirrorSegmentCount <= 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
|
||||
}
|
||||
|
||||
private getStrokeMusicActivity(
|
||||
stroke: GardenAudioStroke,
|
||||
metrics: GardenAudioStrokeMetrics,
|
||||
mirrorAmount: number
|
||||
): number {
|
||||
const speedRatio =
|
||||
(stroke.velocityPixelsPerSecond ?? 0) /
|
||||
Math.max(1, this.config.rhythm.speedForFullEnergyPixelsPerSecond);
|
||||
const speedDrive = smoothstep(0.35, 1.1, speedRatio);
|
||||
const speedOverdrive = smoothstep(1.15, 1.8, speedRatio);
|
||||
const distanceDrive = smoothstep(10, 90, metrics.distancePixels);
|
||||
const baseStroke = clamp01(
|
||||
0.08 + speedDrive * 0.5 + metrics.pressure * 0.2 + distanceDrive * 0.22
|
||||
);
|
||||
const mirrorWild = smoothstep(0.45, 0.9, mirrorAmount);
|
||||
const maniaDrive = speedOverdrive * smoothstep(0.62, 0.82, baseStroke);
|
||||
const maniaBoost = maniaDrive * (0.18 + mirrorWild * 0.62);
|
||||
|
||||
return clamp01(
|
||||
baseStroke * (0.68 + mirrorAmount * 0.3) +
|
||||
0.025 +
|
||||
mirrorAmount * 0.045 +
|
||||
maniaBoost
|
||||
);
|
||||
}
|
||||
|
||||
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
|
||||
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
|
||||
return clamp01(pressure);
|
||||
}
|
||||
|
||||
return pointerType === 'pen'
|
||||
? Math.max(appConfig.audioEngine.input.penMinPressure, this.config.input.pressureFallback)
|
||||
: this.config.input.pressureFallback;
|
||||
}
|
||||
}
|
||||
|
||||
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
||||
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
||||
return amount * amount * (3 - 2 * amount);
|
||||
};
|
||||
234
src/audio/generative-piano.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { gardenAudioConfig } from './garden-audio-config';
|
||||
import { PianoNote } from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
|
||||
const makeEngine = () => {
|
||||
const notes: Array<PianoNote> = [];
|
||||
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
|
||||
notes.push(note);
|
||||
});
|
||||
|
||||
return { engine, notes };
|
||||
};
|
||||
|
||||
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
|
||||
|
||||
const getBeatsPerBar = (): number =>
|
||||
Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat);
|
||||
|
||||
const renderBars = (
|
||||
engine: GenerativePianoEngine,
|
||||
activity: number,
|
||||
selectedColorIndex = 0,
|
||||
bars = 8
|
||||
) => {
|
||||
engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: 0,
|
||||
activity,
|
||||
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
|
||||
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
|
||||
});
|
||||
};
|
||||
|
||||
const average = (values: Array<number>): number =>
|
||||
values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
|
||||
const uniqueStartTimes = (notes: Array<PianoNote>): Array<string> =>
|
||||
Array.from(new Set(notes.map((note) => note.startTime.toFixed(3))));
|
||||
|
||||
const countNotesBetween = (
|
||||
notes: Array<PianoNote>,
|
||||
startSeconds: number,
|
||||
endSeconds: number
|
||||
): number =>
|
||||
notes.filter(
|
||||
(note) => note.startTime >= startSeconds && note.startTime < endSeconds
|
||||
).length;
|
||||
|
||||
describe('GenerativePianoEngine', () => {
|
||||
it('plays quiet background music even when the garden is idle', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, 0);
|
||||
|
||||
expect(notes.length).toBeGreaterThan(0);
|
||||
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe(
|
||||
true
|
||||
);
|
||||
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16);
|
||||
});
|
||||
|
||||
it('keeps the background sparse instead of filling every beat', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, 0, 1, 4);
|
||||
|
||||
expect(uniqueStartTimes(notes).length).toBeLessThan(8);
|
||||
});
|
||||
|
||||
it('lets activity add density without changing the beat grid', () => {
|
||||
const idle = makeEngine();
|
||||
const active = makeEngine();
|
||||
const startDelaySeconds = 0.02;
|
||||
|
||||
renderBars(idle.engine, 0, 1, 8);
|
||||
renderBars(active.engine, 1, 1, 8);
|
||||
|
||||
expect(active.notes.length).toBeGreaterThan(idle.notes.length);
|
||||
active.notes.forEach((note) => {
|
||||
const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds();
|
||||
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses color pools with multiple notes instead of one key per color', () => {
|
||||
([0, 1, 2] as const).forEach((selectedColorIndex) => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, 1, selectedColorIndex, 16);
|
||||
|
||||
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the upper color higher and wider than the lower color', () => {
|
||||
const lower = makeEngine();
|
||||
const upper = makeEngine();
|
||||
|
||||
renderBars(lower.engine, 1, 0, 16);
|
||||
renderBars(upper.engine, 1, 2, 16);
|
||||
|
||||
expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan(
|
||||
average(lower.notes.map((note) => note.midi))
|
||||
);
|
||||
expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan(
|
||||
average(lower.notes.map((note) => note.pan))
|
||||
);
|
||||
});
|
||||
|
||||
it('starts a fading brush phrase layer with each new brush gesture', () => {
|
||||
const baseline = makeEngine();
|
||||
const layered = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
baseline.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 1,
|
||||
lookaheadSeconds: 12,
|
||||
});
|
||||
|
||||
layered.engine.beginGesture();
|
||||
layered.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 1,
|
||||
mirrorAmount: 0.45,
|
||||
});
|
||||
layered.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 1,
|
||||
lookaheadSeconds: 12,
|
||||
});
|
||||
|
||||
const earlyExtra =
|
||||
countNotesBetween(layered.notes, now + 1, now + 5) -
|
||||
countNotesBetween(baseline.notes, now + 1, now + 5);
|
||||
const lateExtra =
|
||||
countNotesBetween(layered.notes, now + 10.5, now + 12) -
|
||||
countNotesBetween(baseline.notes, now + 10.5, now + 12);
|
||||
|
||||
expect(earlyExtra).toBeGreaterThan(2);
|
||||
expect(lateExtra).toBe(0);
|
||||
});
|
||||
|
||||
it('makes brush phrase layers denser at higher mirror amounts', () => {
|
||||
const lowMirror = makeEngine();
|
||||
const highMirror = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
lowMirror.engine.beginGesture();
|
||||
lowMirror.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 2,
|
||||
mirrorAmount: 0,
|
||||
});
|
||||
lowMirror.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 2,
|
||||
lookaheadSeconds: 9,
|
||||
});
|
||||
|
||||
highMirror.engine.beginGesture();
|
||||
highMirror.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 2,
|
||||
mirrorAmount: 1,
|
||||
});
|
||||
highMirror.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 2,
|
||||
lookaheadSeconds: 9,
|
||||
});
|
||||
|
||||
expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
|
||||
});
|
||||
|
||||
it('plays one immediate touch note and throttles later stroke accents', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
engine.beginGesture();
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.9,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: now + 1,
|
||||
activity: 0.95,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].startTime).toBe(now);
|
||||
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: now + 6,
|
||||
activity: 0.95,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('is deterministic for the same musical inputs', () => {
|
||||
const first = makeEngine();
|
||||
const second = makeEngine();
|
||||
|
||||
renderBars(first.engine, 0.78, 2, 16);
|
||||
renderBars(second.engine, 0.78, 2, 16);
|
||||
|
||||
expect(second.notes).toEqual(first.notes);
|
||||
});
|
||||
});
|
||||
900
src/audio/generative-piano.ts
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
GardenAudioChord,
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import { degreeToSemitone, getChordIntervals, getVibeProfile } from './garden-audio-music';
|
||||
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
|
||||
|
||||
interface RenderLookaheadRequest {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
lookaheadSeconds?: number;
|
||||
}
|
||||
|
||||
interface StrokeAccentRequest {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount?: number;
|
||||
}
|
||||
|
||||
interface TouchDownRequest {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
strength: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount?: number;
|
||||
}
|
||||
|
||||
interface Register {
|
||||
midiMin: number;
|
||||
midiMax: number;
|
||||
preferredMidi: number;
|
||||
pan: number;
|
||||
}
|
||||
|
||||
interface ColorPool extends Register {
|
||||
scaleDegrees: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
interface PitchCandidate {
|
||||
midi: number;
|
||||
preference: number;
|
||||
}
|
||||
|
||||
interface PitchSource {
|
||||
baseMidi: number;
|
||||
offsets: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
interface BrushPhraseLayer {
|
||||
vibe: VibePreset;
|
||||
startedAt: number;
|
||||
expiresAt: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
energy: number;
|
||||
mirrorAmount: number;
|
||||
}
|
||||
|
||||
const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [
|
||||
{
|
||||
midiMin: 48,
|
||||
midiMax: 67,
|
||||
preferredMidi: 55,
|
||||
pan: -0.18,
|
||||
scaleDegrees: [0, 1, 2, 4],
|
||||
},
|
||||
{
|
||||
midiMin: 55,
|
||||
midiMax: 74,
|
||||
preferredMidi: 63,
|
||||
pan: 0,
|
||||
scaleDegrees: [1, 2, 3, 5],
|
||||
},
|
||||
{
|
||||
midiMin: 62,
|
||||
midiMax: 81,
|
||||
preferredMidi: 72,
|
||||
pan: 0.18,
|
||||
scaleDegrees: [2, 3, 4, 6],
|
||||
},
|
||||
];
|
||||
|
||||
const PAD_REGISTERS: [Register, Register, Register] = [
|
||||
{
|
||||
midiMin: 40,
|
||||
midiMax: 55,
|
||||
preferredMidi: 48,
|
||||
pan: -0.12,
|
||||
},
|
||||
{
|
||||
midiMin: 48,
|
||||
midiMax: 64,
|
||||
preferredMidi: 55,
|
||||
pan: 0.08,
|
||||
},
|
||||
{
|
||||
midiMin: 58,
|
||||
midiMax: 76,
|
||||
preferredMidi: 67,
|
||||
pan: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
const CHORD_BARS = 4;
|
||||
const SUPPORT_BAR_SPACING = 2;
|
||||
const SUPPORT_BAR_OFFSET = 1;
|
||||
const IDLE_TEXTURE_BAR_SPACING = 2;
|
||||
const MEDIUM_TEXTURE_BAR_SPACING = 1;
|
||||
const TEXTURE_BEAT = 2;
|
||||
const HIGH_ACTIVITY_EXTRA_BEAT = 3;
|
||||
const HIGH_ACTIVITY_EXTRA_THRESHOLD = 0.45;
|
||||
const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
|
||||
const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
|
||||
const NOTE_SCORE_REPEAT_PENALTY = 3.2;
|
||||
const GESTURE_ACCENT_SPACING_SECONDS = 0.26;
|
||||
const GESTURE_ACCENT_MIN_INTERVAL_SECONDS = 2.5;
|
||||
const STROKE_ACCENT_MIN_INTERVAL_SECONDS = 3.2;
|
||||
const STROKE_ACCENT_THRESHOLD = 0.58;
|
||||
const STINGER_SPACING_SECONDS = 0.08;
|
||||
const STINGER_DURATION_SECONDS = 1.1;
|
||||
const MAX_BRUSH_PHRASE_LAYERS = 5;
|
||||
const BRUSH_LAYER_BASE_SECONDS = 5.5;
|
||||
const BRUSH_LAYER_ENERGY_SECONDS = 2.5;
|
||||
const BRUSH_LAYER_MIRROR_SECONDS = 3;
|
||||
const BRUSH_LAYER_MIN_INTENSITY = 0.08;
|
||||
const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2;
|
||||
const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1;
|
||||
const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5;
|
||||
const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25;
|
||||
|
||||
export class GenerativePianoEngine {
|
||||
private nextBeatAt: number | null = null;
|
||||
private timelineStartedAt: number | null = null;
|
||||
private beatIndex = 0;
|
||||
private isWaitingForGestureAccent = false;
|
||||
private lastGestureAccentAt = Number.NEGATIVE_INFINITY;
|
||||
private lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
|
||||
private readonly lastMidiByColor: [number | null, number | null, number | null] = [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
];
|
||||
private brushPhraseLayers: Array<BrushPhraseLayer> = [];
|
||||
private nextBrushStreamAt: number | null = null;
|
||||
private brushStreamNoteIndex = 0;
|
||||
private lastBrushStreamMidi: number | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly playNote: (note: PianoNote) => void
|
||||
) {}
|
||||
|
||||
public prime(now: number): void {
|
||||
if (this.nextBeatAt === null) {
|
||||
this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||
}
|
||||
this.timelineStartedAt ??= now;
|
||||
this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
|
||||
}
|
||||
|
||||
public cue(now: number): void {
|
||||
this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||
this.timelineStartedAt = now;
|
||||
this.beatIndex = 0;
|
||||
this.nextBrushStreamAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||
this.brushStreamNoteIndex = 0;
|
||||
this.lastBrushStreamMidi = null;
|
||||
}
|
||||
|
||||
public beginGesture(): void {
|
||||
this.isWaitingForGestureAccent = true;
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.isWaitingForGestureAccent = false;
|
||||
}
|
||||
|
||||
public recordTouchDown({
|
||||
vibe,
|
||||
now,
|
||||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount = 0,
|
||||
}: TouchDownRequest): void {
|
||||
const normalizedStrength = clamp01(strength);
|
||||
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
||||
|
||||
this.isWaitingForGestureAccent = false;
|
||||
this.lastGestureAccentAt = now;
|
||||
this.lastStrokeAccentAt = now;
|
||||
this.startBrushPhraseLayer({
|
||||
vibe,
|
||||
now,
|
||||
strength: normalizedStrength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount: normalizedMirrorAmount,
|
||||
});
|
||||
this.playTouchNote(vibe, now, selectedColorIndex, normalizedStrength);
|
||||
}
|
||||
|
||||
public recordStroke({
|
||||
vibe,
|
||||
now,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
mirrorAmount = 0,
|
||||
}: StrokeAccentRequest): void {
|
||||
const strength = clamp01(activity);
|
||||
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
||||
|
||||
if (
|
||||
this.isWaitingForGestureAccent &&
|
||||
now - this.lastGestureAccentAt >= GESTURE_ACCENT_MIN_INTERVAL_SECONDS
|
||||
) {
|
||||
this.recordTouchDown({
|
||||
vibe,
|
||||
now,
|
||||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount: normalizedMirrorAmount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWaitingForGestureAccent = false;
|
||||
if (
|
||||
strength >= STROKE_ACCENT_THRESHOLD &&
|
||||
now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS
|
||||
) {
|
||||
this.lastStrokeAccentAt = now;
|
||||
this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public renderLookahead({
|
||||
vibe,
|
||||
now,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
|
||||
}: RenderLookaheadRequest): void {
|
||||
this.prime(now);
|
||||
this.skipLateBeats(now, this.getBeatDurationSeconds());
|
||||
|
||||
if (this.nextBeatAt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const lookaheadEnd = now + lookaheadSeconds;
|
||||
while (this.nextBeatAt <= lookaheadEnd) {
|
||||
this.renderBeat({
|
||||
profile,
|
||||
beatIndex: this.beatIndex,
|
||||
startTime: this.nextBeatAt,
|
||||
expression: this.getExpression(activity),
|
||||
selectedColorIndex,
|
||||
});
|
||||
this.nextBeatAt += this.getBeatDurationSeconds();
|
||||
this.beatIndex += 1;
|
||||
}
|
||||
this.renderBrushPhraseLayers({
|
||||
vibe,
|
||||
now,
|
||||
lookaheadEnd,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
});
|
||||
}
|
||||
|
||||
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const chord = this.getChord(profile, 0);
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const notes = [
|
||||
{
|
||||
midi: this.chooseMidi({ baseMidi: rootMidi, offsets: [0] }, PAD_REGISTERS[0]),
|
||||
velocity: 0.1,
|
||||
pan: -0.16,
|
||||
delaySend: 0.012,
|
||||
},
|
||||
{
|
||||
midi: this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
|
||||
PAD_REGISTERS[1]
|
||||
),
|
||||
velocity: 0.085,
|
||||
pan: 0,
|
||||
delaySend: 0.014,
|
||||
},
|
||||
{
|
||||
midi: this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
||||
PAD_REGISTERS[2]
|
||||
),
|
||||
velocity: 0.07,
|
||||
pan: 0.16,
|
||||
delaySend: 0.016,
|
||||
},
|
||||
];
|
||||
|
||||
notes.forEach((note, index) => {
|
||||
this.playNote({
|
||||
...note,
|
||||
durationSeconds: STINGER_DURATION_SECONDS,
|
||||
lowpassHz: this.getLowpassHz(profile, note.midi, 0.35),
|
||||
startTime: now + index * STINGER_SPACING_SECONDS,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.nextBeatAt = null;
|
||||
this.timelineStartedAt = null;
|
||||
this.beatIndex = 0;
|
||||
this.isWaitingForGestureAccent = false;
|
||||
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastMidiByColor[0] = null;
|
||||
this.lastMidiByColor[1] = null;
|
||||
this.lastMidiByColor[2] = null;
|
||||
this.brushPhraseLayers = [];
|
||||
this.nextBrushStreamAt = null;
|
||||
this.brushStreamNoteIndex = 0;
|
||||
this.lastBrushStreamMidi = null;
|
||||
}
|
||||
|
||||
private renderBeat({
|
||||
profile,
|
||||
beatIndex,
|
||||
startTime,
|
||||
expression,
|
||||
selectedColorIndex,
|
||||
}: {
|
||||
profile: GardenAudioVibeProfile;
|
||||
beatIndex: number;
|
||||
startTime: number;
|
||||
expression: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
}): void {
|
||||
const beatsPerBar = this.getBeatsPerBar();
|
||||
const beatInBar = beatIndex % beatsPerBar;
|
||||
const barIndex = Math.floor(beatIndex / beatsPerBar);
|
||||
|
||||
if (beatInBar === 0 && barIndex % CHORD_BARS === 0) {
|
||||
this.playPadChord(profile, barIndex, startTime, expression);
|
||||
}
|
||||
|
||||
if (beatInBar === 0 && this.shouldPlaySupport(expression, barIndex)) {
|
||||
this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex);
|
||||
}
|
||||
|
||||
if (beatInBar === TEXTURE_BEAT && this.shouldPlayTexture(expression, barIndex)) {
|
||||
this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex);
|
||||
}
|
||||
|
||||
if (
|
||||
beatInBar === HIGH_ACTIVITY_EXTRA_BEAT &&
|
||||
expression >= HIGH_ACTIVITY_EXTRA_THRESHOLD
|
||||
) {
|
||||
this.playTextureNote(
|
||||
profile,
|
||||
barIndex + 1,
|
||||
startTime,
|
||||
expression * 0.9,
|
||||
selectedColorIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private playPadChord(
|
||||
profile: GardenAudioVibeProfile,
|
||||
barIndex: number,
|
||||
startTime: number,
|
||||
expression: number
|
||||
): void {
|
||||
const chord = this.getChord(profile, barIndex);
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * 0.88;
|
||||
const notes = [
|
||||
{
|
||||
source: { baseMidi: rootMidi, offsets: [0] },
|
||||
register: PAD_REGISTERS[0],
|
||||
velocity: 0.082,
|
||||
},
|
||||
{
|
||||
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
|
||||
register: PAD_REGISTERS[1],
|
||||
velocity: 0.064,
|
||||
},
|
||||
{
|
||||
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
||||
register: PAD_REGISTERS[2],
|
||||
velocity: 0.052,
|
||||
},
|
||||
];
|
||||
|
||||
notes.forEach(({ source, register, velocity }) => {
|
||||
const midi = this.chooseMidi(source, register);
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity: velocity + expression * 0.02,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan: register.pan,
|
||||
delaySend: 0.018,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.45),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private playSupportNote(
|
||||
profile: GardenAudioVibeProfile,
|
||||
barIndex: number,
|
||||
startTime: number,
|
||||
expression: number,
|
||||
selectedColorIndex: GardenAudioColorIndex
|
||||
): void {
|
||||
const pool = COLOR_POOLS[selectedColorIndex];
|
||||
const chord = this.getChord(profile, barIndex);
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const midi = this.chooseMidi(
|
||||
{
|
||||
baseMidi: rootMidi,
|
||||
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
|
||||
},
|
||||
pool,
|
||||
this.lastMidiByColor[selectedColorIndex],
|
||||
true
|
||||
);
|
||||
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.105 + expression * 0.07) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime,
|
||||
durationSeconds: 1.35 + expression * 0.4,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
delaySend: 0.016 + expression * 0.006,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.7),
|
||||
});
|
||||
}
|
||||
|
||||
private playTextureNote(
|
||||
profile: GardenAudioVibeProfile,
|
||||
barIndex: number,
|
||||
startTime: number,
|
||||
expression: number,
|
||||
selectedColorIndex: GardenAudioColorIndex
|
||||
): void {
|
||||
const pool = COLOR_POOLS[selectedColorIndex];
|
||||
const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex);
|
||||
const midi = this.chooseMidi(
|
||||
{
|
||||
baseMidi: profile.rootMidi,
|
||||
offsets: degrees.map((degree) => degreeToSemitone(profile, degree)),
|
||||
},
|
||||
pool,
|
||||
this.lastMidiByColor[selectedColorIndex],
|
||||
true
|
||||
);
|
||||
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.09 + expression * 0.08) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime,
|
||||
durationSeconds: 0.62 + expression * 0.24,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
delaySend: 0.016 + expression * 0.006,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, expression),
|
||||
});
|
||||
}
|
||||
|
||||
private playGestureAccent(
|
||||
vibe: VibePreset,
|
||||
now: number,
|
||||
selectedColorIndex: GardenAudioColorIndex,
|
||||
strength: number,
|
||||
noteCount: number
|
||||
): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const pool = COLOR_POOLS[selectedColorIndex];
|
||||
const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
|
||||
|
||||
for (let index = 0; index < noteCount; index += 1) {
|
||||
const midi = this.chooseMidi(
|
||||
{
|
||||
baseMidi: profile.rootMidi,
|
||||
offsets: degrees
|
||||
.slice(index)
|
||||
.concat(degrees.slice(0, index))
|
||||
.map((degree) => degreeToSemitone(profile, degree)),
|
||||
},
|
||||
pool,
|
||||
this.lastMidiByColor[selectedColorIndex],
|
||||
true
|
||||
);
|
||||
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.12 + strength * 0.09) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime:
|
||||
now +
|
||||
appConfig.audioEngine.startDelaySeconds +
|
||||
index * GESTURE_ACCENT_SPACING_SECONDS,
|
||||
durationSeconds: 0.48 + strength * 0.22,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
delaySend: 0.012,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, strength),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private playTouchNote(
|
||||
vibe: VibePreset,
|
||||
now: number,
|
||||
selectedColorIndex: GardenAudioColorIndex,
|
||||
strength: number
|
||||
): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const pool = COLOR_POOLS[selectedColorIndex];
|
||||
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const midi = this.chooseMidi(
|
||||
{
|
||||
baseMidi: rootMidi,
|
||||
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
|
||||
},
|
||||
pool,
|
||||
this.lastMidiByColor[selectedColorIndex],
|
||||
true
|
||||
);
|
||||
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.lastBrushStreamMidi = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.14 + strength * 0.11) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime: now,
|
||||
durationSeconds: 0.55 + strength * 0.18,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
delaySend: 0.006,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.45 + strength * 0.45)),
|
||||
});
|
||||
}
|
||||
|
||||
private startBrushPhraseLayer({
|
||||
vibe,
|
||||
now,
|
||||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
strength: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount: number;
|
||||
}): void {
|
||||
const lifetimeSeconds =
|
||||
BRUSH_LAYER_BASE_SECONDS +
|
||||
strength * BRUSH_LAYER_ENERGY_SECONDS +
|
||||
mirrorAmount * BRUSH_LAYER_MIRROR_SECONDS;
|
||||
|
||||
this.brushPhraseLayers.push({
|
||||
vibe,
|
||||
startedAt: now,
|
||||
expiresAt: now + lifetimeSeconds,
|
||||
selectedColorIndex,
|
||||
energy: strength,
|
||||
mirrorAmount,
|
||||
});
|
||||
|
||||
if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) {
|
||||
this.brushPhraseLayers = this.brushPhraseLayers.slice(-MAX_BRUSH_PHRASE_LAYERS);
|
||||
}
|
||||
}
|
||||
|
||||
private renderBrushPhraseLayers({
|
||||
vibe,
|
||||
now,
|
||||
lookaheadEnd,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
lookaheadEnd: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
}): void {
|
||||
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
|
||||
this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
|
||||
|
||||
this.brushPhraseLayers = this.brushPhraseLayers.filter(
|
||||
(layer) => layer.expiresAt > earliestStart
|
||||
);
|
||||
|
||||
while (this.nextBrushStreamAt < earliestStart) {
|
||||
const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
|
||||
this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
|
||||
this.brushStreamNoteIndex += 1;
|
||||
}
|
||||
|
||||
while (this.nextBrushStreamAt <= lookaheadEnd) {
|
||||
const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
|
||||
if (frame.intensity >= BRUSH_LAYER_MIN_INTENSITY) {
|
||||
this.playBrushStreamNote({
|
||||
vibe,
|
||||
startTime: this.nextBrushStreamAt,
|
||||
intensity: frame.intensity,
|
||||
selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex,
|
||||
});
|
||||
}
|
||||
this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
|
||||
this.brushStreamNoteIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private playBrushStreamNote({
|
||||
vibe,
|
||||
startTime,
|
||||
intensity,
|
||||
selectedColorIndex,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
startTime: number;
|
||||
intensity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
}): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const pool = COLOR_POOLS[selectedColorIndex];
|
||||
const chord = this.getChord(profile, this.getGlobalBarIndex(startTime));
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const useChordTone = this.brushStreamNoteIndex % 4 === 0;
|
||||
const source = useChordTone
|
||||
? {
|
||||
baseMidi: rootMidi,
|
||||
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
|
||||
}
|
||||
: {
|
||||
baseMidi: profile.rootMidi,
|
||||
offsets: this.rotate(
|
||||
pool.scaleDegrees,
|
||||
this.brushStreamNoteIndex + selectedColorIndex
|
||||
).map((degree) => degreeToSemitone(profile, degree)),
|
||||
};
|
||||
const midi = this.chooseMidi(source, pool, this.lastBrushStreamMidi, true);
|
||||
|
||||
this.lastBrushStreamMidi = midi;
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.1 + intensity * 0.13) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime,
|
||||
durationSeconds: 0.42 + intensity * 0.22,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
delaySend: 0.012 + intensity * 0.01,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.35 + intensity * 0.65)),
|
||||
});
|
||||
}
|
||||
|
||||
private getBrushStreamFrame(
|
||||
startTime: number,
|
||||
activity: number
|
||||
): {
|
||||
intensity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex | null;
|
||||
} {
|
||||
const layerStates = this.brushPhraseLayers.map((layer) => ({
|
||||
layer,
|
||||
intensity:
|
||||
layer.energy *
|
||||
this.getBrushPhraseFade(layer, startTime) *
|
||||
(0.8 + layer.mirrorAmount * 0.45),
|
||||
}));
|
||||
const dominant = layerStates.reduce<
|
||||
{ layer: BrushPhraseLayer; intensity: number } | null
|
||||
>((best, state) => {
|
||||
if (state.intensity <= 0) {
|
||||
return best;
|
||||
}
|
||||
return best === null || state.intensity > best.intensity ? state : best;
|
||||
}, null);
|
||||
const layeredIntensity = layerStates.reduce(
|
||||
(sum, state) => sum + Math.max(0, state.intensity),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
intensity: clamp01(activity * 0.45 + layeredIntensity),
|
||||
selectedColorIndex: dominant?.layer.selectedColorIndex ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private getBrushStreamIntervalSeconds(intensity: number): number {
|
||||
const intervalBeats =
|
||||
intensity >= 0.85
|
||||
? BRUSH_STREAM_MANIC_INTERVAL_BEATS
|
||||
: intensity >= 0.62
|
||||
? BRUSH_STREAM_INTENSE_INTERVAL_BEATS
|
||||
: intensity >= 0.34
|
||||
? BRUSH_STREAM_ACTIVE_INTERVAL_BEATS
|
||||
: BRUSH_STREAM_IDLE_INTERVAL_BEATS;
|
||||
return this.getBeatDurationSeconds() * intervalBeats;
|
||||
}
|
||||
|
||||
private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number {
|
||||
const lifetimeSeconds = layer.expiresAt - layer.startedAt;
|
||||
const ageSeconds = startTime - layer.startedAt;
|
||||
return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds));
|
||||
}
|
||||
|
||||
private chooseMidi(
|
||||
pitchSource: PitchSource,
|
||||
register: Register,
|
||||
previousMidi: number | null = null,
|
||||
avoidRepeat = false
|
||||
): number {
|
||||
const candidates = this.getCandidates(pitchSource, register);
|
||||
const referenceMidi = previousMidi ?? register.preferredMidi;
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return register.preferredMidi;
|
||||
}
|
||||
|
||||
return candidates.reduce((best, candidate) =>
|
||||
this.scoreCandidate(candidate, register, referenceMidi, avoidRepeat) <
|
||||
this.scoreCandidate(best, register, referenceMidi, avoidRepeat)
|
||||
? candidate
|
||||
: best
|
||||
).midi;
|
||||
}
|
||||
|
||||
private getCandidates(
|
||||
pitchSource: PitchSource,
|
||||
register: Register
|
||||
): Array<PitchCandidate> {
|
||||
const candidates: Array<PitchCandidate> = [];
|
||||
|
||||
pitchSource.offsets.forEach((offset, preference) => {
|
||||
for (let octave = -3; octave <= 3; octave += 1) {
|
||||
const midi = pitchSource.baseMidi + offset + octave * 12;
|
||||
if (midi >= register.midiMin && midi <= register.midiMax) {
|
||||
candidates.push({ midi: Math.round(midi), preference });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private scoreCandidate(
|
||||
candidate: PitchCandidate,
|
||||
register: Register,
|
||||
previousMidi: number,
|
||||
avoidRepeat: boolean
|
||||
): number {
|
||||
return (
|
||||
Math.abs(candidate.midi - previousMidi) +
|
||||
Math.abs(candidate.midi - register.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
|
||||
candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT +
|
||||
(avoidRepeat && candidate.midi === previousMidi ? NOTE_SCORE_REPEAT_PENALTY : 0)
|
||||
);
|
||||
}
|
||||
|
||||
private shouldPlaySupport(expression: number, barIndex: number): boolean {
|
||||
if (expression >= 0.55) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return barIndex % SUPPORT_BAR_SPACING === SUPPORT_BAR_OFFSET;
|
||||
}
|
||||
|
||||
private shouldPlayTexture(expression: number, barIndex: number): boolean {
|
||||
const spacing =
|
||||
expression < 0.35
|
||||
? IDLE_TEXTURE_BAR_SPACING
|
||||
: expression < 0.7
|
||||
? MEDIUM_TEXTURE_BAR_SPACING
|
||||
: 1;
|
||||
|
||||
return barIndex % spacing === (spacing === 1 ? 0 : 1);
|
||||
}
|
||||
|
||||
private getSupportOffsets(
|
||||
chordIntervals: ReadonlyArray<number>,
|
||||
selectedColorIndex: GardenAudioColorIndex
|
||||
): Array<number> {
|
||||
if (selectedColorIndex === 0) {
|
||||
return [0, chordIntervals[2], 12];
|
||||
}
|
||||
|
||||
if (selectedColorIndex === 1) {
|
||||
return [chordIntervals[1], chordIntervals[2], 0, 12];
|
||||
}
|
||||
|
||||
return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12];
|
||||
}
|
||||
|
||||
private getChord(
|
||||
profile: GardenAudioVibeProfile,
|
||||
barIndex: number
|
||||
): GardenAudioChord {
|
||||
const progressionIndex =
|
||||
Math.floor(barIndex / CHORD_BARS) % profile.progression.length;
|
||||
return profile.progression[progressionIndex];
|
||||
}
|
||||
|
||||
private getGlobalBarIndex(startTime: number): number {
|
||||
const timelineStartedAt = this.timelineStartedAt ?? startTime;
|
||||
const elapsedSeconds = Math.max(0, startTime - timelineStartedAt);
|
||||
return Math.floor(elapsedSeconds / this.getBarDurationSeconds());
|
||||
}
|
||||
|
||||
private getColorPan(selectedColorIndex: GardenAudioColorIndex): number {
|
||||
const pool = COLOR_POOLS[selectedColorIndex];
|
||||
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
||||
return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1);
|
||||
}
|
||||
|
||||
private getLowpassHz(
|
||||
profile: GardenAudioVibeProfile,
|
||||
midi: number,
|
||||
expression: number
|
||||
): number {
|
||||
const midiLift = clamp01((midi - 48) / 33) * 720;
|
||||
return clamp(
|
||||
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
|
||||
midiLift,
|
||||
appConfig.audioEngine.piano.lowpassMinHz,
|
||||
appConfig.audioEngine.piano.lowpassMaxHz
|
||||
);
|
||||
}
|
||||
|
||||
private skipLateBeats(now: number, beatSeconds: number): void {
|
||||
if (this.nextBeatAt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
|
||||
if (this.nextBeatAt >= earliestStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skippedBeats = Math.floor((earliestStart - this.nextBeatAt) / beatSeconds) + 1;
|
||||
this.nextBeatAt += skippedBeats * beatSeconds;
|
||||
this.beatIndex += skippedBeats;
|
||||
}
|
||||
|
||||
private getExpression(activity: number): number {
|
||||
return clamp01(
|
||||
(activity - this.config.rhythm.sparseActivity) /
|
||||
(1 - this.config.rhythm.sparseActivity)
|
||||
);
|
||||
}
|
||||
|
||||
private getBeatDurationSeconds(): number {
|
||||
return 60 / this.config.rhythm.bpm;
|
||||
}
|
||||
|
||||
private getBarDurationSeconds(): number {
|
||||
return this.getBeatDurationSeconds() * this.getBeatsPerBar();
|
||||
}
|
||||
|
||||
private getBeatsPerBar(): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(this.config.rhythm.stepsPerBar / this.config.rhythm.stepsPerBeat)
|
||||
);
|
||||
}
|
||||
|
||||
private rotate<T>(values: ReadonlyArray<T>, offset: number): Array<T> {
|
||||
return values.map((_, index) => values[(index + offset) % values.length]);
|
||||
}
|
||||
}
|
||||
65
src/audio/noise-burst-player.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { NoiseBurst } from './garden-audio-types';
|
||||
|
||||
export class NoiseBurstPlayer {
|
||||
public constructor(
|
||||
private readonly engineConfig: GardenAudioEngineConfig,
|
||||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||
const { context, eventBus, noiseBuffer } = this.graph;
|
||||
if (!context || !eventBus || !noiseBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds,
|
||||
startTime
|
||||
);
|
||||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const envelope = context.createGain();
|
||||
const panner = context.createStereoPanner();
|
||||
const stopAt = scheduledStart + durationSeconds;
|
||||
|
||||
source.buffer = noiseBuffer;
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
|
||||
envelope.gain.setValueAtTime(
|
||||
this.engineConfig.noiseBurst.silentGain,
|
||||
scheduledStart
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
|
||||
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
this.engineConfig.noiseBurst.silentGain,
|
||||
stopAt
|
||||
);
|
||||
panner.pan.setValueAtTime(pan, scheduledStart);
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
source.start(
|
||||
scheduledStart,
|
||||
Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds
|
||||
);
|
||||
source.stop(stopAt);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
285
src/audio/piano-sampler.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { pianoSampleDefinitions } from './piano-samples';
|
||||
|
||||
export class PianoSampler {
|
||||
private sampleLoadPromise: Promise<void> | null = null;
|
||||
private samples: Array<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly engineConfig: GardenAudioEngineConfig,
|
||||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public async load(context: AudioContext): Promise<void> {
|
||||
if (this.sampleLoadPromise) {
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
this.sampleLoadPromise = Promise.all(
|
||||
pianoSampleDefinitions.map(async (sample) => {
|
||||
const response = await fetch(sample.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${sample.url}`);
|
||||
}
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await context.decodeAudioData(audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
})
|
||||
)
|
||||
.then((samples) => {
|
||||
this.samples = samples.sort((a, b) => a.midi - b.midi);
|
||||
})
|
||||
.catch(() => {
|
||||
this.samples = [];
|
||||
});
|
||||
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
public play({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
}: PianoNote): void {
|
||||
const { context, eventBus, delayInput } = this.graph;
|
||||
if (!context || !eventBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = this.findNearestSample(midi);
|
||||
if (!sample) {
|
||||
this.playFallbackPluck({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
delaySend,
|
||||
lowpassHz,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = Math.max(
|
||||
this.engineConfig.piano.minGain,
|
||||
this.config.piano.gain * noteVelocity
|
||||
);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds *
|
||||
(this.engineConfig.piano.sustainBase +
|
||||
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||
const stopAt = releaseAt + releaseSeconds;
|
||||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const gain = context.createGain();
|
||||
const panner = context.createStereoPanner();
|
||||
let sendGain: GainNode | null = null;
|
||||
|
||||
this.trimActiveVoices(scheduledStart);
|
||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
const oldest = this.activeVoices.shift();
|
||||
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
||||
oldest?.gain.gain.setTargetAtTime(
|
||||
this.engineConfig.piano.minGain,
|
||||
scheduledStart,
|
||||
this.engineConfig.piano.voiceStealFadeSeconds
|
||||
);
|
||||
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
|
||||
}
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(
|
||||
2,
|
||||
(midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave
|
||||
),
|
||||
scheduledStart
|
||||
);
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(
|
||||
lowpassHz,
|
||||
this.engineConfig.piano.lowpassMinHz,
|
||||
this.engineConfig.piano.lowpassMaxHz
|
||||
),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = this.engineConfig.piano.filterQ;
|
||||
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.engineConfig.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
this.engineConfig.piano.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
this.engineConfig.piano.minFadeSeconds,
|
||||
sustainSeconds * this.engineConfig.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
this.engineConfig.piano.minGain,
|
||||
releaseAt,
|
||||
releaseSeconds
|
||||
);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
|
||||
if (delayInput && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panner.connect(sendGain);
|
||||
sendGain.connect(delayInput);
|
||||
}
|
||||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panner.disconnect();
|
||||
sendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.sampleLoadPromise = null;
|
||||
this.samples = [];
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||
if (this.samples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.samples.reduce((nearest, sample) =>
|
||||
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
|
||||
);
|
||||
}
|
||||
|
||||
private trimActiveVoices(now: number): void {
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
||||
}
|
||||
|
||||
private playFallbackPluck({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
}: PianoNote): void {
|
||||
const { context, eventBus, delayInput } = this.graph;
|
||||
if (!context || !eventBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
|
||||
startTime
|
||||
);
|
||||
const oscillator = context.createOscillator();
|
||||
const filter = context.createBiquadFilter();
|
||||
const gain = context.createGain();
|
||||
const panner = context.createStereoPanner();
|
||||
let sendGain: GainNode | null = null;
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = Math.max(
|
||||
this.engineConfig.piano.minGain,
|
||||
this.config.piano.gain * noteVelocity * 0.42
|
||||
);
|
||||
const releaseAt =
|
||||
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
|
||||
oscillator.type = 'triangle';
|
||||
oscillator.frequency.setValueAtTime(
|
||||
440 * Math.pow(2, (midi - 69) / appConfig.audioEngine.piano.pitchSemitonesPerOctave),
|
||||
scheduledStart
|
||||
);
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(
|
||||
lowpassHz * 0.72,
|
||||
this.engineConfig.piano.lowpassMinHz,
|
||||
this.engineConfig.piano.lowpassMaxHz
|
||||
),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = this.engineConfig.piano.filterQ;
|
||||
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.engineConfig.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
this.engineConfig.piano.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
|
||||
oscillator.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
|
||||
if (delayInput && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend * 0.5;
|
||||
panner.connect(sendGain);
|
||||
sendGain.connect(delayInput);
|
||||
}
|
||||
|
||||
oscillator.start(scheduledStart);
|
||||
oscillator.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
|
||||
oscillator.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
oscillator.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panner.disconnect();
|
||||
sendGain?.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/audio/piano-samples.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
interface PianoSampleDefinition {
|
||||
midi: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
||||
|
||||
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||
['A0v12.m4a', 21],
|
||||
['C1v12.m4a', 24],
|
||||
['Dsharp1v12.m4a', 27],
|
||||
['Fsharp1v12.m4a', 30],
|
||||
['A1v12.m4a', 33],
|
||||
['C2v12.m4a', 36],
|
||||
['Dsharp2v12.m4a', 39],
|
||||
['Fsharp2v12.m4a', 42],
|
||||
['A2v12.m4a', 45],
|
||||
['C3v12.m4a', 48],
|
||||
['Dsharp3v12.m4a', 51],
|
||||
['Fsharp3v12.m4a', 54],
|
||||
['A3v12.m4a', 57],
|
||||
['C4v12.m4a', 60],
|
||||
['Dsharp4v12.m4a', 63],
|
||||
['Fsharp4v12.m4a', 66],
|
||||
['A4v12.m4a', 69],
|
||||
['C5v12.m4a', 72],
|
||||
['Dsharp5v12.m4a', 75],
|
||||
['Fsharp5v12.m4a', 78],
|
||||
['A5v12.m4a', 81],
|
||||
['C6v12.m4a', 84],
|
||||
['Dsharp6v12.m4a', 87],
|
||||
['Fsharp6v12.m4a', 90],
|
||||
['A6v12.m4a', 93],
|
||||
['C7v12.m4a', 96],
|
||||
['Dsharp7v12.m4a', 99],
|
||||
['Fsharp7v12.m4a', 102],
|
||||
['A7v12.m4a', 105],
|
||||
['C8v12.m4a', 108],
|
||||
];
|
||||
|
||||
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||
.map(([fileName, midi]) => ({
|
||||
midi,
|
||||
url: `${sampleBaseUrl}${fileName}`,
|
||||
}))
|
||||
.sort((a, b) => a.midi - b.midi);
|
||||
292
src/config.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { runtimeSettings } from './config/runtime-settings';
|
||||
import type { GardenAppConfig } from './config/types';
|
||||
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||
|
||||
export type {
|
||||
AgentColorInteractionSettings,
|
||||
GardenAppConfig,
|
||||
GardenAudioEngineConfig,
|
||||
GardenRuntimeSettings,
|
||||
GardenSimulationConfig,
|
||||
GardenStorageConfig,
|
||||
GardenVibeSettings,
|
||||
NumberControlConfig,
|
||||
RuntimeSettingControlConfig,
|
||||
VibePreset,
|
||||
} from './config/types';
|
||||
|
||||
export const appConfig = {
|
||||
audio: {
|
||||
masterVolume: 0.42,
|
||||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
highPassFrequencyHz: 45,
|
||||
fallbackVibeId: defaultVibeId,
|
||||
compressor: {
|
||||
thresholdDb: -18,
|
||||
kneeDb: 18,
|
||||
ratio: 2.4,
|
||||
attackSeconds: 0.006,
|
||||
releaseSeconds: 0.18,
|
||||
},
|
||||
delay: {
|
||||
timeSeconds: 0.46,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.044,
|
||||
},
|
||||
piano: {
|
||||
maxVoices: 24,
|
||||
gain: 0.48,
|
||||
sustainSeconds: 0.42,
|
||||
sustainLevel: 0.32,
|
||||
releaseSeconds: 0.24,
|
||||
lowpassHz: 7600,
|
||||
},
|
||||
input: {
|
||||
pressureFallback: 0.48,
|
||||
},
|
||||
rhythm: {
|
||||
bpm: 74,
|
||||
stepsPerBeat: 4,
|
||||
stepsPerBar: 16,
|
||||
lookaheadSeconds: 0.3,
|
||||
speedForFullEnergyPixelsPerSecond: 1800,
|
||||
sparseActivity: 0.055,
|
||||
},
|
||||
eraser: {
|
||||
minIntervalSeconds: 0.12,
|
||||
noiseGain: 0.028,
|
||||
filterMinHz: 650,
|
||||
filterMaxHz: 3600,
|
||||
},
|
||||
colorVoices: [
|
||||
{
|
||||
scaleDegreeOffset: 0,
|
||||
velocityMultiplier: 0.92,
|
||||
panOffset: -0.14,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 1,
|
||||
velocityMultiplier: 1,
|
||||
panOffset: 0,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 2,
|
||||
velocityMultiplier: 0.86,
|
||||
panOffset: 0.14,
|
||||
},
|
||||
],
|
||||
vibes: audioVibes,
|
||||
},
|
||||
audioEngine: {
|
||||
energy: {
|
||||
attackSeconds: 0.08,
|
||||
decaySeconds: 0.9,
|
||||
releaseSeconds: 1.15,
|
||||
strokeDecaySeconds: 0.32,
|
||||
},
|
||||
eraser: {
|
||||
canvasWidthRatioForFullSize: 0.18,
|
||||
defaultSizePixels: 96,
|
||||
durationSeconds: 0.08,
|
||||
filterPressureWeight: 0.26,
|
||||
filterSizeWeight: 0.16,
|
||||
filterSpeedWeight: 0.58,
|
||||
gainBase: 0.45,
|
||||
gainPressureWeight: 0.24,
|
||||
gainSizeWeight: 0.18,
|
||||
gainSpeedWeight: 0.38,
|
||||
},
|
||||
delay: {
|
||||
erasingActivity: 0.12,
|
||||
},
|
||||
graph: {
|
||||
closeGain: 0.0001,
|
||||
closeRampSeconds: 0.015,
|
||||
delayActivityFeedbackWeight: 0.08,
|
||||
delayFeedbackMax: 0.32,
|
||||
delayFeedbackMin: 0.04,
|
||||
delayOutputActivityWeight: 0.5,
|
||||
delayOutputBase: 0.65,
|
||||
delayTimeRampSeconds: 0.12,
|
||||
eventBusGain: 1,
|
||||
noiseMax: 1,
|
||||
noiseMin: -1,
|
||||
unlockBufferLength: 1,
|
||||
unlockSampleRate: 22050,
|
||||
},
|
||||
input: {
|
||||
distanceEnergyBase: 0.34,
|
||||
distanceEnergyScale: 0.66,
|
||||
distanceForFullEnergyPixels: 140,
|
||||
fallbackFrameSeconds: 1 / 60,
|
||||
penMinPressure: 0.56,
|
||||
strokeEnergyBase: 0.18,
|
||||
strokeEnergyPressureWeight: 0.22,
|
||||
strokeEnergySpeedWeight: 0.62,
|
||||
},
|
||||
muteGain: 0.0001,
|
||||
muteRampSeconds: 0.02,
|
||||
noiseBurst: {
|
||||
attackSeconds: 0.004,
|
||||
filterQ: 1.4,
|
||||
offsetRandomSeconds: 0.4,
|
||||
scheduleAheadSeconds: 0.002,
|
||||
silentGain: 0.0001,
|
||||
},
|
||||
piano: {
|
||||
filterQ: 0.7,
|
||||
gainAttackSeconds: 0.006,
|
||||
lowpassMaxHz: 12000,
|
||||
lowpassMinHz: 1400,
|
||||
minDurationSeconds: 0.08,
|
||||
minFadeSeconds: 0.08,
|
||||
minGain: 0.0001,
|
||||
pitchSemitonesPerOctave: 12,
|
||||
scheduleAheadSeconds: 0.002,
|
||||
sustainBase: 0.45,
|
||||
sustainVelocityRange: 0.55,
|
||||
tailStopExtraSeconds: 0.05,
|
||||
voiceStealFadeSeconds: 0.025,
|
||||
voiceStealStopSeconds: 0.05,
|
||||
},
|
||||
startDelaySeconds: 0.02,
|
||||
vibeChangeStingerMinIntervalSeconds: 0.45,
|
||||
},
|
||||
deltaTime: {
|
||||
fpsExponentialDecayStrength: 0.01,
|
||||
maxDeltaTimeSeconds: 1 / 30,
|
||||
minDeltaTimeSeconds: 1 / 240,
|
||||
},
|
||||
export4k: {
|
||||
bytesPerPixel: 4,
|
||||
height: 2160,
|
||||
jsHeapSafetyMultiplier: 1.5,
|
||||
lowMemoryDeviceGiB: 2,
|
||||
lowMemoryExportFraction: 0.08,
|
||||
rowAlignmentBytes: 256,
|
||||
width: 3840,
|
||||
},
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: 96,
|
||||
intervalMs: 50,
|
||||
timeToLiveMs: 3500,
|
||||
},
|
||||
pipelines: {
|
||||
brush: {
|
||||
maxLineCount: 240,
|
||||
},
|
||||
diffusion: {
|
||||
minDiffusionRate: 0.000001,
|
||||
},
|
||||
eraser: {
|
||||
maxSegmentCount: 384,
|
||||
maxTextureLineCount: 384,
|
||||
segmentFloatCount: 4,
|
||||
workgroupSize: 64,
|
||||
},
|
||||
},
|
||||
runtimeSettings,
|
||||
simulation: {
|
||||
budget: {
|
||||
adaptiveCapDecreaseAgentsPerSecond: 50_000,
|
||||
adaptiveCapMin: 500_000,
|
||||
fpsHeadroom: 0.95,
|
||||
fpsSmoothingNew: 0.06,
|
||||
fpsSmoothingRetain: 0.94,
|
||||
initialTargetAgentBudget: 20_000,
|
||||
rampAgentsPerSecond: 20_000,
|
||||
refreshTargetDecay: 0.995,
|
||||
},
|
||||
brushEffectFramesPerSecond: 60,
|
||||
globalAgentCap: 10_000_000,
|
||||
initialAgentCount: 180_000,
|
||||
intro: {
|
||||
angleJitterRadians: Math.PI * 0.08,
|
||||
circleMaxSideRatio: 0.46,
|
||||
circleMinSideRatio: 0.32,
|
||||
drawHintClass: 'draw-hint',
|
||||
drawHintDelayMs: 3000,
|
||||
durationSeconds: 4,
|
||||
entryJitterSideRatio: 0.035,
|
||||
fontScaleDown: 0.94,
|
||||
initialFontHeightRatio: 0.28,
|
||||
initialFontWidthRatio: 0.19,
|
||||
letterSpacingEm: 0.07,
|
||||
maskAlphaThreshold: 32,
|
||||
maskGradientThreshold: 8,
|
||||
maskSampleDensity: 540,
|
||||
maxHeightRatio: 0.25,
|
||||
maxWidthRatio: 0.76,
|
||||
minEntryJitterPx: 6,
|
||||
minFontSizePx: 18,
|
||||
minTargetJitterPx: 1,
|
||||
radialJitterRatio: 0.35,
|
||||
targetDelayDistanceMultiplier: 0.12,
|
||||
targetDelayMax: 0.22,
|
||||
targetDelayRandomMultiplier: 0.06,
|
||||
targetJitterSideRatio: 0.0035,
|
||||
title: 'Fleeting',
|
||||
titleColorCutLetters: [2, 5],
|
||||
titleRadiusMultiplier: 1.55,
|
||||
titleStrokeWidthMinPx: 6,
|
||||
titleStrokeWidthRatio: 0.11,
|
||||
verticalAnchor: 0.47,
|
||||
},
|
||||
introMoveSpeedBaseMultiplier: 1.8,
|
||||
introMoveSpeedProgressMultiplier: 0.35,
|
||||
maxMirrorSegmentCount: 12,
|
||||
stroke: {
|
||||
angleJitterRadians: Math.PI * 0.7,
|
||||
densityMultiplier: 110,
|
||||
maxAgentCount: 2_400,
|
||||
minAgentCount: 140,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
audioMutedKey: 'fleeting-garden:audio-muted',
|
||||
vibeKey: 'fleeting-garden:vibe',
|
||||
},
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
intervalMs: 1000,
|
||||
},
|
||||
toolbar: {
|
||||
eraser: {
|
||||
controlScaleMax: 1.34,
|
||||
controlScaleMin: 0.74,
|
||||
default: 96,
|
||||
max: 240,
|
||||
min: 24,
|
||||
step: 1,
|
||||
},
|
||||
mirror: {
|
||||
default: 1,
|
||||
max: 12,
|
||||
min: 1,
|
||||
names: {
|
||||
2: 'halves',
|
||||
3: 'thirds',
|
||||
4: 'quarters',
|
||||
5: 'fifths',
|
||||
6: 'sixths',
|
||||
7: 'sevenths',
|
||||
8: 'eighths',
|
||||
9: 'ninths',
|
||||
10: 'tenths',
|
||||
11: 'elevenths',
|
||||
12: 'twelfths',
|
||||
},
|
||||
step: 1,
|
||||
},
|
||||
},
|
||||
tuningPane: {
|
||||
expandedDepth: 1,
|
||||
startHidden: true,
|
||||
title: 'Garden Config',
|
||||
},
|
||||
vibes: {
|
||||
defaultVibeId,
|
||||
presets: vibePresets,
|
||||
},
|
||||
} satisfies GardenAppConfig;
|
||||
71
src/config/color-interactions.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type {
|
||||
AgentColorInteractionSettings,
|
||||
NumberControlConfig,
|
||||
} from './types';
|
||||
|
||||
const agentInteractionOptions: Record<string, number> = {
|
||||
Follow: 1,
|
||||
Avoid: -1,
|
||||
Ignore: 0,
|
||||
};
|
||||
|
||||
export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 0,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: 0,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 0,
|
||||
color3ToColor1: 0,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 1,
|
||||
};
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
const createSeededRandom = (seed: number): (() => number) => {
|
||||
let state = seed;
|
||||
return () => {
|
||||
let value = (state += 0x6d2b79f5);
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
|
||||
export const createColorInteractionSettings = (
|
||||
seedSource: string
|
||||
): AgentColorInteractionSettings => {
|
||||
const random = createSeededRandom(hashString(seedSource));
|
||||
const values = Object.values(agentInteractionOptions);
|
||||
const randomInteraction = () =>
|
||||
values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2;
|
||||
|
||||
return {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: randomInteraction(),
|
||||
color1ToColor3: randomInteraction(),
|
||||
color2ToColor1: randomInteraction(),
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: randomInteraction(),
|
||||
color3ToColor1: randomInteraction(),
|
||||
color3ToColor2: randomInteraction(),
|
||||
color3ToColor3: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||
folder: 'Color Reactions',
|
||||
label,
|
||||
min: -1,
|
||||
max: 1,
|
||||
step: 1,
|
||||
options: agentInteractionOptions,
|
||||
});
|
||||
206
src/config/runtime-settings.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import {
|
||||
colorInteractionControl,
|
||||
defaultColorInteractionSettings,
|
||||
} from './color-interactions';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
|
||||
defaults: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
agentCount: 0,
|
||||
selectedColorIndex: 0,
|
||||
spawnPerPixel: 0.22,
|
||||
|
||||
moveSpeed: 82,
|
||||
turnSpeed: 58,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 38,
|
||||
turnWhenLost: 0.8,
|
||||
|
||||
individualTrailWeight: 0.07,
|
||||
...defaultColorInteractionSettings,
|
||||
|
||||
diffusionRateTrails: 0.22,
|
||||
decayRateTrails: 965,
|
||||
diffusionRateBrush: 0.35,
|
||||
decayRateBrush: 18,
|
||||
brushEffectDuration: 8,
|
||||
|
||||
clarity: 0.62,
|
||||
brushSize: 14,
|
||||
brushCurveResolution: 12,
|
||||
eraserSize: 96,
|
||||
mirrorSegmentCount: 1,
|
||||
|
||||
brushSizeVariation: 0.5,
|
||||
|
||||
startColorHue: 200,
|
||||
|
||||
renderSpeed: 1,
|
||||
simulatedDelayMs: 0,
|
||||
},
|
||||
controls: {
|
||||
agentBudgetMax: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 500_000,
|
||||
max: 10_000_000,
|
||||
step: 50_000,
|
||||
},
|
||||
agentCount: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
step: 1_000,
|
||||
},
|
||||
color1ToColor1: colorInteractionControl('1 -> 1'),
|
||||
color1ToColor2: colorInteractionControl('1 -> 2'),
|
||||
color1ToColor3: colorInteractionControl('1 -> 3'),
|
||||
color2ToColor1: colorInteractionControl('2 -> 1'),
|
||||
color2ToColor2: colorInteractionControl('2 -> 2'),
|
||||
color2ToColor3: colorInteractionControl('2 -> 3'),
|
||||
color3ToColor1: colorInteractionControl('3 -> 1'),
|
||||
color3ToColor2: colorInteractionControl('3 -> 2'),
|
||||
color3ToColor3: colorInteractionControl('3 -> 3'),
|
||||
brushEffectDuration: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.5,
|
||||
max: 20,
|
||||
step: 0.05,
|
||||
},
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 0.25,
|
||||
},
|
||||
brushSizeVariation: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
brushCurveResolution: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
label: 'curve resolution',
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
},
|
||||
clarity: {
|
||||
folder: 'Render',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
decayRateBrush: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.1,
|
||||
max: 100,
|
||||
step: 0.1,
|
||||
},
|
||||
decayRateTrails: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
},
|
||||
diffusionRateBrush: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
diffusionRateTrails: {
|
||||
folder: 'Diffusion',
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.001,
|
||||
},
|
||||
eraserSize: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 24,
|
||||
max: 240,
|
||||
step: 1,
|
||||
},
|
||||
individualTrailWeight: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
mirrorSegmentCount: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 1,
|
||||
max: 12,
|
||||
step: 1,
|
||||
},
|
||||
moveSpeed: {
|
||||
folder: 'Agent',
|
||||
min: 10,
|
||||
max: 500,
|
||||
step: 1,
|
||||
},
|
||||
renderSpeed: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
},
|
||||
selectedColorIndex: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetAngle: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetDistance: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
simulatedDelayMs: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 2000,
|
||||
step: 1,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Agent',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
startColorHue: {
|
||||
folder: 'Render',
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
turnSpeed: {
|
||||
folder: 'Agent',
|
||||
min: 1,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
turnWhenLost: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
},
|
||||
};
|
||||
287
src/config/types.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import type {
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from '../audio/garden-audio-config';
|
||||
import type { GameLoopSettings } from '../game-loop/game-loop-settings';
|
||||
import type { AgentSettings } from '../pipelines/agents/agent-settings';
|
||||
import type { BrushSettings } from '../pipelines/brush/brush-settings';
|
||||
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
|
||||
import type { RenderSettings } from '../pipelines/render/render-settings';
|
||||
|
||||
export type GardenRuntimeSettings = GameLoopSettings &
|
||||
AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
RenderSettings;
|
||||
|
||||
export type AgentColorInteractionSettings = Pick<
|
||||
AgentSettings,
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
>;
|
||||
|
||||
export type GardenVibeSettings = Partial<
|
||||
Pick<
|
||||
GardenRuntimeSettings,
|
||||
| 'agentBudgetMax'
|
||||
| 'brushSize'
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
| 'clarity'
|
||||
| 'decayRateTrails'
|
||||
| 'diffusionRateTrails'
|
||||
| 'individualTrailWeight'
|
||||
| 'moveSpeed'
|
||||
| 'sensorOffsetAngle'
|
||||
| 'sensorOffsetDistance'
|
||||
| 'spawnPerPixel'
|
||||
| 'turnSpeed'
|
||||
>
|
||||
>;
|
||||
|
||||
export interface VibePreset {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: [string, string, string];
|
||||
backgroundColor: string;
|
||||
settings: GardenVibeSettings;
|
||||
audio: GardenAudioVibeProfile;
|
||||
}
|
||||
|
||||
export interface NumberControlConfig {
|
||||
folder: string;
|
||||
integer?: boolean;
|
||||
label?: string;
|
||||
max: number;
|
||||
min: number;
|
||||
options?: Record<string, number>;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export type RuntimeSettingControlConfig = {
|
||||
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
|
||||
};
|
||||
|
||||
export interface GardenAppConfig {
|
||||
audio: GardenAudioConfig;
|
||||
audioEngine: {
|
||||
energy: {
|
||||
attackSeconds: number;
|
||||
decaySeconds: number;
|
||||
releaseSeconds: number;
|
||||
strokeDecaySeconds: number;
|
||||
};
|
||||
eraser: {
|
||||
canvasWidthRatioForFullSize: number;
|
||||
defaultSizePixels: number;
|
||||
durationSeconds: number;
|
||||
filterPressureWeight: number;
|
||||
filterSizeWeight: number;
|
||||
filterSpeedWeight: number;
|
||||
gainBase: number;
|
||||
gainPressureWeight: number;
|
||||
gainSizeWeight: number;
|
||||
gainSpeedWeight: number;
|
||||
};
|
||||
delay: {
|
||||
erasingActivity: number;
|
||||
};
|
||||
graph: {
|
||||
closeGain: number;
|
||||
closeRampSeconds: number;
|
||||
delayActivityFeedbackWeight: number;
|
||||
delayFeedbackMax: number;
|
||||
delayFeedbackMin: number;
|
||||
delayOutputActivityWeight: number;
|
||||
delayOutputBase: number;
|
||||
delayTimeRampSeconds: number;
|
||||
eventBusGain: number;
|
||||
noiseMax: number;
|
||||
noiseMin: number;
|
||||
unlockBufferLength: number;
|
||||
unlockSampleRate: number;
|
||||
};
|
||||
input: {
|
||||
distanceEnergyBase: number;
|
||||
distanceEnergyScale: number;
|
||||
distanceForFullEnergyPixels: number;
|
||||
fallbackFrameSeconds: number;
|
||||
penMinPressure: number;
|
||||
strokeEnergyBase: number;
|
||||
strokeEnergyPressureWeight: number;
|
||||
strokeEnergySpeedWeight: number;
|
||||
};
|
||||
muteGain: number;
|
||||
muteRampSeconds: number;
|
||||
noiseBurst: {
|
||||
attackSeconds: number;
|
||||
filterQ: number;
|
||||
offsetRandomSeconds: number;
|
||||
scheduleAheadSeconds: number;
|
||||
silentGain: number;
|
||||
};
|
||||
piano: {
|
||||
filterQ: number;
|
||||
gainAttackSeconds: number;
|
||||
lowpassMaxHz: number;
|
||||
lowpassMinHz: number;
|
||||
minDurationSeconds: number;
|
||||
minFadeSeconds: number;
|
||||
minGain: number;
|
||||
pitchSemitonesPerOctave: number;
|
||||
scheduleAheadSeconds: number;
|
||||
sustainBase: number;
|
||||
sustainVelocityRange: number;
|
||||
tailStopExtraSeconds: number;
|
||||
voiceStealFadeSeconds: number;
|
||||
voiceStealStopSeconds: number;
|
||||
};
|
||||
startDelaySeconds: number;
|
||||
vibeChangeStingerMinIntervalSeconds: number;
|
||||
};
|
||||
deltaTime: {
|
||||
fpsExponentialDecayStrength: number;
|
||||
maxDeltaTimeSeconds: number;
|
||||
minDeltaTimeSeconds: number;
|
||||
};
|
||||
export4k: {
|
||||
bytesPerPixel: number;
|
||||
height: number;
|
||||
jsHeapSafetyMultiplier: number;
|
||||
lowMemoryDeviceGiB: number;
|
||||
lowMemoryExportFraction: number;
|
||||
rowAlignmentBytes: number;
|
||||
width: number;
|
||||
};
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: number;
|
||||
intervalMs: number;
|
||||
timeToLiveMs: number;
|
||||
};
|
||||
pipelines: {
|
||||
brush: {
|
||||
maxLineCount: number;
|
||||
};
|
||||
diffusion: {
|
||||
minDiffusionRate: number;
|
||||
};
|
||||
eraser: {
|
||||
maxSegmentCount: number;
|
||||
maxTextureLineCount: number;
|
||||
segmentFloatCount: number;
|
||||
workgroupSize: number;
|
||||
};
|
||||
};
|
||||
runtimeSettings: {
|
||||
controls: RuntimeSettingControlConfig;
|
||||
defaults: GardenRuntimeSettings;
|
||||
};
|
||||
simulation: {
|
||||
budget: {
|
||||
adaptiveCapDecreaseAgentsPerSecond: number;
|
||||
adaptiveCapMin: number;
|
||||
fpsHeadroom: number;
|
||||
fpsSmoothingNew: number;
|
||||
fpsSmoothingRetain: number;
|
||||
initialTargetAgentBudget: number;
|
||||
rampAgentsPerSecond: number;
|
||||
refreshTargetDecay: number;
|
||||
};
|
||||
brushEffectFramesPerSecond: number;
|
||||
globalAgentCap: number;
|
||||
initialAgentCount: number;
|
||||
intro: {
|
||||
angleJitterRadians: number;
|
||||
circleMaxSideRatio: number;
|
||||
circleMinSideRatio: number;
|
||||
drawHintClass: string;
|
||||
drawHintDelayMs: number;
|
||||
durationSeconds: number;
|
||||
entryJitterSideRatio: number;
|
||||
fontScaleDown: number;
|
||||
initialFontHeightRatio: number;
|
||||
initialFontWidthRatio: number;
|
||||
letterSpacingEm: number;
|
||||
maskAlphaThreshold: number;
|
||||
maskGradientThreshold: number;
|
||||
maskSampleDensity: number;
|
||||
maxHeightRatio: number;
|
||||
maxWidthRatio: number;
|
||||
minEntryJitterPx: number;
|
||||
minFontSizePx: number;
|
||||
minTargetJitterPx: number;
|
||||
radialJitterRatio: number;
|
||||
targetDelayDistanceMultiplier: number;
|
||||
targetDelayMax: number;
|
||||
targetDelayRandomMultiplier: number;
|
||||
targetJitterSideRatio: number;
|
||||
title: string;
|
||||
titleColorCutLetters: [number, number];
|
||||
titleRadiusMultiplier: number;
|
||||
titleStrokeWidthMinPx: number;
|
||||
titleStrokeWidthRatio: number;
|
||||
verticalAnchor: number;
|
||||
};
|
||||
introMoveSpeedBaseMultiplier: number;
|
||||
introMoveSpeedProgressMultiplier: number;
|
||||
maxMirrorSegmentCount: number;
|
||||
stroke: {
|
||||
angleJitterRadians: number;
|
||||
densityMultiplier: number;
|
||||
maxAgentCount: number;
|
||||
minAgentCount: number;
|
||||
};
|
||||
};
|
||||
storage: {
|
||||
audioMutedKey: string;
|
||||
vibeKey: string;
|
||||
};
|
||||
telemetry: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
};
|
||||
toolbar: {
|
||||
eraser: {
|
||||
controlScaleMax: number;
|
||||
controlScaleMin: number;
|
||||
default: number;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
};
|
||||
mirror: {
|
||||
default: number;
|
||||
max: number;
|
||||
min: number;
|
||||
names: Record<number, string>;
|
||||
step: number;
|
||||
};
|
||||
};
|
||||
tuningPane: {
|
||||
expandedDepth: number;
|
||||
startHidden: boolean;
|
||||
title: string;
|
||||
};
|
||||
vibes: {
|
||||
defaultVibeId: string;
|
||||
presets: Array<VibePreset>;
|
||||
};
|
||||
}
|
||||
|
||||
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
|
||||
export type GardenSimulationConfig = GardenAppConfig['simulation'];
|
||||
export type GardenStorageConfig = GardenAppConfig['storage'];
|
||||
204
src/config/vibe-presets.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import type {
|
||||
GardenAudioChord,
|
||||
GardenAudioVibeProfile,
|
||||
} from '../audio/garden-audio-config';
|
||||
import { createColorInteractionSettings } from './color-interactions';
|
||||
import type { VibePreset } from './types';
|
||||
|
||||
const majorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
];
|
||||
|
||||
const minorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
];
|
||||
|
||||
const majorPentatonic = [0, 2, 4, 7, 9];
|
||||
const minorPentatonic = [0, 3, 5, 7, 10];
|
||||
|
||||
export const defaultVibeId = 'candy-rain';
|
||||
|
||||
export const vibePresets: Array<VibePreset> = [
|
||||
{
|
||||
id: 'candy-rain',
|
||||
name: 'Candy Rain',
|
||||
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
|
||||
backgroundColor: '#10151f',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 14,
|
||||
clarity: 0.62,
|
||||
decayRateTrails: 965,
|
||||
diffusionRateTrails: 0.22,
|
||||
individualTrailWeight: 0.07,
|
||||
moveSpeed: 82,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 38,
|
||||
spawnPerPixel: 0.22,
|
||||
turnSpeed: 58,
|
||||
...createColorInteractionSettings('candy-rain'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 57,
|
||||
scale: majorPentatonic,
|
||||
brightness: 1.04,
|
||||
delayTimeMultiplier: 0.92,
|
||||
progression: majorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sunlit-moss',
|
||||
name: 'Sunlit Moss',
|
||||
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
||||
backgroundColor: '#172016',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 16,
|
||||
clarity: 0.68,
|
||||
decayRateTrails: 975,
|
||||
diffusionRateTrails: 0.18,
|
||||
individualTrailWeight: 0.06,
|
||||
moveSpeed: 70,
|
||||
sensorOffsetAngle: 28,
|
||||
sensorOffsetDistance: 46,
|
||||
spawnPerPixel: 0.18,
|
||||
turnSpeed: 44,
|
||||
...createColorInteractionSettings('sunlit-moss'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 53,
|
||||
scale: majorPentatonic,
|
||||
brightness: 0.92,
|
||||
delayTimeMultiplier: 1.08,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'coral-tide',
|
||||
name: 'Coral Tide',
|
||||
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
|
||||
backgroundColor: '#0f1822',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 13,
|
||||
clarity: 0.58,
|
||||
decayRateTrails: 955,
|
||||
diffusionRateTrails: 0.28,
|
||||
individualTrailWeight: 0.055,
|
||||
moveSpeed: 90,
|
||||
sensorOffsetAngle: 36,
|
||||
sensorOffsetDistance: 35,
|
||||
spawnPerPixel: 0.25,
|
||||
turnSpeed: 62,
|
||||
...createColorInteractionSettings('coral-tide'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 50,
|
||||
scale: minorPentatonic,
|
||||
brightness: 1,
|
||||
delayTimeMultiplier: 1.12,
|
||||
progression: minorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'moon-orchid',
|
||||
name: 'Moon Orchid',
|
||||
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
|
||||
backgroundColor: '#14121d',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 12,
|
||||
clarity: 0.64,
|
||||
decayRateTrails: 968,
|
||||
diffusionRateTrails: 0.2,
|
||||
individualTrailWeight: 0.065,
|
||||
moveSpeed: 76,
|
||||
sensorOffsetAngle: 32,
|
||||
sensorOffsetDistance: 42,
|
||||
spawnPerPixel: 0.2,
|
||||
turnSpeed: 52,
|
||||
...createColorInteractionSettings('moon-orchid'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 49,
|
||||
scale: minorPentatonic,
|
||||
brightness: 0.9,
|
||||
delayTimeMultiplier: 1.24,
|
||||
progression: minorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'peach-neon',
|
||||
name: 'Peach Neon',
|
||||
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
|
||||
backgroundColor: '#191716',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 15,
|
||||
clarity: 0.55,
|
||||
decayRateTrails: 948,
|
||||
diffusionRateTrails: 0.32,
|
||||
individualTrailWeight: 0.05,
|
||||
moveSpeed: 96,
|
||||
sensorOffsetAngle: 40,
|
||||
sensorOffsetDistance: 32,
|
||||
spawnPerPixel: 0.24,
|
||||
turnSpeed: 70,
|
||||
...createColorInteractionSettings('peach-neon'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 56,
|
||||
scale: majorPentatonic,
|
||||
brightness: 1.08,
|
||||
delayTimeMultiplier: 0.86,
|
||||
progression: majorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'frost-bloom',
|
||||
name: 'Frost Bloom',
|
||||
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
|
||||
backgroundColor: '#101820',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 18,
|
||||
clarity: 0.7,
|
||||
decayRateTrails: 982,
|
||||
diffusionRateTrails: 0.14,
|
||||
individualTrailWeight: 0.075,
|
||||
moveSpeed: 62,
|
||||
sensorOffsetAngle: 26,
|
||||
sensorOffsetDistance: 52,
|
||||
spawnPerPixel: 0.16,
|
||||
turnSpeed: 40,
|
||||
...createColorInteractionSettings('frost-bloom'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 62,
|
||||
scale: majorPentatonic,
|
||||
brightness: 0.88,
|
||||
delayTimeMultiplier: 1.32,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const audioVibes = Object.fromEntries(
|
||||
vibePresets.map((vibe) => [vibe.id, vibe.audio])
|
||||
) as Record<string, GardenAudioVibeProfile>;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const isProduction: boolean = import.meta.env.PROD;
|
||||
86
src/game-loop/agent-population.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
|
||||
const originalAgentBudgetMax = settings.agentBudgetMax;
|
||||
const originalBrushSize = settings.brushSize;
|
||||
const originalSelectedColorIndex = settings.selectedColorIndex;
|
||||
const originalSpawnPerPixel = settings.spawnPerPixel;
|
||||
|
||||
const createPopulation = () => {
|
||||
const pipeline = {
|
||||
maxAgentCount: 10_000_000,
|
||||
writeAgents: vi.fn(),
|
||||
resizeAgents: vi.fn(),
|
||||
compactAgents: vi.fn(),
|
||||
} as unknown as AgentGenerationPipeline;
|
||||
|
||||
return new AgentPopulation(pipeline);
|
||||
};
|
||||
|
||||
const setPopulationCounts = (
|
||||
population: AgentPopulation,
|
||||
activeCount: number,
|
||||
targetBudget: number
|
||||
) => {
|
||||
Object.assign(population as unknown as Record<string, number>, {
|
||||
activeCount,
|
||||
targetBudget,
|
||||
});
|
||||
};
|
||||
|
||||
describe('AgentPopulation adaptive budget', () => {
|
||||
beforeEach(() => {
|
||||
settings.agentBudgetMax = 1_000_000;
|
||||
settings.brushSize = 1;
|
||||
settings.selectedColorIndex = 0;
|
||||
settings.spawnPerPixel = 1;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
settings.agentBudgetMax = originalAgentBudgetMax;
|
||||
settings.brushSize = originalBrushSize;
|
||||
settings.selectedColorIndex = originalSelectedColorIndex;
|
||||
settings.spawnPerPixel = originalSpawnPerPixel;
|
||||
});
|
||||
|
||||
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
||||
const population = createPopulation();
|
||||
setPopulationCounts(population, 1_000_000, 1_000_000);
|
||||
|
||||
population.growBudget(1 / 60, 60, 60);
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
||||
|
||||
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
|
||||
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
|
||||
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
|
||||
appConfig.simulation.globalAgentCap
|
||||
);
|
||||
});
|
||||
|
||||
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
||||
const population = createPopulation();
|
||||
setPopulationCounts(population, 1_000_000, 1_000_000);
|
||||
|
||||
population.growBudget(10, 50, 60);
|
||||
|
||||
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
||||
expect(population.activeAgentCount).toBe(
|
||||
appConfig.simulation.budget.adaptiveCapMin
|
||||
);
|
||||
});
|
||||
});
|
||||
247
src/game-loop/agent-population.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { createIntroTitleAgents } from './intro-title-agents';
|
||||
|
||||
export const GLOBAL_AGENT_CAP = appConfig.simulation.globalAgentCap;
|
||||
|
||||
const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
|
||||
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
|
||||
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
|
||||
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
|
||||
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
|
||||
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
|
||||
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
|
||||
|
||||
export class AgentPopulation {
|
||||
private activeCount = 0;
|
||||
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
|
||||
private replacementCursor = 0;
|
||||
private canExpandAdaptiveCap = true;
|
||||
private shouldCompactAfterErase = false;
|
||||
private isCompacting = false;
|
||||
private readonly strokeAgentData = new Float32Array(
|
||||
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
|
||||
);
|
||||
|
||||
public constructor(private readonly pipeline: AgentGenerationPipeline) {}
|
||||
|
||||
public get activeAgentCount(): number {
|
||||
return this.activeCount;
|
||||
}
|
||||
|
||||
public get targetAgentBudget(): number {
|
||||
return this.targetBudget;
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.pipeline.maxAgentCount;
|
||||
}
|
||||
|
||||
public initializeIntroAgents(canvasSize: vec2): void {
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.targetBudget = Math.min(
|
||||
this.pipeline.maxAgentCount,
|
||||
settings.agentBudgetMax,
|
||||
INITIAL_AGENT_COUNT
|
||||
);
|
||||
this.writeAgentBatch(
|
||||
createIntroTitleAgents({
|
||||
count: this.targetBudget,
|
||||
width: canvasSize[0],
|
||||
height: canvasSize[1],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public onVibeChanged(): void {
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.targetBudget = Math.min(
|
||||
this.targetBudget,
|
||||
settings.agentBudgetMax,
|
||||
this.pipeline.maxAgentCount
|
||||
);
|
||||
}
|
||||
|
||||
public growBudget(
|
||||
deltaTime: number,
|
||||
smoothedFps: number,
|
||||
refreshTargetFps: number
|
||||
): void {
|
||||
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
|
||||
|
||||
const cap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
if (
|
||||
this.targetBudget < cap &&
|
||||
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
|
||||
) {
|
||||
this.targetBudget = Math.min(
|
||||
cap,
|
||||
this.targetBudget +
|
||||
Math.ceil(appConfig.simulation.budget.rampAgentsPerSecond * deltaTime)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public resizeAgents(scale: vec2): void {
|
||||
this.pipeline.resizeAgents(this.activeCount, scale);
|
||||
}
|
||||
|
||||
public requestCompactionAfterErase(): void {
|
||||
this.shouldCompactAfterErase = true;
|
||||
}
|
||||
|
||||
public async compactAfterErase(isSwipeActive: boolean): Promise<void> {
|
||||
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldCompactAfterErase = false;
|
||||
if (this.activeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCompacting = true;
|
||||
try {
|
||||
const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount);
|
||||
this.activeCount = compactedAgentCount;
|
||||
this.replacementCursor =
|
||||
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
|
||||
this.targetBudget = Math.max(this.targetBudget, compactedAgentCount);
|
||||
} finally {
|
||||
this.isCompacting = false;
|
||||
}
|
||||
}
|
||||
|
||||
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||
const length = Math.max(1, vec2.dist(from, to));
|
||||
const count = Math.max(
|
||||
MIN_STROKE_AGENT_COUNT,
|
||||
Math.min(
|
||||
MAX_STROKE_AGENT_COUNT,
|
||||
Math.ceil(length * settings.spawnPerPixel * STROKE_AGENT_DENSITY_MULTIPLIER)
|
||||
)
|
||||
);
|
||||
const direction = vec2.sub(vec2.create(), to, from);
|
||||
const baseAngle = Math.atan2(direction[1], direction[0]);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = count === 1 ? 1 : i / (count - 1);
|
||||
const x = from[0] + (to[0] - from[0]) * t;
|
||||
const y = from[1] + (to[1] - from[1]) * t;
|
||||
const angle =
|
||||
(Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) +
|
||||
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
||||
const base = i * AGENT_FLOAT_COUNT;
|
||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
|
||||
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * settings.brushSize;
|
||||
this.strokeAgentData[base + 2] = angle;
|
||||
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
|
||||
this.strokeAgentData[base + 4] = -1;
|
||||
this.strokeAgentData[base + 5] = -1;
|
||||
this.strokeAgentData[base + 6] = angle;
|
||||
this.strokeAgentData[base + 7] = 0;
|
||||
}
|
||||
|
||||
this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT));
|
||||
}
|
||||
|
||||
private writeAgentBatch(data: Float32Array): void {
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = data.length / AGENT_FLOAT_COUNT;
|
||||
this.expandAdaptiveCapForPendingAgents(count);
|
||||
|
||||
const available = Math.max(0, this.targetBudget - this.activeCount);
|
||||
const appendCount = Math.min(count, available);
|
||||
|
||||
if (appendCount > 0) {
|
||||
this.pipeline.writeAgents(
|
||||
this.activeCount,
|
||||
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
|
||||
);
|
||||
this.activeCount += appendCount;
|
||||
}
|
||||
|
||||
let sourceAgentOffset = appendCount;
|
||||
while (sourceAgentOffset < count && this.activeCount > 0) {
|
||||
const targetAgentOffset = this.replacementCursor % this.activeCount;
|
||||
const chunkAgentCount = Math.min(
|
||||
count - sourceAgentOffset,
|
||||
this.activeCount - targetAgentOffset
|
||||
);
|
||||
|
||||
this.pipeline.writeAgents(
|
||||
targetAgentOffset,
|
||||
data.subarray(
|
||||
sourceAgentOffset * AGENT_FLOAT_COUNT,
|
||||
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
|
||||
)
|
||||
);
|
||||
|
||||
sourceAgentOffset += chunkAgentCount;
|
||||
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
|
||||
}
|
||||
}
|
||||
|
||||
private updateAdaptiveCap(
|
||||
deltaTime: number,
|
||||
smoothedFps: number,
|
||||
refreshTargetFps: number
|
||||
): void {
|
||||
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.canExpandAdaptiveCap =
|
||||
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
|
||||
|
||||
if (this.canExpandAdaptiveCap) {
|
||||
settings.agentBudgetMax = previousCap;
|
||||
return;
|
||||
}
|
||||
|
||||
const decrease = Math.max(
|
||||
1,
|
||||
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
|
||||
);
|
||||
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
|
||||
settings.agentBudgetMax = nextCap;
|
||||
this.targetBudget = Math.min(this.targetBudget, nextCap);
|
||||
|
||||
if (this.activeCount > this.targetBudget) {
|
||||
this.activeCount = Math.max(this.targetBudget, this.activeCount - decrease);
|
||||
this.replacementCursor =
|
||||
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||
}
|
||||
}
|
||||
|
||||
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
||||
const available = Math.max(0, this.targetBudget - this.activeCount);
|
||||
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
if (this.targetBudget < currentCap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingAgentCount = requestedAgentCount - available;
|
||||
const nextCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
|
||||
settings.agentBudgetMax = nextCap;
|
||||
this.targetBudget = Math.max(
|
||||
this.targetBudget,
|
||||
Math.min(nextCap, this.activeCount + requestedAgentCount)
|
||||
);
|
||||
}
|
||||
|
||||
private clampAdaptiveCap(value: number): number {
|
||||
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
||||
const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap);
|
||||
const finiteValue = Number.isFinite(value) ? value : minCap;
|
||||
return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue)));
|
||||
}
|
||||
}
|
||||
80
src/game-loop/eraser-preview.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { settings } from '../settings';
|
||||
|
||||
export class EraserPreview {
|
||||
private previewClientPosition: { x: number; y: number } | null = null;
|
||||
private isErasing = false;
|
||||
private isPointerHoveringCanvas = false;
|
||||
private previousSize: number | null = null;
|
||||
private previousLeft = '';
|
||||
private previousTop = '';
|
||||
private isVisible = false;
|
||||
|
||||
public constructor(
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
private readonly element: HTMLElement
|
||||
) {}
|
||||
|
||||
public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void {
|
||||
this.isErasing = isErasing;
|
||||
this.update(undefined, isSwipeActive);
|
||||
}
|
||||
|
||||
public setPointerHoveringCanvas(isHovering: boolean): void {
|
||||
this.isPointerHoveringCanvas = isHovering;
|
||||
}
|
||||
|
||||
public update(event?: PointerEvent, isSwipeActive = false): void {
|
||||
if (event) {
|
||||
this.previewClientPosition = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.previousSize !== settings.eraserSize) {
|
||||
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
|
||||
this.previousSize = settings.eraserSize;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isErasing ||
|
||||
this.previewClientPosition === null ||
|
||||
(!this.isPointerHoveringCanvas && !isSwipeActive)
|
||||
) {
|
||||
this.setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const left = `${this.previewClientPosition.x - rect.left}px`;
|
||||
const top = `${this.previewClientPosition.y - rect.top}px`;
|
||||
if (this.previousLeft !== left) {
|
||||
this.element.style.left = left;
|
||||
this.previousLeft = left;
|
||||
}
|
||||
if (this.previousTop !== top) {
|
||||
this.element.style.top = top;
|
||||
this.previousTop = top;
|
||||
}
|
||||
this.setVisible(true);
|
||||
}
|
||||
|
||||
public isPointerInsideCanvas(event: PointerEvent): boolean {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
private setVisible(isVisible: boolean): void {
|
||||
if (this.isVisible === isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isVisible = isVisible;
|
||||
this.element.classList.toggle('visible', isVisible);
|
||||
}
|
||||
}
|
||||
194
src/game-loop/export-4k-renderer.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import {
|
||||
estimateExport4KMemory,
|
||||
getAspectFitExport4KDimensions,
|
||||
getBrowserExportMemoryInfo,
|
||||
getExport4KPreflightError,
|
||||
} from './export-4k';
|
||||
|
||||
interface Export4KRendererOptions {
|
||||
device: GPUDevice;
|
||||
renderPipeline: RenderPipeline;
|
||||
statusElement: HTMLElement;
|
||||
seed: string;
|
||||
getSourceSize: () => { width: number; height: number };
|
||||
getColorTextureView: () => GPUTextureView;
|
||||
getSourceTextureView: () => GPUTextureView;
|
||||
getVibeId: () => string;
|
||||
}
|
||||
|
||||
export class Export4KRenderer {
|
||||
private isExporting = false;
|
||||
|
||||
public constructor(private readonly options: Export4KRendererOptions) {}
|
||||
|
||||
public async export(): Promise<void> {
|
||||
if (this.isExporting) {
|
||||
this.statusElement.textContent = '4K upscale already rendering...';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExporting = true;
|
||||
this.statusElement.textContent = 'Rendering 4K upscale...';
|
||||
|
||||
try {
|
||||
const sourceSize = this.options.getSourceSize();
|
||||
const exportDimensions = getAspectFitExport4KDimensions(
|
||||
sourceSize.width,
|
||||
sourceSize.height
|
||||
);
|
||||
const estimate = estimateExport4KMemory(
|
||||
exportDimensions.width,
|
||||
exportDimensions.height
|
||||
);
|
||||
const preflightError = getExport4KPreflightError({
|
||||
limits: this.device.limits,
|
||||
memoryInfo: getBrowserExportMemoryInfo(),
|
||||
estimate,
|
||||
});
|
||||
if (preflightError) {
|
||||
this.statusElement.textContent = '4K upscale unavailable';
|
||||
throw preflightError;
|
||||
}
|
||||
|
||||
await this.renderExport(estimate);
|
||||
this.statusElement.textContent = '';
|
||||
} finally {
|
||||
this.isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async renderExport(
|
||||
estimate: ReturnType<typeof estimateExport4KMemory>
|
||||
): Promise<void> {
|
||||
const { width, height, unpaddedBytesPerRow, bytesPerRow } = estimate;
|
||||
const format = navigator.gpu.getPreferredCanvasFormat();
|
||||
let texture: GPUTexture | null = null;
|
||||
let output: GPUBuffer | null = null;
|
||||
let isOutputMapped = false;
|
||||
|
||||
try {
|
||||
texture = this.device.createTexture({
|
||||
size: { width, height },
|
||||
format,
|
||||
usage:
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_SRC |
|
||||
GPUTextureUsage.TEXTURE_BINDING,
|
||||
});
|
||||
output = this.device.createBuffer({
|
||||
size: estimate.readbackBufferBytes,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
});
|
||||
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
this.options.renderPipeline.executeToView(
|
||||
commandEncoder,
|
||||
this.options.getColorTextureView(),
|
||||
this.options.getSourceTextureView(),
|
||||
texture.createView()
|
||||
);
|
||||
commandEncoder.copyTextureToBuffer(
|
||||
{ texture },
|
||||
{ buffer: output, bytesPerRow, rowsPerImage: height },
|
||||
{ width, height }
|
||||
);
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
|
||||
await output.mapAsync(GPUMapMode.READ);
|
||||
isOutputMapped = true;
|
||||
const pixels = readExportPixels({
|
||||
mapped: new Uint8Array(output.getMappedRange()),
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
isBgra: format === 'bgra8unorm',
|
||||
});
|
||||
output.unmap();
|
||||
isOutputMapped = false;
|
||||
output.destroy();
|
||||
output = null;
|
||||
texture.destroy();
|
||||
texture = null;
|
||||
|
||||
await this.downloadPixels(pixels, width, height);
|
||||
} catch (error) {
|
||||
this.statusElement.textContent = '4K upscale failed';
|
||||
throw error;
|
||||
} finally {
|
||||
if (output && isOutputMapped) {
|
||||
output.unmap();
|
||||
}
|
||||
output?.destroy();
|
||||
texture?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadPixels(
|
||||
pixels: Uint8ClampedArray<ArrayBuffer>,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<void> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not create export canvas');
|
||||
}
|
||||
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
const link = document.createElement('a');
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
link.href = objectUrl;
|
||||
link.download = `fleeting-garden_${this.options.getVibeId()}_${
|
||||
this.options.seed
|
||||
}_${width}x${height}-upscale.png`;
|
||||
link.click();
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private get device(): GPUDevice {
|
||||
return this.options.device;
|
||||
}
|
||||
|
||||
private get statusElement(): HTMLElement {
|
||||
return this.options.statusElement;
|
||||
}
|
||||
}
|
||||
|
||||
const readExportPixels = ({
|
||||
mapped,
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
isBgra,
|
||||
}: {
|
||||
mapped: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
isBgra: boolean;
|
||||
}): Uint8ClampedArray<ArrayBuffer> => {
|
||||
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
|
||||
unpaddedBytesPerRow * height
|
||||
);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const sourceOffset = y * bytesPerRow;
|
||||
const targetOffset = y * unpaddedBytesPerRow;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const source = sourceOffset + x * 4;
|
||||
const target = targetOffset + x * 4;
|
||||
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||
pixels[target + 1] = mapped[source + 1];
|
||||
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||
pixels[target + 3] = mapped[source + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return pixels;
|
||||
};
|
||||
87
src/game-loop/export-4k.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
estimateExport4KMemory,
|
||||
formatByteSize,
|
||||
getAspectFitExport4KDimensions,
|
||||
getExport4KPreflightError,
|
||||
} from './export-4k';
|
||||
|
||||
const generousLimits = {
|
||||
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
||||
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
|
||||
describe('4K export preflight', () => {
|
||||
it('fits export dimensions inside 4K while preserving source aspect ratio', () => {
|
||||
expect(getAspectFitExport4KDimensions(3840, 2160)).toEqual({
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
});
|
||||
expect(getAspectFitExport4KDimensions(800, 600)).toEqual({
|
||||
width: 2880,
|
||||
height: 2160,
|
||||
});
|
||||
expect(getAspectFitExport4KDimensions(600, 800)).toEqual({
|
||||
width: 1620,
|
||||
height: 2160,
|
||||
});
|
||||
expect(getAspectFitExport4KDimensions(1000, 1000)).toEqual({
|
||||
width: 2160,
|
||||
height: 2160,
|
||||
});
|
||||
});
|
||||
|
||||
it('estimates padded readback and temporary memory for the export', () => {
|
||||
const estimate = estimateExport4KMemory();
|
||||
|
||||
expect(estimate.width).toBe(3840);
|
||||
expect(estimate.height).toBe(2160);
|
||||
expect(estimate.bytesPerRow % 256).toBe(0);
|
||||
expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes);
|
||||
expect(formatByteSize(estimate.estimatedPeakBytes)).toMatch(/MiB$/);
|
||||
});
|
||||
|
||||
it('rejects GPUs that cannot allocate the export texture', () => {
|
||||
const error = getExport4KPreflightError({
|
||||
limits: {
|
||||
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
||||
maxTextureDimension2D: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
expect(error?.code).toBe('export-4k-texture-too-large');
|
||||
});
|
||||
|
||||
it('rejects GPUs that cannot allocate the readback buffer', () => {
|
||||
const estimate = estimateExport4KMemory();
|
||||
const error = getExport4KPreflightError({
|
||||
limits: {
|
||||
maxBufferSize: estimate.readbackBufferBytes - 1,
|
||||
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
estimate,
|
||||
});
|
||||
|
||||
expect(error?.code).toBe('export-4k-readback-too-large');
|
||||
});
|
||||
|
||||
it('rejects browser-reported low-memory devices', () => {
|
||||
const error = getExport4KPreflightError({
|
||||
limits: generousLimits,
|
||||
memoryInfo: {
|
||||
deviceMemoryBytes: 2 * 1024 ** 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(error?.code).toBe('export-4k-low-device-memory');
|
||||
});
|
||||
|
||||
it('allows export when memory hints are unavailable', () => {
|
||||
expect(
|
||||
getExport4KPreflightError({
|
||||
limits: generousLimits,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
222
src/game-loop/export-4k.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { appConfig } from '../config';
|
||||
import { RuntimeError } from '../utils/error-handler';
|
||||
|
||||
const EXPORT_4K_WIDTH = appConfig.export4k.width;
|
||||
const EXPORT_4K_HEIGHT = appConfig.export4k.height;
|
||||
|
||||
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
|
||||
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
|
||||
const GIBIBYTE = 1024 ** 3;
|
||||
const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
|
||||
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
|
||||
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
|
||||
|
||||
interface Export4KMemoryEstimate {
|
||||
width: number;
|
||||
height: number;
|
||||
bytesPerPixel: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
textureBytes: number;
|
||||
readbackBufferBytes: number;
|
||||
pixelBytes: number;
|
||||
canvasBytes: number;
|
||||
encoderSafetyBytes: number;
|
||||
estimatedJsHeapBytes: number;
|
||||
estimatedPeakBytes: number;
|
||||
}
|
||||
|
||||
interface Export4KDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BrowserMemoryInfo {
|
||||
deviceMemoryBytes?: number;
|
||||
jsHeapSizeLimitBytes?: number;
|
||||
usedJsHeapSizeBytes?: number;
|
||||
}
|
||||
|
||||
interface Export4KPreflightOptions {
|
||||
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
|
||||
memoryInfo?: BrowserMemoryInfo;
|
||||
estimate?: Export4KMemoryEstimate;
|
||||
}
|
||||
|
||||
const alignTo = (value: number, alignment: number): number =>
|
||||
Math.ceil(value / alignment) * alignment;
|
||||
|
||||
const getPositiveFiniteNumber = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
|
||||
export const formatByteSize = (bytes: number): string =>
|
||||
`${Math.ceil(bytes / 1024 / 1024)} MiB`;
|
||||
|
||||
export const getAspectFitExport4KDimensions = (
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
maxWidth = EXPORT_4K_WIDTH,
|
||||
maxHeight = EXPORT_4K_HEIGHT
|
||||
): Export4KDimensions => {
|
||||
if (
|
||||
!Number.isFinite(sourceWidth) ||
|
||||
!Number.isFinite(sourceHeight) ||
|
||||
sourceWidth <= 0 ||
|
||||
sourceHeight <= 0
|
||||
) {
|
||||
return { width: maxWidth, height: maxHeight };
|
||||
}
|
||||
|
||||
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
|
||||
|
||||
return {
|
||||
width: Math.min(maxWidth, Math.max(1, Math.round(sourceWidth * scale))),
|
||||
height: Math.min(maxHeight, Math.max(1, Math.round(sourceHeight * scale))),
|
||||
};
|
||||
};
|
||||
|
||||
export const estimateExport4KMemory = (
|
||||
width = EXPORT_4K_WIDTH,
|
||||
height = EXPORT_4K_HEIGHT
|
||||
): Export4KMemoryEstimate => {
|
||||
const unpaddedBytesPerRow = width * BYTES_PER_PIXEL;
|
||||
const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES);
|
||||
const textureBytes = unpaddedBytesPerRow * height;
|
||||
const readbackBufferBytes = bytesPerRow * height;
|
||||
const pixelBytes = textureBytes;
|
||||
const canvasBytes = textureBytes;
|
||||
const encoderSafetyBytes = textureBytes * 2;
|
||||
const estimatedJsHeapBytes = pixelBytes + canvasBytes + encoderSafetyBytes;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
bytesPerPixel: BYTES_PER_PIXEL,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
textureBytes,
|
||||
readbackBufferBytes,
|
||||
pixelBytes,
|
||||
canvasBytes,
|
||||
encoderSafetyBytes,
|
||||
estimatedJsHeapBytes,
|
||||
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
|
||||
};
|
||||
};
|
||||
|
||||
export const getBrowserExportMemoryInfo = (): BrowserMemoryInfo => {
|
||||
const navigatorWithMemory =
|
||||
typeof navigator === 'undefined'
|
||||
? undefined
|
||||
: (navigator as Navigator & { deviceMemory?: number });
|
||||
const performanceWithMemory =
|
||||
typeof performance === 'undefined'
|
||||
? undefined
|
||||
: (performance as Performance & {
|
||||
memory?: {
|
||||
jsHeapSizeLimit?: number;
|
||||
usedJSHeapSize?: number;
|
||||
};
|
||||
});
|
||||
|
||||
const deviceMemoryGib = getPositiveFiniteNumber(navigatorWithMemory?.deviceMemory);
|
||||
const jsHeapSizeLimitBytes = getPositiveFiniteNumber(
|
||||
performanceWithMemory?.memory?.jsHeapSizeLimit
|
||||
);
|
||||
const usedJsHeapSizeBytes = getPositiveFiniteNumber(
|
||||
performanceWithMemory?.memory?.usedJSHeapSize
|
||||
);
|
||||
|
||||
return {
|
||||
...(deviceMemoryGib === undefined
|
||||
? {}
|
||||
: { deviceMemoryBytes: deviceMemoryGib * GIBIBYTE }),
|
||||
...(jsHeapSizeLimitBytes === undefined ? {} : { jsHeapSizeLimitBytes }),
|
||||
...(usedJsHeapSizeBytes === undefined ? {} : { usedJsHeapSizeBytes }),
|
||||
};
|
||||
};
|
||||
|
||||
export const getExport4KPreflightError = ({
|
||||
limits,
|
||||
memoryInfo = {},
|
||||
estimate = estimateExport4KMemory(),
|
||||
}: Export4KPreflightOptions): RuntimeError | null => {
|
||||
if (
|
||||
estimate.width > limits.maxTextureDimension2D ||
|
||||
estimate.height > limits.maxTextureDimension2D
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-texture-too-large',
|
||||
'This GPU cannot create a 3840x2160 export texture.',
|
||||
{
|
||||
details: {
|
||||
exportWidth: estimate.width,
|
||||
exportHeight: estimate.height,
|
||||
maxTextureDimension2D: limits.maxTextureDimension2D,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (estimate.readbackBufferBytes > limits.maxBufferSize) {
|
||||
return new RuntimeError(
|
||||
'export-4k-readback-too-large',
|
||||
'This GPU cannot allocate the 4K export readback buffer.',
|
||||
{
|
||||
details: {
|
||||
readbackBufferBytes: estimate.readbackBufferBytes,
|
||||
maxBufferSize: limits.maxBufferSize,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
memoryInfo.deviceMemoryBytes !== undefined &&
|
||||
memoryInfo.deviceMemoryBytes <= LOW_MEMORY_DEVICE_GIB * GIBIBYTE &&
|
||||
estimate.estimatedPeakBytes >
|
||||
memoryInfo.deviceMemoryBytes * LOW_MEMORY_EXPORT_FRACTION
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-low-device-memory',
|
||||
`4K upscale export needs about ${formatByteSize(
|
||||
estimate.estimatedPeakBytes
|
||||
)} of temporary memory, which is not safe on this low-memory device.`,
|
||||
{
|
||||
details: {
|
||||
deviceMemoryBytes: memoryInfo.deviceMemoryBytes,
|
||||
estimatedPeakBytes: estimate.estimatedPeakBytes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
memoryInfo.jsHeapSizeLimitBytes !== undefined &&
|
||||
memoryInfo.usedJsHeapSizeBytes !== undefined
|
||||
) {
|
||||
const availableJsHeapBytes =
|
||||
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
|
||||
if (
|
||||
availableJsHeapBytes <
|
||||
estimate.estimatedJsHeapBytes * JS_HEAP_SAFETY_MULTIPLIER
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-low-js-heap',
|
||||
`4K upscale export needs about ${formatByteSize(
|
||||
estimate.estimatedJsHeapBytes
|
||||
)} of JavaScript heap, and this browser does not report enough free heap.`,
|
||||
{
|
||||
details: {
|
||||
availableJsHeapBytes,
|
||||
estimatedJsHeapBytes: estimate.estimatedJsHeapBytes,
|
||||
jsHeapSizeLimitBytes: memoryInfo.jsHeapSizeLimitBytes,
|
||||
usedJsHeapSizeBytes: memoryInfo.usedJsHeapSizeBytes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
73
src/game-loop/frame-performance.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
interface TelemetrySnapshot {
|
||||
frameCpuStartedAt: number;
|
||||
encodeCpuMs: number;
|
||||
activeAgentCount: number;
|
||||
targetAgentBudget: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
devicePixelRatio: number;
|
||||
renderSpeed: number;
|
||||
}
|
||||
|
||||
export class FramePerformance {
|
||||
public latestFps = 60;
|
||||
public smoothedFps = 60;
|
||||
public refreshTargetFps = 60;
|
||||
|
||||
private lastTelemetryAt = 0;
|
||||
|
||||
public markCpuStart(): number {
|
||||
return appConfig.telemetry.enabled ? performance.now() : 0;
|
||||
}
|
||||
|
||||
public measureSince(startedAt: number): number {
|
||||
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
|
||||
}
|
||||
|
||||
public update(deltaTime: number): void {
|
||||
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
|
||||
this.latestFps = fps;
|
||||
this.refreshTargetFps = Math.max(
|
||||
this.refreshTargetFps * appConfig.simulation.budget.refreshTargetDecay,
|
||||
fps
|
||||
);
|
||||
this.smoothedFps =
|
||||
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
||||
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||
}
|
||||
|
||||
public renderTelemetry({
|
||||
frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
activeAgentCount,
|
||||
targetAgentBudget,
|
||||
canvas,
|
||||
devicePixelRatio,
|
||||
renderSpeed,
|
||||
}: TelemetrySnapshot): void {
|
||||
if (!appConfig.telemetry.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastTelemetryAt = now;
|
||||
console.debug('Fleeting Garden telemetry', {
|
||||
fps: Math.round(this.latestFps),
|
||||
smoothedFps: Math.round(this.smoothedFps),
|
||||
refreshTargetFps: Math.round(this.refreshTargetFps),
|
||||
activeAgentCount,
|
||||
targetAgentBudget,
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
dpr: devicePixelRatio,
|
||||
renderSpeed,
|
||||
frameCpuMs: now - frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
28
src/game-loop/game-loop-intro.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const gameLoopSource = readFileSync(
|
||||
join(process.cwd(), 'src/game-loop/game-loop.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const getStartDrawingHandlerSource = () => {
|
||||
const start = gameLoopSource.indexOf('onStartDrawing:');
|
||||
const end = gameLoopSource.indexOf('onEraseGestureEnded:', start);
|
||||
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error('Could not find the pointer drawing intro handler');
|
||||
}
|
||||
|
||||
return gameLoopSource.slice(start, end);
|
||||
};
|
||||
|
||||
describe('GameLoop intro drawing policy', () => {
|
||||
it('allows drawing to start without completing the intro sequence', () => {
|
||||
const handlerSource = getStartDrawingHandlerSource();
|
||||
|
||||
expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()');
|
||||
expect(handlerSource).not.toContain('this.introPrompt.complete(');
|
||||
});
|
||||
});
|
||||
50
src/game-loop/game-loop-ping-pong.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const simulationFrameSource = readFileSync(
|
||||
join(process.cwd(), 'src/game-loop/simulation-frame.ts'),
|
||||
'utf8'
|
||||
);
|
||||
const simulationTexturesSource = readFileSync(
|
||||
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const getRenderStepSource = () => {
|
||||
const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)');
|
||||
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
|
||||
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error('Could not find the render-speed simulation loop');
|
||||
}
|
||||
|
||||
return simulationFrameSource.slice(start, end);
|
||||
};
|
||||
|
||||
describe('GameLoop ping-pong texture flow', () => {
|
||||
it('copies only the trail map and swaps source/influence references after diffusion', () => {
|
||||
const renderStepSource = getRenderStepSource();
|
||||
|
||||
expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1);
|
||||
expect(renderStepSource).toMatch(
|
||||
/this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\s\S]*\);/
|
||||
);
|
||||
expect(renderStepSource).toMatch(
|
||||
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
|
||||
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain(
|
||||
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
|
||||
);
|
||||
expect(simulationTexturesSource).toContain(
|
||||
'[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];'
|
||||
);
|
||||
});
|
||||
});
|
||||
192
src/game-loop/game-loop-resources.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import { CommonState } from '../pipelines/common-state/common-state';
|
||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||
import { GLOBAL_AGENT_CAP } from './agent-population';
|
||||
import { RenderInputs } from './game-loop-types';
|
||||
import { SimulationFrameRenderer } from './simulation-frame';
|
||||
import { SimulationTextures } from './simulation-textures';
|
||||
|
||||
interface FrameParameters extends RenderInputs {
|
||||
time: number;
|
||||
deltaTime: number;
|
||||
canvasSize: vec2;
|
||||
activeAgentCount: number;
|
||||
introProgress: number;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
cameraCenter: [number, number];
|
||||
cameraZoom: number;
|
||||
eraserPixelSize: number;
|
||||
}
|
||||
|
||||
export class GameLoopResources {
|
||||
public readonly textures: SimulationTextures;
|
||||
public readonly commonState: CommonState;
|
||||
public readonly copyPipeline: CopyPipeline;
|
||||
public readonly agentGenerationPipeline: AgentGenerationPipeline;
|
||||
public readonly agentPipeline: AgentPipeline;
|
||||
public readonly brushPipeline: BrushPipeline;
|
||||
public readonly eraserAgentPipeline: EraserAgentPipeline;
|
||||
public readonly eraserTexturePipeline: EraserTexturePipeline;
|
||||
public readonly diffusionPipeline: DiffusionPipeline;
|
||||
public readonly brushEffectDiffusionPipeline: DiffusionPipeline;
|
||||
public readonly renderPipeline: RenderPipeline;
|
||||
|
||||
private readonly frameRenderer: SimulationFrameRenderer;
|
||||
|
||||
public constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
private readonly device: GPUDevice,
|
||||
canvasSize: vec2
|
||||
) {
|
||||
const context = initializeContext({ device, canvas });
|
||||
|
||||
this.textures = new SimulationTextures(this.device, canvasSize);
|
||||
this.copyPipeline = new CopyPipeline(this.device);
|
||||
|
||||
this.commonState = new CommonState(this.device);
|
||||
this.commonState.setParameters({
|
||||
canvasSize,
|
||||
time: 0,
|
||||
deltaTime: 0,
|
||||
});
|
||||
|
||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
GLOBAL_AGENT_CAP
|
||||
);
|
||||
|
||||
this.agentPipeline = new AgentPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
this.agentGenerationPipeline.agentsBuffer
|
||||
);
|
||||
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
||||
this.eraserAgentPipeline = new EraserAgentPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
this.agentGenerationPipeline.agentsBuffer
|
||||
);
|
||||
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
|
||||
this.brushEffectDiffusionPipeline = new DiffusionPipeline(
|
||||
this.device,
|
||||
this.commonState
|
||||
);
|
||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
||||
|
||||
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
|
||||
copyPipeline: this.copyPipeline,
|
||||
agentPipeline: this.agentPipeline,
|
||||
brushPipeline: this.brushPipeline,
|
||||
eraserAgentPipeline: this.eraserAgentPipeline,
|
||||
eraserTexturePipeline: this.eraserTexturePipeline,
|
||||
diffusionPipeline: this.diffusionPipeline,
|
||||
brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline,
|
||||
renderPipeline: this.renderPipeline,
|
||||
});
|
||||
}
|
||||
|
||||
public resizeSimulationTo(nextSize: vec2): vec2 | null {
|
||||
return this.textures.resizeTo(nextSize);
|
||||
}
|
||||
|
||||
public setFrameParameters({
|
||||
time,
|
||||
deltaTime,
|
||||
canvasSize,
|
||||
activeAgentCount,
|
||||
introProgress,
|
||||
selectedColorIndex,
|
||||
isErasing,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
eraserPixelSize,
|
||||
}: FrameParameters): void {
|
||||
this.commonState.setParameters({
|
||||
canvasSize,
|
||||
time,
|
||||
deltaTime,
|
||||
});
|
||||
this.agentPipeline.setParameters({
|
||||
...settings,
|
||||
deltaTime,
|
||||
agentCount: activeAgentCount,
|
||||
moveSpeed:
|
||||
settings.moveSpeed *
|
||||
(introProgress >= 1
|
||||
? 1
|
||||
: appConfig.simulation.introMoveSpeedBaseMultiplier +
|
||||
introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier),
|
||||
introProgress,
|
||||
});
|
||||
this.brushPipeline.setParameters({
|
||||
...settings,
|
||||
selectedColorIndex,
|
||||
isErasing,
|
||||
});
|
||||
this.diffusionPipeline.setParameters(settings);
|
||||
this.renderPipeline.setParameters({
|
||||
...settings,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
});
|
||||
this.eraserAgentPipeline.setParameters({
|
||||
agentCount: activeAgentCount,
|
||||
eraserSize: eraserPixelSize,
|
||||
});
|
||||
this.eraserTexturePipeline.setParameters({
|
||||
eraserSize: eraserPixelSize,
|
||||
});
|
||||
this.setBrushEffectDiffusionParameters();
|
||||
}
|
||||
|
||||
public executeFrame(renderSpeed: number, isErasing: boolean): void {
|
||||
this.frameRenderer.execute(renderSpeed, isErasing);
|
||||
}
|
||||
|
||||
public clearSwipes(): void {
|
||||
this.frameRenderer.clearSwipes();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.copyPipeline.destroy();
|
||||
this.agentGenerationPipeline.destroy();
|
||||
this.agentPipeline.destroy();
|
||||
this.brushPipeline.destroy();
|
||||
this.eraserAgentPipeline.destroy();
|
||||
this.eraserTexturePipeline.destroy();
|
||||
this.diffusionPipeline.destroy();
|
||||
this.brushEffectDiffusionPipeline.destroy();
|
||||
this.renderPipeline.destroy();
|
||||
this.commonState.destroy();
|
||||
this.textures.destroy();
|
||||
}
|
||||
|
||||
private setBrushEffectDiffusionParameters(): void {
|
||||
const framesToOneE = Math.max(
|
||||
1,
|
||||
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond
|
||||
);
|
||||
this.brushEffectDiffusionPipeline.setParameters({
|
||||
...settings,
|
||||
decayRateTrails: Math.exp(-1 / framesToOneE) * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
export interface GameLoopSettings {
|
||||
maxAgentCountUpperLimit: number;
|
||||
agentBudgetMax: number;
|
||||
agentCount: number;
|
||||
renderSpeed: number;
|
||||
simulatedDelayMs: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
|
||||
startColorHue: number;
|
||||
}
|
||||
|
|
|
|||
17
src/game-loop/game-loop-types.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
export interface GardenUi {
|
||||
prompt: HTMLElement;
|
||||
eraserPreview: HTMLElement;
|
||||
exportStatus: HTMLElement;
|
||||
}
|
||||
|
||||
export interface RenderInputs {
|
||||
channelColors: Array<[number, number, number]>;
|
||||
backgroundColor: [number, number, number];
|
||||
}
|
||||
|
||||
export interface StrokeSegment {
|
||||
from: vec2;
|
||||
to: vec2;
|
||||
}
|
||||
|
|
@ -1,258 +1,286 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import { CommonState } from '../pipelines/common-state/common-state';
|
||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { gardenAudioConfig } from '../audio/garden-audio-config';
|
||||
import { appConfig } from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
||||
import { sleep } from '../utils/sleep';
|
||||
import { GamePresentation } from './game-presentation';
|
||||
import { GameRules } from './game-rules';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
import { EraserPreview } from './eraser-preview';
|
||||
import { Export4KRenderer } from './export-4k-renderer';
|
||||
import { FramePerformance } from './frame-performance';
|
||||
import { GameLoopResources } from './game-loop-resources';
|
||||
import { GardenUi } from './game-loop-types';
|
||||
import { IntroPrompt } from './intro-prompt';
|
||||
import { GardenPointerInput } from './pointer-input';
|
||||
import { RenderInputCache } from './render-input-cache';
|
||||
|
||||
export default class GameLoop {
|
||||
private readonly trailMapA: ResizableTexture;
|
||||
private readonly trailMapB: ResizableTexture;
|
||||
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
||||
appConfig.simulation.maxMirrorSegmentCount;
|
||||
private static readonly DEV_STATS_INTERVAL_MS = 250;
|
||||
|
||||
private readonly commonState: CommonState;
|
||||
private readonly copyPipeline: CopyPipeline;
|
||||
private readonly agentGenerationPipeline: AgentGenerationPipeline;
|
||||
private readonly agentPipeline: AgentPipeline;
|
||||
private readonly renderPipeline: RenderPipeline;
|
||||
private readonly brushPipeline: BrushPipeline;
|
||||
private readonly diffusionPipeline: DiffusionPipeline;
|
||||
private readonly resources: GameLoopResources;
|
||||
private readonly audio = new GardenAudio(gardenAudioConfig);
|
||||
private readonly renderInputs = new RenderInputCache();
|
||||
private readonly introPrompt: IntroPrompt;
|
||||
private readonly eraserPreview: EraserPreview;
|
||||
private readonly pointerInput: GardenPointerInput;
|
||||
private readonly agentPopulation: AgentPopulation;
|
||||
private readonly export4KRenderer: Export4KRenderer;
|
||||
private readonly framePerformance = new FramePerformance();
|
||||
private readonly devStatsElement: HTMLDivElement | null = null;
|
||||
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
||||
private readonly resizeListener = this.resize.bind(this);
|
||||
private readonly keydownListener: (event: KeyboardEvent) => void;
|
||||
|
||||
private lastDevStatsUpdateAt = 0;
|
||||
private hasFinished = false;
|
||||
private readonly finished = Promise.withResolvers<void>();
|
||||
|
||||
private activePointerId: number | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
private readonly device: GPUDevice,
|
||||
device: GPUDevice,
|
||||
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
||||
private readonly gameRules: GameRules
|
||||
ui: GardenUi
|
||||
) {
|
||||
const context = initializeContext({ device, canvas });
|
||||
|
||||
this.trailMapA = new ResizableTexture(this.device, this.canvasSize);
|
||||
this.trailMapB = new ResizableTexture(this.device, this.canvasSize);
|
||||
this.resize();
|
||||
|
||||
this.copyPipeline = new CopyPipeline(this.device);
|
||||
|
||||
this.commonState = new CommonState(this.device);
|
||||
this.commonState.setParameters({
|
||||
canvasSize: this.canvasSize,
|
||||
time: 0,
|
||||
deltaTime: 0,
|
||||
if (import.meta.env.DEV) {
|
||||
this.devStatsElement = this.createDevStatsElement();
|
||||
}
|
||||
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
|
||||
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||
this.pointerInput = new GardenPointerInput({
|
||||
canvas,
|
||||
audio: this.audio,
|
||||
brushPipeline: this.resources.brushPipeline,
|
||||
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
||||
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
||||
eraserPreview: this.eraserPreview,
|
||||
getCanvasSize: () => this.canvasSize,
|
||||
getDevicePixelRatio: () => this.devicePixelRatio,
|
||||
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
||||
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
||||
});
|
||||
this.export4KRenderer = new Export4KRenderer({
|
||||
device,
|
||||
renderPipeline: this.resources.renderPipeline,
|
||||
statusElement: ui.exportStatus,
|
||||
seed: this.seed,
|
||||
getSourceSize: () => ({
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
}),
|
||||
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
|
||||
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
||||
getVibeId: () => activeVibe.id,
|
||||
});
|
||||
this.keydownListener = (event: KeyboardEvent) => {
|
||||
this.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||
this.introPrompt.complete();
|
||||
};
|
||||
|
||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
settings.maxAgentCountUpperLimit
|
||||
);
|
||||
this.agentGenerationPipeline.spawnFirstGeneration();
|
||||
|
||||
this.agentPipeline = new AgentPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
this.agentGenerationPipeline.agentsBuffer
|
||||
);
|
||||
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
||||
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
|
||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
||||
|
||||
window.addEventListener('resize', this.resize.bind(this));
|
||||
canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
|
||||
canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
|
||||
canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
|
||||
canvas.addEventListener('pointercancel', this.onPointerUp.bind(this));
|
||||
window.addEventListener('resize', this.resizeListener);
|
||||
window.addEventListener('keydown', this.keydownListener, { once: true });
|
||||
this.pointerInput.attach();
|
||||
}
|
||||
|
||||
private onPointerDown(event: PointerEvent) {
|
||||
if (this.activePointerId !== null) {
|
||||
return;
|
||||
}
|
||||
this.activePointerId = event.pointerId;
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
this.brushPipeline.clearSwipes();
|
||||
this.addSwipeAt(event);
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
this.pointerInput.setEraseMode(isErasing);
|
||||
}
|
||||
|
||||
private onPointerMove(event: PointerEvent) {
|
||||
if (event.pointerId !== this.activePointerId) {
|
||||
return;
|
||||
}
|
||||
this.addSwipeAt(event);
|
||||
public updateEraserPreview(event?: PointerEvent): void {
|
||||
this.pointerInput.updateEraserPreview(event);
|
||||
}
|
||||
|
||||
private onPointerUp(event: PointerEvent) {
|
||||
if (event.pointerId !== this.activePointerId) {
|
||||
return;
|
||||
}
|
||||
this.addSwipeAt(event);
|
||||
this.canvas.releasePointerCapture(event.pointerId);
|
||||
this.activePointerId = null;
|
||||
public onVibeChanged(): void {
|
||||
this.agentPopulation.onVibeChanged();
|
||||
this.renderInputs.invalidate();
|
||||
}
|
||||
|
||||
private addSwipeAt(event: PointerEvent) {
|
||||
const position = vec2.fromValues(
|
||||
event.clientX * this.devicePixelRatio,
|
||||
this.canvas.height - event.clientY * this.devicePixelRatio
|
||||
);
|
||||
this.brushPipeline.addSwipe(position);
|
||||
public setAudioMuted(isMuted: boolean): void {
|
||||
this.audio.setMuted(isMuted);
|
||||
}
|
||||
|
||||
private get isSwipeActive(): boolean {
|
||||
return this.activePointerId !== null;
|
||||
public startAudio(userGesture = false): void {
|
||||
this.audio.start(activeVibe, { userGesture });
|
||||
}
|
||||
|
||||
public playVibeChangeAudio(userGesture = false): void {
|
||||
this.audio.changeVibe(activeVibe, { userGesture });
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
requestAnimationFrame(this.render.bind(this));
|
||||
requestAnimationFrame(this.updateCounts.bind(this));
|
||||
requestAnimationFrame(this.render);
|
||||
return this.finished.promise;
|
||||
}
|
||||
|
||||
private async updateCounts(): Promise<void> {
|
||||
if (this.hasFinished) {
|
||||
return;
|
||||
}
|
||||
const generationCounts = await this.agentGenerationPipeline.countAgents(
|
||||
settings.agentCount
|
||||
);
|
||||
this.gameRules.updateGenerationCounts(generationCounts);
|
||||
requestAnimationFrame(this.updateCounts.bind(this));
|
||||
}
|
||||
|
||||
public get aliveAgentCounts(): {
|
||||
currentGenerationCount: number;
|
||||
nextGenerationCount: number;
|
||||
} {
|
||||
return this.gameRules.generationCounts;
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.agentGenerationPipeline.maxAgentCount;
|
||||
return this.agentPopulation.maxAgentCount;
|
||||
}
|
||||
|
||||
private resize() {
|
||||
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
|
||||
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
|
||||
public async export4K(): Promise<void> {
|
||||
return this.export4KRenderer.export();
|
||||
}
|
||||
|
||||
private async render(time: DOMHighResTimeStamp) {
|
||||
public async destroy(): Promise<void> {
|
||||
this.hasFinished = true;
|
||||
await this.finished.promise;
|
||||
|
||||
window.removeEventListener('resize', this.resizeListener);
|
||||
window.removeEventListener('keydown', this.keydownListener);
|
||||
this.pointerInput.detach();
|
||||
this.devStatsElement?.remove();
|
||||
this.introPrompt.destroy();
|
||||
this.resources.destroy();
|
||||
await this.audio.destroy();
|
||||
}
|
||||
|
||||
private readonly render = async (time: DOMHighResTimeStamp) => {
|
||||
if (this.hasFinished) {
|
||||
this.finished.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const accentColor = GamePresentation.getGenerationColor(
|
||||
this.gameRules.nextGenerationId - 1
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--accent-color',
|
||||
`rgb(${accentColor[0] * 255},${accentColor[1] * 255},${accentColor[2] * 255})`
|
||||
);
|
||||
|
||||
const frameCpuStartedAt = this.framePerformance.markCpuStart();
|
||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||
this.framePerformance.update(deltaTime);
|
||||
this.agentPopulation.growBudget(
|
||||
deltaTime,
|
||||
this.framePerformance.smoothedFps,
|
||||
this.framePerformance.refreshTargetFps
|
||||
);
|
||||
this.introPrompt.update();
|
||||
this.resize();
|
||||
this.resizeSimulationToCanvas();
|
||||
|
||||
time *= settings.renderSpeed;
|
||||
const timeInSeconds = time / 1000;
|
||||
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize);
|
||||
const scaledTime = time * settings.renderSpeed;
|
||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||
const introProgress = this.introPrompt.progress;
|
||||
const cameraZoom = 1;
|
||||
const cameraCenter: [number, number] = [
|
||||
this.canvas.width / 2,
|
||||
this.canvas.height / 2,
|
||||
];
|
||||
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
|
||||
const isErasing = this.pointerInput.isEraseMode;
|
||||
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
||||
this.renderInputs.updateAccentColor(accentColor);
|
||||
this.audio.update({
|
||||
vibe: activeVibe,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
isErasing,
|
||||
mirrorSegmentCount: this.mirrorSegmentCount,
|
||||
});
|
||||
|
||||
[
|
||||
this.commonState,
|
||||
this.agentPipeline,
|
||||
this.brushPipeline,
|
||||
this.diffusionPipeline,
|
||||
this.renderPipeline,
|
||||
].forEach((pipeline) =>
|
||||
pipeline.setParameters({
|
||||
time,
|
||||
isNextGenerationOdd: this.gameRules.nextGenerationId % 2,
|
||||
nextGenerationSensorOffsetDistance: this.gameRules.getSensorOffset(),
|
||||
nextGenerationSpeed: this.gameRules.getNextGenerationMoveSpeed(),
|
||||
infectionProbability: this.gameRules.getInfectionProbability(),
|
||||
this.resources.setFrameParameters({
|
||||
time: scaledTime,
|
||||
deltaTime,
|
||||
canvasSize: this.canvasSize,
|
||||
brushColor: GamePresentation.getGenerationColor(
|
||||
this.gameRules.nextGenerationId - 1
|
||||
),
|
||||
evenGenerationColor: GamePresentation.getGenerationColor(
|
||||
this.gameRules.nextGenerationId % 2 == 0
|
||||
? this.gameRules.nextGenerationId
|
||||
: this.gameRules.nextGenerationId - 1
|
||||
),
|
||||
oddGenerationColor: GamePresentation.getGenerationColor(
|
||||
this.gameRules.nextGenerationId % 2 == 1
|
||||
? this.gameRules.nextGenerationId
|
||||
: this.gameRules.nextGenerationId - 1
|
||||
),
|
||||
...settings,
|
||||
center: spawnAction.position,
|
||||
radius: spawnAction.radius,
|
||||
})
|
||||
);
|
||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||
introProgress,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
isErasing,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
eraserPixelSize,
|
||||
});
|
||||
|
||||
for (let i = 0; i < settings.renderSpeed; i++) {
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
|
||||
this.resources.executeFrame(settings.renderSpeed, isErasing);
|
||||
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
|
||||
|
||||
this.copyPipeline.execute(
|
||||
commandEncoder,
|
||||
this.trailMapA.getTextureView(),
|
||||
this.trailMapB.getTextureView()
|
||||
);
|
||||
this.brushPipeline.execute(commandEncoder, this.trailMapB.getTextureView());
|
||||
this.agentPipeline.execute(
|
||||
commandEncoder,
|
||||
this.trailMapA.getTextureView(),
|
||||
this.trailMapB.getTextureView()
|
||||
);
|
||||
this.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.trailMapB.getTextureView(),
|
||||
this.trailMapA.getTextureView()
|
||||
);
|
||||
this.renderPipeline.execute(commandEncoder, this.trailMapA.getTextureView());
|
||||
this.pointerInput.clearSwipesIfIdle();
|
||||
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
||||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
}
|
||||
|
||||
if (!this.isSwipeActive) {
|
||||
this.brushPipeline.clearSwipes();
|
||||
}
|
||||
this.framePerformance.renderTelemetry({
|
||||
frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||
targetAgentBudget: this.agentPopulation.targetAgentBudget,
|
||||
canvas: this.canvas,
|
||||
devicePixelRatio: this.devicePixelRatio,
|
||||
renderSpeed: settings.renderSpeed,
|
||||
});
|
||||
this.updateDevStats(time);
|
||||
|
||||
if (settings.simulatedDelayMs > 0) {
|
||||
await sleep(settings.simulatedDelayMs);
|
||||
}
|
||||
|
||||
// avoid resizing during rendering
|
||||
this.trailMapA.resize(this.canvasSize);
|
||||
this.trailMapB.resize(this.canvasSize);
|
||||
requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
requestAnimationFrame(this.render.bind(this));
|
||||
private createDevStatsElement(): HTMLDivElement | null {
|
||||
const container = this.canvas.parentElement;
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
this.hasFinished = true;
|
||||
await this.finished.promise;
|
||||
const element = document.createElement('div');
|
||||
element.className = 'dev-stats-overlay';
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
container.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
this.copyPipeline?.destroy();
|
||||
this.agentGenerationPipeline?.destroy();
|
||||
this.agentPipeline?.destroy();
|
||||
this.brushPipeline?.destroy();
|
||||
this.diffusionPipeline?.destroy();
|
||||
this.renderPipeline?.destroy();
|
||||
this.commonState?.destroy();
|
||||
this.trailMapA?.destroy();
|
||||
this.trailMapB?.destroy();
|
||||
private updateDevStats(time: DOMHighResTimeStamp): void {
|
||||
if (
|
||||
!this.devStatsElement ||
|
||||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastDevStatsUpdateAt = time;
|
||||
this.devStatsElement.textContent = [
|
||||
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round(
|
||||
this.framePerformance.refreshTargetFps
|
||||
)}`,
|
||||
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
|
||||
`Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`,
|
||||
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private formatDevStatNumber(value: number): string {
|
||||
return Math.max(0, Math.round(value)).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const width = Math.max(
|
||||
1,
|
||||
Math.floor(this.canvas.clientWidth * this.devicePixelRatio)
|
||||
);
|
||||
const height = Math.max(
|
||||
1,
|
||||
Math.floor(this.canvas.clientHeight * this.devicePixelRatio)
|
||||
);
|
||||
|
||||
if (this.canvas.width === width && this.canvas.height === height) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
}
|
||||
|
||||
private resizeSimulationToCanvas(): void {
|
||||
const scale = this.resources.resizeSimulationTo(this.canvasSize);
|
||||
if (!scale) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.agentPopulation.resizeAgents(scale);
|
||||
this.pointerInput.scaleLastPointerPosition(scale);
|
||||
}
|
||||
|
||||
private get canvasSize(): vec2 {
|
||||
|
|
@ -260,6 +288,14 @@ export default class GameLoop {
|
|||
}
|
||||
|
||||
private get devicePixelRatio(): number {
|
||||
return window.devicePixelRatio || 1;
|
||||
const ratio = window.devicePixelRatio;
|
||||
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
||||
}
|
||||
|
||||
private get mirrorSegmentCount(): number {
|
||||
const count = Number.isFinite(settings.mirrorSegmentCount)
|
||||
? settings.mirrorSegmentCount
|
||||
: 1;
|
||||
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
import { settings } from '../settings';
|
||||
import { hsl } from '../utils/hsl';
|
||||
import { Random } from '../utils/random';
|
||||
|
||||
const hues = [settings.startColorHue];
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360);
|
||||
}
|
||||
|
||||
const colors = hues.map((hue) =>
|
||||
hsl(hue, Random.randomBetween(90, 100), Random.randomBetween(20, 30))
|
||||
);
|
||||
|
||||
export class GamePresentation {
|
||||
public static getGenerationColor(generation: number): vec3 {
|
||||
return colors[generation % colors.length];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { GenerationCounts } from '../pipelines/agents/agent-generation/generation-counts';
|
||||
import { settings } from '../settings';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { mix } from '../utils/mix';
|
||||
import { Random } from '../utils/random';
|
||||
|
||||
export interface SpawnAction {
|
||||
generation: number;
|
||||
position: vec2;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export class GameRules {
|
||||
private static readonly DEFAULT_SPAWN_INTERVAL = 8;
|
||||
private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
|
||||
private static readonly DEFAULT_SPAWN_RADIUS = 20;
|
||||
|
||||
private lastSpawnTimeInSeconds = 0;
|
||||
private currentSpawnInterval = 0;
|
||||
private currentSpawnRadius = 0;
|
||||
private lastGenerationChangeTimeInSeconds = 0;
|
||||
|
||||
public nextGenerationId = 1;
|
||||
public generationCounts: {
|
||||
currentGenerationCount: number;
|
||||
nextGenerationCount: number;
|
||||
} = {
|
||||
currentGenerationCount: 0,
|
||||
nextGenerationCount: 1,
|
||||
};
|
||||
|
||||
public constructor(startingTimeInSeconds: number) {
|
||||
this.lastSpawnTimeInSeconds = startingTimeInSeconds;
|
||||
this.lastGenerationChangeTimeInSeconds = startingTimeInSeconds;
|
||||
}
|
||||
|
||||
private lastSpawnAction: SpawnAction | undefined;
|
||||
|
||||
public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
|
||||
if (
|
||||
this.lastSpawnAction &&
|
||||
timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
|
||||
) {
|
||||
return this.lastSpawnAction;
|
||||
}
|
||||
|
||||
this.currentSpawnInterval = mix(
|
||||
GameRules.DEFAULT_SPAWN_INTERVAL,
|
||||
GameRules.DEFAULT_SPAWN_INTERVAL / 5,
|
||||
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
|
||||
);
|
||||
|
||||
this.currentSpawnRadius = mix(
|
||||
GameRules.DEFAULT_SPAWN_RADIUS,
|
||||
GameRules.DEFAULT_SPAWN_RADIUS * 3,
|
||||
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
|
||||
);
|
||||
|
||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
||||
|
||||
if (
|
||||
timeInSeconds - this.lastSpawnTimeInSeconds < this.currentSpawnInterval ||
|
||||
q > 0.05
|
||||
) {
|
||||
return {
|
||||
generation: this.nextGenerationId,
|
||||
position: vec2.create(),
|
||||
radius: 0,
|
||||
};
|
||||
}
|
||||
|
||||
this.lastSpawnTimeInSeconds = timeInSeconds;
|
||||
|
||||
this.lastSpawnAction = {
|
||||
generation: this.nextGenerationId,
|
||||
position: vec2.fromValues(
|
||||
Random.randomBetween(0, canvasSize[0]),
|
||||
Random.randomBetween(0, canvasSize[1])
|
||||
),
|
||||
radius: this.currentSpawnRadius,
|
||||
};
|
||||
|
||||
return this.lastSpawnAction;
|
||||
}
|
||||
|
||||
public updateGenerationCounts({
|
||||
evenGenerationCount,
|
||||
oddGenerationCount,
|
||||
}: GenerationCounts): void {
|
||||
const nextGenerationCount =
|
||||
this.nextGenerationId % 2 === 1 ? oddGenerationCount : evenGenerationCount;
|
||||
const currentGenerationCount =
|
||||
this.nextGenerationId % 2 === 1 ? evenGenerationCount : oddGenerationCount;
|
||||
|
||||
const q = currentGenerationCount / settings.agentCount;
|
||||
|
||||
if (currentGenerationCount <= 100 && q < 0.05) {
|
||||
this.nextGenerationId++;
|
||||
this.lastGenerationChangeTimeInSeconds = performance.now() / 1000;
|
||||
}
|
||||
|
||||
this.generationCounts = {
|
||||
currentGenerationCount,
|
||||
nextGenerationCount,
|
||||
};
|
||||
}
|
||||
|
||||
public getNextGenerationMoveSpeed(): number {
|
||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
||||
return mix(settings.moveSpeed / 8, settings.moveSpeed, q ** 2);
|
||||
}
|
||||
|
||||
public getInfectionProbability(): number {
|
||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
||||
return clamp(mix(0.3, 1, q * 5), 0, 0.9);
|
||||
}
|
||||
|
||||
public getSensorOffset(): number {
|
||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
||||
return mix(20, settings.sensorOffsetDistance, q);
|
||||
}
|
||||
}
|
||||
80
src/game-loop/intro-prompt.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000;
|
||||
|
||||
export class IntroPrompt {
|
||||
private introComplete = false;
|
||||
private introStartedAt = performance.now();
|
||||
private introCompletedAt: number | null = null;
|
||||
private hasStartedDrawing = false;
|
||||
private isDrawHintVisible = false;
|
||||
|
||||
public constructor(private readonly prompt: HTMLElement) {}
|
||||
|
||||
public get progress(): number {
|
||||
return this.introComplete
|
||||
? 1
|
||||
: Math.min(1, (performance.now() - this.introStartedAt) / INTRO_TITLE_DURATION_MS);
|
||||
}
|
||||
|
||||
public update(): void {
|
||||
const now = performance.now();
|
||||
|
||||
if (!this.introComplete && now - this.introStartedAt > INTRO_TITLE_DURATION_MS) {
|
||||
this.complete(now);
|
||||
}
|
||||
|
||||
if (
|
||||
!this.introComplete ||
|
||||
this.hasStartedDrawing ||
|
||||
this.introCompletedAt === null ||
|
||||
now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showDrawHint();
|
||||
}
|
||||
|
||||
public complete(completedAt = performance.now()): void {
|
||||
if (this.introComplete) {
|
||||
return;
|
||||
}
|
||||
this.introComplete = true;
|
||||
this.introCompletedAt = completedAt;
|
||||
this.hideDrawHint();
|
||||
}
|
||||
|
||||
public markStartedDrawing(): void {
|
||||
this.hasStartedDrawing = true;
|
||||
this.hideDrawHint();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.hideDrawHint();
|
||||
}
|
||||
|
||||
private showDrawHint(): void {
|
||||
if (this.isDrawHintVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDrawHintVisible = true;
|
||||
this.prompt.classList.add(appConfig.simulation.intro.drawHintClass);
|
||||
this.prompt.innerHTML = `
|
||||
<svg class="draw-hint-mark" viewBox="0 0 128 72" aria-hidden="true" focusable="false">
|
||||
<path class="draw-hint-shadow" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
|
||||
<path class="draw-hint-stroke" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
|
||||
<circle class="draw-hint-start" cx="12" cy="50" r="4" />
|
||||
<circle class="draw-hint-end" cx="116" cy="42" r="7" />
|
||||
</svg>
|
||||
<span class="draw-hint-text">Draw on the screen</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private hideDrawHint(): void {
|
||||
this.isDrawHintVisible = false;
|
||||
this.prompt.classList.remove(appConfig.simulation.intro.drawHintClass);
|
||||
this.prompt.replaceChildren();
|
||||
}
|
||||
}
|
||||
354
src/game-loop/intro-title-agents.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { appConfig } from '../config';
|
||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
||||
|
||||
interface IntroTitlePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
tangent: number | null;
|
||||
colorIndex: number;
|
||||
}
|
||||
|
||||
interface IntroTitleAgentOptions {
|
||||
count: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const INTRO_TITLE = appConfig.simulation.intro.title;
|
||||
|
||||
export const createIntroTitleAgents = ({
|
||||
count,
|
||||
width,
|
||||
height,
|
||||
}: IntroTitleAgentOptions): Float32Array => {
|
||||
if (count <= 0) {
|
||||
return new Float32Array();
|
||||
}
|
||||
|
||||
const safeWidth = Math.max(1, width);
|
||||
const safeHeight = Math.max(1, height);
|
||||
const points = createIntroTitlePoints(safeWidth, safeHeight);
|
||||
if (points.length === 0) {
|
||||
return new Float32Array();
|
||||
}
|
||||
|
||||
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
|
||||
const minSide = Math.min(safeWidth, safeHeight);
|
||||
const targetJitter = Math.max(
|
||||
appConfig.simulation.intro.minTargetJitterPx,
|
||||
minSide * appConfig.simulation.intro.targetJitterSideRatio
|
||||
);
|
||||
const entryJitter = Math.max(
|
||||
appConfig.simulation.intro.minEntryJitterPx,
|
||||
minSide * appConfig.simulation.intro.entryJitterSideRatio
|
||||
);
|
||||
const titleRadius = points.reduce(
|
||||
(radius, point) =>
|
||||
Math.max(
|
||||
radius,
|
||||
Math.hypot(
|
||||
point.x - safeWidth / 2,
|
||||
point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
|
||||
)
|
||||
),
|
||||
0
|
||||
);
|
||||
const introCircleRadius = Math.min(
|
||||
Math.max(
|
||||
titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
|
||||
minSide * appConfig.simulation.intro.circleMinSideRatio
|
||||
),
|
||||
minSide * appConfig.simulation.intro.circleMaxSideRatio
|
||||
);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const point = points[Math.floor(Math.random() * points.length)];
|
||||
const targetX = Math.max(
|
||||
0,
|
||||
Math.min(safeWidth - 1, point.x + (Math.random() - 0.5) * targetJitter)
|
||||
);
|
||||
const targetY = Math.max(
|
||||
0,
|
||||
Math.min(safeHeight - 1, point.y + (Math.random() - 0.5) * targetJitter)
|
||||
);
|
||||
const [startX, startY] = getIntroRadialStart(
|
||||
targetX,
|
||||
targetY,
|
||||
safeWidth,
|
||||
safeHeight,
|
||||
introCircleRadius,
|
||||
entryJitter
|
||||
);
|
||||
const approachAngle = Math.atan2(targetY - startY, targetX - startX);
|
||||
let targetAngle = point.tangent ?? approachAngle;
|
||||
if (Math.cos(targetAngle - approachAngle) < 0) {
|
||||
targetAngle += Math.PI;
|
||||
}
|
||||
|
||||
const distanceFraction =
|
||||
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
|
||||
const base = i * AGENT_FLOAT_COUNT;
|
||||
data[base] = startX;
|
||||
data[base + 1] = startY;
|
||||
data[base + 2] =
|
||||
approachAngle +
|
||||
(Math.random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
|
||||
data[base + 3] = point.colorIndex;
|
||||
data[base + 4] = targetX;
|
||||
data[base + 5] = targetY;
|
||||
data[base + 6] = targetAngle;
|
||||
data[base + 7] = Math.min(
|
||||
appConfig.simulation.intro.targetDelayMax,
|
||||
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
|
||||
Math.random() * appConfig.simulation.intro.targetDelayRandomMultiplier
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getIntroRadialStart = (
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
jitter: number
|
||||
): [number, number] => {
|
||||
const centerX = width / 2;
|
||||
const centerY = height * appConfig.simulation.intro.verticalAnchor;
|
||||
const offsetX = targetX - centerX;
|
||||
const offsetY = targetY - centerY;
|
||||
const length = Math.hypot(offsetX, offsetY);
|
||||
const angle =
|
||||
length > 0.001 ? Math.atan2(offsetY, offsetX) : Math.random() * Math.PI * 2;
|
||||
const directionX = Math.cos(angle);
|
||||
const directionY = Math.sin(angle);
|
||||
const tangentX = -directionY;
|
||||
const tangentY = directionX;
|
||||
const tangentJitter = (Math.random() - 0.5) * jitter;
|
||||
const radialJitter =
|
||||
(Math.random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
|
||||
const startX =
|
||||
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
|
||||
const startY =
|
||||
centerY + directionY * (radius + radialJitter) + tangentY * tangentJitter;
|
||||
|
||||
return [
|
||||
Math.max(0, Math.min(width - 1, startX)),
|
||||
Math.max(0, Math.min(height - 1, startY)),
|
||||
];
|
||||
};
|
||||
|
||||
const createIntroTitlePoints = (
|
||||
width: number,
|
||||
height: number
|
||||
): Array<IntroTitlePoint> => {
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
maskCanvas.width = width;
|
||||
maskCanvas.height = height;
|
||||
const context = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fontSize = getIntroTitleFontSize(context, width, height);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillStyle = '#fff';
|
||||
context.strokeStyle = '#fff';
|
||||
context.lineJoin = 'round';
|
||||
context.lineWidth = Math.max(
|
||||
appConfig.simulation.intro.titleStrokeWidthMinPx,
|
||||
fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
|
||||
);
|
||||
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
|
||||
drawIntroTitleText(
|
||||
context,
|
||||
width / 2,
|
||||
height * appConfig.simulation.intro.verticalAnchor,
|
||||
letterSpacing,
|
||||
'stroke'
|
||||
);
|
||||
drawIntroTitleText(
|
||||
context,
|
||||
width / 2,
|
||||
height * appConfig.simulation.intro.verticalAnchor,
|
||||
letterSpacing,
|
||||
'fill'
|
||||
);
|
||||
|
||||
const { data } = context.getImageData(0, 0, width, height);
|
||||
const step = Math.max(
|
||||
1,
|
||||
Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity)
|
||||
);
|
||||
const points: Array<IntroTitlePoint> = [];
|
||||
const characterColorBoundaries = getIntroTitleColorBoundaries(
|
||||
context,
|
||||
width,
|
||||
letterSpacing
|
||||
);
|
||||
|
||||
for (let y = 0; y < height; y += step) {
|
||||
for (let x = 0; x < width; x += step) {
|
||||
const alpha = getMaskAlpha(data, width, height, x, y);
|
||||
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
points.push({
|
||||
x,
|
||||
y,
|
||||
tangent: estimateMaskTangent(data, width, height, x, y),
|
||||
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
const getIntroTitleColorBoundaries = (
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
letterSpacing: number
|
||||
): [number, number] => {
|
||||
const letters = Array.from(INTRO_TITLE);
|
||||
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||
let x = width / 2 - totalWidth / 2;
|
||||
const [firstCutLetter, secondCutLetter] =
|
||||
appConfig.simulation.intro.titleColorCutLetters;
|
||||
const letterBoxes = letters.map((letter, index) => {
|
||||
const letterWidth = context.measureText(letter).width;
|
||||
const box = {
|
||||
left: x,
|
||||
right: x + letterWidth,
|
||||
};
|
||||
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
||||
return box;
|
||||
});
|
||||
|
||||
const getBoundaryBetweenLetters = (leftLetterIndex: number) =>
|
||||
(letterBoxes[leftLetterIndex].right + letterBoxes[leftLetterIndex + 1].left) / 2;
|
||||
|
||||
return [
|
||||
getBoundaryBetweenLetters(firstCutLetter - 1),
|
||||
getBoundaryBetweenLetters(secondCutLetter - 1),
|
||||
];
|
||||
};
|
||||
|
||||
const drawIntroTitleText = (
|
||||
context: CanvasRenderingContext2D,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
letterSpacing: number,
|
||||
mode: 'fill' | 'stroke'
|
||||
): void => {
|
||||
const letters = Array.from(INTRO_TITLE);
|
||||
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||
let x = centerX - totalWidth / 2;
|
||||
|
||||
letters.forEach((letter, index) => {
|
||||
const letterWidth = context.measureText(letter).width;
|
||||
const drawX = x + letterWidth / 2;
|
||||
if (mode === 'fill') {
|
||||
context.fillText(letter, drawX, centerY);
|
||||
} else {
|
||||
context.strokeText(letter, drawX, centerY);
|
||||
}
|
||||
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
||||
});
|
||||
};
|
||||
|
||||
const measureIntroTitleText = (
|
||||
context: CanvasRenderingContext2D,
|
||||
letters: Array<string>,
|
||||
letterSpacing: number
|
||||
): number => {
|
||||
const textWidth = letters.reduce(
|
||||
(width, letter) => width + context.measureText(letter).width,
|
||||
0
|
||||
);
|
||||
return textWidth + Math.max(0, letters.length - 1) * letterSpacing;
|
||||
};
|
||||
|
||||
const getIntroTitleColorIndex = (x: number, boundaries: [number, number]): number => {
|
||||
if (x < boundaries[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x < boundaries[1]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
};
|
||||
|
||||
const getIntroTitleFontSize = (
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): number => {
|
||||
const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
|
||||
const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
|
||||
let fontSize = Math.floor(
|
||||
Math.min(
|
||||
height * appConfig.simulation.intro.initialFontHeightRatio,
|
||||
width * appConfig.simulation.intro.initialFontWidthRatio
|
||||
)
|
||||
);
|
||||
|
||||
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
|
||||
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
|
||||
const metrics = context.measureText(INTRO_TITLE);
|
||||
const measuredHeight =
|
||||
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
|
||||
|
||||
if (metrics.width <= maxWidth && measuredHeight <= maxHeight) {
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
|
||||
}
|
||||
|
||||
return fontSize;
|
||||
};
|
||||
|
||||
const estimateMaskTangent = (
|
||||
data: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number
|
||||
): number | null => {
|
||||
const gradientX =
|
||||
getMaskAlpha(data, width, height, x + 1, y) -
|
||||
getMaskAlpha(data, width, height, x - 1, y);
|
||||
const gradientY =
|
||||
getMaskAlpha(data, width, height, x, y + 1) -
|
||||
getMaskAlpha(data, width, height, x, y - 1);
|
||||
|
||||
if (
|
||||
Math.abs(gradientX) + Math.abs(gradientY) <
|
||||
appConfig.simulation.intro.maskGradientThreshold
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.atan2(gradientX, -gradientY);
|
||||
};
|
||||
|
||||
const getMaskAlpha = (
|
||||
data: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number
|
||||
): number => {
|
||||
const clampedX = Math.max(0, Math.min(width - 1, Math.round(x)));
|
||||
const clampedY = Math.max(0, Math.min(height - 1, Math.round(y)));
|
||||
return data[(clampedY * width + clampedX) * 4 + 3];
|
||||
};
|
||||
277
src/game-loop/pointer-input.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type PointerListener = (event: PointerEvent) => void;
|
||||
|
||||
const makePointerEvent = (
|
||||
type: string,
|
||||
event: Partial<PointerEvent> = {}
|
||||
): PointerEvent =>
|
||||
({
|
||||
buttons: 1,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
isTrusted: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
pressure: 0.5,
|
||||
timeStamp: 100,
|
||||
type,
|
||||
...event,
|
||||
}) as PointerEvent;
|
||||
|
||||
const toPoint = (point: ArrayLike<number>): Array<number> => Array.from(point);
|
||||
|
||||
class FakeCanvas {
|
||||
public readonly capturedPointerIds: Array<number> = [];
|
||||
public readonly releasedPointerIds: Array<number> = [];
|
||||
public width = 300;
|
||||
public height = 200;
|
||||
|
||||
private readonly listeners = new Map<string, Set<PointerListener>>();
|
||||
|
||||
public addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject
|
||||
): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set<PointerListener>();
|
||||
const pointerListener =
|
||||
typeof listener === 'function'
|
||||
? listener
|
||||
: (event: Event) => listener.handleEvent(event);
|
||||
listeners.add(pointerListener as PointerListener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
public removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject
|
||||
): void {
|
||||
const listeners = this.listeners.get(type);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
listeners.delete(listener as PointerListener);
|
||||
}
|
||||
|
||||
public dispatchPointerEvent(type: string, event: Partial<PointerEvent> = {}): void {
|
||||
const pointerEvent = makePointerEvent(type, event);
|
||||
|
||||
this.listeners.get(type)?.forEach((listener) => listener(pointerEvent));
|
||||
}
|
||||
|
||||
public getBoundingClientRect(): DOMRect {
|
||||
return {
|
||||
bottom: this.height,
|
||||
height: this.height,
|
||||
left: 0,
|
||||
right: this.width,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width: this.width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
public setPointerCapture(pointerId: number): void {
|
||||
this.capturedPointerIds.push(pointerId);
|
||||
}
|
||||
|
||||
public releasePointerCapture(pointerId: number): void {
|
||||
this.releasedPointerIds.push(pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
const makeSwipePipeline = () => ({
|
||||
addSwipeSegment: vi.fn(),
|
||||
clearSwipes: vi.fn(),
|
||||
});
|
||||
|
||||
const createPointerInput = async () => {
|
||||
const { GardenPointerInput } = await import('./pointer-input');
|
||||
const { settings: runtimeSettings } = await import('../settings');
|
||||
const canvas = new FakeCanvas();
|
||||
const audio = {
|
||||
beginGesture: vi.fn(),
|
||||
endGesture: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
touchDown: vi.fn(),
|
||||
};
|
||||
const brushPipeline = makeSwipePipeline();
|
||||
const eraserAgentPipeline = makeSwipePipeline();
|
||||
const eraserTexturePipeline = makeSwipePipeline();
|
||||
const eraserPreview = {
|
||||
isPointerInsideCanvas: vi.fn(() => true),
|
||||
setEraseMode: vi.fn(),
|
||||
setPointerHoveringCanvas: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
const onStartDrawing = vi.fn();
|
||||
const onEraseGestureEnded = vi.fn();
|
||||
const spawnStrokeAgents = vi.fn();
|
||||
const input = new GardenPointerInput({
|
||||
audio,
|
||||
brushPipeline,
|
||||
canvas: canvas as unknown as HTMLCanvasElement,
|
||||
eraserAgentPipeline,
|
||||
eraserPreview,
|
||||
eraserTexturePipeline,
|
||||
getCanvasSize: () => [canvas.width, canvas.height],
|
||||
getDevicePixelRatio: () => 1,
|
||||
getMirrorSegmentCount: () => 1,
|
||||
onEraseGestureEnded,
|
||||
onStartDrawing,
|
||||
spawnStrokeAgents,
|
||||
} as unknown as ConstructorParameters<typeof GardenPointerInput>[0]);
|
||||
|
||||
input.attach();
|
||||
|
||||
return {
|
||||
audio,
|
||||
brushPipeline,
|
||||
canvas,
|
||||
input,
|
||||
onStartDrawing,
|
||||
runtimeSettings,
|
||||
spawnStrokeAgents,
|
||||
};
|
||||
};
|
||||
|
||||
describe('GardenPointerInput drawing startup', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('localStorage', {
|
||||
clear: vi.fn(),
|
||||
getItem: vi.fn(() => null),
|
||||
removeItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('allows pointer drawing immediately', async () => {
|
||||
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
|
||||
await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 });
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 80,
|
||||
pointerId: 7,
|
||||
timeStamp: 120,
|
||||
});
|
||||
|
||||
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(2);
|
||||
expect(canvas.capturedPointerIds).toEqual([7]);
|
||||
});
|
||||
|
||||
it('starts drawing from a fresh pointerdown', async () => {
|
||||
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
|
||||
await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
|
||||
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.capturedPointerIds).toEqual([9]);
|
||||
});
|
||||
|
||||
it('flushes the delayed smoothed stroke tail on pointerup', async () => {
|
||||
const { brushPipeline, canvas } = await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 80,
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
});
|
||||
canvas.dispatchPointerEvent('pointerup', {
|
||||
clientX: 60,
|
||||
clientY: 80,
|
||||
pointerId: 9,
|
||||
timeStamp: 140,
|
||||
});
|
||||
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]);
|
||||
});
|
||||
|
||||
it('uses coalesced pointer samples for smoother brush segments', async () => {
|
||||
const { audio, brushPipeline, canvas, spawnStrokeAgents } =
|
||||
await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
audio.stroke.mockClear();
|
||||
brushPipeline.addSwipeSegment.mockClear();
|
||||
spawnStrokeAgents.mockClear();
|
||||
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 40,
|
||||
clientY: 20,
|
||||
getCoalescedEvents: () => [
|
||||
makePointerEvent('pointermove', {
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 110,
|
||||
}),
|
||||
makePointerEvent('pointermove', {
|
||||
clientX: 30,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 115,
|
||||
}),
|
||||
makePointerEvent('pointermove', {
|
||||
clientX: 40,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
}),
|
||||
],
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
});
|
||||
|
||||
expect(audio.stroke).toHaveBeenCalledTimes(3);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(3);
|
||||
expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
||||
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
|
||||
runtimeSettings.brushCurveResolution = 2;
|
||||
runtimeSettings.brushSize = 1;
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 10,
|
||||
clientY: 60,
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
});
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 9,
|
||||
timeStamp: 140,
|
||||
});
|
||||
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
406
src/game-loop/pointer-input.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { gardenAudioConfig } from '../audio/garden-audio-config';
|
||||
import { appConfig } from '../config';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { EraserPreview } from './eraser-preview';
|
||||
import { StrokeSegment } from './game-loop-types';
|
||||
|
||||
interface GardenPointerInputOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
audio: GardenAudio;
|
||||
brushPipeline: BrushPipeline;
|
||||
eraserAgentPipeline: EraserAgentPipeline;
|
||||
eraserTexturePipeline: EraserTexturePipeline;
|
||||
eraserPreview: EraserPreview;
|
||||
getCanvasSize: () => vec2;
|
||||
getDevicePixelRatio: () => number;
|
||||
getMirrorSegmentCount: () => number;
|
||||
onStartDrawing: () => void;
|
||||
onEraseGestureEnded: () => void;
|
||||
spawnStrokeAgents: (from: vec2, to: vec2) => void;
|
||||
}
|
||||
|
||||
export class GardenPointerInput {
|
||||
private static readonly MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED = 0.25;
|
||||
private static readonly MIN_CURVE_SEGMENT_SPACING_PIXELS = 4;
|
||||
private static readonly CURVE_SEGMENT_BRUSH_RADIUS_RATIO = 0.65;
|
||||
|
||||
private activePointerId: number | null = null;
|
||||
private lastPointerPosition: vec2 | null = null;
|
||||
private lastPointerEventTimeMs: number | null = null;
|
||||
private lastPointerPressure = 0.5;
|
||||
private smoothedStrokePoints: Array<vec2> = [];
|
||||
private lastSmoothedBrushPosition: vec2 | null = null;
|
||||
private isErasing = false;
|
||||
|
||||
public constructor(private readonly options: GardenPointerInputOptions) {}
|
||||
|
||||
public attach(): void {
|
||||
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.addEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.addEventListener('pointerup', this.onPointerUp);
|
||||
this.canvas.addEventListener('pointercancel', this.onPointerUp);
|
||||
}
|
||||
|
||||
public detach(): void {
|
||||
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.removeEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.removeEventListener('pointerup', this.onPointerUp);
|
||||
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
|
||||
}
|
||||
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
this.isErasing = isErasing;
|
||||
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
|
||||
}
|
||||
|
||||
public updateEraserPreview(event?: PointerEvent): void {
|
||||
this.options.eraserPreview.update(event, this.isSwipeActive);
|
||||
}
|
||||
|
||||
public clearSwipesIfIdle(): void {
|
||||
if (this.isSwipeActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.brushPipeline.clearSwipes();
|
||||
this.options.eraserAgentPipeline.clearSwipes();
|
||||
this.options.eraserTexturePipeline.clearSwipes();
|
||||
}
|
||||
|
||||
public scaleLastPointerPosition(scale: vec2): void {
|
||||
if (this.lastPointerPosition !== null) {
|
||||
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
|
||||
}
|
||||
|
||||
this.smoothedStrokePoints.forEach((point) => {
|
||||
vec2.mul(point, point, scale);
|
||||
});
|
||||
|
||||
if (this.lastSmoothedBrushPosition !== null) {
|
||||
vec2.mul(this.lastSmoothedBrushPosition, this.lastSmoothedBrushPosition, scale);
|
||||
}
|
||||
}
|
||||
|
||||
public get isSwipeActive(): boolean {
|
||||
return this.activePointerId !== null;
|
||||
}
|
||||
|
||||
public get isEraseMode(): boolean {
|
||||
return this.isErasing;
|
||||
}
|
||||
|
||||
private get canvas(): HTMLCanvasElement {
|
||||
return this.options.canvas;
|
||||
}
|
||||
|
||||
private readonly onPointerDown = (event: PointerEvent) => {
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||
this.updateEraserPreview(event);
|
||||
if (this.activePointerId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||
this.options.audio.beginGesture();
|
||||
this.options.audio.touchDown({
|
||||
vibe: activeVibe,
|
||||
colorIndex: settings.selectedColorIndex,
|
||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
||||
pressure: this.getPointerPressure(event),
|
||||
pointerType: event.pointerType,
|
||||
});
|
||||
this.options.onStartDrawing();
|
||||
this.activePointerId = event.pointerId;
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
this.options.brushPipeline.clearSwipes();
|
||||
this.options.eraserAgentPipeline.clearSwipes();
|
||||
this.options.eraserTexturePipeline.clearSwipes();
|
||||
this.lastPointerPosition = null;
|
||||
this.lastPointerEventTimeMs = null;
|
||||
this.clearSmoothedStroke();
|
||||
this.lastPointerPressure = this.getPointerPressure(event);
|
||||
this.addSwipeAt(event, { emitAudio: false });
|
||||
};
|
||||
|
||||
private readonly onPointerMove = (event: PointerEvent) => {
|
||||
this.updateEraserPreview(event);
|
||||
if (event.pointerId !== this.activePointerId) {
|
||||
return;
|
||||
}
|
||||
this.getCoalescedPointerEvents(event).forEach((coalescedEvent) => {
|
||||
this.addSwipeAt(coalescedEvent);
|
||||
});
|
||||
};
|
||||
|
||||
private readonly onPointerUp = (event: PointerEvent) => {
|
||||
if (event.pointerId !== this.activePointerId) {
|
||||
return;
|
||||
}
|
||||
this.addSwipeAt(event, { emitAudio: false });
|
||||
this.finishSmoothedStroke();
|
||||
this.options.audio.endGesture();
|
||||
if (this.isErasing) {
|
||||
this.options.onEraseGestureEnded();
|
||||
}
|
||||
this.canvas.releasePointerCapture(event.pointerId);
|
||||
this.activePointerId = null;
|
||||
this.lastPointerPosition = null;
|
||||
this.lastPointerEventTimeMs = null;
|
||||
this.clearSmoothedStroke();
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(
|
||||
this.options.eraserPreview.isPointerInsideCanvas(event)
|
||||
);
|
||||
this.updateEraserPreview(event);
|
||||
};
|
||||
|
||||
private readonly onPointerEnter = (event: PointerEvent) => {
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||
this.updateEraserPreview(event);
|
||||
};
|
||||
|
||||
private readonly onPointerLeave = () => {
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(false);
|
||||
this.updateEraserPreview();
|
||||
};
|
||||
|
||||
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const devicePixelRatio = this.options.getDevicePixelRatio();
|
||||
const position = vec2.fromValues(
|
||||
(event.clientX - rect.left) * devicePixelRatio,
|
||||
(event.clientY - rect.top) * devicePixelRatio
|
||||
);
|
||||
const previousPosition = this.lastPointerPosition ?? position;
|
||||
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
||||
const elapsedSeconds = Math.max(
|
||||
appConfig.deltaTime.minDeltaTimeSeconds,
|
||||
(event.timeStamp - previousTimeMs) / 1000
|
||||
);
|
||||
const distancePixels = vec2.distance(previousPosition, position);
|
||||
const velocityPixelsPerSecond = distancePixels / elapsedSeconds;
|
||||
const pressure = this.getPointerPressure(event);
|
||||
this.lastPointerPressure = pressure > 0 ? pressure : this.lastPointerPressure;
|
||||
|
||||
const segments = this.isErasing
|
||||
? [{ from: previousPosition, to: position }]
|
||||
: this.getMirroredStrokeSegments(previousPosition, position);
|
||||
|
||||
if (this.isErasing) {
|
||||
segments.forEach((segment) => {
|
||||
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
|
||||
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
|
||||
});
|
||||
} else {
|
||||
this.addSmoothedBrushSample(position);
|
||||
}
|
||||
|
||||
if (!this.isErasing) {
|
||||
segments.forEach((segment) => {
|
||||
this.options.spawnStrokeAgents(segment.from, segment.to);
|
||||
});
|
||||
}
|
||||
if (options.emitAudio !== false) {
|
||||
this.options.audio.stroke({
|
||||
vibe: activeVibe,
|
||||
from: previousPosition,
|
||||
to: position,
|
||||
canvasSize: this.options.getCanvasSize(),
|
||||
colorIndex: settings.selectedColorIndex,
|
||||
isErasing: this.isErasing,
|
||||
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
|
||||
velocityPixelsPerSecond,
|
||||
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
||||
pointerType: event.pointerType,
|
||||
});
|
||||
}
|
||||
this.lastPointerPosition = position;
|
||||
this.lastPointerEventTimeMs = event.timeStamp;
|
||||
}
|
||||
|
||||
private addSmoothedBrushSample(position: vec2): void {
|
||||
const previousSample =
|
||||
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
||||
if (
|
||||
previousSample !== undefined &&
|
||||
vec2.squaredDistance(previousSample, position) <=
|
||||
GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.smoothedStrokePoints.push(vec2.clone(position));
|
||||
|
||||
if (this.smoothedStrokePoints.length > 3) {
|
||||
this.smoothedStrokePoints.shift();
|
||||
}
|
||||
|
||||
if (this.smoothedStrokePoints.length === 1) {
|
||||
this.addMirroredBrushSegment(position, position);
|
||||
this.lastSmoothedBrushPosition = vec2.clone(position);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.smoothedStrokePoints.length === 2) {
|
||||
const [start, end] = this.smoothedStrokePoints;
|
||||
const midpoint = getMidpoint(start, end);
|
||||
this.addMirroredBrushSegment(start, midpoint);
|
||||
this.lastSmoothedBrushPosition = midpoint;
|
||||
return;
|
||||
}
|
||||
|
||||
const [start, control, end] = this.smoothedStrokePoints;
|
||||
const curveStart = getMidpoint(start, control);
|
||||
const curveEnd = getMidpoint(control, end);
|
||||
this.addQuadraticBrushSegments(curveStart, control, curveEnd);
|
||||
this.lastSmoothedBrushPosition = curveEnd;
|
||||
}
|
||||
|
||||
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
|
||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||
const brushRadius = Math.max(1, settings.brushSize / 2);
|
||||
const segmentSpacing = Math.max(
|
||||
GardenPointerInput.MIN_CURVE_SEGMENT_SPACING_PIXELS,
|
||||
brushRadius * GardenPointerInput.CURVE_SEGMENT_BRUSH_RADIUS_RATIO
|
||||
);
|
||||
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||
const curveResolution = getBrushCurveResolution();
|
||||
const maxCurveSegments = Math.max(
|
||||
1,
|
||||
Math.floor(curveResolution / Math.sqrt(mirrorSegmentCount))
|
||||
);
|
||||
const segmentCount = Math.min(
|
||||
maxCurveSegments,
|
||||
Math.max(1, Math.ceil(curveLength / segmentSpacing))
|
||||
);
|
||||
|
||||
let previousPoint = start;
|
||||
for (let i = 1; i <= segmentCount; i++) {
|
||||
const point = getQuadraticPoint(start, control, end, i / segmentCount);
|
||||
this.addMirroredBrushSegment(previousPoint, point);
|
||||
previousPoint = point;
|
||||
}
|
||||
}
|
||||
|
||||
private addMirroredBrushSegment(from: vec2, to: vec2): void {
|
||||
this.getMirroredStrokeSegments(from, to).forEach((segment) => {
|
||||
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
|
||||
});
|
||||
}
|
||||
|
||||
private finishSmoothedStroke(): void {
|
||||
if (this.isErasing || this.smoothedStrokePoints.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
||||
if (
|
||||
this.lastSmoothedBrushPosition !== null &&
|
||||
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
|
||||
GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
|
||||
) {
|
||||
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
|
||||
}
|
||||
}
|
||||
|
||||
private clearSmoothedStroke(): void {
|
||||
this.smoothedStrokePoints.length = 0;
|
||||
this.lastSmoothedBrushPosition = null;
|
||||
}
|
||||
|
||||
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {
|
||||
const getCoalescedEvents = (
|
||||
event as PointerEvent & { getCoalescedEvents?: () => Array<PointerEvent> }
|
||||
).getCoalescedEvents;
|
||||
const coalescedEvents =
|
||||
typeof getCoalescedEvents === 'function' ? getCoalescedEvents.call(event) : [];
|
||||
|
||||
if (coalescedEvents.length === 0) {
|
||||
return [event];
|
||||
}
|
||||
|
||||
const lastEvent = coalescedEvents[coalescedEvents.length - 1];
|
||||
return isSamePointerSample(lastEvent, event)
|
||||
? coalescedEvents
|
||||
: [...coalescedEvents, event];
|
||||
}
|
||||
|
||||
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> {
|
||||
const segmentCount = this.options.getMirrorSegmentCount();
|
||||
if (segmentCount <= 1) {
|
||||
return [{ from, to }];
|
||||
}
|
||||
|
||||
const center = vec2.fromValues(this.canvas.width / 2, this.canvas.height / 2);
|
||||
const angleStep = (Math.PI * 2) / segmentCount;
|
||||
const segments: Array<StrokeSegment> = [];
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
const angle = angleStep * i;
|
||||
segments.push({
|
||||
from: rotatePointAround(from, center, angle),
|
||||
to: rotatePointAround(to, center, angle),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private getPointerPressure(event: PointerEvent): number {
|
||||
if (Number.isFinite(event.pressure) && event.pressure > 0) {
|
||||
return Math.min(1, Math.max(0, event.pressure));
|
||||
}
|
||||
|
||||
return event.buttons > 0 || event.type === 'pointerdown'
|
||||
? gardenAudioConfig.input.pressureFallback
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
|
||||
if (angle === 0) {
|
||||
return point;
|
||||
}
|
||||
|
||||
const offsetX = point[0] - center[0];
|
||||
const offsetY = point[1] - center[1];
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
return vec2.fromValues(
|
||||
center[0] + offsetX * cos - offsetY * sin,
|
||||
center[1] + offsetX * sin + offsetY * cos
|
||||
);
|
||||
};
|
||||
|
||||
const getMidpoint = (from: vec2, to: vec2): vec2 =>
|
||||
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
|
||||
|
||||
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
|
||||
const inverseT = 1 - t;
|
||||
return vec2.fromValues(
|
||||
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
|
||||
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
|
||||
);
|
||||
};
|
||||
|
||||
const getBrushCurveResolution = (): number => {
|
||||
const resolution = Number.isFinite(settings.brushCurveResolution)
|
||||
? settings.brushCurveResolution
|
||||
: appConfig.runtimeSettings.defaults.brushCurveResolution;
|
||||
return Math.max(1, Math.floor(resolution));
|
||||
};
|
||||
|
||||
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
||||
left.clientX === right.clientX &&
|
||||
left.clientY === right.clientY &&
|
||||
left.pressure === right.pressure &&
|
||||
left.buttons === right.buttons;
|
||||
40
src/game-loop/render-input-cache.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { activeVibe } from '../settings';
|
||||
import { hexToRgb } from '../vibes';
|
||||
import { RenderInputs } from './game-loop-types';
|
||||
|
||||
export class RenderInputCache {
|
||||
private cachedVibeId: string | null = null;
|
||||
private cachedRenderInputs?: RenderInputs;
|
||||
private previousAccentColor = '';
|
||||
|
||||
public invalidate(): void {
|
||||
this.cachedVibeId = null;
|
||||
this.cachedRenderInputs = undefined;
|
||||
}
|
||||
|
||||
public get(): RenderInputs {
|
||||
if (this.cachedRenderInputs && this.cachedVibeId === activeVibe.id) {
|
||||
return this.cachedRenderInputs;
|
||||
}
|
||||
|
||||
this.cachedVibeId = activeVibe.id;
|
||||
this.cachedRenderInputs = {
|
||||
channelColors: activeVibe.colors.map(hexToRgb),
|
||||
backgroundColor: hexToRgb(activeVibe.backgroundColor),
|
||||
};
|
||||
|
||||
return this.cachedRenderInputs;
|
||||
}
|
||||
|
||||
public updateAccentColor(color: [number, number, number]): void {
|
||||
const accentColor = `rgb(${Math.round(color[0] * 255)},${Math.round(
|
||||
color[1] * 255
|
||||
)},${Math.round(color[2] * 255)})`;
|
||||
if (this.previousAccentColor === accentColor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousAccentColor = accentColor;
|
||||
document.documentElement.style.setProperty('--accent-color', accentColor);
|
||||
}
|
||||
}
|
||||
99
src/game-loop/simulation-frame.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import { SimulationTextures } from './simulation-textures';
|
||||
|
||||
interface SimulationFramePipelines {
|
||||
copyPipeline: CopyPipeline;
|
||||
agentPipeline: AgentPipeline;
|
||||
brushPipeline: BrushPipeline;
|
||||
eraserAgentPipeline: EraserAgentPipeline;
|
||||
eraserTexturePipeline: EraserTexturePipeline;
|
||||
diffusionPipeline: DiffusionPipeline;
|
||||
brushEffectDiffusionPipeline: DiffusionPipeline;
|
||||
renderPipeline: RenderPipeline;
|
||||
}
|
||||
|
||||
export class SimulationFrameRenderer {
|
||||
public constructor(
|
||||
private readonly device: GPUDevice,
|
||||
private readonly textures: SimulationTextures,
|
||||
private readonly pipelines: SimulationFramePipelines
|
||||
) {}
|
||||
|
||||
public execute(renderSpeed: number, isErasing: boolean): void {
|
||||
for (let i = 0; i < renderSpeed; i++) {
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
|
||||
this.pipelines.copyPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView()
|
||||
);
|
||||
if (isErasing) {
|
||||
this.pipelines.eraserTexturePipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.sourceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.eraserTexturePipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.influenceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.eraserTexturePipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapB.getTextureView()
|
||||
);
|
||||
this.pipelines.eraserAgentPipeline.execute(commandEncoder);
|
||||
} else {
|
||||
this.pipelines.brushPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.sourceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.brushPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.influenceMapA.getTextureView()
|
||||
);
|
||||
}
|
||||
this.pipelines.agentPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.influenceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.trailMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.renderPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.sourceMapA.getTextureView()
|
||||
);
|
||||
this.pipelines.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
this.textures.sourceMapB.getTextureView()
|
||||
);
|
||||
this.pipelines.brushEffectDiffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.influenceMapA.getTextureView(),
|
||||
this.textures.influenceMapB.getTextureView()
|
||||
);
|
||||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
this.textures.swapSourceMaps();
|
||||
this.textures.swapInfluenceMaps();
|
||||
}
|
||||
}
|
||||
|
||||
public clearSwipes(): void {
|
||||
this.pipelines.brushPipeline.clearSwipes();
|
||||
this.pipelines.eraserAgentPipeline.clearSwipes();
|
||||
this.pipelines.eraserTexturePipeline.clearSwipes();
|
||||
}
|
||||
}
|
||||
58
src/game-loop/simulation-textures.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
||||
|
||||
export class SimulationTextures {
|
||||
public readonly trailMapA: ResizableTexture;
|
||||
public readonly trailMapB: ResizableTexture;
|
||||
public sourceMapA: ResizableTexture;
|
||||
public sourceMapB: ResizableTexture;
|
||||
public influenceMapA: ResizableTexture;
|
||||
public influenceMapB: ResizableTexture;
|
||||
|
||||
public constructor(
|
||||
private readonly device: GPUDevice,
|
||||
canvasSize: vec2
|
||||
) {
|
||||
this.trailMapA = new ResizableTexture(this.device, canvasSize);
|
||||
this.trailMapB = new ResizableTexture(this.device, canvasSize);
|
||||
this.sourceMapA = new ResizableTexture(this.device, canvasSize);
|
||||
this.sourceMapB = new ResizableTexture(this.device, canvasSize);
|
||||
this.influenceMapA = new ResizableTexture(this.device, canvasSize);
|
||||
this.influenceMapB = new ResizableTexture(this.device, canvasSize);
|
||||
}
|
||||
|
||||
public resizeTo(nextSize: vec2): vec2 | null {
|
||||
const previousSize = this.trailMapA.getSize();
|
||||
if (vec2.equals(previousSize, nextSize)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scale = vec2.div(vec2.create(), nextSize, previousSize);
|
||||
this.trailMapA.resize(nextSize);
|
||||
this.trailMapB.resize(nextSize);
|
||||
this.sourceMapA.resize(nextSize);
|
||||
this.sourceMapB.resize(nextSize);
|
||||
this.influenceMapA.resize(nextSize);
|
||||
this.influenceMapB.resize(nextSize);
|
||||
|
||||
return scale;
|
||||
}
|
||||
|
||||
public swapSourceMaps(): void {
|
||||
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
|
||||
}
|
||||
|
||||
public swapInfluenceMaps(): void {
|
||||
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.trailMapA.destroy();
|
||||
this.trailMapB.destroy();
|
||||
this.sourceMapA.destroy();
|
||||
this.sourceMapB.destroy();
|
||||
this.influenceMapA.destroy();
|
||||
this.influenceMapB.destroy();
|
||||
}
|
||||
}
|
||||
65
src/index.dom-contract.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const indexSource = readFileSync(join(projectRoot, 'src/index.ts'), 'utf8');
|
||||
const html = readFileSync(join(projectRoot, 'index.html'), 'utf8');
|
||||
|
||||
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const hasClass = (className: string, tagName?: string) => {
|
||||
const tagPattern = tagName ? `<${tagName}\\b[^>]*` : '<[a-z][^>]*';
|
||||
return new RegExp(
|
||||
`${tagPattern}class="[^"]*\\b${escapeRegex(className)}\\b[^"]*"`,
|
||||
'i'
|
||||
).test(html);
|
||||
};
|
||||
|
||||
const hasId = (id: string) => new RegExp(`\\bid="${escapeRegex(id)}"`, 'i').test(html);
|
||||
|
||||
const hasTag = (tagName: string) =>
|
||||
new RegExp(`<${escapeRegex(tagName)}(?:\\s|>|/)`, 'i').test(html);
|
||||
|
||||
const selectorExists = (selector: string) => {
|
||||
const idSelector = /^#(?<id>[\w-]+)$/.exec(selector);
|
||||
if (idSelector?.groups?.id) {
|
||||
return hasId(idSelector.groups.id);
|
||||
}
|
||||
|
||||
const classSelector = /^\.([\w-]+)$/.exec(selector);
|
||||
if (classSelector?.[1]) {
|
||||
return hasClass(classSelector[1]);
|
||||
}
|
||||
|
||||
const tagClassSelector = /^(?<tagName>[a-z]+)\.(?<className>[\w-]+)$/.exec(selector);
|
||||
if (tagClassSelector?.groups) {
|
||||
return hasClass(tagClassSelector.groups.className, tagClassSelector.groups.tagName);
|
||||
}
|
||||
|
||||
if (/^[a-z]+$/.test(selector)) {
|
||||
return hasTag(selector);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported selector contract syntax: ${selector}`);
|
||||
};
|
||||
|
||||
describe('index DOM selector contract', () => {
|
||||
it('keeps every boot-time required selector target present in index.html', () => {
|
||||
const selectors = Array.from(
|
||||
indexSource.matchAll(/queryRequiredElements?\(\s*'([^']+)'\s*,/g),
|
||||
(match) => match[1]
|
||||
);
|
||||
|
||||
expect(selectors.length).toBeGreaterThan(0);
|
||||
expect(selectors.filter((selector) => !selectorExists(selector))).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the three color swatches expected by the palette UI', () => {
|
||||
const colorSwatchCount = Array.from(
|
||||
html.matchAll(/class="[^"]*\bcolor-swatch\b[^"]*"/g)
|
||||
).length;
|
||||
|
||||
expect(colorSwatchCount).toBe(3);
|
||||
});
|
||||
});
|
||||
282
src/index.scss
|
|
@ -1,275 +1,9 @@
|
|||
@use 'style/mixins' as *;
|
||||
@use 'style/common';
|
||||
|
||||
html > body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
> .canvas-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
> canvas {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
touch-action: none;
|
||||
cursor:
|
||||
url('../assets/icons/brush.svg') 0 24,
|
||||
auto;
|
||||
}
|
||||
|
||||
> .errors-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: var(--normal-margin);
|
||||
|
||||
pre {
|
||||
font-size: 20px;
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.counters {
|
||||
@include blurred-background(white);
|
||||
position: absolute;
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--small-margin);
|
||||
|
||||
@include on-large-screen {
|
||||
top: var(--normal-margin);
|
||||
right: var(--normal-margin);
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
bottom: var(--normal-margin);
|
||||
right: var(--normal-margin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> aside {
|
||||
@include blurred-background(#fff);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
|
||||
@include on-large-screen {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
max-height: 350px;
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
transition: opacity var(--transition-time-long);
|
||||
border-radius: var(--border-radius);
|
||||
margin: var(--small-margin);
|
||||
|
||||
> nav.buttons {
|
||||
@include center-children;
|
||||
justify-content: space-evenly;
|
||||
|
||||
@include on-large-screen {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
> button {
|
||||
position: relative;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
@include square(var(--icon-size));
|
||||
margin: var(--small-margin);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-color: var(--accent-color);
|
||||
|
||||
@include on-large-screen {
|
||||
width: 0;
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
transition:
|
||||
background-color var(--transition-time),
|
||||
width var(--transition-time);
|
||||
left: calc(-1 * var(--small-margin));
|
||||
height: 140%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
height: 0;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
transition:
|
||||
background-color var(--transition-time),
|
||||
height var(--transition-time);
|
||||
top: calc(-1 * var(--small-margin));
|
||||
width: 140%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-color: var(--accent-color);
|
||||
|
||||
transition:
|
||||
transform var(--transition-time),
|
||||
background-color var(--transition-time);
|
||||
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
@include square(var(--icon-size));
|
||||
}
|
||||
|
||||
&.active {
|
||||
&::before {
|
||||
@include on-large-screen {
|
||||
width: calc(100% + 2 * var(--small-margin));
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
height: calc(100% + 2 * var(--small-margin));
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&.info::after {
|
||||
mask-image: url('../assets/icons/info.svg');
|
||||
}
|
||||
&.maximize-full-screen::after {
|
||||
mask-image: url('../assets/icons/maximize.svg');
|
||||
}
|
||||
&.minimize-full-screen::after {
|
||||
mask-image: url('../assets/icons/minimize.svg');
|
||||
}
|
||||
&.settings::after {
|
||||
mask-image: url('../assets/icons/settings.svg');
|
||||
}
|
||||
&.restart::after {
|
||||
mask-image: url('../assets/icons/restart.svg');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> main.pages {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--main-color) transparent;
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar {
|
||||
background-color: transparent;
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--main-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
&,
|
||||
> * {
|
||||
transition:
|
||||
width var(--transition-time-long),
|
||||
height var(--transition-time-long);
|
||||
|
||||
@include on-large-screen {
|
||||
width: max(500px, 10vw);
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
height: max(500px, 70vh);
|
||||
}
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
@include on-large-screen {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> section {
|
||||
padding: var(--normal-margin);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.slider {
|
||||
$track-height: 12px;
|
||||
margin-bottom: var(--small-margin);
|
||||
user-select: none;
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
height: $track-height;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
border-radius: 1000px;
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
border-radius: 1000px;
|
||||
@include square(15px);
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
border-radius: 1000px;
|
||||
$size: 24px;
|
||||
@include square($size);
|
||||
background: white;
|
||||
box-shadow: 0 0 5px 1px var(--accent-color);
|
||||
|
||||
transform: translateY(-5px);
|
||||
transition: transform var(--transition-time);
|
||||
&:hover {
|
||||
transform: translateY(-5px) scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@use 'style/app-shell';
|
||||
@use 'style/garden-prompt';
|
||||
@use 'style/control-dock';
|
||||
@use 'style/toolbar';
|
||||
@use 'style/panels';
|
||||
@use 'style/config-pane';
|
||||
@use 'style/loading';
|
||||
@use 'style/motion';
|
||||
|
|
|
|||
373
src/index.ts
|
|
@ -1,36 +1,196 @@
|
|||
import { isProduction } from './constants';
|
||||
import GameLoop from './game-loop/game-loop';
|
||||
import { GameRules } from './game-loop/game-rules';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
import { appConfig } from './config';
|
||||
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
|
||||
import { ConfigPane } from './page/config-pane';
|
||||
import { FullScreenHandler } from './page/full-screen-handler';
|
||||
import { MenuHider } from './page/menu-hider';
|
||||
import { setUpSettingsPage } from './page/set-up-settings-page';
|
||||
import { SettingsSlider } from './page/settings-slider';
|
||||
import { resetSettings } from './settings';
|
||||
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
|
||||
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
|
||||
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
||||
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
|
||||
import { ErrorHandler, Severity } from './utils/error-handler';
|
||||
import { initializeGpu } from './utils/graphics/initialize-gpu';
|
||||
import { VIBE_PRESETS } from './vibes';
|
||||
|
||||
const clampEraserSize = (value: number): number => {
|
||||
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.eraser.default;
|
||||
return Math.min(
|
||||
appConfig.toolbar.eraser.max,
|
||||
Math.max(appConfig.toolbar.eraser.min, Math.round(safeValue))
|
||||
);
|
||||
};
|
||||
|
||||
const getEraserSizeRatio = (size: number): number =>
|
||||
(size - appConfig.toolbar.eraser.min) /
|
||||
(appConfig.toolbar.eraser.max - appConfig.toolbar.eraser.min);
|
||||
|
||||
const clampMirrorSegmentCount = (value: number): number => {
|
||||
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.mirror.default;
|
||||
return Math.min(
|
||||
appConfig.toolbar.mirror.max,
|
||||
Math.max(appConfig.toolbar.mirror.min, Math.round(safeValue))
|
||||
);
|
||||
};
|
||||
|
||||
const getMirrorSegmentRatio = (count: number): number =>
|
||||
(count - appConfig.toolbar.mirror.min) /
|
||||
(appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min);
|
||||
|
||||
const mirrorSegmentNames: Readonly<Record<number, string>> =
|
||||
appConfig.toolbar.mirror.names;
|
||||
|
||||
const formatMirrorSegmentCount = (count: number): string =>
|
||||
count === appConfig.toolbar.mirror.default
|
||||
? 'Mirror off'
|
||||
: `${count} ${mirrorSegmentNames[count] ?? 'slices'}`;
|
||||
|
||||
const renderRuntimeMessage = (
|
||||
container: HTMLElement,
|
||||
error: Parameters<Parameters<typeof ErrorHandler.addOnErrorListener>[0]>[0]
|
||||
) => {
|
||||
const message = document.createElement('pre');
|
||||
message.className = error.severity;
|
||||
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
|
||||
message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status');
|
||||
message.setAttribute(
|
||||
'aria-live',
|
||||
error.severity === Severity.ERROR ? 'assertive' : 'polite'
|
||||
);
|
||||
container.append(message);
|
||||
|
||||
if (error.severity === Severity.ERROR) {
|
||||
message.tabIndex = -1;
|
||||
message.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
const elements = {
|
||||
aside: document.querySelector('aside') as HTMLDivElement,
|
||||
infoButton: document.querySelector('button.info') as HTMLButtonElement,
|
||||
infoElement: document.querySelector('.info-page') as HTMLDivElement,
|
||||
settingsPage: document.querySelector('.settings-page') as HTMLDivElement,
|
||||
settingsContent: document.querySelector('.settings-content') as HTMLDivElement,
|
||||
applyDefaults: document.querySelector('#apply-defaults') as HTMLButtonElement,
|
||||
minimizeFullScreenButton: document.querySelector(
|
||||
'button.minimize-full-screen'
|
||||
) as HTMLButtonElement,
|
||||
maximizeFullScreenButton: document.querySelector(
|
||||
'button.maximize-full-screen'
|
||||
) as HTMLButtonElement,
|
||||
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
|
||||
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
|
||||
canvas: document.querySelector('canvas') as HTMLCanvasElement,
|
||||
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
|
||||
aside: queryRequiredElement('aside', HTMLDivElement),
|
||||
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
|
||||
infoElement: queryRequiredElement('.info-page', HTMLDivElement),
|
||||
minimizeFullScreenButton: queryRequiredElement(
|
||||
'button.minimize-full-screen',
|
||||
HTMLButtonElement
|
||||
),
|
||||
maximizeFullScreenButton: queryRequiredElement(
|
||||
'button.maximize-full-screen',
|
||||
HTMLButtonElement
|
||||
),
|
||||
settingsButton: queryRequiredElement('button.settings', HTMLButtonElement),
|
||||
soundButton: queryRequiredElement('button.sound', HTMLButtonElement),
|
||||
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
|
||||
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
|
||||
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
|
||||
errorContainer: queryRequiredElement('.errors-container', HTMLDivElement),
|
||||
previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement),
|
||||
nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement),
|
||||
swatches: queryRequiredElements('.color-swatch', HTMLButtonElement),
|
||||
eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement),
|
||||
eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement),
|
||||
mirrorSegmentControl: queryRequiredElement(
|
||||
'.mirror-segment-control',
|
||||
HTMLLabelElement
|
||||
),
|
||||
mirrorSegmentSlider: queryRequiredElement(
|
||||
'.mirror-segment-slider',
|
||||
HTMLInputElement
|
||||
),
|
||||
export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
|
||||
exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
|
||||
prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
|
||||
loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement),
|
||||
loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
|
||||
loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
|
||||
};
|
||||
|
||||
const setLoadingStage = (label: string, ratio: number) => {
|
||||
const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
|
||||
elements.loadingStatus.textContent = label;
|
||||
elements.loadingIndicator.style.setProperty('--loading-progress', `${percent}%`);
|
||||
elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
|
||||
};
|
||||
|
||||
let isAudioMuted = readBrowserStorage(appConfig.storage.audioMutedKey) === '1';
|
||||
|
||||
const renderAudioUi = (game: GameLoop | null) => {
|
||||
elements.soundButton.classList.toggle('muted', isAudioMuted);
|
||||
elements.soundButton.setAttribute('aria-pressed', String(isAudioMuted));
|
||||
elements.soundButton.setAttribute(
|
||||
'aria-label',
|
||||
isAudioMuted ? 'Unmute audio' : 'Mute audio'
|
||||
);
|
||||
elements.soundButton.title = isAudioMuted ? 'Unmute audio' : 'Mute audio';
|
||||
game?.setAudioMuted(isAudioMuted);
|
||||
};
|
||||
|
||||
const renderPaletteUi = (game: GameLoop | null) => {
|
||||
const isErasing = elements.eraserSizeControl.dataset.active === '1';
|
||||
elements.swatches.forEach((swatch, index) => {
|
||||
swatch.style.backgroundColor = activeVibe.colors[index];
|
||||
swatch.classList.toggle(
|
||||
'active',
|
||||
settings.selectedColorIndex === index && !isErasing
|
||||
);
|
||||
});
|
||||
elements.eraserSizeControl.classList.toggle('active', isErasing);
|
||||
game?.setEraseMode(isErasing);
|
||||
document.documentElement.style.setProperty(
|
||||
'--garden-background',
|
||||
activeVibe.backgroundColor
|
||||
);
|
||||
game?.onVibeChanged();
|
||||
};
|
||||
|
||||
const renderEraserSizeUi = (game: GameLoop | null) => {
|
||||
const size = clampEraserSize(settings.eraserSize);
|
||||
if (settings.eraserSize !== size) {
|
||||
settings.eraserSize = size;
|
||||
}
|
||||
|
||||
elements.eraserSizeSlider.min = appConfig.toolbar.eraser.min.toString();
|
||||
elements.eraserSizeSlider.max = appConfig.toolbar.eraser.max.toString();
|
||||
elements.eraserSizeSlider.step = appConfig.toolbar.eraser.step.toString();
|
||||
elements.eraserSizeSlider.value = size.toString();
|
||||
elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
|
||||
|
||||
const ratio = getEraserSizeRatio(size);
|
||||
const scale =
|
||||
appConfig.toolbar.eraser.controlScaleMin +
|
||||
(appConfig.toolbar.eraser.controlScaleMax -
|
||||
appConfig.toolbar.eraser.controlScaleMin) *
|
||||
ratio;
|
||||
elements.eraserSizeControl.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
||||
elements.eraserSizeControl.style.setProperty(
|
||||
'--eraser-control-scale',
|
||||
scale.toFixed(3)
|
||||
);
|
||||
game?.updateEraserPreview();
|
||||
};
|
||||
|
||||
const renderMirrorSegmentUi = () => {
|
||||
const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
|
||||
if (settings.mirrorSegmentCount !== count) {
|
||||
settings.mirrorSegmentCount = count;
|
||||
}
|
||||
|
||||
elements.mirrorSegmentSlider.min = appConfig.toolbar.mirror.min.toString();
|
||||
elements.mirrorSegmentSlider.max = appConfig.toolbar.mirror.max.toString();
|
||||
elements.mirrorSegmentSlider.step = appConfig.toolbar.mirror.step.toString();
|
||||
elements.mirrorSegmentSlider.value = count.toString();
|
||||
|
||||
const label = formatMirrorSegmentCount(count);
|
||||
const ratio = getMirrorSegmentRatio(count);
|
||||
elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
|
||||
elements.mirrorSegmentControl.title = label;
|
||||
elements.mirrorSegmentControl.classList.toggle('active', count > 1);
|
||||
elements.mirrorSegmentControl.style.setProperty('--mirror-progress', `${ratio * 100}%`);
|
||||
elements.mirrorSegmentControl.style.setProperty(
|
||||
'--mirror-angle',
|
||||
`${(360 / count).toFixed(3)}deg`
|
||||
);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
|
|
@ -38,37 +198,51 @@ const main = async () => {
|
|||
let shouldStop = false;
|
||||
let game: GameLoop | null = null;
|
||||
|
||||
elements.errorContainer.setAttribute('aria-live', 'assertive');
|
||||
ErrorHandler.addOnErrorListener((error, _metadata) => {
|
||||
elements.errorContainer.innerHTML += `
|
||||
<pre class="${error.severity}">${error.message}</div>
|
||||
`;
|
||||
renderRuntimeMessage(elements.errorContainer, error);
|
||||
if (error.severity === Severity.ERROR) {
|
||||
document.body.classList.remove('is-loading');
|
||||
game?.destroy();
|
||||
shouldStop = true;
|
||||
}
|
||||
});
|
||||
|
||||
const syncRuntimeUi = () => {
|
||||
renderEraserSizeUi(game);
|
||||
renderMirrorSegmentUi();
|
||||
renderPaletteUi(game);
|
||||
};
|
||||
|
||||
const infoPageHandler = new CollapsiblePanelAnimator(
|
||||
elements.infoButton,
|
||||
elements.infoElement,
|
||||
elements.aside
|
||||
);
|
||||
const settingsPageHandler = new CollapsiblePanelAnimator(
|
||||
elements.settingsButton,
|
||||
elements.settingsPage,
|
||||
elements.aside
|
||||
);
|
||||
settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler);
|
||||
infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler);
|
||||
|
||||
if (isProduction) {
|
||||
infoPageHandler.open();
|
||||
}
|
||||
const configPane = new ConfigPane({
|
||||
settingsButton: elements.settingsButton,
|
||||
onConfigChange: syncRuntimeUi,
|
||||
onRuntimeChange: syncRuntimeUi,
|
||||
onRuntimeReset: () => {
|
||||
resetSettings();
|
||||
syncRuntimeUi();
|
||||
},
|
||||
onRestart: () => game?.destroy(),
|
||||
onVibeChange: (vibeId) => {
|
||||
applyVibeSettings(vibeId);
|
||||
syncRuntimeUi();
|
||||
game?.playVibeChangeAudio(false);
|
||||
},
|
||||
});
|
||||
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
||||
|
||||
new MenuHider(
|
||||
elements.aside,
|
||||
() =>
|
||||
FullScreenHandler.isInFullScreenMode() &&
|
||||
!settingsPageHandler.isOpen &&
|
||||
!infoPageHandler.isOpen
|
||||
!configPane.isOpen &&
|
||||
!infoPageHandler.isOpen,
|
||||
{ persistentElement: elements.settingsButton }
|
||||
);
|
||||
new FullScreenHandler(
|
||||
elements.minimizeFullScreenButton,
|
||||
|
|
@ -76,31 +250,126 @@ const main = async () => {
|
|||
document.body
|
||||
);
|
||||
|
||||
const fontsReady = document.fonts.ready.catch(() => undefined);
|
||||
setLoadingStage('Connecting to GPU…', 0.1);
|
||||
const gpu = await initializeGpu();
|
||||
setLoadingStage('Loading fonts…', 0.4);
|
||||
await fontsReady;
|
||||
setLoadingStage('Compiling shaders…', 0.7);
|
||||
|
||||
elements.restartButton.addEventListener('click', () => game?.destroy());
|
||||
|
||||
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||
let sliders: Array<SettingsSlider<any>> = [];
|
||||
|
||||
elements.applyDefaults.addEventListener('click', () => {
|
||||
resetSettings();
|
||||
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
|
||||
elements.soundButton.addEventListener('click', (event) => {
|
||||
isAudioMuted = !isAudioMuted;
|
||||
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
|
||||
renderAudioUi(game);
|
||||
if (!isAudioMuted) {
|
||||
game?.startAudio(event.isTrusted);
|
||||
}
|
||||
});
|
||||
|
||||
while (!shouldStop) {
|
||||
const gameRules = new GameRules(performance.now() / 1000);
|
||||
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
|
||||
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||
|
||||
if (sliders.length === 0) {
|
||||
sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
|
||||
elements.previousVibe.addEventListener('click', (event) => {
|
||||
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||
const vibe =
|
||||
VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length];
|
||||
applyVibeSettings(vibe.id);
|
||||
configPane.refresh();
|
||||
syncRuntimeUi();
|
||||
game?.playVibeChangeAudio(event.isTrusted);
|
||||
});
|
||||
|
||||
elements.nextVibe.addEventListener('click', (event) => {
|
||||
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||
const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length];
|
||||
applyVibeSettings(vibe.id);
|
||||
configPane.refresh();
|
||||
syncRuntimeUi();
|
||||
game?.playVibeChangeAudio(event.isTrusted);
|
||||
});
|
||||
|
||||
elements.swatches.forEach((swatch, index) => {
|
||||
swatch.addEventListener('click', () => {
|
||||
settings.selectedColorIndex = index;
|
||||
elements.eraserSizeControl.dataset.active = '0';
|
||||
game?.setEraseMode(false);
|
||||
renderPaletteUi(game);
|
||||
configPane.refresh();
|
||||
});
|
||||
});
|
||||
|
||||
const activateEraser = () => {
|
||||
elements.eraserSizeControl.dataset.active = '1';
|
||||
renderPaletteUi(game);
|
||||
};
|
||||
|
||||
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
|
||||
elements.eraserSizeControl.addEventListener('click', activateEraser);
|
||||
elements.eraserSizeSlider.addEventListener('focus', activateEraser);
|
||||
|
||||
elements.eraserSizeSlider.addEventListener('input', () => {
|
||||
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
|
||||
elements.eraserSizeControl.dataset.active = '1';
|
||||
renderEraserSizeUi(game);
|
||||
renderPaletteUi(game);
|
||||
configPane.refresh();
|
||||
});
|
||||
|
||||
elements.mirrorSegmentSlider.addEventListener('input', () => {
|
||||
settings.mirrorSegmentCount = clampMirrorSegmentCount(
|
||||
Number(elements.mirrorSegmentSlider.value)
|
||||
);
|
||||
elements.eraserSizeControl.dataset.active = '0';
|
||||
renderMirrorSegmentUi();
|
||||
renderPaletteUi(game);
|
||||
configPane.refresh();
|
||||
});
|
||||
|
||||
elements.export4k.addEventListener('click', async () => {
|
||||
if (!game || elements.export4k.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await game.start();
|
||||
elements.export4k.disabled = true;
|
||||
try {
|
||||
await game.export4K();
|
||||
} catch (error) {
|
||||
ErrorHandler.addException(error, { severity: Severity.WARNING });
|
||||
} finally {
|
||||
elements.export4k.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
renderPaletteUi(game);
|
||||
renderEraserSizeUi(game);
|
||||
renderMirrorSegmentUi();
|
||||
renderAudioUi(game);
|
||||
|
||||
let isFirstStart = true;
|
||||
while (!shouldStop) {
|
||||
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
||||
prompt: elements.prompt,
|
||||
eraserPreview: elements.eraserPreview,
|
||||
exportStatus: elements.exportStatus,
|
||||
});
|
||||
renderPaletteUi(game);
|
||||
renderEraserSizeUi(game);
|
||||
renderMirrorSegmentUi();
|
||||
renderAudioUi(game);
|
||||
|
||||
const startPromise = game.start();
|
||||
if (isFirstStart) {
|
||||
isFirstStart = false;
|
||||
setLoadingStage('Ready', 1);
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => document.body.classList.remove('is-loading'))
|
||||
);
|
||||
}
|
||||
await startPromise;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
||||
ErrorHandler.addError(Severity.ERROR, message);
|
||||
document.body.classList.remove('is-loading');
|
||||
ErrorHandler.addException(e);
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
export class CollapsiblePanelAnimator {
|
||||
private static nextPanelId = 0;
|
||||
|
||||
private _isOpen = false;
|
||||
private focusBeforeOpen: HTMLElement | null = null;
|
||||
|
||||
public onOpen: () => unknown = () => {};
|
||||
public onClose: () => unknown = () => {};
|
||||
|
|
@ -9,25 +12,64 @@ export class CollapsiblePanelAnimator {
|
|||
private readonly collapsibleContent: HTMLElement,
|
||||
ignoreForCloseOnClick: HTMLElement
|
||||
) {
|
||||
const panelId =
|
||||
collapsibleContent.id ||
|
||||
`collapsible-panel-${CollapsiblePanelAnimator.nextPanelId++}`;
|
||||
collapsibleContent.id = panelId;
|
||||
|
||||
toggleButton.setAttribute('aria-controls', panelId);
|
||||
if (!collapsibleContent.hasAttribute('role')) {
|
||||
collapsibleContent.setAttribute('role', 'region');
|
||||
}
|
||||
if (!collapsibleContent.hasAttribute('aria-label')) {
|
||||
const label =
|
||||
toggleButton.getAttribute('aria-label') || toggleButton.textContent?.trim();
|
||||
collapsibleContent.setAttribute('aria-label', `${label || 'Panel'} panel`);
|
||||
}
|
||||
if (!collapsibleContent.hasAttribute('tabindex')) {
|
||||
collapsibleContent.tabIndex = -1;
|
||||
}
|
||||
|
||||
toggleButton.addEventListener('click', this.toggle.bind(this));
|
||||
window.addEventListener(
|
||||
'click',
|
||||
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
|
||||
);
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (this._isOpen && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
this.syncAccessibility();
|
||||
}
|
||||
|
||||
public open() {
|
||||
if (this._isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.focusBeforeOpen =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
this._isOpen = true;
|
||||
this.collapsibleContent.classList.remove('hidden');
|
||||
this.toggleButton.classList.add('active');
|
||||
this.syncAccessibility();
|
||||
this.onOpen();
|
||||
this.focusPanel();
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (!this._isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusWasInside = this.collapsibleContent.contains(document.activeElement);
|
||||
this._isOpen = false;
|
||||
this.collapsibleContent.classList.add('hidden');
|
||||
this.toggleButton.classList.remove('active');
|
||||
this.syncAccessibility();
|
||||
this.onClose();
|
||||
|
||||
if (focusWasInside) {
|
||||
(this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
public toggle() {
|
||||
|
|
@ -41,4 +83,20 @@ export class CollapsiblePanelAnimator {
|
|||
public get isOpen() {
|
||||
return this._isOpen;
|
||||
}
|
||||
|
||||
private syncAccessibility() {
|
||||
this.collapsibleContent.classList.toggle('hidden', !this._isOpen);
|
||||
this.toggleButton.classList.toggle('active', this._isOpen);
|
||||
this.toggleButton.setAttribute('aria-expanded', String(this._isOpen));
|
||||
this.collapsibleContent.setAttribute('aria-hidden', String(!this._isOpen));
|
||||
this.collapsibleContent.inert = !this._isOpen;
|
||||
}
|
||||
|
||||
private focusPanel() {
|
||||
requestAnimationFrame(() => {
|
||||
if (this._isOpen) {
|
||||
this.collapsibleContent.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
434
src/page/config-pane.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import { Pane, type BindingParams, type FolderApi } from 'tweakpane';
|
||||
|
||||
import {
|
||||
appConfig,
|
||||
type GardenRuntimeSettings,
|
||||
type NumberControlConfig,
|
||||
} from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
|
||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
||||
|
||||
const colorReactionRows = [
|
||||
{
|
||||
colorIndex: 0,
|
||||
label: '1',
|
||||
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
|
||||
},
|
||||
{
|
||||
colorIndex: 1,
|
||||
label: '2',
|
||||
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
|
||||
},
|
||||
{
|
||||
colorIndex: 2,
|
||||
label: '3',
|
||||
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const colorReactionKeySet = new Set<string>(
|
||||
colorReactionRows.flatMap((row) => [...row.keys])
|
||||
);
|
||||
|
||||
const isColorReactionKey = (key: string): key is ColorReactionKey =>
|
||||
colorReactionKeySet.has(key);
|
||||
|
||||
interface ConfigPaneOptions {
|
||||
onConfigChange: () => void;
|
||||
onRestart: () => void;
|
||||
onRuntimeChange: () => void;
|
||||
onRuntimeReset: () => void;
|
||||
onVibeChange: (vibeId: string) => void;
|
||||
settingsButton: HTMLButtonElement;
|
||||
}
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
|
||||
['boolean', 'number', 'string'].includes(typeof value);
|
||||
|
||||
const isColorString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
|
||||
|
||||
const toLabel = (value: string): string =>
|
||||
value
|
||||
.replace(/\[(\d+)\]/g, ' $1')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const normalizeNumber = (value: number, config: NumberControlConfig): number => {
|
||||
if (config.options) {
|
||||
const optionValues = Object.values(config.options);
|
||||
if (optionValues.includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min);
|
||||
}
|
||||
|
||||
const finiteValue = Number.isFinite(value) ? value : config.min;
|
||||
const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
|
||||
return config.integer ? Math.round(clampedValue) : clampedValue;
|
||||
};
|
||||
|
||||
const getNumberBindingParams = (
|
||||
key: keyof GardenRuntimeSettings & string,
|
||||
config: NumberControlConfig
|
||||
): BindingParams => ({
|
||||
label: config.label ?? toLabel(key),
|
||||
min: config.min,
|
||||
max: config.max,
|
||||
options: config.options,
|
||||
step: config.step,
|
||||
});
|
||||
|
||||
export class ConfigPane {
|
||||
private readonly container: HTMLDivElement;
|
||||
private readonly pane: Pane;
|
||||
private readonly colorReactionSelects = new Map<
|
||||
ColorReactionKey,
|
||||
HTMLSelectElement
|
||||
>();
|
||||
private readonly colorReactionSwatches: Array<{
|
||||
colorIndex: number;
|
||||
element: HTMLElement;
|
||||
}> = [];
|
||||
private readonly state = {
|
||||
activeVibeId: activeVibe.id,
|
||||
};
|
||||
|
||||
public constructor(private readonly options: ConfigPaneOptions) {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'config-pane-container';
|
||||
Object.assign(this.container.style, {
|
||||
boxSizing: 'border-box',
|
||||
maxHeight: 'calc(100vh - 24px)',
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
right: 'max(12px, env(safe-area-inset-right, 0px))',
|
||||
top: 'max(12px, env(safe-area-inset-top, 0px))',
|
||||
width:
|
||||
'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))',
|
||||
zIndex: '20',
|
||||
});
|
||||
document.body.appendChild(this.container);
|
||||
|
||||
this.pane = new Pane({
|
||||
container: this.container,
|
||||
title: appConfig.tuningPane.title,
|
||||
expanded: true,
|
||||
});
|
||||
this.pane.hidden = appConfig.tuningPane.startHidden;
|
||||
this.pane.element.classList.add('config-pane');
|
||||
this.pane.element.style.boxSizing = 'border-box';
|
||||
this.pane.element.style.maxHeight = 'calc(100vh - 24px)';
|
||||
this.pane.element.style.overflowY = 'auto';
|
||||
this.pane.element.style.pointerEvents = 'auto';
|
||||
this.pane.element.style.width = '100%';
|
||||
|
||||
this.options.settingsButton.addEventListener('click', this.toggle);
|
||||
|
||||
const tabs = this.pane.addTab({
|
||||
pages: [{ title: 'Runtime' }, { title: 'Config' }],
|
||||
});
|
||||
|
||||
this.setUpRuntimeTab(tabs.pages[0]);
|
||||
this.setUpConfigTab(tabs.pages[1]);
|
||||
this.syncButton();
|
||||
}
|
||||
|
||||
public get isOpen(): boolean {
|
||||
return !this.pane.hidden;
|
||||
}
|
||||
|
||||
public refresh(): void {
|
||||
this.state.activeVibeId = activeVibe.id;
|
||||
this.pane.refresh();
|
||||
this.syncColorReactionMatrix();
|
||||
this.syncButton();
|
||||
}
|
||||
|
||||
private readonly toggle = () => {
|
||||
this.pane.hidden = !this.pane.hidden;
|
||||
this.syncButton();
|
||||
};
|
||||
|
||||
private setHidden(isHidden: boolean): void {
|
||||
this.pane.hidden = isHidden;
|
||||
this.syncButton();
|
||||
}
|
||||
|
||||
private setUpRuntimeTab(container: PaneContainer): void {
|
||||
container
|
||||
.addBinding(this.state, 'activeVibeId', {
|
||||
label: 'active vibe',
|
||||
options: Object.fromEntries(
|
||||
VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
|
||||
) as Record<string, string>,
|
||||
})
|
||||
.on('change', ({ value }) => {
|
||||
this.options.onVibeChange(value);
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
container
|
||||
.addButton({
|
||||
title: 'Reset runtime settings',
|
||||
})
|
||||
.on('click', () => {
|
||||
this.options.onRuntimeReset();
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
container
|
||||
.addButton({
|
||||
title: 'Restart simulation',
|
||||
})
|
||||
.on('click', () => this.options.onRestart());
|
||||
|
||||
const folders = new Map<string, PaneContainer>();
|
||||
let hasAddedColorReactionMatrix = false;
|
||||
Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
|
||||
const settingKey = key as keyof GardenRuntimeSettings & string;
|
||||
settings[settingKey] = normalizeNumber(settings[settingKey], config);
|
||||
|
||||
if (isColorReactionKey(key)) {
|
||||
if (!hasAddedColorReactionMatrix) {
|
||||
this.addColorReactionMatrix(container);
|
||||
hasAddedColorReactionMatrix = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const folder =
|
||||
folders.get(config.folder) ??
|
||||
container.addFolder({
|
||||
title: config.folder,
|
||||
expanded: config.folder !== 'Runtime',
|
||||
});
|
||||
folders.set(config.folder, folder);
|
||||
|
||||
folder
|
||||
.addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
|
||||
.on('change', () => {
|
||||
const nextValue = normalizeNumber(settings[settingKey], config);
|
||||
if (nextValue !== settings[settingKey]) {
|
||||
settings[settingKey] = nextValue;
|
||||
this.pane.refresh();
|
||||
}
|
||||
this.options.onRuntimeChange();
|
||||
});
|
||||
});
|
||||
this.syncColorReactionMatrix();
|
||||
}
|
||||
|
||||
private addColorReactionMatrix(container: PaneContainer): void {
|
||||
const folder = container.addFolder({
|
||||
title: 'Color Reactions',
|
||||
expanded: true,
|
||||
});
|
||||
folder.element.classList.add('color-reaction-folder');
|
||||
|
||||
const content = Array.from(folder.element.children).find((child) =>
|
||||
child.classList.contains('tp-fldv_c')
|
||||
);
|
||||
if (!(content instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = folder.element.ownerDocument;
|
||||
const matrix = doc.createElement('div');
|
||||
matrix.className = 'color-reaction-matrix';
|
||||
|
||||
matrix.appendChild(this.createColorReactionCorner(doc));
|
||||
colorReactionRows.forEach((row) => {
|
||||
matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
|
||||
});
|
||||
|
||||
colorReactionRows.forEach((row) => {
|
||||
matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
|
||||
row.keys.forEach((key, columnIndex) => {
|
||||
matrix.appendChild(
|
||||
this.createColorReactionCell(doc, key, row.colorIndex, columnIndex)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
content.appendChild(matrix);
|
||||
this.syncColorReactionMatrix();
|
||||
}
|
||||
|
||||
private createColorReactionCorner(doc: Document): HTMLDivElement {
|
||||
const corner = doc.createElement('div');
|
||||
corner.className = 'color-reaction-matrix__corner';
|
||||
corner.textContent = 'agent';
|
||||
return corner;
|
||||
}
|
||||
|
||||
private createColorReactionHeader(
|
||||
doc: Document,
|
||||
colorIndex: number,
|
||||
label: string
|
||||
): HTMLDivElement {
|
||||
const header = doc.createElement('div');
|
||||
header.className = 'color-reaction-matrix__header';
|
||||
|
||||
const swatch = doc.createElement('span');
|
||||
swatch.className = 'color-reaction-matrix__swatch';
|
||||
this.colorReactionSwatches.push({ colorIndex, element: swatch });
|
||||
header.appendChild(swatch);
|
||||
|
||||
const text = doc.createElement('span');
|
||||
text.textContent = label;
|
||||
header.appendChild(text);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
private createColorReactionCell(
|
||||
doc: Document,
|
||||
key: ColorReactionKey,
|
||||
sourceColorIndex: number,
|
||||
targetColorIndex: number
|
||||
): HTMLLabelElement {
|
||||
const cell = doc.createElement('label');
|
||||
cell.className = 'color-reaction-matrix__cell';
|
||||
|
||||
const select = doc.createElement('select');
|
||||
select.setAttribute(
|
||||
'aria-label',
|
||||
`Color ${sourceColorIndex + 1} agents reacting to color ${targetColorIndex + 1}`
|
||||
);
|
||||
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
Object.entries(config.options ?? {}).forEach(([label, value]) => {
|
||||
const option = doc.createElement('option');
|
||||
option.value = String(value);
|
||||
option.textContent = label;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
settings[key] = normalizeNumber(Number(select.value), config);
|
||||
select.value = String(settings[key]);
|
||||
this.options.onRuntimeChange();
|
||||
});
|
||||
|
||||
this.colorReactionSelects.set(key, select);
|
||||
cell.appendChild(select);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
private syncColorReactionMatrix(): void {
|
||||
this.colorReactionSelects.forEach((select, key) => {
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
settings[key] = normalizeNumber(settings[key], config);
|
||||
select.value = String(settings[key]);
|
||||
});
|
||||
|
||||
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
|
||||
element.style.backgroundColor = activeVibe.colors[colorIndex] ?? '#ffffff';
|
||||
});
|
||||
}
|
||||
|
||||
private setUpConfigTab(container: PaneContainer): void {
|
||||
this.addObjectBindings(
|
||||
container,
|
||||
appConfig as unknown as Record<string, unknown>,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
private addObjectBindings(
|
||||
container: PaneContainer,
|
||||
source: Record<string, unknown>,
|
||||
path: Array<string>
|
||||
): void {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (isBindablePrimitive(value)) {
|
||||
this.addPrimitiveBinding(container, source, key, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const folder = container.addFolder({
|
||||
title: toLabel(`${key}[]`),
|
||||
expanded: path.length < appConfig.tuningPane.expandedDepth,
|
||||
});
|
||||
value.forEach((item, index) => {
|
||||
if (isBindablePrimitive(item)) {
|
||||
this.addPrimitiveBinding(
|
||||
folder,
|
||||
value as unknown as Record<string, unknown>,
|
||||
`${index}`,
|
||||
item
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(item)) {
|
||||
this.addObjectBindings(
|
||||
folder.addFolder({
|
||||
title: `[${index}]`,
|
||||
expanded: false,
|
||||
}),
|
||||
item,
|
||||
[...path, key, String(index)]
|
||||
);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
this.addObjectBindings(
|
||||
container.addFolder({
|
||||
title: toLabel(key),
|
||||
expanded: path.length < appConfig.tuningPane.expandedDepth,
|
||||
}),
|
||||
value,
|
||||
[...path, key]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private addPrimitiveBinding(
|
||||
container: PaneContainer,
|
||||
source: Record<string, unknown>,
|
||||
key: string,
|
||||
value: boolean | number | string
|
||||
): void {
|
||||
const params: BindingParams = {
|
||||
label: toLabel(key),
|
||||
...(isColorString(value) ? { color: { type: 'int' } } : {}),
|
||||
...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
|
||||
};
|
||||
|
||||
container
|
||||
.addBinding(source, key, params)
|
||||
.on('change', () => this.options.onConfigChange());
|
||||
}
|
||||
|
||||
private syncButton(): void {
|
||||
this.options.settingsButton.classList.toggle('active', this.isOpen);
|
||||
this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen));
|
||||
this.options.settingsButton.setAttribute(
|
||||
'aria-label',
|
||||
this.isOpen ? 'Hide config overlay' : 'Show config overlay'
|
||||
);
|
||||
this.options.settingsButton.title = this.isOpen
|
||||
? 'Hide config overlay'
|
||||
: 'Show config overlay';
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.setHidden(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -32,12 +32,11 @@ export class FullScreenHandler {
|
|||
return document.fullscreenElement !== null;
|
||||
}
|
||||
|
||||
private updateButtons() {
|
||||
this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
|
||||
? 'block'
|
||||
: 'none';
|
||||
this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
|
||||
? 'none'
|
||||
: 'block';
|
||||
private updateButtons(): void {
|
||||
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
|
||||
this.minimizeButton.style.display = isInFullScreenMode ? 'block' : 'none';
|
||||
this.maximizeButton.style.display = isInFullScreenMode ? 'none' : 'block';
|
||||
this.minimizeButton.classList.toggle('active', isInFullScreenMode);
|
||||
this.maximizeButton.classList.toggle('active', isInFullScreenMode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,107 @@
|
|||
export class MenuHider {
|
||||
private static readonly DEFAULT_TIME_TO_LIVE = 3500;
|
||||
private static readonly INTERVAL = 50;
|
||||
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
||||
import { appConfig } from '../config';
|
||||
|
||||
interface MenuHiderOptions {
|
||||
persistentElement?: HTMLElement;
|
||||
}
|
||||
|
||||
export class MenuHider {
|
||||
private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs;
|
||||
private static readonly INTERVAL = appConfig.menuHider.intervalMs;
|
||||
private static readonly BOTTOM_REVEAL_DISTANCE =
|
||||
appConfig.menuHider.bottomRevealDistancePx;
|
||||
private readonly interactiveElements: Array<HTMLElement>;
|
||||
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
||||
private isHidden = false;
|
||||
|
||||
public constructor(
|
||||
private readonly element: HTMLElement,
|
||||
private readonly shouldBeHidden: () => boolean,
|
||||
private readonly options: MenuHiderOptions = {}
|
||||
) {
|
||||
this.interactiveElements = Array.from(
|
||||
element.querySelectorAll<HTMLElement>(
|
||||
'a[href], button, input, select, textarea, [tabindex]'
|
||||
)
|
||||
);
|
||||
|
||||
if (options.persistentElement) {
|
||||
element.classList.add('has-persistent-settings');
|
||||
}
|
||||
|
||||
public constructor(element: HTMLElement, shouldBeHidden: () => boolean) {
|
||||
setInterval(() => {
|
||||
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
|
||||
element.style.opacity = this.timeToLive == 0 && shouldBeHidden() ? '0' : '1';
|
||||
this.updateVisibility();
|
||||
}, MenuHider.INTERVAL);
|
||||
|
||||
element.addEventListener(
|
||||
'mouseover',
|
||||
() => (this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE)
|
||||
element.addEventListener('mouseover', this.wakeUp);
|
||||
element.addEventListener('focusin', this.wakeUp);
|
||||
element.addEventListener('pointerdown', this.wakeUp);
|
||||
window.addEventListener('pointermove', this.wakeUpNearViewportBottom, {
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener('pointerdown', this.wakeUp, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener('touchstart', this.wakeUp, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener('keydown', this.wakeUp, { capture: true });
|
||||
window.addEventListener('focusin', this.wakeUp, { capture: true });
|
||||
|
||||
this.updateVisibility();
|
||||
}
|
||||
|
||||
private readonly wakeUp = () => {
|
||||
this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
||||
this.updateVisibility();
|
||||
};
|
||||
|
||||
private readonly wakeUpNearViewportBottom = (event: PointerEvent) => {
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const revealStart = viewportHeight - MenuHider.BOTTOM_REVEAL_DISTANCE;
|
||||
|
||||
if (event.clientY >= revealStart) {
|
||||
this.wakeUp();
|
||||
}
|
||||
};
|
||||
|
||||
private updateVisibility() {
|
||||
const focusWithin = this.element.contains(document.activeElement);
|
||||
const shouldHide = this.timeToLive === 0 && this.shouldBeHidden() && !focusWithin;
|
||||
|
||||
if (this.isHidden === shouldHide) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isHidden = shouldHide;
|
||||
this.element.classList.toggle('menu-hidden', shouldHide);
|
||||
this.syncAccessibility(shouldHide);
|
||||
}
|
||||
|
||||
private syncAccessibility(shouldHide: boolean): void {
|
||||
const persistentElement = this.options.persistentElement;
|
||||
|
||||
if (!persistentElement) {
|
||||
this.element.style.opacity = shouldHide ? '0' : '1';
|
||||
this.element.setAttribute('aria-hidden', String(shouldHide));
|
||||
this.element.inert = shouldHide;
|
||||
return;
|
||||
}
|
||||
|
||||
this.element.style.opacity = '';
|
||||
this.element.setAttribute('aria-hidden', 'false');
|
||||
this.element.inert = false;
|
||||
|
||||
this.interactiveElements.forEach((interactiveElement) => {
|
||||
const isPersistentElement = interactiveElement === persistentElement;
|
||||
|
||||
interactiveElement.inert = shouldHide && !isPersistentElement;
|
||||
interactiveElement.toggleAttribute(
|
||||
'aria-hidden',
|
||||
shouldHide && !isPersistentElement
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
import { isProduction } from '../constants';
|
||||
import { settings } from '../settings';
|
||||
import { SettingsSlider, ValueScaling } from './settings-slider';
|
||||
|
||||
export const setUpSettingsPage = (
|
||||
settingsPage: HTMLDivElement,
|
||||
maxAgentCount: number
|
||||
): Array<SettingsSlider<any>> => {
|
||||
const sliders: Array<SettingsSlider<any>> = [
|
||||
...(isProduction
|
||||
? []
|
||||
: [
|
||||
new SettingsSlider(settings, 'renderSpeed', {
|
||||
min: 1,
|
||||
max: 10,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
]),
|
||||
|
||||
new SettingsSlider(settings, 'agentCount', {
|
||||
min: 1,
|
||||
max: maxAgentCount,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'currentGenerationAggression', {
|
||||
min: -5,
|
||||
max: 5,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'nextGenerationAggression', {
|
||||
min: -5,
|
||||
max: 5,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'moveSpeed', {
|
||||
min: 10,
|
||||
max: 500,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'turnSpeed', {
|
||||
min: 1,
|
||||
max: 200,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'sensorOffsetAngle', {
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 1,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'sensorOffsetDistance', {
|
||||
min: 0,
|
||||
max: 200,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'turnWhenLost', {
|
||||
min: 0,
|
||||
max: 1,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'individualTrailWeight', {
|
||||
min: 0,
|
||||
max: 1,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'diffusionRateTrails', {
|
||||
min: 0,
|
||||
max: 2,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'decayRateTrails', {
|
||||
min: 0.1,
|
||||
max: 5000,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'diffusionRateBrush', {
|
||||
min: 0.001,
|
||||
max: 1,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'decayRateBrush', {
|
||||
min: 0.1,
|
||||
max: 100,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'brushSize', {
|
||||
min: 1,
|
||||
max: 30,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'clarity', {
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const sliderContainerElement = document.createElement('div');
|
||||
|
||||
sliders.forEach((slider) => {
|
||||
sliderContainerElement.appendChild(slider.element);
|
||||
});
|
||||
|
||||
settingsPage.appendChild(sliderContainerElement);
|
||||
|
||||
return sliders;
|
||||
};
|
||||