sure
This commit is contained in:
parent
ced0ac56f3
commit
d6a8f898d1
27 changed files with 760 additions and 363 deletions
312
e2e/app.spec.ts
312
e2e/app.spec.ts
|
|
@ -1,5 +1,34 @@
|
|||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const canvasName = 'Interactive generative garden canvas';
|
||||
|
||||
const isLocalUrl = (url: string) => {
|
||||
const { hostname } = new URL(url);
|
||||
return hostname === '127.0.0.1' || hostname === 'localhost';
|
||||
};
|
||||
|
||||
const collectLocalBrowserFailures = (page: Page) => {
|
||||
const failures: Array<string> = [];
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
if (!isLocalUrl(request.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const failure = request.failure();
|
||||
failures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() < 400 || !isLocalUrl(response.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
failures.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
|
||||
return failures;
|
||||
};
|
||||
|
||||
const disableWebGpu = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
|
|
@ -9,223 +38,98 @@ const disableWebGpu = async (page: Page) => {
|
|||
});
|
||||
};
|
||||
|
||||
const getFirstSwatchColor = (page: Page) =>
|
||||
page
|
||||
.locator('.color-swatch')
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
|
||||
const getGardenBackground = (page: Page) =>
|
||||
page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue('--garden-background').trim()
|
||||
);
|
||||
|
||||
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
|
||||
const browserFailures: Array<string> = [];
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const failure = request.failure();
|
||||
browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
browserFailures.push(`${response.status()} ${response.url()}`);
|
||||
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
const consoleErrors: Array<string> = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await disableWebGpu(page);
|
||||
await page.addInitScript((expectedCanvasName) => {
|
||||
const captureState = { count: 0 };
|
||||
Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
|
||||
configurable: true,
|
||||
value: captureState,
|
||||
});
|
||||
|
||||
const originalSetPointerCapture = Element.prototype.setPointerCapture;
|
||||
Element.prototype.setPointerCapture = function setPointerCapture(pointerId) {
|
||||
if (
|
||||
this instanceof HTMLCanvasElement &&
|
||||
this.getAttribute('aria-label') === expectedCanvasName
|
||||
) {
|
||||
captureState.count += 1;
|
||||
}
|
||||
|
||||
return originalSetPointerCapture.call(this, pointerId);
|
||||
};
|
||||
}, canvasName);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page).toHaveTitle('Fleeting Garden');
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||
|
||||
await page.getByRole('button', { name: 'About' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||
const canvas = page.getByRole('img', { name: canvasName });
|
||||
await expect(canvas).toBeVisible();
|
||||
const canvasSize = await canvas.evaluate((element) => {
|
||||
const canvasElement = element as HTMLCanvasElement;
|
||||
return {
|
||||
height: canvasElement.height,
|
||||
width: canvasElement.width,
|
||||
};
|
||||
});
|
||||
expect(canvasSize.width).toBeGreaterThan(0);
|
||||
expect(canvasSize.height).toBeGreaterThan(0);
|
||||
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.5, {
|
||||
steps: 16,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__fleetingGardenPointerCaptures?: { count: number };
|
||||
}
|
||||
).__fleetingGardenPointerCaptures?.count ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(browserFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps fallback controls interactive and accessible', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveTitle('Fleeting Garden');
|
||||
await expect(page.getByRole('img', { name: canvasName })).toBeVisible();
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
|
||||
const aboutButton = page.getByRole('button', { name: 'About' });
|
||||
const aboutPanel = page.locator('#info-panel');
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await aboutButton.click();
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(aboutPanel).not.toHaveAttribute('inert', '');
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(aboutPanel).toHaveAttribute('inert', '');
|
||||
|
||||
const settingsButton = page.locator('button.settings');
|
||||
await expect(settingsButton).toHaveAttribute('aria-label', 'Show config overlay');
|
||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await settingsButton.click();
|
||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(settingsButton).toHaveAttribute('aria-label', 'Hide config overlay');
|
||||
await expect(page.locator('.config-pane')).toBeVisible();
|
||||
await expect(page.locator('.config-pane')).toContainText('Runtime');
|
||||
await expect(page.locator('.color-reaction-matrix')).toBeVisible();
|
||||
|
||||
const colorReaction = page.getByLabel('Color 1 agents reacting to color 2');
|
||||
await colorReaction.selectOption('-1');
|
||||
await expect(colorReaction).toHaveValue('-1');
|
||||
await settingsButton.click();
|
||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
const soundButton = page.locator('button.sound');
|
||||
const volumeSlider = page.getByLabel('Master volume');
|
||||
await expect(volumeSlider).toHaveValue('0.42');
|
||||
await volumeSlider.evaluate((input) => {
|
||||
const slider = input as HTMLInputElement;
|
||||
slider.value = '0.25';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await expect(volumeSlider).toHaveValue('0.25');
|
||||
await expect(volumeSlider).toHaveAttribute('aria-valuetext', '25%');
|
||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
|
||||
await soundButton.click();
|
||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio');
|
||||
await page.reload();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByLabel('Master volume')).toHaveValue('0.25');
|
||||
await expect(page.getByLabel('Master volume')).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
'Muted, 25%'
|
||||
);
|
||||
|
||||
const initialSwatchColor = await getFirstSwatchColor(page);
|
||||
const initialBackground = await getGardenBackground(page);
|
||||
await page.getByRole('button', { name: 'Next vibe' }).click();
|
||||
await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor);
|
||||
await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground);
|
||||
|
||||
await page.getByRole('button', { name: 'Draw colour 2' }).click();
|
||||
await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/);
|
||||
await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/);
|
||||
|
||||
const mirrorSlider = page.locator('.mirror-segment-slider');
|
||||
await mirrorSlider.evaluate((input) => {
|
||||
const slider = input as HTMLInputElement;
|
||||
slider.value = '3';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await expect(page.locator('.mirror-segment-control')).toHaveAttribute(
|
||||
'title',
|
||||
'3 thirds'
|
||||
);
|
||||
await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('keeps the fallback shell usable on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
|
||||
const canvasBox = await page
|
||||
.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||
.boundingBox();
|
||||
expect(canvasBox?.width).toBeGreaterThan(0);
|
||||
expect(canvasBox?.height).toBeGreaterThan(0);
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'About' })).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||
|
||||
const aboutButtonReceivesPointer = await page
|
||||
.getByRole('button', { name: 'About' })
|
||||
.evaluate((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const target = document.elementFromPoint(
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2
|
||||
);
|
||||
|
||||
return button === target || button.contains(target);
|
||||
});
|
||||
|
||||
expect(aboutButtonReceivesPointer).toBe(true);
|
||||
});
|
||||
|
||||
test('hides the bottom dock after the cursor leaves fullscreen controls', async ({
|
||||
page,
|
||||
}) => {
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Boolean(document.fullscreenElement)))
|
||||
.toBe(true);
|
||||
|
||||
await page.mouse.move(640, 120);
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
});
|
||||
|
||||
await expect(page.locator('aside.control-dock')).toHaveClass(/menu-hidden/, {
|
||||
timeout: 6000,
|
||||
});
|
||||
await expect(page.locator('.garden-controls')).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('aside.control-dock')
|
||||
.evaluate((dock) => dock.getBoundingClientRect().top >= window.innerHeight)
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await page.mouse.move(640, 700);
|
||||
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
|
||||
await expect(page.locator('.garden-controls')).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('aside.control-dock')
|
||||
.evaluate((dock) => dock.getBoundingClientRect().bottom <= window.innerHeight)
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => {
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Boolean(document.fullscreenElement)))
|
||||
.toBe(true);
|
||||
|
||||
await page.mouse.move(195, 120);
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(5200);
|
||||
|
||||
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
|
||||
await expect(page.getByRole('button', { name: 'About' })).toBeVisible();
|
||||
const fallback = page.getByRole('alert');
|
||||
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
|
||||
await expect(fallback).toContainText('webgpu-unsupported');
|
||||
expect(browserFailures).toEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ export default defineConfig({
|
|||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
launchOptions: {
|
||||
args: ['--enable-unsafe-webgpu'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
import type { VibeId } from './vibes';
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
||||
|
|
@ -37,7 +39,7 @@ export const trackVibeChange = ({
|
|||
vibeName,
|
||||
source,
|
||||
}: {
|
||||
vibeId: string;
|
||||
vibeId: VibeId;
|
||||
vibeName: string;
|
||||
source: string;
|
||||
}) => {
|
||||
|
|
@ -50,7 +52,7 @@ export const trackVibeChange = ({
|
|||
});
|
||||
};
|
||||
|
||||
export const trackExport = ({ vibeId }: { vibeId: string }) => {
|
||||
export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
|
||||
track('Export', {
|
||||
props: {
|
||||
format: 'png',
|
||||
|
|
|
|||
|
|
@ -94,8 +94,6 @@ interface GardenAudioGenerativePianoConfig {
|
|||
};
|
||||
brushPhrase: {
|
||||
initialMotifOffset: number;
|
||||
energyRetain: number;
|
||||
maniaRetain: number;
|
||||
energyDecaySeconds: number;
|
||||
maniaDecaySeconds: number;
|
||||
fadeMinimumLifetimeSeconds: number;
|
||||
|
|
@ -165,7 +163,6 @@ interface GardenAudioGenerativePianoConfig {
|
|||
min: number;
|
||||
max: number;
|
||||
};
|
||||
styleRotationMinSeconds: number;
|
||||
stylePanOffsetScale: number;
|
||||
lowpass: {
|
||||
midiBase: number;
|
||||
|
|
@ -174,7 +171,6 @@ interface GardenAudioGenerativePianoConfig {
|
|||
expressionBase: number;
|
||||
expressionWeight: number;
|
||||
};
|
||||
styleRotationSeconds: number;
|
||||
styleRotationBars: number;
|
||||
chordBars: number;
|
||||
supportBarSpacing: number;
|
||||
|
|
@ -189,7 +185,6 @@ interface GardenAudioGenerativePianoConfig {
|
|||
noteScoreChordToneWeight: number;
|
||||
noteScoreRepeatPenalty: number;
|
||||
gestureAccentMinIntervalSeconds: number;
|
||||
strokeAccentMinIntervalSeconds: number;
|
||||
strokeAccentMinSteps: number;
|
||||
strokeAccentThreshold: number;
|
||||
stingerDurationSeconds: number;
|
||||
|
|
@ -315,8 +310,6 @@ export interface GardenAudioConfig {
|
|||
};
|
||||
};
|
||||
input: {
|
||||
distanceWindowForFullActivityPixels: number;
|
||||
distanceWindowSeconds: number;
|
||||
fallbackFrameSeconds: number;
|
||||
fullActivitySpeed: number;
|
||||
activityNoiseFloorSpeed: number;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
const fallbackNormalizationPixels = 1000;
|
||||
|
||||
export interface GardenAudioStrokeMetrics {
|
||||
distancePixels: number;
|
||||
elapsedSeconds: number;
|
||||
|
|
@ -16,8 +18,7 @@ export const getStrokeMetrics = (
|
|||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
const elapsedSeconds = getElapsedSeconds(stroke, inputConfig);
|
||||
const normalizedDistance =
|
||||
distancePixels / getStrokeNormalizationPixels(stroke, inputConfig);
|
||||
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
|
||||
|
||||
return {
|
||||
distancePixels,
|
||||
|
|
@ -42,10 +43,7 @@ const getElapsedSeconds = (
|
|||
return inputConfig.fallbackFrameSeconds;
|
||||
};
|
||||
|
||||
const getStrokeNormalizationPixels = (
|
||||
stroke: GardenAudioStroke,
|
||||
inputConfig: GardenAudioConfig['input']
|
||||
): number => {
|
||||
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
|
||||
const width = stroke.canvasSize?.[0];
|
||||
const height = stroke.canvasSize?.[1];
|
||||
if (
|
||||
|
|
@ -59,5 +57,5 @@ const getStrokeNormalizationPixels = (
|
|||
return Math.max(1, Math.min(width, height));
|
||||
}
|
||||
|
||||
return Math.max(1, inputConfig.distanceWindowForFullActivityPixels);
|
||||
return fallbackNormalizationPixels;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { clamp01 } from '../utils/clamp';
|
||||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { VibePreset } from '../vibes';
|
||||
import type { VibeId, VibePreset } from '../vibes';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||
|
|
@ -22,6 +22,8 @@ export type {
|
|||
GardenAudioStroke,
|
||||
} from './garden-audio-types';
|
||||
|
||||
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||
|
||||
export class GardenAudio {
|
||||
private readonly graph: GardenAudioGraph;
|
||||
private readonly piano: PianoSampler;
|
||||
|
|
@ -30,13 +32,11 @@ export class GardenAudio {
|
|||
private readonly gestureState: GardenAudioGestureState;
|
||||
private readonly pianoEngine: GenerativePianoEngine;
|
||||
|
||||
private currentVibeId: string | null = null;
|
||||
private hasStarted = false;
|
||||
private isDestroyed = false;
|
||||
private currentVibeId: VibeId | null = null;
|
||||
private lifecycle: AudioLifecycle = 'idle';
|
||||
private isMuted = false;
|
||||
private masterVolume: number;
|
||||
private isGestureActive = false;
|
||||
private hasQueuedPianoLoad = false;
|
||||
private masterVolume: number;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
if (this.lifecycle === 'destroyed' || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +81,11 @@ export class GardenAudio {
|
|||
if (resumePromise) {
|
||||
void resumePromise
|
||||
.then(() => {
|
||||
if (this.graph.context === context && !this.isDestroyed && !this.isMuted) {
|
||||
if (
|
||||
this.graph.context === context &&
|
||||
this.lifecycle !== 'destroyed' &&
|
||||
!this.isMuted
|
||||
) {
|
||||
this.graph.unlock();
|
||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||
}
|
||||
|
|
@ -94,17 +98,16 @@ export class GardenAudio {
|
|||
});
|
||||
}
|
||||
|
||||
this.hasStarted = true;
|
||||
this.lifecycle = 'started';
|
||||
this.applyVibe(vibe);
|
||||
this.pianoEngine.prime(context.currentTime);
|
||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||
|
||||
if (!this.hasQueuedPianoLoad) {
|
||||
this.hasQueuedPianoLoad = true;
|
||||
void this.piano
|
||||
.load(context)
|
||||
const pianoLoad = this.piano.loadIfIdle(context);
|
||||
if (pianoLoad) {
|
||||
void pianoLoad
|
||||
.then(() => {
|
||||
if (this.graph.context === context && !this.isDestroyed) {
|
||||
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||
this.pianoEngine.cue(context.currentTime);
|
||||
}
|
||||
})
|
||||
|
|
@ -126,7 +129,7 @@ export class GardenAudio {
|
|||
context &&
|
||||
(context.state === 'running' || options.userGesture === true) &&
|
||||
!this.isMuted &&
|
||||
!this.isDestroyed &&
|
||||
this.lifecycle !== 'destroyed' &&
|
||||
didChangeVibe
|
||||
) {
|
||||
this.playVibeChangeStinger(vibe);
|
||||
|
|
@ -173,7 +176,7 @@ export class GardenAudio {
|
|||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
const context = this.graph.context;
|
||||
if (!this.hasStarted || !context || this.isMuted) {
|
||||
if (this.lifecycle !== 'started' || !context || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +198,7 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
if (this.lifecycle === 'destroyed' || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +233,7 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.isDestroyed = true;
|
||||
this.lifecycle = 'destroyed';
|
||||
await this.graph.close();
|
||||
|
||||
this.piano.reset();
|
||||
|
|
@ -238,9 +241,7 @@ export class GardenAudio {
|
|||
this.gestureState.reset();
|
||||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.hasStarted = false;
|
||||
this.isGestureActive = false;
|
||||
this.hasQueuedPianoLoad = false;
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,47 @@ describe('PianoSampler', () => {
|
|||
expect(calls.bufferSourcesStarted).toBe(1);
|
||||
});
|
||||
|
||||
it('only queues a piano load when the sampler is idle', async () => {
|
||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
||||
const sampler = await makeSampler(context);
|
||||
const fetch = vi.fn(async () => {
|
||||
return {
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
ok: true,
|
||||
} as Response;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
const firstLoad = sampler.loadIfIdle(context);
|
||||
const secondLoad = sampler.loadIfIdle(context);
|
||||
|
||||
expect(firstLoad).toBeInstanceOf(Promise);
|
||||
expect(secondLoad).toBeNull();
|
||||
|
||||
await firstLoad;
|
||||
|
||||
expect(sampler.loadIfIdle(context)).toBeNull();
|
||||
expect(fetch).toHaveBeenCalledTimes(sampleCount);
|
||||
});
|
||||
|
||||
it('allows loading to be retried after a load failure', async () => {
|
||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
||||
const sampler = await makeSampler(context);
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('load failed'))
|
||||
.mockResolvedValue({
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
ok: true,
|
||||
} as Response);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
await expect(sampler.loadIfIdle(context)).rejects.toThrow('load failed');
|
||||
await expect(sampler.loadIfIdle(context)).resolves.toBeUndefined();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(sampleCount * 2);
|
||||
});
|
||||
|
||||
it('stays silent when no decoded sample is available', () => {
|
||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
||||
return makeSampler(context).then((sampler) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { GardenAudioGraph } from './garden-audio-graph';
|
|||
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||
|
||||
type PianoLoadState = 'idle' | 'loading' | 'loaded';
|
||||
|
||||
export class PianoSampler {
|
||||
private loadState: PianoLoadState = 'idle';
|
||||
private sampleLoadPromise: Promise<void> | null = null;
|
||||
private samples: Array<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
|
|
@ -15,9 +18,14 @@ export class PianoSampler {
|
|||
) {}
|
||||
|
||||
public load(context: BaseAudioContext): Promise<void> {
|
||||
if (this.loadState === 'loaded') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const loadedSamples = getLoadedPianoSamples();
|
||||
if (loadedSamples) {
|
||||
this.setSamples(loadedSamples);
|
||||
this.loadState = 'loaded';
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
|
@ -25,13 +33,29 @@ export class PianoSampler {
|
|||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
this.sampleLoadPromise = loadPianoSamples(context).then((samples) => {
|
||||
this.setSamples(samples);
|
||||
});
|
||||
this.loadState = 'loading';
|
||||
this.sampleLoadPromise = loadPianoSamples(context)
|
||||
.then((samples) => {
|
||||
this.setSamples(samples);
|
||||
this.loadState = 'loaded';
|
||||
})
|
||||
.catch((error) => {
|
||||
this.loadState = 'idle';
|
||||
this.sampleLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
|
||||
if (this.loadState !== 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.load(context);
|
||||
}
|
||||
|
||||
public play({
|
||||
midi,
|
||||
velocity,
|
||||
|
|
@ -158,6 +182,7 @@ export class PianoSampler {
|
|||
}
|
||||
|
||||
public reset(): void {
|
||||
this.loadState = 'idle';
|
||||
this.sampleLoadPromise = null;
|
||||
this.samples = [];
|
||||
this.activeVoices = [];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
|||
|
||||
const defaultAudioMasterVolume = 0.42;
|
||||
|
||||
export { VibeId } from './config/types';
|
||||
|
||||
export type {
|
||||
GardenAppConfig,
|
||||
GardenRuntimeSettings,
|
||||
|
|
@ -56,7 +58,7 @@ export const appConfig = {
|
|||
tailStopExtraSeconds: 0.05,
|
||||
voiceStealFadeSeconds: 0.025,
|
||||
voiceStealStopSeconds: 0.05,
|
||||
sampleBaseUrl: `${import.meta.env.BASE_URL}audio/piano/`,
|
||||
sampleBaseUrl: `${import.meta.env.BASE_URL}audio/`,
|
||||
preloadDecode: {
|
||||
channels: 1,
|
||||
frames: 1,
|
||||
|
|
@ -126,8 +128,6 @@ export const appConfig = {
|
|||
},
|
||||
},
|
||||
input: {
|
||||
distanceWindowForFullActivityPixels: 140,
|
||||
distanceWindowSeconds: 0.5,
|
||||
fallbackFrameSeconds: 1 / 60,
|
||||
fullActivitySpeed: 0.86,
|
||||
activityNoiseFloorSpeed: 0.025,
|
||||
|
|
@ -267,8 +267,6 @@ export const appConfig = {
|
|||
},
|
||||
brushPhrase: {
|
||||
initialMotifOffset: -1,
|
||||
energyRetain: 0.94,
|
||||
maniaRetain: 0.92,
|
||||
energyDecaySeconds: 0.72,
|
||||
maniaDecaySeconds: 0.54,
|
||||
fadeMinimumLifetimeSeconds: 0.001,
|
||||
|
|
@ -338,7 +336,6 @@ export const appConfig = {
|
|||
min: -3,
|
||||
max: 3,
|
||||
},
|
||||
styleRotationMinSeconds: 0.001,
|
||||
stylePanOffsetScale: 0.35,
|
||||
lowpass: {
|
||||
midiBase: 48,
|
||||
|
|
@ -347,7 +344,6 @@ export const appConfig = {
|
|||
expressionBase: 0.58,
|
||||
expressionWeight: 0.32,
|
||||
},
|
||||
styleRotationSeconds: 8,
|
||||
styleRotationBars: 2,
|
||||
chordBars: 4,
|
||||
supportBarSpacing: 2,
|
||||
|
|
@ -362,7 +358,6 @@ export const appConfig = {
|
|||
noteScoreChordToneWeight: 0.75,
|
||||
noteScoreRepeatPenalty: 3.2,
|
||||
gestureAccentMinIntervalSeconds: 2.5,
|
||||
strokeAccentMinIntervalSeconds: 3.2,
|
||||
strokeAccentMinSteps: 12,
|
||||
strokeAccentThreshold: 0.58,
|
||||
stingerSpacingSeconds: 0.08,
|
||||
|
|
|
|||
|
|
@ -64,8 +64,17 @@ type GardenDefaultSettings = Omit<
|
|||
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
|
||||
>;
|
||||
|
||||
export enum VibeId {
|
||||
CandyRain = 'candy-rain',
|
||||
SunlitMoss = 'sunlit-moss',
|
||||
CoralTide = 'coral-tide',
|
||||
MoonOrchid = 'moon-orchid',
|
||||
PeachNeon = 'peach-neon',
|
||||
FrostBloom = 'frost-bloom',
|
||||
}
|
||||
|
||||
export interface VibePreset {
|
||||
id: string;
|
||||
id: VibeId;
|
||||
name: string;
|
||||
colors: [string, string, string];
|
||||
backgroundColor: string;
|
||||
|
|
@ -248,7 +257,7 @@ export interface GardenAppConfig {
|
|||
title: string;
|
||||
};
|
||||
vibes: {
|
||||
defaultVibeId: string;
|
||||
defaultVibeId: VibeId;
|
||||
presets: Array<VibePreset>;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { GardenAudioChord } from '../audio/garden-audio-config';
|
||||
import type { VibePreset } from './types';
|
||||
import { VibeId, type VibePreset } from './types';
|
||||
|
||||
const majorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
|
|
@ -21,11 +21,11 @@ const mixolydianPentatonic = [0, 2, 4, 7, 10];
|
|||
const dorianHexatonic = [0, 2, 3, 5, 7, 10];
|
||||
const darkMinorPentatonic = [0, 2, 3, 7, 10];
|
||||
|
||||
export const defaultVibeId = 'candy-rain';
|
||||
export const defaultVibeId = VibeId.CandyRain;
|
||||
|
||||
export const vibePresets: Array<VibePreset> = [
|
||||
{
|
||||
id: 'candy-rain',
|
||||
id: VibeId.CandyRain,
|
||||
name: 'Candy Rain',
|
||||
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
|
||||
backgroundColor: '#10151f',
|
||||
|
|
@ -50,7 +50,7 @@ export const vibePresets: Array<VibePreset> = [
|
|||
},
|
||||
},
|
||||
{
|
||||
id: 'sunlit-moss',
|
||||
id: VibeId.SunlitMoss,
|
||||
name: 'Sunlit Moss',
|
||||
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
||||
backgroundColor: '#172016',
|
||||
|
|
@ -80,7 +80,7 @@ export const vibePresets: Array<VibePreset> = [
|
|||
},
|
||||
},
|
||||
{
|
||||
id: 'coral-tide',
|
||||
id: VibeId.CoralTide,
|
||||
name: 'Coral Tide',
|
||||
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
|
||||
backgroundColor: '#0f1822',
|
||||
|
|
@ -105,7 +105,7 @@ export const vibePresets: Array<VibePreset> = [
|
|||
},
|
||||
},
|
||||
{
|
||||
id: 'moon-orchid',
|
||||
id: VibeId.MoonOrchid,
|
||||
name: 'Moon Orchid',
|
||||
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
|
||||
backgroundColor: '#14121d',
|
||||
|
|
@ -130,7 +130,7 @@ export const vibePresets: Array<VibePreset> = [
|
|||
},
|
||||
},
|
||||
{
|
||||
id: 'peach-neon',
|
||||
id: VibeId.PeachNeon,
|
||||
name: 'Peach Neon',
|
||||
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
|
||||
backgroundColor: '#191716',
|
||||
|
|
@ -155,7 +155,7 @@ export const vibePresets: Array<VibePreset> = [
|
|||
},
|
||||
},
|
||||
{
|
||||
id: 'frost-bloom',
|
||||
id: VibeId.FrostBloom,
|
||||
name: 'Frost Bloom',
|
||||
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
|
||||
backgroundColor: '#101820',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ vi.hoisted(() => {
|
|||
const originalBrushSize = settings.brushSize;
|
||||
const originalSelectedColorIndex = settings.selectedColorIndex;
|
||||
const originalSpawnPerPixel = settings.spawnPerPixel;
|
||||
const originalStrokeSpawnSpreadBrushSizeMultiplier =
|
||||
settings.strokeSpawnSpreadBrushSizeMultiplier;
|
||||
|
||||
const createPopulation = () => {
|
||||
const pipeline = {
|
||||
|
|
@ -51,12 +53,16 @@ describe('AgentPopulation adaptive budget', () => {
|
|||
settings.brushSize = 1;
|
||||
settings.selectedColorIndex = 0;
|
||||
settings.spawnPerPixel = 1;
|
||||
settings.strokeSpawnSpreadBrushSizeMultiplier = 1;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
settings.brushSize = originalBrushSize;
|
||||
settings.selectedColorIndex = originalSelectedColorIndex;
|
||||
settings.spawnPerPixel = originalSpawnPerPixel;
|
||||
settings.strokeSpawnSpreadBrushSizeMultiplier =
|
||||
originalStrokeSpawnSpreadBrushSizeMultiplier;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
||||
|
|
@ -102,6 +108,25 @@ describe('AgentPopulation adaptive budget', () => {
|
|||
expect(population.activeAgentCount).toBe(maxAgentCount);
|
||||
});
|
||||
|
||||
it('scales stroke spawn spread by device pixel ratio', () => {
|
||||
settings.brushSize = 10;
|
||||
const writeAgents = vi.fn();
|
||||
const pipeline = {
|
||||
maxAgentCount: 10_000_000,
|
||||
writeAgents,
|
||||
resizeAgents: vi.fn(),
|
||||
compactAgents: vi.fn(),
|
||||
} as unknown as AgentGenerationPipeline;
|
||||
const population = new AgentPopulation(pipeline, 0, () => 2);
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(0, 0));
|
||||
|
||||
const firstBatch = writeAgents.mock.calls[0][1] as Float32Array;
|
||||
expect(firstBatch[0]).toBe(10);
|
||||
expect(firstBatch[1]).toBe(10);
|
||||
});
|
||||
|
||||
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
||||
const population = createPopulation();
|
||||
setPopulationActiveCount(population, 1_000_000);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { vec2 } from 'gl-matrix';
|
|||
import { appConfig } from '../config';
|
||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { getSafeDevicePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { createIntroTitleAgents } from './intro-title-agents';
|
||||
|
||||
|
|
@ -19,7 +20,8 @@ export class AgentPopulation {
|
|||
|
||||
public constructor(
|
||||
private readonly pipeline: AgentGenerationPipeline,
|
||||
private readonly introSeed = Math.floor(Math.random() * 0xffffffff)
|
||||
private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
|
||||
private readonly getDevicePixelRatio = () => 1
|
||||
) {
|
||||
this.adaptiveCap = this.clampAdaptiveCap(
|
||||
appConfig.simulation.budget.adaptiveCapInitial
|
||||
|
|
@ -121,7 +123,10 @@ export class AgentPopulation {
|
|||
baseAngle +
|
||||
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
||||
const base = i * AGENT_FLOAT_COUNT;
|
||||
const spread = settings.brushSize * settings.strokeSpawnSpreadBrushSizeMultiplier;
|
||||
const spread =
|
||||
settings.brushSize *
|
||||
getSafeDevicePixelRatio(this.getDevicePixelRatio()) *
|
||||
settings.strokeSpawnSpreadBrushSizeMultiplier;
|
||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
|
||||
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
|
||||
this.strokeAgentData[base + 2] = angle;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { appConfig } from '../config';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import type { VibeId } from '../vibes';
|
||||
import {
|
||||
estimateExport4KMemory,
|
||||
getAspectFitExport4KDimensions,
|
||||
|
|
@ -15,7 +16,7 @@ interface Export4KRendererOptions {
|
|||
getSourceSize: () => { width: number; height: number };
|
||||
getColorTextureView: () => GPUTextureView;
|
||||
getSourceTextureView: () => GPUTextureView;
|
||||
getVibeId: () => string;
|
||||
getVibeId: () => VibeId;
|
||||
}
|
||||
|
||||
export class Export4KRenderer {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ interface FrameParameters extends RenderInputs {
|
|||
deltaTime: number;
|
||||
canvasSize: vec2;
|
||||
activeAgentCount: number;
|
||||
devicePixelRatio: number;
|
||||
introProgress: number;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
|
|
@ -99,6 +100,7 @@ export class GameLoopResources {
|
|||
deltaTime,
|
||||
canvasSize,
|
||||
activeAgentCount,
|
||||
devicePixelRatio,
|
||||
introProgress,
|
||||
selectedColorIndex,
|
||||
channelColors,
|
||||
|
|
@ -123,6 +125,7 @@ export class GameLoopResources {
|
|||
});
|
||||
this.brushPipeline.setParameters({
|
||||
...settings,
|
||||
devicePixelRatio,
|
||||
selectedColorIndex,
|
||||
});
|
||||
this.diffusionPipeline.setParameters(settings);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ export default class GameLoop {
|
|||
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
||||
this.agentPopulation = new AgentPopulation(
|
||||
this.resources.agentGenerationPipeline,
|
||||
this.seedValue
|
||||
this.seedValue,
|
||||
() => this.devicePixelRatio
|
||||
);
|
||||
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||
this.pointerInput = new GardenPointerInput({
|
||||
|
|
@ -155,7 +156,8 @@ export default class GameLoop {
|
|||
|
||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||
const introProgress = this.introPrompt.progress;
|
||||
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
|
||||
const devicePixelRatio = this.devicePixelRatio;
|
||||
const eraserPixelSize = settings.eraserSize * devicePixelRatio;
|
||||
const isErasing = this.pointerInput.isEraseMode;
|
||||
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
||||
this.renderInputs.updateAccentColor(accentColor);
|
||||
|
|
@ -169,6 +171,7 @@ export default class GameLoop {
|
|||
deltaTime,
|
||||
canvasSize: this.canvasSize,
|
||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||
devicePixelRatio,
|
||||
introProgress,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
isErasing,
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ const makeSwipePipeline = () => ({
|
|||
clearSwipes: vi.fn(),
|
||||
});
|
||||
|
||||
const createPointerInput = async () => {
|
||||
const createPointerInput = async ({
|
||||
devicePixelRatio = 1,
|
||||
}: { devicePixelRatio?: number } = {}) => {
|
||||
const { GardenPointerInput } = await import('./pointer-input');
|
||||
const { settings: runtimeSettings } = await import('../settings');
|
||||
const canvas = new FakeCanvas();
|
||||
|
|
@ -117,7 +119,7 @@ const createPointerInput = async () => {
|
|||
eraserAgentPipeline,
|
||||
eraserPreview,
|
||||
eraserTexturePipeline,
|
||||
getDevicePixelRatio: () => 1,
|
||||
getDevicePixelRatio: () => devicePixelRatio,
|
||||
getMirrorSegmentCount: () => 1,
|
||||
onEraseGestureEnded,
|
||||
onStartDrawing,
|
||||
|
|
@ -277,6 +279,30 @@ describe('GardenPointerInput drawing startup', () => {
|
|||
expect(toPoint(stroke.to)).toEqual([40, 50]);
|
||||
});
|
||||
|
||||
it('keeps pointer geometry in backing pixels on high-DPR canvases', async () => {
|
||||
const { audio, brushPipeline, canvas } = await createPointerInput({
|
||||
devicePixelRatio: 2,
|
||||
});
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', {
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 100,
|
||||
});
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 40,
|
||||
clientY: 50,
|
||||
pointerId: 9,
|
||||
timeStamp: 150,
|
||||
});
|
||||
|
||||
const firstStroke = audio.stroke.mock.calls[0][0];
|
||||
expect(toPoint(firstStroke.from)).toEqual([20, 40]);
|
||||
expect(toPoint(firstStroke.to)).toEqual([80, 100]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[0][0])).toEqual([20, 40]);
|
||||
});
|
||||
|
||||
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
||||
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
|
||||
runtimeSettings.brushCurveResolution = 2;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { vec2 } from 'gl-matrix';
|
|||
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { appConfig } from '../config';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import {
|
||||
BrushPipeline,
|
||||
getSafeDevicePixelRatio,
|
||||
} from '../pipelines/brush/brush-pipeline';
|
||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
|
|
@ -201,7 +204,9 @@ export class GardenPointerInput {
|
|||
|
||||
private getCanvasPointerPosition(event: PointerEvent): vec2 {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const devicePixelRatio = this.options.getDevicePixelRatio();
|
||||
const devicePixelRatio = getSafeDevicePixelRatio(
|
||||
this.options.getDevicePixelRatio()
|
||||
);
|
||||
return vec2.fromValues(
|
||||
(event.clientX - rect.left) * devicePixelRatio,
|
||||
(event.clientY - rect.top) * devicePixelRatio
|
||||
|
|
@ -213,7 +218,8 @@ export class GardenPointerInput {
|
|||
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
||||
if (
|
||||
previousSample !== undefined &&
|
||||
vec2.squaredDistance(previousSample, position) <= getBrushSmoothingDistanceSquared()
|
||||
vec2.squaredDistance(previousSample, position) <=
|
||||
getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -247,12 +253,13 @@ export class GardenPointerInput {
|
|||
|
||||
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
|
||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||
const devicePixelRatio = getSafeDevicePixelRatio(this.options.getDevicePixelRatio());
|
||||
const brushRadius = Math.max(
|
||||
settings.brushCurveMinBrushRadius,
|
||||
settings.brushSize / 2
|
||||
settings.brushCurveMinBrushRadius * devicePixelRatio,
|
||||
(settings.brushSize * devicePixelRatio) / 2
|
||||
);
|
||||
const segmentSpacing = Math.max(
|
||||
settings.brushCurveMinSegmentSpacing,
|
||||
settings.brushCurveMinSegmentSpacing * devicePixelRatio,
|
||||
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
||||
);
|
||||
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||
|
|
@ -292,7 +299,7 @@ export class GardenPointerInput {
|
|||
if (
|
||||
this.lastSmoothedBrushPosition !== null &&
|
||||
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
|
||||
getBrushSmoothingDistanceSquared()
|
||||
getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
|
||||
) {
|
||||
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
|
||||
}
|
||||
|
|
@ -374,11 +381,11 @@ const getBrushCurveResolution = (): number => {
|
|||
return Math.max(1, Math.floor(resolution));
|
||||
};
|
||||
|
||||
const getBrushSmoothingDistanceSquared = (): number => {
|
||||
const getBrushSmoothingDistanceSquared = (devicePixelRatio?: number): number => {
|
||||
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||
? settings.brushSmoothingMinSampleDistance
|
||||
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||
return Math.max(0, distance) ** 2;
|
||||
return Math.max(0, distance * getSafeDevicePixelRatio(devicePixelRatio)) ** 2;
|
||||
};
|
||||
|
||||
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { activeVibe } from '../settings';
|
||||
import { hexToRgb } from '../vibes';
|
||||
import { hexToRgb, type VibeId } from '../vibes';
|
||||
import { RenderInputs } from './game-loop-types';
|
||||
|
||||
export class RenderInputCache {
|
||||
private cachedVibeId: string | null = null;
|
||||
private cachedVibeId: VibeId | null = null;
|
||||
private cachedRenderInputs?: RenderInputs;
|
||||
private previousAccentColor = '';
|
||||
|
||||
|
|
|
|||
64
src/index.ts
64
src/index.ts
|
|
@ -308,38 +308,6 @@ const main = async () => {
|
|||
elements.infoElement,
|
||||
elements.aside
|
||||
);
|
||||
configPane = new ConfigPane({
|
||||
settingsButton: elements.settingsButton,
|
||||
onConfigChange: () => {
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
},
|
||||
onOpenChange: () => undefined,
|
||||
onRuntimeChange: syncRuntimeUi,
|
||||
onRuntimeReset: () => {
|
||||
resetSettings();
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
},
|
||||
onRestart: () => game?.destroy(),
|
||||
onVibeChange: (vibeId) => {
|
||||
const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
|
||||
if (!vibe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activePreset = applyVibeSettings(vibe);
|
||||
trackVibeChange({
|
||||
vibeId: activePreset.id,
|
||||
vibeName: activePreset.name,
|
||||
source: 'settings',
|
||||
});
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
game?.playVibeChangeAudio(false);
|
||||
},
|
||||
});
|
||||
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
||||
|
||||
new MenuHider(
|
||||
elements.aside,
|
||||
|
|
@ -488,6 +456,38 @@ const main = async () => {
|
|||
});
|
||||
setLoadingStage('Connecting to GPU…', 0.1);
|
||||
const gpu = await initializeGpu();
|
||||
configPane = new ConfigPane({
|
||||
settingsButton: elements.settingsButton,
|
||||
onConfigChange: () => {
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
},
|
||||
onOpenChange: () => undefined,
|
||||
onRuntimeChange: syncRuntimeUi,
|
||||
onRuntimeReset: () => {
|
||||
resetSettings();
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
},
|
||||
onRestart: () => game?.destroy(),
|
||||
onVibeChange: (vibeId) => {
|
||||
const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
|
||||
if (!vibe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activePreset = applyVibeSettings(vibe);
|
||||
trackVibeChange({
|
||||
vibeId: activePreset.id,
|
||||
vibeName: activePreset.name,
|
||||
source: 'settings',
|
||||
});
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
game?.playVibeChangeAudio(false);
|
||||
},
|
||||
});
|
||||
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
||||
setLoadingStage('Loading fonts…', 0.3);
|
||||
await fontsReady;
|
||||
setLoadingStage('Loading piano samples…', 0.45);
|
||||
|
|
|
|||
137
src/page/collapsible-panel-animator.test.ts
Normal file
137
src/page/collapsible-panel-animator.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CollapsiblePanelAnimator } from './collapsible-panel-animator';
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
|
||||
class FakeClassList {
|
||||
private readonly classes = new Set<string>();
|
||||
|
||||
public add(className: string): void {
|
||||
this.classes.add(className);
|
||||
}
|
||||
|
||||
public contains(className: string): boolean {
|
||||
return this.classes.has(className);
|
||||
}
|
||||
|
||||
public remove(className: string): void {
|
||||
this.classes.delete(className);
|
||||
}
|
||||
|
||||
public toggle(className: string, force?: boolean): boolean {
|
||||
const shouldAdd = force ?? !this.classes.has(className);
|
||||
if (shouldAdd) {
|
||||
this.add(className);
|
||||
} else {
|
||||
this.remove(className);
|
||||
}
|
||||
return shouldAdd;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
public readonly classList = new FakeClassList();
|
||||
public inert = false;
|
||||
|
||||
private readonly attributes = new Map<string, string>();
|
||||
private readonly children = new Set<FakeElement>();
|
||||
private readonly listeners = new Map<string, Array<Listener>>();
|
||||
|
||||
public addChild(child: FakeElement): void {
|
||||
this.children.add(child);
|
||||
}
|
||||
|
||||
public addEventListener(type: string, listener: Listener): void {
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||
}
|
||||
|
||||
public contains(target: unknown): boolean {
|
||||
return target === this || this.children.has(target as FakeElement);
|
||||
}
|
||||
|
||||
public dispatch(type: string, event: Record<string, unknown> = {}): void {
|
||||
this.listeners.get(type)?.forEach((listener) => listener({ target: this, ...event }));
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
fakeDocument.activeElement = this;
|
||||
}
|
||||
|
||||
public getAttribute(name: string): string | null {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
public setAttribute(name: string, value: string): void {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
const windowListeners = new Map<string, Array<Listener>>();
|
||||
const fakeDocument: { activeElement: FakeElement | null } = {
|
||||
activeElement: null,
|
||||
};
|
||||
|
||||
const dispatchWindowEvent = (type: string, event: Record<string, unknown> = {}) => {
|
||||
windowListeners.get(type)?.forEach((listener) => listener(event));
|
||||
};
|
||||
|
||||
describe('CollapsiblePanelAnimator', () => {
|
||||
beforeEach(() => {
|
||||
windowListeners.clear();
|
||||
fakeDocument.activeElement = null;
|
||||
vi.stubGlobal('HTMLElement', FakeElement);
|
||||
vi.stubGlobal('document', fakeDocument);
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: (type: string, listener: Listener) => {
|
||||
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('syncs About panel accessibility when toggled and closed with Escape', () => {
|
||||
const button = new FakeElement();
|
||||
const panel = new FakeElement();
|
||||
const dock = new FakeElement();
|
||||
dock.addChild(button);
|
||||
dock.addChild(panel);
|
||||
|
||||
new CollapsiblePanelAnimator(
|
||||
button as unknown as HTMLButtonElement,
|
||||
panel as unknown as HTMLElement,
|
||||
dock as unknown as HTMLElement
|
||||
);
|
||||
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(panel.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(panel.inert).toBe(true);
|
||||
expect(panel.classList.contains('hidden')).toBe(true);
|
||||
|
||||
fakeDocument.activeElement = button;
|
||||
button.dispatch('click');
|
||||
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(button.classList.contains('active')).toBe(true);
|
||||
expect(panel.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(panel.inert).toBe(false);
|
||||
expect(panel.classList.contains('hidden')).toBe(false);
|
||||
expect(fakeDocument.activeElement).toBe(panel);
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
dispatchWindowEvent('keydown', { key: 'Escape', preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(panel.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(panel.inert).toBe(true);
|
||||
expect(fakeDocument.activeElement).toBe(button);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type NumberControlConfig,
|
||||
} from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes';
|
||||
|
||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
||||
|
|
@ -43,7 +43,7 @@ interface ConfigPaneOptions {
|
|||
onRestart: () => void;
|
||||
onRuntimeChange: () => void;
|
||||
onRuntimeReset: () => void;
|
||||
onVibeChange: (vibeId: string) => void;
|
||||
onVibeChange: (vibeId: VibeId) => void;
|
||||
settingsButton: HTMLButtonElement;
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ export class ConfigPane {
|
|||
colorIndex: number;
|
||||
element: HTMLElement;
|
||||
}> = [];
|
||||
private readonly state = {
|
||||
private readonly state: { activeVibeId: VibeId } = {
|
||||
activeVibeId: activeVibe.id,
|
||||
};
|
||||
|
||||
|
|
@ -172,9 +172,13 @@ export class ConfigPane {
|
|||
label: 'active vibe',
|
||||
options: Object.fromEntries(
|
||||
VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
|
||||
) as Record<string, string>,
|
||||
) as Record<string, VibeId>,
|
||||
})
|
||||
.on('change', ({ value }) => {
|
||||
if (!isVibeId(value)) {
|
||||
this.refresh();
|
||||
return;
|
||||
}
|
||||
this.options.onVibeChange(value);
|
||||
this.refresh();
|
||||
});
|
||||
|
|
|
|||
138
src/page/menu-hider.test.ts
Normal file
138
src/page/menu-hider.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { MenuHider } from './menu-hider';
|
||||
|
||||
type Listener<T = Record<string, unknown>> = (event: T) => void;
|
||||
|
||||
class FakeClassList {
|
||||
private readonly classes = new Set<string>();
|
||||
|
||||
public add(className: string): void {
|
||||
this.classes.add(className);
|
||||
}
|
||||
|
||||
public contains(className: string): boolean {
|
||||
return this.classes.has(className);
|
||||
}
|
||||
|
||||
public remove(className: string): void {
|
||||
this.classes.delete(className);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDockElement {
|
||||
public readonly classList = new FakeClassList();
|
||||
public inert = false;
|
||||
|
||||
private readonly attributes = new Map<string, string>();
|
||||
private readonly listeners = new Map<string, Array<Listener>>();
|
||||
|
||||
public addEventListener(type: string, listener: Listener): void {
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||
}
|
||||
|
||||
public contains(target: unknown): boolean {
|
||||
return target === this;
|
||||
}
|
||||
|
||||
public dispatch(type: string, event: Record<string, unknown> = {}): void {
|
||||
this.listeners.get(type)?.forEach((listener) => listener(event));
|
||||
}
|
||||
|
||||
public getAttribute(name: string): string | null {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
public getBoundingClientRect(): DOMRect {
|
||||
return {
|
||||
bottom: 720,
|
||||
height: 120,
|
||||
left: 0,
|
||||
right: 1280,
|
||||
toJSON: () => ({}),
|
||||
top: 600,
|
||||
width: 1280,
|
||||
x: 0,
|
||||
y: 600,
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
public setAttribute(name: string, value: string): void {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
const windowListeners = new Map<string, Array<Listener>>();
|
||||
let isDesktop = true;
|
||||
|
||||
const dispatchWindowEvent = <T extends Record<string, unknown>>(
|
||||
type: string,
|
||||
event: T
|
||||
) => {
|
||||
windowListeners.get(type)?.forEach((listener) => listener(event));
|
||||
};
|
||||
|
||||
describe('MenuHider', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
windowListeners.clear();
|
||||
isDesktop = true;
|
||||
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: null,
|
||||
addEventListener: vi.fn(),
|
||||
documentElement: {
|
||||
clientHeight: 720,
|
||||
},
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: (type: string, listener: Listener) => {
|
||||
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]);
|
||||
},
|
||||
clearTimeout,
|
||||
innerHeight: 720,
|
||||
matchMedia: () => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: isDesktop,
|
||||
}),
|
||||
setTimeout,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('hides the dock after the desktop fullscreen pointer leaves it', () => {
|
||||
const dock = new FakeDockElement();
|
||||
|
||||
new MenuHider(dock as unknown as HTMLElement, () => true);
|
||||
dock.dispatch('pointerleave');
|
||||
vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs);
|
||||
|
||||
expect(dock.classList.contains('menu-hidden')).toBe(true);
|
||||
expect(dock.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(dock.inert).toBe(true);
|
||||
|
||||
dispatchWindowEvent('pointermove', { clientX: 640, clientY: 710 });
|
||||
|
||||
expect(dock.classList.contains('menu-hidden')).toBe(false);
|
||||
expect(dock.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(dock.inert).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the dock visible outside the desktop auto-hide breakpoint', () => {
|
||||
isDesktop = false;
|
||||
const dock = new FakeDockElement();
|
||||
|
||||
new MenuHider(dock as unknown as HTMLElement, () => true);
|
||||
dock.dispatch('pointerleave');
|
||||
vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs);
|
||||
|
||||
expect(dock.classList.contains('menu-hidden')).toBe(false);
|
||||
expect(dock.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(dock.inert).toBe(false);
|
||||
});
|
||||
});
|
||||
45
src/pipelines/brush/brush-pipeline.test.ts
Normal file
45
src/pipelines/brush/brush-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSafeDevicePixelRatio, setBrushUniformValues } from './brush-pipeline';
|
||||
|
||||
const brushSettings = {
|
||||
brushAlpha: 0.75,
|
||||
brushCoarseNoiseScale: 100,
|
||||
brushDiscardThreshold: 0.02,
|
||||
brushFeatherRatio: 0.25,
|
||||
brushGrainMaxStrength: 1,
|
||||
brushGrainMinStrength: 0.4,
|
||||
brushGrainNoiseOffsetX: 0.1,
|
||||
brushGrainNoiseOffsetY: 0.2,
|
||||
brushGrainNoiseScale: 25,
|
||||
brushMinimumFeather: 2,
|
||||
brushSize: 10,
|
||||
brushSizeVariation: 0.5,
|
||||
selectedColorIndex: 1,
|
||||
};
|
||||
|
||||
describe('brush pipeline parameters', () => {
|
||||
it('scales pixel-space brush uniforms by device pixel ratio', () => {
|
||||
const uniformValues = new Float32Array(16);
|
||||
|
||||
setBrushUniformValues(uniformValues, {
|
||||
...brushSettings,
|
||||
devicePixelRatio: 2,
|
||||
});
|
||||
|
||||
expect(uniformValues[0]).toBe(10);
|
||||
expect(uniformValues[1]).toBe(5);
|
||||
expect(uniformValues[3]).toBe(4);
|
||||
expect(uniformValues[5]).toBe(1);
|
||||
expect(uniformValues[8]).toBe(200);
|
||||
expect(uniformValues[9]).toBe(50);
|
||||
expect(uniformValues[15]).toBe(19);
|
||||
});
|
||||
|
||||
it('falls back to a 1x pixel ratio for invalid values', () => {
|
||||
expect(getSafeDevicePixelRatio(0)).toBe(1);
|
||||
expect(getSafeDevicePixelRatio(Number.NaN)).toBe(1);
|
||||
expect(getSafeDevicePixelRatio(undefined)).toBe(1);
|
||||
expect(getSafeDevicePixelRatio(1.5)).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,66 @@ interface LineSegment {
|
|||
to: vec2;
|
||||
}
|
||||
|
||||
interface BrushParameterSettings extends BrushSettings {
|
||||
devicePixelRatio?: number;
|
||||
selectedColorIndex: number;
|
||||
}
|
||||
|
||||
export const getSafeDevicePixelRatio = (devicePixelRatio: number | undefined): number =>
|
||||
typeof devicePixelRatio === 'number' &&
|
||||
Number.isFinite(devicePixelRatio) &&
|
||||
devicePixelRatio > 0
|
||||
? devicePixelRatio
|
||||
: 1;
|
||||
|
||||
export const setBrushUniformValues = (
|
||||
target: Float32Array,
|
||||
{
|
||||
brushSize,
|
||||
brushSizeVariation,
|
||||
brushAlpha,
|
||||
brushFeatherRatio,
|
||||
brushMinimumFeather,
|
||||
brushDiscardThreshold,
|
||||
brushCoarseNoiseScale,
|
||||
brushGrainNoiseScale,
|
||||
brushGrainNoiseOffsetX,
|
||||
brushGrainNoiseOffsetY,
|
||||
brushGrainMinStrength,
|
||||
brushGrainMaxStrength,
|
||||
selectedColorIndex,
|
||||
devicePixelRatio,
|
||||
}: BrushParameterSettings
|
||||
): void => {
|
||||
const pixelRatio = getSafeDevicePixelRatio(devicePixelRatio);
|
||||
const brushRadius = (brushSize * pixelRatio) / 2;
|
||||
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
||||
const brushMinimumFeatherPixels = brushMinimumFeather * pixelRatio;
|
||||
const brushFeather = Math.max(
|
||||
brushMinimumFeatherPixels,
|
||||
brushRadius * brushFeatherRatio
|
||||
);
|
||||
const brushGeometryRadius =
|
||||
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
|
||||
|
||||
target[0] = brushRadius;
|
||||
target[1] = brushRadiusVariation;
|
||||
target[2] = brushFeatherRatio;
|
||||
target[3] = brushMinimumFeatherPixels;
|
||||
target[4] = selectedColorIndex === 0 ? 1 : 0;
|
||||
target[5] = selectedColorIndex === 1 ? 1 : 0;
|
||||
target[6] = selectedColorIndex === 2 ? 1 : 0;
|
||||
target[7] = brushAlpha;
|
||||
target[8] = brushCoarseNoiseScale * pixelRatio;
|
||||
target[9] = brushGrainNoiseScale * pixelRatio;
|
||||
target[10] = brushGrainNoiseOffsetX;
|
||||
target[11] = brushGrainNoiseOffsetY;
|
||||
target[12] = brushDiscardThreshold;
|
||||
target[13] = brushGrainMinStrength;
|
||||
target[14] = brushGrainMaxStrength;
|
||||
target[15] = brushGeometryRadius;
|
||||
};
|
||||
|
||||
export class BrushPipeline {
|
||||
private static readonly UNIFORM_COUNT = 16;
|
||||
private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
|
||||
|
|
@ -87,43 +147,8 @@ export class BrushPipeline {
|
|||
this.actualSegments.length = 0;
|
||||
}
|
||||
|
||||
public setParameters({
|
||||
brushSize,
|
||||
brushSizeVariation,
|
||||
brushAlpha,
|
||||
brushFeatherRatio,
|
||||
brushMinimumFeather,
|
||||
brushDiscardThreshold,
|
||||
brushCoarseNoiseScale,
|
||||
brushGrainNoiseScale,
|
||||
brushGrainNoiseOffsetX,
|
||||
brushGrainNoiseOffsetY,
|
||||
brushGrainMinStrength,
|
||||
brushGrainMaxStrength,
|
||||
selectedColorIndex,
|
||||
}: BrushSettings & { selectedColorIndex: number }) {
|
||||
const brushRadius = brushSize / 2;
|
||||
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
||||
const brushFeather = Math.max(brushMinimumFeather, brushRadius * brushFeatherRatio);
|
||||
const brushGeometryRadius =
|
||||
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
|
||||
|
||||
this.uniformValues[0] = brushRadius;
|
||||
this.uniformValues[1] = brushRadiusVariation;
|
||||
this.uniformValues[2] = brushFeatherRatio;
|
||||
this.uniformValues[3] = brushMinimumFeather;
|
||||
this.uniformValues[4] = selectedColorIndex === 0 ? 1 : 0;
|
||||
this.uniformValues[5] = selectedColorIndex === 1 ? 1 : 0;
|
||||
this.uniformValues[6] = selectedColorIndex === 2 ? 1 : 0;
|
||||
this.uniformValues[7] = brushAlpha;
|
||||
this.uniformValues[8] = brushCoarseNoiseScale;
|
||||
this.uniformValues[9] = brushGrainNoiseScale;
|
||||
this.uniformValues[10] = brushGrainNoiseOffsetX;
|
||||
this.uniformValues[11] = brushGrainNoiseOffsetY;
|
||||
this.uniformValues[12] = brushDiscardThreshold;
|
||||
this.uniformValues[13] = brushGrainMinStrength;
|
||||
this.uniformValues[14] = brushGrainMaxStrength;
|
||||
this.uniformValues[15] = brushGeometryRadius;
|
||||
public setParameters(parameters: BrushParameterSettings) {
|
||||
setBrushUniformValues(this.uniformValues, parameters);
|
||||
writeFloat32BufferIfChanged(
|
||||
this.device,
|
||||
this.uniforms,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { gardenAudioConfig } from './audio/garden-audio-config';
|
||||
import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
|
||||
import { getInitialVibe, hexToRgb, VIBE_PRESETS, VibeId } from './vibes';
|
||||
|
||||
const originalLocalStorage = globalThis.localStorage;
|
||||
|
||||
|
|
@ -29,9 +29,9 @@ describe('vibe selection', () => {
|
|||
});
|
||||
|
||||
it('uses a valid stored vibe id', () => {
|
||||
setBrowserVibeState({ storedVibeId: 'sunlit-moss' });
|
||||
setBrowserVibeState({ storedVibeId: VibeId.SunlitMoss });
|
||||
|
||||
expect(getInitialVibe().id).toBe('sunlit-moss');
|
||||
expect(getInitialVibe().id).toBe(VibeId.SunlitMoss);
|
||||
});
|
||||
|
||||
it('falls back to the default preset for an unknown stored vibe id', () => {
|
||||
|
|
|
|||
19
src/vibes.ts
19
src/vibes.ts
|
|
@ -1,9 +1,11 @@
|
|||
import { appConfig, type VibePreset } from './config';
|
||||
import { appConfig, type VibeId, type VibePreset } from './config';
|
||||
import { readBrowserStorage } from './utils/browser-storage';
|
||||
|
||||
export { VibeId } from './config';
|
||||
export type { VibePreset } from './config';
|
||||
|
||||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||
|
||||
const HEX_COLOR_PATTERN =
|
||||
/^#?(?<red>[0-9a-f]{2})(?<green>[0-9a-f]{2})(?<blue>[0-9a-f]{2})$/i;
|
||||
|
|
@ -18,11 +20,14 @@ export const hexToRgb = (hex: string): [number, number, number] => {
|
|||
return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
|
||||
};
|
||||
|
||||
export const isVibeId = (value: unknown): value is VibeId =>
|
||||
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
||||
|
||||
export const getInitialVibe = (): VibePreset => {
|
||||
const id = readBrowserStorage(appConfig.storage.vibeKey);
|
||||
return (
|
||||
VIBE_PRESETS.find((vibe) => vibe.id === id) ??
|
||||
VIBE_PRESETS.find((vibe) => vibe.id === appConfig.vibes.defaultVibeId) ??
|
||||
VIBE_PRESETS[0]
|
||||
);
|
||||
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
||||
const initialVibeId = isVibeId(storedVibeId)
|
||||
? storedVibeId
|
||||
: appConfig.vibes.defaultVibeId;
|
||||
|
||||
return VIBE_PRESETS.find((vibe) => vibe.id === initialVibeId) ?? VIBE_PRESETS[0];
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue