diff --git a/.gitignore b/.gitignore index 0f59a68..db39815 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules dist test-results +.DS_Store +*.log diff --git a/.prettierrc b/.prettierrc index 2a3ea53..743851d 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,5 +6,5 @@ "endOfLine": "lf", "plugins": ["@ianvs/prettier-plugin-sort-imports"], "importOrder": ["", "", "", "^[./]"], - "importOrderTypeScriptVersion": "5.0.0" + "importOrderTypeScriptVersion": "5.6.0" } diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 6f50cb8..3285b30 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -71,7 +71,7 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => { const startButton = page.getByRole('button', { name: 'Start' }); await expect(startButton).toBeVisible(); await expect(startButton).toBeEnabled({ timeout: 30_000 }); - await startButton.click(); + await page.keyboard.press('Enter'); await expect(page.locator('body')).not.toHaveClass(/is-loading/, { timeout: 30_000, }); @@ -146,7 +146,7 @@ test('syncs the selected vibe with the URI', async ({ page }) => { await expect(page).toHaveURL(/vibe=bone-archive/); - await page.locator('.next-vibe').click(); + await page.getByRole('button', { name: 'Next vibe' }).click(); await expect(page).toHaveURL(/vibe=pelagic-caustics/); await page.goBack(); @@ -160,8 +160,8 @@ test('keeps audio focus outlines scoped to the active control', async ({ page }) await expect(page.locator('body')).not.toHaveClass(/is-loading/); const audioControl = page.locator('.audio-control'); - const soundButton = page.locator('button.sound'); - const volumeSlider = page.locator('.volume-slider'); + const soundButton = page.getByRole('button', { name: /audio/i }); + const volumeSlider = page.getByRole('slider', { name: 'Master volume' }); await soundButton.click(); await expect(audioControl).toHaveCSS('outline-style', 'none'); @@ -201,7 +201,7 @@ test('keeps the config overlay scrollable and dismissible on mobile', async ({ timeout: 30_000, }); - const settingsButton = page.locator('button.settings'); + const settingsButton = page.getByRole('button', { name: 'Show config overlay' }); await settingsButton.click(); const pane = page.locator('.config-pane'); diff --git a/package.json b/package.json index 9d07a0f..fdd05c4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "generate-icons": "pwa-assets-generator" }, "engines": { - "node": ">=20" + "node": ">=22" }, "repository": { "type": "git", diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index bc6b599..da68c93 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -1,6 +1,7 @@ -import { DEFAULT_AUDIO_VOLUME } from '../consts'; import type { PianoNoteRole } from './garden-audio-types'; +const DEFAULT_AUDIO_VOLUME = 0.5; + type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4'; export interface GardenAudioChord { diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index d992b95..de934f9 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -23,8 +23,8 @@ const graphTuning = { eventBusGain: 1, noiseMax: 1, noiseMin: -1, - latencyHint: 'interactive' as AudioContextLatencyCategory, - outputFilterType: 'highpass' as BiquadFilterType, + latencyHint: 'interactive', + outputFilterType: 'highpass', compressor: { thresholdDb: -18, kneeDb: 18, @@ -32,7 +32,7 @@ const graphTuning = { attackSeconds: 0.018, releaseSeconds: 0.18, }, -}; +} as const; const delayFilterTuning = { feedbackHighPassHz: 180, feedbackLowPassHz: 5200, diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index 5743964..fbe1201 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -17,6 +17,8 @@ import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler'; const GENERATIVE_LOOKAHEAD_SECONDS = 0.3; const GENERATIVE_START_DELAY_SECONDS = 0.02; +const TEXTURE_ONSET_EXPRESSION = 0.15; +const SUPPORT_ONSET_EXPRESSION = 0.4; const chordVoicings: Record< GardenAudioChord['quality'], @@ -1087,6 +1089,9 @@ export class GenerativePianoEngine { } private shouldPlaySupport(expression: number, barIndex: number): boolean { + if (expression < SUPPORT_ONSET_EXPRESSION) { + return false; + } if (expression >= generativePianoTuning.supportNote.expressionThreshold) { return true; } @@ -1098,19 +1103,17 @@ export class GenerativePianoEngine { } private shouldPlayTexture(expression: number, barIndex: number): boolean { + if (expression < TEXTURE_ONSET_EXPRESSION) { + return false; + } + if (expression >= generativePianoTuning.textureNote.mediumExpressionThreshold) { + return barIndex % generativePianoTuning.textureNote.intenseSpacing === 0; + } const spacing = expression < generativePianoTuning.textureNote.idleExpressionThreshold ? generativePianoTuning.idleTextureBarSpacing - : expression < generativePianoTuning.textureNote.mediumExpressionThreshold - ? generativePianoTuning.mediumTextureBarSpacing - : generativePianoTuning.textureNote.intenseSpacing; - - return ( - barIndex % spacing === - (spacing === generativePianoTuning.textureNote.intenseSpacing - ? 0 - : generativePianoTuning.textureNote.idlePhase) - ); + : generativePianoTuning.mediumTextureBarSpacing; + return barIndex % spacing === generativePianoTuning.textureNote.idlePhase % spacing; } private getSupportOffsets( diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts index 19cc45b..7d148ac 100644 --- a/src/audio/noise-burst-player.ts +++ b/src/audio/noise-burst-player.ts @@ -8,8 +8,8 @@ const noiseBurstTuning = { offsetRandomSeconds: 0.4, scheduleAheadSeconds: 0.002, silentGain: 0.0001, - filterType: 'bandpass' as BiquadFilterType, -}; + filterType: 'bandpass', +} as const; export class NoiseBurstPlayer { public constructor(private readonly graph: GardenAudioGraph) {} diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index 1960b3b..a3f80f7 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -14,7 +14,7 @@ interface ActivePianoVoice { } const pianoSamplerTuning = { - filterType: 'lowpass' as BiquadFilterType, + filterType: 'lowpass', filterQ: 0.7, minDurationSeconds: 0.08, minFadeSeconds: 0.08, @@ -22,7 +22,7 @@ const pianoSamplerTuning = { tailStopExtraSeconds: 0.05, voiceStealFadeSeconds: 0.025, voiceStealStopSeconds: 0.05, -}; +} as const; export class PianoSampler { private samples: Array = []; @@ -178,7 +178,11 @@ export class PianoSampler { this.trimActiveVoices(scheduledStart); while (this.activeVoices.length >= this.config.piano.maxVoices) { - this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart); + const oldest = this.activeVoices.shift(); + if (!oldest) { + break; + } + this.stopVoice(oldest, scheduledStart); } filter.type = pianoSamplerTuning.filterType; @@ -220,11 +224,8 @@ export class PianoSampler { ); } - private computeNoteGain(velocity: number, scale = 1): number { - return Math.max( - pianoSamplerTuning.minGain, - this.config.piano.gain * velocity * scale - ); + private computeNoteGain(velocity: number): number { + return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity); } private findNearestSample(midi: number): LoadedPianoSample | null { diff --git a/src/config.ts b/src/config.ts index 473dbf8..27e8a24 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,8 @@ 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'; -import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts'; + +const DEFAULT_AUDIO_VOLUME = 0.5; export { normalizeNumberControlValue, @@ -114,9 +115,9 @@ export const appConfig = { }, }, storage: { - audioMutedKey: APP_STORAGE_KEYS.audioMuted, - audioVolumeKey: APP_STORAGE_KEYS.audioVolume, - vibeKey: APP_STORAGE_KEYS.vibe, + audioMutedKey: 'fleeting-garden:audio-muted', + audioVolumeKey: 'fleeting-garden:audio-volume', + vibeKey: 'fleeting-garden:vibe', }, toolbar: { eraser: { diff --git a/src/config/vibe-presets.test.ts b/src/config/vibe-presets.test.ts index 54c7c9e..608a86e 100644 --- a/src/config/vibe-presets.test.ts +++ b/src/config/vibe-presets.test.ts @@ -11,6 +11,19 @@ const FINAL_VIBE_NAMES = [ 'Chrome Pollen', ]; +const BLENDED_BRUSH_SIZE_MIN = 17; +const BLENDED_CLARITY_MAX = 0.56; +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 HIGH_DENSITY_SPAWN_THRESHOLD = 0.28; +const HIGH_DENSITY_DECAY_LIMIT = 940; +const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14; +const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055; + describe('vibePresets', () => { it('keeps the classic preset set distinct', () => { expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES); @@ -22,12 +35,16 @@ describe('vibePresets', () => { it('includes both blended and visibly particulate styles', () => { const blendedNames = vibePresets .filter( - (preset) => preset.settings.brushSize >= 17 && preset.settings.clarity <= 0.56 + (preset) => + preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN && + preset.settings.clarity <= BLENDED_CLARITY_MAX ) .map((preset) => preset.name); const softParticleNames = vibePresets .filter( - (preset) => preset.settings.brushSize <= 5 && preset.settings.clarity <= 0.2 + (preset) => + preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX && + preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX ) .map((preset) => preset.name); @@ -40,17 +57,17 @@ describe('vibePresets', () => { const { name, settings } = preset; const presetViolations: Array = []; - if (settings.spawnPerPixel > 0.38) { - presetViolations.push(`${name} density exceeds 0.38`); + if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) { + presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`); } - if (settings.brushSize > 36) { - presetViolations.push(`${name} brush size exceeds 36`); + if (settings.brushSize > MAX_BRUSH_SIZE) { + presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`); } if ( - settings.spawnPerPixel >= 0.28 && - (settings.decayRateTrails > 940 || - settings.brushSize > 14 || - settings.individualTrailWeight > 0.055) + settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD && + (settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT || + settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT || + settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT) ) { presetViolations.push(`${name} combines high density with too much persistence`); } diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index 3b9a2b5..c5c7d40 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -155,20 +155,20 @@ export const vibePresets: Array = [ moveSpeed: 270, sensorOffsetAngle: 36, sensorOffsetDistance: 51, - spawnPerPixel: 0.13999999999999999, - strokeAngleJitterRadians: 0.44999999999999996, + spawnPerPixel: 0.14, + strokeAngleJitterRadians: 0.45, turnSpeed: 22, - turnWhenLost: 6.071532165918825e-17, + turnWhenLost: 0, }, audio: { ...defaultGardenAudioVibeSettings, idleIntensity: 0.12, bpm: 60, - rampUpIntensity: 0.7000000000000001, + rampUpIntensity: 0.7, rampUpTime: 0.14, noteLength: 0.86, notePitchOffset: -2, - brightness: 0.8400000000000001, + brightness: 0.84, scale: musicScales.lydian, progression: musicProgressions.aurora, }, @@ -188,23 +188,23 @@ export const vibePresets: Array = [ brushSize: 9.75, clarity: 0.437, decayRateTrails: 915, - forwardRotationScale: 2.0816681711721685e-17, + forwardRotationScale: 0, individualTrailWeight: 0.1, moveSpeed: 216, sensorOffsetAngle: 24, sensorOffsetDistance: 17, spawnPerPixel: 0.24, - strokeAngleJitterRadians: 0.16999999999999993, + strokeAngleJitterRadians: 0.17, turnSpeed: 33, - turnWhenLost: 0.42000000000000004, + turnWhenLost: 0.42, }, audio: { ...defaultGardenAudioVibeSettings, idleIntensity: 0.55, bpm: 72, rampUpIntensity: 1.42, - rampUpTime: 0.07000000000000002, - noteLength: 0.7000000000000001, + rampUpTime: 0.07, + noteLength: 0.7, notePitchOffset: 0, brightness: 0.94, scale: musicScales.naturalMinor, @@ -227,21 +227,21 @@ export const vibePresets: Array = [ clarity: 0.74, decayRateTrails: 962, forwardRotationScale: 0.3, - individualTrailWeight: 0.052000000000000005, + individualTrailWeight: 0.052, moveSpeed: 72, sensorOffsetAngle: 42, sensorOffsetDistance: 54, - spawnPerPixel: 0.15999999999999998, - strokeAngleJitterRadians: 3.1399999999999997, + spawnPerPixel: 0.16, + strokeAngleJitterRadians: 3.14, turnSpeed: 44, - turnWhenLost: 0.9200000000000002, + turnWhenLost: 0.92, }, audio: { ...defaultGardenAudioVibeSettings, idleIntensity: 0.13, bpm: 68, rampUpIntensity: 1.46, - rampUpTime: 0.10000000000000002, + rampUpTime: 0.1, noteLength: 0.6, notePitchOffset: -3, brightness: 1.21, @@ -307,7 +307,7 @@ export const vibePresets: Array = [ moveSpeed: 28, sensorOffsetAngle: 34, sensorOffsetDistance: 66, - spawnPerPixel: 0.05499999999999998, + spawnPerPixel: 0.055, strokeAngleJitterRadians: 0, turnSpeed: 30, turnWhenLost: 1.52, @@ -317,7 +317,7 @@ export const vibePresets: Array = [ idleIntensity: 0.33, bpm: 127, rampUpIntensity: 0.66, - rampUpTime: 0.03000000000000001, + rampUpTime: 0.03, noteLength: 0.92, notePitchOffset: 10, brightness: 1.42, @@ -341,7 +341,7 @@ export const vibePresets: Array = [ clarity: 0.1, decayRateTrails: 922, forwardRotationScale: 0.5, - individualTrailWeight: 0.026000000000000002, + individualTrailWeight: 0.026, moveSpeed: 86, sensorOffsetAngle: 46, sensorOffsetDistance: 14, @@ -355,7 +355,7 @@ export const vibePresets: Array = [ idleIntensity: 0.11, bpm: 150, rampUpIntensity: 2, - rampUpTime: 0.06000000000000001, + rampUpTime: 0.06, noteLength: 1.8, notePitchOffset: -12, brightness: 0.5, diff --git a/src/consts.ts b/src/consts.ts deleted file mode 100644 index 8991f08..0000000 --- a/src/consts.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const ENABLED_FLAG_VALUE = '1'; -export const DISABLED_FLAG_VALUE = '0'; - -export const DEFAULT_AUDIO_VOLUME = 0.5; - -export const APP_STORAGE_KEYS = { - audioMuted: 'fleeting-garden:audio-muted', - audioVolume: 'fleeting-garden:audio-volume', - vibe: 'fleeting-garden:vibe', -} as const; diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index abdb795..5cab70d 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -1,19 +1,18 @@ import { settings } from '../settings'; -export class FramePerformance { - private readonly adaptiveRefreshTargetFps = 60; - private readonly initialFps = this.adaptiveRefreshTargetFps; - public smoothedFps = this.initialFps; +const ADAPTIVE_REFRESH_TARGET_FPS = 60; +const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = 200_000; +const FRAME_GAP_RESET_SECONDS = 1; +const FPS_HEADROOM = 0.9; +const FPS_SMOOTHING_NEW = 0.06; +const FPS_SMOOTHING_RETAIN = 1 - FPS_SMOOTHING_NEW; +export class FramePerformance { + public smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS; public measuredFps = 0; public frameDeltaSeconds = 0; public measuredFrameTimeMs = 0; - private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000; - private readonly frameGapResetSeconds = 1; - private readonly fpsHeadroom = 0.9; - private readonly fpsSmoothingNew = 0.06; - private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew; private previousFrameTime: DOMHighResTimeStamp | null = null; public get adaptiveCapInitial(): number { @@ -25,13 +24,13 @@ export class FramePerformance { } public get hasAdaptiveCapHeadroom(): boolean { - return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom; + return this.smoothedFps >= ADAPTIVE_REFRESH_TARGET_FPS * FPS_HEADROOM; } public get adaptiveCapDecreaseAgents(): number { return Math.max( 1, - Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds) + Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * this.frameDeltaSeconds) ); } @@ -51,11 +50,10 @@ export class FramePerformance { this.frameDeltaSeconds = deltaSeconds; this.measuredFrameTimeMs = deltaSeconds * 1000; this.measuredFps = fps; - if (deltaSeconds > this.frameGapResetSeconds) { + if (deltaSeconds > FRAME_GAP_RESET_SECONDS) { return; } - this.smoothedFps = - this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew; + this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW; } } diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts index 7832469..d2256d0 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -14,8 +14,7 @@ export class SimulationTextures { public trailMapA: ResizableTexture; public trailMapB: ResizableTexture; // Per-frame deposit accumulator: cleared each frame, written sparsely by - // agents, then read by diffuse alongside trailMapA. Replaces the previous - // full-resolution copyTrailMapAToB seed. + // agents, then read by diffuse alongside trailMapA. public readonly depositMap: ResizableTexture; public readonly eraserMask: ResizableTexture; public sourceMapA: ResizableTexture; diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts index c96fb1d..7677bad 100644 --- a/src/page/audio-control.ts +++ b/src/page/audio-control.ts @@ -1,5 +1,4 @@ import { appConfig } from '../config'; -import { DISABLED_FLAG_VALUE, ENABLED_FLAG_VALUE } from '../consts'; import type GameLoop from '../game-loop/game-loop'; import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage'; import { queryRequiredElement } from '../utils/dom'; @@ -21,6 +20,9 @@ const readInitialAudioVolume = (): number => { const formatStoredAudioVolume = (volume: number): string => clampAudioVolume(volume).toFixed(2); +const STORED_MUTED_TRUE = '1'; +const STORED_MUTED_FALSE = '0'; + interface AudioControlOptions { getGame: () => GameLoop | null; hasStarted: () => boolean; @@ -43,7 +45,7 @@ export class AudioControl { private audioVolume = readInitialAudioVolume(); private isMutedState = - readBrowserStorage(appConfig.storage.audioMutedKey) === ENABLED_FLAG_VALUE || + readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE || this.audioVolume <= 0; public constructor(private readonly options: AudioControlOptions) { @@ -140,7 +142,7 @@ export class AudioControl { private persist(): void { writeBrowserStorage( appConfig.storage.audioMutedKey, - this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE + this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE ); writeBrowserStorage( appConfig.storage.audioVolumeKey, diff --git a/src/page/splash-screen.ts b/src/page/splash-screen.ts index 50dd211..00d44a4 100644 --- a/src/page/splash-screen.ts +++ b/src/page/splash-screen.ts @@ -30,13 +30,24 @@ export class SplashScreen { public awaitStart(onStart: () => void): Promise { this.startButton.disabled = false; return new Promise((resolve) => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || event.defaultPrevented) { + return; + } + + event.preventDefault(); + this.startButton.click(); + }; const onClick = () => { this.startButton.removeEventListener('click', onClick); + document.removeEventListener('keydown', onKeyDown); onStart(); this.setVisible(this.splash, false); resolve(); }; + this.startButton.addEventListener('click', onClick); + document.addEventListener('keydown', onKeyDown); }); } diff --git a/src/style/vars.scss b/src/style/vars.scss index 24df21b..319d82e 100644 --- a/src/style/vars.scss +++ b/src/style/vars.scss @@ -1,7 +1,7 @@ :root { --transition-time: 200ms; --transition-time-long: 350ms; - --accent-color: rgb(255, 93, 162); + --accent-color: rgb(255 93 162); --main-color: #aaa; --normal-margin: 2rem; --small-margin: 1rem; diff --git a/vite.config.ts b/vite.config.ts index da6207c..b063e3c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,7 +22,6 @@ export default defineConfig(({ command }) => ({ }, server: { host: true, - hmr: false, }, test: { environment: 'node',