.
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
parent 39b0160064
commit 2347ecd201
71 changed files with 3799 additions and 1606 deletions

View file

@ -27,17 +27,33 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Typecheck browser tests
run: npm run typecheck:e2e
- name: Test
run: npm test
- name: Build
run: npm run build
- name: Browser tests
run: npm run test:e2e
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
- name: Copy build to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

2
.gitignore vendored
View file

@ -5,6 +5,8 @@ ts-node--*/
rss.xml
dist
playwright-report
test-results
# 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.
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"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#b7455e" />
<meta name="theme-color" content="#10151f" />
<meta
name="description"
content="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>
</head>
<body>
<body class="is-loading">
<main class="canvas-container">
<canvas
role="img"
@ -47,6 +47,25 @@
<div class="eraser-preview" aria-hidden="true"></div>
<div class="garden-prompt" aria-live="polite"></div>
<div class="loading-indicator" role="status" aria-live="polite">
<div class="loading-dots" aria-hidden="true">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
</div>
<div class="loading-status">Starting up&hellip;</div>
<div
class="loading-progress"
role="progressbar"
aria-label="Loading Fleeting Garden"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<div class="loading-progress-fill"></div>
</div>
</div>
<section class="errors-container">
<noscript>JavaScript is required for this website.</noscript>
</section>
@ -57,16 +76,20 @@
<section>
<h1>Fleeting Garden</h1>
<p>
Pick a vibe palette, draw with one of the three colours, and agents grow
organic paths from your strokes.
A living sketchpad where each stroke becomes a trail that agents follow,
branch from, and weave into the scene.
</p>
<p>
Your drawn paths persist until you erase them. Switching vibes recolours the
whole garden without clearing the scene.
Paint with the three colour swatches, carve space with the eraser, and raise
the mirror control when you want radial patterns instead of a single line.
</p>
<p>
Runs entirely on your GPU via WebGPU compute shaders &mdash; no servers, no
tracking, no analytics. Source on
Switch vibes to recolour the whole garden without clearing your drawing. Add
or mute the generated piano, restart for a blank canvas, or export the current
frame as a 4K image.
</p>
<p>
Built with WebGPU and running locally in your browser. Source on
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
>GitHub</a
>.

64
package-lock.json generated
View file

@ -14,6 +14,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@playwright/test": "^1.60.0",
"@tweakpane/core": "^2.0.5",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",
@ -1261,6 +1262,22 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@quansync/fs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@ -3461,6 +3478,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",

View file

@ -9,13 +9,17 @@
"build": "vite build",
"preview": "vite preview",
"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\"",
"format": "prettier --write \"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}\" \".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}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"typecheck": "tsc --noEmit",
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
"test": "vitest run",
"test:e2e": "npm run build && playwright test",
"test:e2e:ui": "npm run build && playwright test --ui",
"test:watch": "vitest",
"unused:check": "node scripts/check-unused-exports.mjs",
"generate-icons": "pwa-assets-generator",
"update": "ncu"
},
@ -40,6 +44,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@playwright/test": "^1.60.0",
"@tweakpane/core": "^2.0.5",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",

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">
<rect width="64" height="64" rx="14" fill="#b7455e" />
<circle cx="22" cy="26" r="9" fill="#fff" opacity="0.95" />
<circle cx="42" cy="32" r="11" fill="#fff" opacity="0.85" />
<circle cx="28" cy="44" r="7" fill="#fff" opacity="0.75" />
<defs>
<clipPath id="icon-clip">
<rect width="64" height="64" rx="14" />
</clipPath>
</defs>
<g clip-path="url(#icon-clip)">
<rect width="64" height="64" fill="#10151f" />
<path d="M0 64a32 32 0 0 1 64 0Z" fill="#ffd84d" />
<path
d="M32 34c1.2-7.2 4.8-12.3 10-16"
fill="none"
stroke="#10151f"
stroke-linecap="round"
stroke-width="8"
/>
<path
d="M32 34c1.2-7.2 4.8-12.3 10-16"
fill="none"
stroke="#ff2fa3"
stroke-linecap="round"
stroke-width="4"
/>
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff2fa3" />
<ellipse cx="48.5" cy="18" rx="6.4" ry="4.2" fill="#ff2fa3" />
<ellipse cx="42" cy="24.5" rx="4.2" ry="6.4" fill="#ff2fa3" />
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" fill="#ff2fa3" />
<circle cx="42" cy="18" r="3.2" fill="#10151f" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 950 B

