From c40c5d97dba65654e2ff6cca0a00ed2807dd3a01 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 24 May 2026 10:52:20 +0100 Subject: [PATCH] Final clean up --- .forgejo/workflows/deploy.yml | 5 + .nvmrc | 2 +- .prettierrc | 2 +- assets/icons/download.svg | 4 +- assets/icons/info.svg | 2 +- assets/icons/maximize.svg | 4 +- assets/icons/minimize.svg | 4 +- assets/icons/restart.svg | 2 +- assets/icons/settings.svg | 2 +- assets/icons/sound.svg | 2 +- definitions.d.ts | 8 - e2e/app.spec.ts | 68 ++++-- package-lock.json | 12 +- package.json | 2 +- playwright.config.ts | 2 +- src/analytics.ts | 9 +- src/audio/garden-audio-config.ts | 3 +- src/audio/garden-audio-graph.ts | 23 +- src/audio/garden-audio-music.ts | 25 +-- src/audio/garden-audio-types.ts | 2 +- src/audio/garden-audio.ts | 108 +++++++--- src/audio/noise-burst-player.ts | 5 +- src/audio/piano-sampler.ts | 5 +- src/audio/piano-samples.ts | 172 ++++++++++----- src/audio/samples/README.md | 9 + src/config.ts | 18 +- src/config/default-settings.ts | 10 +- src/config/runtime-controls.ts | 14 +- src/config/types.ts | 7 + src/config/vibe-presets.test.ts | 9 +- src/config/vibe-presets.ts | 40 ++-- src/game-loop/agent-population.test.ts | 12 ++ src/game-loop/agent-population.ts | 2 + src/game-loop/frame-performance.ts | 6 +- src/game-loop/game-loop-resources.ts | 38 ++-- src/game-loop/game-loop.ts | 22 +- src/game-loop/intro-title-agents.ts | 55 +++-- src/game-loop/perf-stats-overlay.ts | 8 +- src/game-loop/pointer-input.ts | 16 +- src/game-loop/simulation-frame.ts | 2 +- src/game-loop/simulation-textures.ts | 2 +- src/game-loop/toolbar-contrast-monitor.ts | 191 +++++++++++------ src/index.ts | 8 +- src/page/collapsible-panel-animator.ts | 29 ++- src/page/config-pane.ts | 199 +----------------- src/page/eraser-size-control.ts | 28 ++- src/page/full-screen-handler.ts | 31 ++- src/page/menu-hider.ts | 8 +- src/page/mirror-segment-control.ts | 2 +- src/page/palette-control.ts | 16 +- src/page/vibe-navigator.ts | 22 +- .../agent-generation/agent-compaction.wgsl | 4 +- .../agent-generation-pipeline.ts | 11 +- src/pipelines/agents/agent-pipeline.ts | 8 +- src/pipelines/agents/agent.wgsl | 8 +- src/pipelines/brush/brush-pipeline.ts | 14 +- src/pipelines/brush/brush.wgsl | 2 +- src/pipelines/diffusion/diffuse.wgsl | 4 +- src/pipelines/diffusion/diffusion-pipeline.ts | 8 +- src/pipelines/eraser/eraser-agent-pipeline.ts | 2 +- src/pipelines/eraser/eraser-agent.wgsl | 2 +- src/pipelines/render/render-pipeline.ts | 11 +- src/pipelines/render/render.wgsl | 6 +- src/settings.ts | 10 +- src/style/_config-pane.scss | 4 +- src/style/mixins.scss | 6 + src/style/toolbar/_layout.scss | 44 +++- src/style/toolbar/_responsive.scss | 10 +- src/utils/error-handler.ts | 7 +- src/utils/graphics/bind-group-cache.ts | 76 +++---- src/vibe-uri.test.ts | 4 +- src/vibe-uri.ts | 8 +- src/vibes.ts | 6 +- tsconfig.json | 2 +- 74 files changed, 864 insertions(+), 670 deletions(-) delete mode 100644 definitions.d.ts diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 3bb5949..bbf03a4 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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 diff --git a/.nvmrc b/.nvmrc index 2bd5a0a..6fa8dec 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +22.13.0 diff --git a/.prettierrc b/.prettierrc index 743851d..fef36af 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,5 +6,5 @@ "endOfLine": "lf", "plugins": ["@ianvs/prettier-plugin-sort-imports"], "importOrder": ["", "", "", "^[./]"], - "importOrderTypeScriptVersion": "5.6.0" + "importOrderTypeScriptVersion": "6.0.3" } diff --git a/assets/icons/download.svg b/assets/icons/download.svg index 423bc7d..eb27c41 100644 --- a/assets/icons/download.svg +++ b/assets/icons/download.svg @@ -2,9 +2,9 @@ diff --git a/assets/icons/info.svg b/assets/icons/info.svg index 3573e28..19e44e5 100644 --- a/assets/icons/info.svg +++ b/assets/icons/info.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/maximize.svg b/assets/icons/maximize.svg index 7239da3..bf1f311 100644 --- a/assets/icons/maximize.svg +++ b/assets/icons/maximize.svg @@ -1,7 +1,7 @@ - + - \ No newline at end of file + diff --git a/assets/icons/minimize.svg b/assets/icons/minimize.svg index 2ffe1f7..19e8e7f 100644 --- a/assets/icons/minimize.svg +++ b/assets/icons/minimize.svg @@ -1,7 +1,7 @@ - + - \ No newline at end of file + diff --git a/assets/icons/restart.svg b/assets/icons/restart.svg index a58d2a6..d3dce2c 100644 --- a/assets/icons/restart.svg +++ b/assets/icons/restart.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg index ba7478f..9d5e6fd 100644 --- a/assets/icons/settings.svg +++ b/assets/icons/settings.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/sound.svg b/assets/icons/sound.svg index d440a9c..4f382b5 100644 --- a/assets/icons/sound.svg +++ b/assets/icons/sound.svg @@ -1,3 +1,3 @@ - + diff --git a/definitions.d.ts b/definitions.d.ts deleted file mode 100644 index c90ad44..0000000 --- a/definitions.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare module '*.wgsl?raw' { - const content: string; - export default content; -} - -interface HTMLCanvasElement { - getContext(contextId: 'webgpu'): GPUCanvasContext | null; -} diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 3285b30..1caa3f2 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -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; + consoleErrors: Array; +} + 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 = []; + + 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 = []; - 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/, { diff --git a/package-lock.json b/package-lock.json index 89cdaff..7c0cc30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index fdd05c4..a65ec2a 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "generate-icons": "pwa-assets-generator" }, "engines": { - "node": ">=22" + "node": ">=22.13.0" }, "repository": { "type": "git", diff --git a/playwright.config.ts b/playwright.config.ts index e62296a..01f7ce9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/src/analytics.ts b/src/analytics.ts index ddfb650..b19958a 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -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) { diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index da68c93..b5fbc1d 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -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'; diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index de934f9..a288465 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -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['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(); 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 { diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts index 0d056e7..a0e5e7b 100644 --- a/src/audio/garden-audio-music.ts +++ b/src/audio/garden-audio-music.ts @@ -13,8 +13,6 @@ const DEFAULT_PROGRESSION: ReadonlyArray = [ const DEFAULT_ROOT_MIDI = 57; const DEFAULT_SCALE: ReadonlyArray = [0, 2, 4, 7, 9]; -const profileCache = new WeakMap(); - const getProfileScale = (vibe: VibePreset): Array => { const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE; return [...scale]; @@ -26,21 +24,10 @@ const getProfileProgression = (vibe: VibePreset): Array => ); 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), + }; }; diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index 3a0a592..fecbcf8 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -1,4 +1,4 @@ -import { VibePreset } from '../vibes'; +import type { VibePreset } from '../vibes'; export interface GardenAudioSnapshot { vibe: VibePreset; diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index 5d6131c..3aa4c88 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -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; } } diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts index 7d148ac..f9ab2bf 100644 --- a/src/audio/noise-burst-player.ts +++ b/src/audio/noise-burst-player.ts @@ -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', diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index a3f80f7..1a0c77c 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -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; diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts index 8b3b6c4..569eca4 100644 --- a/src/audio/piano-samples.ts +++ b/src/audio/piano-samples.ts @@ -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 = [ - { 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 | null = null; let pianoSampleLoadPromise: Promise> | 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> => { + 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 => { 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 = ( } ); }); + +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 = /^(?[A-G])(?sharp)?(?\d+)$/.exec(sample.note); + if (!match?.groups) { + throw new Error(`Invalid piano sample note ${sample.note}`); + } + + const semitoneByName: Record = { + 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; +}; diff --git a/src/audio/samples/README.md b/src/audio/samples/README.md index ab6ee8c..bdde746 100644 --- a/src/audio/samples/README.md +++ b/src/audio/samples/README.md @@ -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 `v12.m4a`, for example +`C4v12.m4a`. diff --git a/src/config.ts b/src/config.ts index 27e8a24..e0e6b19 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, }, diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts index bdcf049..37ce510 100644 --- a/src/config/default-settings.ts +++ b/src/config/default-settings.ts @@ -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) ); }; diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index 82278b0..e07a259 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -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, }, }; diff --git a/src/config/types.ts b/src/config/types.ts index 1c6bedf..456dec8 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; diff --git a/src/config/vibe-presets.test.ts b/src/config/vibe-presets.test.ts index 608a86e..a69d524 100644 --- a/src/config/vibe-presets.test.ts +++ b/src/config/vibe-presets.test.ts @@ -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; diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index c5c7d40..7a30359 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -139,7 +139,7 @@ export const vibePresets: Array = [ 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 = [ ...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 = [ }, { 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 = [ ...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 = [ }, { id: VibeId.PaperLanternFog, - name: 'Paper Lantern Fog Copy', + name: 'Paper Lantern Fog', colors: [ [255, 176, 108], [239, 90, 108], diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts index f49a3ab..2bc0e26 100644 --- a/src/game-loop/agent-population.test.ts +++ b/src/game-loop/agent-population.test.ts @@ -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(); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index 68e05c0..1d0390f 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -59,6 +59,8 @@ export class AgentPopulation { }); if (data.length === 0) { + this.activeCount = 0; + this.replacementCursor = 0; return; } diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index 5cab70d..9fc249e 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -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; } } diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index 14588f9..64c9680 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -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, }); } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 6cabd79..aed5289 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -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( diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts index 2d530cb..a34bbf4 100644 --- a/src/game-loop/intro-title-agents.ts +++ b/src/game-loop/intro-title-agents.ts @@ -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 => { + 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 = []; 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; } diff --git a/src/game-loop/perf-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts index 6ffd55b..9e6717f 100644 --- a/src/game-loop/perf-stats-overlay.ts +++ b/src/game-loop/perf-stats-overlay.ts @@ -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))}` diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index ae5fed0..bee83ef 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -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 { diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts index ec4b9e3..36629e8 100644 --- a/src/game-loop/simulation-frame.ts +++ b/src/game-loop/simulation-frame.ts @@ -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') diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts index d2256d0..7166595 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -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; diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts index 9c908fe..8898123 100644 --- a/src/game-loop/toolbar-contrast-monitor.ts +++ b/src/game-loop/toolbar-contrast-monitor.ts @@ -7,6 +7,14 @@ interface CanvasSamplePoint { y: number; } +interface CanvasSampleRegion { + bytesPerRow: number; + height: number; + origin: CanvasSamplePoint; + sampleOffsets: Array; + 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, 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 { + 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(); 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 { + 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 + ): Promise { 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; diff --git a/src/index.ts b/src/index.ts index db0425f..74b98d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); } }; diff --git a/src/page/collapsible-panel-animator.ts b/src/page/collapsible-panel-animator.ts index 4b10e52..a86a637 100644 --- a/src/page/collapsible-panel-animator.ts +++ b/src/page/collapsible-panel-animator.ts @@ -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() { diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index 030f631..3e6c397 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -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; -type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number]; type RuntimeControlKey = keyof GardenRuntimeSettings & string; type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor'; type NumberPropertyKey = { @@ -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 }) => { diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts index b5de8f7..1099821 100644 --- a/src/page/eraser-size-control.ts +++ b/src/page/eraser-size-control.ts @@ -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' + ); + } } diff --git a/src/page/full-screen-handler.ts b/src/page/full-screen-handler.ts index 1167543..498c08c 100644 --- a/src/page/full-screen-handler.ts +++ b/src/page/full-screen-handler.ts @@ -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; - } + }; } diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts index 841ac48..ed8aa59 100644 --- a/src/page/menu-hider.ts +++ b/src/page/menu-hider.ts @@ -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 { diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts index 98f6b75..1ea8ddc 100644 --- a/src/page/mirror-segment-control.ts +++ b/src/page/mirror-segment-control.ts @@ -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[ diff --git a/src/page/palette-control.ts b/src/page/palette-control.ts index 615625f..9007d8f 100644 --- a/src/page/palette-control.ts +++ b/src/page/palette-control.ts @@ -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', diff --git a/src/page/vibe-navigator.ts b/src/page/vibe-navigator.ts index f784e78..fc6ecc0 100644 --- a/src/page/vibe-navigator.ts +++ b/src/page/vibe-navigator.ts @@ -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, diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl index 86a3447..ae9336c 100644 --- a/src/pipelines/agents/agent-generation/agent-compaction.wgsl +++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl @@ -9,7 +9,7 @@ struct Counters { aliveAgentCount: atomic, }; -const clearCompactedTailStride = 4u; +const clearCompactedTailStride = __CLEAR_COMPACTED_TAIL_STRIDE__u; @group(1) @binding(0) var settings: Settings; @group(1) @binding(2) var 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]; } diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index 9b62fbb..524a5c2 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -20,7 +20,7 @@ export class AgentGenerationPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly uniforms: GPUBuffer; - private readonly bindGroupCache = createBindGroupCache( + 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({ diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index 4d4dd0d..6138935 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -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, diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 591fa10..912e2a0 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -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(nextPosition), diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index 7302f14..b759c4f 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -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; +}; diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl index b12f376..8152986 100644 --- a/src/pipelines/brush/brush.wgsl +++ b/src/pipelines/brush/brush.wgsl @@ -50,7 +50,7 @@ fn vertex( } @fragment -fn fragmentMrt( +fn fragment( @location(0) screenPosition: vec2, @location(1) @interpolate(flat) start: vec2, @location(2) @interpolate(flat) direction: vec2, diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index 57bf139..92ae448 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -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; -var tile: array, 324>; -var tileTrailStrength: array; +var tile: array, TILE_TEXEL_COUNT>; +var tileTrailStrength: array; @compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__) fn main( diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index 373e736..b1c763d 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -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, diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index 4a85c14..5cc9b32 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -32,7 +32,7 @@ export class EraserAgentPipeline { private readonly uniformCache = createCachedBufferWrite( EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT ); - private readonly bindGroupCache = createBindGroupCache( + private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUTextureView]>( (agentsBuffer, eraserMask) => this.device.createBindGroup({ layout: this.bindGroupLayout, diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl index 273ffbc..f98d551 100644 --- a/src/pipelines/eraser/eraser-agent.wgsl +++ b/src/pipelines/eraser/eraser-agent.wgsl @@ -21,7 +21,7 @@ fn main( } let colorIndex = agents[id].colorIndex; - if colorIndex < 0.0 { + if colorIndex < 0.0 || colorIndex >= 2.5 { return; } diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts index 57f0b77..12d7ada 100644 --- a/src/pipelines/render/render-pipeline.ts +++ b/src/pipelines/render/render-pipeline.ts @@ -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( + 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(); } diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index d5613bd..7e1ab26 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -20,9 +20,9 @@ const LOW_SATURATION_RESCUE_MAX: f32 = 0.22; const COLOR_WEIGHT_EPSILON: f32 = 0.0001; const LUMA_WEIGHTS: vec3 = vec3(0.2126, 0.7152, 0.0722); -@group(1) @binding(0) var settings: Settings; -@group(1) @binding(2) var trailMap: texture_2d; -@group(1) @binding(3) var sourceMap: texture_2d; +@group(0) @binding(0) var settings: Settings; +@group(0) @binding(2) var trailMap: texture_2d; +@group(0) @binding(3) var sourceMap: texture_2d; @fragment fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { diff --git a/src/settings.ts b/src/settings.ts index 49e2518..c91ac83 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -18,12 +18,20 @@ const preservedRuntimeSettingKeys = [ const cloneRgbColor = (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 => diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss index 71ed186..ad5402f 100644 --- a/src/style/_config-pane.scss +++ b/src/style/_config-pane.scss @@ -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; } diff --git a/src/style/mixins.scss b/src/style/mixins.scss index 46f2dd9..9197605 100644 --- a/src/style/mixins.scss +++ b/src/style/mixins.scss @@ -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; + } +} diff --git a/src/style/toolbar/_layout.scss b/src/style/toolbar/_layout.scss index e11a841..f2c8930 100644 --- a/src/style/toolbar/_layout.scss +++ b/src/style/toolbar/_layout.scss @@ -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 { diff --git a/src/style/toolbar/_responsive.scss b/src/style/toolbar/_responsive.scss index 658c40b..2aeba6c 100644 --- a/src/style/toolbar/_responsive.scss +++ b/src/style/toolbar/_responsive.scss @@ -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 { diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts index 328f329..cffd60c 100644 --- a/src/utils/error-handler.ts +++ b/src/utils/error-handler.ts @@ -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 + ); + }; } } diff --git a/src/utils/graphics/bind-group-cache.ts b/src/utils/graphics/bind-group-cache.ts index 28ecb84..40ebce0 100644 --- a/src/utils/graphics/bind-group-cache.ts +++ b/src/utils/graphics/bind-group-cache.ts @@ -1,48 +1,38 @@ -export const createBindGroupCache = ( - factory: (key1: K1, key2: K2) => GPUBindGroup -): ((key1: K1, key2: K2) => GPUBindGroup) => { - const outer = new WeakMap>(); - 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; +} + +const createNode = (): BindGroupCacheNode => ({ + children: new WeakMap(), +}); + +const getOrCreateNode = ( + children: WeakMap, + 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>>(); - return (key1, key2, key3) => { - let mid = outer.get(key1); - if (!mid) { - mid = new WeakMap(); - outer.set(key1, mid); +export const createBindGroupCache = ( + factory: (...keys: Keys) => GPUBindGroup +): ((...keys: Keys) => GPUBindGroup) => { + const root = new WeakMap(); + + 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; }; }; diff --git a/src/vibe-uri.test.ts b/src/vibe-uri.test.ts index 79f0109..fe55d5e 100644 --- a/src/vibe-uri.test.ts +++ b/src/vibe-uri.test.ts @@ -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 ); }); diff --git a/src/vibe-uri.ts b/src/vibe-uri.ts index c8ee804..3a4024c 100644 --- a/src/vibe-uri.ts +++ b/src/vibe-uri.ts @@ -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(); -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 => { diff --git a/src/vibes.ts b/src/vibes.ts index def4584..95d1f7c 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -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 = appConfig.vibes.presets; const VIBE_IDS = new Set(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); diff --git a/tsconfig.json b/tsconfig.json index 09374bb..fbd104c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] }