|
|
@ -27,17 +27,33 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Typecheck browser tests
|
||||||
|
run: npm run typecheck:e2e
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Build
|
- name: Browser tests
|
||||||
run: npm run build
|
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
|
- name: Copy build to host pages mount
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
|
||||||
2
.gitignore
vendored
|
|
@ -5,6 +5,8 @@ ts-node--*/
|
||||||
rss.xml
|
rss.xml
|
||||||
|
|
||||||
dist
|
dist
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,10 @@ draw persistent coloured paths, spawn agents from those strokes, erase locally,
|
||||||
and export the scene as a 4K wallpaper.
|
and export the scene as a 4K wallpaper.
|
||||||
|
|
||||||
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
|
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
|
||||||
23
e2e/app.spec.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'gpu', {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle('Fleeting Garden');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
|
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'About' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||||
|
});
|
||||||
39
index.html
|
|
@ -6,7 +6,7 @@
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#b7455e" />
|
<meta name="theme-color" content="#10151f" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Fleeting Garden is a joyful WebGPU drawing garden where your coloured paths bloom into moving organic trails."
|
content="Fleeting Garden is a joyful WebGPU drawing garden where your coloured paths bloom into moving organic trails."
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
|
|
||||||
<title>Fleeting Garden</title>
|
<title>Fleeting Garden</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="is-loading">
|
||||||
<main class="canvas-container">
|
<main class="canvas-container">
|
||||||
<canvas
|
<canvas
|
||||||
role="img"
|
role="img"
|
||||||
|
|
@ -47,6 +47,25 @@
|
||||||
<div class="eraser-preview" aria-hidden="true"></div>
|
<div class="eraser-preview" aria-hidden="true"></div>
|
||||||
<div class="garden-prompt" aria-live="polite"></div>
|
<div class="garden-prompt" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<div class="loading-indicator" role="status" aria-live="polite">
|
||||||
|
<div class="loading-dots" aria-hidden="true">
|
||||||
|
<span class="loading-dot"></span>
|
||||||
|
<span class="loading-dot"></span>
|
||||||
|
<span class="loading-dot"></span>
|
||||||
|
</div>
|
||||||
|
<div class="loading-status">Starting up…</div>
|
||||||
|
<div
|
||||||
|
class="loading-progress"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Loading Fleeting Garden"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow="0"
|
||||||
|
>
|
||||||
|
<div class="loading-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="errors-container">
|
<section class="errors-container">
|
||||||
<noscript>JavaScript is required for this website.</noscript>
|
<noscript>JavaScript is required for this website.</noscript>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -57,16 +76,20 @@
|
||||||
<section>
|
<section>
|
||||||
<h1>Fleeting Garden</h1>
|
<h1>Fleeting Garden</h1>
|
||||||
<p>
|
<p>
|
||||||
Pick a vibe palette, draw with one of the three colours, and agents grow
|
A living sketchpad where each stroke becomes a trail that agents follow,
|
||||||
organic paths from your strokes.
|
branch from, and weave into the scene.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Your drawn paths persist until you erase them. Switching vibes recolours the
|
Paint with the three colour swatches, carve space with the eraser, and raise
|
||||||
whole garden without clearing the scene.
|
the mirror control when you want radial patterns instead of a single line.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Runs entirely on your GPU via WebGPU compute shaders — no servers, no
|
Switch vibes to recolour the whole garden without clearing your drawing. Add
|
||||||
tracking, no analytics. Source on
|
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"
|
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
|
||||||
>GitHub</a
|
>GitHub</a
|
||||||
>.
|
>.
|
||||||
|
|
|
||||||
64
package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@tweakpane/core": "^2.0.5",
|
"@tweakpane/core": "^2.0.5",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|
@ -1261,6 +1262,22 @@
|
||||||
"url": "https://opencollective.com/pkgr"
|
"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": {
|
"node_modules/@quansync/fs": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
||||||
|
|
@ -3461,6 +3478,53 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.13",
|
"version": "8.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||||
|
|
|
||||||
11
package.json
|
|
@ -9,13 +9,17 @@
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "npm run lint:check",
|
"lint": "npm run lint:check",
|
||||||
"lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\"",
|
"lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\" && npm run unused:check",
|
||||||
"lint:fix": "eslint --fix \"src/**/*.ts\"",
|
"lint:fix": "eslint --fix \"src/**/*.ts\"",
|
||||||
"format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
"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}\" \".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": "tsc --noEmit",
|
||||||
|
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:e2e": "npm run build && playwright test",
|
||||||
|
"test:e2e:ui": "npm run build && playwright test --ui",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"unused:check": "node scripts/check-unused-exports.mjs",
|
||||||
"generate-icons": "pwa-assets-generator",
|
"generate-icons": "pwa-assets-generator",
|
||||||
"update": "ncu"
|
"update": "ncu"
|
||||||
},
|
},
|
||||||
|
|
@ -40,6 +44,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@tweakpane/core": "^2.0.5",
|
"@tweakpane/core": "^2.0.5",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|
|
||||||
32
playwright.config.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const port = 4173;
|
||||||
|
const baseURL = `https://127.0.0.1:${port}`;
|
||||||
|
const isCi = Boolean(process.env.CI);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: isCi,
|
||||||
|
retries: isCi ? 2 : 0,
|
||||||
|
workers: isCi ? 1 : undefined,
|
||||||
|
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
reuseExistingServer: !isCi,
|
||||||
|
timeout: 120_000,
|
||||||
|
url: baseURL,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 908 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 914 B |
|
|
@ -1,6 +1,31 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
<rect width="64" height="64" rx="14" fill="#b7455e" />
|
<defs>
|
||||||
<circle cx="22" cy="26" r="9" fill="#fff" opacity="0.95" />
|
<clipPath id="icon-clip">
|
||||||
<circle cx="42" cy="32" r="11" fill="#fff" opacity="0.85" />
|
<rect width="64" height="64" rx="14" />
|
||||||
<circle cx="28" cy="44" r="7" fill="#fff" opacity="0.75" />
|
</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>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 950 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 488 B After Width: | Height: | Size: 709 B |
185
scripts/check-unused-exports.mjs
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const sourceRoot = path.join(projectRoot, 'src');
|
||||||
|
|
||||||
|
const toPosix = (value) => value.split(path.sep).join('/');
|
||||||
|
|
||||||
|
const listTypeScriptFiles = (directory) =>
|
||||||
|
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const entryPath = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return listTypeScriptFiles(entryPath);
|
||||||
|
}
|
||||||
|
return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')
|
||||||
|
? [entryPath]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = listTypeScriptFiles(sourceRoot);
|
||||||
|
const fileSet = new Set(files.map((file) => path.resolve(file)));
|
||||||
|
|
||||||
|
const resolveModule = (fromFile, specifier) => {
|
||||||
|
if (!specifier.startsWith('.')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = path.resolve(path.dirname(fromFile), specifier);
|
||||||
|
const candidates = [
|
||||||
|
`${base}.ts`,
|
||||||
|
path.join(base, 'index.ts'),
|
||||||
|
base.endsWith('.ts') ? base : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportKey = (file, name) => `${path.resolve(file)}:${name}`;
|
||||||
|
const isExported = (node) =>
|
||||||
|
ts.canHaveModifiers(node) &&
|
||||||
|
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
||||||
|
const isDefaultExported = (node) =>
|
||||||
|
ts.canHaveModifiers(node) &&
|
||||||
|
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
|
||||||
|
|
||||||
|
const exportedDeclarations = new Map();
|
||||||
|
const usedExports = new Set();
|
||||||
|
const wildcardUsedFiles = new Set();
|
||||||
|
|
||||||
|
const markUsed = (fromFile, name) => {
|
||||||
|
usedExports.add(exportKey(fromFile, name));
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectImportUsage = (file, sourceFile) => {
|
||||||
|
sourceFile.forEachChild((node) => {
|
||||||
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||||
|
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
||||||
|
if (!resolved || !node.importClause) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.importClause.name) {
|
||||||
|
markUsed(resolved, 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedBindings = node.importClause.namedBindings;
|
||||||
|
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
||||||
|
namedBindings.elements.forEach((element) => {
|
||||||
|
markUsed(resolved, (element.propertyName ?? element.name).text);
|
||||||
|
});
|
||||||
|
} else if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
||||||
|
wildcardUsedFiles.add(path.resolve(resolved));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ts.isExportDeclaration(node) &&
|
||||||
|
node.moduleSpecifier &&
|
||||||
|
ts.isStringLiteral(node.moduleSpecifier)
|
||||||
|
) {
|
||||||
|
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
||||||
|
if (!resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.exportClause) {
|
||||||
|
wildcardUsedFiles.add(path.resolve(resolved));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ts.isNamedExports(node.exportClause)) {
|
||||||
|
node.exportClause.elements.forEach((element) => {
|
||||||
|
markUsed(resolved, (element.propertyName ?? element.name).text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectExportedDeclarations = (file, sourceFile) => {
|
||||||
|
if (file.endsWith('.test.ts')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceFile.forEachChild((node) => {
|
||||||
|
if (ts.isVariableStatement(node) && isExported(node)) {
|
||||||
|
node.declarationList.declarations.forEach((declaration) => {
|
||||||
|
if (ts.isIdentifier(declaration.name)) {
|
||||||
|
exportedDeclarations.set(exportKey(file, declaration.name.text), {
|
||||||
|
file,
|
||||||
|
name: declaration.name.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(ts.isFunctionDeclaration(node) ||
|
||||||
|
ts.isClassDeclaration(node) ||
|
||||||
|
ts.isInterfaceDeclaration(node) ||
|
||||||
|
ts.isTypeAliasDeclaration(node) ||
|
||||||
|
ts.isEnumDeclaration(node)) &&
|
||||||
|
isExported(node)
|
||||||
|
) {
|
||||||
|
if (isDefaultExported(node)) {
|
||||||
|
exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name) {
|
||||||
|
exportedDeclarations.set(exportKey(file, node.name.text), {
|
||||||
|
file,
|
||||||
|
name: node.name.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ts.isExportDeclaration(node) &&
|
||||||
|
!node.moduleSpecifier &&
|
||||||
|
node.exportClause &&
|
||||||
|
ts.isNamedExports(node.exportClause)
|
||||||
|
) {
|
||||||
|
node.exportClause.elements.forEach((element) => {
|
||||||
|
exportedDeclarations.set(exportKey(file, element.name.text), {
|
||||||
|
file,
|
||||||
|
name: element.name.text,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedFiles = files.map((file) => ({
|
||||||
|
file,
|
||||||
|
sourceFile: ts.createSourceFile(
|
||||||
|
file,
|
||||||
|
readFileSync(file, 'utf8'),
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
ts.ScriptKind.TS
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile));
|
||||||
|
parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile));
|
||||||
|
|
||||||
|
const unusedExports = Array.from(exportedDeclarations.entries())
|
||||||
|
.filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file))
|
||||||
|
.map(([, declaration]) => declaration)
|
||||||
|
.sort((left, right) =>
|
||||||
|
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unusedExports.length > 0) {
|
||||||
|
console.error('Unused exported declarations found:');
|
||||||
|
unusedExports.forEach(({ file, name }) => {
|
||||||
|
console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`);
|
||||||
|
});
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
export type GardenAudioChordQuality = 'major' | 'minor';
|
type GardenAudioChordQuality = 'major' | 'minor';
|
||||||
|
|
||||||
export interface GardenAudioChord {
|
export interface GardenAudioChord {
|
||||||
rootOffset: number;
|
rootOffset: number;
|
||||||
quality: GardenAudioChordQuality;
|
quality: GardenAudioChordQuality;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioColorVoice {
|
interface GardenAudioColorVoice {
|
||||||
scaleDegreeOffset: number;
|
scaleDegreeOffset: number;
|
||||||
velocityMultiplier: number;
|
velocityMultiplier: number;
|
||||||
panOffset: number;
|
panOffset: number;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { appConfig } from '../config';
|
import type { GardenAudioEngineConfig } from '../config';
|
||||||
import { clamp01 } from '../utils/clamp';
|
import { clamp01 } from '../utils/clamp';
|
||||||
|
|
||||||
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
|
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
|
||||||
|
|
@ -9,6 +9,8 @@ export class GardenAudioEnergy {
|
||||||
private targetEnergy = 0;
|
private targetEnergy = 0;
|
||||||
private lastEnergyUpdateAt = 0;
|
private lastEnergyUpdateAt = 0;
|
||||||
|
|
||||||
|
public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
|
||||||
|
|
||||||
public beginGesture(now: number): void {
|
public beginGesture(now: number): void {
|
||||||
this.isGestureActive = true;
|
this.isGestureActive = true;
|
||||||
this.lastEnergyUpdateAt = now;
|
this.lastEnergyUpdateAt = now;
|
||||||
|
|
@ -46,15 +48,15 @@ export class GardenAudioEnergy {
|
||||||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||||
this.lastEnergyUpdateAt = now;
|
this.lastEnergyUpdateAt = now;
|
||||||
this.targetEnergy *= Math.exp(
|
this.targetEnergy *= Math.exp(
|
||||||
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
|
-elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
const target = this.isGestureActive ? this.targetEnergy : 0;
|
const target = this.isGestureActive ? this.targetEnergy : 0;
|
||||||
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
|
let timeConstant = this.engineConfig.energy.decaySeconds;
|
||||||
if (!this.isGestureActive) {
|
if (!this.isGestureActive) {
|
||||||
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
|
timeConstant = this.engineConfig.energy.releaseSeconds;
|
||||||
} else if (target > this.energy) {
|
} else if (target > this.energy) {
|
||||||
timeConstant = appConfig.audioEngine.energy.attackSeconds;
|
timeConstant = this.engineConfig.energy.attackSeconds;
|
||||||
}
|
}
|
||||||
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
||||||
this.energy += (target - this.energy) * amount;
|
this.energy += (target - this.energy) * amount;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { appConfig } from '../config';
|
import type { GardenAudioEngineConfig } from '../config';
|
||||||
import { clamp } from '../utils/clamp';
|
import { clamp } from '../utils/clamp';
|
||||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||||
|
|
||||||
|
|
@ -14,7 +14,10 @@ export class GardenAudioGraph {
|
||||||
private delayOutput: GainNode | null = null;
|
private delayOutput: GainNode | null = null;
|
||||||
private hasUnlocked = false;
|
private hasUnlocked = false;
|
||||||
|
|
||||||
public constructor(private readonly config: GardenAudioConfig) {}
|
public constructor(
|
||||||
|
private readonly config: GardenAudioConfig,
|
||||||
|
private readonly engineConfig: GardenAudioEngineConfig
|
||||||
|
) {}
|
||||||
|
|
||||||
public ensureContext(canCreate: boolean): AudioContext | null {
|
public ensureContext(canCreate: boolean): AudioContext | null {
|
||||||
if (this.context) {
|
if (this.context) {
|
||||||
|
|
@ -62,8 +65,8 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
const buffer = this.context.createBuffer(
|
const buffer = this.context.createBuffer(
|
||||||
1,
|
1,
|
||||||
appConfig.audioEngine.graph.unlockBufferLength,
|
this.engineConfig.graph.unlockBufferLength,
|
||||||
appConfig.audioEngine.graph.unlockSampleRate
|
this.engineConfig.graph.unlockSampleRate
|
||||||
);
|
);
|
||||||
const source = this.context.createBufferSource();
|
const source = this.context.createBufferSource();
|
||||||
source.buffer = buffer;
|
source.buffer = buffer;
|
||||||
|
|
@ -92,7 +95,7 @@ export class GardenAudioGraph {
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||||
this.context.currentTime,
|
this.context.currentTime,
|
||||||
appConfig.audioEngine.graph.delayTimeRampSeconds
|
this.engineConfig.graph.delayTimeRampSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,22 +108,22 @@ export class GardenAudioGraph {
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||||
now,
|
now,
|
||||||
appConfig.audioEngine.graph.delayTimeRampSeconds
|
this.engineConfig.graph.delayTimeRampSeconds
|
||||||
);
|
);
|
||||||
this.delayFeedback.gain.setTargetAtTime(
|
this.delayFeedback.gain.setTargetAtTime(
|
||||||
clamp(
|
clamp(
|
||||||
this.config.delay.feedback +
|
this.config.delay.feedback +
|
||||||
activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
|
activity * this.engineConfig.graph.delayActivityFeedbackWeight,
|
||||||
appConfig.audioEngine.graph.delayFeedbackMin,
|
this.engineConfig.graph.delayFeedbackMin,
|
||||||
appConfig.audioEngine.graph.delayFeedbackMax
|
this.engineConfig.graph.delayFeedbackMax
|
||||||
),
|
),
|
||||||
now,
|
now,
|
||||||
this.config.updateRampSeconds
|
this.config.updateRampSeconds
|
||||||
);
|
);
|
||||||
this.delayOutput.gain.setTargetAtTime(
|
this.delayOutput.gain.setTargetAtTime(
|
||||||
this.config.delay.wetGain *
|
this.config.delay.wetGain *
|
||||||
(appConfig.audioEngine.graph.delayOutputBase +
|
(this.engineConfig.graph.delayOutputBase +
|
||||||
activity * appConfig.audioEngine.graph.delayOutputActivityWeight),
|
activity * this.engineConfig.graph.delayOutputActivityWeight),
|
||||||
now,
|
now,
|
||||||
this.config.updateRampSeconds
|
this.config.updateRampSeconds
|
||||||
);
|
);
|
||||||
|
|
@ -134,9 +137,9 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
if (this.masterGain && context.state !== 'closed') {
|
if (this.masterGain && context.state !== 'closed') {
|
||||||
this.masterGain.gain.setTargetAtTime(
|
this.masterGain.gain.setTargetAtTime(
|
||||||
appConfig.audioEngine.graph.closeGain,
|
this.engineConfig.graph.closeGain,
|
||||||
context.currentTime,
|
context.currentTime,
|
||||||
appConfig.audioEngine.graph.closeRampSeconds
|
this.engineConfig.graph.closeRampSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,7 +174,7 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||||
this.eventBus = context.createGain();
|
this.eventBus = context.createGain();
|
||||||
this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
|
this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
|
||||||
this.eventBus.connect(masterGain);
|
this.eventBus.connect(masterGain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,9 +184,9 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
for (let index = 0; index < data.length; index++) {
|
for (let index = 0; index < data.length; index++) {
|
||||||
data[index] =
|
data[index] =
|
||||||
appConfig.audioEngine.graph.noiseMin +
|
this.engineConfig.graph.noiseMin +
|
||||||
Math.random() *
|
Math.random() *
|
||||||
(appConfig.audioEngine.graph.noiseMax - appConfig.audioEngine.graph.noiseMin);
|
(this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer;
|
return buffer;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { appConfig } from '../config';
|
import type { GardenAudioEngineConfig } from '../config';
|
||||||
import { clamp01 } from '../utils/clamp';
|
import { clamp01 } from '../utils/clamp';
|
||||||
import { GardenAudioStroke } from './garden-audio-types';
|
import { GardenAudioStroke } from './garden-audio-types';
|
||||||
|
|
||||||
|
|
@ -12,24 +12,25 @@ export interface GardenAudioStrokeMetrics {
|
||||||
export const getStrokeMetrics = (
|
export const getStrokeMetrics = (
|
||||||
stroke: GardenAudioStroke,
|
stroke: GardenAudioStroke,
|
||||||
speedForFullEnergyPixelsPerSecond: number,
|
speedForFullEnergyPixelsPerSecond: number,
|
||||||
fallbackPressure: number
|
fallbackPressure: number,
|
||||||
|
inputConfig: GardenAudioEngineConfig['input']
|
||||||
): GardenAudioStrokeMetrics => {
|
): GardenAudioStrokeMetrics => {
|
||||||
const dx = stroke.to[0] - stroke.from[0];
|
const dx = stroke.to[0] - stroke.from[0];
|
||||||
const dy = stroke.to[1] - stroke.from[1];
|
const dy = stroke.to[1] - stroke.from[1];
|
||||||
const distancePixels = Math.hypot(dx, dy);
|
const distancePixels = Math.hypot(dx, dy);
|
||||||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
|
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
|
||||||
const pressure = getPressureAmount(stroke, fallbackPressure);
|
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
|
||||||
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
||||||
const strokeEnergy = clamp01(
|
const strokeEnergy = clamp01(
|
||||||
appConfig.audioEngine.input.strokeEnergyBase +
|
inputConfig.strokeEnergyBase +
|
||||||
speedAmount * appConfig.audioEngine.input.strokeEnergySpeedWeight +
|
speedAmount * inputConfig.strokeEnergySpeedWeight +
|
||||||
pressure * appConfig.audioEngine.input.strokeEnergyPressureWeight
|
pressure * inputConfig.strokeEnergyPressureWeight
|
||||||
);
|
);
|
||||||
const effectiveEnergy =
|
const effectiveEnergy =
|
||||||
strokeEnergy *
|
strokeEnergy *
|
||||||
(appConfig.audioEngine.input.distanceEnergyBase +
|
(inputConfig.distanceEnergyBase +
|
||||||
clamp01(distancePixels / appConfig.audioEngine.input.distanceForFullEnergyPixels) *
|
clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
|
||||||
appConfig.audioEngine.input.distanceEnergyScale);
|
inputConfig.distanceEnergyScale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
distancePixels,
|
distancePixels,
|
||||||
|
|
@ -39,7 +40,11 @@ export const getStrokeMetrics = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): number => {
|
const getStrokeVelocity = (
|
||||||
|
stroke: GardenAudioStroke,
|
||||||
|
distancePixels: number,
|
||||||
|
inputConfig: GardenAudioEngineConfig['input']
|
||||||
|
): number => {
|
||||||
if (
|
if (
|
||||||
stroke.velocityPixelsPerSecond !== undefined &&
|
stroke.velocityPixelsPerSecond !== undefined &&
|
||||||
Number.isFinite(stroke.velocityPixelsPerSecond) &&
|
Number.isFinite(stroke.velocityPixelsPerSecond) &&
|
||||||
|
|
@ -48,12 +53,13 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
|
||||||
return stroke.velocityPixelsPerSecond;
|
return stroke.velocityPixelsPerSecond;
|
||||||
}
|
}
|
||||||
|
|
||||||
return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
|
return distancePixels / inputConfig.fallbackFrameSeconds;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPressureAmount = (
|
const getPressureAmount = (
|
||||||
stroke: GardenAudioStroke,
|
stroke: GardenAudioStroke,
|
||||||
fallbackPressure: number
|
fallbackPressure: number,
|
||||||
|
inputConfig: GardenAudioEngineConfig['input']
|
||||||
): number => {
|
): number => {
|
||||||
if (
|
if (
|
||||||
stroke.pressure !== undefined &&
|
stroke.pressure !== undefined &&
|
||||||
|
|
@ -64,6 +70,6 @@ const getPressureAmount = (
|
||||||
}
|
}
|
||||||
|
|
||||||
return stroke.pointerType === 'pen'
|
return stroke.pointerType === 'pen'
|
||||||
? Math.max(appConfig.audioEngine.input.penMinPressure, clamp01(fallbackPressure))
|
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
|
||||||
: clamp01(fallbackPressure);
|
: clamp01(fallbackPressure);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { clamp } from '../utils/clamp';
|
|
||||||
import { VibePreset } from '../vibes';
|
import { VibePreset } from '../vibes';
|
||||||
import {
|
import {
|
||||||
GardenAudioChord,
|
GardenAudioChord,
|
||||||
|
|
@ -10,9 +9,6 @@ import { GardenAudioColorIndex } from './garden-audio-types';
|
||||||
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
|
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
|
||||||
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
|
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
|
||||||
|
|
||||||
export const clampMidi = (midi: number, min: number, max: number): number =>
|
|
||||||
Math.round(clamp(midi, min, max));
|
|
||||||
|
|
||||||
export const getVibeProfile = (
|
export const getVibeProfile = (
|
||||||
config: GardenAudioConfig,
|
config: GardenAudioConfig,
|
||||||
vibe: VibePreset
|
vibe: VibePreset
|
||||||
|
|
@ -21,15 +17,6 @@ export const getVibeProfile = (
|
||||||
config.vibes[config.fallbackVibeId] ??
|
config.vibes[config.fallbackVibeId] ??
|
||||||
Object.values(config.vibes)[0];
|
Object.values(config.vibes)[0];
|
||||||
|
|
||||||
export const getChordAtStep = (
|
|
||||||
config: GardenAudioConfig,
|
|
||||||
profile: GardenAudioVibeProfile,
|
|
||||||
stepIndex: number
|
|
||||||
): GardenAudioChord => {
|
|
||||||
const barIndex = Math.floor(stepIndex / config.rhythm.stepsPerBar);
|
|
||||||
return profile.progression[barIndex % profile.progression.length];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getChordIntervals = (
|
export const getChordIntervals = (
|
||||||
chord: GardenAudioChord,
|
chord: GardenAudioChord,
|
||||||
openVoicing: boolean
|
openVoicing: boolean
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export interface GardenAudioSnapshot {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
isErasing: boolean;
|
isErasing: boolean;
|
||||||
|
mirrorSegmentCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioStroke {
|
export interface GardenAudioStroke {
|
||||||
|
|
@ -18,6 +19,15 @@ export interface GardenAudioStroke {
|
||||||
pressure?: number;
|
pressure?: number;
|
||||||
velocityPixelsPerSecond?: number;
|
velocityPixelsPerSecond?: number;
|
||||||
eraserSizePixels?: number;
|
eraserSizePixels?: number;
|
||||||
|
mirrorSegmentCount?: number;
|
||||||
|
pointerType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GardenAudioTouchDown {
|
||||||
|
vibe: VibePreset;
|
||||||
|
colorIndex: number;
|
||||||
|
mirrorSegmentCount?: number;
|
||||||
|
pressure?: number;
|
||||||
pointerType?: string;
|
pointerType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import { VibePreset } from '../vibes';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { getStrokeMetrics } from './garden-audio-input';
|
import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input';
|
||||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||||
import type {
|
import type {
|
||||||
GardenAudioColorIndex,
|
GardenAudioColorIndex,
|
||||||
GardenAudioSnapshot,
|
GardenAudioSnapshot,
|
||||||
GardenAudioStartOptions,
|
GardenAudioStartOptions,
|
||||||
GardenAudioStroke,
|
GardenAudioStroke,
|
||||||
|
GardenAudioTouchDown,
|
||||||
} from './garden-audio-types';
|
} from './garden-audio-types';
|
||||||
import { GenerativePianoEngine } from './generative-piano';
|
import { GenerativePianoEngine } from './generative-piano';
|
||||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||||
|
|
@ -20,6 +21,7 @@ export type {
|
||||||
GardenAudioSnapshot,
|
GardenAudioSnapshot,
|
||||||
GardenAudioStartOptions,
|
GardenAudioStartOptions,
|
||||||
GardenAudioStroke,
|
GardenAudioStroke,
|
||||||
|
GardenAudioTouchDown,
|
||||||
} from './garden-audio-types';
|
} from './garden-audio-types';
|
||||||
|
|
||||||
export class GardenAudio {
|
export class GardenAudio {
|
||||||
|
|
@ -35,6 +37,7 @@ export class GardenAudio {
|
||||||
private isMuted = false;
|
private isMuted = false;
|
||||||
private isGestureActive = false;
|
private isGestureActive = false;
|
||||||
private selectedColorIndex: GardenAudioColorIndex = 0;
|
private selectedColorIndex: GardenAudioColorIndex = 0;
|
||||||
|
private hasQueuedPianoLoad = false;
|
||||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
|
@ -70,9 +73,21 @@ export class GardenAudio {
|
||||||
this.hasStarted = true;
|
this.hasStarted = true;
|
||||||
this.applyVibe(vibe);
|
this.applyVibe(vibe);
|
||||||
this.pianoEngine.prime(context.currentTime);
|
this.pianoEngine.prime(context.currentTime);
|
||||||
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
|
this.graph.setMasterGain(
|
||||||
|
this.config.masterVolume,
|
||||||
|
options.userGesture === true
|
||||||
|
? appConfig.audioEngine.muteRampSeconds
|
||||||
|
: this.config.fadeInSeconds
|
||||||
|
);
|
||||||
|
|
||||||
void this.piano.load(context);
|
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 {
|
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||||
|
|
@ -108,19 +123,38 @@ export class GardenAudio {
|
||||||
|
|
||||||
this.isGestureActive = true;
|
this.isGestureActive = true;
|
||||||
this.energy.beginGesture(context.currentTime);
|
this.energy.beginGesture(context.currentTime);
|
||||||
this.pianoEngine.beginGesture(context.currentTime);
|
this.pianoEngine.beginGesture();
|
||||||
}
|
}
|
||||||
|
|
||||||
public endGesture(): void {
|
public endGesture(): void {
|
||||||
const context = this.graph.context;
|
|
||||||
this.isGestureActive = false;
|
this.isGestureActive = false;
|
||||||
this.energy.endGesture();
|
this.energy.endGesture();
|
||||||
this.pianoEngine.endGesture();
|
this.pianoEngine.endGesture();
|
||||||
if (!context) {
|
}
|
||||||
|
|
||||||
|
public touchDown(touch: GardenAudioTouchDown): void {
|
||||||
|
if (this.isDestroyed || this.isMuted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
|
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 {
|
public update(snapshot: GardenAudioSnapshot): void {
|
||||||
|
|
@ -135,22 +169,14 @@ export class GardenAudio {
|
||||||
|
|
||||||
if (snapshot.isErasing) {
|
if (snapshot.isErasing) {
|
||||||
this.energy.silence();
|
this.energy.silence();
|
||||||
this.piano.fadeActive(
|
|
||||||
context.currentTime,
|
|
||||||
appConfig.audioEngine.gestureFadeSeconds
|
|
||||||
);
|
|
||||||
this.updateDelay(snapshot);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isGestureActive) {
|
|
||||||
this.pianoEngine.renderLookahead({
|
this.pianoEngine.renderLookahead({
|
||||||
vibe: snapshot.vibe,
|
vibe: snapshot.vibe,
|
||||||
now: context.currentTime,
|
now: context.currentTime,
|
||||||
activity: this.energy.getActivity(),
|
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
|
||||||
selectedColorIndex: this.selectedColorIndex,
|
selectedColorIndex: this.selectedColorIndex,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
this.updateDelay(snapshot);
|
this.updateDelay(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,9 +209,16 @@ export class GardenAudio {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const strokeEnergy = metrics.effectiveEnergy;
|
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
|
||||||
|
const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount);
|
||||||
this.energy.recordStroke(strokeEnergy, now);
|
this.energy.recordStroke(strokeEnergy, now);
|
||||||
this.pianoEngine.wake(now);
|
this.pianoEngine.recordStroke({
|
||||||
|
vibe: stroke.vibe,
|
||||||
|
now,
|
||||||
|
activity: strokeEnergy,
|
||||||
|
selectedColorIndex: this.selectedColorIndex,
|
||||||
|
mirrorAmount,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async destroy(): Promise<void> {
|
public async destroy(): Promise<void> {
|
||||||
|
|
@ -199,6 +232,7 @@ export class GardenAudio {
|
||||||
this.hasStarted = false;
|
this.hasStarted = false;
|
||||||
this.isGestureActive = false;
|
this.isGestureActive = false;
|
||||||
this.selectedColorIndex = 0;
|
this.selectedColorIndex = 0;
|
||||||
|
this.hasQueuedPianoLoad = false;
|
||||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
}
|
}
|
||||||
|
|
@ -285,5 +319,62 @@ export class GardenAudio {
|
||||||
|
|
||||||
this.currentVibeId = vibe.id;
|
this.currentVibeId = vibe.id;
|
||||||
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
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);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,104 +14,220 @@ const makeEngine = () => {
|
||||||
return { engine, notes };
|
return { engine, notes };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
|
||||||
|
|
||||||
|
const getBeatsPerBar = (): number =>
|
||||||
|
Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat);
|
||||||
|
|
||||||
const renderBars = (
|
const renderBars = (
|
||||||
engine: GenerativePianoEngine,
|
engine: GenerativePianoEngine,
|
||||||
activity: number,
|
activity: number,
|
||||||
selectedColorIndex = 0,
|
selectedColorIndex = 0,
|
||||||
bars = 4
|
bars = 8
|
||||||
) => {
|
) => {
|
||||||
engine.renderLookahead({
|
engine.renderLookahead({
|
||||||
vibe: VIBE_PRESETS[0],
|
vibe: VIBE_PRESETS[0],
|
||||||
now: 0,
|
now: 0,
|
||||||
activity,
|
activity,
|
||||||
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
|
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
|
||||||
lookaheadSeconds:
|
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
|
||||||
(60 / gardenAudioConfig.rhythm.bpm) *
|
|
||||||
Math.round(
|
|
||||||
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
|
|
||||||
) *
|
|
||||||
bars,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const average = (values: Array<number>): number =>
|
const average = (values: Array<number>): number =>
|
||||||
values.reduce((sum, value) => sum + value, 0) / values.length;
|
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', () => {
|
describe('GenerativePianoEngine', () => {
|
||||||
it('does not emit notes below the sparse activity threshold', () => {
|
it('plays quiet background music even when the garden is idle', () => {
|
||||||
const { engine, notes } = makeEngine();
|
const { engine, notes } = makeEngine();
|
||||||
|
|
||||||
renderBars(engine, gardenAudioConfig.rhythm.sparseActivity - 0.01);
|
renderBars(engine, 0);
|
||||||
|
|
||||||
expect(notes).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps drawing notes on beat starts', () => {
|
|
||||||
const { engine, notes } = makeEngine();
|
|
||||||
const beatSeconds = 60 / gardenAudioConfig.rhythm.bpm;
|
|
||||||
const startDelaySeconds = 0.02;
|
|
||||||
|
|
||||||
renderBars(engine, 1, 1);
|
|
||||||
|
|
||||||
expect(notes.length).toBeGreaterThan(0);
|
expect(notes.length).toBeGreaterThan(0);
|
||||||
notes.forEach((note) => {
|
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe(
|
||||||
const beatsFromStart = (note.startTime - startDelaySeconds) / beatSeconds;
|
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);
|
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('varies density with activity without exceeding one note per beat', () => {
|
it('uses color pools with multiple notes instead of one key per color', () => {
|
||||||
const low = makeEngine();
|
([0, 1, 2] as const).forEach((selectedColorIndex) => {
|
||||||
const high = makeEngine();
|
const { engine, notes } = makeEngine();
|
||||||
|
|
||||||
renderBars(low.engine, gardenAudioConfig.rhythm.sparseActivity + 0.03, 1);
|
renderBars(engine, 1, selectedColorIndex, 16);
|
||||||
renderBars(high.engine, 1, 1);
|
|
||||||
|
|
||||||
expect(high.notes.length).toBeGreaterThan(low.notes.length);
|
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
|
||||||
expect(high.notes.length).toBeLessThanOrEqual(16);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wakes every color with a prompt first note at low activity', () => {
|
it('keeps the upper color higher and wider than the lower color', () => {
|
||||||
([0, 1, 2] as const).forEach((selectedColorIndex) => {
|
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 { engine, notes } = makeEngine();
|
||||||
const now = 4;
|
const now = 4;
|
||||||
|
|
||||||
engine.beginGesture(1);
|
engine.beginGesture();
|
||||||
engine.wake(now);
|
engine.recordStroke({
|
||||||
engine.renderLookahead({
|
|
||||||
vibe: VIBE_PRESETS[0],
|
vibe: VIBE_PRESETS[0],
|
||||||
now,
|
now,
|
||||||
activity: gardenAudioConfig.rhythm.sparseActivity + 0.01,
|
activity: 0.9,
|
||||||
selectedColorIndex,
|
selectedColorIndex: 1,
|
||||||
lookaheadSeconds: 0.08,
|
});
|
||||||
|
engine.recordStroke({
|
||||||
|
vibe: VIBE_PRESETS[0],
|
||||||
|
now: now + 1,
|
||||||
|
activity: 0.95,
|
||||||
|
selectedColorIndex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(notes).toHaveLength(1);
|
expect(notes).toHaveLength(1);
|
||||||
expect(notes[0].startTime).toBeCloseTo(now + 0.02);
|
expect(notes[0].startTime).toBe(now);
|
||||||
});
|
|
||||||
|
engine.recordStroke({
|
||||||
|
vibe: VIBE_PRESETS[0],
|
||||||
|
now: now + 6,
|
||||||
|
activity: 0.95,
|
||||||
|
selectedColorIndex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses different color roles for register and pan', () => {
|
expect(notes).toHaveLength(2);
|
||||||
const anchor = makeEngine();
|
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1);
|
||||||
const spark = makeEngine();
|
|
||||||
|
|
||||||
renderBars(anchor.engine, 1, 0);
|
|
||||||
renderBars(spark.engine, 1, 2);
|
|
||||||
|
|
||||||
expect(average(spark.notes.map((note) => note.midi))).toBeGreaterThan(
|
|
||||||
average(anchor.notes.map((note) => note.midi))
|
|
||||||
);
|
|
||||||
expect(average(spark.notes.map((note) => note.pan))).toBeGreaterThan(
|
|
||||||
average(anchor.notes.map((note) => note.pan))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for the same phrase inputs', () => {
|
it('is deterministic for the same musical inputs', () => {
|
||||||
const first = makeEngine();
|
const first = makeEngine();
|
||||||
const second = makeEngine();
|
const second = makeEngine();
|
||||||
|
|
||||||
renderBars(first.engine, 0.78, 2);
|
renderBars(first.engine, 0.78, 2, 16);
|
||||||
renderBars(second.engine, 0.78, 2);
|
renderBars(second.engine, 0.78, 2, 16);
|
||||||
|
|
||||||
expect(second.notes).toEqual(first.notes);
|
expect(second.notes).toEqual(first.notes);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { appConfig } from '../config';
|
import type { GardenAudioEngineConfig } from '../config';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { NoiseBurst } from './garden-audio-types';
|
import { NoiseBurst } from './garden-audio-types';
|
||||||
|
|
||||||
export class NoiseBurstPlayer {
|
export class NoiseBurstPlayer {
|
||||||
public constructor(private readonly graph: GardenAudioGraph) {}
|
public constructor(
|
||||||
|
private readonly engineConfig: GardenAudioEngineConfig,
|
||||||
|
private readonly graph: GardenAudioGraph
|
||||||
|
) {}
|
||||||
|
|
||||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||||
const { context, eventBus, noiseBuffer } = this.graph;
|
const { context, eventBus, noiseBuffer } = this.graph;
|
||||||
|
|
@ -12,7 +15,7 @@ export class NoiseBurstPlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledStart = Math.max(
|
const scheduledStart = Math.max(
|
||||||
context.currentTime + appConfig.audioEngine.noiseBurst.scheduleAheadSeconds,
|
context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds,
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
|
|
@ -24,17 +27,17 @@ export class NoiseBurstPlayer {
|
||||||
source.buffer = noiseBuffer;
|
source.buffer = noiseBuffer;
|
||||||
filter.type = 'bandpass';
|
filter.type = 'bandpass';
|
||||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||||
filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
|
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
|
||||||
envelope.gain.setValueAtTime(
|
envelope.gain.setValueAtTime(
|
||||||
appConfig.audioEngine.noiseBurst.silentGain,
|
this.engineConfig.noiseBurst.silentGain,
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
envelope.gain.exponentialRampToValueAtTime(
|
envelope.gain.exponentialRampToValueAtTime(
|
||||||
Math.max(appConfig.audioEngine.noiseBurst.silentGain, gain),
|
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
|
||||||
scheduledStart + appConfig.audioEngine.noiseBurst.attackSeconds
|
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
|
||||||
);
|
);
|
||||||
envelope.gain.exponentialRampToValueAtTime(
|
envelope.gain.exponentialRampToValueAtTime(
|
||||||
appConfig.audioEngine.noiseBurst.silentGain,
|
this.engineConfig.noiseBurst.silentGain,
|
||||||
stopAt
|
stopAt
|
||||||
);
|
);
|
||||||
panner.pan.setValueAtTime(pan, scheduledStart);
|
panner.pan.setValueAtTime(pan, scheduledStart);
|
||||||
|
|
@ -45,7 +48,7 @@ export class NoiseBurstPlayer {
|
||||||
panner.connect(eventBus);
|
panner.connect(eventBus);
|
||||||
source.start(
|
source.start(
|
||||||
scheduledStart,
|
scheduledStart,
|
||||||
Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
|
Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds
|
||||||
);
|
);
|
||||||
source.stop(stopAt);
|
source.stop(stopAt);
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { appConfig } from '../config';
|
import type { GardenAudioEngineConfig } from '../config';
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
|
|
@ -12,6 +12,7 @@ export class PianoSampler {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly config: GardenAudioConfig,
|
private readonly config: GardenAudioConfig,
|
||||||
|
private readonly engineConfig: GardenAudioEngineConfig,
|
||||||
private readonly graph: GardenAudioGraph
|
private readonly graph: GardenAudioGraph
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -51,31 +52,39 @@ export class PianoSampler {
|
||||||
lowpassHz = this.config.piano.lowpassHz,
|
lowpassHz = this.config.piano.lowpassHz,
|
||||||
}: PianoNote): void {
|
}: PianoNote): void {
|
||||||
const { context, eventBus, delayInput } = this.graph;
|
const { context, eventBus, delayInput } = this.graph;
|
||||||
if (!context || !eventBus || this.samples.length === 0) {
|
if (!context || !eventBus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sample = this.findNearestSample(midi);
|
const sample = this.findNearestSample(midi);
|
||||||
if (!sample) {
|
if (!sample) {
|
||||||
|
this.playFallbackPluck({
|
||||||
|
midi,
|
||||||
|
velocity,
|
||||||
|
startTime,
|
||||||
|
durationSeconds,
|
||||||
|
pan,
|
||||||
|
delaySend,
|
||||||
|
lowpassHz,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledStart = Math.max(
|
const scheduledStart = Math.max(
|
||||||
context.currentTime + appConfig.audioEngine.piano.scheduleAheadSeconds,
|
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
const noteVelocity = clamp01(velocity);
|
const noteVelocity = clamp01(velocity);
|
||||||
const noteGainValue = Math.max(
|
const noteGainValue = Math.max(
|
||||||
appConfig.audioEngine.piano.minGain,
|
this.engineConfig.piano.minGain,
|
||||||
this.config.piano.gain * noteVelocity
|
this.config.piano.gain * noteVelocity
|
||||||
);
|
);
|
||||||
const sustainSeconds =
|
const sustainSeconds =
|
||||||
this.config.piano.sustainSeconds *
|
this.config.piano.sustainSeconds *
|
||||||
(appConfig.audioEngine.piano.sustainBase +
|
(this.engineConfig.piano.sustainBase +
|
||||||
noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
|
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
|
||||||
const sustainAt =
|
const sustainAt =
|
||||||
scheduledStart +
|
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
||||||
Math.max(appConfig.audioEngine.piano.minDurationSeconds, durationSeconds);
|
|
||||||
const releaseAt = sustainAt + sustainSeconds;
|
const releaseAt = sustainAt + sustainSeconds;
|
||||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||||
const stopAt = releaseAt + releaseSeconds;
|
const stopAt = releaseAt + releaseSeconds;
|
||||||
|
|
@ -90,20 +99,18 @@ export class PianoSampler {
|
||||||
const oldest = this.activeVoices.shift();
|
const oldest = this.activeVoices.shift();
|
||||||
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
||||||
oldest?.gain.gain.setTargetAtTime(
|
oldest?.gain.gain.setTargetAtTime(
|
||||||
appConfig.audioEngine.piano.minGain,
|
this.engineConfig.piano.minGain,
|
||||||
scheduledStart,
|
scheduledStart,
|
||||||
appConfig.audioEngine.piano.voiceStealFadeSeconds
|
this.engineConfig.piano.voiceStealFadeSeconds
|
||||||
);
|
|
||||||
oldest?.source.stop(
|
|
||||||
scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
|
|
||||||
);
|
);
|
||||||
|
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
source.buffer = sample.buffer;
|
source.buffer = sample.buffer;
|
||||||
source.playbackRate.setValueAtTime(
|
source.playbackRate.setValueAtTime(
|
||||||
Math.pow(
|
Math.pow(
|
||||||
2,
|
2,
|
||||||
(midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
|
(midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave
|
||||||
),
|
),
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
|
|
@ -111,30 +118,30 @@ export class PianoSampler {
|
||||||
filter.frequency.setValueAtTime(
|
filter.frequency.setValueAtTime(
|
||||||
clamp(
|
clamp(
|
||||||
lowpassHz,
|
lowpassHz,
|
||||||
appConfig.audioEngine.piano.lowpassMinHz,
|
this.engineConfig.piano.lowpassMinHz,
|
||||||
appConfig.audioEngine.piano.lowpassMaxHz
|
this.engineConfig.piano.lowpassMaxHz
|
||||||
),
|
),
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
filter.Q.value = appConfig.audioEngine.piano.filterQ;
|
filter.Q.value = this.engineConfig.piano.filterQ;
|
||||||
gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, scheduledStart);
|
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
|
||||||
gain.gain.exponentialRampToValueAtTime(
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
noteGainValue,
|
noteGainValue,
|
||||||
scheduledStart + appConfig.audioEngine.piano.gainAttackSeconds
|
scheduledStart + this.engineConfig.piano.gainAttackSeconds
|
||||||
);
|
);
|
||||||
gain.gain.setTargetAtTime(
|
gain.gain.setTargetAtTime(
|
||||||
Math.max(
|
Math.max(
|
||||||
appConfig.audioEngine.piano.minGain,
|
this.engineConfig.piano.minGain,
|
||||||
noteGainValue * this.config.piano.sustainLevel
|
noteGainValue * this.config.piano.sustainLevel
|
||||||
),
|
),
|
||||||
sustainAt,
|
sustainAt,
|
||||||
Math.max(
|
Math.max(
|
||||||
appConfig.audioEngine.piano.minFadeSeconds,
|
this.engineConfig.piano.minFadeSeconds,
|
||||||
sustainSeconds * appConfig.audioEngine.piano.sustainBase
|
sustainSeconds * this.engineConfig.piano.sustainBase
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
gain.gain.setTargetAtTime(
|
gain.gain.setTargetAtTime(
|
||||||
appConfig.audioEngine.piano.minGain,
|
this.engineConfig.piano.minGain,
|
||||||
releaseAt,
|
releaseAt,
|
||||||
releaseSeconds
|
releaseSeconds
|
||||||
);
|
);
|
||||||
|
|
@ -153,7 +160,7 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
|
|
||||||
source.start(scheduledStart);
|
source.start(scheduledStart);
|
||||||
source.stop(stopAt + appConfig.audioEngine.piano.tailStopExtraSeconds);
|
source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
|
||||||
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
||||||
|
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
|
|
@ -170,41 +177,6 @@ export class PianoSampler {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public fadeActive(
|
|
||||||
now: number,
|
|
||||||
fadeSeconds = appConfig.audioEngine.piano.defaultFadeSeconds
|
|
||||||
): void {
|
|
||||||
this.activeVoices.forEach((voice) => {
|
|
||||||
voice.gain.gain.cancelScheduledValues(now);
|
|
||||||
if (voice.startAt > now) {
|
|
||||||
voice.gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, now);
|
|
||||||
voice.stopAt = now;
|
|
||||||
try {
|
|
||||||
voice.source.stop(now);
|
|
||||||
} catch {
|
|
||||||
// The source may already have a stop time scheduled.
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopAt = Math.min(voice.stopAt, now + fadeSeconds);
|
|
||||||
voice.gain.gain.setTargetAtTime(
|
|
||||||
appConfig.audioEngine.piano.minGain,
|
|
||||||
now,
|
|
||||||
Math.max(
|
|
||||||
appConfig.audioEngine.piano.minFadeSeconds,
|
|
||||||
fadeSeconds * appConfig.audioEngine.piano.fadeTimeConstantRatio
|
|
||||||
)
|
|
||||||
);
|
|
||||||
voice.stopAt = stopAt;
|
|
||||||
try {
|
|
||||||
voice.source.stop(stopAt);
|
|
||||||
} catch {
|
|
||||||
// The source may already have a stop time scheduled.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.sampleLoadPromise = null;
|
this.sampleLoadPromise = null;
|
||||||
this.samples = [];
|
this.samples = [];
|
||||||
|
|
@ -224,4 +196,90 @@ export class PianoSampler {
|
||||||
private trimActiveVoices(now: number): void {
|
private trimActiveVoices(now: number): void {
|
||||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export interface PianoSampleDefinition {
|
interface PianoSampleDefinition {
|
||||||
midi: number;
|
midi: number;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
687
src/config.ts
|
|
@ -1,463 +1,23 @@
|
||||||
import type {
|
import { runtimeSettings } from './config/runtime-settings';
|
||||||
GardenAudioChord,
|
import type { GardenAppConfig } from './config/types';
|
||||||
GardenAudioConfig,
|
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
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 &
|
export type {
|
||||||
AgentSettings &
|
AgentColorInteractionSettings,
|
||||||
BrushSettings &
|
GardenAppConfig,
|
||||||
DiffusionSettings &
|
GardenAudioEngineConfig,
|
||||||
RenderSettings;
|
|
||||||
|
|
||||||
export type GardenVibeSettings = Partial<
|
|
||||||
Pick<
|
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
| 'agentBudgetMax'
|
GardenSimulationConfig,
|
||||||
| 'brushSize'
|
GardenStorageConfig,
|
||||||
| 'clarity'
|
GardenVibeSettings,
|
||||||
| 'decayRateTrails'
|
NumberControlConfig,
|
||||||
| 'diffusionRateTrails'
|
RuntimeSettingControlConfig,
|
||||||
| 'individualTrailWeight'
|
VibePreset,
|
||||||
| 'moveSpeed'
|
} from './config/types';
|
||||||
| 'sensorOffsetAngle'
|
|
||||||
| 'sensorOffsetDistance'
|
|
||||||
| 'spawnPerPixel'
|
|
||||||
| 'turnSpeed'
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface VibePreset {
|
export const appConfig = {
|
||||||
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;
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
gestureFadeSeconds: 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: {
|
|
||||||
fadeStopExtraSeconds: number;
|
|
||||||
defaultFadeSeconds: number;
|
|
||||||
fadeTimeConstantRatio: number;
|
|
||||||
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: {
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
introCameraZoom: 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>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
|
||||||
|
|
||||||
const defaultVibeId = 'candy-rain';
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 57,
|
masterVolume: 0.42,
|
||||||
scale: majorPentatonic,
|
|
||||||
brightness: 1.04,
|
|
||||||
delayTimeMultiplier: 0.92,
|
|
||||||
progression: majorProgression,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sunlit-moss',
|
|
||||||
name: 'Sunlit Moss',
|
|
||||||
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
|
||||||
backgroundColor: '#172016',
|
|
||||||
settings: {
|
|
||||||
agentBudgetMax: 900_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,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
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: 850_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,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
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: 750_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,
|
|
||||||
},
|
|
||||||
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' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const audioVibes = Object.fromEntries(
|
|
||||||
vibePresets.map((vibe) => [vibe.id, vibe.audio])
|
|
||||||
) as Record<string, GardenAudioVibeProfile>;
|
|
||||||
|
|
||||||
export const appConfig: GardenAppConfig = {
|
|
||||||
audio: {
|
|
||||||
masterVolume: 0.32,
|
|
||||||
fadeInSeconds: 0.45,
|
fadeInSeconds: 0.45,
|
||||||
updateRampSeconds: 0.08,
|
updateRampSeconds: 0.08,
|
||||||
highPassFrequencyHz: 45,
|
highPassFrequencyHz: 45,
|
||||||
|
|
@ -470,26 +30,26 @@ export const appConfig: GardenAppConfig = {
|
||||||
releaseSeconds: 0.18,
|
releaseSeconds: 0.18,
|
||||||
},
|
},
|
||||||
delay: {
|
delay: {
|
||||||
timeSeconds: 0.42,
|
timeSeconds: 0.46,
|
||||||
feedback: 0.12,
|
feedback: 0.12,
|
||||||
wetGain: 0.048,
|
wetGain: 0.044,
|
||||||
},
|
},
|
||||||
piano: {
|
piano: {
|
||||||
maxVoices: 32,
|
maxVoices: 24,
|
||||||
gain: 0.42,
|
gain: 0.48,
|
||||||
sustainSeconds: 0.52,
|
sustainSeconds: 0.42,
|
||||||
sustainLevel: 0.34,
|
sustainLevel: 0.32,
|
||||||
releaseSeconds: 0.16,
|
releaseSeconds: 0.24,
|
||||||
lowpassHz: 9000,
|
lowpassHz: 7600,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
pressureFallback: 0.48,
|
pressureFallback: 0.48,
|
||||||
},
|
},
|
||||||
rhythm: {
|
rhythm: {
|
||||||
bpm: 82,
|
bpm: 74,
|
||||||
stepsPerBeat: 4,
|
stepsPerBeat: 4,
|
||||||
stepsPerBar: 16,
|
stepsPerBar: 16,
|
||||||
lookaheadSeconds: 0.18,
|
lookaheadSeconds: 0.3,
|
||||||
speedForFullEnergyPixelsPerSecond: 1800,
|
speedForFullEnergyPixelsPerSecond: 1800,
|
||||||
sparseActivity: 0.055,
|
sparseActivity: 0.055,
|
||||||
},
|
},
|
||||||
|
|
@ -540,7 +100,6 @@ export const appConfig: GardenAppConfig = {
|
||||||
delay: {
|
delay: {
|
||||||
erasingActivity: 0.12,
|
erasingActivity: 0.12,
|
||||||
},
|
},
|
||||||
gestureFadeSeconds: 1.35,
|
|
||||||
graph: {
|
graph: {
|
||||||
closeGain: 0.0001,
|
closeGain: 0.0001,
|
||||||
closeRampSeconds: 0.015,
|
closeRampSeconds: 0.015,
|
||||||
|
|
@ -576,9 +135,6 @@ export const appConfig: GardenAppConfig = {
|
||||||
silentGain: 0.0001,
|
silentGain: 0.0001,
|
||||||
},
|
},
|
||||||
piano: {
|
piano: {
|
||||||
fadeStopExtraSeconds: 0.05,
|
|
||||||
defaultFadeSeconds: 0.9,
|
|
||||||
fadeTimeConstantRatio: 0.3,
|
|
||||||
filterQ: 0.7,
|
filterQ: 0.7,
|
||||||
gainAttackSeconds: 0.006,
|
gainAttackSeconds: 0.006,
|
||||||
lowpassMaxHz: 12000,
|
lowpassMaxHz: 12000,
|
||||||
|
|
@ -630,190 +186,12 @@ export const appConfig: GardenAppConfig = {
|
||||||
workgroupSize: 64,
|
workgroupSize: 64,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
runtimeSettings: {
|
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,
|
|
||||||
|
|
||||||
diffusionRateTrails: 0.22,
|
|
||||||
decayRateTrails: 965,
|
|
||||||
diffusionRateBrush: 0.35,
|
|
||||||
decayRateBrush: 18,
|
|
||||||
brushEffectDuration: 8,
|
|
||||||
|
|
||||||
clarity: 0.62,
|
|
||||||
brushSize: 14,
|
|
||||||
eraserSize: 96,
|
|
||||||
mirrorSegmentCount: 1,
|
|
||||||
|
|
||||||
brushSizeVariation: 0.5,
|
|
||||||
|
|
||||||
startColorHue: 200,
|
|
||||||
|
|
||||||
renderSpeed: 1,
|
|
||||||
simulatedDelayMs: 0,
|
|
||||||
},
|
|
||||||
controls: {
|
|
||||||
agentBudgetMax: {
|
|
||||||
folder: 'Runtime',
|
|
||||||
integer: true,
|
|
||||||
min: 1_000,
|
|
||||||
max: 1_000_000,
|
|
||||||
step: 1_000,
|
|
||||||
},
|
|
||||||
agentCount: {
|
|
||||||
folder: 'Runtime',
|
|
||||||
integer: true,
|
|
||||||
min: 0,
|
|
||||||
max: 1_000_000,
|
|
||||||
step: 1_000,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
simulation: {
|
simulation: {
|
||||||
budget: {
|
budget: {
|
||||||
fpsHeadroom: 0.82,
|
adaptiveCapDecreaseAgentsPerSecond: 50_000,
|
||||||
|
adaptiveCapMin: 500_000,
|
||||||
|
fpsHeadroom: 0.95,
|
||||||
fpsSmoothingNew: 0.06,
|
fpsSmoothingNew: 0.06,
|
||||||
fpsSmoothingRetain: 0.94,
|
fpsSmoothingRetain: 0.94,
|
||||||
initialTargetAgentBudget: 20_000,
|
initialTargetAgentBudget: 20_000,
|
||||||
|
|
@ -821,7 +199,7 @@ export const appConfig: GardenAppConfig = {
|
||||||
refreshTargetDecay: 0.995,
|
refreshTargetDecay: 0.995,
|
||||||
},
|
},
|
||||||
brushEffectFramesPerSecond: 60,
|
brushEffectFramesPerSecond: 60,
|
||||||
globalAgentCap: 1_000_000,
|
globalAgentCap: 10_000_000,
|
||||||
initialAgentCount: 180_000,
|
initialAgentCount: 180_000,
|
||||||
intro: {
|
intro: {
|
||||||
angleJitterRadians: Math.PI * 0.08,
|
angleJitterRadians: Math.PI * 0.08,
|
||||||
|
|
@ -855,7 +233,6 @@ export const appConfig: GardenAppConfig = {
|
||||||
titleStrokeWidthRatio: 0.11,
|
titleStrokeWidthRatio: 0.11,
|
||||||
verticalAnchor: 0.47,
|
verticalAnchor: 0.47,
|
||||||
},
|
},
|
||||||
introCameraZoom: 0.12,
|
|
||||||
introMoveSpeedBaseMultiplier: 1.8,
|
introMoveSpeedBaseMultiplier: 1.8,
|
||||||
introMoveSpeedProgressMultiplier: 0.35,
|
introMoveSpeedProgressMultiplier: 0.35,
|
||||||
maxMirrorSegmentCount: 12,
|
maxMirrorSegmentCount: 12,
|
||||||
|
|
@ -912,4 +289,4 @@ export const appConfig: GardenAppConfig = {
|
||||||
defaultVibeId,
|
defaultVibeId,
|
||||||
presets: vibePresets,
|
presets: vibePresets,
|
||||||
},
|
},
|
||||||
};
|
} satisfies GardenAppConfig;
|
||||||
|
|
|
||||||
71
src/config/color-interactions.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import type {
|
||||||
|
AgentColorInteractionSettings,
|
||||||
|
NumberControlConfig,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const agentInteractionOptions: Record<string, number> = {
|
||||||
|
Follow: 1,
|
||||||
|
Avoid: -1,
|
||||||
|
Ignore: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: 0,
|
||||||
|
color1ToColor3: 0,
|
||||||
|
color2ToColor1: 0,
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: 0,
|
||||||
|
color3ToColor1: 0,
|
||||||
|
color3ToColor2: 0,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hashString = (value: string): number => {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
hash ^= value.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSeededRandom = (seed: number): (() => number) => {
|
||||||
|
let state = seed;
|
||||||
|
return () => {
|
||||||
|
let value = (state += 0x6d2b79f5);
|
||||||
|
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||||
|
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||||
|
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createColorInteractionSettings = (
|
||||||
|
seedSource: string
|
||||||
|
): AgentColorInteractionSettings => {
|
||||||
|
const random = createSeededRandom(hashString(seedSource));
|
||||||
|
const values = Object.values(agentInteractionOptions);
|
||||||
|
const randomInteraction = () =>
|
||||||
|
values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: randomInteraction(),
|
||||||
|
color1ToColor3: randomInteraction(),
|
||||||
|
color2ToColor1: randomInteraction(),
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: randomInteraction(),
|
||||||
|
color3ToColor1: randomInteraction(),
|
||||||
|
color3ToColor2: randomInteraction(),
|
||||||
|
color3ToColor3: 1,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||||
|
folder: 'Color Reactions',
|
||||||
|
label,
|
||||||
|
min: -1,
|
||||||
|
max: 1,
|
||||||
|
step: 1,
|
||||||
|
options: agentInteractionOptions,
|
||||||
|
});
|
||||||
206
src/config/runtime-settings.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import {
|
||||||
|
colorInteractionControl,
|
||||||
|
defaultColorInteractionSettings,
|
||||||
|
} from './color-interactions';
|
||||||
|
import type { GardenAppConfig } from './types';
|
||||||
|
|
||||||
|
export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
|
||||||
|
defaults: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
agentCount: 0,
|
||||||
|
selectedColorIndex: 0,
|
||||||
|
spawnPerPixel: 0.22,
|
||||||
|
|
||||||
|
moveSpeed: 82,
|
||||||
|
turnSpeed: 58,
|
||||||
|
sensorOffsetAngle: 34,
|
||||||
|
sensorOffsetDistance: 38,
|
||||||
|
turnWhenLost: 0.8,
|
||||||
|
|
||||||
|
individualTrailWeight: 0.07,
|
||||||
|
...defaultColorInteractionSettings,
|
||||||
|
|
||||||
|
diffusionRateTrails: 0.22,
|
||||||
|
decayRateTrails: 965,
|
||||||
|
diffusionRateBrush: 0.35,
|
||||||
|
decayRateBrush: 18,
|
||||||
|
brushEffectDuration: 8,
|
||||||
|
|
||||||
|
clarity: 0.62,
|
||||||
|
brushSize: 14,
|
||||||
|
brushCurveResolution: 12,
|
||||||
|
eraserSize: 96,
|
||||||
|
mirrorSegmentCount: 1,
|
||||||
|
|
||||||
|
brushSizeVariation: 0.5,
|
||||||
|
|
||||||
|
startColorHue: 200,
|
||||||
|
|
||||||
|
renderSpeed: 1,
|
||||||
|
simulatedDelayMs: 0,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
agentBudgetMax: {
|
||||||
|
folder: 'Runtime',
|
||||||
|
integer: true,
|
||||||
|
min: 500_000,
|
||||||
|
max: 10_000_000,
|
||||||
|
step: 50_000,
|
||||||
|
},
|
||||||
|
agentCount: {
|
||||||
|
folder: 'Runtime',
|
||||||
|
integer: true,
|
||||||
|
min: 0,
|
||||||
|
max: 1_000_000,
|
||||||
|
step: 1_000,
|
||||||
|
},
|
||||||
|
color1ToColor1: colorInteractionControl('1 -> 1'),
|
||||||
|
color1ToColor2: colorInteractionControl('1 -> 2'),
|
||||||
|
color1ToColor3: colorInteractionControl('1 -> 3'),
|
||||||
|
color2ToColor1: colorInteractionControl('2 -> 1'),
|
||||||
|
color2ToColor2: colorInteractionControl('2 -> 2'),
|
||||||
|
color2ToColor3: colorInteractionControl('2 -> 3'),
|
||||||
|
color3ToColor1: colorInteractionControl('3 -> 1'),
|
||||||
|
color3ToColor2: colorInteractionControl('3 -> 2'),
|
||||||
|
color3ToColor3: colorInteractionControl('3 -> 3'),
|
||||||
|
brushEffectDuration: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0.5,
|
||||||
|
max: 20,
|
||||||
|
step: 0.05,
|
||||||
|
},
|
||||||
|
brushSize: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 1,
|
||||||
|
max: 60,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
brushSizeVariation: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
brushCurveResolution: {
|
||||||
|
folder: 'Brush',
|
||||||
|
integer: true,
|
||||||
|
label: 'curve resolution',
|
||||||
|
min: 1,
|
||||||
|
max: 32,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
clarity: {
|
||||||
|
folder: 'Render',
|
||||||
|
min: 0.00001,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
decayRateBrush: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0.1,
|
||||||
|
max: 100,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
decayRateTrails: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0.1,
|
||||||
|
max: 5000,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
diffusionRateBrush: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0.001,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
diffusionRateTrails: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
eraserSize: {
|
||||||
|
folder: 'Brush',
|
||||||
|
integer: true,
|
||||||
|
min: 24,
|
||||||
|
max: 240,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
individualTrailWeight: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
mirrorSegmentCount: {
|
||||||
|
folder: 'Brush',
|
||||||
|
integer: true,
|
||||||
|
min: 1,
|
||||||
|
max: 12,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
moveSpeed: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 10,
|
||||||
|
max: 500,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
renderSpeed: {
|
||||||
|
folder: 'Runtime',
|
||||||
|
integer: true,
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
selectedColorIndex: {
|
||||||
|
folder: 'Brush',
|
||||||
|
integer: true,
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
sensorOffsetAngle: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 90,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
sensorOffsetDistance: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 200,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
simulatedDelayMs: {
|
||||||
|
folder: 'Runtime',
|
||||||
|
integer: true,
|
||||||
|
min: 0,
|
||||||
|
max: 2000,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
spawnPerPixel: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0.01,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
startColorHue: {
|
||||||
|
folder: 'Render',
|
||||||
|
min: 0,
|
||||||
|
max: 360,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
turnSpeed: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 1,
|
||||||
|
max: 200,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
turnWhenLost: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
287
src/config/types.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
import type {
|
||||||
|
GardenAudioConfig,
|
||||||
|
GardenAudioVibeProfile,
|
||||||
|
} from '../audio/garden-audio-config';
|
||||||
|
import type { GameLoopSettings } from '../game-loop/game-loop-settings';
|
||||||
|
import type { AgentSettings } from '../pipelines/agents/agent-settings';
|
||||||
|
import type { BrushSettings } from '../pipelines/brush/brush-settings';
|
||||||
|
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
|
||||||
|
import type { RenderSettings } from '../pipelines/render/render-settings';
|
||||||
|
|
||||||
|
export type GardenRuntimeSettings = GameLoopSettings &
|
||||||
|
AgentSettings &
|
||||||
|
BrushSettings &
|
||||||
|
DiffusionSettings &
|
||||||
|
RenderSettings;
|
||||||
|
|
||||||
|
export type AgentColorInteractionSettings = Pick<
|
||||||
|
AgentSettings,
|
||||||
|
| 'color1ToColor1'
|
||||||
|
| 'color1ToColor2'
|
||||||
|
| 'color1ToColor3'
|
||||||
|
| 'color2ToColor1'
|
||||||
|
| 'color2ToColor2'
|
||||||
|
| 'color2ToColor3'
|
||||||
|
| 'color3ToColor1'
|
||||||
|
| 'color3ToColor2'
|
||||||
|
| 'color3ToColor3'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GardenVibeSettings = Partial<
|
||||||
|
Pick<
|
||||||
|
GardenRuntimeSettings,
|
||||||
|
| 'agentBudgetMax'
|
||||||
|
| 'brushSize'
|
||||||
|
| 'color1ToColor1'
|
||||||
|
| 'color1ToColor2'
|
||||||
|
| 'color1ToColor3'
|
||||||
|
| 'color2ToColor1'
|
||||||
|
| 'color2ToColor2'
|
||||||
|
| 'color2ToColor3'
|
||||||
|
| 'color3ToColor1'
|
||||||
|
| 'color3ToColor2'
|
||||||
|
| 'color3ToColor3'
|
||||||
|
| 'clarity'
|
||||||
|
| 'decayRateTrails'
|
||||||
|
| 'diffusionRateTrails'
|
||||||
|
| 'individualTrailWeight'
|
||||||
|
| 'moveSpeed'
|
||||||
|
| 'sensorOffsetAngle'
|
||||||
|
| 'sensorOffsetDistance'
|
||||||
|
| 'spawnPerPixel'
|
||||||
|
| 'turnSpeed'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface VibePreset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
colors: [string, string, string];
|
||||||
|
backgroundColor: string;
|
||||||
|
settings: GardenVibeSettings;
|
||||||
|
audio: GardenAudioVibeProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberControlConfig {
|
||||||
|
folder: string;
|
||||||
|
integer?: boolean;
|
||||||
|
label?: string;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
options?: Record<string, number>;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeSettingControlConfig = {
|
||||||
|
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GardenAppConfig {
|
||||||
|
audio: GardenAudioConfig;
|
||||||
|
audioEngine: {
|
||||||
|
energy: {
|
||||||
|
attackSeconds: number;
|
||||||
|
decaySeconds: number;
|
||||||
|
releaseSeconds: number;
|
||||||
|
strokeDecaySeconds: number;
|
||||||
|
};
|
||||||
|
eraser: {
|
||||||
|
canvasWidthRatioForFullSize: number;
|
||||||
|
defaultSizePixels: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
filterPressureWeight: number;
|
||||||
|
filterSizeWeight: number;
|
||||||
|
filterSpeedWeight: number;
|
||||||
|
gainBase: number;
|
||||||
|
gainPressureWeight: number;
|
||||||
|
gainSizeWeight: number;
|
||||||
|
gainSpeedWeight: number;
|
||||||
|
};
|
||||||
|
delay: {
|
||||||
|
erasingActivity: number;
|
||||||
|
};
|
||||||
|
graph: {
|
||||||
|
closeGain: number;
|
||||||
|
closeRampSeconds: number;
|
||||||
|
delayActivityFeedbackWeight: number;
|
||||||
|
delayFeedbackMax: number;
|
||||||
|
delayFeedbackMin: number;
|
||||||
|
delayOutputActivityWeight: number;
|
||||||
|
delayOutputBase: number;
|
||||||
|
delayTimeRampSeconds: number;
|
||||||
|
eventBusGain: number;
|
||||||
|
noiseMax: number;
|
||||||
|
noiseMin: number;
|
||||||
|
unlockBufferLength: number;
|
||||||
|
unlockSampleRate: number;
|
||||||
|
};
|
||||||
|
input: {
|
||||||
|
distanceEnergyBase: number;
|
||||||
|
distanceEnergyScale: number;
|
||||||
|
distanceForFullEnergyPixels: number;
|
||||||
|
fallbackFrameSeconds: number;
|
||||||
|
penMinPressure: number;
|
||||||
|
strokeEnergyBase: number;
|
||||||
|
strokeEnergyPressureWeight: number;
|
||||||
|
strokeEnergySpeedWeight: number;
|
||||||
|
};
|
||||||
|
muteGain: number;
|
||||||
|
muteRampSeconds: number;
|
||||||
|
noiseBurst: {
|
||||||
|
attackSeconds: number;
|
||||||
|
filterQ: number;
|
||||||
|
offsetRandomSeconds: number;
|
||||||
|
scheduleAheadSeconds: number;
|
||||||
|
silentGain: number;
|
||||||
|
};
|
||||||
|
piano: {
|
||||||
|
filterQ: number;
|
||||||
|
gainAttackSeconds: number;
|
||||||
|
lowpassMaxHz: number;
|
||||||
|
lowpassMinHz: number;
|
||||||
|
minDurationSeconds: number;
|
||||||
|
minFadeSeconds: number;
|
||||||
|
minGain: number;
|
||||||
|
pitchSemitonesPerOctave: number;
|
||||||
|
scheduleAheadSeconds: number;
|
||||||
|
sustainBase: number;
|
||||||
|
sustainVelocityRange: number;
|
||||||
|
tailStopExtraSeconds: number;
|
||||||
|
voiceStealFadeSeconds: number;
|
||||||
|
voiceStealStopSeconds: number;
|
||||||
|
};
|
||||||
|
startDelaySeconds: number;
|
||||||
|
vibeChangeStingerMinIntervalSeconds: number;
|
||||||
|
};
|
||||||
|
deltaTime: {
|
||||||
|
fpsExponentialDecayStrength: number;
|
||||||
|
maxDeltaTimeSeconds: number;
|
||||||
|
minDeltaTimeSeconds: number;
|
||||||
|
};
|
||||||
|
export4k: {
|
||||||
|
bytesPerPixel: number;
|
||||||
|
height: number;
|
||||||
|
jsHeapSafetyMultiplier: number;
|
||||||
|
lowMemoryDeviceGiB: number;
|
||||||
|
lowMemoryExportFraction: number;
|
||||||
|
rowAlignmentBytes: number;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
menuHider: {
|
||||||
|
bottomRevealDistancePx: number;
|
||||||
|
intervalMs: number;
|
||||||
|
timeToLiveMs: number;
|
||||||
|
};
|
||||||
|
pipelines: {
|
||||||
|
brush: {
|
||||||
|
maxLineCount: number;
|
||||||
|
};
|
||||||
|
diffusion: {
|
||||||
|
minDiffusionRate: number;
|
||||||
|
};
|
||||||
|
eraser: {
|
||||||
|
maxSegmentCount: number;
|
||||||
|
maxTextureLineCount: number;
|
||||||
|
segmentFloatCount: number;
|
||||||
|
workgroupSize: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
runtimeSettings: {
|
||||||
|
controls: RuntimeSettingControlConfig;
|
||||||
|
defaults: GardenRuntimeSettings;
|
||||||
|
};
|
||||||
|
simulation: {
|
||||||
|
budget: {
|
||||||
|
adaptiveCapDecreaseAgentsPerSecond: number;
|
||||||
|
adaptiveCapMin: number;
|
||||||
|
fpsHeadroom: number;
|
||||||
|
fpsSmoothingNew: number;
|
||||||
|
fpsSmoothingRetain: number;
|
||||||
|
initialTargetAgentBudget: number;
|
||||||
|
rampAgentsPerSecond: number;
|
||||||
|
refreshTargetDecay: number;
|
||||||
|
};
|
||||||
|
brushEffectFramesPerSecond: number;
|
||||||
|
globalAgentCap: number;
|
||||||
|
initialAgentCount: number;
|
||||||
|
intro: {
|
||||||
|
angleJitterRadians: number;
|
||||||
|
circleMaxSideRatio: number;
|
||||||
|
circleMinSideRatio: number;
|
||||||
|
drawHintClass: string;
|
||||||
|
drawHintDelayMs: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
entryJitterSideRatio: number;
|
||||||
|
fontScaleDown: number;
|
||||||
|
initialFontHeightRatio: number;
|
||||||
|
initialFontWidthRatio: number;
|
||||||
|
letterSpacingEm: number;
|
||||||
|
maskAlphaThreshold: number;
|
||||||
|
maskGradientThreshold: number;
|
||||||
|
maskSampleDensity: number;
|
||||||
|
maxHeightRatio: number;
|
||||||
|
maxWidthRatio: number;
|
||||||
|
minEntryJitterPx: number;
|
||||||
|
minFontSizePx: number;
|
||||||
|
minTargetJitterPx: number;
|
||||||
|
radialJitterRatio: number;
|
||||||
|
targetDelayDistanceMultiplier: number;
|
||||||
|
targetDelayMax: number;
|
||||||
|
targetDelayRandomMultiplier: number;
|
||||||
|
targetJitterSideRatio: number;
|
||||||
|
title: string;
|
||||||
|
titleColorCutLetters: [number, number];
|
||||||
|
titleRadiusMultiplier: number;
|
||||||
|
titleStrokeWidthMinPx: number;
|
||||||
|
titleStrokeWidthRatio: number;
|
||||||
|
verticalAnchor: number;
|
||||||
|
};
|
||||||
|
introMoveSpeedBaseMultiplier: number;
|
||||||
|
introMoveSpeedProgressMultiplier: number;
|
||||||
|
maxMirrorSegmentCount: number;
|
||||||
|
stroke: {
|
||||||
|
angleJitterRadians: number;
|
||||||
|
densityMultiplier: number;
|
||||||
|
maxAgentCount: number;
|
||||||
|
minAgentCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
storage: {
|
||||||
|
audioMutedKey: string;
|
||||||
|
vibeKey: string;
|
||||||
|
};
|
||||||
|
telemetry: {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalMs: number;
|
||||||
|
};
|
||||||
|
toolbar: {
|
||||||
|
eraser: {
|
||||||
|
controlScaleMax: number;
|
||||||
|
controlScaleMin: number;
|
||||||
|
default: number;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
mirror: {
|
||||||
|
default: number;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
names: Record<number, string>;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tuningPane: {
|
||||||
|
expandedDepth: number;
|
||||||
|
startHidden: boolean;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
vibes: {
|
||||||
|
defaultVibeId: string;
|
||||||
|
presets: Array<VibePreset>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
|
||||||
|
export type GardenSimulationConfig = GardenAppConfig['simulation'];
|
||||||
|
export type GardenStorageConfig = GardenAppConfig['storage'];
|
||||||
204
src/config/vibe-presets.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import type {
|
||||||
|
GardenAudioChord,
|
||||||
|
GardenAudioVibeProfile,
|
||||||
|
} from '../audio/garden-audio-config';
|
||||||
|
import { createColorInteractionSettings } from './color-interactions';
|
||||||
|
import type { VibePreset } from './types';
|
||||||
|
|
||||||
|
const majorProgression: Array<GardenAudioChord> = [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
{ rootOffset: 5, quality: 'major' },
|
||||||
|
{ rootOffset: 7, quality: 'major' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const minorProgression: Array<GardenAudioChord> = [
|
||||||
|
{ rootOffset: 0, quality: 'minor' },
|
||||||
|
{ rootOffset: 8, quality: 'major' },
|
||||||
|
{ rootOffset: 3, quality: 'major' },
|
||||||
|
{ rootOffset: 10, quality: 'major' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const majorPentatonic = [0, 2, 4, 7, 9];
|
||||||
|
const minorPentatonic = [0, 3, 5, 7, 10];
|
||||||
|
|
||||||
|
export const defaultVibeId = 'candy-rain';
|
||||||
|
|
||||||
|
export const vibePresets: Array<VibePreset> = [
|
||||||
|
{
|
||||||
|
id: 'candy-rain',
|
||||||
|
name: 'Candy Rain',
|
||||||
|
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
|
||||||
|
backgroundColor: '#10151f',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
brushSize: 14,
|
||||||
|
clarity: 0.62,
|
||||||
|
decayRateTrails: 965,
|
||||||
|
diffusionRateTrails: 0.22,
|
||||||
|
individualTrailWeight: 0.07,
|
||||||
|
moveSpeed: 82,
|
||||||
|
sensorOffsetAngle: 34,
|
||||||
|
sensorOffsetDistance: 38,
|
||||||
|
spawnPerPixel: 0.22,
|
||||||
|
turnSpeed: 58,
|
||||||
|
...createColorInteractionSettings('candy-rain'),
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 57,
|
||||||
|
scale: majorPentatonic,
|
||||||
|
brightness: 1.04,
|
||||||
|
delayTimeMultiplier: 0.92,
|
||||||
|
progression: majorProgression,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sunlit-moss',
|
||||||
|
name: 'Sunlit Moss',
|
||||||
|
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
||||||
|
backgroundColor: '#172016',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
brushSize: 16,
|
||||||
|
clarity: 0.68,
|
||||||
|
decayRateTrails: 975,
|
||||||
|
diffusionRateTrails: 0.18,
|
||||||
|
individualTrailWeight: 0.06,
|
||||||
|
moveSpeed: 70,
|
||||||
|
sensorOffsetAngle: 28,
|
||||||
|
sensorOffsetDistance: 46,
|
||||||
|
spawnPerPixel: 0.18,
|
||||||
|
turnSpeed: 44,
|
||||||
|
...createColorInteractionSettings('sunlit-moss'),
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 53,
|
||||||
|
scale: majorPentatonic,
|
||||||
|
brightness: 0.92,
|
||||||
|
delayTimeMultiplier: 1.08,
|
||||||
|
progression: [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 7, quality: 'major' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
{ rootOffset: 5, quality: 'major' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coral-tide',
|
||||||
|
name: 'Coral Tide',
|
||||||
|
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
|
||||||
|
backgroundColor: '#0f1822',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
brushSize: 13,
|
||||||
|
clarity: 0.58,
|
||||||
|
decayRateTrails: 955,
|
||||||
|
diffusionRateTrails: 0.28,
|
||||||
|
individualTrailWeight: 0.055,
|
||||||
|
moveSpeed: 90,
|
||||||
|
sensorOffsetAngle: 36,
|
||||||
|
sensorOffsetDistance: 35,
|
||||||
|
spawnPerPixel: 0.25,
|
||||||
|
turnSpeed: 62,
|
||||||
|
...createColorInteractionSettings('coral-tide'),
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 50,
|
||||||
|
scale: minorPentatonic,
|
||||||
|
brightness: 1,
|
||||||
|
delayTimeMultiplier: 1.12,
|
||||||
|
progression: minorProgression,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moon-orchid',
|
||||||
|
name: 'Moon Orchid',
|
||||||
|
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
|
||||||
|
backgroundColor: '#14121d',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
brushSize: 12,
|
||||||
|
clarity: 0.64,
|
||||||
|
decayRateTrails: 968,
|
||||||
|
diffusionRateTrails: 0.2,
|
||||||
|
individualTrailWeight: 0.065,
|
||||||
|
moveSpeed: 76,
|
||||||
|
sensorOffsetAngle: 32,
|
||||||
|
sensorOffsetDistance: 42,
|
||||||
|
spawnPerPixel: 0.2,
|
||||||
|
turnSpeed: 52,
|
||||||
|
...createColorInteractionSettings('moon-orchid'),
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 49,
|
||||||
|
scale: minorPentatonic,
|
||||||
|
brightness: 0.9,
|
||||||
|
delayTimeMultiplier: 1.24,
|
||||||
|
progression: minorProgression,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'peach-neon',
|
||||||
|
name: 'Peach Neon',
|
||||||
|
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
|
||||||
|
backgroundColor: '#191716',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
brushSize: 15,
|
||||||
|
clarity: 0.55,
|
||||||
|
decayRateTrails: 948,
|
||||||
|
diffusionRateTrails: 0.32,
|
||||||
|
individualTrailWeight: 0.05,
|
||||||
|
moveSpeed: 96,
|
||||||
|
sensorOffsetAngle: 40,
|
||||||
|
sensorOffsetDistance: 32,
|
||||||
|
spawnPerPixel: 0.24,
|
||||||
|
turnSpeed: 70,
|
||||||
|
...createColorInteractionSettings('peach-neon'),
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 56,
|
||||||
|
scale: majorPentatonic,
|
||||||
|
brightness: 1.08,
|
||||||
|
delayTimeMultiplier: 0.86,
|
||||||
|
progression: majorProgression,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'frost-bloom',
|
||||||
|
name: 'Frost Bloom',
|
||||||
|
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
|
||||||
|
backgroundColor: '#101820',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
brushSize: 18,
|
||||||
|
clarity: 0.7,
|
||||||
|
decayRateTrails: 982,
|
||||||
|
diffusionRateTrails: 0.14,
|
||||||
|
individualTrailWeight: 0.075,
|
||||||
|
moveSpeed: 62,
|
||||||
|
sensorOffsetAngle: 26,
|
||||||
|
sensorOffsetDistance: 52,
|
||||||
|
spawnPerPixel: 0.16,
|
||||||
|
turnSpeed: 40,
|
||||||
|
...createColorInteractionSettings('frost-bloom'),
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 62,
|
||||||
|
scale: majorPentatonic,
|
||||||
|
brightness: 0.88,
|
||||||
|
delayTimeMultiplier: 1.32,
|
||||||
|
progression: [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 5, quality: 'major' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
{ rootOffset: 7, quality: 'major' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const audioVibes = Object.fromEntries(
|
||||||
|
vibePresets.map((vibe) => [vibe.id, vibe.audio])
|
||||||
|
) as Record<string, GardenAudioVibeProfile>;
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export const isProduction: boolean = import.meta.env.PROD;
|
|
||||||
86
src/game-loop/agent-population.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: vi.fn(() => null),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { settings } from '../settings';
|
||||||
|
import { AgentPopulation } from './agent-population';
|
||||||
|
|
||||||
|
const originalAgentBudgetMax = settings.agentBudgetMax;
|
||||||
|
const originalBrushSize = settings.brushSize;
|
||||||
|
const originalSelectedColorIndex = settings.selectedColorIndex;
|
||||||
|
const originalSpawnPerPixel = settings.spawnPerPixel;
|
||||||
|
|
||||||
|
const createPopulation = () => {
|
||||||
|
const pipeline = {
|
||||||
|
maxAgentCount: 10_000_000,
|
||||||
|
writeAgents: vi.fn(),
|
||||||
|
resizeAgents: vi.fn(),
|
||||||
|
compactAgents: vi.fn(),
|
||||||
|
} as unknown as AgentGenerationPipeline;
|
||||||
|
|
||||||
|
return new AgentPopulation(pipeline);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPopulationCounts = (
|
||||||
|
population: AgentPopulation,
|
||||||
|
activeCount: number,
|
||||||
|
targetBudget: number
|
||||||
|
) => {
|
||||||
|
Object.assign(population as unknown as Record<string, number>, {
|
||||||
|
activeCount,
|
||||||
|
targetBudget,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AgentPopulation adaptive budget', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
settings.agentBudgetMax = 1_000_000;
|
||||||
|
settings.brushSize = 1;
|
||||||
|
settings.selectedColorIndex = 0;
|
||||||
|
settings.spawnPerPixel = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
settings.agentBudgetMax = originalAgentBudgetMax;
|
||||||
|
settings.brushSize = originalBrushSize;
|
||||||
|
settings.selectedColorIndex = originalSelectedColorIndex;
|
||||||
|
settings.spawnPerPixel = originalSpawnPerPixel;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
||||||
|
const population = createPopulation();
|
||||||
|
setPopulationCounts(population, 1_000_000, 1_000_000);
|
||||||
|
|
||||||
|
population.growBudget(1 / 60, 60, 60);
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
||||||
|
|
||||||
|
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
|
||||||
|
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
|
||||||
|
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
|
||||||
|
appConfig.simulation.globalAgentCap
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
||||||
|
const population = createPopulation();
|
||||||
|
setPopulationCounts(population, 1_000_000, 1_000_000);
|
||||||
|
|
||||||
|
population.growBudget(10, 50, 60);
|
||||||
|
|
||||||
|
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
||||||
|
expect(population.activeAgentCount).toBe(
|
||||||
|
appConfig.simulation.budget.adaptiveCapMin
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,11 +12,15 @@ const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
|
||||||
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
|
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
|
||||||
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
|
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
|
||||||
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
|
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 {
|
export class AgentPopulation {
|
||||||
private activeCount = 0;
|
private activeCount = 0;
|
||||||
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
|
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
|
||||||
private replacementCursor = 0;
|
private replacementCursor = 0;
|
||||||
|
private canExpandAdaptiveCap = true;
|
||||||
private shouldCompactAfterErase = false;
|
private shouldCompactAfterErase = false;
|
||||||
private isCompacting = false;
|
private isCompacting = false;
|
||||||
private readonly strokeAgentData = new Float32Array(
|
private readonly strokeAgentData = new Float32Array(
|
||||||
|
|
@ -38,6 +42,7 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
public initializeIntroAgents(canvasSize: vec2): void {
|
public initializeIntroAgents(canvasSize: vec2): void {
|
||||||
|
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||||
this.targetBudget = Math.min(
|
this.targetBudget = Math.min(
|
||||||
this.pipeline.maxAgentCount,
|
this.pipeline.maxAgentCount,
|
||||||
settings.agentBudgetMax,
|
settings.agentBudgetMax,
|
||||||
|
|
@ -53,6 +58,7 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
public onVibeChanged(): void {
|
public onVibeChanged(): void {
|
||||||
|
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||||
this.targetBudget = Math.min(
|
this.targetBudget = Math.min(
|
||||||
this.targetBudget,
|
this.targetBudget,
|
||||||
settings.agentBudgetMax,
|
settings.agentBudgetMax,
|
||||||
|
|
@ -65,7 +71,9 @@ export class AgentPopulation {
|
||||||
smoothedFps: number,
|
smoothedFps: number,
|
||||||
refreshTargetFps: number
|
refreshTargetFps: number
|
||||||
): void {
|
): void {
|
||||||
const cap = Math.min(settings.agentBudgetMax, this.pipeline.maxAgentCount);
|
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
|
||||||
|
|
||||||
|
const cap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||||
if (
|
if (
|
||||||
this.targetBudget < cap &&
|
this.targetBudget < cap &&
|
||||||
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
|
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
|
||||||
|
|
@ -147,6 +155,8 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = data.length / AGENT_FLOAT_COUNT;
|
const count = data.length / AGENT_FLOAT_COUNT;
|
||||||
|
this.expandAdaptiveCapForPendingAgents(count);
|
||||||
|
|
||||||
const available = Math.max(0, this.targetBudget - this.activeCount);
|
const available = Math.max(0, this.targetBudget - this.activeCount);
|
||||||
const appendCount = Math.min(count, available);
|
const appendCount = Math.min(count, available);
|
||||||
|
|
||||||
|
|
@ -178,4 +188,60 @@ export class AgentPopulation {
|
||||||
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { RuntimeError } from '../utils/error-handler';
|
import { RuntimeError } from '../utils/error-handler';
|
||||||
|
|
||||||
export const EXPORT_4K_WIDTH = appConfig.export4k.width;
|
const EXPORT_4K_WIDTH = appConfig.export4k.width;
|
||||||
export const EXPORT_4K_HEIGHT = appConfig.export4k.height;
|
const EXPORT_4K_HEIGHT = appConfig.export4k.height;
|
||||||
|
|
||||||
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
|
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
|
||||||
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
|
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
|
||||||
|
|
@ -11,7 +11,7 @@ const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
|
||||||
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
|
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
|
||||||
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
|
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
|
||||||
|
|
||||||
export interface Export4KMemoryEstimate {
|
interface Export4KMemoryEstimate {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
bytesPerPixel: number;
|
bytesPerPixel: number;
|
||||||
|
|
@ -26,18 +26,18 @@ export interface Export4KMemoryEstimate {
|
||||||
estimatedPeakBytes: number;
|
estimatedPeakBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Export4KDimensions {
|
interface Export4KDimensions {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserMemoryInfo {
|
interface BrowserMemoryInfo {
|
||||||
deviceMemoryBytes?: number;
|
deviceMemoryBytes?: number;
|
||||||
jsHeapSizeLimitBytes?: number;
|
jsHeapSizeLimitBytes?: number;
|
||||||
usedJsHeapSizeBytes?: number;
|
usedJsHeapSizeBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Export4KPreflightOptions {
|
interface Export4KPreflightOptions {
|
||||||
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
|
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
|
||||||
memoryInfo?: BrowserMemoryInfo;
|
memoryInfo?: BrowserMemoryInfo;
|
||||||
estimate?: Export4KMemoryEstimate;
|
estimate?: Export4KMemoryEstimate;
|
||||||
|
|
|
||||||
28
src/game-loop/game-loop-intro.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const gameLoopSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src/game-loop/game-loop.ts'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStartDrawingHandlerSource = () => {
|
||||||
|
const start = gameLoopSource.indexOf('onStartDrawing:');
|
||||||
|
const end = gameLoopSource.indexOf('onEraseGestureEnded:', start);
|
||||||
|
|
||||||
|
if (start < 0 || end < 0) {
|
||||||
|
throw new Error('Could not find the pointer drawing intro handler');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameLoopSource.slice(start, end);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GameLoop intro drawing policy', () => {
|
||||||
|
it('allows drawing to start without completing the intro sequence', () => {
|
||||||
|
const handlerSource = getStartDrawingHandlerSource();
|
||||||
|
|
||||||
|
expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()');
|
||||||
|
expect(handlerSource).not.toContain('this.introPrompt.complete(');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -19,6 +19,7 @@ import { RenderInputCache } from './render-input-cache';
|
||||||
export default class GameLoop {
|
export default class GameLoop {
|
||||||
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
||||||
appConfig.simulation.maxMirrorSegmentCount;
|
appConfig.simulation.maxMirrorSegmentCount;
|
||||||
|
private static readonly DEV_STATS_INTERVAL_MS = 250;
|
||||||
|
|
||||||
private readonly resources: GameLoopResources;
|
private readonly resources: GameLoopResources;
|
||||||
private readonly audio = new GardenAudio(gardenAudioConfig);
|
private readonly audio = new GardenAudio(gardenAudioConfig);
|
||||||
|
|
@ -29,10 +30,12 @@ export default class GameLoop {
|
||||||
private readonly agentPopulation: AgentPopulation;
|
private readonly agentPopulation: AgentPopulation;
|
||||||
private readonly export4KRenderer: Export4KRenderer;
|
private readonly export4KRenderer: Export4KRenderer;
|
||||||
private readonly framePerformance = new FramePerformance();
|
private readonly framePerformance = new FramePerformance();
|
||||||
|
private readonly devStatsElement: HTMLDivElement | null = null;
|
||||||
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
||||||
private readonly resizeListener = this.resize.bind(this);
|
private readonly resizeListener = this.resize.bind(this);
|
||||||
private readonly keydownListener: (event: KeyboardEvent) => void;
|
private readonly keydownListener: (event: KeyboardEvent) => void;
|
||||||
|
|
||||||
|
private lastDevStatsUpdateAt = 0;
|
||||||
private hasFinished = false;
|
private hasFinished = false;
|
||||||
private readonly finished = Promise.withResolvers<void>();
|
private readonly finished = Promise.withResolvers<void>();
|
||||||
|
|
||||||
|
|
@ -43,6 +46,9 @@ export default class GameLoop {
|
||||||
ui: GardenUi
|
ui: GardenUi
|
||||||
) {
|
) {
|
||||||
this.resize();
|
this.resize();
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
this.devStatsElement = this.createDevStatsElement();
|
||||||
|
}
|
||||||
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||||
|
|
@ -58,10 +64,7 @@ export default class GameLoop {
|
||||||
getCanvasSize: () => this.canvasSize,
|
getCanvasSize: () => this.canvasSize,
|
||||||
getDevicePixelRatio: () => this.devicePixelRatio,
|
getDevicePixelRatio: () => this.devicePixelRatio,
|
||||||
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||||
onStartDrawing: () => {
|
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
||||||
this.introPrompt.markStartedDrawing();
|
|
||||||
this.introPrompt.complete();
|
|
||||||
},
|
|
||||||
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||||
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
||||||
});
|
});
|
||||||
|
|
@ -133,6 +136,7 @@ export default class GameLoop {
|
||||||
window.removeEventListener('resize', this.resizeListener);
|
window.removeEventListener('resize', this.resizeListener);
|
||||||
window.removeEventListener('keydown', this.keydownListener);
|
window.removeEventListener('keydown', this.keydownListener);
|
||||||
this.pointerInput.detach();
|
this.pointerInput.detach();
|
||||||
|
this.devStatsElement?.remove();
|
||||||
this.introPrompt.destroy();
|
this.introPrompt.destroy();
|
||||||
this.resources.destroy();
|
this.resources.destroy();
|
||||||
await this.audio.destroy();
|
await this.audio.destroy();
|
||||||
|
|
@ -159,7 +163,7 @@ export default class GameLoop {
|
||||||
const scaledTime = time * settings.renderSpeed;
|
const scaledTime = time * settings.renderSpeed;
|
||||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||||
const introProgress = this.introPrompt.progress;
|
const introProgress = this.introPrompt.progress;
|
||||||
const cameraZoom = 1 + (1 - introProgress) * appConfig.simulation.introCameraZoom;
|
const cameraZoom = 1;
|
||||||
const cameraCenter: [number, number] = [
|
const cameraCenter: [number, number] = [
|
||||||
this.canvas.width / 2,
|
this.canvas.width / 2,
|
||||||
this.canvas.height / 2,
|
this.canvas.height / 2,
|
||||||
|
|
@ -172,6 +176,7 @@ export default class GameLoop {
|
||||||
vibe: activeVibe,
|
vibe: activeVibe,
|
||||||
selectedColorIndex: settings.selectedColorIndex,
|
selectedColorIndex: settings.selectedColorIndex,
|
||||||
isErasing,
|
isErasing,
|
||||||
|
mirrorSegmentCount: this.mirrorSegmentCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.resources.setFrameParameters({
|
this.resources.setFrameParameters({
|
||||||
|
|
@ -205,6 +210,7 @@ export default class GameLoop {
|
||||||
devicePixelRatio: this.devicePixelRatio,
|
devicePixelRatio: this.devicePixelRatio,
|
||||||
renderSpeed: settings.renderSpeed,
|
renderSpeed: settings.renderSpeed,
|
||||||
});
|
});
|
||||||
|
this.updateDevStats(time);
|
||||||
|
|
||||||
if (settings.simulatedDelayMs > 0) {
|
if (settings.simulatedDelayMs > 0) {
|
||||||
await sleep(settings.simulatedDelayMs);
|
await sleep(settings.simulatedDelayMs);
|
||||||
|
|
@ -213,6 +219,42 @@ export default class GameLoop {
|
||||||
requestAnimationFrame(this.render);
|
requestAnimationFrame(this.render);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDevStats(time: DOMHighResTimeStamp): void {
|
||||||
|
if (
|
||||||
|
!this.devStatsElement ||
|
||||||
|
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastDevStatsUpdateAt = time;
|
||||||
|
this.devStatsElement.textContent = [
|
||||||
|
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round(
|
||||||
|
this.framePerformance.refreshTargetFps
|
||||||
|
)}`,
|
||||||
|
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
|
||||||
|
`Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`,
|
||||||
|
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDevStatNumber(value: number): string {
|
||||||
|
return Math.max(0, Math.round(value)).toLocaleString('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
private resize(): void {
|
private resize(): void {
|
||||||
const width = Math.max(
|
const width = Math.max(
|
||||||
1,
|
1,
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { vec3 } from 'gl-matrix';
|
|
||||||
|
|
||||||
import { settings } from '../settings';
|
|
||||||
import { hsl } from '../utils/hsl';
|
|
||||||
import { Random } from '../utils/random';
|
|
||||||
|
|
||||||
const hues = [settings.startColorHue];
|
|
||||||
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360);
|
|
||||||
}
|
|
||||||
|
|
||||||
const colors = hues.map((hue) =>
|
|
||||||
hsl(hue, Random.randomBetween(90, 100), Random.randomBetween(20, 30))
|
|
||||||
);
|
|
||||||
|
|
||||||
export class GamePresentation {
|
|
||||||
public static getGenerationColor(generation: number): vec3 {
|
|
||||||
return colors[generation % colors.length];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
|
|
||||||
import { GenerationCounts } from '../pipelines/agents/agent-generation/generation-counts';
|
|
||||||
import { settings } from '../settings';
|
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
|
||||||
import { mix } from '../utils/mix';
|
|
||||||
import { Random } from '../utils/random';
|
|
||||||
|
|
||||||
export interface SpawnAction {
|
|
||||||
generation: number;
|
|
||||||
position: vec2;
|
|
||||||
radius: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GameRules {
|
|
||||||
private static readonly DEFAULT_SPAWN_INTERVAL = 8;
|
|
||||||
private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
|
|
||||||
private static readonly DEFAULT_SPAWN_RADIUS = 20;
|
|
||||||
|
|
||||||
private lastSpawnTimeInSeconds = 0;
|
|
||||||
private currentSpawnInterval = 0;
|
|
||||||
private currentSpawnRadius = 0;
|
|
||||||
private lastGenerationChangeTimeInSeconds = 0;
|
|
||||||
|
|
||||||
public nextGenerationId = 1;
|
|
||||||
public generationCounts: {
|
|
||||||
currentGenerationCount: number;
|
|
||||||
nextGenerationCount: number;
|
|
||||||
} = {
|
|
||||||
currentGenerationCount: 0,
|
|
||||||
nextGenerationCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
public constructor(startingTimeInSeconds: number) {
|
|
||||||
this.lastSpawnTimeInSeconds = startingTimeInSeconds;
|
|
||||||
this.lastGenerationChangeTimeInSeconds = startingTimeInSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
private lastSpawnAction: SpawnAction | undefined;
|
|
||||||
|
|
||||||
public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
|
|
||||||
if (
|
|
||||||
this.lastSpawnAction &&
|
|
||||||
timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
|
|
||||||
) {
|
|
||||||
return this.lastSpawnAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSpawnInterval = mix(
|
|
||||||
GameRules.DEFAULT_SPAWN_INTERVAL,
|
|
||||||
GameRules.DEFAULT_SPAWN_INTERVAL / 5,
|
|
||||||
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.currentSpawnRadius = mix(
|
|
||||||
GameRules.DEFAULT_SPAWN_RADIUS,
|
|
||||||
GameRules.DEFAULT_SPAWN_RADIUS * 3,
|
|
||||||
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
|
|
||||||
);
|
|
||||||
|
|
||||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
|
||||||
|
|
||||||
if (
|
|
||||||
timeInSeconds - this.lastSpawnTimeInSeconds < this.currentSpawnInterval ||
|
|
||||||
q > 0.05
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
generation: this.nextGenerationId,
|
|
||||||
position: vec2.create(),
|
|
||||||
radius: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastSpawnTimeInSeconds = timeInSeconds;
|
|
||||||
|
|
||||||
this.lastSpawnAction = {
|
|
||||||
generation: this.nextGenerationId,
|
|
||||||
position: vec2.fromValues(
|
|
||||||
Random.randomBetween(0, canvasSize[0]),
|
|
||||||
Random.randomBetween(0, canvasSize[1])
|
|
||||||
),
|
|
||||||
radius: this.currentSpawnRadius,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.lastSpawnAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
public updateGenerationCounts({
|
|
||||||
evenGenerationCount,
|
|
||||||
oddGenerationCount,
|
|
||||||
}: GenerationCounts): void {
|
|
||||||
const nextGenerationCount =
|
|
||||||
this.nextGenerationId % 2 === 1 ? oddGenerationCount : evenGenerationCount;
|
|
||||||
const currentGenerationCount =
|
|
||||||
this.nextGenerationId % 2 === 1 ? evenGenerationCount : oddGenerationCount;
|
|
||||||
|
|
||||||
const q = currentGenerationCount / settings.agentCount;
|
|
||||||
|
|
||||||
if (currentGenerationCount <= 100 && q < 0.05) {
|
|
||||||
this.nextGenerationId++;
|
|
||||||
this.lastGenerationChangeTimeInSeconds = performance.now() / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.generationCounts = {
|
|
||||||
currentGenerationCount,
|
|
||||||
nextGenerationCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getNextGenerationMoveSpeed(): number {
|
|
||||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
|
||||||
return mix(settings.moveSpeed / 8, settings.moveSpeed, q ** 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getInfectionProbability(): number {
|
|
||||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
|
||||||
return clamp(mix(0.3, 1, q * 5), 0, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getSensorOffset(): number {
|
|
||||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
|
||||||
return mix(20, settings.sensorOffsetDistance, q);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
277
src/game-loop/pointer-input.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
type PointerListener = (event: PointerEvent) => void;
|
||||||
|
|
||||||
|
const makePointerEvent = (
|
||||||
|
type: string,
|
||||||
|
event: Partial<PointerEvent> = {}
|
||||||
|
): PointerEvent =>
|
||||||
|
({
|
||||||
|
buttons: 1,
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 20,
|
||||||
|
isTrusted: true,
|
||||||
|
pointerId: 1,
|
||||||
|
pointerType: 'mouse',
|
||||||
|
pressure: 0.5,
|
||||||
|
timeStamp: 100,
|
||||||
|
type,
|
||||||
|
...event,
|
||||||
|
}) as PointerEvent;
|
||||||
|
|
||||||
|
const toPoint = (point: ArrayLike<number>): Array<number> => Array.from(point);
|
||||||
|
|
||||||
|
class FakeCanvas {
|
||||||
|
public readonly capturedPointerIds: Array<number> = [];
|
||||||
|
public readonly releasedPointerIds: Array<number> = [];
|
||||||
|
public width = 300;
|
||||||
|
public height = 200;
|
||||||
|
|
||||||
|
private readonly listeners = new Map<string, Set<PointerListener>>();
|
||||||
|
|
||||||
|
public addEventListener(
|
||||||
|
type: string,
|
||||||
|
listener: EventListenerOrEventListenerObject
|
||||||
|
): void {
|
||||||
|
const listeners = this.listeners.get(type) ?? new Set<PointerListener>();
|
||||||
|
const pointerListener =
|
||||||
|
typeof listener === 'function'
|
||||||
|
? listener
|
||||||
|
: (event: Event) => listener.handleEvent(event);
|
||||||
|
listeners.add(pointerListener as PointerListener);
|
||||||
|
this.listeners.set(type, listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeEventListener(
|
||||||
|
type: string,
|
||||||
|
listener: EventListenerOrEventListenerObject
|
||||||
|
): void {
|
||||||
|
const listeners = this.listeners.get(type);
|
||||||
|
if (!listeners) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners.delete(listener as PointerListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispatchPointerEvent(type: string, event: Partial<PointerEvent> = {}): void {
|
||||||
|
const pointerEvent = makePointerEvent(type, event);
|
||||||
|
|
||||||
|
this.listeners.get(type)?.forEach((listener) => listener(pointerEvent));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBoundingClientRect(): DOMRect {
|
||||||
|
return {
|
||||||
|
bottom: this.height,
|
||||||
|
height: this.height,
|
||||||
|
left: 0,
|
||||||
|
right: this.width,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
top: 0,
|
||||||
|
width: this.width,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPointerCapture(pointerId: number): void {
|
||||||
|
this.capturedPointerIds.push(pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public releasePointerCapture(pointerId: number): void {
|
||||||
|
this.releasedPointerIds.push(pointerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeSwipePipeline = () => ({
|
||||||
|
addSwipeSegment: vi.fn(),
|
||||||
|
clearSwipes: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPointerInput = async () => {
|
||||||
|
const { GardenPointerInput } = await import('./pointer-input');
|
||||||
|
const { settings: runtimeSettings } = await import('../settings');
|
||||||
|
const canvas = new FakeCanvas();
|
||||||
|
const audio = {
|
||||||
|
beginGesture: vi.fn(),
|
||||||
|
endGesture: vi.fn(),
|
||||||
|
start: vi.fn(),
|
||||||
|
stroke: vi.fn(),
|
||||||
|
touchDown: vi.fn(),
|
||||||
|
};
|
||||||
|
const brushPipeline = makeSwipePipeline();
|
||||||
|
const eraserAgentPipeline = makeSwipePipeline();
|
||||||
|
const eraserTexturePipeline = makeSwipePipeline();
|
||||||
|
const eraserPreview = {
|
||||||
|
isPointerInsideCanvas: vi.fn(() => true),
|
||||||
|
setEraseMode: vi.fn(),
|
||||||
|
setPointerHoveringCanvas: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
};
|
||||||
|
const onStartDrawing = vi.fn();
|
||||||
|
const onEraseGestureEnded = vi.fn();
|
||||||
|
const spawnStrokeAgents = vi.fn();
|
||||||
|
const input = new GardenPointerInput({
|
||||||
|
audio,
|
||||||
|
brushPipeline,
|
||||||
|
canvas: canvas as unknown as HTMLCanvasElement,
|
||||||
|
eraserAgentPipeline,
|
||||||
|
eraserPreview,
|
||||||
|
eraserTexturePipeline,
|
||||||
|
getCanvasSize: () => [canvas.width, canvas.height],
|
||||||
|
getDevicePixelRatio: () => 1,
|
||||||
|
getMirrorSegmentCount: () => 1,
|
||||||
|
onEraseGestureEnded,
|
||||||
|
onStartDrawing,
|
||||||
|
spawnStrokeAgents,
|
||||||
|
} as unknown as ConstructorParameters<typeof GardenPointerInput>[0]);
|
||||||
|
|
||||||
|
input.attach();
|
||||||
|
|
||||||
|
return {
|
||||||
|
audio,
|
||||||
|
brushPipeline,
|
||||||
|
canvas,
|
||||||
|
input,
|
||||||
|
onStartDrawing,
|
||||||
|
runtimeSettings,
|
||||||
|
spawnStrokeAgents,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GardenPointerInput drawing startup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
clear: vi.fn(),
|
||||||
|
getItem: vi.fn(() => null),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows pointer drawing immediately', async () => {
|
||||||
|
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
|
||||||
|
await createPointerInput();
|
||||||
|
|
||||||
|
canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 });
|
||||||
|
canvas.dispatchPointerEvent('pointermove', {
|
||||||
|
clientX: 60,
|
||||||
|
clientY: 80,
|
||||||
|
pointerId: 7,
|
||||||
|
timeStamp: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||||
|
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||||
|
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||||
|
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2);
|
||||||
|
expect(spawnStrokeAgents).toHaveBeenCalledTimes(2);
|
||||||
|
expect(canvas.capturedPointerIds).toEqual([7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts drawing from a fresh pointerdown', async () => {
|
||||||
|
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
|
||||||
|
await createPointerInput();
|
||||||
|
|
||||||
|
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||||
|
|
||||||
|
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||||
|
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||||
|
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||||
|
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
|
||||||
|
expect(canvas.capturedPointerIds).toEqual([9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes the delayed smoothed stroke tail on pointerup', async () => {
|
||||||
|
const { brushPipeline, canvas } = await createPointerInput();
|
||||||
|
|
||||||
|
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||||
|
canvas.dispatchPointerEvent('pointermove', {
|
||||||
|
clientX: 60,
|
||||||
|
clientY: 80,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 120,
|
||||||
|
});
|
||||||
|
canvas.dispatchPointerEvent('pointerup', {
|
||||||
|
clientX: 60,
|
||||||
|
clientY: 80,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 140,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3);
|
||||||
|
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]);
|
||||||
|
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]);
|
||||||
|
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]);
|
||||||
|
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses coalesced pointer samples for smoother brush segments', async () => {
|
||||||
|
const { audio, brushPipeline, canvas, spawnStrokeAgents } =
|
||||||
|
await createPointerInput();
|
||||||
|
|
||||||
|
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||||
|
audio.stroke.mockClear();
|
||||||
|
brushPipeline.addSwipeSegment.mockClear();
|
||||||
|
spawnStrokeAgents.mockClear();
|
||||||
|
|
||||||
|
canvas.dispatchPointerEvent('pointermove', {
|
||||||
|
clientX: 40,
|
||||||
|
clientY: 20,
|
||||||
|
getCoalescedEvents: () => [
|
||||||
|
makePointerEvent('pointermove', {
|
||||||
|
clientX: 20,
|
||||||
|
clientY: 20,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 110,
|
||||||
|
}),
|
||||||
|
makePointerEvent('pointermove', {
|
||||||
|
clientX: 30,
|
||||||
|
clientY: 20,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 115,
|
||||||
|
}),
|
||||||
|
makePointerEvent('pointermove', {
|
||||||
|
clientX: 40,
|
||||||
|
clientY: 20,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 120,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(audio.stroke).toHaveBeenCalledTimes(3);
|
||||||
|
expect(spawnStrokeAgents).toHaveBeenCalledTimes(3);
|
||||||
|
expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
||||||
|
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
|
||||||
|
runtimeSettings.brushCurveResolution = 2;
|
||||||
|
runtimeSettings.brushSize = 1;
|
||||||
|
|
||||||
|
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||||
|
canvas.dispatchPointerEvent('pointermove', {
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 60,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 120,
|
||||||
|
});
|
||||||
|
canvas.dispatchPointerEvent('pointermove', {
|
||||||
|
clientX: 60,
|
||||||
|
clientY: 60,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 140,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -26,10 +26,16 @@ interface GardenPointerInputOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GardenPointerInput {
|
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 activePointerId: number | null = null;
|
||||||
private lastPointerPosition: vec2 | null = null;
|
private lastPointerPosition: vec2 | null = null;
|
||||||
private lastPointerEventTimeMs: number | null = null;
|
private lastPointerEventTimeMs: number | null = null;
|
||||||
private lastPointerPressure = 0.5;
|
private lastPointerPressure = 0.5;
|
||||||
|
private smoothedStrokePoints: Array<vec2> = [];
|
||||||
|
private lastSmoothedBrushPosition: vec2 | null = null;
|
||||||
private isErasing = false;
|
private isErasing = false;
|
||||||
|
|
||||||
public constructor(private readonly options: GardenPointerInputOptions) {}
|
public constructor(private readonly options: GardenPointerInputOptions) {}
|
||||||
|
|
@ -75,6 +81,14 @@ export class GardenPointerInput {
|
||||||
if (this.lastPointerPosition !== null) {
|
if (this.lastPointerPosition !== null) {
|
||||||
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
|
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 {
|
public get isSwipeActive(): boolean {
|
||||||
|
|
@ -98,6 +112,13 @@ export class GardenPointerInput {
|
||||||
|
|
||||||
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
|
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||||
this.options.audio.beginGesture();
|
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.options.onStartDrawing();
|
||||||
this.activePointerId = event.pointerId;
|
this.activePointerId = event.pointerId;
|
||||||
this.canvas.setPointerCapture(event.pointerId);
|
this.canvas.setPointerCapture(event.pointerId);
|
||||||
|
|
@ -106,8 +127,9 @@ export class GardenPointerInput {
|
||||||
this.options.eraserTexturePipeline.clearSwipes();
|
this.options.eraserTexturePipeline.clearSwipes();
|
||||||
this.lastPointerPosition = null;
|
this.lastPointerPosition = null;
|
||||||
this.lastPointerEventTimeMs = null;
|
this.lastPointerEventTimeMs = null;
|
||||||
|
this.clearSmoothedStroke();
|
||||||
this.lastPointerPressure = this.getPointerPressure(event);
|
this.lastPointerPressure = this.getPointerPressure(event);
|
||||||
this.addSwipeAt(event);
|
this.addSwipeAt(event, { emitAudio: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly onPointerMove = (event: PointerEvent) => {
|
private readonly onPointerMove = (event: PointerEvent) => {
|
||||||
|
|
@ -115,7 +137,9 @@ export class GardenPointerInput {
|
||||||
if (event.pointerId !== this.activePointerId) {
|
if (event.pointerId !== this.activePointerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.addSwipeAt(event);
|
this.getCoalescedPointerEvents(event).forEach((coalescedEvent) => {
|
||||||
|
this.addSwipeAt(coalescedEvent);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly onPointerUp = (event: PointerEvent) => {
|
private readonly onPointerUp = (event: PointerEvent) => {
|
||||||
|
|
@ -123,6 +147,7 @@ export class GardenPointerInput {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.addSwipeAt(event, { emitAudio: false });
|
this.addSwipeAt(event, { emitAudio: false });
|
||||||
|
this.finishSmoothedStroke();
|
||||||
this.options.audio.endGesture();
|
this.options.audio.endGesture();
|
||||||
if (this.isErasing) {
|
if (this.isErasing) {
|
||||||
this.options.onEraseGestureEnded();
|
this.options.onEraseGestureEnded();
|
||||||
|
|
@ -131,6 +156,7 @@ export class GardenPointerInput {
|
||||||
this.activePointerId = null;
|
this.activePointerId = null;
|
||||||
this.lastPointerPosition = null;
|
this.lastPointerPosition = null;
|
||||||
this.lastPointerEventTimeMs = null;
|
this.lastPointerEventTimeMs = null;
|
||||||
|
this.clearSmoothedStroke();
|
||||||
this.options.eraserPreview.setPointerHoveringCanvas(
|
this.options.eraserPreview.setPointerHoveringCanvas(
|
||||||
this.options.eraserPreview.isPointerInsideCanvas(event)
|
this.options.eraserPreview.isPointerInsideCanvas(event)
|
||||||
);
|
);
|
||||||
|
|
@ -169,14 +195,14 @@ export class GardenPointerInput {
|
||||||
? [{ from: previousPosition, to: position }]
|
? [{ from: previousPosition, to: position }]
|
||||||
: this.getMirroredStrokeSegments(previousPosition, position);
|
: this.getMirroredStrokeSegments(previousPosition, position);
|
||||||
|
|
||||||
segments.forEach((segment) => {
|
|
||||||
if (this.isErasing) {
|
if (this.isErasing) {
|
||||||
|
segments.forEach((segment) => {
|
||||||
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
|
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
|
||||||
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
|
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
|
||||||
} else {
|
|
||||||
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.addSmoothedBrushSample(position);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.isErasing) {
|
if (!this.isErasing) {
|
||||||
segments.forEach((segment) => {
|
segments.forEach((segment) => {
|
||||||
|
|
@ -194,6 +220,7 @@ export class GardenPointerInput {
|
||||||
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
|
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
|
||||||
velocityPixelsPerSecond,
|
velocityPixelsPerSecond,
|
||||||
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
||||||
|
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
||||||
pointerType: event.pointerType,
|
pointerType: event.pointerType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -201,6 +228,113 @@ export class GardenPointerInput {
|
||||||
this.lastPointerEventTimeMs = event.timeStamp;
|
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> {
|
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> {
|
||||||
const segmentCount = this.options.getMirrorSegmentCount();
|
const segmentCount = this.options.getMirrorSegmentCount();
|
||||||
if (segmentCount <= 1) {
|
if (segmentCount <= 1) {
|
||||||
|
|
@ -246,3 +380,27 @@ const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
|
||||||
center[1] + offsetX * sin + offsetY * cos
|
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;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeli
|
||||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
import { SimulationTextures } from './simulation-textures';
|
import { SimulationTextures } from './simulation-textures';
|
||||||
|
|
||||||
export interface SimulationFramePipelines {
|
interface SimulationFramePipelines {
|
||||||
copyPipeline: CopyPipeline;
|
copyPipeline: CopyPipeline;
|
||||||
agentPipeline: AgentPipeline;
|
agentPipeline: AgentPipeline;
|
||||||
brushPipeline: BrushPipeline;
|
brushPipeline: BrushPipeline;
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,9 @@ const selectorExists = (selector: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('index DOM selector contract', () => {
|
describe('index DOM selector contract', () => {
|
||||||
it('keeps every boot-time querySelector target present in index.html', () => {
|
it('keeps every boot-time required selector target present in index.html', () => {
|
||||||
const selectors = Array.from(
|
const selectors = Array.from(
|
||||||
indexSource.matchAll(/document\.querySelector(?:All)?\(\s*'([^']+)'\s*\)/g),
|
indexSource.matchAll(/queryRequiredElements?\(\s*'([^']+)'\s*,/g),
|
||||||
(match) => match[1]
|
(match) => match[1]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,6 @@
|
||||||
@use 'style/control-dock';
|
@use 'style/control-dock';
|
||||||
@use 'style/toolbar';
|
@use 'style/toolbar';
|
||||||
@use 'style/panels';
|
@use 'style/panels';
|
||||||
|
@use 'style/config-pane';
|
||||||
|
@use 'style/loading';
|
||||||
@use 'style/motion';
|
@use 'style/motion';
|
||||||
|
|
|
||||||
104
src/index.ts
|
|
@ -8,7 +8,9 @@ import { ConfigPane } from './page/config-pane';
|
||||||
import { FullScreenHandler } from './page/full-screen-handler';
|
import { FullScreenHandler } from './page/full-screen-handler';
|
||||||
import { MenuHider } from './page/menu-hider';
|
import { MenuHider } from './page/menu-hider';
|
||||||
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
|
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
|
||||||
|
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
|
||||||
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
||||||
|
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
|
||||||
import { ErrorHandler, Severity } from './utils/error-handler';
|
import { ErrorHandler, Severity } from './utils/error-handler';
|
||||||
import { initializeGpu } from './utils/graphics/initialize-gpu';
|
import { initializeGpu } from './utils/graphics/initialize-gpu';
|
||||||
import { VIBE_PRESETS } from './vibes';
|
import { VIBE_PRESETS } from './vibes';
|
||||||
|
|
@ -37,10 +39,13 @@ const getMirrorSegmentRatio = (count: number): number =>
|
||||||
(count - appConfig.toolbar.mirror.min) /
|
(count - appConfig.toolbar.mirror.min) /
|
||||||
(appConfig.toolbar.mirror.max - 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 =>
|
const formatMirrorSegmentCount = (count: number): string =>
|
||||||
count === appConfig.toolbar.mirror.default
|
count === appConfig.toolbar.mirror.default
|
||||||
? 'Mirror off'
|
? 'Mirror off'
|
||||||
: `${count} ${appConfig.toolbar.mirror.names[count] ?? 'slices'}`;
|
: `${count} ${mirrorSegmentNames[count] ?? 'slices'}`;
|
||||||
|
|
||||||
const renderRuntimeMessage = (
|
const renderRuntimeMessage = (
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
|
|
@ -63,40 +68,52 @@ const renderRuntimeMessage = (
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
aside: document.querySelector('aside') as HTMLDivElement,
|
aside: queryRequiredElement('aside', HTMLDivElement),
|
||||||
infoButton: document.querySelector('button.info') as HTMLButtonElement,
|
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
|
||||||
infoElement: document.querySelector('.info-page') as HTMLDivElement,
|
infoElement: queryRequiredElement('.info-page', HTMLDivElement),
|
||||||
minimizeFullScreenButton: document.querySelector(
|
minimizeFullScreenButton: queryRequiredElement(
|
||||||
'button.minimize-full-screen'
|
'button.minimize-full-screen',
|
||||||
) as HTMLButtonElement,
|
HTMLButtonElement
|
||||||
maximizeFullScreenButton: document.querySelector(
|
),
|
||||||
'button.maximize-full-screen'
|
maximizeFullScreenButton: queryRequiredElement(
|
||||||
) as HTMLButtonElement,
|
'button.maximize-full-screen',
|
||||||
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
|
HTMLButtonElement
|
||||||
soundButton: document.querySelector('button.sound') as HTMLButtonElement,
|
),
|
||||||
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
|
settingsButton: queryRequiredElement('button.settings', HTMLButtonElement),
|
||||||
canvas: document.querySelector('canvas') as HTMLCanvasElement,
|
soundButton: queryRequiredElement('button.sound', HTMLButtonElement),
|
||||||
eraserPreview: document.querySelector('.eraser-preview') as HTMLDivElement,
|
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
|
||||||
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
|
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
|
||||||
previousVibe: document.querySelector('.previous-vibe') as HTMLButtonElement,
|
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
|
||||||
nextVibe: document.querySelector('.next-vibe') as HTMLButtonElement,
|
errorContainer: queryRequiredElement('.errors-container', HTMLDivElement),
|
||||||
swatches: Array.from(
|
previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement),
|
||||||
document.querySelectorAll('.color-swatch')
|
nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement),
|
||||||
) as Array<HTMLButtonElement>,
|
swatches: queryRequiredElements('.color-swatch', HTMLButtonElement),
|
||||||
eraserSizeControl: document.querySelector('.eraser-size-control') as HTMLLabelElement,
|
eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement),
|
||||||
eraserSizeSlider: document.querySelector('.eraser-size-slider') as HTMLInputElement,
|
eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement),
|
||||||
mirrorSegmentControl: document.querySelector(
|
mirrorSegmentControl: queryRequiredElement(
|
||||||
'.mirror-segment-control'
|
'.mirror-segment-control',
|
||||||
) as HTMLLabelElement,
|
HTMLLabelElement
|
||||||
mirrorSegmentSlider: document.querySelector(
|
),
|
||||||
'.mirror-segment-slider'
|
mirrorSegmentSlider: queryRequiredElement(
|
||||||
) as HTMLInputElement,
|
'.mirror-segment-slider',
|
||||||
export4k: document.querySelector('.export-4k') as HTMLButtonElement,
|
HTMLInputElement
|
||||||
exportStatus: document.querySelector('.export-status') as HTMLSpanElement,
|
),
|
||||||
prompt: document.querySelector('.garden-prompt') as HTMLDivElement,
|
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),
|
||||||
};
|
};
|
||||||
|
|
||||||
let isAudioMuted = localStorage.getItem(appConfig.storage.audioMutedKey) === '1';
|
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) => {
|
const renderAudioUi = (game: GameLoop | null) => {
|
||||||
elements.soundButton.classList.toggle('muted', isAudioMuted);
|
elements.soundButton.classList.toggle('muted', isAudioMuted);
|
||||||
|
|
@ -185,6 +202,7 @@ const main = async () => {
|
||||||
ErrorHandler.addOnErrorListener((error, _metadata) => {
|
ErrorHandler.addOnErrorListener((error, _metadata) => {
|
||||||
renderRuntimeMessage(elements.errorContainer, error);
|
renderRuntimeMessage(elements.errorContainer, error);
|
||||||
if (error.severity === Severity.ERROR) {
|
if (error.severity === Severity.ERROR) {
|
||||||
|
document.body.classList.remove('is-loading');
|
||||||
game?.destroy();
|
game?.destroy();
|
||||||
shouldStop = true;
|
shouldStop = true;
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +241,8 @@ const main = async () => {
|
||||||
() =>
|
() =>
|
||||||
FullScreenHandler.isInFullScreenMode() &&
|
FullScreenHandler.isInFullScreenMode() &&
|
||||||
!configPane.isOpen &&
|
!configPane.isOpen &&
|
||||||
!infoPageHandler.isOpen
|
!infoPageHandler.isOpen,
|
||||||
|
{ persistentElement: elements.settingsButton }
|
||||||
);
|
);
|
||||||
new FullScreenHandler(
|
new FullScreenHandler(
|
||||||
elements.minimizeFullScreenButton,
|
elements.minimizeFullScreenButton,
|
||||||
|
|
@ -232,13 +251,16 @@ const main = async () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const fontsReady = document.fonts.ready.catch(() => undefined);
|
const fontsReady = document.fonts.ready.catch(() => undefined);
|
||||||
|
setLoadingStage('Connecting to GPU…', 0.1);
|
||||||
const gpu = await initializeGpu();
|
const gpu = await initializeGpu();
|
||||||
|
setLoadingStage('Loading fonts…', 0.4);
|
||||||
await fontsReady;
|
await fontsReady;
|
||||||
|
setLoadingStage('Compiling shaders…', 0.7);
|
||||||
|
|
||||||
elements.restartButton.addEventListener('click', () => game?.destroy());
|
elements.restartButton.addEventListener('click', () => game?.destroy());
|
||||||
elements.soundButton.addEventListener('click', (event) => {
|
elements.soundButton.addEventListener('click', (event) => {
|
||||||
isAudioMuted = !isAudioMuted;
|
isAudioMuted = !isAudioMuted;
|
||||||
localStorage.setItem(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
|
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
|
||||||
renderAudioUi(game);
|
renderAudioUi(game);
|
||||||
if (!isAudioMuted) {
|
if (!isAudioMuted) {
|
||||||
game?.startAudio(event.isTrusted);
|
game?.startAudio(event.isTrusted);
|
||||||
|
|
@ -323,6 +345,7 @@ const main = async () => {
|
||||||
renderMirrorSegmentUi();
|
renderMirrorSegmentUi();
|
||||||
renderAudioUi(game);
|
renderAudioUi(game);
|
||||||
|
|
||||||
|
let isFirstStart = true;
|
||||||
while (!shouldStop) {
|
while (!shouldStop) {
|
||||||
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
||||||
prompt: elements.prompt,
|
prompt: elements.prompt,
|
||||||
|
|
@ -334,9 +357,18 @@ const main = async () => {
|
||||||
renderMirrorSegmentUi();
|
renderMirrorSegmentUi();
|
||||||
renderAudioUi(game);
|
renderAudioUi(game);
|
||||||
|
|
||||||
await game.start();
|
const startPromise = game.start();
|
||||||
|
if (isFirstStart) {
|
||||||
|
isFirstStart = false;
|
||||||
|
setLoadingStage('Ready', 1);
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => document.body.classList.remove('is-loading'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await startPromise;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
document.body.classList.remove('is-loading');
|
||||||
ErrorHandler.addException(e);
|
ErrorHandler.addException(e);
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,32 @@ import { activeVibe, settings } from '../settings';
|
||||||
import { VIBE_PRESETS } from '../vibes';
|
import { VIBE_PRESETS } from '../vibes';
|
||||||
|
|
||||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
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 {
|
interface ConfigPaneOptions {
|
||||||
onConfigChange: () => void;
|
onConfigChange: () => void;
|
||||||
|
|
@ -37,6 +63,14 @@ const toLabel = (value: string): string =>
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const normalizeNumber = (value: number, config: NumberControlConfig): number => {
|
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 finiteValue = Number.isFinite(value) ? value : config.min;
|
||||||
const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
|
const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
|
||||||
return config.integer ? Math.round(clampedValue) : clampedValue;
|
return config.integer ? Math.round(clampedValue) : clampedValue;
|
||||||
|
|
@ -49,12 +83,21 @@ const getNumberBindingParams = (
|
||||||
label: config.label ?? toLabel(key),
|
label: config.label ?? toLabel(key),
|
||||||
min: config.min,
|
min: config.min,
|
||||||
max: config.max,
|
max: config.max,
|
||||||
|
options: config.options,
|
||||||
step: config.step,
|
step: config.step,
|
||||||
});
|
});
|
||||||
|
|
||||||
export class ConfigPane {
|
export class ConfigPane {
|
||||||
private readonly container: HTMLDivElement;
|
private readonly container: HTMLDivElement;
|
||||||
private readonly pane: Pane;
|
private readonly pane: Pane;
|
||||||
|
private readonly colorReactionSelects = new Map<
|
||||||
|
ColorReactionKey,
|
||||||
|
HTMLSelectElement
|
||||||
|
>();
|
||||||
|
private readonly colorReactionSwatches: Array<{
|
||||||
|
colorIndex: number;
|
||||||
|
element: HTMLElement;
|
||||||
|
}> = [];
|
||||||
private readonly state = {
|
private readonly state = {
|
||||||
activeVibeId: activeVibe.id,
|
activeVibeId: activeVibe.id,
|
||||||
};
|
};
|
||||||
|
|
@ -106,6 +149,7 @@ export class ConfigPane {
|
||||||
public refresh(): void {
|
public refresh(): void {
|
||||||
this.state.activeVibeId = activeVibe.id;
|
this.state.activeVibeId = activeVibe.id;
|
||||||
this.pane.refresh();
|
this.pane.refresh();
|
||||||
|
this.syncColorReactionMatrix();
|
||||||
this.syncButton();
|
this.syncButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +192,19 @@ export class ConfigPane {
|
||||||
.on('click', () => this.options.onRestart());
|
.on('click', () => this.options.onRestart());
|
||||||
|
|
||||||
const folders = new Map<string, PaneContainer>();
|
const folders = new Map<string, PaneContainer>();
|
||||||
|
let hasAddedColorReactionMatrix = false;
|
||||||
Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
|
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 =
|
const folder =
|
||||||
folders.get(config.folder) ??
|
folders.get(config.folder) ??
|
||||||
container.addFolder({
|
container.addFolder({
|
||||||
|
|
@ -157,8 +213,6 @@ export class ConfigPane {
|
||||||
});
|
});
|
||||||
folders.set(config.folder, folder);
|
folders.set(config.folder, folder);
|
||||||
|
|
||||||
const settingKey = key as keyof GardenRuntimeSettings & string;
|
|
||||||
settings[settingKey] = normalizeNumber(settings[settingKey], config);
|
|
||||||
folder
|
folder
|
||||||
.addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
|
.addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
|
||||||
.on('change', () => {
|
.on('change', () => {
|
||||||
|
|
@ -170,6 +224,117 @@ export class ConfigPane {
|
||||||
this.options.onRuntimeChange();
|
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 {
|
private setUpConfigTab(container: PaneContainer): void {
|
||||||
|
|
@ -252,6 +417,7 @@ export class ConfigPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncButton(): void {
|
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-expanded', String(this.isOpen));
|
||||||
this.options.settingsButton.setAttribute(
|
this.options.settingsButton.setAttribute(
|
||||||
'aria-label',
|
'aria-label',
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,11 @@ export class FullScreenHandler {
|
||||||
return document.fullscreenElement !== null;
|
return document.fullscreenElement !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateButtons() {
|
private updateButtons(): void {
|
||||||
this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
|
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
|
||||||
? 'block'
|
this.minimizeButton.style.display = isInFullScreenMode ? 'block' : 'none';
|
||||||
: 'none';
|
this.maximizeButton.style.display = isInFullScreenMode ? 'none' : 'block';
|
||||||
this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
|
this.minimizeButton.classList.toggle('active', isInFullScreenMode);
|
||||||
? 'none'
|
this.maximizeButton.classList.toggle('active', isInFullScreenMode);
|
||||||
: 'block';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,33 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
|
interface MenuHiderOptions {
|
||||||
|
persistentElement?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
export class MenuHider {
|
export class MenuHider {
|
||||||
private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs;
|
private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs;
|
||||||
private static readonly INTERVAL = appConfig.menuHider.intervalMs;
|
private static readonly INTERVAL = appConfig.menuHider.intervalMs;
|
||||||
private static readonly BOTTOM_REVEAL_DISTANCE =
|
private static readonly BOTTOM_REVEAL_DISTANCE =
|
||||||
appConfig.menuHider.bottomRevealDistancePx;
|
appConfig.menuHider.bottomRevealDistancePx;
|
||||||
|
private readonly interactiveElements: Array<HTMLElement>;
|
||||||
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
||||||
private isHidden = false;
|
private isHidden = false;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly element: HTMLElement,
|
private readonly element: HTMLElement,
|
||||||
private readonly shouldBeHidden: () => boolean
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
|
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
|
||||||
this.updateVisibility();
|
this.updateVisibility();
|
||||||
|
|
@ -61,8 +77,31 @@ export class MenuHider {
|
||||||
|
|
||||||
this.isHidden = shouldHide;
|
this.isHidden = shouldHide;
|
||||||
this.element.classList.toggle('menu-hidden', 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.style.opacity = shouldHide ? '0' : '1';
|
||||||
this.element.setAttribute('aria-hidden', String(shouldHide));
|
this.element.setAttribute('aria-hidden', String(shouldHide));
|
||||||
this.element.inert = shouldHide;
|
this.element.inert = shouldHide;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element.style.opacity = '';
|
||||||
|
this.element.setAttribute('aria-hidden', 'false');
|
||||||
|
this.element.inert = false;
|
||||||
|
|
||||||
|
this.interactiveElements.forEach((interactiveElement) => {
|
||||||
|
const isPersistentElement = interactiveElement === persistentElement;
|
||||||
|
|
||||||
|
interactiveElement.inert = shouldHide && !isPersistentElement;
|
||||||
|
interactiveElement.toggleAttribute(
|
||||||
|
'aria-hidden',
|
||||||
|
shouldHide && !isPersistentElement
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,2 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
|
|
||||||
export interface Agent {
|
|
||||||
position: vec2;
|
|
||||||
angle: number;
|
|
||||||
colorIndex: number;
|
|
||||||
targetPosition: vec2;
|
|
||||||
targetAngle: number;
|
|
||||||
introDelay: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AGENT_FLOAT_COUNT = 8;
|
export const AGENT_FLOAT_COUNT = 8;
|
||||||
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import shader from './agent.wgsl?raw';
|
||||||
|
|
||||||
export class AgentPipeline {
|
export class AgentPipeline {
|
||||||
private static readonly WORKGROUP_SIZE = 64;
|
private static readonly WORKGROUP_SIZE = 64;
|
||||||
private static readonly UNIFORM_COUNT = 8;
|
private static readonly UNIFORM_COUNT = 17;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPUComputePipeline;
|
private readonly pipeline: GPUComputePipeline;
|
||||||
|
|
@ -58,6 +58,15 @@ export class AgentPipeline {
|
||||||
sensorOffsetDistance,
|
sensorOffsetDistance,
|
||||||
turnWhenLost,
|
turnWhenLost,
|
||||||
individualTrailWeight,
|
individualTrailWeight,
|
||||||
|
color1ToColor1,
|
||||||
|
color1ToColor2,
|
||||||
|
color1ToColor3,
|
||||||
|
color2ToColor1,
|
||||||
|
color2ToColor2,
|
||||||
|
color2ToColor3,
|
||||||
|
color3ToColor1,
|
||||||
|
color3ToColor2,
|
||||||
|
color3ToColor3,
|
||||||
agentCount,
|
agentCount,
|
||||||
introProgress,
|
introProgress,
|
||||||
}: AgentSettings & {
|
}: AgentSettings & {
|
||||||
|
|
@ -74,6 +83,15 @@ export class AgentPipeline {
|
||||||
this.uniformValues[5] = individualTrailWeight;
|
this.uniformValues[5] = individualTrailWeight;
|
||||||
this.uniformValues[6] = agentCount;
|
this.uniformValues[6] = agentCount;
|
||||||
this.uniformValues[7] = introProgress ?? 1;
|
this.uniformValues[7] = introProgress ?? 1;
|
||||||
|
this.uniformValues[8] = color1ToColor1;
|
||||||
|
this.uniformValues[9] = color1ToColor2;
|
||||||
|
this.uniformValues[10] = color1ToColor3;
|
||||||
|
this.uniformValues[11] = color2ToColor1;
|
||||||
|
this.uniformValues[12] = color2ToColor2;
|
||||||
|
this.uniformValues[13] = color2ToColor3;
|
||||||
|
this.uniformValues[14] = color3ToColor1;
|
||||||
|
this.uniformValues[15] = color3ToColor2;
|
||||||
|
this.uniformValues[16] = color3ToColor3;
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
export interface AgentSettings {
|
export interface AgentSettings {
|
||||||
|
color1ToColor1: number;
|
||||||
|
color1ToColor2: number;
|
||||||
|
color1ToColor3: number;
|
||||||
|
color2ToColor1: number;
|
||||||
|
color2ToColor2: number;
|
||||||
|
color2ToColor3: number;
|
||||||
|
color3ToColor1: number;
|
||||||
|
color3ToColor2: number;
|
||||||
|
color3ToColor3: number;
|
||||||
moveSpeed: number;
|
moveSpeed: number;
|
||||||
turnSpeed: number;
|
turnSpeed: number;
|
||||||
sensorOffsetAngle: number;
|
sensorOffsetAngle: number;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,15 @@ struct Settings {
|
||||||
individualTrailWeight: f32,
|
individualTrailWeight: f32,
|
||||||
agentCount: f32,
|
agentCount: f32,
|
||||||
introProgress: f32,
|
introProgress: f32,
|
||||||
|
color1ToColor1: f32,
|
||||||
|
color1ToColor2: f32,
|
||||||
|
color1ToColor3: f32,
|
||||||
|
color2ToColor1: f32,
|
||||||
|
color2ToColor2: f32,
|
||||||
|
color2ToColor3: f32,
|
||||||
|
color3ToColor1: f32,
|
||||||
|
color3ToColor2: f32,
|
||||||
|
color3ToColor3: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
|
@ -58,17 +67,19 @@ fn main(
|
||||||
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
|
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
|
||||||
|
|
||||||
let channelMask = get_channel_mask(agent.colorIndex);
|
let channelMask = get_channel_mask(agent.colorIndex);
|
||||||
let friendForward = dot(trailForward.rgb, channelMask);
|
let reactionMask = get_reaction_mask(agent.colorIndex);
|
||||||
let friendLeft = dot(trailLeft.rgb, channelMask);
|
|
||||||
let friendRight = dot(trailRight.rgb, channelMask);
|
|
||||||
|
|
||||||
let sourceForward = dot(sourceForwardSample.rgb, channelMask);
|
let trailForwardWeight = dot(trailForward.rgb, reactionMask);
|
||||||
let sourceLeft = dot(sourceLeftSample.rgb, channelMask);
|
let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
|
||||||
let sourceRight = dot(sourceRightSample.rgb, channelMask);
|
let trailRightWeight = dot(trailRight.rgb, reactionMask);
|
||||||
|
|
||||||
let weightForward = friendForward + sourceForward * 24.0;
|
let sourceForwardWeight = dot(sourceForwardSample.rgb, reactionMask);
|
||||||
let weightLeft = friendLeft + sourceLeft * 24.0;
|
let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
|
||||||
let weightRight = friendRight + sourceRight * 24.0;
|
let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
|
||||||
|
|
||||||
|
let weightForward = trailForwardWeight + sourceForwardWeight * 24.0;
|
||||||
|
let weightLeft = trailLeftWeight + sourceLeftWeight * 24.0;
|
||||||
|
let weightRight = trailRightWeight + sourceRightWeight * 24.0;
|
||||||
|
|
||||||
var rotation = (random.r - 0.5) * settings.turnWhenLost;
|
var rotation = (random.r - 0.5) * settings.turnWhenLost;
|
||||||
if weightForward >= weightLeft && weightForward >= weightRight {
|
if weightForward >= weightLeft && weightForward >= weightRight {
|
||||||
|
|
@ -78,7 +89,8 @@ fn main(
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceAtAgent = textureLoad(sourceMap, vec2<i32>(agent.position), 0);
|
let sourceAtAgent = textureLoad(sourceMap, vec2<i32>(agent.position), 0);
|
||||||
let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, channelMask), 0.0, 1.0);
|
let positiveReactionMask = max(reactionMask, vec3<f32>(0.0));
|
||||||
|
let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, positiveReactionMask), 0.0, 1.0);
|
||||||
var moveRate = settings.moveRate * mix(1.0, 0.08, sourceAtAgentStrength);
|
var moveRate = settings.moveRate * mix(1.0, 0.08, sourceAtAgentStrength);
|
||||||
var introTargetOffset = vec2<f32>(0.0, 0.0);
|
var introTargetOffset = vec2<f32>(0.0, 0.0);
|
||||||
var introTargetDistance = 0.0;
|
var introTargetDistance = 0.0;
|
||||||
|
|
@ -111,7 +123,7 @@ fn main(
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceBelow = textureLoad(sourceMap, vec2<i32>(nextPosition), 0);
|
let sourceBelow = textureLoad(sourceMap, vec2<i32>(nextPosition), 0);
|
||||||
let sourceBelowStrength = dot(sourceBelow.rgb, channelMask);
|
let sourceBelowStrength = clamp(dot(sourceBelow.rgb, positiveReactionMask), 0.0, 1.0);
|
||||||
let trailWeight = settings.individualTrailWeight * (1.0 + sourceBelowStrength * 16.0);
|
let trailWeight = settings.individualTrailWeight * (1.0 + sourceBelowStrength * 16.0);
|
||||||
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
|
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
|
||||||
trailBelow = vec4<f32>(
|
trailBelow = vec4<f32>(
|
||||||
|
|
@ -145,6 +157,28 @@ fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
return vec3<f32>(0, 0, 1);
|
return vec3<f32>(0, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
|
if colorIndex < 0.5 {
|
||||||
|
return vec3<f32>(
|
||||||
|
settings.color1ToColor1,
|
||||||
|
settings.color1ToColor2,
|
||||||
|
settings.color1ToColor3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if colorIndex < 1.5 {
|
||||||
|
return vec3<f32>(
|
||||||
|
settings.color2ToColor1,
|
||||||
|
settings.color2ToColor2,
|
||||||
|
settings.color2ToColor3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return vec3<f32>(
|
||||||
|
settings.color3ToColor1,
|
||||||
|
settings.color3ToColor2,
|
||||||
|
settings.color3ToColor3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
||||||
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
|
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export interface BrushSettings {
|
export interface BrushSettings {
|
||||||
brushSize: number;
|
brushSize: number;
|
||||||
|
brushCurveResolution: number;
|
||||||
eraserSize: number;
|
eraserSize: number;
|
||||||
mirrorSegmentCount: number;
|
mirrorSegmentCount: number;
|
||||||
brushSizeVariation: number;
|
brushSizeVariation: number;
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,15 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
'individualTrailWeight',
|
'individualTrailWeight',
|
||||||
'agentCount',
|
'agentCount',
|
||||||
'introProgress',
|
'introProgress',
|
||||||
|
'color1ToColor1',
|
||||||
|
'color1ToColor2',
|
||||||
|
'color1ToColor3',
|
||||||
|
'color2ToColor1',
|
||||||
|
'color2ToColor2',
|
||||||
|
'color2ToColor3',
|
||||||
|
'color3ToColor1',
|
||||||
|
'color3ToColor2',
|
||||||
|
'color3ToColor3',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expectStructUniformLayout({
|
expectStructUniformLayout({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { appConfig, type GardenRuntimeSettings } from './config';
|
import { appConfig, type GardenRuntimeSettings } from './config';
|
||||||
|
import { writeBrowserStorage } from './utils/browser-storage';
|
||||||
import { getInitialVibe, VIBE_PRESETS, type VibePreset } from './vibes';
|
import { getInitialVibe, VIBE_PRESETS, type VibePreset } from './vibes';
|
||||||
|
|
||||||
const buildInitialValues = (vibe: VibePreset): GardenRuntimeSettings => ({
|
const buildInitialValues = (vibe: VibePreset): GardenRuntimeSettings => ({
|
||||||
|
|
@ -32,7 +33,7 @@ export const applyVibeSettings = (vibeId: string) => {
|
||||||
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
|
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.setItem(appConfig.storage.vibeKey, vibe.id);
|
writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);
|
||||||
|
|
||||||
return activeVibe;
|
return activeVibe;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,27 @@ html > body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .dev-stats-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: max(10px, env(safe-area-inset-top, 0px));
|
||||||
|
left: max(10px, env(safe-area-inset-left, 0px));
|
||||||
|
z-index: 6;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 18%);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgb(9 12 18 / 72%);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 22%);
|
||||||
|
color: rgb(255 255 255 / 90%);
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||||
|
monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
> .errors-container {
|
> .errors-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
|
||||||
69
src/style/_config-pane.scss
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
.config-pane {
|
||||||
|
.color-reaction-folder > .tp-fldv_c {
|
||||||
|
padding: 6px 8px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-reaction-matrix {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-reaction-matrix__corner,
|
||||||
|
.color-reaction-matrix__header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: rgb(255 255 255 / 76%);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-reaction-matrix__corner {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-left: 2px;
|
||||||
|
color: rgb(255 255 255 / 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-reaction-matrix__swatch {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 55%);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 0 1px rgb(0 0 0 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-reaction-matrix__cell {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-reaction-matrix__cell > select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 16%);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
appearance: auto;
|
||||||
|
background: rgb(255 255 255 / 8%);
|
||||||
|
color: white;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-reaction-matrix__cell > select:focus-visible {
|
||||||
|
outline: 2px solid rgb(255 255 255 / 72%);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-reaction-matrix__cell > select > option {
|
||||||
|
background: rgb(28 31 38);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
@ -4,12 +4,14 @@ html > body > aside.control-dock {
|
||||||
bottom: env(safe-area-inset-bottom);
|
bottom: env(safe-area-inset-bottom);
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
width: min(calc(100vw - 1rem), 980px);
|
width: min(calc(100vw - 1rem), 980px);
|
||||||
transform: translateX(-50%);
|
transform: translate(-50%, 0);
|
||||||
|
translate: 0 0;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition:
|
transition:
|
||||||
opacity var(--transition-time-long),
|
opacity var(--transition-time-long),
|
||||||
transform var(--transition-time-long),
|
transform var(--transition-time-long),
|
||||||
|
translate var(--transition-time-long),
|
||||||
visibility 0s;
|
visibility 0s;
|
||||||
|
|
||||||
> .toolbar-row,
|
> .toolbar-row,
|
||||||
|
|
@ -32,4 +34,32 @@ html > body > aside.control-dock {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.menu-hidden.has-persistent-settings {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
|
||||||
|
> .pages,
|
||||||
|
> .toolbar-row > .vibe-button,
|
||||||
|
> .toolbar-row > .toolbar-shell > .garden-controls,
|
||||||
|
> .toolbar-row > .toolbar-shell > nav.buttons > button:not(.settings),
|
||||||
|
> .toolbar-row > .toolbar-shell > nav.buttons > .export-status {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .toolbar-row,
|
||||||
|
> .toolbar-row > .toolbar-shell,
|
||||||
|
> .toolbar-row > .toolbar-shell > nav.buttons {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .toolbar-row > .toolbar-shell > nav.buttons > button.settings {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
120
src/style/_loading.scss
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
.loading-indicator {
|
||||||
|
--loading-progress: 0%;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3;
|
||||||
|
width: min(78vw, 320px);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--transition-time-long);
|
||||||
|
|
||||||
|
> .loading-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
> .loading-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 92%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 18px rgb(255 255 255 / 38%),
|
||||||
|
0 0 4px rgb(255 255 255 / 60%);
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0.4;
|
||||||
|
animation: loading-bloom 1.4s ease-in-out infinite;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.36s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .loading-status {
|
||||||
|
color: rgb(255 255 255 / 88%);
|
||||||
|
font:
|
||||||
|
600 16px/1.25 'Open Sans',
|
||||||
|
sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
min-height: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .loading-progress {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 14%);
|
||||||
|
box-shadow: 0 1px 6px rgb(0 0 0 / 28%);
|
||||||
|
|
||||||
|
> .loading-progress-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--loading-progress);
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgb(255 255 255 / 72%),
|
||||||
|
rgb(255 255 255 / 96%)
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 12px rgb(255 255 255 / 38%);
|
||||||
|
transition: width var(--transition-time-long) ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html > body.is-loading {
|
||||||
|
.loading-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eraser-preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside.control-dock {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
translate: 0 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-bloom {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.loading-indicator > .loading-dots > .loading-dot {
|
||||||
|
animation: none;
|
||||||
|
transform: scale(0.85);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,42 @@ html > body > aside.control-dock > .pages {
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.info-page {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
|
||||||
|
rgb(255 255 255);
|
||||||
|
border-color: rgb(255 255 255 / 78%);
|
||||||
|
color: rgb(24 30 27);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 54px rgb(0 0 0 / 38%),
|
||||||
|
0 2px 12px rgb(0 0 0 / 22%);
|
||||||
|
|
||||||
|
> section {
|
||||||
|
gap: 0.85rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: rgb(16 24 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
max-width: 54ch;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: rgb(42 48 45);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(0 84 120);
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
|
||||||
17
src/utils/browser-storage.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export const readBrowserStorage = (key: string): string | null => {
|
||||||
|
try {
|
||||||
|
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeBrowserStorage = (key: string, value: string): void => {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in private browsing or embedded contexts.
|
||||||
|
}
|
||||||
|
};
|
||||||
63
src/utils/dom.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { ErrorCode, RuntimeError } from './error-handler';
|
||||||
|
|
||||||
|
type ElementConstructor<T extends Element> = abstract new () => T;
|
||||||
|
|
||||||
|
export const queryRequiredElement = <T extends Element>(
|
||||||
|
selector: string,
|
||||||
|
constructor: ElementConstructor<T>,
|
||||||
|
root: ParentNode = document
|
||||||
|
): T => {
|
||||||
|
const element = root.querySelector(selector);
|
||||||
|
if (!(element instanceof constructor)) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.DOM_ELEMENT_MISSING,
|
||||||
|
`Missing required DOM element: ${selector}`,
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
expectedType: constructor.name,
|
||||||
|
selector,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const queryRequiredElements = <T extends Element>(
|
||||||
|
selector: string,
|
||||||
|
constructor: ElementConstructor<T>,
|
||||||
|
root: ParentNode = document
|
||||||
|
): Array<T> => {
|
||||||
|
const elements = Array.from(root.querySelectorAll(selector));
|
||||||
|
if (elements.length === 0) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.DOM_ELEMENT_MISSING,
|
||||||
|
`Missing required DOM elements: ${selector}`,
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
expectedType: constructor.name,
|
||||||
|
selector,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements.map((element) => {
|
||||||
|
if (!(element instanceof constructor)) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.DOM_ELEMENT_MISSING,
|
||||||
|
`DOM element has the wrong type: ${selector}`,
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
actualType: element.constructor.name,
|
||||||
|
expectedType: constructor.name,
|
||||||
|
selector,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -14,16 +14,17 @@ export enum ErrorCode {
|
||||||
WEBGPU_CONTEXT_CONFIGURATION_FAILED = 'webgpu-context-configuration-failed',
|
WEBGPU_CONTEXT_CONFIGURATION_FAILED = 'webgpu-context-configuration-failed',
|
||||||
WEBGPU_UNCAPTURED_ERROR = 'webgpu-uncaptured-error',
|
WEBGPU_UNCAPTURED_ERROR = 'webgpu-uncaptured-error',
|
||||||
WEBGPU_DEVICE_LOST = 'webgpu-device-lost',
|
WEBGPU_DEVICE_LOST = 'webgpu-device-lost',
|
||||||
|
DOM_ELEMENT_MISSING = 'dom-element-missing',
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorMetadataPrimitive = string | number | boolean | null;
|
type ErrorMetadataPrimitive = string | number | boolean | null;
|
||||||
export type ErrorMetadataValue =
|
type ErrorMetadataValue =
|
||||||
| ErrorMetadataPrimitive
|
| ErrorMetadataPrimitive
|
||||||
| Array<ErrorMetadataValue>
|
| Array<ErrorMetadataValue>
|
||||||
| { [key: string]: ErrorMetadataValue };
|
| { [key: string]: ErrorMetadataValue };
|
||||||
export type ErrorMetadata = { [key: string]: ErrorMetadataValue };
|
type ErrorMetadata = { [key: string]: ErrorMetadataValue };
|
||||||
|
|
||||||
export interface RuntimeErrorOptions {
|
interface RuntimeErrorOptions {
|
||||||
cause?: unknown;
|
cause?: unknown;
|
||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
@ -48,19 +49,19 @@ export class RuntimeError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorHandlerError {
|
interface ErrorHandlerError {
|
||||||
severity: Severity;
|
severity: Severity;
|
||||||
message: string;
|
message: string;
|
||||||
code?: ErrorCode | string;
|
code?: ErrorCode | string;
|
||||||
details?: ErrorMetadata;
|
details?: ErrorMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorHandlerErrorOptions {
|
interface ErrorHandlerErrorOptions {
|
||||||
code?: ErrorCode | string;
|
code?: ErrorCode | string;
|
||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
|
interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
|
||||||
fallbackMessage?: string;
|
fallbackMessage?: string;
|
||||||
severity?: Severity;
|
severity?: Severity;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { formatNumber } from './format-number';
|
|
||||||
|
|
||||||
describe('formatNumber', () => {
|
|
||||||
it('renders integers without decimals', () => {
|
|
||||||
expect(formatNumber(42)).toBe('42 ');
|
|
||||||
});
|
|
||||||
it('renders fractional values with two decimals', () => {
|
|
||||||
expect(formatNumber(3.14159)).toBe('3.14 ');
|
|
||||||
});
|
|
||||||
it('renders thousands compactly', () => {
|
|
||||||
expect(formatNumber(2500)).toBe('2.5 thousand ');
|
|
||||||
});
|
|
||||||
it('renders millions compactly', () => {
|
|
||||||
expect(formatNumber(1_500_000)).toBe('1.5 million ');
|
|
||||||
});
|
|
||||||
it('appends the unit when provided', () => {
|
|
||||||
expect(formatNumber(5, 'agents')).toBe('5 agents');
|
|
||||||
expect(formatNumber(2_000_000, 'agents')).toBe('2.0 million agents');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export const formatNumber = (value: number, unit = ''): string => {
|
|
||||||
if (value >= 1e6) {
|
|
||||||
return `${(value / 1e6).toFixed(1)} million ${unit}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value >= 1e3) {
|
|
||||||
return `${(value / 1e3).toFixed(1)} thousand ${unit}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${value === Math.floor(value) ? value : value.toFixed(2)} ${unit}`;
|
|
||||||
};
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export interface CachedFloat32BufferWrite {
|
interface CachedFloat32BufferWrite {
|
||||||
hasValue: boolean;
|
hasValue: boolean;
|
||||||
previous: Float32Array;
|
previous: Float32Array;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,17 @@ import { gardenAudioConfig } from './audio/garden-audio-config';
|
||||||
import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
|
import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
|
||||||
|
|
||||||
const originalLocalStorage = globalThis.localStorage;
|
const originalLocalStorage = globalThis.localStorage;
|
||||||
|
const colorInteractionKeys = [
|
||||||
|
'color1ToColor1',
|
||||||
|
'color1ToColor2',
|
||||||
|
'color1ToColor3',
|
||||||
|
'color2ToColor1',
|
||||||
|
'color2ToColor2',
|
||||||
|
'color2ToColor3',
|
||||||
|
'color3ToColor1',
|
||||||
|
'color3ToColor2',
|
||||||
|
'color3ToColor3',
|
||||||
|
] as const;
|
||||||
|
|
||||||
const setBrowserVibeState = ({
|
const setBrowserVibeState = ({
|
||||||
storedVibeId = null,
|
storedVibeId = null,
|
||||||
|
|
@ -85,4 +96,15 @@ describe('vibe and audio config contract', () => {
|
||||||
expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1);
|
expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses discrete color interaction matrices for every vibe', () => {
|
||||||
|
VIBE_PRESETS.forEach((vibe) => {
|
||||||
|
colorInteractionKeys.forEach((key) => {
|
||||||
|
expect([-1, 0, 1]).toContain(vibe.settings[key]);
|
||||||
|
});
|
||||||
|
expect(vibe.settings.color1ToColor1).toBe(1);
|
||||||
|
expect(vibe.settings.color2ToColor2).toBe(1);
|
||||||
|
expect(vibe.settings.color3ToColor3).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { appConfig, type VibePreset } from './config';
|
import { appConfig, type VibePreset } from './config';
|
||||||
|
import { readBrowserStorage } from './utils/browser-storage';
|
||||||
|
|
||||||
export type { GardenVibeSettings, VibePreset } from './config';
|
export type { GardenVibeSettings, VibePreset } from './config';
|
||||||
|
|
||||||
|
|
@ -14,7 +15,7 @@ export const hexToRgb = (hex: string): [number, number, number] => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInitialVibe = (): VibePreset => {
|
export const getInitialVibe = (): VibePreset => {
|
||||||
const id = localStorage.getItem(appConfig.storage.vibeKey);
|
const id = readBrowserStorage(appConfig.storage.vibeKey);
|
||||||
return (
|
return (
|
||||||
VIBE_PRESETS.find((vibe) => vibe.id === id) ??
|
VIBE_PRESETS.find((vibe) => vibe.id === id) ??
|
||||||
VIBE_PRESETS.find((vibe) => vibe.id === appConfig.vibes.defaultVibeId) ??
|
VIBE_PRESETS.find((vibe) => vibe.id === appConfig.vibes.defaultVibeId) ??
|
||||||
|
|
|
||||||
7
tsconfig.playwright.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node", "@playwright/test"]
|
||||||
|
},
|
||||||
|
"include": ["playwright.config.ts", "e2e/**/*.ts"]
|
||||||
|
}
|
||||||