Before After
Before After

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';
export type GardenAudioChordQuality = 'major' | 'minor';
type GardenAudioChordQuality = 'major' | 'minor';
export interface GardenAudioChord {
rootOffset: number;
quality: GardenAudioChordQuality;
}
export interface GardenAudioColorVoice {
interface GardenAudioColorVoice {
scaleDegreeOffset: number;
velocityMultiplier: number;
panOffset: number;

View file

@ -1,4 +1,4 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
@ -9,6 +9,8 @@ export class GardenAudioEnergy {
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
this.lastEnergyUpdateAt = now;
@ -46,15 +48,15 @@ export class GardenAudioEnergy {
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
-elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
);
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
let timeConstant = this.engineConfig.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
timeConstant = this.engineConfig.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = appConfig.audioEngine.energy.attackSeconds;
timeConstant = this.engineConfig.energy.attackSeconds;
}
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
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 { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
@ -14,7 +14,10 @@ export class GardenAudioGraph {
private delayOutput: GainNode | null = null;
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 {
if (this.context) {
@ -62,8 +65,8 @@ export class GardenAudioGraph {
const buffer = this.context.createBuffer(
1,
appConfig.audioEngine.graph.unlockBufferLength,
appConfig.audioEngine.graph.unlockSampleRate
this.engineConfig.graph.unlockBufferLength,
this.engineConfig.graph.unlockSampleRate
);
const source = this.context.createBufferSource();
source.buffer = buffer;
@ -92,7 +95,7 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.context.currentTime,
appConfig.audioEngine.graph.delayTimeRampSeconds
this.engineConfig.graph.delayTimeRampSeconds
);
}
@ -105,22 +108,22 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
now,
appConfig.audioEngine.graph.delayTimeRampSeconds
this.engineConfig.graph.delayTimeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
clamp(
this.config.delay.feedback +
activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
appConfig.audioEngine.graph.delayFeedbackMin,
appConfig.audioEngine.graph.delayFeedbackMax
activity * this.engineConfig.graph.delayActivityFeedbackWeight,
this.engineConfig.graph.delayFeedbackMin,
this.engineConfig.graph.delayFeedbackMax
),
now,
this.config.updateRampSeconds
);
this.delayOutput.gain.setTargetAtTime(
this.config.delay.wetGain *
(appConfig.audioEngine.graph.delayOutputBase +
activity * appConfig.audioEngine.graph.delayOutputActivityWeight),
(this.engineConfig.graph.delayOutputBase +
activity * this.engineConfig.graph.delayOutputActivityWeight),
now,
this.config.updateRampSeconds
);
@ -134,9 +137,9 @@ export class GardenAudioGraph {
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(
appConfig.audioEngine.graph.closeGain,
this.engineConfig.graph.closeGain,
context.currentTime,
appConfig.audioEngine.graph.closeRampSeconds
this.engineConfig.graph.closeRampSeconds
);
}
@ -171,7 +174,7 @@ export class GardenAudioGraph {
private createBuses(context: AudioContext, masterGain: GainNode): void {
this.eventBus = context.createGain();
this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
this.eventBus.connect(masterGain);
}
@ -181,9 +184,9 @@ export class GardenAudioGraph {
for (let index = 0; index < data.length; index++) {
data[index] =
appConfig.audioEngine.graph.noiseMin +
this.engineConfig.graph.noiseMin +
Math.random() *
(appConfig.audioEngine.graph.noiseMax - appConfig.audioEngine.graph.noiseMin);
(this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
}
return buffer;

View file

@ -1,4 +1,4 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioStroke } from './garden-audio-types';
@ -12,24 +12,25 @@ export interface GardenAudioStrokeMetrics {
export const getStrokeMetrics = (
stroke: GardenAudioStroke,
speedForFullEnergyPixelsPerSecond: number,
fallbackPressure: number
fallbackPressure: number,
inputConfig: GardenAudioEngineConfig['input']
): GardenAudioStrokeMetrics => {
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
const pressure = getPressureAmount(stroke, fallbackPressure);
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(
appConfig.audioEngine.input.strokeEnergyBase +
speedAmount * appConfig.audioEngine.input.strokeEnergySpeedWeight +
pressure * appConfig.audioEngine.input.strokeEnergyPressureWeight
inputConfig.strokeEnergyBase +
speedAmount * inputConfig.strokeEnergySpeedWeight +
pressure * inputConfig.strokeEnergyPressureWeight
);
const effectiveEnergy =
strokeEnergy *
(appConfig.audioEngine.input.distanceEnergyBase +
clamp01(distancePixels / appConfig.audioEngine.input.distanceForFullEnergyPixels) *
appConfig.audioEngine.input.distanceEnergyScale);
(inputConfig.distanceEnergyBase +
clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
inputConfig.distanceEnergyScale);
return {
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 (
stroke.velocityPixelsPerSecond !== undefined &&
Number.isFinite(stroke.velocityPixelsPerSecond) &&
@ -48,12 +53,13 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
return stroke.velocityPixelsPerSecond;
}
return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
return distancePixels / inputConfig.fallbackFrameSeconds;
};
const getPressureAmount = (
stroke: GardenAudioStroke,
fallbackPressure: number
fallbackPressure: number,
inputConfig: GardenAudioEngineConfig['input']
): number => {
if (
stroke.pressure !== undefined &&
@ -64,6 +70,6 @@ const getPressureAmount = (
}
return stroke.pointerType === 'pen'
? Math.max(appConfig.audioEngine.input.penMinPressure, clamp01(fallbackPressure))
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
: clamp01(fallbackPressure);
};

View file

@ -1,4 +1,3 @@
import { clamp } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
@ -10,9 +9,6 @@ import { GardenAudioColorIndex } from './garden-audio-types';
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
export const clampMidi = (midi: number, min: number, max: number): number =>
Math.round(clamp(midi, min, max));
export const getVibeProfile = (
config: GardenAudioConfig,
vibe: VibePreset
@ -21,15 +17,6 @@ export const getVibeProfile = (
config.vibes[config.fallbackVibeId] ??
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 = (
chord: GardenAudioChord,
openVoicing: boolean

View file

@ -6,6 +6,7 @@ export interface GardenAudioSnapshot {
vibe: VibePreset;
selectedColorIndex: number;
isErasing: boolean;
mirrorSegmentCount?: number;
}
export interface GardenAudioStroke {
@ -18,6 +19,15 @@ export interface GardenAudioStroke {
pressure?: number;
velocityPixelsPerSecond?: number;
eraserSizePixels?: number;
mirrorSegmentCount?: number;
pointerType?: string;
}
export interface GardenAudioTouchDown {
vibe: VibePreset;
colorIndex: number;
mirrorSegmentCount?: number;
pressure?: number;
pointerType?: string;
}

View file

@ -4,13 +4,14 @@ import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
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 type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
@ -20,6 +21,7 @@ export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
export class GardenAudio {
@ -35,6 +37,7 @@ export class GardenAudio {
private isMuted = false;
private isGestureActive = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
private hasQueuedPianoLoad = false;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -70,9 +73,21 @@ export class GardenAudio {
this.hasStarted = true;
this.applyVibe(vibe);
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 {
@ -108,19 +123,38 @@ export class GardenAudio {
this.isGestureActive = true;
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
const context = this.graph.context;
this.isGestureActive = false;
this.energy.endGesture();
this.pianoEngine.endGesture();
if (!context) {
}
public touchDown(touch: GardenAudioTouchDown): void {
if (this.isDestroyed || this.isMuted) {
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 {
@ -135,22 +169,14 @@ export class GardenAudio {
if (snapshot.isErasing) {
this.energy.silence();
this.piano.fadeActive(
context.currentTime,
appConfig.audioEngine.gestureFadeSeconds
);
this.updateDelay(snapshot);
return;
}
if (this.isGestureActive) {
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
activity: this.energy.getActivity(),
selectedColorIndex: this.selectedColorIndex,
});
}
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
selectedColorIndex: this.selectedColorIndex,
});
this.updateDelay(snapshot);
}
@ -183,9 +209,16 @@ export class GardenAudio {
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.pianoEngine.wake(now);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
activity: strokeEnergy,
selectedColorIndex: this.selectedColorIndex,
mirrorAmount,
});
}
public async destroy(): Promise<void> {
@ -199,6 +232,7 @@ export class GardenAudio {
this.hasStarted = false;
this.isGestureActive = false;
this.selectedColorIndex = 0;
this.hasQueuedPianoLoad = false;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
@ -285,5 +319,62 @@ export class GardenAudio {
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
this.pianoEngine.cue(this.graph.context.currentTime);
}
private getMirrorAmount(mirrorSegmentCount: number): number {
const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount);
const segmentCount = clamp(
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
1,
maxMirrorSegmentCount
);
if (maxMirrorSegmentCount <= 1) {
return 0;
}
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
}
private getStrokeMusicActivity(
stroke: GardenAudioStroke,
metrics: GardenAudioStrokeMetrics,
mirrorAmount: number
): number {
const speedRatio =
(stroke.velocityPixelsPerSecond ?? 0) /
Math.max(1, this.config.rhythm.speedForFullEnergyPixelsPerSecond);
const speedDrive = smoothstep(0.35, 1.1, speedRatio);
const speedOverdrive = smoothstep(1.15, 1.8, speedRatio);
const distanceDrive = smoothstep(10, 90, metrics.distancePixels);
const baseStroke = clamp01(
0.08 + speedDrive * 0.5 + metrics.pressure * 0.2 + distanceDrive * 0.22
);
const mirrorWild = smoothstep(0.45, 0.9, mirrorAmount);
const maniaDrive = speedOverdrive * smoothstep(0.62, 0.82, baseStroke);
const maniaBoost = maniaDrive * (0.18 + mirrorWild * 0.62);
return clamp01(
baseStroke * (0.68 + mirrorAmount * 0.3) +
0.025 +
mirrorAmount * 0.045 +
maniaBoost
);
}
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
return clamp01(pressure);
}
return pointerType === 'pen'
? Math.max(appConfig.audioEngine.input.penMinPressure, this.config.input.pressureFallback)
: this.config.input.pressureFallback;
}
}
const smoothstep = (edge0: number, edge1: number, value: number): number => {
const amount = clamp01((value - edge0) / (edge1 - edge0));
return amount * amount * (3 - 2 * amount);
};

View file

@ -14,104 +14,220 @@ const makeEngine = () => {
return { engine, notes };
};
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
const getBeatsPerBar = (): number =>
Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat);
const renderBars = (
engine: GenerativePianoEngine,
activity: number,
selectedColorIndex = 0,
bars = 4
bars = 8
) => {
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now: 0,
activity,
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
lookaheadSeconds:
(60 / gardenAudioConfig.rhythm.bpm) *
Math.round(
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
) *
bars,
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
});
};
const average = (values: Array<number>): number =>
values.reduce((sum, value) => sum + value, 0) / values.length;
const uniqueStartTimes = (notes: Array<PianoNote>): Array<string> =>
Array.from(new Set(notes.map((note) => note.startTime.toFixed(3))));
const countNotesBetween = (
notes: Array<PianoNote>,
startSeconds: number,
endSeconds: number
): number =>
notes.filter(
(note) => note.startTime >= startSeconds && note.startTime < endSeconds
).length;
describe('GenerativePianoEngine', () => {
it('does not emit notes below the sparse activity threshold', () => {
it('plays quiet background music even when the garden is idle', () => {
const { engine, notes } = makeEngine();
renderBars(engine, gardenAudioConfig.rhythm.sparseActivity - 0.01);
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);
renderBars(engine, 0);
expect(notes.length).toBeGreaterThan(0);
notes.forEach((note) => {
const beatsFromStart = (note.startTime - startDelaySeconds) / beatSeconds;
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe(
true
);
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16);
});
it('keeps the background sparse instead of filling every beat', () => {
const { engine, notes } = makeEngine();
renderBars(engine, 0, 1, 4);
expect(uniqueStartTimes(notes).length).toBeLessThan(8);
});
it('lets activity add density without changing the beat grid', () => {
const idle = makeEngine();
const active = makeEngine();
const startDelaySeconds = 0.02;
renderBars(idle.engine, 0, 1, 8);
renderBars(active.engine, 1, 1, 8);
expect(active.notes.length).toBeGreaterThan(idle.notes.length);
active.notes.forEach((note) => {
const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds();
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
});
});
it('varies density with activity without exceeding one note per beat', () => {
const low = makeEngine();
const high = makeEngine();
renderBars(low.engine, gardenAudioConfig.rhythm.sparseActivity + 0.03, 1);
renderBars(high.engine, 1, 1);
expect(high.notes.length).toBeGreaterThan(low.notes.length);
expect(high.notes.length).toBeLessThanOrEqual(16);
});
it('wakes every color with a prompt first note at low activity', () => {
it('uses color pools with multiple notes instead of one key per color', () => {
([0, 1, 2] as const).forEach((selectedColorIndex) => {
const { engine, notes } = makeEngine();
const now = 4;
engine.beginGesture(1);
engine.wake(now);
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: gardenAudioConfig.rhythm.sparseActivity + 0.01,
selectedColorIndex,
lookaheadSeconds: 0.08,
});
renderBars(engine, 1, selectedColorIndex, 16);
expect(notes).toHaveLength(1);
expect(notes[0].startTime).toBeCloseTo(now + 0.02);
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
});
});
it('uses different color roles for register and pan', () => {
const anchor = makeEngine();
const spark = makeEngine();
it('keeps the upper color higher and wider than the lower color', () => {
const lower = makeEngine();
const upper = makeEngine();
renderBars(anchor.engine, 1, 0);
renderBars(spark.engine, 1, 2);
renderBars(lower.engine, 1, 0, 16);
renderBars(upper.engine, 1, 2, 16);
expect(average(spark.notes.map((note) => note.midi))).toBeGreaterThan(
average(anchor.notes.map((note) => note.midi))
expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan(
average(lower.notes.map((note) => note.midi))
);
expect(average(spark.notes.map((note) => note.pan))).toBeGreaterThan(
average(anchor.notes.map((note) => note.pan))
expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan(
average(lower.notes.map((note) => note.pan))
);
});
it('is deterministic for the same phrase inputs', () => {
it('starts a fading brush phrase layer with each new brush gesture', () => {
const baseline = makeEngine();
const layered = makeEngine();
const now = 4;
baseline.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 1,
lookaheadSeconds: 12,
});
layered.engine.beginGesture();
layered.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 1,
mirrorAmount: 0.45,
});
layered.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 1,
lookaheadSeconds: 12,
});
const earlyExtra =
countNotesBetween(layered.notes, now + 1, now + 5) -
countNotesBetween(baseline.notes, now + 1, now + 5);
const lateExtra =
countNotesBetween(layered.notes, now + 10.5, now + 12) -
countNotesBetween(baseline.notes, now + 10.5, now + 12);
expect(earlyExtra).toBeGreaterThan(2);
expect(lateExtra).toBe(0);
});
it('makes brush phrase layers denser at higher mirror amounts', () => {
const lowMirror = makeEngine();
const highMirror = makeEngine();
const now = 4;
lowMirror.engine.beginGesture();
lowMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 0,
});
lowMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
highMirror.engine.beginGesture();
highMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 1,
});
highMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
});
it('plays one immediate touch note and throttles later stroke accents', () => {
const { engine, notes } = makeEngine();
const now = 4;
engine.beginGesture();
engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.9,
selectedColorIndex: 1,
});
engine.recordStroke({
vibe: VIBE_PRESETS[0],
now: now + 1,
activity: 0.95,
selectedColorIndex: 1,
});
expect(notes).toHaveLength(1);
expect(notes[0].startTime).toBe(now);
engine.recordStroke({
vibe: VIBE_PRESETS[0],
now: now + 6,
activity: 0.95,
selectedColorIndex: 1,
});
expect(notes).toHaveLength(2);
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1);
});
it('is deterministic for the same musical inputs', () => {
const first = makeEngine();
const second = makeEngine();
renderBars(first.engine, 0.78, 2);
renderBars(second.engine, 0.78, 2);
renderBars(first.engine, 0.78, 2, 16);
renderBars(second.engine, 0.78, 2, 16);
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 { NoiseBurst } from './garden-audio-types';
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 {
const { context, eventBus, noiseBuffer } = this.graph;
@ -12,7 +15,7 @@ export class NoiseBurstPlayer {
}
const scheduledStart = Math.max(
context.currentTime + appConfig.audioEngine.noiseBurst.scheduleAheadSeconds,
context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds,
startTime
);
const source = context.createBufferSource();
@ -24,17 +27,17 @@ export class NoiseBurstPlayer {
source.buffer = noiseBuffer;
filter.type = 'bandpass';
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
envelope.gain.setValueAtTime(
appConfig.audioEngine.noiseBurst.silentGain,
this.engineConfig.noiseBurst.silentGain,
scheduledStart
);
envelope.gain.exponentialRampToValueAtTime(
Math.max(appConfig.audioEngine.noiseBurst.silentGain, gain),
scheduledStart + appConfig.audioEngine.noiseBurst.attackSeconds
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(
appConfig.audioEngine.noiseBurst.silentGain,
this.engineConfig.noiseBurst.silentGain,
stopAt
);
panner.pan.setValueAtTime(pan, scheduledStart);
@ -45,7 +48,7 @@ export class NoiseBurstPlayer {
panner.connect(eventBus);
source.start(
scheduledStart,
Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds
);
source.stop(stopAt);
source.addEventListener(

View file

@ -1,4 +1,4 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
@ -12,6 +12,7 @@ export class PianoSampler {
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig,
private readonly graph: GardenAudioGraph
) {}
@ -51,31 +52,39 @@ export class PianoSampler {
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, eventBus, delayInput } = this.graph;
if (!context || !eventBus || this.samples.length === 0) {
if (!context || !eventBus) {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
this.playFallbackPluck({
midi,
velocity,
startTime,
durationSeconds,
pan,
delaySend,
lowpassHz,
});
return;
}
const scheduledStart = Math.max(
context.currentTime + appConfig.audioEngine.piano.scheduleAheadSeconds,
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
appConfig.audioEngine.piano.minGain,
this.engineConfig.piano.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds *
(appConfig.audioEngine.piano.sustainBase +
noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
(this.engineConfig.piano.sustainBase +
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(appConfig.audioEngine.piano.minDurationSeconds, durationSeconds);
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
@ -90,20 +99,18 @@ export class PianoSampler {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
this.engineConfig.piano.minGain,
scheduledStart,
appConfig.audioEngine.piano.voiceStealFadeSeconds
);
oldest?.source.stop(
scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
this.engineConfig.piano.voiceStealFadeSeconds
);
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
}
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(
2,
(midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
(midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave
),
scheduledStart
);
@ -111,30 +118,30 @@ export class PianoSampler {
filter.frequency.setValueAtTime(
clamp(
lowpassHz,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
),
scheduledStart
);
filter.Q.value = appConfig.audioEngine.piano.filterQ;
gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, scheduledStart);
filter.Q.value = this.engineConfig.piano.filterQ;
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + appConfig.audioEngine.piano.gainAttackSeconds
scheduledStart + this.engineConfig.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
appConfig.audioEngine.piano.minGain,
this.engineConfig.piano.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
appConfig.audioEngine.piano.minFadeSeconds,
sustainSeconds * appConfig.audioEngine.piano.sustainBase
this.engineConfig.piano.minFadeSeconds,
sustainSeconds * this.engineConfig.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
this.engineConfig.piano.minGain,
releaseAt,
releaseSeconds
);
@ -153,7 +160,7 @@ export class PianoSampler {
}
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 });
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 {
this.sampleLoadPromise = null;
this.samples = [];
@ -224,4 +196,90 @@ export class PianoSampler {
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
private playFallbackPluck({
midi,
velocity,
startTime,
durationSeconds,
pan,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, eventBus, delayInput } = this.graph;
if (!context || !eventBus) {
return;
}
const scheduledStart = Math.max(
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
startTime
);
const oscillator = context.createOscillator();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
this.engineConfig.piano.minGain,
this.config.piano.gain * noteVelocity * 0.42
);
const releaseAt =
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
const stopAt = releaseAt + this.config.piano.releaseSeconds;
oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(
440 * Math.pow(2, (midi - 69) / appConfig.audioEngine.piano.pitchSemitonesPerOctave),
scheduledStart
);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(
clamp(
lowpassHz * 0.72,
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
),
scheduledStart
);
filter.Q.value = this.engineConfig.piano.filterQ;
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.engineConfig.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
this.engineConfig.piano.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
oscillator.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend * 0.5;
panner.connect(sendGain);
sendGain.connect(delayInput);
}
oscillator.start(scheduledStart);
oscillator.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
oscillator.addEventListener(
'ended',
() => {
oscillator.disconnect();
filter.disconnect();
gain.disconnect();
panner.disconnect();
sendGain?.disconnect();
},
{ once: true }
);
}
}

View file

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

View file

@ -1,463 +1,23 @@
import type {
GardenAudioChord,
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';
import { runtimeSettings } from './config/runtime-settings';
import type { GardenAppConfig } from './config/types';
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
export type GardenRuntimeSettings = GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
export type {
AgentColorInteractionSettings,
GardenAppConfig,
GardenAudioEngineConfig,
GardenRuntimeSettings,
GardenSimulationConfig,
GardenStorageConfig,
GardenVibeSettings,
NumberControlConfig,
RuntimeSettingControlConfig,
VibePreset,
} from './config/types';
export type GardenVibeSettings = Partial<
Pick<
GardenRuntimeSettings,
| 'agentBudgetMax'
| 'brushSize'
| '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;
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: {
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: 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 = {
export const appConfig = {
audio: {
masterVolume: 0.32,
masterVolume: 0.42,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
highPassFrequencyHz: 45,
@ -470,26 +30,26 @@ export const appConfig: GardenAppConfig = {
releaseSeconds: 0.18,
},
delay: {
timeSeconds: 0.42,
timeSeconds: 0.46,
feedback: 0.12,
wetGain: 0.048,
wetGain: 0.044,
},
piano: {
maxVoices: 32,
gain: 0.42,
sustainSeconds: 0.52,
sustainLevel: 0.34,
releaseSeconds: 0.16,
lowpassHz: 9000,
maxVoices: 24,
gain: 0.48,
sustainSeconds: 0.42,
sustainLevel: 0.32,
releaseSeconds: 0.24,
lowpassHz: 7600,
},
input: {
pressureFallback: 0.48,
},
rhythm: {
bpm: 82,
bpm: 74,
stepsPerBeat: 4,
stepsPerBar: 16,
lookaheadSeconds: 0.18,
lookaheadSeconds: 0.3,
speedForFullEnergyPixelsPerSecond: 1800,
sparseActivity: 0.055,
},
@ -540,7 +100,6 @@ export const appConfig: GardenAppConfig = {
delay: {
erasingActivity: 0.12,
},
gestureFadeSeconds: 1.35,
graph: {
closeGain: 0.0001,
closeRampSeconds: 0.015,
@ -576,9 +135,6 @@ export const appConfig: GardenAppConfig = {
silentGain: 0.0001,
},
piano: {
fadeStopExtraSeconds: 0.05,
defaultFadeSeconds: 0.9,
fadeTimeConstantRatio: 0.3,
filterQ: 0.7,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
@ -630,190 +186,12 @@ export const appConfig: GardenAppConfig = {
workgroupSize: 64,
},
},
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,
},
},
},
runtimeSettings,
simulation: {
budget: {
fpsHeadroom: 0.82,
adaptiveCapDecreaseAgentsPerSecond: 50_000,
adaptiveCapMin: 500_000,
fpsHeadroom: 0.95,
fpsSmoothingNew: 0.06,
fpsSmoothingRetain: 0.94,
initialTargetAgentBudget: 20_000,
@ -821,7 +199,7 @@ export const appConfig: GardenAppConfig = {
refreshTargetDecay: 0.995,
},
brushEffectFramesPerSecond: 60,
globalAgentCap: 1_000_000,
globalAgentCap: 10_000_000,
initialAgentCount: 180_000,
intro: {
angleJitterRadians: Math.PI * 0.08,
@ -855,7 +233,6 @@ export const appConfig: GardenAppConfig = {
titleStrokeWidthRatio: 0.11,
verticalAnchor: 0.47,
},
introCameraZoom: 0.12,
introMoveSpeedBaseMultiplier: 1.8,
introMoveSpeedProgressMultiplier: 0.35,
maxMirrorSegmentCount: 12,
@ -912,4 +289,4 @@ export const appConfig: GardenAppConfig = {
defaultVibeId,
presets: vibePresets,
},
};
} satisfies GardenAppConfig;

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

@ -12,11 +12,15 @@ const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
export class AgentPopulation {
private activeCount = 0;
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
private replacementCursor = 0;
private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
private isCompacting = false;
private readonly strokeAgentData = new Float32Array(
@ -38,6 +42,7 @@ export class AgentPopulation {
}
public initializeIntroAgents(canvasSize: vec2): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.targetBudget = Math.min(
this.pipeline.maxAgentCount,
settings.agentBudgetMax,
@ -53,6 +58,7 @@ export class AgentPopulation {
}
public onVibeChanged(): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.targetBudget = Math.min(
this.targetBudget,
settings.agentBudgetMax,
@ -65,7 +71,9 @@ export class AgentPopulation {
smoothedFps: number,
refreshTargetFps: number
): void {
const cap = Math.min(settings.agentBudgetMax, this.pipeline.maxAgentCount);
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
const cap = this.clampAdaptiveCap(settings.agentBudgetMax);
if (
this.targetBudget < cap &&
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
@ -147,6 +155,8 @@ export class AgentPopulation {
}
const count = data.length / AGENT_FLOAT_COUNT;
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, this.targetBudget - this.activeCount);
const appendCount = Math.min(count, available);
@ -178,4 +188,60 @@ export class AgentPopulation {
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 { RuntimeError } from '../utils/error-handler';
export const EXPORT_4K_WIDTH = appConfig.export4k.width;
export const EXPORT_4K_HEIGHT = appConfig.export4k.height;
const EXPORT_4K_WIDTH = appConfig.export4k.width;
const EXPORT_4K_HEIGHT = appConfig.export4k.height;
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
@ -11,7 +11,7 @@ const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
export interface Export4KMemoryEstimate {
interface Export4KMemoryEstimate {
width: number;
height: number;
bytesPerPixel: number;
@ -26,18 +26,18 @@ export interface Export4KMemoryEstimate {
estimatedPeakBytes: number;
}
export interface Export4KDimensions {
interface Export4KDimensions {
width: number;
height: number;
}
export interface BrowserMemoryInfo {
interface BrowserMemoryInfo {
deviceMemoryBytes?: number;
jsHeapSizeLimitBytes?: number;
usedJsHeapSizeBytes?: number;
}
export interface Export4KPreflightOptions {
interface Export4KPreflightOptions {
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
memoryInfo?: BrowserMemoryInfo;
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 {
private static readonly MAX_MIRROR_SEGMENT_COUNT =
appConfig.simulation.maxMirrorSegmentCount;
private static readonly DEV_STATS_INTERVAL_MS = 250;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig);
@ -29,10 +30,12 @@ export default class GameLoop {
private readonly agentPopulation: AgentPopulation;
private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance();
private readonly devStatsElement: HTMLDivElement | null = null;
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly keydownListener: (event: KeyboardEvent) => void;
private lastDevStatsUpdateAt = 0;
private hasFinished = false;
private readonly finished = Promise.withResolvers<void>();
@ -43,6 +46,9 @@ export default class GameLoop {
ui: GardenUi
) {
this.resize();
if (import.meta.env.DEV) {
this.devStatsElement = this.createDevStatsElement();
}
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
@ -58,10 +64,7 @@ export default class GameLoop {
getCanvasSize: () => this.canvasSize,
getDevicePixelRatio: () => this.devicePixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => {
this.introPrompt.markStartedDrawing();
this.introPrompt.complete();
},
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
@ -133,6 +136,7 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener);
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
this.devStatsElement?.remove();
this.introPrompt.destroy();
this.resources.destroy();
await this.audio.destroy();
@ -159,7 +163,7 @@ export default class GameLoop {
const scaledTime = time * settings.renderSpeed;
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const cameraZoom = 1 + (1 - introProgress) * appConfig.simulation.introCameraZoom;
const cameraZoom = 1;
const cameraCenter: [number, number] = [
this.canvas.width / 2,
this.canvas.height / 2,
@ -172,6 +176,7 @@ export default class GameLoop {
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
mirrorSegmentCount: this.mirrorSegmentCount,
});
this.resources.setFrameParameters({
@ -205,6 +210,7 @@ export default class GameLoop {
devicePixelRatio: this.devicePixelRatio,
renderSpeed: settings.renderSpeed,
});
this.updateDevStats(time);
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
@ -213,6 +219,42 @@ export default class GameLoop {
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 {
const width = Math.max(
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 {
private static readonly MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED = 0.25;
private static readonly MIN_CURVE_SEGMENT_SPACING_PIXELS = 4;
private static readonly CURVE_SEGMENT_BRUSH_RADIUS_RATIO = 0.65;
private activePointerId: number | null = null;
private lastPointerPosition: vec2 | null = null;
private lastPointerEventTimeMs: number | null = null;
private lastPointerPressure = 0.5;
private smoothedStrokePoints: Array<vec2> = [];
private lastSmoothedBrushPosition: vec2 | null = null;
private isErasing = false;
public constructor(private readonly options: GardenPointerInputOptions) {}
@ -75,6 +81,14 @@ export class GardenPointerInput {
if (this.lastPointerPosition !== null) {
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
}
this.smoothedStrokePoints.forEach((point) => {
vec2.mul(point, point, scale);
});
if (this.lastSmoothedBrushPosition !== null) {
vec2.mul(this.lastSmoothedBrushPosition, this.lastSmoothedBrushPosition, scale);
}
}
public get isSwipeActive(): boolean {
@ -98,6 +112,13 @@ export class GardenPointerInput {
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
this.options.audio.beginGesture();
this.options.audio.touchDown({
vibe: activeVibe,
colorIndex: settings.selectedColorIndex,
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
pressure: this.getPointerPressure(event),
pointerType: event.pointerType,
});
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
@ -106,8 +127,9 @@ export class GardenPointerInput {
this.options.eraserTexturePipeline.clearSwipes();
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke();
this.lastPointerPressure = this.getPointerPressure(event);
this.addSwipeAt(event);
this.addSwipeAt(event, { emitAudio: false });
};
private readonly onPointerMove = (event: PointerEvent) => {
@ -115,7 +137,9 @@ export class GardenPointerInput {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
this.getCoalescedPointerEvents(event).forEach((coalescedEvent) => {
this.addSwipeAt(coalescedEvent);
});
};
private readonly onPointerUp = (event: PointerEvent) => {
@ -123,6 +147,7 @@ export class GardenPointerInput {
return;
}
this.addSwipeAt(event, { emitAudio: false });
this.finishSmoothedStroke();
this.options.audio.endGesture();
if (this.isErasing) {
this.options.onEraseGestureEnded();
@ -131,6 +156,7 @@ export class GardenPointerInput {
this.activePointerId = null;
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke();
this.options.eraserPreview.setPointerHoveringCanvas(
this.options.eraserPreview.isPointerInsideCanvas(event)
);
@ -169,14 +195,14 @@ export class GardenPointerInput {
? [{ from: previousPosition, to: 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.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
} else {
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
}
});
});
} else {
this.addSmoothedBrushSample(position);
}
if (!this.isErasing) {
segments.forEach((segment) => {
@ -194,6 +220,7 @@ export class GardenPointerInput {
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
velocityPixelsPerSecond,
eraserSizePixels: settings.eraserSize * devicePixelRatio,
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
pointerType: event.pointerType,
});
}
@ -201,6 +228,113 @@ export class GardenPointerInput {
this.lastPointerEventTimeMs = event.timeStamp;
}
private addSmoothedBrushSample(position: vec2): void {
const previousSample =
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
if (
previousSample !== undefined &&
vec2.squaredDistance(previousSample, position) <=
GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
) {
return;
}
this.smoothedStrokePoints.push(vec2.clone(position));
if (this.smoothedStrokePoints.length > 3) {
this.smoothedStrokePoints.shift();
}
if (this.smoothedStrokePoints.length === 1) {
this.addMirroredBrushSegment(position, position);
this.lastSmoothedBrushPosition = vec2.clone(position);
return;
}
if (this.smoothedStrokePoints.length === 2) {
const [start, end] = this.smoothedStrokePoints;
const midpoint = getMidpoint(start, end);
this.addMirroredBrushSegment(start, midpoint);
this.lastSmoothedBrushPosition = midpoint;
return;
}
const [start, control, end] = this.smoothedStrokePoints;
const curveStart = getMidpoint(start, control);
const curveEnd = getMidpoint(control, end);
this.addQuadraticBrushSegments(curveStart, control, curveEnd);
this.lastSmoothedBrushPosition = curveEnd;
}
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
const brushRadius = Math.max(1, settings.brushSize / 2);
const segmentSpacing = Math.max(
GardenPointerInput.MIN_CURVE_SEGMENT_SPACING_PIXELS,
brushRadius * GardenPointerInput.CURVE_SEGMENT_BRUSH_RADIUS_RATIO
);
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
const curveResolution = getBrushCurveResolution();
const maxCurveSegments = Math.max(
1,
Math.floor(curveResolution / Math.sqrt(mirrorSegmentCount))
);
const segmentCount = Math.min(
maxCurveSegments,
Math.max(1, Math.ceil(curveLength / segmentSpacing))
);
let previousPoint = start;
for (let i = 1; i <= segmentCount; i++) {
const point = getQuadraticPoint(start, control, end, i / segmentCount);
this.addMirroredBrushSegment(previousPoint, point);
previousPoint = point;
}
}
private addMirroredBrushSegment(from: vec2, to: vec2): void {
this.getMirroredStrokeSegments(from, to).forEach((segment) => {
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
});
}
private finishSmoothedStroke(): void {
if (this.isErasing || this.smoothedStrokePoints.length === 0) {
return;
}
const finalSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
if (
this.lastSmoothedBrushPosition !== null &&
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
) {
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
}
}
private clearSmoothedStroke(): void {
this.smoothedStrokePoints.length = 0;
this.lastSmoothedBrushPosition = null;
}
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {
const getCoalescedEvents = (
event as PointerEvent & { getCoalescedEvents?: () => Array<PointerEvent> }
).getCoalescedEvents;
const coalescedEvents =
typeof getCoalescedEvents === 'function' ? getCoalescedEvents.call(event) : [];
if (coalescedEvents.length === 0) {
return [event];
}
const lastEvent = coalescedEvents[coalescedEvents.length - 1];
return isSamePointerSample(lastEvent, event)
? coalescedEvents
: [...coalescedEvents, event];
}
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> {
const segmentCount = this.options.getMirrorSegmentCount();
if (segmentCount <= 1) {
@ -246,3 +380,27 @@ const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
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 { SimulationTextures } from './simulation-textures';
export interface SimulationFramePipelines {
interface SimulationFramePipelines {
copyPipeline: CopyPipeline;
agentPipeline: AgentPipeline;
brushPipeline: BrushPipeline;

View file

@ -45,9 +45,9 @@ const selectorExists = (selector: string) => {
};
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(
indexSource.matchAll(/document\.querySelector(?:All)?\(\s*'([^']+)'\s*\)/g),
indexSource.matchAll(/queryRequiredElements?\(\s*'([^']+)'\s*,/g),
(match) => match[1]
);

View file

@ -4,4 +4,6 @@
@use 'style/control-dock';
@use 'style/toolbar';
@use 'style/panels';
@use 'style/config-pane';
@use 'style/loading';
@use 'style/motion';

View file

@ -8,7 +8,9 @@ import { ConfigPane } from './page/config-pane';
import { FullScreenHandler } from './page/full-screen-handler';
import { MenuHider } from './page/menu-hider';
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
import { ErrorHandler, Severity } from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu';
import { VIBE_PRESETS } from './vibes';
@ -37,10 +39,13 @@ const getMirrorSegmentRatio = (count: number): number =>
(count - appConfig.toolbar.mirror.min) /
(appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min);
const mirrorSegmentNames: Readonly<Record<number, string>> =
appConfig.toolbar.mirror.names;
const formatMirrorSegmentCount = (count: number): string =>
count === appConfig.toolbar.mirror.default
? 'Mirror off'
: `${count} ${appConfig.toolbar.mirror.names[count] ?? 'slices'}`;
: `${count} ${mirrorSegmentNames[count] ?? 'slices'}`;
const renderRuntimeMessage = (
container: HTMLElement,
@ -63,40 +68,52 @@ const renderRuntimeMessage = (
};
const elements = {
aside: document.querySelector('aside') as HTMLDivElement,
infoButton: document.querySelector('button.info') as HTMLButtonElement,
infoElement: document.querySelector('.info-page') as HTMLDivElement,
minimizeFullScreenButton: document.querySelector(
'button.minimize-full-screen'
) as HTMLButtonElement,
maximizeFullScreenButton: document.querySelector(
'button.maximize-full-screen'
) as HTMLButtonElement,
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
soundButton: document.querySelector('button.sound') as HTMLButtonElement,
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
canvas: document.querySelector('canvas') as HTMLCanvasElement,
eraserPreview: document.querySelector('.eraser-preview') as HTMLDivElement,
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
previousVibe: document.querySelector('.previous-vibe') as HTMLButtonElement,
nextVibe: document.querySelector('.next-vibe') as HTMLButtonElement,
swatches: Array.from(
document.querySelectorAll('.color-swatch')
) as Array<HTMLButtonElement>,
eraserSizeControl: document.querySelector('.eraser-size-control') as HTMLLabelElement,
eraserSizeSlider: document.querySelector('.eraser-size-slider') as HTMLInputElement,
mirrorSegmentControl: document.querySelector(
'.mirror-segment-control'
) as HTMLLabelElement,
mirrorSegmentSlider: document.querySelector(
'.mirror-segment-slider'
) as HTMLInputElement,
export4k: document.querySelector('.export-4k') as HTMLButtonElement,
exportStatus: document.querySelector('.export-status') as HTMLSpanElement,
prompt: document.querySelector('.garden-prompt') as HTMLDivElement,
aside: queryRequiredElement('aside', HTMLDivElement),
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
infoElement: queryRequiredElement('.info-page', HTMLDivElement),
minimizeFullScreenButton: queryRequiredElement(
'button.minimize-full-screen',
HTMLButtonElement
),
maximizeFullScreenButton: queryRequiredElement(
'button.maximize-full-screen',
HTMLButtonElement
),
settingsButton: queryRequiredElement('button.settings', HTMLButtonElement),
soundButton: queryRequiredElement('button.sound', HTMLButtonElement),
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
errorContainer: queryRequiredElement('.errors-container', HTMLDivElement),
previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement),
nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement),
swatches: queryRequiredElements('.color-swatch', HTMLButtonElement),
eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement),
eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement),
mirrorSegmentControl: queryRequiredElement(
'.mirror-segment-control',
HTMLLabelElement
),
mirrorSegmentSlider: queryRequiredElement(
'.mirror-segment-slider',
HTMLInputElement
),
export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement),
loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
};
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) => {
elements.soundButton.classList.toggle('muted', isAudioMuted);
@ -185,6 +202,7 @@ const main = async () => {
ErrorHandler.addOnErrorListener((error, _metadata) => {
renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) {
document.body.classList.remove('is-loading');
game?.destroy();
shouldStop = true;
}
@ -223,7 +241,8 @@ const main = async () => {
() =>
FullScreenHandler.isInFullScreenMode() &&
!configPane.isOpen &&
!infoPageHandler.isOpen
!infoPageHandler.isOpen,
{ persistentElement: elements.settingsButton }
);
new FullScreenHandler(
elements.minimizeFullScreenButton,
@ -232,13 +251,16 @@ const main = async () => {
);
const fontsReady = document.fonts.ready.catch(() => undefined);
setLoadingStage('Connecting to GPU…', 0.1);
const gpu = await initializeGpu();
setLoadingStage('Loading fonts…', 0.4);
await fontsReady;
setLoadingStage('Compiling shaders…', 0.7);
elements.restartButton.addEventListener('click', () => game?.destroy());
elements.soundButton.addEventListener('click', (event) => {
isAudioMuted = !isAudioMuted;
localStorage.setItem(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
renderAudioUi(game);
if (!isAudioMuted) {
game?.startAudio(event.isTrusted);
@ -323,6 +345,7 @@ const main = async () => {
renderMirrorSegmentUi();
renderAudioUi(game);
let isFirstStart = true;
while (!shouldStop) {
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
prompt: elements.prompt,
@ -334,9 +357,18 @@ const main = async () => {
renderMirrorSegmentUi();
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) {
document.body.classList.remove('is-loading');
ErrorHandler.addException(e);
console.error(e);
}

View file

@ -9,6 +9,32 @@ import { activeVibe, settings } from '../settings';
import { VIBE_PRESETS } from '../vibes';
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
const colorReactionRows = [
{
colorIndex: 0,
label: '1',
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
},
{
colorIndex: 1,
label: '2',
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
},
{
colorIndex: 2,
label: '3',
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
},
] as const;
const colorReactionKeySet = new Set<string>(
colorReactionRows.flatMap((row) => [...row.keys])
);
const isColorReactionKey = (key: string): key is ColorReactionKey =>
colorReactionKeySet.has(key);
interface ConfigPaneOptions {
onConfigChange: () => void;
@ -37,6 +63,14 @@ const toLabel = (value: string): string =>
.trim();
const normalizeNumber = (value: number, config: NumberControlConfig): number => {
if (config.options) {
const optionValues = Object.values(config.options);
if (optionValues.includes(value)) {
return value;
}
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min);
}
const finiteValue = Number.isFinite(value) ? value : config.min;
const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
return config.integer ? Math.round(clampedValue) : clampedValue;
@ -49,12 +83,21 @@ const getNumberBindingParams = (
label: config.label ?? toLabel(key),
min: config.min,
max: config.max,
options: config.options,
step: config.step,
});
export class ConfigPane {
private readonly container: HTMLDivElement;
private readonly pane: Pane;
private readonly colorReactionSelects = new Map<
ColorReactionKey,
HTMLSelectElement
>();
private readonly colorReactionSwatches: Array<{
colorIndex: number;
element: HTMLElement;
}> = [];
private readonly state = {
activeVibeId: activeVibe.id,
};
@ -106,6 +149,7 @@ export class ConfigPane {
public refresh(): void {
this.state.activeVibeId = activeVibe.id;
this.pane.refresh();
this.syncColorReactionMatrix();
this.syncButton();
}
@ -148,7 +192,19 @@ export class ConfigPane {
.on('click', () => this.options.onRestart());
const folders = new Map<string, PaneContainer>();
let hasAddedColorReactionMatrix = false;
Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
const settingKey = key as keyof GardenRuntimeSettings & string;
settings[settingKey] = normalizeNumber(settings[settingKey], config);
if (isColorReactionKey(key)) {
if (!hasAddedColorReactionMatrix) {
this.addColorReactionMatrix(container);
hasAddedColorReactionMatrix = true;
}
return;
}
const folder =
folders.get(config.folder) ??
container.addFolder({
@ -157,8 +213,6 @@ export class ConfigPane {
});
folders.set(config.folder, folder);
const settingKey = key as keyof GardenRuntimeSettings & string;
settings[settingKey] = normalizeNumber(settings[settingKey], config);
folder
.addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
.on('change', () => {
@ -170,6 +224,117 @@ export class ConfigPane {
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 {
@ -252,6 +417,7 @@ export class ConfigPane {
}
private syncButton(): void {
this.options.settingsButton.classList.toggle('active', this.isOpen);
this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen));
this.options.settingsButton.setAttribute(
'aria-label',

View file

@ -32,12 +32,11 @@ export class FullScreenHandler {
return document.fullscreenElement !== null;
}
private updateButtons() {
this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
? 'block'
: 'none';
this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
? 'none'
: 'block';
private updateButtons(): void {
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
this.minimizeButton.style.display = isInFullScreenMode ? 'block' : 'none';
this.maximizeButton.style.display = isInFullScreenMode ? 'none' : 'block';
this.minimizeButton.classList.toggle('active', isInFullScreenMode);
this.maximizeButton.classList.toggle('active', isInFullScreenMode);
}
}

View file

@ -1,17 +1,33 @@
import { appConfig } from '../config';
interface MenuHiderOptions {
persistentElement?: HTMLElement;
}
export class MenuHider {
private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs;
private static readonly INTERVAL = appConfig.menuHider.intervalMs;
private static readonly BOTTOM_REVEAL_DISTANCE =
appConfig.menuHider.bottomRevealDistancePx;
private readonly interactiveElements: Array<HTMLElement>;
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
private isHidden = false;
public constructor(
private readonly element: HTMLElement,
private readonly shouldBeHidden: () => boolean
private readonly 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(() => {
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
this.updateVisibility();
@ -61,8 +77,31 @@ export class MenuHider {
this.isHidden = shouldHide;
this.element.classList.toggle('menu-hidden', shouldHide);
this.element.style.opacity = shouldHide ? '0' : '1';
this.element.setAttribute('aria-hidden', String(shouldHide));
this.element.inert = shouldHide;
this.syncAccessibility(shouldHide);
}
private syncAccessibility(shouldHide: boolean): void {
const persistentElement = this.options.persistentElement;
if (!persistentElement) {
this.element.style.opacity = shouldHide ? '0' : '1';
this.element.setAttribute('aria-hidden', String(shouldHide));
this.element.inert = shouldHide;
return;
}
this.element.style.opacity = '';
this.element.setAttribute('aria-hidden', 'false');
this.element.inert = false;
this.interactiveElements.forEach((interactiveElement) => {
const isPersistentElement = interactiveElement === persistentElement;
interactiveElement.inert = shouldHide && !isPersistentElement;
interactiveElement.toggleAttribute(
'aria-hidden',
shouldHide && !isPersistentElement
);
});
}
}

View file

@ -1,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_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 {
private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 8;
private static readonly UNIFORM_COUNT = 17;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
@ -58,6 +58,15 @@ export class AgentPipeline {
sensorOffsetDistance,
turnWhenLost,
individualTrailWeight,
color1ToColor1,
color1ToColor2,
color1ToColor3,
color2ToColor1,
color2ToColor2,
color2ToColor3,
color3ToColor1,
color3ToColor2,
color3ToColor3,
agentCount,
introProgress,
}: AgentSettings & {
@ -74,6 +83,15 @@ export class AgentPipeline {
this.uniformValues[5] = individualTrailWeight;
this.uniformValues[6] = agentCount;
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(
this.device,
this.uniforms,

View file

@ -1,4 +1,13 @@
export interface AgentSettings {
color1ToColor1: number;
color1ToColor2: number;
color1ToColor3: number;
color2ToColor1: number;
color2ToColor2: number;
color2ToColor3: number;
color3ToColor1: number;
color3ToColor2: number;
color3ToColor3: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;

View file

@ -7,6 +7,15 @@ struct Settings {
individualTrailWeight: f32,
agentCount: 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;
@ -58,17 +67,19 @@ fn main(
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
let channelMask = get_channel_mask(agent.colorIndex);
let friendForward = dot(trailForward.rgb, channelMask);
let friendLeft = dot(trailLeft.rgb, channelMask);
let friendRight = dot(trailRight.rgb, channelMask);
let reactionMask = get_reaction_mask(agent.colorIndex);
let sourceForward = dot(sourceForwardSample.rgb, channelMask);
let sourceLeft = dot(sourceLeftSample.rgb, channelMask);
let sourceRight = dot(sourceRightSample.rgb, channelMask);
let trailForwardWeight = dot(trailForward.rgb, reactionMask);
let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
let trailRightWeight = dot(trailRight.rgb, reactionMask);
let weightForward = friendForward + sourceForward * 24.0;
let weightLeft = friendLeft + sourceLeft * 24.0;
let weightRight = friendRight + sourceRight * 24.0;
let sourceForwardWeight = dot(sourceForwardSample.rgb, reactionMask);
let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
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;
if weightForward >= weightLeft && weightForward >= weightRight {
@ -78,7 +89,8 @@ fn main(
}
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 introTargetOffset = vec2<f32>(0.0, 0.0);
var introTargetDistance = 0.0;
@ -111,7 +123,7 @@ fn main(
}
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);
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
trailBelow = vec4<f32>(
@ -145,6 +157,28 @@ fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
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 {
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
}

View file

@ -1,5 +1,6 @@
export interface BrushSettings {
brushSize: number;
brushCurveResolution: number;
eraserSize: number;
mirrorSegmentCount: number;
brushSizeVariation: number;

View file

@ -110,6 +110,15 @@ describe('WGSL uniform layout contracts', () => {
'individualTrailWeight',
'agentCount',
'introProgress',
'color1ToColor1',
'color1ToColor2',
'color1ToColor3',
'color2ToColor1',
'color2ToColor2',
'color2ToColor3',
'color3ToColor1',
'color3ToColor2',
'color3ToColor3',
],
});
expectStructUniformLayout({

View file

@ -1,4 +1,5 @@
import { appConfig, type GardenRuntimeSettings } from './config';
import { writeBrowserStorage } from './utils/browser-storage';
import { getInitialVibe, VIBE_PRESETS, type VibePreset } from './vibes';
const buildInitialValues = (vibe: VibePreset): GardenRuntimeSettings => ({
@ -32,7 +33,7 @@ export const applyVibeSettings = (vibeId: string) => {
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
});
localStorage.setItem(appConfig.storage.vibeKey, vibe.id);
writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);
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 {
position: absolute;
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);
z-index: 4;
width: min(calc(100vw - 1rem), 980px);
transform: translateX(-50%);
transform: translate(-50%, 0);
translate: 0 0;
visibility: visible;
pointer-events: none;
transition:
opacity var(--transition-time-long),
transform var(--transition-time-long),
translate var(--transition-time-long),
visibility 0s;
> .toolbar-row,
@ -32,4 +34,32 @@ html > body > aside.control-dock {
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;
}
&.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 {
max-height: 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_UNCAPTURED_ERROR = 'webgpu-uncaptured-error',
WEBGPU_DEVICE_LOST = 'webgpu-device-lost',
DOM_ELEMENT_MISSING = 'dom-element-missing',
}
type ErrorMetadataPrimitive = string | number | boolean | null;
export type ErrorMetadataValue =
type ErrorMetadataValue =
| ErrorMetadataPrimitive
| Array<ErrorMetadataValue>
| { [key: string]: ErrorMetadataValue };
export type ErrorMetadata = { [key: string]: ErrorMetadataValue };
type ErrorMetadata = { [key: string]: ErrorMetadataValue };
export interface RuntimeErrorOptions {
interface RuntimeErrorOptions {
cause?: unknown;
details?: Record<string, unknown>;
}
@ -48,19 +49,19 @@ export class RuntimeError extends Error {
}
}
export interface ErrorHandlerError {
interface ErrorHandlerError {
severity: Severity;
message: string;
code?: ErrorCode | string;
details?: ErrorMetadata;
}
export interface ErrorHandlerErrorOptions {
interface ErrorHandlerErrorOptions {
code?: ErrorCode | string;
details?: Record<string, unknown>;
}
export interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
fallbackMessage?: string;
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;
previous: Float32Array;
}

View file

@ -4,6 +4,17 @@ import { gardenAudioConfig } from './audio/garden-audio-config';
import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
const originalLocalStorage = globalThis.localStorage;
const colorInteractionKeys = [
'color1ToColor1',
'color1ToColor2',
'color1ToColor3',
'color2ToColor1',
'color2ToColor2',
'color2ToColor3',
'color3ToColor1',
'color3ToColor2',
'color3ToColor3',
] as const;
const setBrowserVibeState = ({
storedVibeId = null,
@ -85,4 +96,15 @@ describe('vibe and audio config contract', () => {
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 { readBrowserStorage } from './utils/browser-storage';
export type { GardenVibeSettings, VibePreset } from './config';
@ -14,7 +15,7 @@ export const hexToRgb = (hex: string): [number, number, number] => {
};
export const getInitialVibe = (): VibePreset => {
const id = localStorage.getItem(appConfig.storage.vibeKey);
const id = readBrowserStorage(appConfig.storage.vibeKey);
return (
VIBE_PRESETS.find((vibe) => vibe.id === id) ??
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"]
}