|
|
@ -32,6 +32,7 @@ jobs:
|
|||
- name: Test
|
||||
run: |
|
||||
npm run lint:check
|
||||
npm run format:check
|
||||
npm run typecheck
|
||||
npm run typecheck:e2e
|
||||
npm test
|
||||
|
|
@ -40,6 +41,10 @@ jobs:
|
|||
run: |
|
||||
npm run test:e2e
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
npm run build
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
|
|||
2
.nvmrc
|
|
@ -1 +1 @@
|
|||
22
|
||||
22.13.0
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@
|
|||
"endOfLine": "lf",
|
||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"],
|
||||
"importOrderTypeScriptVersion": "5.6.0"
|
||||
"importOrderTypeScriptVersion": "6.0.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
<path
|
||||
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 239 B After Width: | Height: | Size: 248 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 342 B After Width: | Height: | Size: 349 B |
|
|
@ -1,7 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
|
||||
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
|
||||
</svg>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 382 B |
|
|
@ -1,7 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M15 19v-2a2 2 0 0 1 2 -2h2" />
|
||||
<path d="M15 5v2a2 2 0 0 0 2 2h2" />
|
||||
<path d="M5 15h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M5 9h2a2 2 0 0 0 2 -2v-2" />
|
||||
</svg>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 382 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
|
||||
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 324 B After Width: | Height: | Size: 331 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 6l8 0" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 534 B After Width: | Height: | Size: 541 B |
|
|
@ -1,3 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 384 B After Width: | Height: | Size: 391 B |
8
definitions.d.ts
vendored
|
|
@ -1,8 +0,0 @@
|
|||
declare module '*.wgsl?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
interface HTMLCanvasElement {
|
||||
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
|
||||
}
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
|
||||
const canvasName = 'Interactive generative garden canvas';
|
||||
|
||||
interface BrowserDiagnostics {
|
||||
browserFailures: Array<string>;
|
||||
consoleErrors: Array<string>;
|
||||
}
|
||||
|
||||
const isLocalUrl = (url: string) => {
|
||||
const { hostname } = new URL(url);
|
||||
return hostname === '127.0.0.1' || hostname === 'localhost';
|
||||
|
|
@ -29,6 +34,27 @@ const collectLocalBrowserFailures = (page: Page) => {
|
|||
return failures;
|
||||
};
|
||||
|
||||
const test = base.extend<{ browserDiagnostics: BrowserDiagnostics }>({
|
||||
browserDiagnostics: [
|
||||
async ({ page }, use) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
const consoleErrors: Array<string> = [];
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await use({ browserFailures, consoleErrors });
|
||||
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(browserFailures).toEqual([]);
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
const disableWebGpu = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
|
|
@ -39,14 +65,6 @@ const disableWebGpu = async (page: Page) => {
|
|||
};
|
||||
|
||||
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
const consoleErrors: Array<string> = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript((expectedCanvasName) => {
|
||||
const captureState = { count: 0 };
|
||||
Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
|
||||
|
|
@ -68,7 +86,7 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
|||
}, canvasName);
|
||||
|
||||
await page.goto('/');
|
||||
const startButton = page.getByRole('button', { name: 'Start' });
|
||||
const startButton = page.getByRole('button', { exact: true, name: 'Start' });
|
||||
await expect(startButton).toBeVisible();
|
||||
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
||||
await page.keyboard.press('Enter');
|
||||
|
|
@ -117,13 +135,21 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
|||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(browserFailures).toEqual([]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__fleetingGardenBrushPasses?: number;
|
||||
}
|
||||
).__fleetingGardenBrushPasses ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/');
|
||||
|
||||
|
|
@ -135,23 +161,19 @@ test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
|||
const fallback = page.getByRole('alert');
|
||||
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
|
||||
await expect(fallback).toContainText('webgpu-unsupported');
|
||||
expect(browserFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('syncs the selected vibe with the URI', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/?vibe=Bone%20Archive');
|
||||
await page.goto('/?vibe=Aurora%20Mycelium');
|
||||
|
||||
await expect(page).toHaveURL(/vibe=bone-archive/);
|
||||
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
|
||||
|
||||
await page.getByRole('button', { name: 'Next vibe' }).click();
|
||||
await expect(page).toHaveURL(/vibe=pelagic-caustics/);
|
||||
await expect(page).toHaveURL(/vibe=velvet-observatory/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/vibe=bone-archive/);
|
||||
expect(browserFailures).toEqual([]);
|
||||
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
|
||||
});
|
||||
|
||||
test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
|
||||
|
|
@ -194,7 +216,7 @@ test('keeps the config overlay scrollable and dismissible on mobile', async ({
|
|||
await page.setViewportSize({ width: 390, height: 640 });
|
||||
await page.goto('/');
|
||||
|
||||
const startButton = page.getByRole('button', { name: 'Start' });
|
||||
const startButton = page.getByRole('button', { exact: true, name: 'Start' });
|
||||
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
||||
await startButton.click();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||
|
|
|
|||
12
package-lock.json
generated
|
|
@ -10,13 +10,13 @@
|
|||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"tweakpane": "^4.0.5"
|
||||
"tweakpane": "~4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tweakpane/core": "^2.0.5",
|
||||
"@tweakpane/core": "~2.0.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"vitest": "^4.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
|
|
@ -2774,9 +2774,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
"generate-icons": "pwa-assets-generator"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export default defineConfig({
|
|||
fullyParallel: true,
|
||||
forbidOnly: isCi,
|
||||
retries: isCi ? 2 : 0,
|
||||
workers: isCi ? 1 : undefined,
|
||||
workers: 1,
|
||||
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
import { appConfig } from './config';
|
||||
import type { VibeId } from './vibes';
|
||||
|
||||
let isInitialized = false;
|
||||
|
|
@ -23,10 +24,10 @@ export const initAnalytics = () => {
|
|||
|
||||
try {
|
||||
plausibleInit({
|
||||
domain: 'schmelczer.dev/floating',
|
||||
endpoint: 'https://stats.schmelczer.dev/status',
|
||||
autoCapturePageviews: true,
|
||||
logging: true,
|
||||
domain: appConfig.analytics.domain,
|
||||
endpoint: appConfig.analytics.endpoint,
|
||||
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
|
||||
logging: appConfig.analytics.logging,
|
||||
});
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||
export const SILENT_AUDIO_GAIN = 0.0001;
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import { SILENT_AUDIO_GAIN, type GardenAudioConfig } from './garden-audio-config';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type AudioSessionType = NonNullable<NavigatorWithAudioSession['audioSession']>['type'];
|
||||
|
||||
type NavigatorWithAudioSession = Navigator & {
|
||||
audioSession?: {
|
||||
type:
|
||||
|
|
@ -17,7 +19,7 @@ type NavigatorWithAudioSession = Navigator & {
|
|||
const outputHighPassFrequencyHz = 45;
|
||||
const noiseBufferDurationSeconds = 1;
|
||||
const graphTuning = {
|
||||
closeGain: 0.0001,
|
||||
closeGain: SILENT_AUDIO_GAIN,
|
||||
closeRampSeconds: 0.015,
|
||||
delayMaxSeconds: 2,
|
||||
eventBusGain: 1,
|
||||
|
|
@ -54,6 +56,7 @@ export class GardenAudioGraph {
|
|||
private pianoBusGainScale = 1;
|
||||
private pianoBusGainScaleAutomationUntil = 0;
|
||||
private pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
private previousAudioSessionType: AudioSessionType | null = null;
|
||||
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
|
@ -77,6 +80,7 @@ export class GardenAudioGraph {
|
|||
// Audio Session API.
|
||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||
if (audioSession) {
|
||||
this.previousAudioSessionType ??= audioSession.type;
|
||||
audioSession.type = 'playback';
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +207,21 @@ export class GardenAudioGraph {
|
|||
if (context.state !== 'closed') {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
this.restoreAudioSessionType();
|
||||
}
|
||||
|
||||
private restoreAudioSessionType(): void {
|
||||
const previousType = this.previousAudioSessionType;
|
||||
this.previousAudioSessionType = null;
|
||||
if (previousType === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||
if (audioSession) {
|
||||
audioSession.type = previousType;
|
||||
}
|
||||
}
|
||||
|
||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
|
|||
const DEFAULT_ROOT_MIDI = 57;
|
||||
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
|
||||
|
||||
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
|
||||
|
||||
const getProfileScale = (vibe: VibePreset): Array<number> => {
|
||||
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
|
||||
return [...scale];
|
||||
|
|
@ -26,21 +24,10 @@ const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
|
|||
);
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
||||
let profile = profileCache.get(vibe);
|
||||
if (!profile) {
|
||||
profile = {
|
||||
...vibe.audio,
|
||||
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
||||
scale: getProfileScale(vibe),
|
||||
progression: getProfileProgression(vibe),
|
||||
};
|
||||
profileCache.set(vibe, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
Object.assign(profile, vibe.audio);
|
||||
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
|
||||
profile.scale = getProfileScale(vibe);
|
||||
profile.progression = getProfileProgression(vibe);
|
||||
return profile;
|
||||
return {
|
||||
...vibe.audio,
|
||||
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
||||
scale: getProfileScale(vibe),
|
||||
progression: getProfileProgression(vibe),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { VibePreset } from '../vibes';
|
||||
import type { VibePreset } from '../vibes';
|
||||
|
||||
export interface GardenAudioSnapshot {
|
||||
vibe: VibePreset;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { clamp01 } from '../utils/math';
|
||||
import type { VibeId, VibePreset } from '../vibes';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import {
|
||||
SILENT_AUDIO_GAIN,
|
||||
type GardenAudioConfig,
|
||||
type GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
|
|
@ -13,8 +17,12 @@ import { NoiseBurstPlayer } from './noise-burst-player';
|
|||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||
type PianoReleasePhase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'awaiting-fade' }
|
||||
| { kind: 'scheduled-fade'; fadeAt: number }
|
||||
| { kind: 'settling'; stopAt: number };
|
||||
|
||||
const muteGain = 0.0001;
|
||||
const muteRampSeconds = 0.02;
|
||||
const brushUpPianoBusFadeSeconds = 2.4;
|
||||
const brushUpPianoBusFadeSettleSeconds = 3.2;
|
||||
|
|
@ -29,16 +37,16 @@ export class GardenAudio {
|
|||
private readonly pianoEngine: GenerativePianoEngine;
|
||||
|
||||
private currentVibeId: VibeId | null = null;
|
||||
private currentVibe: VibePreset | null = null;
|
||||
private lifecycle: AudioLifecycle = 'idle';
|
||||
private isReleasingPiano = false;
|
||||
private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' };
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private fadePianoAt: number | null = null;
|
||||
private masterVolume: number;
|
||||
private stopPianoAt: number | null = null;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
private startRequestId = 0;
|
||||
private hasLoadedPiano = false;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {
|
||||
this.masterVolume = clamp01(config.masterVolume);
|
||||
|
|
@ -60,7 +68,8 @@ export class GardenAudio {
|
|||
if (
|
||||
this.lifecycle === 'started' &&
|
||||
this.currentVibeId === vibe.id &&
|
||||
this.graph.context?.state === 'running'
|
||||
this.graph.context?.state === 'running' &&
|
||||
this.hasLoadedPiano
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -74,6 +83,7 @@ export class GardenAudio {
|
|||
? muteRampSeconds
|
||||
: this.config.fadeInSeconds;
|
||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||
const startRequestId = ++this.startRequestId;
|
||||
|
||||
if (needsResume) {
|
||||
if (!isUserGesture) {
|
||||
|
|
@ -83,7 +93,7 @@ export class GardenAudio {
|
|||
.resume()
|
||||
.then(() => {
|
||||
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||
this.completeStart(vibe, { context, startupRampSeconds });
|
||||
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
@ -95,16 +105,18 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
this.completeStart(vibe, { context, startupRampSeconds });
|
||||
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
||||
}
|
||||
|
||||
private completeStart(
|
||||
vibe: VibePreset,
|
||||
{
|
||||
context,
|
||||
startRequestId,
|
||||
startupRampSeconds,
|
||||
}: {
|
||||
context: AudioContext;
|
||||
startRequestId: number;
|
||||
startupRampSeconds: number;
|
||||
}
|
||||
): void {
|
||||
|
|
@ -113,11 +125,11 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
if (this.isMuted) {
|
||||
this.graph.setMasterGain(muteGain, muteRampSeconds);
|
||||
this.activateMutedStart(vibe, context);
|
||||
this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
const startRequestId = ++this.startRequestId;
|
||||
void this.piano
|
||||
.load(context)
|
||||
.then(() => {
|
||||
|
|
@ -155,15 +167,28 @@ export class GardenAudio {
|
|||
): void {
|
||||
this.lifecycle = 'started';
|
||||
this.currentVibeId = vibe.id;
|
||||
this.currentVibe = vibe;
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||
|
||||
if (cuePiano) {
|
||||
this.hasLoadedPiano = true;
|
||||
this.pianoEngine.cue(context.currentTime, profile);
|
||||
}
|
||||
}
|
||||
|
||||
private activateMutedStart(vibe: VibePreset, context: AudioContext): void {
|
||||
this.lifecycle = 'started';
|
||||
this.currentVibeId = vibe.id;
|
||||
this.currentVibe = vibe;
|
||||
this.hasLoadedPiano = false;
|
||||
this.graph.applyDelayProfile(getVibeProfile(vibe).bpm);
|
||||
if (this.graph.context === context) {
|
||||
this.pianoEngine.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
|
||||
const previousVibeId = this.currentVibeId;
|
||||
this.start(vibe, options);
|
||||
|
|
@ -171,6 +196,7 @@ export class GardenAudio {
|
|||
|
||||
if (didChangeVibe) {
|
||||
this.piano.stopAll();
|
||||
this.hasLoadedPiano = false;
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
|
|
@ -192,9 +218,13 @@ export class GardenAudio {
|
|||
|
||||
this.isMuted = isMuted;
|
||||
this.graph.setMasterGain(
|
||||
isMuted ? muteGain : this.masterVolume,
|
||||
isMuted ? SILENT_AUDIO_GAIN : this.masterVolume,
|
||||
isMuted ? muteRampSeconds : this.config.fadeInSeconds
|
||||
);
|
||||
|
||||
if (!isMuted && this.currentVibe && !this.hasLoadedPiano) {
|
||||
this.start(this.currentVibe);
|
||||
}
|
||||
}
|
||||
|
||||
public setMasterVolume(masterVolume: number): void {
|
||||
|
|
@ -211,9 +241,7 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.isReleasingPiano = false;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.pianoReleasePhase = { kind: 'idle' };
|
||||
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
|
||||
this.gestureState.reset();
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
|
|
@ -223,9 +251,7 @@ export class GardenAudio {
|
|||
public endGesture(): void {
|
||||
this.gestureState.reset();
|
||||
this.isGestureActive = false;
|
||||
this.isReleasingPiano = true;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.pianoReleasePhase = { kind: 'awaiting-fade' };
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
}
|
||||
|
|
@ -244,7 +270,7 @@ export class GardenAudio {
|
|||
this.energy.silence();
|
||||
}
|
||||
|
||||
if (!this.isGestureActive && this.isReleasingPiano) {
|
||||
if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') {
|
||||
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
||||
this.updateDelay(snapshot, profile);
|
||||
return;
|
||||
|
|
@ -299,14 +325,14 @@ export class GardenAudio {
|
|||
await this.graph.close();
|
||||
|
||||
this.piano.reset();
|
||||
this.hasLoadedPiano = false;
|
||||
this.energy.reset();
|
||||
this.gestureState.reset();
|
||||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.currentVibe = null;
|
||||
this.isGestureActive = false;
|
||||
this.isReleasingPiano = false;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.pianoReleasePhase = { kind: 'idle' };
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
|
@ -327,21 +353,41 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
private updatePianoRelease(vibe: VibePreset, now: number): void {
|
||||
if (this.fadePianoAt === null && this.stopPianoAt === null) {
|
||||
this.fadePianoAt = this.pianoEngine.release(vibe, now);
|
||||
}
|
||||
if (this.pianoReleasePhase.kind === 'awaiting-fade') {
|
||||
const fadeAt = this.pianoEngine.release(vibe, now);
|
||||
if (now < fadeAt) {
|
||||
this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt };
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
|
||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds;
|
||||
this.pianoReleasePhase = {
|
||||
kind: 'settling',
|
||||
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stopPianoAt !== null && now >= this.stopPianoAt) {
|
||||
if (
|
||||
this.pianoReleasePhase.kind === 'scheduled-fade' &&
|
||||
now >= this.pianoReleasePhase.fadeAt
|
||||
) {
|
||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||
this.pianoReleasePhase = {
|
||||
kind: 'settling',
|
||||
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.pianoReleasePhase.kind === 'settling' &&
|
||||
now >= this.pianoReleasePhase.stopAt
|
||||
) {
|
||||
this.piano.stopAll();
|
||||
this.pianoEngine.reset();
|
||||
this.stopPianoAt = null;
|
||||
this.isReleasingPiano = false;
|
||||
this.hasLoadedPiano = false;
|
||||
this.pianoReleasePhase = { kind: 'idle' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,8 +439,10 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.currentVibeId = vibe.id;
|
||||
this.currentVibe = vibe;
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
||||
this.hasLoadedPiano = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ export class NoiseBurstPlayer {
|
|||
filter.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
panner.connect(noiseBus);
|
||||
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
|
||||
const maxOffsetSeconds = Math.max(0, noiseBuffer.duration - durationSeconds);
|
||||
const offsetSeconds =
|
||||
Math.random() * Math.min(noiseBurstTuning.offsetRandomSeconds, maxOffsetSeconds);
|
||||
source.start(scheduledStart, offsetSeconds);
|
||||
source.stop(stopAt);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const pianoSamplerTuning = {
|
|||
minDurationSeconds: 0.08,
|
||||
minFadeSeconds: 0.08,
|
||||
minGain: 0.0001,
|
||||
releaseTimeConstantCount: 5,
|
||||
tailStopExtraSeconds: 0.05,
|
||||
voiceStealFadeSeconds: 0.025,
|
||||
voiceStealStopSeconds: 0.05,
|
||||
|
|
@ -84,7 +85,9 @@ export class PianoSampler {
|
|||
const sustainAt =
|
||||
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
const stopAt =
|
||||
releaseAt +
|
||||
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
|
||||
const source = context.createBufferSource();
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
|
|
|
|||
|
|
@ -31,51 +31,56 @@ import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
|
|||
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
|
||||
|
||||
interface PianoSampleDefinition {
|
||||
midi: number;
|
||||
path: string;
|
||||
note: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PianoSampleLoadProgress {
|
||||
failedCount: number;
|
||||
loadedCount: number;
|
||||
settledCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
|
||||
{ url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 },
|
||||
{ url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 },
|
||||
{ url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 },
|
||||
{ url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 },
|
||||
{ url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 },
|
||||
{ url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 },
|
||||
{ url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 },
|
||||
{ url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 },
|
||||
{ url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 },
|
||||
{ url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 },
|
||||
{ url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 },
|
||||
{ url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 },
|
||||
{ url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 },
|
||||
{ url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 },
|
||||
{ url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 },
|
||||
{ url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 },
|
||||
{ url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 },
|
||||
{ url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 },
|
||||
{ url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 },
|
||||
{ url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 },
|
||||
{ url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 },
|
||||
{ url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 },
|
||||
{ url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 },
|
||||
{ url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 },
|
||||
{ url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 },
|
||||
{ url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 },
|
||||
{ url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 },
|
||||
{ url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 },
|
||||
{ url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 },
|
||||
{ url: c8SampleUrl, path: './samples/C8v12.m4a', midi: 108 },
|
||||
{ url: a0SampleUrl, note: 'A0' },
|
||||
{ url: c1SampleUrl, note: 'C1' },
|
||||
{ url: dSharp1SampleUrl, note: 'Dsharp1' },
|
||||
{ url: fSharp1SampleUrl, note: 'Fsharp1' },
|
||||
{ url: a1SampleUrl, note: 'A1' },
|
||||
{ url: c2SampleUrl, note: 'C2' },
|
||||
{ url: dSharp2SampleUrl, note: 'Dsharp2' },
|
||||
{ url: fSharp2SampleUrl, note: 'Fsharp2' },
|
||||
{ url: a2SampleUrl, note: 'A2' },
|
||||
{ url: c3SampleUrl, note: 'C3' },
|
||||
{ url: dSharp3SampleUrl, note: 'Dsharp3' },
|
||||
{ url: fSharp3SampleUrl, note: 'Fsharp3' },
|
||||
{ url: a3SampleUrl, note: 'A3' },
|
||||
{ url: c4SampleUrl, note: 'C4' },
|
||||
{ url: dSharp4SampleUrl, note: 'Dsharp4' },
|
||||
{ url: fSharp4SampleUrl, note: 'Fsharp4' },
|
||||
{ url: a4SampleUrl, note: 'A4' },
|
||||
{ url: c5SampleUrl, note: 'C5' },
|
||||
{ url: dSharp5SampleUrl, note: 'Dsharp5' },
|
||||
{ url: fSharp5SampleUrl, note: 'Fsharp5' },
|
||||
{ url: a5SampleUrl, note: 'A5' },
|
||||
{ url: c6SampleUrl, note: 'C6' },
|
||||
{ url: dSharp6SampleUrl, note: 'Dsharp6' },
|
||||
{ url: fSharp6SampleUrl, note: 'Fsharp6' },
|
||||
{ url: a6SampleUrl, note: 'A6' },
|
||||
{ url: c7SampleUrl, note: 'C7' },
|
||||
{ url: dSharp7SampleUrl, note: 'Dsharp7' },
|
||||
{ url: fSharp7SampleUrl, note: 'Fsharp7' },
|
||||
{ url: a7SampleUrl, note: 'A7' },
|
||||
{ url: c8SampleUrl, note: 'C8' },
|
||||
];
|
||||
|
||||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
|
||||
const pianoSampleProgressListeners = new Set<
|
||||
(progress: PianoSampleLoadProgress) => void
|
||||
>();
|
||||
|
||||
const sampleLoadTuning = {
|
||||
concurrency: 4,
|
||||
|
|
@ -102,50 +107,65 @@ export const loadPianoSamples = (
|
|||
decodeContext: BaseAudioContext,
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
|
||||
|
||||
if (loadedPianoSamples) {
|
||||
onProgress?.({
|
||||
emitPianoSampleProgress({
|
||||
failedCount: 0,
|
||||
loadedCount: loadedPianoSamples.length,
|
||||
settledCount: loadedPianoSamples.length,
|
||||
totalCount: pianoSampleDefinitions.length,
|
||||
});
|
||||
unsubscribeProgress();
|
||||
return Promise.resolve([...loadedPianoSamples]);
|
||||
}
|
||||
|
||||
if (pianoSampleLoadPromise) {
|
||||
return pianoSampleLoadPromise;
|
||||
return pianoSampleLoadPromise.finally(unsubscribeProgress);
|
||||
}
|
||||
|
||||
let loadedCount = 0;
|
||||
let failedCount = 0;
|
||||
let settledCount = 0;
|
||||
const totalCount = pianoSampleDefinitions.length;
|
||||
onProgress?.({ loadedCount, totalCount });
|
||||
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||
|
||||
pianoSampleLoadPromise = loadPianoSampleBatch(
|
||||
pianoSampleDefinitions,
|
||||
async (sample) => {
|
||||
try {
|
||||
return await withTimeout(
|
||||
const loadedSample = await withTimeout(
|
||||
(signal) => loadPianoSample(decodeContext, sample, signal),
|
||||
sampleLoadTuning.sampleTimeoutMs
|
||||
);
|
||||
} finally {
|
||||
loadedCount += 1;
|
||||
onProgress?.({ loadedCount, totalCount });
|
||||
return loadedSample;
|
||||
} catch (error) {
|
||||
failedCount += 1;
|
||||
throw error;
|
||||
} finally {
|
||||
settledCount += 1;
|
||||
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||
}
|
||||
}
|
||||
).then(
|
||||
(samples) => {
|
||||
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
|
||||
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
|
||||
throw new Error(
|
||||
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
|
||||
);
|
||||
)
|
||||
.then(
|
||||
(samples) => {
|
||||
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
|
||||
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
|
||||
throw new Error(
|
||||
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
|
||||
);
|
||||
}
|
||||
return [...loadedPianoSamples];
|
||||
},
|
||||
(error: unknown) => {
|
||||
pianoSampleLoadPromise = null;
|
||||
pianoSampleProgressListeners.clear();
|
||||
throw error;
|
||||
}
|
||||
return [...loadedPianoSamples];
|
||||
},
|
||||
(error: unknown) => {
|
||||
pianoSampleLoadPromise = null;
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
)
|
||||
.finally(unsubscribeProgress);
|
||||
|
||||
return pianoSampleLoadPromise;
|
||||
};
|
||||
|
|
@ -160,12 +180,12 @@ const loadPianoSample = async (
|
|||
): Promise<LoadedPianoSample> => {
|
||||
const response = await fetch(sample.url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${sample.path}`);
|
||||
throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`);
|
||||
}
|
||||
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await decodeContext.decodeAudioData(audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
return { midi: getMidiForPianoSample(sample), buffer };
|
||||
};
|
||||
|
||||
const loadPianoSampleBatch = async (
|
||||
|
|
@ -205,3 +225,47 @@ const withTimeout = <T>(
|
|||
}
|
||||
);
|
||||
});
|
||||
|
||||
const subscribeToPianoSampleProgress = (
|
||||
onProgress: ((progress: PianoSampleLoadProgress) => void) | undefined
|
||||
): (() => void) => {
|
||||
if (!onProgress) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
pianoSampleProgressListeners.add(onProgress);
|
||||
if (lastPianoSampleProgress) {
|
||||
onProgress(lastPianoSampleProgress);
|
||||
}
|
||||
return () => {
|
||||
pianoSampleProgressListeners.delete(onProgress);
|
||||
};
|
||||
};
|
||||
|
||||
const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
|
||||
lastPianoSampleProgress = progress;
|
||||
pianoSampleProgressListeners.forEach((listener) => listener(progress));
|
||||
};
|
||||
|
||||
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
|
||||
`./samples/${sample.note}v12.m4a`;
|
||||
|
||||
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
|
||||
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid piano sample note ${sample.note}`);
|
||||
}
|
||||
|
||||
const semitoneByName: Record<string, number> = {
|
||||
C: 0,
|
||||
D: 2,
|
||||
E: 4,
|
||||
F: 5,
|
||||
G: 7,
|
||||
A: 9,
|
||||
B: 11,
|
||||
};
|
||||
const octave = Number(match.groups.octave);
|
||||
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
|
||||
return (octave + 1) * 12 + semitone;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,3 +5,12 @@ under CC BY 3.0.
|
|||
Source package: @audio-samples/piano-velocity12
|
||||
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
||||
License: https://creativecommons.org/licenses/by/3.0/
|
||||
|
||||
Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
|
||||
through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
|
||||
The app derives MIDI values from those note names in `piano-samples.ts`.
|
||||
|
||||
Repro notes: start from the matching `v12` OGG files in the source package and
|
||||
transcode each selected sample to AAC/M4A without renaming the note/velocity
|
||||
stem. The expected output filenames are `<note>v12.m4a`, for example
|
||||
`C4v12.m4a`.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { createGardenAudioConfig } from './audio/garden-audio-config';
|
||||
import {
|
||||
createGardenAudioConfig,
|
||||
DEFAULT_AUDIO_VOLUME,
|
||||
} from './audio/garden-audio-config';
|
||||
import { defaultSettings } from './config/default-settings';
|
||||
import { runtimeControls } from './config/runtime-controls';
|
||||
import type { GardenAppConfig } from './config/types';
|
||||
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||
|
||||
const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||
|
||||
export {
|
||||
normalizeNumberControlValue,
|
||||
normalizeRuntimeSettings,
|
||||
|
|
@ -19,6 +20,12 @@ export type {
|
|||
|
||||
export const appConfig = {
|
||||
audio: createGardenAudioConfig(),
|
||||
analytics: {
|
||||
autoCapturePageviews: true,
|
||||
domain: 'fleeting.garden',
|
||||
endpoint: 'https://stats.schmelczer.dev/status',
|
||||
logging: import.meta.env.DEV,
|
||||
},
|
||||
deltaTime: {
|
||||
maxDeltaTimeSeconds: 1 / 30,
|
||||
minDeltaTimeSeconds: 1 / 240,
|
||||
|
|
@ -85,13 +92,14 @@ export const appConfig = {
|
|||
letterSpacingEm: 0.07,
|
||||
maskAlphaThreshold: 32,
|
||||
maskGradientThreshold: 8,
|
||||
maskMaxPixels: 1_000_000,
|
||||
maskSampleDensity: 540,
|
||||
maxHeightRatio: 0.25,
|
||||
maxWidthRatio: 0.76,
|
||||
minEntryJitterPx: 6,
|
||||
minFontSizePx: 18,
|
||||
minTargetJitterPx: 1,
|
||||
pathEasing: 'easeOutQuad' as GardenAppConfig['simulation']['intro']['pathEasing'],
|
||||
pathEasing: 'easeOutQuad',
|
||||
pathProgressEpsilon: 0.001,
|
||||
radialJitterRatio: 0.35,
|
||||
radialStartEpsilon: 0.001,
|
||||
|
|
@ -124,7 +132,7 @@ export const appConfig = {
|
|||
controlScaleMax: 1.34,
|
||||
controlScaleMin: 0.74,
|
||||
default: 96,
|
||||
max: 240,
|
||||
max: 480,
|
||||
min: 24,
|
||||
step: 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import { runtimeControls } from './runtime-controls';
|
||||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
// Mirrors the historical render-scale cap so the default render area stays
|
||||
// roughly equivalent to native rendering on high-DPR phones without the
|
||||
// pipeline applying its own clamp. The slider can override freely.
|
||||
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
|
||||
const INTERNAL_RENDER_AREA_BOUNDS = {
|
||||
min: runtimeControls.internalRenderAreaMegapixels?.min ?? 0.5,
|
||||
max: runtimeControls.internalRenderAreaMegapixels?.max ?? 16.6,
|
||||
};
|
||||
|
||||
const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
||||
const rawDpr =
|
||||
|
|
@ -20,8 +16,8 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
|||
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
||||
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
|
||||
return Math.min(
|
||||
INTERNAL_RENDER_AREA_BOUNDS.max,
|
||||
Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels)
|
||||
INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||
Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { colorInteractionControl } from './color-interactions';
|
||||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
||||
|
|
@ -55,6 +56,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetAngle: {
|
||||
folder: 'Movement',
|
||||
label: 'Sensor Angle',
|
||||
min: 0,
|
||||
max: 180,
|
||||
step: 1,
|
||||
},
|
||||
moveSpeed: {
|
||||
folder: 'Movement',
|
||||
label: 'Travel Speed',
|
||||
|
|
@ -81,7 +89,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
folder: 'Movement',
|
||||
label: 'Wander Turn',
|
||||
min: 0,
|
||||
max: 6.28,
|
||||
max: Math.PI * 2,
|
||||
step: 0.01,
|
||||
},
|
||||
individualTrailWeight: {
|
||||
|
|
@ -132,8 +140,8 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
internalRenderAreaMegapixels: {
|
||||
folder: 'Performance',
|
||||
label: 'Render Quality (MP)',
|
||||
min: 0.5,
|
||||
max: 16.6,
|
||||
min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min,
|
||||
max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||
step: 0.1,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -103,6 +103,12 @@ export interface VibePreset {
|
|||
|
||||
export interface GardenAppConfig {
|
||||
audio: GardenAudioConfig;
|
||||
analytics: {
|
||||
autoCapturePageviews: boolean;
|
||||
domain: string;
|
||||
endpoint: string;
|
||||
logging: boolean;
|
||||
};
|
||||
deltaTime: {
|
||||
maxDeltaTimeSeconds: number;
|
||||
minDeltaTimeSeconds: number;
|
||||
|
|
@ -167,6 +173,7 @@ export interface GardenAppConfig {
|
|||
letterSpacingEm: number;
|
||||
maskAlphaThreshold: number;
|
||||
maskGradientThreshold: number;
|
||||
maskMaxPixels: number;
|
||||
maskSampleDensity: number;
|
||||
maxHeightRatio: number;
|
||||
maxWidthRatio: number;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runtimeControls } from './runtime-controls';
|
||||
import { vibePresets } from './vibe-presets';
|
||||
|
||||
const FINAL_VIBE_NAMES = [
|
||||
'Aurora Mycelium Copy',
|
||||
'Velvet Observatory',
|
||||
'Velvet Observatory Copy',
|
||||
'Lichen Signal',
|
||||
'Tidepool Lantern',
|
||||
'Paper Lantern Fog Copy',
|
||||
'Paper Lantern Fog',
|
||||
'Chrome Pollen',
|
||||
];
|
||||
|
||||
|
|
@ -17,8 +18,8 @@ const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
|
|||
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
|
||||
|
||||
// Performance guardrails — bumping any of these is an explicit perf trade-off.
|
||||
const MAX_SPAWN_PER_PIXEL = 0.38;
|
||||
const MAX_BRUSH_SIZE = 36;
|
||||
const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38;
|
||||
const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36;
|
||||
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
|
||||
const HIGH_DENSITY_DECAY_LIMIT = 940;
|
||||
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export const vibePresets: Array<VibePreset> = [
|
|||
id: VibeId.AuroraMycelium,
|
||||
name: 'Aurora Mycelium Copy',
|
||||
colors: [
|
||||
[221, 255, 78],
|
||||
[251, 210, 94],
|
||||
[154, 99, 255],
|
||||
[255, 31, 199],
|
||||
],
|
||||
|
|
@ -148,25 +148,25 @@ export const vibePresets: Array<VibePreset> = [
|
|||
...colorReactions.auroraMycelium,
|
||||
backgroundGrainStrength: 0.003,
|
||||
brushSize: 8.75,
|
||||
clarity: 0.379,
|
||||
decayRateTrails: 940,
|
||||
forwardRotationScale: 0,
|
||||
individualTrailWeight: 0.121,
|
||||
moveSpeed: 270,
|
||||
sensorOffsetAngle: 36,
|
||||
sensorOffsetDistance: 51,
|
||||
spawnPerPixel: 0.14,
|
||||
clarity: 1,
|
||||
decayRateTrails: 973,
|
||||
forwardRotationScale: 0.37,
|
||||
individualTrailWeight: 0.053000000000000005,
|
||||
moveSpeed: 144,
|
||||
sensorOffsetAngle: 35,
|
||||
sensorOffsetDistance: 52,
|
||||
spawnPerPixel: 0.13999999999999999,
|
||||
strokeAngleJitterRadians: 0.45,
|
||||
turnSpeed: 22,
|
||||
turnSpeed: 13,
|
||||
turnWhenLost: 0,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.12,
|
||||
idleIntensity: 0.12000000000000002,
|
||||
bpm: 60,
|
||||
rampUpIntensity: 0.7,
|
||||
rampUpTime: 0.14,
|
||||
noteLength: 0.86,
|
||||
noteLength: 0.8599999999999999,
|
||||
notePitchOffset: -2,
|
||||
brightness: 0.84,
|
||||
scale: musicScales.lydian,
|
||||
|
|
@ -175,7 +175,7 @@ export const vibePresets: Array<VibePreset> = [
|
|||
},
|
||||
{
|
||||
id: VibeId.VelvetObservatory,
|
||||
name: 'Velvet Observatory',
|
||||
name: 'Velvet Observatory Copy',
|
||||
colors: [
|
||||
[178, 76, 62],
|
||||
[2, 174, 255],
|
||||
|
|
@ -186,21 +186,21 @@ export const vibePresets: Array<VibePreset> = [
|
|||
...colorReactions.velvetObservatory,
|
||||
backgroundGrainStrength: 0.005,
|
||||
brushSize: 9.75,
|
||||
clarity: 0.437,
|
||||
decayRateTrails: 915,
|
||||
clarity: 1,
|
||||
decayRateTrails: 974,
|
||||
forwardRotationScale: 0,
|
||||
individualTrailWeight: 0.1,
|
||||
moveSpeed: 216,
|
||||
individualTrailWeight: 0.232,
|
||||
moveSpeed: 121,
|
||||
sensorOffsetAngle: 24,
|
||||
sensorOffsetDistance: 17,
|
||||
spawnPerPixel: 0.24,
|
||||
spawnPerPixel: 0.11499999999999999,
|
||||
strokeAngleJitterRadians: 0.17,
|
||||
turnSpeed: 33,
|
||||
turnWhenLost: 0.42,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.55,
|
||||
idleIntensity: 0.24000000000000002,
|
||||
bpm: 72,
|
||||
rampUpIntensity: 1.42,
|
||||
rampUpTime: 0.07,
|
||||
|
|
@ -289,7 +289,7 @@ export const vibePresets: Array<VibePreset> = [
|
|||
},
|
||||
{
|
||||
id: VibeId.PaperLanternFog,
|
||||
name: 'Paper Lantern Fog Copy',
|
||||
name: 'Paper Lantern Fog',
|
||||
colors: [
|
||||
[255, 176, 108],
|
||||
[239, 90, 108],
|
||||
|
|
|
|||
|
|
@ -145,6 +145,18 @@ describe('AgentPopulation stroke spawning', () => {
|
|||
expect(pipeline.writtenBatches[0][2]).toBe(0);
|
||||
});
|
||||
|
||||
it('clears active agents when an intro replacement has no generated agents', () => {
|
||||
const { population } = createPopulation();
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
|
||||
expect(population.activeAgentCount).toBe(3);
|
||||
|
||||
settings.maxAgentCount = 0;
|
||||
population.replaceIntroAgents(vec2.fromValues(100, 100), 0);
|
||||
|
||||
expect(population.activeAgentCount).toBe(0);
|
||||
});
|
||||
|
||||
it('queues stroke writes while async compaction is in flight', async () => {
|
||||
const { pipeline, population } = createPopulation();
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export class AgentPopulation {
|
|||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
this.activeCount = 0;
|
||||
this.replacementCursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,14 +46,16 @@ export class FramePerformance {
|
|||
return;
|
||||
}
|
||||
|
||||
const fps = 1 / deltaSeconds;
|
||||
this.frameDeltaSeconds = deltaSeconds;
|
||||
this.measuredFrameTimeMs = deltaSeconds * 1000;
|
||||
const fps = 1 / deltaSeconds;
|
||||
this.measuredFps = fps;
|
||||
if (deltaSeconds > FRAME_GAP_RESET_SECONDS) {
|
||||
this.frameDeltaSeconds = 0;
|
||||
this.smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
|
||||
return;
|
||||
}
|
||||
|
||||
this.frameDeltaSeconds = deltaSeconds;
|
||||
this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { appConfig, type GardenRuntimeSettings } from '../config';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
|
|
@ -9,7 +9,6 @@ import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
|||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
|
||||
import { GpuProfiler } from './gpu-profiler';
|
||||
|
|
@ -25,6 +24,7 @@ interface FrameParameters extends RenderInputs {
|
|||
introProgress: number;
|
||||
selectedColorIndex: number;
|
||||
eraserPixelSize: number;
|
||||
runtimeSettings: GardenRuntimeSettings;
|
||||
}
|
||||
|
||||
export class GameLoopResources {
|
||||
|
|
@ -46,7 +46,8 @@ export class GameLoopResources {
|
|||
private readonly device: GPUDevice,
|
||||
private readonly canvasFormat: GPUTextureFormat,
|
||||
canvasSize: vec2,
|
||||
initialAgentCapacity: number
|
||||
initialAgentCapacity: number,
|
||||
initialMaxAgentCount: number
|
||||
) {
|
||||
const context = initializeContext({ device, canvas, format: canvasFormat });
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ export class GameLoopResources {
|
|||
|
||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||
this.device,
|
||||
Math.min(settings.maxAgentCount, initialAgentCapacity)
|
||||
Math.min(initialMaxAgentCount, initialAgentCapacity)
|
||||
);
|
||||
|
||||
this.agentPipeline = new AgentPipeline(
|
||||
|
|
@ -74,12 +75,7 @@ export class GameLoopResources {
|
|||
);
|
||||
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
||||
this.renderPipeline = new RenderPipeline(
|
||||
context,
|
||||
this.device,
|
||||
this.commonState,
|
||||
this.canvasFormat
|
||||
);
|
||||
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
|
||||
this.gpuProfiler = GpuProfiler.create(
|
||||
this.device,
|
||||
() => appConfig.tuningPane.showFpsOverlay
|
||||
|
|
@ -128,43 +124,43 @@ export class GameLoopResources {
|
|||
channelColors,
|
||||
backgroundColor,
|
||||
eraserPixelSize,
|
||||
runtimeSettings,
|
||||
}: FrameParameters): void {
|
||||
this.commonState.setParameters({
|
||||
canvasSize,
|
||||
});
|
||||
this.agentPipeline.setParameters({
|
||||
...settings,
|
||||
...runtimeSettings,
|
||||
deltaTime,
|
||||
time,
|
||||
agentCount: activeAgentCount,
|
||||
moveSpeed: settings.moveSpeed,
|
||||
introMoveSpeed: appConfig.simulation.introMoveSpeed,
|
||||
introProgress,
|
||||
});
|
||||
this.brushPipeline.setParameters({
|
||||
...settings,
|
||||
...runtimeSettings,
|
||||
pixelRatio: canvasPixelRatio,
|
||||
selectedColorIndex,
|
||||
});
|
||||
this.diffusionPipeline.setParameters(settings);
|
||||
this.diffusionPipeline.setParameters(runtimeSettings);
|
||||
this.renderPipeline.setParameters({
|
||||
...settings,
|
||||
...runtimeSettings,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
});
|
||||
this.eraserAgentPipeline.setParameters({
|
||||
agentCount: activeAgentCount,
|
||||
eraserSize: eraserPixelSize,
|
||||
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold,
|
||||
eraserMaskAlphaThreshold: runtimeSettings.eraserMaskAlphaThreshold,
|
||||
maskSize: canvasSize,
|
||||
});
|
||||
this.eraserTexturePipeline.setParameters({
|
||||
eraserSize: eraserPixelSize,
|
||||
eraserLineDistanceEpsilon: settings.eraserLineDistanceEpsilon,
|
||||
eraserClearRed: settings.eraserClearRed,
|
||||
eraserClearGreen: settings.eraserClearGreen,
|
||||
eraserClearBlue: settings.eraserClearBlue,
|
||||
eraserClearAlpha: settings.eraserClearAlpha,
|
||||
eraserLineDistanceEpsilon: runtimeSettings.eraserLineDistanceEpsilon,
|
||||
eraserClearRed: runtimeSettings.eraserClearRed,
|
||||
eraserClearGreen: runtimeSettings.eraserClearGreen,
|
||||
eraserClearBlue: runtimeSettings.eraserClearBlue,
|
||||
eraserClearAlpha: runtimeSettings.eraserClearAlpha,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ export default class GameLoop {
|
|||
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
||||
private readonly seed = this.seedValue.toString(16);
|
||||
private readonly resizeListener = this.resize.bind(this);
|
||||
private readonly _canvasSize: vec2 = vec2.create();
|
||||
|
||||
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
||||
|
|
@ -55,7 +54,8 @@ export default class GameLoop {
|
|||
device,
|
||||
this.canvasFormat,
|
||||
this.canvasSize,
|
||||
this.framePerformance.adaptiveCapInitial
|
||||
this.framePerformance.adaptiveCapInitial,
|
||||
settings.maxAgentCount
|
||||
);
|
||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||
this.toolbarContrastMonitor = new ToolbarContrastMonitor(
|
||||
|
|
@ -112,13 +112,12 @@ export default class GameLoop {
|
|||
getVibeId: () => activeVibe.id,
|
||||
});
|
||||
|
||||
window.addEventListener('resize', this.resizeListener);
|
||||
this.eraserPreview.attach();
|
||||
this.syncPerfStatsOverlay();
|
||||
}
|
||||
|
||||
public attachPointerInput(): void {
|
||||
this.pointerInput.attach();
|
||||
this.eraserPreview.attach();
|
||||
}
|
||||
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
|
|
@ -173,9 +172,6 @@ export default class GameLoop {
|
|||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
this.finished.resolve();
|
||||
|
||||
window.removeEventListener('resize', this.resizeListener);
|
||||
this.pointerInput.detach();
|
||||
this.eraserPreview.detach();
|
||||
this.perfStatsOverlay?.destroy();
|
||||
|
|
@ -185,6 +181,7 @@ export default class GameLoop {
|
|||
await this.agentPopulation.waitForCompaction();
|
||||
this.resources.destroy();
|
||||
await this.audio.destroy();
|
||||
this.finished.resolve();
|
||||
}
|
||||
|
||||
private readonly render = (time: DOMHighResTimeStamp) => {
|
||||
|
|
@ -204,13 +201,15 @@ export default class GameLoop {
|
|||
|
||||
const channelColors = activeVibe.colors;
|
||||
const backgroundColor = activeVibe.backgroundColor;
|
||||
const runtimeSettings = { ...settings };
|
||||
const introProgress = this.introPrompt.progress;
|
||||
const canvasPixelRatio = this.canvasPixelRatio;
|
||||
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
|
||||
const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
|
||||
const isErasing = this.pointerInput.isEraseMode;
|
||||
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
||||
const accentColor =
|
||||
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
|
||||
this.updateAccentColor(accentColor);
|
||||
this.updateGrainOverlay(settings.backgroundGrainStrength);
|
||||
this.updateGrainOverlay(runtimeSettings.backgroundGrainStrength);
|
||||
this.audio.update({
|
||||
vibe: activeVibe,
|
||||
isErasing,
|
||||
|
|
@ -223,10 +222,11 @@ export default class GameLoop {
|
|||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||
canvasPixelRatio,
|
||||
introProgress,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
selectedColorIndex: runtimeSettings.selectedColorIndex,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
eraserPixelSize,
|
||||
runtimeSettings,
|
||||
});
|
||||
|
||||
this.resources.executeFrame(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import { appConfig, type GardenAppConfig } from '../config';
|
||||
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
|
||||
|
||||
|
|
@ -18,8 +18,11 @@ interface IntroTitleAgentOptions {
|
|||
}
|
||||
|
||||
type RandomSource = () => number;
|
||||
type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing'];
|
||||
|
||||
const INTRO_TITLE = appConfig.simulation.intro.title;
|
||||
const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean =>
|
||||
pathEasing === 'linear';
|
||||
|
||||
export const createIntroTitleAgents = ({
|
||||
count,
|
||||
|
|
@ -169,16 +172,22 @@ const createIntroTitlePoints = (
|
|||
width: number,
|
||||
height: number
|
||||
): Array<IntroTitlePoint> => {
|
||||
const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels);
|
||||
const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height)));
|
||||
const maskWidth = Math.max(1, Math.round(width * maskScale));
|
||||
const maskHeight = Math.max(1, Math.round(height * maskScale));
|
||||
const pointScaleX = width / maskWidth;
|
||||
const pointScaleY = height / maskHeight;
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
maskCanvas.width = width;
|
||||
maskCanvas.height = height;
|
||||
maskCanvas.width = maskWidth;
|
||||
maskCanvas.height = maskHeight;
|
||||
const context = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fontSize = getIntroTitleFontSize(context, width, height);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight);
|
||||
context.clearRect(0, 0, maskWidth, maskHeight);
|
||||
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
|
|
@ -192,42 +201,44 @@ const createIntroTitlePoints = (
|
|||
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
|
||||
drawIntroTitleText(
|
||||
context,
|
||||
width / 2,
|
||||
height * appConfig.simulation.intro.verticalAnchor,
|
||||
maskWidth / 2,
|
||||
maskHeight * appConfig.simulation.intro.verticalAnchor,
|
||||
letterSpacing,
|
||||
'stroke'
|
||||
);
|
||||
drawIntroTitleText(
|
||||
context,
|
||||
width / 2,
|
||||
height * appConfig.simulation.intro.verticalAnchor,
|
||||
maskWidth / 2,
|
||||
maskHeight * appConfig.simulation.intro.verticalAnchor,
|
||||
letterSpacing,
|
||||
'fill'
|
||||
);
|
||||
|
||||
const { data } = context.getImageData(0, 0, width, height);
|
||||
const { data } = context.getImageData(0, 0, maskWidth, maskHeight);
|
||||
const step = Math.max(
|
||||
1,
|
||||
Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity)
|
||||
Math.floor(
|
||||
Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity
|
||||
)
|
||||
);
|
||||
const points: Array<IntroTitlePoint> = [];
|
||||
const characterColorBoundaries = getIntroTitleColorBoundaries(
|
||||
context,
|
||||
width,
|
||||
maskWidth,
|
||||
letterSpacing
|
||||
);
|
||||
|
||||
for (let y = 0; y < height; y += step) {
|
||||
for (let x = 0; x < width; x += step) {
|
||||
const alpha = getMaskAlpha(data, width, height, x, y);
|
||||
for (let y = 0; y < maskHeight; y += step) {
|
||||
for (let x = 0; x < maskWidth; x += step) {
|
||||
const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y);
|
||||
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
points.push({
|
||||
x,
|
||||
y,
|
||||
tangent: estimateMaskTangent(data, width, height, x, y),
|
||||
x: x * pointScaleX,
|
||||
y: y * pointScaleY,
|
||||
tangent: estimateMaskTangent(data, maskWidth, maskHeight, x, y),
|
||||
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
|
||||
});
|
||||
}
|
||||
|
|
@ -244,8 +255,10 @@ const getIntroTitleColorBoundaries = (
|
|||
const letters = Array.from(INTRO_TITLE);
|
||||
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||
let x = width / 2 - totalWidth / 2;
|
||||
const [firstCutLetter, secondCutLetter] =
|
||||
appConfig.simulation.intro.titleColorCutLetters;
|
||||
const cutLetters = appConfig.simulation.intro.titleColorCutLetters
|
||||
.map((cutLetter) => Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter))))
|
||||
.sort((a, b) => a - b);
|
||||
const [firstCutLetter, secondCutLetter] = cutLetters;
|
||||
const letterBoxes = letters.map((letter, index) => {
|
||||
const letterWidth = context.measureText(letter).width;
|
||||
const box = {
|
||||
|
|
@ -401,7 +414,7 @@ const createSeededRandom = (seed: number): RandomSource => {
|
|||
};
|
||||
|
||||
const easePathProgress = (amount: number): number => {
|
||||
if (appConfig.simulation.intro.pathEasing === 'linear') {
|
||||
if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const PERF_STATS_REFRESH_MS = 200;
|
||||
const UNAVAILABLE_STAT_TEXT = 'n/a';
|
||||
const ZERO_STAT_TEXT = '0';
|
||||
const ZERO_FRAME_TIME_TEXT = '0ms';
|
||||
const ZERO_RESOLUTION_TEXT = '0x0';
|
||||
|
|
@ -39,7 +40,7 @@ export class PerfStatsOverlay {
|
|||
}
|
||||
|
||||
this.previousUpdateTime = time;
|
||||
const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
|
||||
const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatOptionalFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
|
||||
if (text !== this.previousText) {
|
||||
this.element.textContent = text;
|
||||
this.previousText = text;
|
||||
|
|
@ -68,6 +69,11 @@ const formatFrameTime = (frameTimeMs: number | undefined): string => {
|
|||
return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`;
|
||||
};
|
||||
|
||||
const formatOptionalFrameTime = (frameTimeMs: number | undefined): string =>
|
||||
typeof frameTimeMs === 'number' && Number.isFinite(frameTimeMs)
|
||||
? formatFrameTime(frameTimeMs)
|
||||
: UNAVAILABLE_STAT_TEXT;
|
||||
|
||||
const formatResolution = (width: number, height: number): string =>
|
||||
Number.isFinite(width) && Number.isFinite(height)
|
||||
? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}`
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ export class GardenPointerInput {
|
|||
this.options.onStartDrawing();
|
||||
this.activePointerId = event.pointerId;
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
this.options.strokeOutput.clearSwipes();
|
||||
this.lastPointerPosition = null;
|
||||
this.lastPointerEventTimeMs = null;
|
||||
this.brushSmoother.clear();
|
||||
|
|
@ -122,11 +121,16 @@ export class GardenPointerInput {
|
|||
if (this.isErasing) {
|
||||
this.options.onEraseGestureEnded();
|
||||
}
|
||||
this.canvas.releasePointerCapture(event.pointerId);
|
||||
this.activePointerId = null;
|
||||
this.lastPointerPosition = null;
|
||||
this.lastPointerEventTimeMs = null;
|
||||
this.brushSmoother.clear();
|
||||
try {
|
||||
if (this.canvas.hasPointerCapture(event.pointerId)) {
|
||||
this.canvas.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
} finally {
|
||||
this.activePointerId = null;
|
||||
this.lastPointerPosition = null;
|
||||
this.lastPointerEventTimeMs = null;
|
||||
this.brushSmoother.clear();
|
||||
}
|
||||
};
|
||||
|
||||
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export class SimulationFrameRenderer {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget(
|
||||
wroteSourceMap = this.pipelines.brushPipeline.executeSource(
|
||||
commandEncoder,
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
this.gpuProfiler?.timestampWrites('brush')
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export class SimulationTextures {
|
|||
// diffused texture becomes trailMapA for the next frame.
|
||||
public trailMapA: ResizableTexture;
|
||||
public trailMapB: ResizableTexture;
|
||||
// Per-frame deposit accumulator: cleared each frame, written sparsely by
|
||||
// Per-frame last-writer deposit map: cleared each frame, written sparsely by
|
||||
// agents, then read by diffuse alongside trailMapA.
|
||||
public readonly depositMap: ResizableTexture;
|
||||
public readonly eraserMask: ResizableTexture;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ interface CanvasSamplePoint {
|
|||
y: number;
|
||||
}
|
||||
|
||||
interface CanvasSampleRegion {
|
||||
bytesPerRow: number;
|
||||
height: number;
|
||||
origin: CanvasSamplePoint;
|
||||
sampleOffsets: Array<number>;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ToolbarContrastMetrics {
|
||||
averageLuminance: number;
|
||||
backgroundOpacity: number;
|
||||
|
|
@ -16,6 +24,7 @@ interface ToolbarContrastMetrics {
|
|||
|
||||
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
||||
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
||||
const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256;
|
||||
|
||||
const getLinearChannel = (channel: number): number => {
|
||||
const normalized = channel / 255;
|
||||
|
|
@ -33,16 +42,13 @@ const getRelativeLuminance = (red: number, green: number, blue: number): number
|
|||
|
||||
const getToolbarContrastMetrics = (
|
||||
pixels: Uint8Array,
|
||||
sampleCount: number,
|
||||
sampleOffsets: ReadonlyArray<number>,
|
||||
isBgra: boolean
|
||||
): ToolbarContrastMetrics => {
|
||||
const count = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
sampleCount,
|
||||
Math.floor(pixels.length / appConfig.toolbar.contrast.bytesPerSample)
|
||||
)
|
||||
);
|
||||
const count = sampleOffsets.filter(
|
||||
(offset) =>
|
||||
offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length
|
||||
).length;
|
||||
if (count === 0) {
|
||||
return {
|
||||
averageLuminance: 0,
|
||||
|
|
@ -56,8 +62,14 @@ const getToolbarContrastMetrics = (
|
|||
let brightCount = 0;
|
||||
let lowContrastCount = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = i * appConfig.toolbar.contrast.bytesPerSample;
|
||||
sampleOffsets.forEach((offset) => {
|
||||
if (
|
||||
offset < 0 ||
|
||||
offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const red = pixels[offset + (isBgra ? 2 : 0)];
|
||||
const green = pixels[offset + 1];
|
||||
const blue = pixels[offset + (isBgra ? 0 : 2)];
|
||||
|
|
@ -73,7 +85,7 @@ const getToolbarContrastMetrics = (
|
|||
if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
|
||||
lowContrastCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const averageLuminance = luminanceTotal / count;
|
||||
const brightRatio = brightCount / count;
|
||||
|
|
@ -100,6 +112,8 @@ export class ToolbarContrastMonitor {
|
|||
private isDestroyed = false;
|
||||
private isReadbackPending = false;
|
||||
private lastSampleAt = Number.NEGATIVE_INFINITY;
|
||||
private readbackBuffer: GPUBuffer | null = null;
|
||||
private readbackBufferSize = 0;
|
||||
|
||||
public constructor(
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
|
|
@ -119,45 +133,29 @@ export class ToolbarContrastMonitor {
|
|||
return null;
|
||||
}
|
||||
|
||||
const samplePoints = this.getSamplePoints();
|
||||
if (samplePoints.length === 0) {
|
||||
const sampleRegion = this.getSampleRegion();
|
||||
if (sampleRegion.sampleOffsets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let buffer: GPUBuffer;
|
||||
try {
|
||||
buffer = this.device.createBuffer({
|
||||
size: samplePoints.length * appConfig.toolbar.contrast.bytesPerSample,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
});
|
||||
} catch {
|
||||
const bufferSize = sampleRegion.bytesPerRow * sampleRegion.height;
|
||||
const buffer = this.getReadbackBuffer(bufferSize);
|
||||
if (!buffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.isReadbackPending = true;
|
||||
this.lastSampleAt = time;
|
||||
|
||||
let isBufferDestroyed = false;
|
||||
let isCancelled = false;
|
||||
let isEncoded = false;
|
||||
const destroyBuffer = () => {
|
||||
if (isBufferDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
isBufferDestroyed = true;
|
||||
buffer.destroy();
|
||||
};
|
||||
const cancel = (destroyNow = true) => {
|
||||
const cancel = () => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCancelled = true;
|
||||
this.isReadbackPending = false;
|
||||
if (destroyNow) {
|
||||
destroyBuffer();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -167,31 +165,28 @@ export class ToolbarContrastMonitor {
|
|||
}
|
||||
|
||||
try {
|
||||
samplePoints.forEach((point, index) => {
|
||||
commandEncoder.copyTextureToBuffer(
|
||||
{
|
||||
origin: point,
|
||||
texture,
|
||||
},
|
||||
{
|
||||
buffer,
|
||||
offset: index * appConfig.toolbar.contrast.bytesPerSample,
|
||||
},
|
||||
{
|
||||
depthOrArrayLayers: 1,
|
||||
height: 1,
|
||||
width: 1,
|
||||
}
|
||||
);
|
||||
});
|
||||
commandEncoder.copyTextureToBuffer(
|
||||
{
|
||||
origin: sampleRegion.origin,
|
||||
texture,
|
||||
},
|
||||
{
|
||||
buffer,
|
||||
bytesPerRow: sampleRegion.bytesPerRow,
|
||||
},
|
||||
{
|
||||
depthOrArrayLayers: 1,
|
||||
height: sampleRegion.height,
|
||||
width: sampleRegion.width,
|
||||
}
|
||||
);
|
||||
isEncoded = true;
|
||||
} catch {
|
||||
cancel(false);
|
||||
cancel();
|
||||
}
|
||||
},
|
||||
afterSubmit: () => {
|
||||
if (isCancelled) {
|
||||
destroyBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -200,13 +195,16 @@ export class ToolbarContrastMonitor {
|
|||
return;
|
||||
}
|
||||
|
||||
void this.readBuffer(buffer, samplePoints.length);
|
||||
void this.readBuffer(buffer, sampleRegion.sampleOffsets);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.isDestroyed = true;
|
||||
this.readbackBuffer?.destroy();
|
||||
this.readbackBuffer = null;
|
||||
this.readbackBufferSize = 0;
|
||||
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY);
|
||||
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY);
|
||||
}
|
||||
|
|
@ -231,7 +229,14 @@ export class ToolbarContrastMonitor {
|
|||
);
|
||||
}
|
||||
|
||||
private getSamplePoints(): Array<CanvasSamplePoint> {
|
||||
private getSampleRegion(): CanvasSampleRegion {
|
||||
const emptyRegion = {
|
||||
bytesPerRow: 0,
|
||||
height: 0,
|
||||
origin: { x: 0, y: 0 },
|
||||
sampleOffsets: [],
|
||||
width: 0,
|
||||
};
|
||||
const canvasRect = this.canvas.getBoundingClientRect();
|
||||
const toolbarRect = this.toolbar.getBoundingClientRect();
|
||||
if (
|
||||
|
|
@ -240,7 +245,7 @@ export class ToolbarContrastMonitor {
|
|||
toolbarRect.width <= 0 ||
|
||||
toolbarRect.height <= 0
|
||||
) {
|
||||
return [];
|
||||
return emptyRegion;
|
||||
}
|
||||
|
||||
const left = Math.max(canvasRect.left, toolbarRect.left);
|
||||
|
|
@ -248,17 +253,40 @@ export class ToolbarContrastMonitor {
|
|||
const top = Math.max(canvasRect.top, toolbarRect.top);
|
||||
const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom);
|
||||
if (left >= right || top >= bottom) {
|
||||
return [];
|
||||
return emptyRegion;
|
||||
}
|
||||
|
||||
const xScale = this.canvas.width / canvasRect.width;
|
||||
const yScale = this.canvas.height / canvasRect.height;
|
||||
const width = right - left;
|
||||
const height = bottom - top;
|
||||
const cssWidth = right - left;
|
||||
const cssHeight = bottom - top;
|
||||
const origin = {
|
||||
x: Math.max(0, Math.floor((left - canvasRect.left) * xScale)),
|
||||
y: Math.max(0, Math.floor((top - canvasRect.top) * yScale)),
|
||||
};
|
||||
const regionRight = Math.min(
|
||||
this.canvas.width,
|
||||
Math.ceil((right - canvasRect.left) * xScale)
|
||||
);
|
||||
const regionBottom = Math.min(
|
||||
this.canvas.height,
|
||||
Math.ceil((bottom - canvasRect.top) * yScale)
|
||||
);
|
||||
const width = Math.max(0, regionRight - origin.x);
|
||||
const height = Math.max(0, regionBottom - origin.y);
|
||||
if (width === 0 || height === 0) {
|
||||
return emptyRegion;
|
||||
}
|
||||
|
||||
const bytesPerRow = alignTo(
|
||||
width * appConfig.toolbar.contrast.bytesPerSample,
|
||||
GPU_COPY_BYTES_PER_ROW_ALIGNMENT
|
||||
);
|
||||
const points = new Map<string, CanvasSamplePoint>();
|
||||
|
||||
for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) {
|
||||
const cssY = top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * height;
|
||||
const cssY =
|
||||
top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight;
|
||||
const y = Math.min(
|
||||
this.canvas.height - 1,
|
||||
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
|
||||
|
|
@ -266,7 +294,7 @@ export class ToolbarContrastMonitor {
|
|||
|
||||
for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
|
||||
const cssX =
|
||||
left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * width;
|
||||
left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth;
|
||||
const x = Math.min(
|
||||
this.canvas.width - 1,
|
||||
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
|
||||
|
|
@ -275,10 +303,43 @@ export class ToolbarContrastMonitor {
|
|||
}
|
||||
}
|
||||
|
||||
return [...points.values()];
|
||||
return {
|
||||
bytesPerRow,
|
||||
height,
|
||||
origin,
|
||||
sampleOffsets: [...points.values()].map(
|
||||
(point) =>
|
||||
(point.y - origin.y) * bytesPerRow +
|
||||
(point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample
|
||||
),
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
private async readBuffer(buffer: GPUBuffer, sampleCount: number): Promise<void> {
|
||||
private getReadbackBuffer(size: number): GPUBuffer | null {
|
||||
if (this.readbackBuffer && this.readbackBufferSize >= size) {
|
||||
return this.readbackBuffer;
|
||||
}
|
||||
|
||||
this.readbackBuffer?.destroy();
|
||||
try {
|
||||
this.readbackBuffer = this.device.createBuffer({
|
||||
size,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
});
|
||||
this.readbackBufferSize = size;
|
||||
return this.readbackBuffer;
|
||||
} catch {
|
||||
this.readbackBuffer = null;
|
||||
this.readbackBufferSize = 0;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readBuffer(
|
||||
buffer: GPUBuffer,
|
||||
sampleOffsets: Array<number>
|
||||
): Promise<void> {
|
||||
let isMapped = false;
|
||||
try {
|
||||
await buffer.mapAsync(GPUMapMode.READ);
|
||||
|
|
@ -286,7 +347,7 @@ export class ToolbarContrastMonitor {
|
|||
|
||||
if (!this.isDestroyed) {
|
||||
const pixels = new Uint8Array(buffer.getMappedRange());
|
||||
const metrics = getToolbarContrastMetrics(pixels, sampleCount, this.isBgra);
|
||||
const metrics = getToolbarContrastMetrics(pixels, sampleOffsets, this.isBgra);
|
||||
this.setToolbarBackgroundOpacity(metrics.backgroundOpacity);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -295,8 +356,10 @@ export class ToolbarContrastMonitor {
|
|||
if (isMapped) {
|
||||
buffer.unmap();
|
||||
}
|
||||
buffer.destroy();
|
||||
this.isReadbackPending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const alignTo = (value: number, alignment: number): number =>
|
||||
Math.ceil(value / alignment) * alignment;
|
||||
|
|
|
|||
|
|
@ -82,11 +82,13 @@ const main = async () => {
|
|||
);
|
||||
|
||||
const splash = new SplashScreen();
|
||||
let eraserSizeControl: EraserSizeControl | null = null;
|
||||
const paletteControl = new PaletteControl({
|
||||
getGame,
|
||||
onChange: () => configPane?.refresh(),
|
||||
onModeChange: (isEraserActive) => eraserSizeControl?.setActive(isEraserActive),
|
||||
});
|
||||
const eraserSizeControl = new EraserSizeControl({
|
||||
eraserSizeControl = new EraserSizeControl({
|
||||
getGame,
|
||||
onActivate: () => paletteControl.setEraserActive(true),
|
||||
onChange: () => configPane?.refresh(),
|
||||
|
|
@ -104,7 +106,8 @@ const main = async () => {
|
|||
});
|
||||
|
||||
const syncRuntimeUi = () => {
|
||||
eraserSizeControl.render();
|
||||
eraserSizeControl?.render();
|
||||
eraserSizeControl?.setActive(paletteControl.isEraserActive);
|
||||
mirrorSegmentControl.render();
|
||||
paletteControl.render();
|
||||
};
|
||||
|
|
@ -243,7 +246,6 @@ const main = async () => {
|
|||
ErrorPresenter.renderStartup(e);
|
||||
ErrorHandler.addException(e);
|
||||
}
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export class CollapsiblePanelAnimator {
|
||||
private _isOpen = false;
|
||||
private focusBeforeOpen: HTMLElement | null = null;
|
||||
private readonly abortController = new AbortController();
|
||||
public onOpen?: () => void;
|
||||
|
||||
public constructor(
|
||||
|
|
@ -8,17 +9,23 @@ export class CollapsiblePanelAnimator {
|
|||
private readonly collapsibleContent: HTMLElement,
|
||||
ignoreForCloseOnClick: HTMLElement
|
||||
) {
|
||||
toggleButton.addEventListener('click', this.toggle.bind(this));
|
||||
const { signal } = this.abortController;
|
||||
toggleButton.addEventListener('click', this.toggle, { signal });
|
||||
window.addEventListener(
|
||||
'click',
|
||||
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
|
||||
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close(),
|
||||
{ signal }
|
||||
);
|
||||
window.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (this._isOpen && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (this._isOpen && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
this.syncAccessibility();
|
||||
}
|
||||
|
||||
|
|
@ -49,12 +56,16 @@ export class CollapsiblePanelAnimator {
|
|||
}
|
||||
}
|
||||
|
||||
public toggle() {
|
||||
public readonly toggle = () => {
|
||||
if (this._isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
};
|
||||
|
||||
public destroy(): void {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
public get isOpen() {
|
||||
|
|
|
|||
|
|
@ -9,15 +9,10 @@ import {
|
|||
type NumberControlConfig,
|
||||
} from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import {
|
||||
hexColorToRgbColor,
|
||||
rgbColorToCss,
|
||||
rgbColorToHex,
|
||||
type RgbColor,
|
||||
} from '../utils/rgb-color';
|
||||
import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
|
||||
import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
|
||||
|
||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
||||
type RuntimeControlKey = keyof GardenRuntimeSettings & string;
|
||||
type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
|
||||
type NumberPropertyKey<T> = {
|
||||
|
|
@ -33,31 +28,6 @@ interface PaneState extends GardenAudioVibeSettings {
|
|||
color3: string;
|
||||
}
|
||||
|
||||
const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
|
||||
const COLOR_REACTION_STATES = [
|
||||
{ id: 'follow', label: 'Move Toward', value: 1 },
|
||||
{ id: 'ignore', label: 'Ignore', value: 0 },
|
||||
{ id: 'avoid', label: 'Move Away', value: -1 },
|
||||
] as const;
|
||||
|
||||
const colorReactionRows = [
|
||||
{
|
||||
colorIndex: 0,
|
||||
label: COLOR_REACTION_LABELS[0],
|
||||
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
|
||||
},
|
||||
{
|
||||
colorIndex: 1,
|
||||
label: COLOR_REACTION_LABELS[1],
|
||||
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
|
||||
},
|
||||
{
|
||||
colorIndex: 2,
|
||||
label: COLOR_REACTION_LABELS[2],
|
||||
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
|
||||
|
||||
const MUSIC_CONTROLS: ReadonlyArray<{
|
||||
|
|
@ -111,37 +81,11 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
|||
return params;
|
||||
};
|
||||
|
||||
const getColorReactionStateIndex = (value: number): number =>
|
||||
COLOR_REACTION_STATES.findIndex((state) => state.value === value);
|
||||
|
||||
const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
|
||||
COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
|
||||
|
||||
const getNextColorReactionState = (
|
||||
value: number
|
||||
): (typeof COLOR_REACTION_STATES)[number] => {
|
||||
const index = getColorReactionStateIndex(value);
|
||||
return COLOR_REACTION_STATES[
|
||||
((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
|
||||
];
|
||||
};
|
||||
|
||||
export class ConfigPane {
|
||||
private readonly container: HTMLDivElement;
|
||||
private readonly closeButton: HTMLButtonElement;
|
||||
private readonly colorReactionMatrix: ColorReactionMatrixControl;
|
||||
private readonly pane: Pane;
|
||||
private readonly colorReactionButtons = new Map<
|
||||
ColorReactionKey,
|
||||
{
|
||||
element: HTMLButtonElement;
|
||||
sourceColorIndex: number;
|
||||
targetColorIndex: number;
|
||||
}
|
||||
>();
|
||||
private readonly colorReactionSwatches: Array<{
|
||||
colorIndex: number;
|
||||
element: HTMLElement;
|
||||
}> = [];
|
||||
private readonly state: PaneState = {
|
||||
backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
|
||||
color1: rgbColorToHex(activeVibe.colors[0]),
|
||||
|
|
@ -151,6 +95,9 @@ export class ConfigPane {
|
|||
};
|
||||
|
||||
public constructor(private readonly options: ConfigPaneOptions) {
|
||||
this.colorReactionMatrix = new ColorReactionMatrixControl(
|
||||
this.options.onRuntimeChange
|
||||
);
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'config-pane-container';
|
||||
|
||||
|
|
@ -191,7 +138,7 @@ export class ConfigPane {
|
|||
public refresh(): void {
|
||||
this.syncVibeState();
|
||||
this.pane.refresh();
|
||||
this.syncColorReactionMatrix();
|
||||
this.colorReactionMatrix.sync();
|
||||
this.syncOpenState();
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +184,7 @@ export class ConfigPane {
|
|||
this.setUpVibeSection(container);
|
||||
this.addRuntimeSection(container, runtimeFolderOrder[0], true);
|
||||
this.addRuntimeSection(container, runtimeFolderOrder[1], true);
|
||||
this.addColorReactionMatrix(container);
|
||||
this.colorReactionMatrix.addTo(container);
|
||||
this.addRuntimeSection(container, runtimeFolderOrder[2], true);
|
||||
const performanceFolder = this.addRuntimeSection(
|
||||
container,
|
||||
|
|
@ -246,7 +193,7 @@ export class ConfigPane {
|
|||
);
|
||||
this.addFpsOverlayBinding(performanceFolder);
|
||||
this.setUpMusicSection(container);
|
||||
this.syncColorReactionMatrix();
|
||||
this.colorReactionMatrix.sync();
|
||||
}
|
||||
|
||||
private setUpVibeSection(container: PaneContainer): void {
|
||||
|
|
@ -295,7 +242,7 @@ export class ConfigPane {
|
|||
}
|
||||
|
||||
updateColor(color);
|
||||
this.syncColorReactionMatrix();
|
||||
this.colorReactionMatrix.sync();
|
||||
this.options.onConfigChange();
|
||||
});
|
||||
}
|
||||
|
|
@ -352,132 +299,6 @@ export class ConfigPane {
|
|||
.on('change', () => this.options.onConfigChange());
|
||||
}
|
||||
|
||||
private addColorReactionMatrix(container: PaneContainer): void {
|
||||
const folder = container.addFolder({
|
||||
title: 'Color Behavior',
|
||||
expanded: true,
|
||||
});
|
||||
|
||||
const matrix = document.createElement('div');
|
||||
matrix.className = 'color-reaction-matrix';
|
||||
|
||||
matrix.appendChild(this.createColorReactionCorner());
|
||||
colorReactionRows.forEach((row) => {
|
||||
matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label));
|
||||
});
|
||||
|
||||
colorReactionRows.forEach((row) => {
|
||||
matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label));
|
||||
row.keys.forEach((key, columnIndex) => {
|
||||
matrix.appendChild(
|
||||
this.createColorReactionCell(key, row.colorIndex, columnIndex)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const matrixBlade = folder.addBlade({ view: 'separator' });
|
||||
matrixBlade.element.classList.add('color-reaction-matrix-blade');
|
||||
matrixBlade.element.replaceChildren(matrix);
|
||||
this.syncColorReactionMatrix();
|
||||
}
|
||||
|
||||
private createColorReactionCorner(): HTMLDivElement {
|
||||
const corner = document.createElement('div');
|
||||
corner.className = 'color-reaction-matrix__corner';
|
||||
return corner;
|
||||
}
|
||||
|
||||
private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'color-reaction-matrix__header';
|
||||
header.setAttribute('aria-label', label);
|
||||
header.title = label;
|
||||
|
||||
const swatch = document.createElement('span');
|
||||
swatch.className = 'color-reaction-matrix__swatch';
|
||||
this.colorReactionSwatches.push({ colorIndex, element: swatch });
|
||||
header.appendChild(swatch);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
private createColorReactionCell(
|
||||
key: ColorReactionKey,
|
||||
sourceColorIndex: number,
|
||||
targetColorIndex: number
|
||||
): HTMLDivElement {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'color-reaction-matrix__cell';
|
||||
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config) {
|
||||
return cell;
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'color-reaction-matrix__button';
|
||||
button.type = 'button';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'color-reaction-matrix__icon';
|
||||
button.appendChild(icon);
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const currentValue = normalizeNumberControlValue(settings[key], config);
|
||||
const nextState = getNextColorReactionState(currentValue);
|
||||
settings[key] = nextState.value;
|
||||
this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex);
|
||||
this.options.onRuntimeChange();
|
||||
});
|
||||
|
||||
this.colorReactionButtons.set(key, {
|
||||
element: button,
|
||||
sourceColorIndex,
|
||||
targetColorIndex,
|
||||
});
|
||||
cell.appendChild(button);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
private syncColorReactionMatrix(): void {
|
||||
this.colorReactionButtons.forEach(
|
||||
({ element, sourceColorIndex, targetColorIndex }, key) => {
|
||||
this.syncColorReactionButton(element, key, sourceColorIndex, targetColorIndex);
|
||||
}
|
||||
);
|
||||
|
||||
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
|
||||
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
|
||||
});
|
||||
}
|
||||
|
||||
private syncColorReactionButton(
|
||||
button: HTMLButtonElement,
|
||||
key: ColorReactionKey,
|
||||
sourceColorIndex: number,
|
||||
targetColorIndex: number
|
||||
): void {
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings[key] = normalizeNumberControlValue(settings[key], config);
|
||||
|
||||
const state = getColorReactionState(settings[key]);
|
||||
const nextState = getNextColorReactionState(settings[key]);
|
||||
const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
|
||||
const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
|
||||
|
||||
button.dataset.reaction = state.id;
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
`${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
|
||||
);
|
||||
button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
|
||||
}
|
||||
|
||||
private setUpMusicSection(container: PaneContainer): void {
|
||||
const folder = container.addFolder({ title: 'Music', expanded: true });
|
||||
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
|
||||
|
|
|
|||
|
|
@ -26,14 +26,15 @@ export class EraserSizeControl {
|
|||
HTMLLabelElement
|
||||
);
|
||||
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
|
||||
private isActive = false;
|
||||
|
||||
public constructor(private readonly options: EraserSizeControlOptions) {
|
||||
this.control.addEventListener('pointerdown', this.options.onActivate);
|
||||
this.control.addEventListener('click', this.options.onActivate);
|
||||
this.slider.addEventListener('focus', this.options.onActivate);
|
||||
this.control.addEventListener('pointerdown', this.activate);
|
||||
this.control.addEventListener('click', this.activate);
|
||||
this.slider.addEventListener('focus', this.activate);
|
||||
this.slider.addEventListener('input', () => {
|
||||
settings.eraserSize = clampEraserSize(Number(this.slider.value));
|
||||
this.options.onActivate();
|
||||
this.activate();
|
||||
this.render();
|
||||
this.options.onChange();
|
||||
});
|
||||
|
|
@ -59,6 +60,25 @@ export class EraserSizeControl {
|
|||
ratio;
|
||||
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
||||
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
||||
this.syncActiveState();
|
||||
this.options.getGame()?.updateEraserPreview();
|
||||
}
|
||||
|
||||
public setActive(isActive: boolean): void {
|
||||
this.isActive = isActive;
|
||||
this.syncActiveState();
|
||||
}
|
||||
|
||||
private readonly activate = () => {
|
||||
this.setActive(true);
|
||||
this.options.onActivate();
|
||||
};
|
||||
|
||||
private syncActiveState(): void {
|
||||
this.control.classList.toggle('active', this.isActive);
|
||||
this.slider.setAttribute(
|
||||
'aria-label',
|
||||
this.isActive ? 'Eraser size, active' : 'Eraser size'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export class FullScreenHandler {
|
||||
private readonly abortController = new AbortController();
|
||||
|
||||
public constructor(
|
||||
private readonly toggleButton: HTMLElement,
|
||||
target: HTMLElement
|
||||
|
|
@ -10,26 +12,35 @@ export class FullScreenHandler {
|
|||
|
||||
this.updateButtons();
|
||||
|
||||
addEventListener('fullscreenchange', this.updateButtons.bind(this));
|
||||
toggleButton.addEventListener('click', () => {
|
||||
if (FullScreenHandler.isInFullScreenMode()) {
|
||||
void document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
const { signal } = this.abortController;
|
||||
addEventListener('fullscreenchange', this.updateButtons, { signal });
|
||||
toggleButton.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
if (FullScreenHandler.isInFullScreenMode()) {
|
||||
void document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
void target.requestFullscreen().catch(() => undefined);
|
||||
});
|
||||
void target.requestFullscreen().catch(() => undefined);
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
public static isInFullScreenMode(): boolean {
|
||||
return document.fullscreenElement !== null;
|
||||
}
|
||||
|
||||
private updateButtons(): void {
|
||||
public destroy(): void {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private readonly updateButtons = (): void => {
|
||||
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
|
||||
const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
|
||||
this.toggleButton.classList.toggle('active', isInFullScreenMode);
|
||||
this.toggleButton.setAttribute('aria-label', label);
|
||||
this.toggleButton.title = label;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,18 +99,18 @@ export class MenuHider {
|
|||
}
|
||||
|
||||
private reveal(): void {
|
||||
if (!this.isHidden && this.hideTimeout === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearHideTimeout();
|
||||
this.isHidden = false;
|
||||
this.element.classList.remove('menu-hidden');
|
||||
this.element.setAttribute('aria-hidden', 'false');
|
||||
this.element.inert = false;
|
||||
}
|
||||
|
||||
private hide(): void {
|
||||
this.isHidden = true;
|
||||
this.element.classList.add('menu-hidden');
|
||||
this.element.setAttribute('aria-hidden', 'true');
|
||||
this.element.inert = true;
|
||||
}
|
||||
|
||||
private clearHideTimeout(): void {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const getMirrorSegmentRatio = (count: number): number => {
|
|||
};
|
||||
|
||||
const formatMirrorSegmentCount = (count: number): string =>
|
||||
count === appConfig.toolbar.mirror.default
|
||||
count <= 1
|
||||
? appConfig.toolbar.mirror.offLabel
|
||||
: `${count} ${
|
||||
appConfig.toolbar.mirror.names[
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
import type GameLoop from '../game-loop/game-loop';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
import { ErrorCode, RuntimeError } from '../utils/error-handler';
|
||||
import { rgbColorToCss } from '../utils/rgb-color';
|
||||
|
||||
interface PaletteControlOptions {
|
||||
getGame: () => GameLoop | null;
|
||||
onChange: () => void;
|
||||
onModeChange?: (isEraserActive: boolean) => void;
|
||||
}
|
||||
|
||||
export class PaletteControl {
|
||||
private readonly swatches = queryRequiredColorSwatches();
|
||||
private readonly eraserControl = queryRequiredElement(
|
||||
'.eraser-size-control',
|
||||
HTMLLabelElement
|
||||
);
|
||||
private isEraserActiveState = false;
|
||||
|
||||
public constructor(private readonly options: PaletteControlOptions) {
|
||||
|
|
@ -23,6 +19,7 @@ export class PaletteControl {
|
|||
settings.selectedColorIndex = index;
|
||||
this.isEraserActiveState = false;
|
||||
this.render();
|
||||
this.options.onModeChange?.(false);
|
||||
this.options.onChange();
|
||||
});
|
||||
});
|
||||
|
|
@ -35,17 +32,16 @@ export class PaletteControl {
|
|||
public setEraserActive(active: boolean): void {
|
||||
this.isEraserActiveState = active;
|
||||
this.render();
|
||||
this.options.onModeChange?.(active);
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.swatches.forEach((swatch, index) => {
|
||||
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
|
||||
swatch.classList.toggle(
|
||||
'active',
|
||||
settings.selectedColorIndex === index && !this.isEraserActiveState
|
||||
);
|
||||
const isActive = settings.selectedColorIndex === index && !this.isEraserActiveState;
|
||||
swatch.classList.toggle('active', isActive);
|
||||
swatch.setAttribute('aria-pressed', String(isActive));
|
||||
});
|
||||
this.eraserControl.classList.toggle('active', this.isEraserActiveState);
|
||||
this.options.getGame()?.setEraseMode(this.isEraserActiveState);
|
||||
document.documentElement.style.setProperty(
|
||||
'--garden-background',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface VibeNavigatorOptions {
|
|||
}
|
||||
|
||||
export class VibeNavigator {
|
||||
private readonly abortController = new AbortController();
|
||||
private readonly previousButton = queryRequiredElement(
|
||||
'.previous-vibe',
|
||||
HTMLButtonElement
|
||||
|
|
@ -25,11 +26,20 @@ export class VibeNavigator {
|
|||
rememberActiveVibeSelection();
|
||||
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||
|
||||
this.previousButton.addEventListener('click', () =>
|
||||
this.select(-1, 'previous-button')
|
||||
const { signal } = this.abortController;
|
||||
this.previousButton.addEventListener(
|
||||
'click',
|
||||
() => this.select(-1, 'previous-button'),
|
||||
{ signal }
|
||||
);
|
||||
this.nextButton.addEventListener('click', () => this.select(1, 'next-button'));
|
||||
window.addEventListener('popstate', () => this.selectFromCurrentUri());
|
||||
this.nextButton.addEventListener('click', () => this.select(1, 'next-button'), {
|
||||
signal,
|
||||
});
|
||||
window.addEventListener('popstate', this.selectFromCurrentUri, { signal });
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private select(offset: number, source: string): void {
|
||||
|
|
@ -42,7 +52,7 @@ export class VibeNavigator {
|
|||
this.notifyChange(activePreset, source, true);
|
||||
}
|
||||
|
||||
private selectFromCurrentUri(): void {
|
||||
private readonly selectFromCurrentUri = (): void => {
|
||||
const vibeId = getCurrentUriVibeId();
|
||||
if (!vibeId || vibeId === activeVibe.id) {
|
||||
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||
|
|
@ -58,7 +68,7 @@ export class VibeNavigator {
|
|||
const activePreset = applyVibeSettings(vibe);
|
||||
writeCurrentVibeUri(activePreset.id, 'replace');
|
||||
this.notifyChange(activePreset, 'uri-popstate', false);
|
||||
}
|
||||
};
|
||||
|
||||
private notifyChange(
|
||||
activePreset: typeof activeVibe,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ struct Counters {
|
|||
aliveAgentCount: atomic<u32>,
|
||||
};
|
||||
|
||||
const clearCompactedTailStride = 4u;
|
||||
const clearCompactedTailStride = __CLEAR_COMPACTED_TAIL_STRIDE__u;
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
||||
|
|
@ -30,7 +30,7 @@ fn main(
|
|||
var isAlive = false;
|
||||
var agent: Agent;
|
||||
if id < settings.agentCount {
|
||||
isAlive = agents[id].colorIndex >= 0.0;
|
||||
isAlive = agents[id].colorIndex >= 0.0 && agents[id].colorIndex < 2.5;
|
||||
if isAlive {
|
||||
agent = agents[id];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export class AgentGenerationPipeline {
|
|||
|
||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUBuffer>(
|
||||
private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUBuffer]>(
|
||||
(active, inactive) =>
|
||||
this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
|
|
@ -130,7 +130,14 @@ export class AgentGenerationPipeline {
|
|||
},
|
||||
});
|
||||
|
||||
const compactionModule = smartCompile(device, compactionSchema, compactionShader);
|
||||
const compactionModule = smartCompile(
|
||||
device,
|
||||
compactionSchema,
|
||||
compactionShader.replaceAll(
|
||||
'__CLEAR_COMPACTED_TAIL_STRIDE__',
|
||||
AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE.toString()
|
||||
)
|
||||
);
|
||||
|
||||
this.compactionPipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache';
|
||||
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||
import {
|
||||
createCachedBufferWrite,
|
||||
writeBufferIfChanged,
|
||||
|
|
@ -64,10 +64,8 @@ export class AgentPipeline {
|
|||
private readonly uniformCache = createCachedBufferWrite(
|
||||
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
private readonly bindGroupCache = createBindGroupCache3<
|
||||
GPUBuffer,
|
||||
GPUTextureView,
|
||||
GPUTextureView
|
||||
private readonly bindGroupCache = createBindGroupCache<
|
||||
[GPUBuffer, GPUTextureView, GPUTextureView]
|
||||
>((agentsBuffer, trailMapIn, trailMapOut) =>
|
||||
this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
|
|
|
|||
|
|
@ -212,10 +212,10 @@ fn agent_finalize(
|
|||
rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
|
||||
}
|
||||
|
||||
// Writes only the deposit into a per-frame-cleared depositMap. The diffusion
|
||||
// pass sums trailMap + depositMap at tile-load time, so the previous trail
|
||||
// value is no longer needed here. Alpha stays 0 in depositMap — diffuse's
|
||||
// alpha decay reads it from trailMap (where deposit alpha contributes 0).
|
||||
// Writes only this agent's last-writer-wins deposit into a per-frame-cleared
|
||||
// depositMap. Storage textures do not blend concurrent compute writes, so
|
||||
// overlapping agents intentionally collapse to whichever write wins. The
|
||||
// diffusion pass then sums trailMap + depositMap at tile-load time.
|
||||
textureStore(
|
||||
trailMapOut,
|
||||
vec2<i32>(nextPosition),
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export class BrushPipeline {
|
|||
},
|
||||
fragment: {
|
||||
module: shaderModule,
|
||||
entryPoint: 'fragmentMrt',
|
||||
entryPoint: 'fragment',
|
||||
targets: [
|
||||
{
|
||||
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||
|
|
@ -166,7 +166,7 @@ export class BrushPipeline {
|
|||
this.segments.flush();
|
||||
}
|
||||
|
||||
public executeMultiTarget(
|
||||
public executeSource(
|
||||
commandEncoder: GPUCommandEncoder,
|
||||
sourceMapOut: GPUTextureView,
|
||||
timestampWrites?: GPURenderPassTimestampWrites
|
||||
|
|
@ -176,6 +176,7 @@ export class BrushPipeline {
|
|||
return false;
|
||||
}
|
||||
|
||||
recordBrushPassForE2e();
|
||||
const passEncoder = commandEncoder.beginRenderPass({
|
||||
colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }],
|
||||
timestampWrites,
|
||||
|
|
@ -194,3 +195,12 @@ export class BrushPipeline {
|
|||
this.uniforms.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const recordBrushPassForE2e = (): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = window as Window & { __fleetingGardenBrushPasses?: number };
|
||||
state.__fleetingGardenBrushPasses = (state.__fleetingGardenBrushPasses ?? 0) + 1;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ fn vertex(
|
|||
}
|
||||
|
||||
@fragment
|
||||
fn fragmentMrt(
|
||||
fn fragment(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
|
|||
// in the same frame.
|
||||
@group(0) @binding(3) var depositMap: texture_2d<f32>;
|
||||
|
||||
var<workgroup> tile: array<vec4<f32>, 324>;
|
||||
var<workgroup> tileTrailStrength: array<f32, 324>;
|
||||
var<workgroup> tile: array<vec4<f32>, TILE_TEXEL_COUNT>;
|
||||
var<workgroup> tileTrailStrength: array<f32, TILE_TEXEL_COUNT>;
|
||||
|
||||
@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
|
||||
fn main(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../../config';
|
||||
import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache';
|
||||
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||
import {
|
||||
createCachedBufferWrite,
|
||||
writeBufferIfChanged,
|
||||
|
|
@ -78,10 +78,8 @@ export class DiffusionPipeline {
|
|||
private readonly uniformCache = createCachedBufferWrite(
|
||||
DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
private readonly getBindGroup = createBindGroupCache3<
|
||||
GPUTextureView,
|
||||
GPUTextureView,
|
||||
GPUTextureView
|
||||
private readonly getBindGroup = createBindGroupCache<
|
||||
[GPUTextureView, GPUTextureView, GPUTextureView]
|
||||
>((trailMapIn, trailMapOut, depositMap) =>
|
||||
this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class EraserAgentPipeline {
|
|||
private readonly uniformCache = createCachedBufferWrite(
|
||||
EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>(
|
||||
private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUTextureView]>(
|
||||
(agentsBuffer, eraserMask) =>
|
||||
this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ fn main(
|
|||
}
|
||||
|
||||
let colorIndex = agents[id].colorIndex;
|
||||
if colorIndex < 0.0 {
|
||||
if colorIndex < 0.0 || colorIndex >= 2.5 {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
|
||||
import { CommonState } from '../common-state/common-state';
|
||||
import shader from './render.wgsl?raw';
|
||||
|
||||
export interface RenderSettings {
|
||||
|
|
@ -30,7 +29,7 @@ export class RenderPipeline {
|
|||
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
|
||||
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
||||
private readonly getBindGroup = createBindGroupCache<[GPUTextureView, GPUTextureView]>(
|
||||
(colorTexture, sourceTexture) =>
|
||||
this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
|
|
@ -45,7 +44,6 @@ export class RenderPipeline {
|
|||
public constructor(
|
||||
private readonly context: GPUCanvasContext,
|
||||
private readonly device: GPUDevice,
|
||||
private readonly commonState: CommonState,
|
||||
private readonly canvasFormat: GPUTextureFormat
|
||||
) {
|
||||
this.bindGroupLayout = device.createBindGroupLayout({
|
||||
|
|
@ -68,10 +66,10 @@ export class RenderPipeline {
|
|||
],
|
||||
});
|
||||
|
||||
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
||||
const shaderModule = smartCompile(device, shader);
|
||||
const vertex = setUpFullScreenQuad(device);
|
||||
const pipelineLayout = device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
});
|
||||
this.pipeline = this.createPipeline(
|
||||
pipelineLayout,
|
||||
|
|
@ -207,8 +205,7 @@ export class RenderPipeline {
|
|||
timestampWrites,
|
||||
});
|
||||
passEncoder.setPipeline(this.getPipeline(useSourceTexture));
|
||||
this.commonState.execute(passEncoder);
|
||||
passEncoder.setBindGroup(1, this.getBindGroup(colorTexture, sourceTexture));
|
||||
passEncoder.setBindGroup(0, this.getBindGroup(colorTexture, sourceTexture));
|
||||
passEncoder.draw(3, 1);
|
||||
passEncoder.end();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ const LOW_SATURATION_RESCUE_MAX: f32 = 0.22;
|
|||
const COLOR_WEIGHT_EPSILON: f32 = 0.0001;
|
||||
const LUMA_WEIGHTS: vec3<f32> = vec3<f32>(0.2126, 0.7152, 0.0722);
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
||||
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
|
||||
@group(0) @binding(0) var<uniform> settings: Settings;
|
||||
@group(0) @binding(2) var trailMap: texture_2d<f32>;
|
||||
@group(0) @binding(3) var sourceMap: texture_2d<f32>;
|
||||
|
||||
@fragment
|
||||
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
|
|
|
|||
|
|
@ -18,12 +18,20 @@ const preservedRuntimeSettingKeys = [
|
|||
const cloneRgbColor = <T extends [number, number, number]>(color: T): T =>
|
||||
[...color] as T;
|
||||
|
||||
const cloneVibeAudio = (audio: VibePreset['audio']): VibePreset['audio'] => ({
|
||||
...audio,
|
||||
...(audio.scale ? { scale: [...audio.scale] } : {}),
|
||||
...(audio.progression
|
||||
? { progression: audio.progression.map((chord) => ({ ...chord })) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
|
||||
...vibe,
|
||||
colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
|
||||
backgroundColor: cloneRgbColor(vibe.backgroundColor),
|
||||
settings: { ...vibe.settings },
|
||||
audio: { ...vibe.audio },
|
||||
audio: cloneVibeAudio(vibe.audio),
|
||||
});
|
||||
|
||||
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
@use 'mixins' as *;
|
||||
|
||||
.config-pane-container {
|
||||
--config-pane-available-height: calc(
|
||||
100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
||||
|
|
@ -154,7 +156,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px), (hover: none) and (pointer: coarse) {
|
||||
@include on-mobile-input {
|
||||
@include mobile-config-pane;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,3 +5,9 @@ $breakpoint-width: 600px !default;
|
|||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin on-mobile-input() {
|
||||
@media (max-width: ($breakpoint-width - 1px)), (hover: none) and (pointer: coarse) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,13 @@
|
|||
}
|
||||
|
||||
> .vibe-button {
|
||||
--vibe-button-surface-inset-block: 10px;
|
||||
--vibe-button-surface-inset-inline: 8px;
|
||||
--vibe-chevron-size: 22px;
|
||||
--vibe-chevron-stroke: 4px;
|
||||
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--vibe-button-hit-size);
|
||||
|
|
@ -96,30 +102,56 @@
|
|||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: rgb(255 255 255 / 70%);
|
||||
color: rgb(255 255 255 / 88%);
|
||||
font-size: 0;
|
||||
line-height: 1;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: var(--vibe-button-surface-inset-block)
|
||||
var(--vibe-button-surface-inset-inline);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / calc(9% + var(--toolbar-background-strength) * 10%));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(255 255 255 / 18%),
|
||||
0 8px 18px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 22%));
|
||||
transition:
|
||||
background var(--transition-time),
|
||||
box-shadow var(--transition-time),
|
||||
opacity var(--transition-time);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: var(--vibe-chevron-size);
|
||||
height: var(--vibe-chevron-size);
|
||||
border-color: currentColor;
|
||||
border-style: solid;
|
||||
border-width: 0 0 3px 3px;
|
||||
border-width: 0 0 var(--vibe-chevron-stroke) var(--vibe-chevron-stroke);
|
||||
filter: drop-shadow(0 1px 3px rgb(0 0 0 / 70%));
|
||||
transform: translate(-35%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
&.next-vibe::before {
|
||||
border-width: 3px 3px 0 0;
|
||||
border-width: var(--vibe-chevron-stroke) var(--vibe-chevron-stroke) 0 0;
|
||||
transform: translate(-65%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: color-mix(in srgb, var(--accent-color) 70%, white);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
background: color-mix(in srgb, var(--accent-color) 34%, rgb(255 255 255 / 18%));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(255 255 255 / 28%),
|
||||
0 10px 22px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 30%));
|
||||
}
|
||||
|
||||
&.previous-vibe:hover {
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@
|
|||
row-gap: 0;
|
||||
|
||||
> .vibe-button {
|
||||
--vibe-button-surface-inset-block: 5px;
|
||||
--vibe-button-surface-inset-inline: 3px;
|
||||
--vibe-chevron-size: 17px;
|
||||
--vibe-chevron-stroke: 3px;
|
||||
|
||||
width: var(--vibe-button-hit-size);
|
||||
min-height: 44px;
|
||||
|
||||
&::before {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
> .toolbar-shell {
|
||||
|
|
|
|||
|
|
@ -223,7 +223,12 @@ export class ErrorHandler {
|
|||
|
||||
public static addOnErrorListener(
|
||||
listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void
|
||||
) {
|
||||
): () => void {
|
||||
ErrorHandler.onErrorListeners.push(listener);
|
||||
return () => {
|
||||
ErrorHandler.onErrorListeners = ErrorHandler.onErrorListeners.filter(
|
||||
(registeredListener) => registeredListener !== listener
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,38 @@
|
|||
export const createBindGroupCache = <K1 extends object, K2 extends object>(
|
||||
factory: (key1: K1, key2: K2) => GPUBindGroup
|
||||
): ((key1: K1, key2: K2) => GPUBindGroup) => {
|
||||
const outer = new WeakMap<K1, WeakMap<K2, GPUBindGroup>>();
|
||||
return (key1, key2) => {
|
||||
let inner = outer.get(key1);
|
||||
if (!inner) {
|
||||
inner = new WeakMap();
|
||||
outer.set(key1, inner);
|
||||
}
|
||||
const cached = inner.get(key2);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const bindGroup = factory(key1, key2);
|
||||
inner.set(key2, bindGroup);
|
||||
return bindGroup;
|
||||
};
|
||||
type BindGroupCacheKeys = readonly [object, ...object[]];
|
||||
|
||||
interface BindGroupCacheNode {
|
||||
bindGroup?: GPUBindGroup;
|
||||
children: WeakMap<object, BindGroupCacheNode>;
|
||||
}
|
||||
|
||||
const createNode = (): BindGroupCacheNode => ({
|
||||
children: new WeakMap(),
|
||||
});
|
||||
|
||||
const getOrCreateNode = (
|
||||
children: WeakMap<object, BindGroupCacheNode>,
|
||||
key: object
|
||||
): BindGroupCacheNode => {
|
||||
let node = children.get(key);
|
||||
if (!node) {
|
||||
node = createNode();
|
||||
children.set(key, node);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
export const createBindGroupCache3 = <
|
||||
K1 extends object,
|
||||
K2 extends object,
|
||||
K3 extends object,
|
||||
>(
|
||||
factory: (key1: K1, key2: K2, key3: K3) => GPUBindGroup
|
||||
): ((key1: K1, key2: K2, key3: K3) => GPUBindGroup) => {
|
||||
const outer = new WeakMap<K1, WeakMap<K2, WeakMap<K3, GPUBindGroup>>>();
|
||||
return (key1, key2, key3) => {
|
||||
let mid = outer.get(key1);
|
||||
if (!mid) {
|
||||
mid = new WeakMap();
|
||||
outer.set(key1, mid);
|
||||
export const createBindGroupCache = <Keys extends BindGroupCacheKeys>(
|
||||
factory: (...keys: Keys) => GPUBindGroup
|
||||
): ((...keys: Keys) => GPUBindGroup) => {
|
||||
const root = new WeakMap<object, BindGroupCacheNode>();
|
||||
|
||||
return (...keys) => {
|
||||
let node = getOrCreateNode(root, keys[0]);
|
||||
for (const key of keys.slice(1)) {
|
||||
node = getOrCreateNode(node.children, key);
|
||||
}
|
||||
let inner = mid.get(key2);
|
||||
if (!inner) {
|
||||
inner = new WeakMap();
|
||||
mid.set(key2, inner);
|
||||
}
|
||||
const cached = inner.get(key3);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const bindGroup = factory(key1, key2, key3);
|
||||
inner.set(key3, bindGroup);
|
||||
return bindGroup;
|
||||
|
||||
node.bindGroup ??= factory(...keys);
|
||||
return node.bindGroup;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ describe('vibe URI handling', () => {
|
|||
expect(getVibeIdFromUri('https://example.test/?vibe=aurora-mycelium')).toBe(
|
||||
VibeId.AuroraMycelium
|
||||
);
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium%20Copy')).toBe(
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
|
||||
VibeId.AuroraMycelium
|
||||
);
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=velvet%20observatory')).toBe(
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe(
|
||||
VibeId.VelvetObservatory
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { VibeId } from './config/types';
|
||||
import { vibePresets } from './config/vibe-presets';
|
||||
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
|
||||
|
||||
const VIBE_URI_QUERY_PARAM = 'vibe';
|
||||
const FALLBACK_URL_ORIGIN = 'https://fleeting.garden';
|
||||
|
|
@ -27,7 +27,7 @@ const normalizeVibeIdentifier = (value: string): string =>
|
|||
|
||||
const vibeIdByIdentifier = new Map<string, VibeId>();
|
||||
|
||||
for (const vibe of vibePresets) {
|
||||
for (const vibe of VIBE_PRESETS) {
|
||||
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.id), vibe.id);
|
||||
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.name), vibe.id);
|
||||
}
|
||||
|
|
@ -106,8 +106,8 @@ export const getCurrentUriVibeId = (): VibeId | null => {
|
|||
};
|
||||
|
||||
const getVibeSlug = (vibeId: VibeId): string => {
|
||||
const vibe = vibePresets.find((preset) => preset.id === vibeId);
|
||||
return vibe ? slugifyVibeName(vibe.name) : vibeId;
|
||||
const vibe = getVibeById(vibeId);
|
||||
return vibe ? vibe.id : vibeId;
|
||||
};
|
||||
|
||||
export const createVibeUri = (url: string | URL, vibeId: VibeId): string => {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
import { appConfig } from './config';
|
||||
import { VibeId, type VibePreset } from './config/types';
|
||||
import { readBrowserStorage } from './utils/browser-storage';
|
||||
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
|
||||
import { getCurrentUriVibeId, getVibeIdFromUri } from './vibe-uri';
|
||||
|
||||
export { VibeId };
|
||||
export { getVibeById, VIBE_PRESETS };
|
||||
export type { VibePreset };
|
||||
|
||||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||
|
||||
const isVibeId = (value: unknown): value is VibeId =>
|
||||
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
||||
|
||||
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
|
||||
VIBE_PRESETS.find((vibe) => vibe.id === vibeId);
|
||||
|
||||
export const getInitialVibe = (): VibePreset => {
|
||||
const uriVibeId = getCurrentUriVibeId();
|
||||
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
||||
|
|
|
|||
|
|
@ -17,5 +17,5 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*", "definitions.d.ts", "vite.config.ts"]
|
||||
"include": ["src/**/*", "pwa-assets.config.ts", "vite.config.ts"]
|
||||
}
|
||||
|
|
|
|||