|
|
@ -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
|
|
@ -5,6 +5,8 @@ ts-node--*/
|
|||
rss.xml
|
||||
|
||||
dist
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
|
|
|||
|
|
@ -5,3 +5,10 @@ draw persistent coloured paths, spawn agents from those strokes, erase locally,
|
|||
and export the scene as a 4K wallpaper.
|
||||
|
||||
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
|
||||
|
||||
## Testing
|
||||
|
||||
- `npm test` runs the Vitest unit suite.
|
||||
- `npm run test:e2e` builds the production bundle and runs the Playwright Chromium
|
||||
smoke test.
|
||||
- `npx playwright install chromium` installs the local browser binary when needed.
|
||||
|
|
|
|||
23
e2e/app.spec.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveTitle('Fleeting Garden');
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||
|
||||
await page.getByRole('button', { name: 'About' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||
});
|
||||
39
index.html
|
|
@ -6,7 +6,7 @@
|
|||
name="viewport"
|
||||
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…</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 — 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
|
|
@ -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",
|
||||
|
|
|
|||
11
package.json
|
|
@ -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
|
|
@ -0,0 +1,32 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = 4173;
|
||||
const baseURL = `https://127.0.0.1:${port}`;
|
||||
const isCi = Boolean(process.env.CI);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCi,
|
||||
retries: isCi ? 2 : 0,
|
||||
workers: isCi ? 1 : undefined,
|
||||
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||
ignoreHTTPSErrors: true,
|
||||
reuseExistingServer: !isCi,
|
||||
timeout: 120_000,
|
||||
url: baseURL,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
Before Width: | Height: | Size: 908 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 914 B |
|
|
@ -1,6 +1,31 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<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 Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 488 B After Width: | Height: | Size: 709 B |
185
scripts/check-unused-exports.mjs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const sourceRoot = path.join(projectRoot, 'src');
|
||||
|
||||
const toPosix = (value) => value.split(path.sep).join('/');
|
||||
|
||||
const listTypeScriptFiles = (directory) =>
|
||||
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return listTypeScriptFiles(entryPath);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')
|
||||
? [entryPath]
|
||||
: [];
|
||||
});
|
||||
|
||||
const files = listTypeScriptFiles(sourceRoot);
|
||||
const fileSet = new Set(files.map((file) => path.resolve(file)));
|
||||
|
||||
const resolveModule = (fromFile, specifier) => {
|
||||
if (!specifier.startsWith('.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base = path.resolve(path.dirname(fromFile), specifier);
|
||||
const candidates = [
|
||||
`${base}.ts`,
|
||||
path.join(base, 'index.ts'),
|
||||
base.endsWith('.ts') ? base : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null;
|
||||
};
|
||||
|
||||
const exportKey = (file, name) => `${path.resolve(file)}:${name}`;
|
||||
const isExported = (node) =>
|
||||
ts.canHaveModifiers(node) &&
|
||||
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
||||
const isDefaultExported = (node) =>
|
||||
ts.canHaveModifiers(node) &&
|
||||
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
|
||||
|
||||
const exportedDeclarations = new Map();
|
||||
const usedExports = new Set();
|
||||
const wildcardUsedFiles = new Set();
|
||||
|
||||
const markUsed = (fromFile, name) => {
|
||||
usedExports.add(exportKey(fromFile, name));
|
||||
};
|
||||
|
||||
const collectImportUsage = (file, sourceFile) => {
|
||||
sourceFile.forEachChild((node) => {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
||||
if (!resolved || !node.importClause) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.importClause.name) {
|
||||
markUsed(resolved, 'default');
|
||||
}
|
||||
|
||||
const namedBindings = node.importClause.namedBindings;
|
||||
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
||||
namedBindings.elements.forEach((element) => {
|
||||
markUsed(resolved, (element.propertyName ?? element.name).text);
|
||||
});
|
||||
} else if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
||||
wildcardUsedFiles.add(path.resolve(resolved));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.exportClause) {
|
||||
wildcardUsedFiles.add(path.resolve(resolved));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isNamedExports(node.exportClause)) {
|
||||
node.exportClause.elements.forEach((element) => {
|
||||
markUsed(resolved, (element.propertyName ?? element.name).text);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const collectExportedDeclarations = (file, sourceFile) => {
|
||||
if (file.endsWith('.test.ts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
sourceFile.forEachChild((node) => {
|
||||
if (ts.isVariableStatement(node) && isExported(node)) {
|
||||
node.declarationList.declarations.forEach((declaration) => {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
exportedDeclarations.set(exportKey(file, declaration.name.text), {
|
||||
file,
|
||||
name: declaration.name.text,
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ts.isFunctionDeclaration(node) ||
|
||||
ts.isClassDeclaration(node) ||
|
||||
ts.isInterfaceDeclaration(node) ||
|
||||
ts.isTypeAliasDeclaration(node) ||
|
||||
ts.isEnumDeclaration(node)) &&
|
||||
isExported(node)
|
||||
) {
|
||||
if (isDefaultExported(node)) {
|
||||
exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.name) {
|
||||
exportedDeclarations.set(exportKey(file, node.name.text), {
|
||||
file,
|
||||
name: node.name.text,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
!node.moduleSpecifier &&
|
||||
node.exportClause &&
|
||||
ts.isNamedExports(node.exportClause)
|
||||
) {
|
||||
node.exportClause.elements.forEach((element) => {
|
||||
exportedDeclarations.set(exportKey(file, element.name.text), {
|
||||
file,
|
||||
name: element.name.text,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const parsedFiles = files.map((file) => ({
|
||||
file,
|
||||
sourceFile: ts.createSourceFile(
|
||||
file,
|
||||
readFileSync(file, 'utf8'),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS
|
||||
),
|
||||
}));
|
||||
|
||||
parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile));
|
||||
parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile));
|
||||
|
||||
const unusedExports = Array.from(exportedDeclarations.entries())
|
||||
.filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file))
|
||||
.map(([, declaration]) => declaration)
|
||||
.sort((left, right) =>
|
||||
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
|
||||
);
|
||||
|
||||
if (unusedExports.length > 0) {
|
||||
console.error('Unused exported declarations found:');
|
||||
unusedExports.forEach(({ file, name }) => {
|
||||
console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`);
|
||||
});
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export interface PianoSampleDefinition {
|
||||
interface PianoSampleDefinition {
|
||||
midi: number;
|
||||
url: string;
|
||||
}
|
||||
|
|
|
|||
689
src/config.ts
|
|
@ -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;
|
||||
|
|
|
|||
71
src/config/color-interactions.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type {
|
||||
AgentColorInteractionSettings,
|
||||
NumberControlConfig,
|
||||
} from './types';
|
||||
|
||||
const agentInteractionOptions: Record<string, number> = {
|
||||
Follow: 1,
|
||||
Avoid: -1,
|
||||
Ignore: 0,
|
||||
};
|
||||
|
||||
export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 0,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: 0,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 0,
|
||||
color3ToColor1: 0,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 1,
|
||||
};
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
const createSeededRandom = (seed: number): (() => number) => {
|
||||
let state = seed;
|
||||
return () => {
|
||||
let value = (state += 0x6d2b79f5);
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
|
||||
export const createColorInteractionSettings = (
|
||||
seedSource: string
|
||||
): AgentColorInteractionSettings => {
|
||||
const random = createSeededRandom(hashString(seedSource));
|
||||
const values = Object.values(agentInteractionOptions);
|
||||
const randomInteraction = () =>
|
||||
values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2;
|
||||
|
||||
return {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: randomInteraction(),
|
||||
color1ToColor3: randomInteraction(),
|
||||
color2ToColor1: randomInteraction(),
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: randomInteraction(),
|
||||
color3ToColor1: randomInteraction(),
|
||||
color3ToColor2: randomInteraction(),
|
||||
color3ToColor3: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||
folder: 'Color Reactions',
|
||||
label,
|
||||
min: -1,
|
||||
max: 1,
|
||||
step: 1,
|
||||
options: agentInteractionOptions,
|
||||
});
|
||||
206
src/config/runtime-settings.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import {
|
||||
colorInteractionControl,
|
||||
defaultColorInteractionSettings,
|
||||
} from './color-interactions';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
|
||||
defaults: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
agentCount: 0,
|
||||
selectedColorIndex: 0,
|
||||
spawnPerPixel: 0.22,
|
||||
|
||||
moveSpeed: 82,
|
||||
turnSpeed: 58,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 38,
|
||||
turnWhenLost: 0.8,
|
||||
|
||||
individualTrailWeight: 0.07,
|
||||
...defaultColorInteractionSettings,
|
||||
|
||||
diffusionRateTrails: 0.22,
|
||||
decayRateTrails: 965,
|
||||
diffusionRateBrush: 0.35,
|
||||
decayRateBrush: 18,
|
||||
brushEffectDuration: 8,
|
||||
|
||||
clarity: 0.62,
|
||||
brushSize: 14,
|
||||
brushCurveResolution: 12,
|
||||
eraserSize: 96,
|
||||
mirrorSegmentCount: 1,
|
||||
|
||||
brushSizeVariation: 0.5,
|
||||
|
||||
startColorHue: 200,
|
||||
|
||||
renderSpeed: 1,
|
||||
simulatedDelayMs: 0,
|
||||
},
|
||||
controls: {
|
||||
agentBudgetMax: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 500_000,
|
||||
max: 10_000_000,
|
||||
step: 50_000,
|
||||
},
|
||||
agentCount: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
step: 1_000,
|
||||
},
|
||||
color1ToColor1: colorInteractionControl('1 -> 1'),
|
||||
color1ToColor2: colorInteractionControl('1 -> 2'),
|
||||
color1ToColor3: colorInteractionControl('1 -> 3'),
|
||||
color2ToColor1: colorInteractionControl('2 -> 1'),
|
||||
color2ToColor2: colorInteractionControl('2 -> 2'),
|
||||
color2ToColor3: colorInteractionControl('2 -> 3'),
|
||||
color3ToColor1: colorInteractionControl('3 -> 1'),
|
||||
color3ToColor2: colorInteractionControl('3 -> 2'),
|
||||
color3ToColor3: colorInteractionControl('3 -> 3'),
|
||||
brushEffectDuration: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.5,
|
||||
max: 20,
|
||||
step: 0.05,
|
||||
},
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 0.25,
|
||||
},
|
||||
brushSizeVariation: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
brushCurveResolution: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
label: 'curve resolution',
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
},
|
||||
clarity: {
|
||||
folder: 'Render',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
decayRateBrush: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.1,
|
||||
max: 100,
|
||||
step: 0.1,
|
||||
},
|
||||
decayRateTrails: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
},
|
||||
diffusionRateBrush: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
diffusionRateTrails: {
|
||||
folder: 'Diffusion',
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.001,
|
||||
},
|
||||
eraserSize: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 24,
|
||||
max: 240,
|
||||
step: 1,
|
||||
},
|
||||
individualTrailWeight: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
mirrorSegmentCount: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 1,
|
||||
max: 12,
|
||||
step: 1,
|
||||
},
|
||||
moveSpeed: {
|
||||
folder: 'Agent',
|
||||
min: 10,
|
||||
max: 500,
|
||||
step: 1,
|
||||
},
|
||||
renderSpeed: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
},
|
||||
selectedColorIndex: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetAngle: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetDistance: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
simulatedDelayMs: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 2000,
|
||||
step: 1,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Agent',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
startColorHue: {
|
||||
folder: 'Render',
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
turnSpeed: {
|
||||
folder: 'Agent',
|
||||
min: 1,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
turnWhenLost: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
},
|
||||
};
|
||||
287
src/config/types.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import type {
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from '../audio/garden-audio-config';
|
||||
import type { GameLoopSettings } from '../game-loop/game-loop-settings';
|
||||
import type { AgentSettings } from '../pipelines/agents/agent-settings';
|
||||
import type { BrushSettings } from '../pipelines/brush/brush-settings';
|
||||
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
|
||||
import type { RenderSettings } from '../pipelines/render/render-settings';
|
||||
|
||||
export type GardenRuntimeSettings = GameLoopSettings &
|
||||
AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
RenderSettings;
|
||||
|
||||
export type AgentColorInteractionSettings = Pick<
|
||||
AgentSettings,
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
>;
|
||||
|
||||
export type GardenVibeSettings = Partial<
|
||||
Pick<
|
||||
GardenRuntimeSettings,
|
||||
| 'agentBudgetMax'
|
||||
| 'brushSize'
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
| 'clarity'
|
||||
| 'decayRateTrails'
|
||||
| 'diffusionRateTrails'
|
||||
| 'individualTrailWeight'
|
||||
| 'moveSpeed'
|
||||
| 'sensorOffsetAngle'
|
||||
| 'sensorOffsetDistance'
|
||||
| 'spawnPerPixel'
|
||||
| 'turnSpeed'
|
||||
>
|
||||
>;
|
||||
|
||||
export interface VibePreset {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: [string, string, string];
|
||||
backgroundColor: string;
|
||||
settings: GardenVibeSettings;
|
||||
audio: GardenAudioVibeProfile;
|
||||
}
|
||||
|
||||
export interface NumberControlConfig {
|
||||
folder: string;
|
||||
integer?: boolean;
|
||||
label?: string;
|
||||
max: number;
|
||||
min: number;
|
||||
options?: Record<string, number>;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export type RuntimeSettingControlConfig = {
|
||||
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
|
||||
};
|
||||
|
||||
export interface GardenAppConfig {
|
||||
audio: GardenAudioConfig;
|
||||
audioEngine: {
|
||||
energy: {
|
||||
attackSeconds: number;
|
||||
decaySeconds: number;
|
||||
releaseSeconds: number;
|
||||
strokeDecaySeconds: number;
|
||||
};
|
||||
eraser: {
|
||||
canvasWidthRatioForFullSize: number;
|
||||
defaultSizePixels: number;
|
||||
durationSeconds: number;
|
||||
filterPressureWeight: number;
|
||||
filterSizeWeight: number;
|
||||
filterSpeedWeight: number;
|
||||
gainBase: number;
|
||||
gainPressureWeight: number;
|
||||
gainSizeWeight: number;
|
||||
gainSpeedWeight: number;
|
||||
};
|
||||
delay: {
|
||||
erasingActivity: number;
|
||||
};
|
||||
graph: {
|
||||
closeGain: number;
|
||||
closeRampSeconds: number;
|
||||
delayActivityFeedbackWeight: number;
|
||||
delayFeedbackMax: number;
|
||||
delayFeedbackMin: number;
|
||||
delayOutputActivityWeight: number;
|
||||
delayOutputBase: number;
|
||||
delayTimeRampSeconds: number;
|
||||
eventBusGain: number;
|
||||
noiseMax: number;
|
||||
noiseMin: number;
|
||||
unlockBufferLength: number;
|
||||
unlockSampleRate: number;
|
||||
};
|
||||
input: {
|
||||
distanceEnergyBase: number;
|
||||
distanceEnergyScale: number;
|
||||
distanceForFullEnergyPixels: number;
|
||||
fallbackFrameSeconds: number;
|
||||
penMinPressure: number;
|
||||
strokeEnergyBase: number;
|
||||
strokeEnergyPressureWeight: number;
|
||||
strokeEnergySpeedWeight: number;
|
||||
};
|
||||
muteGain: number;
|
||||
muteRampSeconds: number;
|
||||
noiseBurst: {
|
||||
attackSeconds: number;
|
||||
filterQ: number;
|
||||
offsetRandomSeconds: number;
|
||||
scheduleAheadSeconds: number;
|
||||
silentGain: number;
|
||||
};
|
||||
piano: {
|
||||
filterQ: number;
|
||||
gainAttackSeconds: number;
|
||||
lowpassMaxHz: number;
|
||||
lowpassMinHz: number;
|
||||
minDurationSeconds: number;
|
||||
minFadeSeconds: number;
|
||||
minGain: number;
|
||||
pitchSemitonesPerOctave: number;
|
||||
scheduleAheadSeconds: number;
|
||||
sustainBase: number;
|
||||
sustainVelocityRange: number;
|
||||
tailStopExtraSeconds: number;
|
||||
voiceStealFadeSeconds: number;
|
||||
voiceStealStopSeconds: number;
|
||||
};
|
||||
startDelaySeconds: number;
|
||||
vibeChangeStingerMinIntervalSeconds: number;
|
||||
};
|
||||
deltaTime: {
|
||||
fpsExponentialDecayStrength: number;
|
||||
maxDeltaTimeSeconds: number;
|
||||
minDeltaTimeSeconds: number;
|
||||
};
|
||||
export4k: {
|
||||
bytesPerPixel: number;
|
||||
height: number;
|
||||
jsHeapSafetyMultiplier: number;
|
||||
lowMemoryDeviceGiB: number;
|
||||
lowMemoryExportFraction: number;
|
||||
rowAlignmentBytes: number;
|
||||
width: number;
|
||||
};
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: number;
|
||||
intervalMs: number;
|
||||
timeToLiveMs: number;
|
||||
};
|
||||
pipelines: {
|
||||
brush: {
|
||||
maxLineCount: number;
|
||||
};
|
||||
diffusion: {
|
||||
minDiffusionRate: number;
|
||||
};
|
||||
eraser: {
|
||||
maxSegmentCount: number;
|
||||
maxTextureLineCount: number;
|
||||
segmentFloatCount: number;
|
||||
workgroupSize: number;
|
||||
};
|
||||
};
|
||||
runtimeSettings: {
|
||||
controls: RuntimeSettingControlConfig;
|
||||
defaults: GardenRuntimeSettings;
|
||||
};
|
||||
simulation: {
|
||||
budget: {
|
||||
adaptiveCapDecreaseAgentsPerSecond: number;
|
||||
adaptiveCapMin: number;
|
||||
fpsHeadroom: number;
|
||||
fpsSmoothingNew: number;
|
||||
fpsSmoothingRetain: number;
|
||||
initialTargetAgentBudget: number;
|
||||
rampAgentsPerSecond: number;
|
||||
refreshTargetDecay: number;
|
||||
};
|
||||
brushEffectFramesPerSecond: number;
|
||||
globalAgentCap: number;
|
||||
initialAgentCount: number;
|
||||
intro: {
|
||||
angleJitterRadians: number;
|
||||
circleMaxSideRatio: number;
|
||||
circleMinSideRatio: number;
|
||||
drawHintClass: string;
|
||||
drawHintDelayMs: number;
|
||||
durationSeconds: number;
|
||||
entryJitterSideRatio: number;
|
||||
fontScaleDown: number;
|
||||
initialFontHeightRatio: number;
|
||||
initialFontWidthRatio: number;
|
||||
letterSpacingEm: number;
|
||||
maskAlphaThreshold: number;
|
||||
maskGradientThreshold: number;
|
||||
maskSampleDensity: number;
|
||||
maxHeightRatio: number;
|
||||
maxWidthRatio: number;
|
||||
minEntryJitterPx: number;
|
||||
minFontSizePx: number;
|
||||
minTargetJitterPx: number;
|
||||
radialJitterRatio: number;
|
||||
targetDelayDistanceMultiplier: number;
|
||||
targetDelayMax: number;
|
||||
targetDelayRandomMultiplier: number;
|
||||
targetJitterSideRatio: number;
|
||||
title: string;
|
||||
titleColorCutLetters: [number, number];
|
||||
titleRadiusMultiplier: number;
|
||||
titleStrokeWidthMinPx: number;
|
||||
titleStrokeWidthRatio: number;
|
||||
verticalAnchor: number;
|
||||
};
|
||||
introMoveSpeedBaseMultiplier: number;
|
||||
introMoveSpeedProgressMultiplier: number;
|
||||
maxMirrorSegmentCount: number;
|
||||
stroke: {
|
||||
angleJitterRadians: number;
|
||||
densityMultiplier: number;
|
||||
maxAgentCount: number;
|
||||
minAgentCount: number;
|
||||
};
|
||||
};
|
||||
storage: {
|
||||
audioMutedKey: string;
|
||||
vibeKey: string;
|
||||
};
|
||||
telemetry: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
};
|
||||
toolbar: {
|
||||
eraser: {
|
||||
controlScaleMax: number;
|
||||
controlScaleMin: number;
|
||||
default: number;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
};
|
||||
mirror: {
|
||||
default: number;
|
||||
max: number;
|
||||
min: number;
|
||||
names: Record<number, string>;
|
||||
step: number;
|
||||
};
|
||||
};
|
||||
tuningPane: {
|
||||
expandedDepth: number;
|
||||
startHidden: boolean;
|
||||
title: string;
|
||||
};
|
||||
vibes: {
|
||||
defaultVibeId: string;
|
||||
presets: Array<VibePreset>;
|
||||
};
|
||||
}
|
||||
|
||||
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
|
||||
export type GardenSimulationConfig = GardenAppConfig['simulation'];
|
||||
export type GardenStorageConfig = GardenAppConfig['storage'];
|
||||
204
src/config/vibe-presets.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import type {
|
||||
GardenAudioChord,
|
||||
GardenAudioVibeProfile,
|
||||
} from '../audio/garden-audio-config';
|
||||
import { createColorInteractionSettings } from './color-interactions';
|
||||
import type { VibePreset } from './types';
|
||||
|
||||
const majorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
];
|
||||
|
||||
const minorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
];
|
||||
|
||||
const majorPentatonic = [0, 2, 4, 7, 9];
|
||||
const minorPentatonic = [0, 3, 5, 7, 10];
|
||||
|
||||
export const defaultVibeId = 'candy-rain';
|
||||
|
||||
export const vibePresets: Array<VibePreset> = [
|
||||
{
|
||||
id: 'candy-rain',
|
||||
name: 'Candy Rain',
|
||||
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
|
||||
backgroundColor: '#10151f',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 14,
|
||||
clarity: 0.62,
|
||||
decayRateTrails: 965,
|
||||
diffusionRateTrails: 0.22,
|
||||
individualTrailWeight: 0.07,
|
||||
moveSpeed: 82,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 38,
|
||||
spawnPerPixel: 0.22,
|
||||
turnSpeed: 58,
|
||||
...createColorInteractionSettings('candy-rain'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 57,
|
||||
scale: majorPentatonic,
|
||||
brightness: 1.04,
|
||||
delayTimeMultiplier: 0.92,
|
||||
progression: majorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sunlit-moss',
|
||||
name: 'Sunlit Moss',
|
||||
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
||||
backgroundColor: '#172016',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 16,
|
||||
clarity: 0.68,
|
||||
decayRateTrails: 975,
|
||||
diffusionRateTrails: 0.18,
|
||||
individualTrailWeight: 0.06,
|
||||
moveSpeed: 70,
|
||||
sensorOffsetAngle: 28,
|
||||
sensorOffsetDistance: 46,
|
||||
spawnPerPixel: 0.18,
|
||||
turnSpeed: 44,
|
||||
...createColorInteractionSettings('sunlit-moss'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 53,
|
||||
scale: majorPentatonic,
|
||||
brightness: 0.92,
|
||||
delayTimeMultiplier: 1.08,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'coral-tide',
|
||||
name: 'Coral Tide',
|
||||
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
|
||||
backgroundColor: '#0f1822',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 13,
|
||||
clarity: 0.58,
|
||||
decayRateTrails: 955,
|
||||
diffusionRateTrails: 0.28,
|
||||
individualTrailWeight: 0.055,
|
||||
moveSpeed: 90,
|
||||
sensorOffsetAngle: 36,
|
||||
sensorOffsetDistance: 35,
|
||||
spawnPerPixel: 0.25,
|
||||
turnSpeed: 62,
|
||||
...createColorInteractionSettings('coral-tide'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 50,
|
||||
scale: minorPentatonic,
|
||||
brightness: 1,
|
||||
delayTimeMultiplier: 1.12,
|
||||
progression: minorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'moon-orchid',
|
||||
name: 'Moon Orchid',
|
||||
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
|
||||
backgroundColor: '#14121d',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 12,
|
||||
clarity: 0.64,
|
||||
decayRateTrails: 968,
|
||||
diffusionRateTrails: 0.2,
|
||||
individualTrailWeight: 0.065,
|
||||
moveSpeed: 76,
|
||||
sensorOffsetAngle: 32,
|
||||
sensorOffsetDistance: 42,
|
||||
spawnPerPixel: 0.2,
|
||||
turnSpeed: 52,
|
||||
...createColorInteractionSettings('moon-orchid'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 49,
|
||||
scale: minorPentatonic,
|
||||
brightness: 0.9,
|
||||
delayTimeMultiplier: 1.24,
|
||||
progression: minorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'peach-neon',
|
||||
name: 'Peach Neon',
|
||||
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
|
||||
backgroundColor: '#191716',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 15,
|
||||
clarity: 0.55,
|
||||
decayRateTrails: 948,
|
||||
diffusionRateTrails: 0.32,
|
||||
individualTrailWeight: 0.05,
|
||||
moveSpeed: 96,
|
||||
sensorOffsetAngle: 40,
|
||||
sensorOffsetDistance: 32,
|
||||
spawnPerPixel: 0.24,
|
||||
turnSpeed: 70,
|
||||
...createColorInteractionSettings('peach-neon'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 56,
|
||||
scale: majorPentatonic,
|
||||
brightness: 1.08,
|
||||
delayTimeMultiplier: 0.86,
|
||||
progression: majorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'frost-bloom',
|
||||
name: 'Frost Bloom',
|
||||
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
|
||||
backgroundColor: '#101820',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 18,
|
||||
clarity: 0.7,
|
||||
decayRateTrails: 982,
|
||||
diffusionRateTrails: 0.14,
|
||||
individualTrailWeight: 0.075,
|
||||
moveSpeed: 62,
|
||||
sensorOffsetAngle: 26,
|
||||
sensorOffsetDistance: 52,
|
||||
spawnPerPixel: 0.16,
|
||||
turnSpeed: 40,
|
||||
...createColorInteractionSettings('frost-bloom'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 62,
|
||||
scale: majorPentatonic,
|
||||
brightness: 0.88,
|
||||
delayTimeMultiplier: 1.32,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const audioVibes = Object.fromEntries(
|
||||
vibePresets.map((vibe) => [vibe.id, vibe.audio])
|
||||
) as Record<string, GardenAudioVibeProfile>;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const isProduction: boolean = import.meta.env.PROD;
|
||||
86
src/game-loop/agent-population.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
|
||||
const originalAgentBudgetMax = settings.agentBudgetMax;
|
||||
const originalBrushSize = settings.brushSize;
|
||||
const originalSelectedColorIndex = settings.selectedColorIndex;
|
||||
const originalSpawnPerPixel = settings.spawnPerPixel;
|
||||
|
||||
const createPopulation = () => {
|
||||
const pipeline = {
|
||||
maxAgentCount: 10_000_000,
|
||||
writeAgents: vi.fn(),
|
||||
resizeAgents: vi.fn(),
|
||||
compactAgents: vi.fn(),
|
||||
} as unknown as AgentGenerationPipeline;
|
||||
|
||||
return new AgentPopulation(pipeline);
|
||||
};
|
||||
|
||||
const setPopulationCounts = (
|
||||
population: AgentPopulation,
|
||||
activeCount: number,
|
||||
targetBudget: number
|
||||
) => {
|
||||
Object.assign(population as unknown as Record<string, number>, {
|
||||
activeCount,
|
||||
targetBudget,
|
||||
});
|
||||
};
|
||||
|
||||
describe('AgentPopulation adaptive budget', () => {
|
||||
beforeEach(() => {
|
||||
settings.agentBudgetMax = 1_000_000;
|
||||
settings.brushSize = 1;
|
||||
settings.selectedColorIndex = 0;
|
||||
settings.spawnPerPixel = 1;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
settings.agentBudgetMax = originalAgentBudgetMax;
|
||||
settings.brushSize = originalBrushSize;
|
||||
settings.selectedColorIndex = originalSelectedColorIndex;
|
||||
settings.spawnPerPixel = originalSpawnPerPixel;
|
||||
});
|
||||
|
||||
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
||||
const population = createPopulation();
|
||||
setPopulationCounts(population, 1_000_000, 1_000_000);
|
||||
|
||||
population.growBudget(1 / 60, 60, 60);
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
||||
|
||||
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
|
||||
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
|
||||
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
|
||||
appConfig.simulation.globalAgentCap
|
||||
);
|
||||
});
|
||||
|
||||
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
||||
const population = createPopulation();
|
||||
setPopulationCounts(population, 1_000_000, 1_000_000);
|
||||
|
||||
population.growBudget(10, 50, 60);
|
||||
|
||||
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
||||
expect(population.activeAgentCount).toBe(
|
||||
appConfig.simulation.budget.adaptiveCapMin
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,11 +12,15 @@ const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
|
|||
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
|
||||
const 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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
28
src/game-loop/game-loop-intro.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const gameLoopSource = readFileSync(
|
||||
join(process.cwd(), 'src/game-loop/game-loop.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const getStartDrawingHandlerSource = () => {
|
||||
const start = gameLoopSource.indexOf('onStartDrawing:');
|
||||
const end = gameLoopSource.indexOf('onEraseGestureEnded:', start);
|
||||
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error('Could not find the pointer drawing intro handler');
|
||||
}
|
||||
|
||||
return gameLoopSource.slice(start, end);
|
||||
};
|
||||
|
||||
describe('GameLoop intro drawing policy', () => {
|
||||
it('allows drawing to start without completing the intro sequence', () => {
|
||||
const handlerSource = getStartDrawingHandlerSource();
|
||||
|
||||
expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()');
|
||||
expect(handlerSource).not.toContain('this.introPrompt.complete(');
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,7 @@ import { RenderInputCache } from './render-input-cache';
|
|||
export default class GameLoop {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
import { settings } from '../settings';
|
||||
import { hsl } from '../utils/hsl';
|
||||
import { Random } from '../utils/random';
|
||||
|
||||
const hues = [settings.startColorHue];
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360);
|
||||
}
|
||||
|
||||
const colors = hues.map((hue) =>
|
||||
hsl(hue, Random.randomBetween(90, 100), Random.randomBetween(20, 30))
|
||||
);
|
||||
|
||||
export class GamePresentation {
|
||||
public static getGenerationColor(generation: number): vec3 {
|
||||
return colors[generation % colors.length];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { GenerationCounts } from '../pipelines/agents/agent-generation/generation-counts';
|
||||
import { settings } from '../settings';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { mix } from '../utils/mix';
|
||||
import { Random } from '../utils/random';
|
||||
|
||||
export interface SpawnAction {
|
||||
generation: number;
|
||||
position: vec2;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export class GameRules {
|
||||
private static readonly DEFAULT_SPAWN_INTERVAL = 8;
|
||||
private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
|
||||
private static readonly DEFAULT_SPAWN_RADIUS = 20;
|
||||
|
||||
private lastSpawnTimeInSeconds = 0;
|
||||
private currentSpawnInterval = 0;
|
||||
private currentSpawnRadius = 0;
|
||||
private lastGenerationChangeTimeInSeconds = 0;
|
||||
|
||||
public nextGenerationId = 1;
|
||||
public generationCounts: {
|
||||
currentGenerationCount: number;
|
||||
nextGenerationCount: number;
|
||||
} = {
|
||||
currentGenerationCount: 0,
|
||||
nextGenerationCount: 1,
|
||||
};
|
||||
|
||||
public constructor(startingTimeInSeconds: number) {
|
||||
this.lastSpawnTimeInSeconds = startingTimeInSeconds;
|
||||
this.lastGenerationChangeTimeInSeconds = startingTimeInSeconds;
|
||||
}
|
||||
|
||||
private lastSpawnAction: SpawnAction | undefined;
|
||||
|
||||
public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
|
||||
if (
|
||||
this.lastSpawnAction &&
|
||||
timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
|
||||
) {
|
||||
return this.lastSpawnAction;
|
||||
}
|
||||
|
||||
this.currentSpawnInterval = mix(
|
||||
GameRules.DEFAULT_SPAWN_INTERVAL,
|
||||
GameRules.DEFAULT_SPAWN_INTERVAL / 5,
|
||||
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
|
||||
);
|
||||
|
||||
this.currentSpawnRadius = mix(
|
||||
GameRules.DEFAULT_SPAWN_RADIUS,
|
||||
GameRules.DEFAULT_SPAWN_RADIUS * 3,
|
||||
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
|
||||
);
|
||||
|
||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
||||
|
||||
if (
|
||||
timeInSeconds - this.lastSpawnTimeInSeconds < this.currentSpawnInterval ||
|
||||
q > 0.05
|
||||
) {
|
||||
return {
|
||||
generation: this.nextGenerationId,
|
||||
position: vec2.create(),
|
||||
radius: 0,
|
||||
};
|
||||
}
|
||||
|
||||
this.lastSpawnTimeInSeconds = timeInSeconds;
|
||||
|
||||
this.lastSpawnAction = {
|
||||
generation: this.nextGenerationId,
|
||||
position: vec2.fromValues(
|
||||
Random.randomBetween(0, canvasSize[0]),
|
||||
Random.randomBetween(0, canvasSize[1])
|
||||
),
|
||||
radius: this.currentSpawnRadius,
|
||||
};
|
||||
|
||||
return this.lastSpawnAction;
|
||||
}
|
||||
|
||||
public updateGenerationCounts({
|
||||
evenGenerationCount,
|
||||
oddGenerationCount,
|
||||
}: GenerationCounts): void {
|
||||
const nextGenerationCount =
|
||||
this.nextGenerationId % 2 === 1 ? oddGenerationCount : evenGenerationCount;
|
||||
const currentGenerationCount =
|
||||
this.nextGenerationId % 2 === 1 ? evenGenerationCount : oddGenerationCount;
|
||||
|
||||
const q = currentGenerationCount / settings.agentCount;
|
||||
|
||||
if (currentGenerationCount <= 100 && q < 0.05) {
|
||||
this.nextGenerationId++;
|
||||
this.lastGenerationChangeTimeInSeconds = performance.now() / 1000;
|
||||
}
|
||||
|
||||
this.generationCounts = {
|
||||
currentGenerationCount,
|
||||
nextGenerationCount,
|
||||
};
|
||||
}
|
||||
|
||||
public getNextGenerationMoveSpeed(): number {
|
||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
||||
return mix(settings.moveSpeed / 8, settings.moveSpeed, q ** 2);
|
||||
}
|
||||
|
||||
public getInfectionProbability(): number {
|
||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
||||
return clamp(mix(0.3, 1, q * 5), 0, 0.9);
|
||||
}
|
||||
|
||||
public getSensorOffset(): number {
|
||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
||||
return mix(20, settings.sensorOffsetDistance, q);
|
||||
}
|
||||
}
|
||||
277
src/game-loop/pointer-input.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type PointerListener = (event: PointerEvent) => void;
|
||||
|
||||
const makePointerEvent = (
|
||||
type: string,
|
||||
event: Partial<PointerEvent> = {}
|
||||
): PointerEvent =>
|
||||
({
|
||||
buttons: 1,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
isTrusted: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
pressure: 0.5,
|
||||
timeStamp: 100,
|
||||
type,
|
||||
...event,
|
||||
}) as PointerEvent;
|
||||
|
||||
const toPoint = (point: ArrayLike<number>): Array<number> => Array.from(point);
|
||||
|
||||
class FakeCanvas {
|
||||
public readonly capturedPointerIds: Array<number> = [];
|
||||
public readonly releasedPointerIds: Array<number> = [];
|
||||
public width = 300;
|
||||
public height = 200;
|
||||
|
||||
private readonly listeners = new Map<string, Set<PointerListener>>();
|
||||
|
||||
public addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject
|
||||
): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set<PointerListener>();
|
||||
const pointerListener =
|
||||
typeof listener === 'function'
|
||||
? listener
|
||||
: (event: Event) => listener.handleEvent(event);
|
||||
listeners.add(pointerListener as PointerListener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
public removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject
|
||||
): void {
|
||||
const listeners = this.listeners.get(type);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
listeners.delete(listener as PointerListener);
|
||||
}
|
||||
|
||||
public dispatchPointerEvent(type: string, event: Partial<PointerEvent> = {}): void {
|
||||
const pointerEvent = makePointerEvent(type, event);
|
||||
|
||||
this.listeners.get(type)?.forEach((listener) => listener(pointerEvent));
|
||||
}
|
||||
|
||||
public getBoundingClientRect(): DOMRect {
|
||||
return {
|
||||
bottom: this.height,
|
||||
height: this.height,
|
||||
left: 0,
|
||||
right: this.width,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width: this.width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
public setPointerCapture(pointerId: number): void {
|
||||
this.capturedPointerIds.push(pointerId);
|
||||
}
|
||||
|
||||
public releasePointerCapture(pointerId: number): void {
|
||||
this.releasedPointerIds.push(pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
const makeSwipePipeline = () => ({
|
||||
addSwipeSegment: vi.fn(),
|
||||
clearSwipes: vi.fn(),
|
||||
});
|
||||
|
||||
const createPointerInput = async () => {
|
||||
const { GardenPointerInput } = await import('./pointer-input');
|
||||
const { settings: runtimeSettings } = await import('../settings');
|
||||
const canvas = new FakeCanvas();
|
||||
const audio = {
|
||||
beginGesture: vi.fn(),
|
||||
endGesture: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
touchDown: vi.fn(),
|
||||
};
|
||||
const brushPipeline = makeSwipePipeline();
|
||||
const eraserAgentPipeline = makeSwipePipeline();
|
||||
const eraserTexturePipeline = makeSwipePipeline();
|
||||
const eraserPreview = {
|
||||
isPointerInsideCanvas: vi.fn(() => true),
|
||||
setEraseMode: vi.fn(),
|
||||
setPointerHoveringCanvas: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
const onStartDrawing = vi.fn();
|
||||
const onEraseGestureEnded = vi.fn();
|
||||
const spawnStrokeAgents = vi.fn();
|
||||
const input = new GardenPointerInput({
|
||||
audio,
|
||||
brushPipeline,
|
||||
canvas: canvas as unknown as HTMLCanvasElement,
|
||||
eraserAgentPipeline,
|
||||
eraserPreview,
|
||||
eraserTexturePipeline,
|
||||
getCanvasSize: () => [canvas.width, canvas.height],
|
||||
getDevicePixelRatio: () => 1,
|
||||
getMirrorSegmentCount: () => 1,
|
||||
onEraseGestureEnded,
|
||||
onStartDrawing,
|
||||
spawnStrokeAgents,
|
||||
} as unknown as ConstructorParameters<typeof GardenPointerInput>[0]);
|
||||
|
||||
input.attach();
|
||||
|
||||
return {
|
||||
audio,
|
||||
brushPipeline,
|
||||
canvas,
|
||||
input,
|
||||
onStartDrawing,
|
||||
runtimeSettings,
|
||||
spawnStrokeAgents,
|
||||
};
|
||||
};
|
||||
|
||||
describe('GardenPointerInput drawing startup', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('localStorage', {
|
||||
clear: vi.fn(),
|
||||
getItem: vi.fn(() => null),
|
||||
removeItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('allows pointer drawing immediately', async () => {
|
||||
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
|
||||
await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 });
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 80,
|
||||
pointerId: 7,
|
||||
timeStamp: 120,
|
||||
});
|
||||
|
||||
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(2);
|
||||
expect(canvas.capturedPointerIds).toEqual([7]);
|
||||
});
|
||||
|
||||
it('starts drawing from a fresh pointerdown', async () => {
|
||||
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
|
||||
await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
|
||||
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.capturedPointerIds).toEqual([9]);
|
||||
});
|
||||
|
||||
it('flushes the delayed smoothed stroke tail on pointerup', async () => {
|
||||
const { brushPipeline, canvas } = await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 80,
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
});
|
||||
canvas.dispatchPointerEvent('pointerup', {
|
||||
clientX: 60,
|
||||
clientY: 80,
|
||||
pointerId: 9,
|
||||
timeStamp: 140,
|
||||
});
|
||||
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]);
|
||||
});
|
||||
|
||||
it('uses coalesced pointer samples for smoother brush segments', async () => {
|
||||
const { audio, brushPipeline, canvas, spawnStrokeAgents } =
|
||||
await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
audio.stroke.mockClear();
|
||||
brushPipeline.addSwipeSegment.mockClear();
|
||||
spawnStrokeAgents.mockClear();
|
||||
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 40,
|
||||
clientY: 20,
|
||||
getCoalescedEvents: () => [
|
||||
makePointerEvent('pointermove', {
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 110,
|
||||
}),
|
||||
makePointerEvent('pointermove', {
|
||||
clientX: 30,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 115,
|
||||
}),
|
||||
makePointerEvent('pointermove', {
|
||||
clientX: 40,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
}),
|
||||
],
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
});
|
||||
|
||||
expect(audio.stroke).toHaveBeenCalledTimes(3);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(3);
|
||||
expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
||||
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
|
||||
runtimeSettings.brushCurveResolution = 2;
|
||||
runtimeSettings.brushSize = 1;
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 10,
|
||||
clientY: 60,
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
});
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 9,
|
||||
timeStamp: 140,
|
||||
});
|
||||
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -26,10 +26,16 @@ interface GardenPointerInputOptions {
|
|||
}
|
||||
|
||||
export class GardenPointerInput {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@
|
|||
@use 'style/control-dock';
|
||||
@use 'style/toolbar';
|
||||
@use 'style/panels';
|
||||
@use 'style/config-pane';
|
||||
@use 'style/loading';
|
||||
@use 'style/motion';
|
||||
|
|
|
|||
104
src/index.ts
|
|
@ -8,7 +8,9 @@ import { ConfigPane } from './page/config-pane';
|
|||
import { FullScreenHandler } from './page/full-screen-handler';
|
||||
import { 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -32,12 +32,11 @@ export class FullScreenHandler {
|
|||
return document.fullscreenElement !== null;
|
||||
}
|
||||
|
||||
private updateButtons() {
|
||||
this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
|
||||
? 'block'
|
||||
: 'none';
|
||||
this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
|
||||
? 'none'
|
||||
: 'block';
|
||||
private updateButtons(): void {
|
||||
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
|
||||
this.minimizeButton.style.display = isInFullScreenMode ? 'block' : 'none';
|
||||
this.maximizeButton.style.display = isInFullScreenMode ? 'none' : 'block';
|
||||
this.minimizeButton.classList.toggle('active', isInFullScreenMode);
|
||||
this.maximizeButton.classList.toggle('active', isInFullScreenMode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export interface BrushSettings {
|
||||
brushSize: number;
|
||||
brushCurveResolution: number;
|
||||
eraserSize: number;
|
||||
mirrorSegmentCount: number;
|
||||
brushSizeVariation: number;
|
||||
|
|
|
|||
|
|
@ -110,6 +110,15 @@ describe('WGSL uniform layout contracts', () => {
|
|||
'individualTrailWeight',
|
||||
'agentCount',
|
||||
'introProgress',
|
||||
'color1ToColor1',
|
||||
'color1ToColor2',
|
||||
'color1ToColor3',
|
||||
'color2ToColor1',
|
||||
'color2ToColor2',
|
||||
'color2ToColor3',
|
||||
'color3ToColor1',
|
||||
'color3ToColor2',
|
||||
'color3ToColor3',
|
||||
],
|
||||
});
|
||||
expectStructUniformLayout({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
69
src/style/_config-pane.scss
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
.config-pane {
|
||||
.color-reaction-folder > .tp-fldv_c {
|
||||
padding: 6px 8px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-reaction-matrix {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.color-reaction-matrix__corner,
|
||||
.color-reaction-matrix__header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
color: rgb(255 255 255 / 76%);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.color-reaction-matrix__corner {
|
||||
justify-content: flex-start;
|
||||
padding-left: 2px;
|
||||
color: rgb(255 255 255 / 62%);
|
||||
}
|
||||
|
||||
.color-reaction-matrix__swatch {
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid rgb(255 255 255 / 55%);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.color-reaction-matrix__cell {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.color-reaction-matrix__cell > select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
border: 1px solid rgb(255 255 255 / 16%);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
appearance: auto;
|
||||
background: rgb(255 255 255 / 8%);
|
||||
color: white;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.color-reaction-matrix__cell > select:focus-visible {
|
||||
outline: 2px solid rgb(255 255 255 / 72%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.color-reaction-matrix__cell > select > option {
|
||||
background: rgb(28 31 38);
|
||||
color: white;
|
||||
}
|
||||
|
|
@ -4,12 +4,14 @@ html > body > aside.control-dock {
|
|||
bottom: env(safe-area-inset-bottom);
|
||||
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
|
|
@ -0,0 +1,120 @@
|
|||
.loading-indicator {
|
||||
--loading-progress: 0%;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
width: min(78vw, 320px);
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-time-long);
|
||||
|
||||
> .loading-dots {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
> .loading-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
box-shadow:
|
||||
0 0 18px rgb(255 255 255 / 38%),
|
||||
0 0 4px rgb(255 255 255 / 60%);
|
||||
transform: scale(0.5);
|
||||
opacity: 0.4;
|
||||
animation: loading-bloom 1.4s ease-in-out infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .loading-status {
|
||||
color: rgb(255 255 255 / 88%);
|
||||
font:
|
||||
600 16px/1.25 'Open Sans',
|
||||
sans-serif;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
|
||||
letter-spacing: 0.01em;
|
||||
min-height: 1.25em;
|
||||
}
|
||||
|
||||
> .loading-progress {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 14%);
|
||||
box-shadow: 0 1px 6px rgb(0 0 0 / 28%);
|
||||
|
||||
> .loading-progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--loading-progress);
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(255 255 255 / 72%),
|
||||
rgb(255 255 255 / 96%)
|
||||
);
|
||||
box-shadow: 0 0 12px rgb(255 255 255 / 38%);
|
||||
transition: width var(--transition-time-long) ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html > body.is-loading {
|
||||
.loading-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.eraser-preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
aside.control-dock {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
translate: 0 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-bloom {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.5);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loading-indicator > .loading-dots > .loading-dot {
|
||||
animation: none;
|
||||
transform: scale(0.85);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,42 @@ html > body > aside.control-dock > .pages {
|
|||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
&.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;
|
||||
|
|
|
|||
17
src/utils/browser-storage.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export const readBrowserStorage = (key: string): string | null => {
|
||||
try {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeBrowserStorage = (key: string, value: string): void => {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing or embedded contexts.
|
||||
}
|
||||
};
|
||||
63
src/utils/dom.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { ErrorCode, RuntimeError } from './error-handler';
|
||||
|
||||
type ElementConstructor<T extends Element> = abstract new () => T;
|
||||
|
||||
export const queryRequiredElement = <T extends Element>(
|
||||
selector: string,
|
||||
constructor: ElementConstructor<T>,
|
||||
root: ParentNode = document
|
||||
): T => {
|
||||
const element = root.querySelector(selector);
|
||||
if (!(element instanceof constructor)) {
|
||||
throw new RuntimeError(
|
||||
ErrorCode.DOM_ELEMENT_MISSING,
|
||||
`Missing required DOM element: ${selector}`,
|
||||
{
|
||||
details: {
|
||||
expectedType: constructor.name,
|
||||
selector,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const queryRequiredElements = <T extends Element>(
|
||||
selector: string,
|
||||
constructor: ElementConstructor<T>,
|
||||
root: ParentNode = document
|
||||
): Array<T> => {
|
||||
const elements = Array.from(root.querySelectorAll(selector));
|
||||
if (elements.length === 0) {
|
||||
throw new RuntimeError(
|
||||
ErrorCode.DOM_ELEMENT_MISSING,
|
||||
`Missing required DOM elements: ${selector}`,
|
||||
{
|
||||
details: {
|
||||
expectedType: constructor.name,
|
||||
selector,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return elements.map((element) => {
|
||||
if (!(element instanceof constructor)) {
|
||||
throw new RuntimeError(
|
||||
ErrorCode.DOM_ELEMENT_MISSING,
|
||||
`DOM element has the wrong type: ${selector}`,
|
||||
{
|
||||
details: {
|
||||
actualType: element.constructor.name,
|
||||
expectedType: constructor.name,
|
||||
selector,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return element;
|
||||
});
|
||||
};
|
||||
|
|
@ -14,16 +14,17 @@ export enum ErrorCode {
|
|||
WEBGPU_CONTEXT_CONFIGURATION_FAILED = 'webgpu-context-configuration-failed',
|
||||
WEBGPU_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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatNumber } from './format-number';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('renders integers without decimals', () => {
|
||||
expect(formatNumber(42)).toBe('42 ');
|
||||
});
|
||||
it('renders fractional values with two decimals', () => {
|
||||
expect(formatNumber(3.14159)).toBe('3.14 ');
|
||||
});
|
||||
it('renders thousands compactly', () => {
|
||||
expect(formatNumber(2500)).toBe('2.5 thousand ');
|
||||
});
|
||||
it('renders millions compactly', () => {
|
||||
expect(formatNumber(1_500_000)).toBe('1.5 million ');
|
||||
});
|
||||
it('appends the unit when provided', () => {
|
||||
expect(formatNumber(5, 'agents')).toBe('5 agents');
|
||||
expect(formatNumber(2_000_000, 'agents')).toBe('2.0 million agents');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export const formatNumber = (value: number, unit = ''): string => {
|
||||
if (value >= 1e6) {
|
||||
return `${(value / 1e6).toFixed(1)} million ${unit}`;
|
||||
}
|
||||
|
||||
if (value >= 1e3) {
|
||||
return `${(value / 1e3).toFixed(1)} thousand ${unit}`;
|
||||
}
|
||||
|
||||
return `${value === Math.floor(value) ? value : value.toFixed(2)} ${unit}`;
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export interface CachedFloat32BufferWrite {
|
||||
interface CachedFloat32BufferWrite {
|
||||
hasValue: boolean;
|
||||
previous: Float32Array;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["playwright.config.ts", "e2e/**/*.ts"]
|
||||
}
|
||||