Compare commits

...

7 commits

Author SHA1 Message Date
2347ecd201 .
Some checks failed
Deploy to Pages / build (pull_request) Failing after 3m15s
2026-05-13 22:13:15 +01:00
39b0160064 WIP 2026-05-13 21:07:10 +01:00
34ac200437 Add WIP sound generation 2026-05-10 15:26:44 +01:00
cb1df6f29e Update SCSS 2026-05-10 15:16:19 +01:00
4e92913925 LGTM 2026-05-09 22:27:51 +01:00
b1acdff594 Refactoring start 2026-05-09 22:09:04 +01:00
6588930911 Add piano 2026-05-09 21:50:56 +01:00
155 changed files with 12473 additions and 1647 deletions

View file

@ -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
View file

@ -5,6 +5,8 @@ ts-node--*/
rss.xml
dist
playwright-report
test-results
# Logs
logs

View file

@ -1,15 +1,14 @@
# Just a bunch of blobs
# Fleeting Garden
[![Deploy to GitHub Pages](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml/badge.svg)](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
View 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
View 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
View 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();
});

View file

@ -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&hellip;</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 &mdash; 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>
</section>
<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"
>
&lsaquo;
</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">
&rsaquo;
</button>
</div>
</aside>
<script type="module" src="/src/index.ts"></script>
</body>

103
package-lock.json generated
View file

@ -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",

View file

@ -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
View 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'] },
},
],
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 914 B

Before After
Before After

View file

@ -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

Before After
Before After

View file

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 709 B

Before After
Before After

View 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;
}

View 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;

View 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);
});
});

View 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;
}
}

View 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;
}
}

View 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);
};

View 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;
};

View 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;
}

View 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
View 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);
};

View 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);
});
});

View 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]);
}
}

View 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
View 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 }
);
}
}

View 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
View 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;

View 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,
});

View 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
View 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
View 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>;

View file

@ -1 +0,0 @@
export const isProduction: boolean = import.meta.env.PROD;

View 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
);
});
});

View 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)));
}
}

View 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);
}
}

View 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;
};

View 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
View 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;
};

View 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,
});
}
}

View 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(');
});
});

View 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];'
);
});
});

View 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,
});
}
}

View file

@ -1,8 +1,10 @@
export interface GameLoopSettings {
maxAgentCountUpperLimit: number;
agentBudgetMax: number;
agentCount: number;
renderSpeed: number;
simulatedDelayMs: number;
selectedColorIndex: number;
spawnPerPixel: number;
startColorHue: number;
}

View 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;
}

View file

@ -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);
time *= settings.renderSpeed;
const timeInSeconds = time / 1000;
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize);
[
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(),
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,
})
this.framePerformance.update(deltaTime);
this.agentPopulation.growBudget(
deltaTime,
this.framePerformance.smoothedFps,
this.framePerformance.refreshTargetFps
);
this.introPrompt.update();
this.resize();
this.resizeSimulationToCanvas();
for (let i = 0; i < settings.renderSpeed; i++) {
const commandEncoder = this.device.createCommandEncoder();
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.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.resources.setFrameParameters({
time: scaledTime,
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
introProgress,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
});
this.device.queue.submit([commandEncoder.finish()]);
}
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
this.resources.executeFrame(settings.renderSpeed, isErasing);
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
if (!this.isSwipeActive) {
this.brushPipeline.clearSwipes();
}
this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
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;
}
const element = document.createElement('div');
element.className = 'dev-stats-overlay';
element.setAttribute('aria-hidden', 'true');
container.appendChild(element);
return element;
}
public async destroy() {
this.hasFinished = true;
await this.finished.promise;
private updateDevStats(time: DOMHighResTimeStamp): void {
if (
!this.devStatsElement ||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
) {
return;
}
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();
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)));
}
}

View file

@ -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];
}
}

View file

@ -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);
}
}

View 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();
}
}

View 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];
};

View 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);
});
});

View 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;

View 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);
}
}

View 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();
}
}

View 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();
}
}

View 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);
});
});

View file

@ -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';

View file

@ -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>
`;
game?.destroy();
shouldStop = true;
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);
}
};

View file

@ -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
View 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);
}
}

View file

@ -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);
}
}

View file

@ -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
);
});
}
}

View file

@ -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;
};

Some files were not shown because too many files have changed in this diff Show more