.
Some checks failed
Deploy to Pages / build (pull_request) Failing after 3m15s

This commit is contained in:
Andras Schmelczer 2026-05-13 22:13:15 +01:00
commit 2347ecd201
71 changed files with 3779 additions and 1586 deletions

View file

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

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

View file

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

@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'gpu', {
configurable: true,
value: undefined,
});
});
await page.goto('/');
await expect(page).toHaveTitle('Fleeting Garden');
await expect(
page.getByRole('img', { name: 'Interactive generative garden canvas' })
).toBeVisible();
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
await page.getByRole('button', { name: 'About' }).click();
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
});

View file

@ -6,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&hellip;</div>
<div
class="loading-progress"
role="progressbar"
aria-label="Loading Fleeting Garden"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<div class="loading-progress-fill"></div>
</div>
</div>
<section class="errors-container"> <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 &mdash; 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
View file

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

View file

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

@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test';
const port = 4173;
const baseURL = `https://127.0.0.1:${port}`;
const isCi = Boolean(process.env.CI);
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: isCi,
retries: isCi ? 2 : 0,
workers: isCi ? 1 : undefined,
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL,
ignoreHTTPSErrors: true,
trace: 'on-first-retry',
},
webServer: {
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
ignoreHTTPSErrors: true,
reuseExistingServer: !isCi,
timeout: 120_000,
url: baseURL,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 914 B

Before After
Before After

View file

@ -1,6 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <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 After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 709 B

Before After
Before After

View file

@ -0,0 +1,185 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const projectRoot = process.cwd();
const sourceRoot = path.join(projectRoot, 'src');
const toPosix = (value) => value.split(path.sep).join('/');
const listTypeScriptFiles = (directory) =>
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listTypeScriptFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')
? [entryPath]
: [];
});
const files = listTypeScriptFiles(sourceRoot);
const fileSet = new Set(files.map((file) => path.resolve(file)));
const resolveModule = (fromFile, specifier) => {
if (!specifier.startsWith('.')) {
return null;
}
const base = path.resolve(path.dirname(fromFile), specifier);
const candidates = [
`${base}.ts`,
path.join(base, 'index.ts'),
base.endsWith('.ts') ? base : null,
].filter(Boolean);
return candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null;
};
const exportKey = (file, name) => `${path.resolve(file)}:${name}`;
const isExported = (node) =>
ts.canHaveModifiers(node) &&
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
const isDefaultExported = (node) =>
ts.canHaveModifiers(node) &&
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
const exportedDeclarations = new Map();
const usedExports = new Set();
const wildcardUsedFiles = new Set();
const markUsed = (fromFile, name) => {
usedExports.add(exportKey(fromFile, name));
};
const collectImportUsage = (file, sourceFile) => {
sourceFile.forEachChild((node) => {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
const resolved = resolveModule(file, node.moduleSpecifier.text);
if (!resolved || !node.importClause) {
return;
}
if (node.importClause.name) {
markUsed(resolved, 'default');
}
const namedBindings = node.importClause.namedBindings;
if (namedBindings && ts.isNamedImports(namedBindings)) {
namedBindings.elements.forEach((element) => {
markUsed(resolved, (element.propertyName ?? element.name).text);
});
} else if (namedBindings && ts.isNamespaceImport(namedBindings)) {
wildcardUsedFiles.add(path.resolve(resolved));
}
return;
}
if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const resolved = resolveModule(file, node.moduleSpecifier.text);
if (!resolved) {
return;
}
if (!node.exportClause) {
wildcardUsedFiles.add(path.resolve(resolved));
return;
}
if (ts.isNamedExports(node.exportClause)) {
node.exportClause.elements.forEach((element) => {
markUsed(resolved, (element.propertyName ?? element.name).text);
});
}
}
});
};
const collectExportedDeclarations = (file, sourceFile) => {
if (file.endsWith('.test.ts')) {
return;
}
sourceFile.forEachChild((node) => {
if (ts.isVariableStatement(node) && isExported(node)) {
node.declarationList.declarations.forEach((declaration) => {
if (ts.isIdentifier(declaration.name)) {
exportedDeclarations.set(exportKey(file, declaration.name.text), {
file,
name: declaration.name.text,
});
}
});
return;
}
if (
(ts.isFunctionDeclaration(node) ||
ts.isClassDeclaration(node) ||
ts.isInterfaceDeclaration(node) ||
ts.isTypeAliasDeclaration(node) ||
ts.isEnumDeclaration(node)) &&
isExported(node)
) {
if (isDefaultExported(node)) {
exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' });
return;
}
if (node.name) {
exportedDeclarations.set(exportKey(file, node.name.text), {
file,
name: node.name.text,
});
}
return;
}
if (
ts.isExportDeclaration(node) &&
!node.moduleSpecifier &&
node.exportClause &&
ts.isNamedExports(node.exportClause)
) {
node.exportClause.elements.forEach((element) => {
exportedDeclarations.set(exportKey(file, element.name.text), {
file,
name: element.name.text,
});
});
}
});
};
const parsedFiles = files.map((file) => ({
file,
sourceFile: ts.createSourceFile(
file,
readFileSync(file, 'utf8'),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
),
}));
parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile));
parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile));
const unusedExports = Array.from(exportedDeclarations.entries())
.filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file))
.map(([, declaration]) => declaration)
.sort((left, right) =>
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
);
if (unusedExports.length > 0) {
console.error('Unused exported declarations found:');
unusedExports.forEach(({ file, name }) => {
console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`);
});
process.exitCode = 1;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,4 +1,4 @@
export interface PianoSampleDefinition { interface PianoSampleDefinition {
midi: number; midi: number;
url: string; url: string;
} }

View file

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

View file

@ -0,0 +1,71 @@
import type {
AgentColorInteractionSettings,
NumberControlConfig,
} from './types';
const agentInteractionOptions: Record<string, number> = {
Follow: 1,
Avoid: -1,
Ignore: 0,
};
export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 0,
color2ToColor1: 0,
color2ToColor2: 1,
color2ToColor3: 0,
color3ToColor1: 0,
color3ToColor2: 0,
color3ToColor3: 1,
};
const hashString = (value: string): number => {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
};
const createSeededRandom = (seed: number): (() => number) => {
let state = seed;
return () => {
let value = (state += 0x6d2b79f5);
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
};
export const createColorInteractionSettings = (
seedSource: string
): AgentColorInteractionSettings => {
const random = createSeededRandom(hashString(seedSource));
const values = Object.values(agentInteractionOptions);
const randomInteraction = () =>
values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2;
return {
color1ToColor1: 1,
color1ToColor2: randomInteraction(),
color1ToColor3: randomInteraction(),
color2ToColor1: randomInteraction(),
color2ToColor2: 1,
color2ToColor3: randomInteraction(),
color3ToColor1: randomInteraction(),
color3ToColor2: randomInteraction(),
color3ToColor3: 1,
};
};
export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions',
label,
min: -1,
max: 1,
step: 1,
options: agentInteractionOptions,
});

View file

@ -0,0 +1,206 @@
import {
colorInteractionControl,
defaultColorInteractionSettings,
} from './color-interactions';
import type { GardenAppConfig } from './types';
export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
defaults: {
agentBudgetMax: 1_000_000,
agentCount: 0,
selectedColorIndex: 0,
spawnPerPixel: 0.22,
moveSpeed: 82,
turnSpeed: 58,
sensorOffsetAngle: 34,
sensorOffsetDistance: 38,
turnWhenLost: 0.8,
individualTrailWeight: 0.07,
...defaultColorInteractionSettings,
diffusionRateTrails: 0.22,
decayRateTrails: 965,
diffusionRateBrush: 0.35,
decayRateBrush: 18,
brushEffectDuration: 8,
clarity: 0.62,
brushSize: 14,
brushCurveResolution: 12,
eraserSize: 96,
mirrorSegmentCount: 1,
brushSizeVariation: 0.5,
startColorHue: 200,
renderSpeed: 1,
simulatedDelayMs: 0,
},
controls: {
agentBudgetMax: {
folder: 'Runtime',
integer: true,
min: 500_000,
max: 10_000_000,
step: 50_000,
},
agentCount: {
folder: 'Runtime',
integer: true,
min: 0,
max: 1_000_000,
step: 1_000,
},
color1ToColor1: colorInteractionControl('1 -> 1'),
color1ToColor2: colorInteractionControl('1 -> 2'),
color1ToColor3: colorInteractionControl('1 -> 3'),
color2ToColor1: colorInteractionControl('2 -> 1'),
color2ToColor2: colorInteractionControl('2 -> 2'),
color2ToColor3: colorInteractionControl('2 -> 3'),
color3ToColor1: colorInteractionControl('3 -> 1'),
color3ToColor2: colorInteractionControl('3 -> 2'),
color3ToColor3: colorInteractionControl('3 -> 3'),
brushEffectDuration: {
folder: 'Diffusion',
min: 0.5,
max: 20,
step: 0.05,
},
brushSize: {
folder: 'Brush',
min: 1,
max: 60,
step: 0.25,
},
brushSizeVariation: {
folder: 'Brush',
min: 0,
max: 1,
step: 0.01,
},
brushCurveResolution: {
folder: 'Brush',
integer: true,
label: 'curve resolution',
min: 1,
max: 32,
step: 1,
},
clarity: {
folder: 'Render',
min: 0.00001,
max: 1,
step: 0.001,
},
decayRateBrush: {
folder: 'Diffusion',
min: 0.1,
max: 100,
step: 0.1,
},
decayRateTrails: {
folder: 'Diffusion',
min: 0.1,
max: 5000,
step: 1,
},
diffusionRateBrush: {
folder: 'Diffusion',
min: 0.001,
max: 1,
step: 0.001,
},
diffusionRateTrails: {
folder: 'Diffusion',
min: 0,
max: 2,
step: 0.001,
},
eraserSize: {
folder: 'Brush',
integer: true,
min: 24,
max: 240,
step: 1,
},
individualTrailWeight: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
mirrorSegmentCount: {
folder: 'Brush',
integer: true,
min: 1,
max: 12,
step: 1,
},
moveSpeed: {
folder: 'Agent',
min: 10,
max: 500,
step: 1,
},
renderSpeed: {
folder: 'Runtime',
integer: true,
min: 1,
max: 10,
step: 1,
},
selectedColorIndex: {
folder: 'Brush',
integer: true,
min: 0,
max: 2,
step: 1,
},
sensorOffsetAngle: {
folder: 'Agent',
min: 0,
max: 90,
step: 1,
},
sensorOffsetDistance: {
folder: 'Agent',
min: 0,
max: 200,
step: 1,
},
simulatedDelayMs: {
folder: 'Runtime',
integer: true,
min: 0,
max: 2000,
step: 1,
},
spawnPerPixel: {
folder: 'Agent',
min: 0.01,
max: 1,
step: 0.001,
},
startColorHue: {
folder: 'Render',
min: 0,
max: 360,
step: 1,
},
turnSpeed: {
folder: 'Agent',
min: 1,
max: 200,
step: 1,
},
turnWhenLost: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
},
};

287
src/config/types.ts Normal file
View file

@ -0,0 +1,287 @@
import type {
GardenAudioConfig,
GardenAudioVibeProfile,
} from '../audio/garden-audio-config';
import type { GameLoopSettings } from '../game-loop/game-loop-settings';
import type { AgentSettings } from '../pipelines/agents/agent-settings';
import type { BrushSettings } from '../pipelines/brush/brush-settings';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
import type { RenderSettings } from '../pipelines/render/render-settings';
export type GardenRuntimeSettings = GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
export type AgentColorInteractionSettings = Pick<
AgentSettings,
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
>;
export type GardenVibeSettings = Partial<
Pick<
GardenRuntimeSettings,
| 'agentBudgetMax'
| 'brushSize'
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
| 'clarity'
| 'decayRateTrails'
| 'diffusionRateTrails'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'turnSpeed'
>
>;
export interface VibePreset {
id: string;
name: string;
colors: [string, string, string];
backgroundColor: string;
settings: GardenVibeSettings;
audio: GardenAudioVibeProfile;
}
export interface NumberControlConfig {
folder: string;
integer?: boolean;
label?: string;
max: number;
min: number;
options?: Record<string, number>;
step?: number;
}
export type RuntimeSettingControlConfig = {
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
};
export interface GardenAppConfig {
audio: GardenAudioConfig;
audioEngine: {
energy: {
attackSeconds: number;
decaySeconds: number;
releaseSeconds: number;
strokeDecaySeconds: number;
};
eraser: {
canvasWidthRatioForFullSize: number;
defaultSizePixels: number;
durationSeconds: number;
filterPressureWeight: number;
filterSizeWeight: number;
filterSpeedWeight: number;
gainBase: number;
gainPressureWeight: number;
gainSizeWeight: number;
gainSpeedWeight: number;
};
delay: {
erasingActivity: number;
};
graph: {
closeGain: number;
closeRampSeconds: number;
delayActivityFeedbackWeight: number;
delayFeedbackMax: number;
delayFeedbackMin: number;
delayOutputActivityWeight: number;
delayOutputBase: number;
delayTimeRampSeconds: number;
eventBusGain: number;
noiseMax: number;
noiseMin: number;
unlockBufferLength: number;
unlockSampleRate: number;
};
input: {
distanceEnergyBase: number;
distanceEnergyScale: number;
distanceForFullEnergyPixels: number;
fallbackFrameSeconds: number;
penMinPressure: number;
strokeEnergyBase: number;
strokeEnergyPressureWeight: number;
strokeEnergySpeedWeight: number;
};
muteGain: number;
muteRampSeconds: number;
noiseBurst: {
attackSeconds: number;
filterQ: number;
offsetRandomSeconds: number;
scheduleAheadSeconds: number;
silentGain: number;
};
piano: {
filterQ: number;
gainAttackSeconds: number;
lowpassMaxHz: number;
lowpassMinHz: number;
minDurationSeconds: number;
minFadeSeconds: number;
minGain: number;
pitchSemitonesPerOctave: number;
scheduleAheadSeconds: number;
sustainBase: number;
sustainVelocityRange: number;
tailStopExtraSeconds: number;
voiceStealFadeSeconds: number;
voiceStealStopSeconds: number;
};
startDelaySeconds: number;
vibeChangeStingerMinIntervalSeconds: number;
};
deltaTime: {
fpsExponentialDecayStrength: number;
maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number;
};
export4k: {
bytesPerPixel: number;
height: number;
jsHeapSafetyMultiplier: number;
lowMemoryDeviceGiB: number;
lowMemoryExportFraction: number;
rowAlignmentBytes: number;
width: number;
};
menuHider: {
bottomRevealDistancePx: number;
intervalMs: number;
timeToLiveMs: number;
};
pipelines: {
brush: {
maxLineCount: number;
};
diffusion: {
minDiffusionRate: number;
};
eraser: {
maxSegmentCount: number;
maxTextureLineCount: number;
segmentFloatCount: number;
workgroupSize: number;
};
};
runtimeSettings: {
controls: RuntimeSettingControlConfig;
defaults: GardenRuntimeSettings;
};
simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: number;
adaptiveCapMin: number;
fpsHeadroom: number;
fpsSmoothingNew: number;
fpsSmoothingRetain: number;
initialTargetAgentBudget: number;
rampAgentsPerSecond: number;
refreshTargetDecay: number;
};
brushEffectFramesPerSecond: number;
globalAgentCap: number;
initialAgentCount: number;
intro: {
angleJitterRadians: number;
circleMaxSideRatio: number;
circleMinSideRatio: number;
drawHintClass: string;
drawHintDelayMs: number;
durationSeconds: number;
entryJitterSideRatio: number;
fontScaleDown: number;
initialFontHeightRatio: number;
initialFontWidthRatio: number;
letterSpacingEm: number;
maskAlphaThreshold: number;
maskGradientThreshold: number;
maskSampleDensity: number;
maxHeightRatio: number;
maxWidthRatio: number;
minEntryJitterPx: number;
minFontSizePx: number;
minTargetJitterPx: number;
radialJitterRatio: number;
targetDelayDistanceMultiplier: number;
targetDelayMax: number;
targetDelayRandomMultiplier: number;
targetJitterSideRatio: number;
title: string;
titleColorCutLetters: [number, number];
titleRadiusMultiplier: number;
titleStrokeWidthMinPx: number;
titleStrokeWidthRatio: number;
verticalAnchor: number;
};
introMoveSpeedBaseMultiplier: number;
introMoveSpeedProgressMultiplier: number;
maxMirrorSegmentCount: number;
stroke: {
angleJitterRadians: number;
densityMultiplier: number;
maxAgentCount: number;
minAgentCount: number;
};
};
storage: {
audioMutedKey: string;
vibeKey: string;
};
telemetry: {
enabled: boolean;
intervalMs: number;
};
toolbar: {
eraser: {
controlScaleMax: number;
controlScaleMin: number;
default: number;
max: number;
min: number;
step: number;
};
mirror: {
default: number;
max: number;
min: number;
names: Record<number, string>;
step: number;
};
};
tuningPane: {
expandedDepth: number;
startHidden: boolean;
title: string;
};
vibes: {
defaultVibeId: string;
presets: Array<VibePreset>;
};
}
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
export type GardenSimulationConfig = GardenAppConfig['simulation'];
export type GardenStorageConfig = GardenAppConfig['storage'];

204
src/config/vibe-presets.ts Normal file
View file

@ -0,0 +1,204 @@
import type {
GardenAudioChord,
GardenAudioVibeProfile,
} from '../audio/garden-audio-config';
import { createColorInteractionSettings } from './color-interactions';
import type { VibePreset } from './types';
const majorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const minorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
];
const majorPentatonic = [0, 2, 4, 7, 9];
const minorPentatonic = [0, 3, 5, 7, 10];
export const defaultVibeId = 'candy-rain';
export const vibePresets: Array<VibePreset> = [
{
id: 'candy-rain',
name: 'Candy Rain',
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
backgroundColor: '#10151f',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 14,
clarity: 0.62,
decayRateTrails: 965,
diffusionRateTrails: 0.22,
individualTrailWeight: 0.07,
moveSpeed: 82,
sensorOffsetAngle: 34,
sensorOffsetDistance: 38,
spawnPerPixel: 0.22,
turnSpeed: 58,
...createColorInteractionSettings('candy-rain'),
},
audio: {
rootMidi: 57,
scale: majorPentatonic,
brightness: 1.04,
delayTimeMultiplier: 0.92,
progression: majorProgression,
},
},
{
id: 'sunlit-moss',
name: 'Sunlit Moss',
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
backgroundColor: '#172016',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 16,
clarity: 0.68,
decayRateTrails: 975,
diffusionRateTrails: 0.18,
individualTrailWeight: 0.06,
moveSpeed: 70,
sensorOffsetAngle: 28,
sensorOffsetDistance: 46,
spawnPerPixel: 0.18,
turnSpeed: 44,
...createColorInteractionSettings('sunlit-moss'),
},
audio: {
rootMidi: 53,
scale: majorPentatonic,
brightness: 0.92,
delayTimeMultiplier: 1.08,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
],
},
},
{
id: 'coral-tide',
name: 'Coral Tide',
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
backgroundColor: '#0f1822',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 13,
clarity: 0.58,
decayRateTrails: 955,
diffusionRateTrails: 0.28,
individualTrailWeight: 0.055,
moveSpeed: 90,
sensorOffsetAngle: 36,
sensorOffsetDistance: 35,
spawnPerPixel: 0.25,
turnSpeed: 62,
...createColorInteractionSettings('coral-tide'),
},
audio: {
rootMidi: 50,
scale: minorPentatonic,
brightness: 1,
delayTimeMultiplier: 1.12,
progression: minorProgression,
},
},
{
id: 'moon-orchid',
name: 'Moon Orchid',
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
backgroundColor: '#14121d',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 12,
clarity: 0.64,
decayRateTrails: 968,
diffusionRateTrails: 0.2,
individualTrailWeight: 0.065,
moveSpeed: 76,
sensorOffsetAngle: 32,
sensorOffsetDistance: 42,
spawnPerPixel: 0.2,
turnSpeed: 52,
...createColorInteractionSettings('moon-orchid'),
},
audio: {
rootMidi: 49,
scale: minorPentatonic,
brightness: 0.9,
delayTimeMultiplier: 1.24,
progression: minorProgression,
},
},
{
id: 'peach-neon',
name: 'Peach Neon',
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
backgroundColor: '#191716',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 15,
clarity: 0.55,
decayRateTrails: 948,
diffusionRateTrails: 0.32,
individualTrailWeight: 0.05,
moveSpeed: 96,
sensorOffsetAngle: 40,
sensorOffsetDistance: 32,
spawnPerPixel: 0.24,
turnSpeed: 70,
...createColorInteractionSettings('peach-neon'),
},
audio: {
rootMidi: 56,
scale: majorPentatonic,
brightness: 1.08,
delayTimeMultiplier: 0.86,
progression: majorProgression,
},
},
{
id: 'frost-bloom',
name: 'Frost Bloom',
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
backgroundColor: '#101820',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 18,
clarity: 0.7,
decayRateTrails: 982,
diffusionRateTrails: 0.14,
individualTrailWeight: 0.075,
moveSpeed: 62,
sensorOffsetAngle: 26,
sensorOffsetDistance: 52,
spawnPerPixel: 0.16,
turnSpeed: 40,
...createColorInteractionSettings('frost-bloom'),
},
audio: {
rootMidi: 62,
scale: majorPentatonic,
brightness: 0.88,
delayTimeMultiplier: 1.32,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 7, quality: 'major' },
],
},
},
];
export const audioVibes = Object.fromEntries(
vibePresets.map((vibe) => [vibe.id, vibe.audio])
) as Record<string, GardenAudioVibeProfile>;

View file

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

View file

@ -0,0 +1,86 @@
import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: vi.fn(() => null),
setItem: vi.fn(),
},
});
});
import { appConfig } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { settings } from '../settings';
import { AgentPopulation } from './agent-population';
const originalAgentBudgetMax = settings.agentBudgetMax;
const originalBrushSize = settings.brushSize;
const originalSelectedColorIndex = settings.selectedColorIndex;
const originalSpawnPerPixel = settings.spawnPerPixel;
const createPopulation = () => {
const pipeline = {
maxAgentCount: 10_000_000,
writeAgents: vi.fn(),
resizeAgents: vi.fn(),
compactAgents: vi.fn(),
} as unknown as AgentGenerationPipeline;
return new AgentPopulation(pipeline);
};
const setPopulationCounts = (
population: AgentPopulation,
activeCount: number,
targetBudget: number
) => {
Object.assign(population as unknown as Record<string, number>, {
activeCount,
targetBudget,
});
};
describe('AgentPopulation adaptive budget', () => {
beforeEach(() => {
settings.agentBudgetMax = 1_000_000;
settings.brushSize = 1;
settings.selectedColorIndex = 0;
settings.spawnPerPixel = 1;
});
afterEach(() => {
settings.agentBudgetMax = originalAgentBudgetMax;
settings.brushSize = originalBrushSize;
settings.selectedColorIndex = originalSelectedColorIndex;
settings.spawnPerPixel = originalSpawnPerPixel;
});
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
const population = createPopulation();
setPopulationCounts(population, 1_000_000, 1_000_000);
population.growBudget(1 / 60, 60, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
appConfig.simulation.globalAgentCap
);
});
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
const population = createPopulation();
setPopulationCounts(population, 1_000_000, 1_000_000);
population.growBudget(10, 50, 60);
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
expect(population.activeAgentCount).toBe(
appConfig.simulation.budget.adaptiveCapMin
);
});
});

View file

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

View file

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

View file

@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const gameLoopSource = readFileSync(
join(process.cwd(), 'src/game-loop/game-loop.ts'),
'utf8'
);
const getStartDrawingHandlerSource = () => {
const start = gameLoopSource.indexOf('onStartDrawing:');
const end = gameLoopSource.indexOf('onEraseGestureEnded:', start);
if (start < 0 || end < 0) {
throw new Error('Could not find the pointer drawing intro handler');
}
return gameLoopSource.slice(start, end);
};
describe('GameLoop intro drawing policy', () => {
it('allows drawing to start without completing the intro sequence', () => {
const handlerSource = getStartDrawingHandlerSource();
expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()');
expect(handlerSource).not.toContain('this.introPrompt.complete(');
});
});

View file

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

View file

@ -1,21 +0,0 @@
import { vec3 } from 'gl-matrix';
import { settings } from '../settings';
import { hsl } from '../utils/hsl';
import { Random } from '../utils/random';
const hues = [settings.startColorHue];
for (let i = 0; i < 100; i++) {
hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360);
}
const colors = hues.map((hue) =>
hsl(hue, Random.randomBetween(90, 100), Random.randomBetween(20, 30))
);
export class GamePresentation {
public static getGenerationColor(generation: number): vec3 {
return colors[generation % colors.length];
}
}

View file

@ -1,124 +0,0 @@
import { vec2 } from 'gl-matrix';
import { GenerationCounts } from '../pipelines/agents/agent-generation/generation-counts';
import { settings } from '../settings';
import { clamp, clamp01 } from '../utils/clamp';
import { mix } from '../utils/mix';
import { Random } from '../utils/random';
export interface SpawnAction {
generation: number;
position: vec2;
radius: number;
}
export class GameRules {
private static readonly DEFAULT_SPAWN_INTERVAL = 8;
private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
private static readonly DEFAULT_SPAWN_RADIUS = 20;
private lastSpawnTimeInSeconds = 0;
private currentSpawnInterval = 0;
private currentSpawnRadius = 0;
private lastGenerationChangeTimeInSeconds = 0;
public nextGenerationId = 1;
public generationCounts: {
currentGenerationCount: number;
nextGenerationCount: number;
} = {
currentGenerationCount: 0,
nextGenerationCount: 1,
};
public constructor(startingTimeInSeconds: number) {
this.lastSpawnTimeInSeconds = startingTimeInSeconds;
this.lastGenerationChangeTimeInSeconds = startingTimeInSeconds;
}
private lastSpawnAction: SpawnAction | undefined;
public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
if (
this.lastSpawnAction &&
timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
) {
return this.lastSpawnAction;
}
this.currentSpawnInterval = mix(
GameRules.DEFAULT_SPAWN_INTERVAL,
GameRules.DEFAULT_SPAWN_INTERVAL / 5,
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
);
this.currentSpawnRadius = mix(
GameRules.DEFAULT_SPAWN_RADIUS,
GameRules.DEFAULT_SPAWN_RADIUS * 3,
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
);
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
if (
timeInSeconds - this.lastSpawnTimeInSeconds < this.currentSpawnInterval ||
q > 0.05
) {
return {
generation: this.nextGenerationId,
position: vec2.create(),
radius: 0,
};
}
this.lastSpawnTimeInSeconds = timeInSeconds;
this.lastSpawnAction = {
generation: this.nextGenerationId,
position: vec2.fromValues(
Random.randomBetween(0, canvasSize[0]),
Random.randomBetween(0, canvasSize[1])
),
radius: this.currentSpawnRadius,
};
return this.lastSpawnAction;
}
public updateGenerationCounts({
evenGenerationCount,
oddGenerationCount,
}: GenerationCounts): void {
const nextGenerationCount =
this.nextGenerationId % 2 === 1 ? oddGenerationCount : evenGenerationCount;
const currentGenerationCount =
this.nextGenerationId % 2 === 1 ? evenGenerationCount : oddGenerationCount;
const q = currentGenerationCount / settings.agentCount;
if (currentGenerationCount <= 100 && q < 0.05) {
this.nextGenerationId++;
this.lastGenerationChangeTimeInSeconds = performance.now() / 1000;
}
this.generationCounts = {
currentGenerationCount,
nextGenerationCount,
};
}
public getNextGenerationMoveSpeed(): number {
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
return mix(settings.moveSpeed / 8, settings.moveSpeed, q ** 2);
}
public getInfectionProbability(): number {
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
return clamp(mix(0.3, 1, q * 5), 0, 0.9);
}
public getSensorOffset(): number {
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
return mix(20, settings.sensorOffsetDistance, q);
}
}

View file

@ -0,0 +1,277 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type PointerListener = (event: PointerEvent) => void;
const makePointerEvent = (
type: string,
event: Partial<PointerEvent> = {}
): PointerEvent =>
({
buttons: 1,
clientX: 10,
clientY: 20,
isTrusted: true,
pointerId: 1,
pointerType: 'mouse',
pressure: 0.5,
timeStamp: 100,
type,
...event,
}) as PointerEvent;
const toPoint = (point: ArrayLike<number>): Array<number> => Array.from(point);
class FakeCanvas {
public readonly capturedPointerIds: Array<number> = [];
public readonly releasedPointerIds: Array<number> = [];
public width = 300;
public height = 200;
private readonly listeners = new Map<string, Set<PointerListener>>();
public addEventListener(
type: string,
listener: EventListenerOrEventListenerObject
): void {
const listeners = this.listeners.get(type) ?? new Set<PointerListener>();
const pointerListener =
typeof listener === 'function'
? listener
: (event: Event) => listener.handleEvent(event);
listeners.add(pointerListener as PointerListener);
this.listeners.set(type, listeners);
}
public removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject
): void {
const listeners = this.listeners.get(type);
if (!listeners) {
return;
}
listeners.delete(listener as PointerListener);
}
public dispatchPointerEvent(type: string, event: Partial<PointerEvent> = {}): void {
const pointerEvent = makePointerEvent(type, event);
this.listeners.get(type)?.forEach((listener) => listener(pointerEvent));
}
public getBoundingClientRect(): DOMRect {
return {
bottom: this.height,
height: this.height,
left: 0,
right: this.width,
toJSON: () => ({}),
top: 0,
width: this.width,
x: 0,
y: 0,
} as DOMRect;
}
public setPointerCapture(pointerId: number): void {
this.capturedPointerIds.push(pointerId);
}
public releasePointerCapture(pointerId: number): void {
this.releasedPointerIds.push(pointerId);
}
}
const makeSwipePipeline = () => ({
addSwipeSegment: vi.fn(),
clearSwipes: vi.fn(),
});
const createPointerInput = async () => {
const { GardenPointerInput } = await import('./pointer-input');
const { settings: runtimeSettings } = await import('../settings');
const canvas = new FakeCanvas();
const audio = {
beginGesture: vi.fn(),
endGesture: vi.fn(),
start: vi.fn(),
stroke: vi.fn(),
touchDown: vi.fn(),
};
const brushPipeline = makeSwipePipeline();
const eraserAgentPipeline = makeSwipePipeline();
const eraserTexturePipeline = makeSwipePipeline();
const eraserPreview = {
isPointerInsideCanvas: vi.fn(() => true),
setEraseMode: vi.fn(),
setPointerHoveringCanvas: vi.fn(),
update: vi.fn(),
};
const onStartDrawing = vi.fn();
const onEraseGestureEnded = vi.fn();
const spawnStrokeAgents = vi.fn();
const input = new GardenPointerInput({
audio,
brushPipeline,
canvas: canvas as unknown as HTMLCanvasElement,
eraserAgentPipeline,
eraserPreview,
eraserTexturePipeline,
getCanvasSize: () => [canvas.width, canvas.height],
getDevicePixelRatio: () => 1,
getMirrorSegmentCount: () => 1,
onEraseGestureEnded,
onStartDrawing,
spawnStrokeAgents,
} as unknown as ConstructorParameters<typeof GardenPointerInput>[0]);
input.attach();
return {
audio,
brushPipeline,
canvas,
input,
onStartDrawing,
runtimeSettings,
spawnStrokeAgents,
};
};
describe('GardenPointerInput drawing startup', () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal('localStorage', {
clear: vi.fn(),
getItem: vi.fn(() => null),
removeItem: vi.fn(),
setItem: vi.fn(),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('allows pointer drawing immediately', async () => {
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 60,
clientY: 80,
pointerId: 7,
timeStamp: 120,
});
expect(onStartDrawing).toHaveBeenCalledTimes(1);
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(2);
expect(canvas.capturedPointerIds).toEqual([7]);
});
it('starts drawing from a fresh pointerdown', async () => {
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
expect(onStartDrawing).toHaveBeenCalledTimes(1);
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
expect(canvas.capturedPointerIds).toEqual([9]);
});
it('flushes the delayed smoothed stroke tail on pointerup', async () => {
const { brushPipeline, canvas } = await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 60,
clientY: 80,
pointerId: 9,
timeStamp: 120,
});
canvas.dispatchPointerEvent('pointerup', {
clientX: 60,
clientY: 80,
pointerId: 9,
timeStamp: 140,
});
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]);
});
it('uses coalesced pointer samples for smoother brush segments', async () => {
const { audio, brushPipeline, canvas, spawnStrokeAgents } =
await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
audio.stroke.mockClear();
brushPipeline.addSwipeSegment.mockClear();
spawnStrokeAgents.mockClear();
canvas.dispatchPointerEvent('pointermove', {
clientX: 40,
clientY: 20,
getCoalescedEvents: () => [
makePointerEvent('pointermove', {
clientX: 20,
clientY: 20,
pointerId: 9,
timeStamp: 110,
}),
makePointerEvent('pointermove', {
clientX: 30,
clientY: 20,
pointerId: 9,
timeStamp: 115,
}),
makePointerEvent('pointermove', {
clientX: 40,
clientY: 20,
pointerId: 9,
timeStamp: 120,
}),
],
pointerId: 9,
timeStamp: 120,
});
expect(audio.stroke).toHaveBeenCalledTimes(3);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(3);
expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
});
it('caps curve tessellation with the brush curve resolution setting', async () => {
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
runtimeSettings.brushCurveResolution = 2;
runtimeSettings.brushSize = 1;
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 10,
clientY: 60,
pointerId: 9,
timeStamp: 120,
});
canvas.dispatchPointerEvent('pointermove', {
clientX: 60,
clientY: 60,
pointerId: 9,
timeStamp: 140,
});
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
export interface CachedFloat32BufferWrite { interface CachedFloat32BufferWrite {
hasValue: boolean; hasValue: boolean;
previous: Float32Array; previous: Float32Array;
} }

View file

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

View file

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

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node", "@playwright/test"]
},
"include": ["playwright.config.ts", "e2e/**/*.ts"]
}