diff --git a/assets/icons/close.svg b/assets/icons/close.svg
new file mode 100644
index 0000000..ae3fbc7
--- /dev/null
+++ b/assets/icons/close.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/index.html b/index.html
index fadc28f..1414388 100644
--- a/index.html
+++ b/index.html
@@ -11,7 +11,7 @@
@@ -22,7 +22,7 @@
@@ -35,7 +35,7 @@
@@ -46,7 +46,7 @@
"@type": "WebApplication",
"name": "Fleeting Garden",
"url": "https://schmelczer.dev/fleeting/",
- "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
+ "description": "Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden.",
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any",
@@ -118,35 +118,47 @@
id="info-panel"
class="hidden info-page"
role="region"
- aria-label="About panel"
+ aria-labelledby="info-panel-title"
aria-hidden="true"
tabindex="-1"
inert
>
-
- Fleeting Garden
-
+
+
+
+
A garden is what we tend; the wild is what we get the moment we look away.
Both happen here at once. Your strokes plant colour, small agents follow them,
branch off, and slowly rewrite the patch you laid down into something you
didn't quite plan.
-
- Three swatches plant the line. The eraser carves a clearing. The mirror folds
- one gesture into many, like footpaths around a hidden well.
-
-
- Switch vibes to change the season; your shapes stay, the light moves. Add or
- quiet the piano. Restart when you want a fresh field. Take a snapshot if you
- want to keep one particular instant of weather.
-
-
+
+
+ Three swatches plant the line; the eraser carves a clearing.
+ The mirror folds one gesture into many.
+ The arrows change the season.
+
+
+
Built with WebGPU, running locally in your browser. More of my work at
schmelczer.dev .
-
+
diff --git a/public/og-image.jpg b/public/og-image.jpg
index 0ced8fc..00d22a1 100644
Binary files a/public/og-image.jpg and b/public/og-image.jpg differ
diff --git a/src/analytics.ts b/src/analytics.ts
index b19958a..427f871 100644
--- a/src/analytics.ts
+++ b/src/analytics.ts
@@ -4,9 +4,13 @@ import {
type PlausibleEventOptions,
} from '@plausible-analytics/tracker';
-import { appConfig } from './config';
import type { VibeId } from './vibes';
+const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
+const ANALYTICS_DOMAIN = 'schmelczer.dev/fleeting';
+const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
+const ANALYTICS_LOGGING = import.meta.env.DEV;
+
let isInitialized = false;
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
@@ -24,10 +28,10 @@ export const initAnalytics = () => {
try {
plausibleInit({
- domain: appConfig.analytics.domain,
- endpoint: appConfig.analytics.endpoint,
- autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
- logging: appConfig.analytics.logging,
+ domain: ANALYTICS_DOMAIN,
+ endpoint: ANALYTICS_ENDPOINT,
+ autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
+ logging: ANALYTICS_LOGGING,
});
isInitialized = true;
} catch (error) {
diff --git a/src/config.ts b/src/config.ts
index e0e6b19..37e7f78 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,199 +1,9 @@
-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';
-
+export { defaultSettings } from './config/default-settings';
export {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './config/normalize-runtime-settings';
+export { runtimeControls } from './config/runtime-controls';
+export { defaultVibeId, vibePresets } from './config/vibe-presets';
-export type {
- GardenAppConfig,
- GardenRuntimeSettings,
- NumberControlConfig,
-} from './config/types';
-
-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,
- },
- exportSnapshot: {
- bytesPerPixel: 4,
- filenameExtension: 'png',
- filenamePrefix: 'fleeting-garden',
- filenameSuffix: '-snapshot',
- mimeType: 'image/png',
- rowAlignmentBytes: 256,
- },
- menuHider: {
- bottomRevealDistancePx: 96,
- desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
- hideDelayMs: 3000,
- },
- pipelines: {
- common: {
- noiseChannelSeeds: [0, 1, 2, 3],
- noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
- noiseDrawInstanceCount: 1,
- noiseDrawVertexCount: 3,
- noiseHashMultiplier: 43758.5453123,
- noiseHashX: 12.9898,
- noiseHashY: 78.233,
- noiseTextureFormat: 'r8unorm',
- noiseTextureSize: 2048,
- },
- brush: {
- maxLineCount: 240,
- },
- diffusion: {
- minDiffusionRate: 0.000001,
- },
- eraser: {
- maxTextureLineCount: 384,
- },
- },
- defaultSettings,
- runtimeSettings: {
- controls: runtimeControls,
- },
- simulation: {
- brushEffectFramesPerSecond: 60,
- clearColor: { r: 0, g: 0, b: 0, a: 0 },
- initialAgentCount: 180_000,
- // How long the source map continues to be diffused after a brush stroke ends.
- // 600 frames at ~60 FPS ≈ 10 seconds.
- sourceActiveFramesAfterWrite: 600,
- intro: {
- angleJitterRadians: Math.PI * 0.08,
- angleEaseEnd: 1,
- angleEaseStart: 0.6,
- circleMaxSideRatio: 0.46,
- circleMinSideRatio: 0.32,
- drawHintDelayMs: 3000,
- durationSeconds: 4,
- entryJitterSideRatio: 0.035,
- fontScaleDown: 0.94,
- fontFamily: '"Open Sans", sans-serif',
- initialFontHeightRatio: 0.28,
- initialFontWidthRatio: 0.19,
- 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',
- pathProgressEpsilon: 0.001,
- radialJitterRatio: 0.35,
- radialStartEpsilon: 0.001,
- resizeMinimumRemainingSeconds: 1.4,
- resizeSettleMs: 120,
- targetDelayDistanceMultiplier: 0.12,
- targetDelayMax: 0.22,
- targetDelayRandomMultiplier: 0.06,
- targetJitterSideRatio: 0.0035,
- title: 'Fleeting',
- titleColorCutLetters: [2, 5],
- titleRadiusMultiplier: 1.55,
- titleStrokeWidthMinPx: 6,
- titleStrokeWidthRatio: 0.11,
- verticalAnchor: 0.47,
- },
- introMoveSpeed: 280,
- stroke: {
- densityMultiplier: 110,
- maxAgentCount: 2_400,
- },
- },
- storage: {
- audioMutedKey: 'fleeting-garden:audio-muted',
- audioVolumeKey: 'fleeting-garden:audio-volume',
- vibeKey: 'fleeting-garden:vibe',
- },
- toolbar: {
- eraser: {
- controlScaleMax: 1.34,
- controlScaleMin: 0.74,
- default: 96,
- max: 480,
- min: 24,
- step: 1,
- },
- mirror: {
- default: 8,
- fallbackSegmentName: 'slices',
- max: 12,
- min: 1,
- names: {
- 2: 'halves',
- 3: 'thirds',
- 4: 'quarters',
- 5: 'fifths',
- 6: 'sixths',
- 7: 'sevenths',
- 8: 'eighths',
- 9: 'ninths',
- 10: 'tenths',
- 11: 'elevenths',
- 12: 'twelfths',
- },
- offLabel: 'Mirror off',
- step: 1,
- },
- contrast: {
- backgroundOpacityMax: 0.82,
- brightLuminanceThreshold: 0.32,
- brightWeight: 0.65,
- bytesPerSample: 4,
- contrastOffset: 0.05,
- linearChannelBreakpoint: 0.03928,
- linearChannelDivisor: 12.92,
- linearChannelGamma: 2.4,
- linearChannelOffset: 0.055,
- linearChannelScale: 1.055,
- lowContrastThreshold: 3,
- lowContrastWeight: 1.8,
- luminanceBase: 0.11,
- luminanceBlueWeight: 0.0722,
- luminanceGreenWeight: 0.7152,
- luminanceRange: 0.28,
- luminanceRedWeight: 0.2126,
- sampleColumns: 13,
- sampleIntervalMs: 300,
- sampleRows: 7,
- whiteContrastNumerator: 1.05,
- },
- volume: {
- default: DEFAULT_AUDIO_VOLUME,
- max: 1,
- min: 0,
- step: 0.01,
- },
- },
- tuningPane: {
- showFpsOverlay: import.meta.env.DEV,
- startHidden: true,
- title: 'Garden Settings',
- },
- vibes: {
- defaultVibeId,
- presets: vibePresets,
- },
-} satisfies GardenAppConfig;
+export type { GardenRuntimeSettings, NumberControlConfig } from './config/types';
diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts
index 37ce510..ba59a1e 100644
--- a/src/config/default-settings.ts
+++ b/src/config/default-settings.ts
@@ -1,5 +1,5 @@
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
-import type { GardenAppConfig } from './types';
+import type { GardenDefaultSettings } 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
@@ -21,7 +21,7 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
);
};
-export const defaultSettings: GardenAppConfig['defaultSettings'] = {
+export const defaultSettings: GardenDefaultSettings = {
selectedColorIndex: 0,
introNearDistanceMin: 28,
diff --git a/src/config/normalize-runtime-settings.ts b/src/config/normalize-runtime-settings.ts
index ec4dcf5..60cbf38 100644
--- a/src/config/normalize-runtime-settings.ts
+++ b/src/config/normalize-runtime-settings.ts
@@ -1,11 +1,9 @@
import type {
- GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
+ RuntimeSettingControlConfig,
} from './types';
-type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
-
export const normalizeNumberControlValue = (
value: number,
config: NumberControlConfig
@@ -28,7 +26,7 @@ export const normalizeNumberControlValue = (
export const normalizeRuntimeSettings = (
settings: GardenRuntimeSettings,
- controls: RuntimeSettingControls
+ controls: RuntimeSettingControlConfig
): GardenRuntimeSettings => {
const normalized = { ...settings };
diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts
index e6b8a70..463735a 100644
--- a/src/config/runtime-controls.ts
+++ b/src/config/runtime-controls.ts
@@ -1,6 +1,6 @@
import { colorInteractionControl } from './color-interactions';
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
-import type { GardenAppConfig } from './types';
+import type { RuntimeSettingControlConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
const formatRadiansAsDegrees = (value: number): string =>
@@ -16,7 +16,7 @@ const formatCompactNumber = (value: number): string => {
return `${value}`;
};
-export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
+export const runtimeControls: RuntimeSettingControlConfig = {
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
diff --git a/src/config/types.ts b/src/config/types.ts
index 6f9aa29..cd94c9a 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -1,7 +1,4 @@
-import type {
- GardenAudioConfig,
- GardenAudioVibeSettings,
-} from '../audio/garden-audio-config';
+import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
@@ -49,7 +46,7 @@ export type GardenRuntimeSettings = {
DiffusionSettings &
RenderSettings;
-type RuntimeSettingControlConfig = Partial<
+export type RuntimeSettingControlConfig = Partial<
Record
>;
@@ -79,7 +76,7 @@ export type GardenVibeSettings = Pick<
| 'turnWhenLost'
>;
-type GardenDefaultSettings = Omit<
+export type GardenDefaultSettings = Omit<
GardenRuntimeSettings,
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
>;
@@ -101,169 +98,3 @@ export interface VibePreset {
settings: GardenVibeSettings;
audio: GardenAudioVibeSettings;
}
-
-export interface GardenAppConfig {
- audio: GardenAudioConfig;
- analytics: {
- autoCapturePageviews: boolean;
- domain: string;
- endpoint: string;
- logging: boolean;
- };
- deltaTime: {
- maxDeltaTimeSeconds: number;
- minDeltaTimeSeconds: number;
- };
- exportSnapshot: {
- bytesPerPixel: number;
- filenameExtension: string;
- filenamePrefix: string;
- filenameSuffix: string;
- mimeType: string;
- rowAlignmentBytes: number;
- };
- menuHider: {
- bottomRevealDistancePx: number;
- desktopMediaQuery: string;
- hideDelayMs: number;
- };
- pipelines: {
- common: {
- noiseChannelSeeds: [number, number, number, number];
- noiseClearValue: GPUColor;
- noiseDrawInstanceCount: number;
- noiseDrawVertexCount: number;
- noiseHashMultiplier: number;
- noiseHashX: number;
- noiseHashY: number;
- noiseTextureFormat: GPUTextureFormat;
- noiseTextureSize: number;
- };
- brush: {
- maxLineCount: number;
- };
- diffusion: {
- minDiffusionRate: number;
- };
- eraser: {
- maxTextureLineCount: number;
- };
- };
- defaultSettings: GardenDefaultSettings;
- runtimeSettings: {
- controls: RuntimeSettingControlConfig;
- };
- simulation: {
- brushEffectFramesPerSecond: number;
- clearColor: GPUColor;
- initialAgentCount: number;
- sourceActiveFramesAfterWrite: number;
- intro: {
- angleJitterRadians: number;
- angleEaseEnd: number;
- angleEaseStart: number;
- circleMaxSideRatio: number;
- circleMinSideRatio: number;
- drawHintDelayMs: number;
- durationSeconds: number;
- entryJitterSideRatio: number;
- fontScaleDown: number;
- fontFamily: string;
- initialFontHeightRatio: number;
- initialFontWidthRatio: number;
- letterSpacingEm: number;
- maskAlphaThreshold: number;
- maskGradientThreshold: number;
- maskMaxPixels: number;
- maskSampleDensity: number;
- maxHeightRatio: number;
- maxWidthRatio: number;
- minEntryJitterPx: number;
- minFontSizePx: number;
- minTargetJitterPx: number;
- pathEasing: 'easeOutQuad' | 'linear';
- pathProgressEpsilon: number;
- radialJitterRatio: number;
- radialStartEpsilon: number;
- resizeMinimumRemainingSeconds: number;
- resizeSettleMs: number;
- targetDelayDistanceMultiplier: number;
- targetDelayMax: number;
- targetDelayRandomMultiplier: number;
- targetJitterSideRatio: number;
- title: string;
- titleColorCutLetters: [number, number];
- titleRadiusMultiplier: number;
- titleStrokeWidthMinPx: number;
- titleStrokeWidthRatio: number;
- verticalAnchor: number;
- };
- introMoveSpeed: number;
- stroke: {
- densityMultiplier: number;
- maxAgentCount: number;
- };
- };
- storage: {
- audioMutedKey: string;
- audioVolumeKey: string;
- vibeKey: string;
- };
- toolbar: {
- eraser: {
- controlScaleMax: number;
- controlScaleMin: number;
- default: number;
- max: number;
- min: number;
- step: number;
- };
- mirror: {
- default: number;
- fallbackSegmentName: string;
- max: number;
- min: number;
- names: Record;
- offLabel: string;
- step: number;
- };
- contrast: {
- backgroundOpacityMax: number;
- brightLuminanceThreshold: number;
- brightWeight: number;
- bytesPerSample: number;
- contrastOffset: number;
- linearChannelBreakpoint: number;
- linearChannelDivisor: number;
- linearChannelGamma: number;
- linearChannelOffset: number;
- linearChannelScale: number;
- lowContrastThreshold: number;
- lowContrastWeight: number;
- luminanceBase: number;
- luminanceBlueWeight: number;
- luminanceGreenWeight: number;
- luminanceRange: number;
- luminanceRedWeight: number;
- sampleColumns: number;
- sampleIntervalMs: number;
- sampleRows: number;
- whiteContrastNumerator: number;
- };
- volume: {
- default: number;
- max: number;
- min: number;
- step: number;
- };
- };
- tuningPane: {
- showFpsOverlay: boolean;
- startHidden: boolean;
- title: string;
- };
- vibes: {
- defaultVibeId: VibeId;
- presets: Array;
- };
-}
diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts
index 2bc0e26..7232781 100644
--- a/src/game-loop/agent-population.test.ts
+++ b/src/game-loop/agent-population.test.ts
@@ -1,11 +1,14 @@
import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { appConfig } from '../config';
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
import { settings } from '../settings';
-import { AgentPopulation } from './agent-population';
+import {
+ AgentPopulation,
+ STROKE_AGENT_BATCH_CAPACITY,
+ STROKE_DENSITY_MULTIPLIER,
+} from './agent-population';
import { type FramePerformance } from './frame-performance';
const originalSettings = {
@@ -70,7 +73,7 @@ const createPopulation = (): {
};
const setSpawnRate = (agentsPerPixel: number): void => {
- settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
+ settings.spawnPerPixel = agentsPerPixel / STROKE_DENSITY_MULTIPLIER;
};
describe('AgentPopulation stroke spawning', () => {
@@ -124,7 +127,7 @@ describe('AgentPopulation stroke spawning', () => {
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
const { pipeline, population } = createPopulation();
- const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
+ const batchCapacity = STROKE_AGENT_BATCH_CAPACITY;
const expectedAgentCount = batchCapacity + 10;
population.spawnStrokeAgents(
diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts
index 1d0390f..019a74f 100644
--- a/src/game-loop/agent-population.ts
+++ b/src/game-loop/agent-population.ts
@@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
@@ -9,6 +8,11 @@ import { settings } from '../settings';
import type { FramePerformance } from './frame-performance';
import { createIntroTitleAgents } from './intro-title-agents';
+export const STROKE_AGENT_BATCH_CAPACITY = 2_400;
+export const STROKE_DENSITY_MULTIPLIER = 110;
+
+const INITIAL_INTRO_AGENT_COUNT = 180_000;
+
export class AgentPopulation {
private activeCount = 0;
// Current performance-aware limit; new agents above it replace old agents.
@@ -22,7 +26,7 @@ export class AgentPopulation {
private readonly queuedAgentBatches: Array = [];
private pendingStrokeAgentCount = 0;
private readonly strokeAgentData = new Float32Array(
- appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
+ STROKE_AGENT_BATCH_CAPACITY * AGENT_FLOAT_COUNT
);
public constructor(
@@ -46,10 +50,7 @@ export class AgentPopulation {
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
- const introAgentCount = Math.min(
- this.adaptiveCap,
- appConfig.simulation.initialAgentCount
- );
+ const introAgentCount = Math.min(this.adaptiveCap, INITIAL_INTRO_AGENT_COUNT);
const data = createIntroTitleAgents({
count: introAgentCount,
width: canvasSize[0],
@@ -332,8 +333,5 @@ const getStrokeSpawnRate = (): number => {
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
? settings.spawnPerPixel
: 0;
- const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
- ? appConfig.simulation.stroke.densityMultiplier
- : 0;
- return Math.max(0, spawnPerPixel * densityMultiplier);
+ return Math.max(0, spawnPerPixel * STROKE_DENSITY_MULTIPLIER);
};
diff --git a/src/game-loop/brush-stroke-smoother.ts b/src/game-loop/brush-stroke-smoother.ts
index 3d45283..918b95c 100644
--- a/src/game-loop/brush-stroke-smoother.ts
+++ b/src/game-loop/brush-stroke-smoother.ts
@@ -1,6 +1,6 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../config';
+import { defaultSettings } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
@@ -143,13 +143,13 @@ const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): ve
const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution
- : appConfig.defaultSettings.brushCurveResolution;
+ : defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance
- : appConfig.defaultSettings.brushSmoothingMinSampleDistance;
+ : defaultSettings.brushSmoothingMinSampleDistance;
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
};
diff --git a/src/game-loop/export-snapshot-renderer.ts b/src/game-loop/export-snapshot-renderer.ts
index 0be8911..db675cc 100644
--- a/src/game-loop/export-snapshot-renderer.ts
+++ b/src/game-loop/export-snapshot-renderer.ts
@@ -1,7 +1,13 @@
-import { appConfig } from '../config';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import type { VibeId } from '../vibes';
+const SNAPSHOT_BYTES_PER_PIXEL = 4;
+const SNAPSHOT_FILENAME_EXTENSION = 'png';
+const SNAPSHOT_FILENAME_PREFIX = 'fleeting-garden';
+const SNAPSHOT_FILENAME_SUFFIX = '-snapshot';
+const SNAPSHOT_MIME_TYPE = 'image/png';
+const SNAPSHOT_ROW_ALIGNMENT_BYTES = 256;
+
interface ExportSnapshotRendererOptions {
device: GPUDevice;
renderPipeline: RenderPipeline;
@@ -121,15 +127,15 @@ export class ExportSnapshotRenderer {
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvas.convertToBlob({
- type: appConfig.exportSnapshot.mimeType,
+ type: SNAPSHOT_MIME_TYPE,
});
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
try {
link.href = objectUrl;
- link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
+ link.download = `${SNAPSHOT_FILENAME_PREFIX}_${this.options.getVibeId()}_${
this.options.seed
- }_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
+ }_${width}x${height}${SNAPSHOT_FILENAME_SUFFIX}.${SNAPSHOT_FILENAME_EXTENSION}`;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
@@ -154,11 +160,8 @@ const getSnapshotDimension = (value: number): number =>
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
const width = getSnapshotDimension(sourceWidth);
const height = getSnapshotDimension(sourceHeight);
- const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
- const bytesPerRow = alignTo(
- unpaddedBytesPerRow,
- appConfig.exportSnapshot.rowAlignmentBytes
- );
+ const unpaddedBytesPerRow = width * SNAPSHOT_BYTES_PER_PIXEL;
+ const bytesPerRow = alignTo(unpaddedBytesPerRow, SNAPSHOT_ROW_ALIGNMENT_BYTES);
return {
width,
@@ -191,8 +194,8 @@ const readSnapshotPixels = ({
const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) {
- const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
- const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
+ const source = sourceOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
+ const target = targetOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
pixels[target + 1] = mapped[source + 1];
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts
index 64c9680..a380c23 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, type GardenRuntimeSettings } from '../config';
+import { 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';
@@ -12,9 +12,12 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { initializeContext } from '../utils/graphics/initialize-context';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
+import { perfStatsOverlayState } from './perf-stats-overlay';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
+const INTRO_MOVE_SPEED = 280;
+
interface FrameParameters extends RenderInputs {
time: number;
deltaTime: number;
@@ -78,7 +81,7 @@ export class GameLoopResources {
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
this.gpuProfiler = GpuProfiler.create(
this.device,
- () => appConfig.tuningPane.showFpsOverlay
+ () => perfStatsOverlayState.isVisible
);
this.frameRenderer = new SimulationFrameRenderer(
@@ -134,7 +137,7 @@ export class GameLoopResources {
deltaTime,
time,
agentCount: activeAgentCount,
- introMoveSpeed: appConfig.simulation.introMoveSpeed,
+ introMoveSpeed: INTRO_MOVE_SPEED,
introProgress,
});
this.brushPipeline.setParameters({
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index aed5289..25d2323 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -1,7 +1,7 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
-import { appConfig } from '../config';
+import { createGardenAudioConfig } from '../audio/garden-audio-config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
@@ -13,14 +13,19 @@ import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types';
import { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt';
-import { PerfStatsOverlay } from './perf-stats-overlay';
+import { PerfStatsOverlay, perfStatsOverlayState } from './perf-stats-overlay';
import { GardenPointerInput } from './pointer-input';
+import { MAX_MIRROR_SEGMENT_COUNT, MIN_MIRROR_SEGMENT_COUNT } from './stroke-mirroring';
import { PipelineStrokeOutput } from './stroke-output';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
+const INTRO_RESIZE_SETTLE_MS = 120;
+const INTRO_RESIZE_MINIMUM_REMAINING_SECONDS = 1.4;
+const GARDEN_AUDIO_CONFIG = createGardenAudioConfig();
+
export default class GameLoop {
private readonly resources: GameLoopResources;
- private readonly audio = new GardenAudio(appConfig.audio);
+ private readonly audio = new GardenAudio(GARDEN_AUDIO_CONFIG);
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
@@ -250,7 +255,7 @@ export default class GameLoop {
};
private syncPerfStatsOverlay(): void {
- if (appConfig.tuningPane.showFpsOverlay) {
+ if (perfStatsOverlayState.isVisible) {
this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body
);
@@ -323,13 +328,11 @@ export default class GameLoop {
return;
}
- if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
+ if (time - this.pendingIntroResizeAt < INTRO_RESIZE_SETTLE_MS) {
return;
}
- this.introPrompt.rewindToLeaveRemainingTime(
- appConfig.simulation.intro.resizeMinimumRemainingSeconds
- );
+ this.introPrompt.rewindToLeaveRemainingTime(INTRO_RESIZE_MINIMUM_REMAINING_SECONDS);
this.resources.clearSimulation();
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
this.pendingIntroResizeAt = null;
@@ -351,10 +354,10 @@ export default class GameLoop {
private get mirrorSegmentCount(): number {
const count = Number.isFinite(settings.mirrorSegmentCount)
? settings.mirrorSegmentCount
- : appConfig.toolbar.mirror.min;
+ : MIN_MIRROR_SEGMENT_COUNT;
return Math.min(
- appConfig.toolbar.mirror.max,
- Math.max(appConfig.toolbar.mirror.min, Math.round(count))
+ MAX_MIRROR_SEGMENT_COUNT,
+ Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(count))
);
}
diff --git a/src/game-loop/intro-prompt.ts b/src/game-loop/intro-prompt.ts
index cd9468d..483f7f0 100644
--- a/src/game-loop/intro-prompt.ts
+++ b/src/game-loop/intro-prompt.ts
@@ -1,6 +1,6 @@
-import { appConfig } from '../config';
-
const DRAW_HINT_CLASS = 'draw-hint';
+const INTRO_DURATION_SECONDS = 4;
+const DRAW_HINT_DELAY_MS = 3000;
export class IntroPrompt {
private introComplete = false;
@@ -13,10 +13,7 @@ export class IntroPrompt {
public get progress(): number {
return this.introComplete
? 1
- : Math.min(
- 1,
- this.introElapsedSeconds / appConfig.simulation.intro.durationSeconds
- );
+ : Math.min(1, this.introElapsedSeconds / INTRO_DURATION_SECONDS);
}
public get shouldRegenerateTitleOnResize(): boolean {
@@ -33,7 +30,7 @@ export class IntroPrompt {
: 0;
this.introElapsedSeconds = Math.min(
this.introElapsedSeconds,
- Math.max(0, appConfig.simulation.intro.durationSeconds - safeRemainingSeconds)
+ Math.max(0, INTRO_DURATION_SECONDS - safeRemainingSeconds)
);
}
@@ -45,10 +42,7 @@ export class IntroPrompt {
this.introElapsedSeconds += safeDeltaTime;
}
- if (
- !this.introComplete &&
- this.introElapsedSeconds >= appConfig.simulation.intro.durationSeconds
- ) {
+ if (!this.introComplete && this.introElapsedSeconds >= INTRO_DURATION_SECONDS) {
this.complete(now);
}
@@ -56,7 +50,7 @@ export class IntroPrompt {
!this.introComplete ||
this.hasStartedDrawing ||
this.introCompletedAt === null ||
- now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
+ now - this.introCompletedAt < DRAW_HINT_DELAY_MS
) {
return;
}
diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts
index a34bbf4..168c106 100644
--- a/src/game-loop/intro-title-agents.ts
+++ b/src/game-loop/intro-title-agents.ts
@@ -1,4 +1,3 @@
-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,9 +17,43 @@ interface IntroTitleAgentOptions {
}
type RandomSource = () => number;
-type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing'];
+type IntroPathEasing = 'easeOutQuad' | 'linear';
+
+const INTRO_TITLE = 'Fleeting';
+const INTRO_ANGLE_JITTER_RADIANS = Math.PI * 0.08;
+const INTRO_ANGLE_EASE_START = 0.6;
+const INTRO_ANGLE_EASE_END = 1;
+const INTRO_CIRCLE_MIN_SIDE_RATIO = 0.32;
+const INTRO_CIRCLE_MAX_SIDE_RATIO = 0.46;
+const INTRO_ENTRY_JITTER_SIDE_RATIO = 0.035;
+const INTRO_FONT_FAMILY = '"Open Sans", sans-serif';
+const INTRO_FONT_SCALE_DOWN = 0.94;
+const INTRO_INITIAL_FONT_HEIGHT_RATIO = 0.28;
+const INTRO_INITIAL_FONT_WIDTH_RATIO = 0.19;
+const INTRO_LETTER_SPACING_EM = 0.07;
+const INTRO_MASK_ALPHA_THRESHOLD = 32;
+const INTRO_MASK_GRADIENT_THRESHOLD = 8;
+const INTRO_MASK_MAX_PIXELS = 1_000_000;
+const INTRO_MASK_SAMPLE_DENSITY = 540;
+const INTRO_MAX_HEIGHT_RATIO = 0.25;
+const INTRO_MAX_WIDTH_RATIO = 0.76;
+const INTRO_MIN_ENTRY_JITTER_PX = 6;
+const INTRO_MIN_FONT_SIZE_PX = 18;
+const INTRO_MIN_TARGET_JITTER_PX = 1;
+const INTRO_PATH_EASING: IntroPathEasing = 'easeOutQuad';
+const INTRO_PATH_PROGRESS_EPSILON = 0.001;
+const INTRO_RADIAL_JITTER_RATIO = 0.35;
+const INTRO_RADIAL_START_EPSILON = 0.001;
+const INTRO_TARGET_DELAY_DISTANCE_MULTIPLIER = 0.12;
+const INTRO_TARGET_DELAY_MAX = 0.22;
+const INTRO_TARGET_DELAY_RANDOM_MULTIPLIER = 0.06;
+const INTRO_TARGET_JITTER_SIDE_RATIO = 0.0035;
+const INTRO_TITLE_COLOR_CUT_LETTERS = [2, 5] as const;
+const INTRO_TITLE_RADIUS_MULTIPLIER = 1.55;
+const INTRO_TITLE_STROKE_WIDTH_MIN_PX = 6;
+const INTRO_TITLE_STROKE_WIDTH_RATIO = 0.11;
+const INTRO_VERTICAL_ANCHOR = 0.47;
-const INTRO_TITLE = appConfig.simulation.intro.title;
const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean =>
pathEasing === 'linear';
@@ -47,30 +80,27 @@ export const createIntroTitleAgents = ({
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
const minSide = Math.min(safeWidth, safeHeight);
const targetJitter = Math.max(
- appConfig.simulation.intro.minTargetJitterPx,
- minSide * appConfig.simulation.intro.targetJitterSideRatio
+ INTRO_MIN_TARGET_JITTER_PX,
+ minSide * INTRO_TARGET_JITTER_SIDE_RATIO
);
const entryJitter = Math.max(
- appConfig.simulation.intro.minEntryJitterPx,
- minSide * appConfig.simulation.intro.entryJitterSideRatio
+ INTRO_MIN_ENTRY_JITTER_PX,
+ minSide * INTRO_ENTRY_JITTER_SIDE_RATIO
);
const titleRadius = points.reduce(
(radius, point) =>
Math.max(
radius,
- Math.hypot(
- point.x - safeWidth / 2,
- point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
- )
+ Math.hypot(point.x - safeWidth / 2, point.y - safeHeight * INTRO_VERTICAL_ANCHOR)
),
0
);
const introCircleRadius = Math.min(
Math.max(
- titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
- minSide * appConfig.simulation.intro.circleMinSideRatio
+ titleRadius * INTRO_TITLE_RADIUS_MULTIPLIER,
+ minSide * INTRO_CIRCLE_MIN_SIDE_RATIO
),
- minSide * appConfig.simulation.intro.circleMaxSideRatio
+ minSide * INTRO_CIRCLE_MAX_SIDE_RATIO
);
for (let i = 0; i < count; i++) {
@@ -101,21 +131,16 @@ export const createIntroTitleAgents = ({
const distanceFraction =
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
const introDelay = Math.min(
- appConfig.simulation.intro.targetDelayMax,
- distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
- random() * appConfig.simulation.intro.targetDelayRandomMultiplier
+ INTRO_TARGET_DELAY_MAX,
+ distanceFraction * INTRO_TARGET_DELAY_DISTANCE_MULTIPLIER +
+ random() * INTRO_TARGET_DELAY_RANDOM_MULTIPLIER
);
const pathProgress = getIntroAgentPathProgress(introProgress, introDelay);
- const initialAngle =
- approachAngle + (random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
+ const initialAngle = approachAngle + (random() - 0.5) * INTRO_ANGLE_JITTER_RADIANS;
const currentAngle = mixAngle(
initialAngle,
targetAngle,
- smoothstep(
- appConfig.simulation.intro.angleEaseStart,
- appConfig.simulation.intro.angleEaseEnd,
- pathProgress
- )
+ smoothstep(INTRO_ANGLE_EASE_START, INTRO_ANGLE_EASE_END, pathProgress)
);
writeAgentValues(data, i, {
positionX: mix(startX, targetX, pathProgress),
@@ -142,12 +167,12 @@ const getIntroRadialStart = (
random: RandomSource
): [number, number] => {
const centerX = width / 2;
- const centerY = height * appConfig.simulation.intro.verticalAnchor;
+ const centerY = height * INTRO_VERTICAL_ANCHOR;
const offsetX = targetX - centerX;
const offsetY = targetY - centerY;
const length = Math.hypot(offsetX, offsetY);
const angle =
- length > appConfig.simulation.intro.radialStartEpsilon
+ length > INTRO_RADIAL_START_EPSILON
? Math.atan2(offsetY, offsetX)
: random() * Math.PI * 2;
const directionX = Math.cos(angle);
@@ -155,8 +180,7 @@ const getIntroRadialStart = (
const tangentX = -directionY;
const tangentY = directionX;
const tangentJitter = (random() - 0.5) * jitter;
- const radialJitter =
- (random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
+ const radialJitter = (random() - 0.5) * jitter * INTRO_RADIAL_JITTER_RATIO;
const startX =
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
const startY =
@@ -172,7 +196,7 @@ const createIntroTitlePoints = (
width: number,
height: number
): Array => {
- const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels);
+ const safeMaxPixels = Math.max(1, INTRO_MASK_MAX_PIXELS);
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));
@@ -188,28 +212,28 @@ const createIntroTitlePoints = (
const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight);
context.clearRect(0, 0, maskWidth, maskHeight);
- context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
+ context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = '#fff';
context.strokeStyle = '#fff';
context.lineJoin = 'round';
context.lineWidth = Math.max(
- appConfig.simulation.intro.titleStrokeWidthMinPx,
- fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
+ INTRO_TITLE_STROKE_WIDTH_MIN_PX,
+ fontSize * INTRO_TITLE_STROKE_WIDTH_RATIO
);
- const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
+ const letterSpacing = fontSize * INTRO_LETTER_SPACING_EM;
drawIntroTitleText(
context,
maskWidth / 2,
- maskHeight * appConfig.simulation.intro.verticalAnchor,
+ maskHeight * INTRO_VERTICAL_ANCHOR,
letterSpacing,
'stroke'
);
drawIntroTitleText(
context,
maskWidth / 2,
- maskHeight * appConfig.simulation.intro.verticalAnchor,
+ maskHeight * INTRO_VERTICAL_ANCHOR,
letterSpacing,
'fill'
);
@@ -217,9 +241,7 @@ const createIntroTitlePoints = (
const { data } = context.getImageData(0, 0, maskWidth, maskHeight);
const step = Math.max(
1,
- Math.floor(
- Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity
- )
+ Math.floor(Math.min(maskWidth, maskHeight) / INTRO_MASK_SAMPLE_DENSITY)
);
const points: Array = [];
const characterColorBoundaries = getIntroTitleColorBoundaries(
@@ -231,7 +253,7 @@ const createIntroTitlePoints = (
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) {
+ if (alpha < INTRO_MASK_ALPHA_THRESHOLD) {
continue;
}
@@ -255,9 +277,9 @@ const getIntroTitleColorBoundaries = (
const letters = Array.from(INTRO_TITLE);
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
let x = width / 2 - totalWidth / 2;
- 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 cutLetters = INTRO_TITLE_COLOR_CUT_LETTERS.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;
@@ -330,17 +352,17 @@ const getIntroTitleFontSize = (
width: number,
height: number
): number => {
- const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
- const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
+ const maxWidth = width * INTRO_MAX_WIDTH_RATIO;
+ const maxHeight = height * INTRO_MAX_HEIGHT_RATIO;
let fontSize = Math.floor(
Math.min(
- height * appConfig.simulation.intro.initialFontHeightRatio,
- width * appConfig.simulation.intro.initialFontWidthRatio
+ height * INTRO_INITIAL_FONT_HEIGHT_RATIO,
+ width * INTRO_INITIAL_FONT_WIDTH_RATIO
)
);
- while (fontSize > appConfig.simulation.intro.minFontSizePx) {
- context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
+ while (fontSize > INTRO_MIN_FONT_SIZE_PX) {
+ context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
const metrics = context.measureText(INTRO_TITLE);
const measuredHeight =
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
@@ -349,7 +371,7 @@ const getIntroTitleFontSize = (
return fontSize;
}
- fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
+ fontSize = Math.floor(fontSize * INTRO_FONT_SCALE_DOWN);
}
return fontSize;
@@ -369,10 +391,7 @@ const estimateMaskTangent = (
getMaskAlpha(data, width, height, x, y + 1) -
getMaskAlpha(data, width, height, x, y - 1);
- if (
- Math.abs(gradientX) + Math.abs(gradientY) <
- appConfig.simulation.intro.maskGradientThreshold
- ) {
+ if (Math.abs(gradientX) + Math.abs(gradientY) < INTRO_MASK_GRADIENT_THRESHOLD) {
return null;
}
@@ -397,8 +416,7 @@ const getIntroAgentPathProgress = (introProgress: number, introDelay: number): n
}
const activeProgress =
- (introProgress - introDelay) /
- Math.max(appConfig.simulation.intro.pathProgressEpsilon, 1 - introDelay);
+ (introProgress - introDelay) / Math.max(INTRO_PATH_PROGRESS_EPSILON, 1 - introDelay);
return easePathProgress(clamp(activeProgress, 0, 1));
};
@@ -414,7 +432,7 @@ const createSeededRandom = (seed: number): RandomSource => {
};
const easePathProgress = (amount: number): number => {
- if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) {
+ if (isLinearPathEasing(INTRO_PATH_EASING)) {
return amount;
}
diff --git a/src/game-loop/perf-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts
index 9e6717f..e9853d9 100644
--- a/src/game-loop/perf-stats-overlay.ts
+++ b/src/game-loop/perf-stats-overlay.ts
@@ -4,6 +4,10 @@ const ZERO_STAT_TEXT = '0';
const ZERO_FRAME_TIME_TEXT = '0ms';
const ZERO_RESOLUTION_TEXT = '0x0';
+export const perfStatsOverlayState = {
+ isVisible: import.meta.env.DEV,
+};
+
interface PerfStatsSnapshot {
time: DOMHighResTimeStamp;
fps: number;
diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts
index bee83ef..c04196b 100644
--- a/src/game-loop/pointer-input.ts
+++ b/src/game-loop/pointer-input.ts
@@ -1,9 +1,9 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
-import { appConfig } from '../config';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { activeVibe } from '../settings';
+import { MIN_DELTA_TIME_SECONDS } from '../utils/delta-time-calculator';
import { BrushStrokeSmoother } from './brush-stroke-smoother';
import { type StrokeSegment } from './game-loop-types';
import { getMirroredStrokeSegments } from './stroke-mirroring';
@@ -155,7 +155,7 @@ export class GardenPointerInput {
const previousPosition = this.lastPointerPosition ?? position;
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
const elapsedSeconds = Math.max(
- appConfig.deltaTime.minDeltaTimeSeconds,
+ MIN_DELTA_TIME_SECONDS,
(event.timeStamp - previousTimeMs) / 1000
);
diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts
index 36629e8..9064af2 100644
--- a/src/game-loop/simulation-frame.ts
+++ b/src/game-loop/simulation-frame.ts
@@ -1,4 +1,3 @@
-import { appConfig } from '../config';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
@@ -10,6 +9,11 @@ import { CanvasReadbackRequest } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
import { SimulationTextures } from './simulation-textures';
+const BRUSH_EFFECT_FRAMES_PER_SECOND = 60;
+// How long the source map continues to be diffused after a brush stroke ends.
+// 600 frames at ~60 FPS is roughly 10 seconds.
+const SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600;
+
interface SimulationFramePipelines {
agentPipeline: AgentPipeline;
brushPipeline: BrushPipeline;
@@ -135,10 +139,9 @@ export class SimulationFrameRenderer {
}
const getSourceActiveFrameCount = (): number => {
- const frameCount =
- settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond;
+ const frameCount = settings.brushEffectDuration * BRUSH_EFFECT_FRAMES_PER_SECOND;
if (Number.isFinite(frameCount) && frameCount > 0) {
return Math.ceil(frameCount);
}
- return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite);
+ return Math.max(1, SOURCE_ACTIVE_FRAMES_AFTER_WRITE);
};
diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts
index 7166595..bf62766 100644
--- a/src/game-loop/simulation-textures.ts
+++ b/src/game-loop/simulation-textures.ts
@@ -1,12 +1,13 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../config';
import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats';
import {
ResizableTexture,
type PendingTextureResize,
} from '../utils/graphics/resizable-texture';
+const SIMULATION_CLEAR_COLOR = { r: 0, g: 0, b: 0, a: 0 };
+
export class SimulationTextures {
// trailMapA holds the current trail (read by agent and diffuse). trailMapB
// receives the diffuse output; the two swap each frame so the freshly
@@ -83,7 +84,7 @@ export class SimulationTextures {
colorAttachments: [
{
view: texture.getTextureView(),
- clearValue: appConfig.simulation.clearColor,
+ clearValue: SIMULATION_CLEAR_COLOR,
loadOp: 'clear',
storeOp: 'store',
},
@@ -122,7 +123,7 @@ export class SimulationTextures {
colorAttachments: [
{
view: this.sourceMapA.getTextureView(),
- clearValue: appConfig.simulation.clearColor,
+ clearValue: SIMULATION_CLEAR_COLOR,
loadOp: 'clear',
storeOp: 'store',
},
diff --git a/src/game-loop/stroke-mirroring.ts b/src/game-loop/stroke-mirroring.ts
index 9bfb8d4..ffa9412 100644
--- a/src/game-loop/stroke-mirroring.ts
+++ b/src/game-loop/stroke-mirroring.ts
@@ -2,6 +2,9 @@ import { vec2 } from 'gl-matrix';
import { type StrokeSegment } from './game-loop-types';
+export const MIN_MIRROR_SEGMENT_COUNT = 1;
+export const MAX_MIRROR_SEGMENT_COUNT = 12;
+
export const getMirroredStrokeSegments = (
from: vec2,
to: vec2,
diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts
index 8898123..df074d4 100644
--- a/src/game-loop/toolbar-contrast-monitor.ts
+++ b/src/game-loop/toolbar-contrast-monitor.ts
@@ -1,4 +1,3 @@
-import { appConfig } from '../config';
import { clamp01 } from '../utils/math';
import type { CanvasReadbackRequest } from './game-loop-types';
@@ -24,21 +23,41 @@ interface ToolbarContrastMetrics {
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
+const BACKGROUND_OPACITY_MAX = 0.82;
+const BRIGHT_LUMINANCE_THRESHOLD = 0.32;
+const BRIGHT_WEIGHT = 0.65;
+const BYTES_PER_SAMPLE = 4;
+const CONTRAST_OFFSET = 0.05;
const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256;
+const LINEAR_CHANNEL_BREAKPOINT = 0.03928;
+const LINEAR_CHANNEL_DIVISOR = 12.92;
+const LINEAR_CHANNEL_GAMMA = 2.4;
+const LINEAR_CHANNEL_OFFSET = 0.055;
+const LINEAR_CHANNEL_SCALE = 1.055;
+const LOW_CONTRAST_THRESHOLD = 3;
+const LOW_CONTRAST_WEIGHT = 1.8;
+const LUMINANCE_BASE = 0.11;
+const LUMINANCE_BLUE_WEIGHT = 0.0722;
+const LUMINANCE_GREEN_WEIGHT = 0.7152;
+const LUMINANCE_RANGE = 0.28;
+const LUMINANCE_RED_WEIGHT = 0.2126;
+const SAMPLE_COLUMNS = 13;
+const SAMPLE_INTERVAL_MS = 300;
+const SAMPLE_ROWS = 7;
+const WHITE_CONTRAST_NUMERATOR = 1.05;
const getLinearChannel = (channel: number): number => {
const normalized = channel / 255;
- return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint
- ? normalized / appConfig.toolbar.contrast.linearChannelDivisor
- : ((normalized + appConfig.toolbar.contrast.linearChannelOffset) /
- appConfig.toolbar.contrast.linearChannelScale) **
- appConfig.toolbar.contrast.linearChannelGamma;
+ return normalized <= LINEAR_CHANNEL_BREAKPOINT
+ ? normalized / LINEAR_CHANNEL_DIVISOR
+ : ((normalized + LINEAR_CHANNEL_OFFSET) / LINEAR_CHANNEL_SCALE) **
+ LINEAR_CHANNEL_GAMMA;
};
const getRelativeLuminance = (red: number, green: number, blue: number): number =>
- appConfig.toolbar.contrast.luminanceRedWeight * getLinearChannel(red) +
- appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) +
- appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue);
+ LUMINANCE_RED_WEIGHT * getLinearChannel(red) +
+ LUMINANCE_GREEN_WEIGHT * getLinearChannel(green) +
+ LUMINANCE_BLUE_WEIGHT * getLinearChannel(blue);
const getToolbarContrastMetrics = (
pixels: Uint8Array,
@@ -46,8 +65,7 @@ const getToolbarContrastMetrics = (
isBgra: boolean
): ToolbarContrastMetrics => {
const count = sampleOffsets.filter(
- (offset) =>
- offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length
+ (offset) => offset >= 0 && offset + BYTES_PER_SAMPLE <= pixels.length
).length;
if (count === 0) {
return {
@@ -63,10 +81,7 @@ const getToolbarContrastMetrics = (
let lowContrastCount = 0;
sampleOffsets.forEach((offset) => {
- if (
- offset < 0 ||
- offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length
- ) {
+ if (offset < 0 || offset + BYTES_PER_SAMPLE > pixels.length) {
return;
}
@@ -74,15 +89,13 @@ const getToolbarContrastMetrics = (
const green = pixels[offset + 1];
const blue = pixels[offset + (isBgra ? 0 : 2)];
const luminance = getRelativeLuminance(red, green, blue);
- const contrastWithWhite =
- appConfig.toolbar.contrast.whiteContrastNumerator /
- (luminance + appConfig.toolbar.contrast.contrastOffset);
+ const contrastWithWhite = WHITE_CONTRAST_NUMERATOR / (luminance + CONTRAST_OFFSET);
luminanceTotal += luminance;
- if (luminance > appConfig.toolbar.contrast.brightLuminanceThreshold) {
+ if (luminance > BRIGHT_LUMINANCE_THRESHOLD) {
brightCount++;
}
- if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
+ if (contrastWithWhite < LOW_CONTRAST_THRESHOLD) {
lowContrastCount++;
}
});
@@ -91,13 +104,11 @@ const getToolbarContrastMetrics = (
const brightRatio = brightCount / count;
const lowContrastRatio = lowContrastCount / count;
const backgroundStrength = clamp01(
- Math.max(0, averageLuminance - appConfig.toolbar.contrast.luminanceBase) /
- appConfig.toolbar.contrast.luminanceRange +
- brightRatio * appConfig.toolbar.contrast.brightWeight +
- lowContrastRatio * appConfig.toolbar.contrast.lowContrastWeight
+ Math.max(0, averageLuminance - LUMINANCE_BASE) / LUMINANCE_RANGE +
+ brightRatio * BRIGHT_WEIGHT +
+ lowContrastRatio * LOW_CONTRAST_WEIGHT
);
- const backgroundOpacity =
- backgroundStrength * appConfig.toolbar.contrast.backgroundOpacityMax;
+ const backgroundOpacity = backgroundStrength * BACKGROUND_OPACITY_MAX;
return {
averageLuminance,
@@ -128,7 +139,7 @@ export class ToolbarContrastMonitor {
if (
this.isDestroyed ||
this.isReadbackPending ||
- time - this.lastSampleAt < appConfig.toolbar.contrast.sampleIntervalMs
+ time - this.lastSampleAt < SAMPLE_INTERVAL_MS
) {
return null;
}
@@ -211,12 +222,12 @@ export class ToolbarContrastMonitor {
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
const safeBackgroundOpacity = Math.min(
- appConfig.toolbar.contrast.backgroundOpacityMax,
+ BACKGROUND_OPACITY_MAX,
Math.max(0, backgroundOpacity)
);
const backgroundStrength =
- appConfig.toolbar.contrast.backgroundOpacityMax > 0
- ? clamp01(safeBackgroundOpacity / appConfig.toolbar.contrast.backgroundOpacityMax)
+ BACKGROUND_OPACITY_MAX > 0
+ ? clamp01(safeBackgroundOpacity / BACKGROUND_OPACITY_MAX)
: 0;
this.toolbar.style.setProperty(
@@ -279,22 +290,20 @@ export class ToolbarContrastMonitor {
}
const bytesPerRow = alignTo(
- width * appConfig.toolbar.contrast.bytesPerSample,
+ width * BYTES_PER_SAMPLE,
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) * cssHeight;
+ for (let row = 0; row < SAMPLE_ROWS; row++) {
+ const cssY = top + ((row + 0.5) / SAMPLE_ROWS) * cssHeight;
const y = Math.min(
this.canvas.height - 1,
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
);
- for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
- const cssX =
- left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth;
+ for (let column = 0; column < SAMPLE_COLUMNS; column++) {
+ const cssX = left + ((column + 0.5) / SAMPLE_COLUMNS) * cssWidth;
const x = Math.min(
this.canvas.width - 1,
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
@@ -309,8 +318,7 @@ export class ToolbarContrastMonitor {
origin,
sampleOffsets: [...points.values()].map(
(point) =>
- (point.y - origin.y) * bytesPerRow +
- (point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample
+ (point.y - origin.y) * bytesPerRow + (point.x - origin.x) * BYTES_PER_SAMPLE
),
width,
};
diff --git a/src/index.ts b/src/index.ts
index 74b98d7..79b5251 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -71,6 +71,10 @@ const main = async () => {
HTMLButtonElement
);
const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
+ const infoCloseButton = queryRequiredElement(
+ '[data-control="info-close"]',
+ HTMLButtonElement
+ );
const infoElement = queryRequiredElement('.info-page', HTMLElement);
const fullScreenButton = queryRequiredElement(
'[data-control="full-screen"]',
@@ -113,6 +117,7 @@ const main = async () => {
};
const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
+ infoCloseButton.addEventListener('click', () => infoPageHandler.close());
new MenuHider(
aside,
() =>
diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts
index 7677bad..159c6c2 100644
--- a/src/page/audio-control.ts
+++ b/src/page/audio-control.ts
@@ -1,19 +1,24 @@
-import { appConfig } from '../config';
+import { DEFAULT_AUDIO_VOLUME } from '../audio/garden-audio-config';
import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom';
import { clamp01 } from '../utils/math';
+const AUDIO_MUTED_STORAGE_KEY = 'fleeting-garden:audio-muted';
+const AUDIO_VOLUME_STORAGE_KEY = 'fleeting-garden:audio-volume';
+const AUDIO_VOLUME_MIN = 0;
+const AUDIO_VOLUME_MAX = 1;
+const AUDIO_VOLUME_STEP = 0.01;
+
const clampAudioVolume = (value: number): number => {
- const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
- const safeValue = Number.isFinite(value) ? value : defaultVolume;
- return Math.min(max, Math.max(min, clamp01(safeValue)));
+ const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
+ return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, clamp01(safeValue)));
};
const readInitialAudioVolume = (): number => {
- const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
+ const storedVolume = readBrowserStorage(AUDIO_VOLUME_STORAGE_KEY);
return storedVolume === null
- ? appConfig.toolbar.volume.default
+ ? DEFAULT_AUDIO_VOLUME
: clampAudioVolume(Number(storedVolume));
};
@@ -45,7 +50,7 @@ export class AudioControl {
private audioVolume = readInitialAudioVolume();
private isMutedState =
- readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
+ readBrowserStorage(AUDIO_MUTED_STORAGE_KEY) === STORED_MUTED_TRUE ||
this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) {
@@ -85,9 +90,9 @@ export class AudioControl {
this.soundButton.setAttribute('aria-label', muteLabel);
this.soundButton.title = muteLabel;
- this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
- this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
- this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
+ this.volumeSlider.min = AUDIO_VOLUME_MIN.toString();
+ this.volumeSlider.max = AUDIO_VOLUME_MAX.toString();
+ this.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
this.volumeSlider.setAttribute(
'aria-valuetext',
@@ -107,7 +112,7 @@ export class AudioControl {
private readonly onToggleMute = () => {
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
if (shouldUnmute && this.audioVolume <= 0) {
- this.audioVolume = appConfig.toolbar.volume.default;
+ this.audioVolume = DEFAULT_AUDIO_VOLUME;
}
this.isMutedState = !shouldUnmute;
this.persist();
@@ -141,11 +146,11 @@ export class AudioControl {
private persist(): void {
writeBrowserStorage(
- appConfig.storage.audioMutedKey,
+ AUDIO_MUTED_STORAGE_KEY,
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
);
writeBrowserStorage(
- appConfig.storage.audioVolumeKey,
+ AUDIO_VOLUME_STORAGE_KEY,
formatStoredAudioVolume(this.audioVolume)
);
}
diff --git a/src/page/color-reaction-matrix-control.ts b/src/page/color-reaction-matrix-control.ts
index ca78cbb..756fd63 100644
--- a/src/page/color-reaction-matrix-control.ts
+++ b/src/page/color-reaction-matrix-control.ts
@@ -1,6 +1,6 @@
import type { FolderApi } from '@tweakpane/core';
-import { appConfig, normalizeNumberControlValue } from '../config';
+import { normalizeNumberControlValue, runtimeControls } from '../config';
import { activeVibe, settings } from '../settings';
import { rgbColorToCss } from '../utils/rgb-color';
@@ -128,7 +128,7 @@ export class ColorReactionMatrixControl {
const cell = document.createElement('div');
cell.className = 'color-reaction-matrix__cell';
- const config = appConfig.runtimeSettings.controls[key];
+ const config = runtimeControls[key];
if (!config) {
return cell;
}
@@ -165,7 +165,7 @@ export class ColorReactionMatrixControl {
sourceColorIndex: number,
targetColorIndex: number
): void {
- const config = appConfig.runtimeSettings.controls[key];
+ const config = runtimeControls[key];
if (!config) {
return;
}
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
index 8bb7f28..bdae8f7 100644
--- a/src/page/config-pane.ts
+++ b/src/page/config-pane.ts
@@ -3,11 +3,12 @@ import { Pane } from 'tweakpane';
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import {
- appConfig,
normalizeNumberControlValue,
+ runtimeControls,
type GardenRuntimeSettings,
type NumberControlConfig,
} from '../config';
+import { perfStatsOverlayState } from '../game-loop/perf-stats-overlay';
import { activeVibe, settings } from '../settings';
import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
@@ -29,6 +30,8 @@ interface PaneState extends GardenAudioVibeSettings {
}
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
+const CONFIG_PANE_TITLE = 'Garden Settings';
+const CONFIG_PANE_START_HIDDEN = true;
const MUSIC_CONTROLS: ReadonlyArray<{
key: VibeNumberKey;
@@ -56,7 +59,7 @@ interface ConfigPaneOptions {
const getRuntimeControlKeys = (folder: string): Array =>
(
- Object.entries(appConfig.runtimeSettings.controls) as Array<
+ Object.entries(runtimeControls) as Array<
[RuntimeControlKey, NumberControlConfig | undefined]
>
)
@@ -123,10 +126,10 @@ export class ConfigPane {
this.pane = new Pane({
container: this.container,
- title: appConfig.tuningPane.title,
+ title: CONFIG_PANE_TITLE,
expanded: true,
});
- this.pane.hidden = appConfig.tuningPane.startHidden;
+ this.pane.hidden = CONFIG_PANE_START_HIDDEN;
this.pane.element.classList.add('config-pane');
this.pane.element.id = 'config-pane';
@@ -310,7 +313,7 @@ export class ConfigPane {
private getRuntimeControlConfig(
key: RuntimeControlKey
): NumberControlConfig | undefined {
- const config = appConfig.runtimeSettings.controls[key];
+ const config = runtimeControls[key];
if (!config || key !== 'maxAgentCount') {
return config;
}
@@ -323,7 +326,7 @@ export class ConfigPane {
private addFpsOverlayBinding(container: PaneContainer): void {
container
- .addBinding(appConfig.tuningPane, 'showFpsOverlay', {
+ .addBinding(perfStatsOverlayState, 'isVisible', {
label: 'Show FPS',
})
.on('change', () => this.options.onConfigChange());
diff --git a/src/page/eraser-size-control.test.ts b/src/page/eraser-size-control.test.ts
index 6b2bbe8..1d93778 100644
--- a/src/page/eraser-size-control.test.ts
+++ b/src/page/eraser-size-control.test.ts
@@ -1,26 +1,26 @@
import { describe, expect, it } from 'vitest';
-import { appConfig } from '../config';
import {
+ ERASER_SIZE_MAX,
+ ERASER_SIZE_MIN,
getEraserSizeFromSliderRatio,
getEraserSliderRatioFromSize,
} from './eraser-size-control';
describe('eraser size slider mapping', () => {
it('maps slider position quadratically to eraser size', () => {
- const { max, min } = appConfig.toolbar.eraser;
-
- expect(getEraserSizeFromSliderRatio(0)).toBe(min);
- expect(getEraserSizeFromSliderRatio(0.5)).toBe(min + (max - min) * 0.25);
- expect(getEraserSizeFromSliderRatio(1)).toBe(max);
+ expect(getEraserSizeFromSliderRatio(0)).toBe(ERASER_SIZE_MIN);
+ expect(getEraserSizeFromSliderRatio(0.5)).toBe(
+ ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25
+ );
+ expect(getEraserSizeFromSliderRatio(1)).toBe(ERASER_SIZE_MAX);
});
it('maps eraser size back to the inverse slider position', () => {
- const { max, min } = appConfig.toolbar.eraser;
- const quarterRangeSize = min + (max - min) * 0.25;
+ const quarterRangeSize = ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25;
- expect(getEraserSliderRatioFromSize(min)).toBe(0);
+ expect(getEraserSliderRatioFromSize(ERASER_SIZE_MIN)).toBe(0);
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
- expect(getEraserSliderRatioFromSize(max)).toBe(1);
+ expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
});
});
diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts
index 2c0e8a8..8f93d51 100644
--- a/src/page/eraser-size-control.ts
+++ b/src/page/eraser-size-control.ts
@@ -1,12 +1,16 @@
-import { appConfig } from '../config';
import type GameLoop from '../game-loop/game-loop';
-import { settings } from '../settings';
+import { DEFAULT_ERASER_SIZE, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
+export const ERASER_SIZE_MIN = 24;
+export const ERASER_SIZE_MAX = 480;
+
+const ERASER_CONTROL_SCALE_MIN = 0.74;
+const ERASER_CONTROL_SCALE_MAX = 1.34;
+
const clampEraserSize = (value: number): number => {
- const { default: defaultSize, max, min } = appConfig.toolbar.eraser;
- const safeValue = Number.isFinite(value) ? value : defaultSize;
- return Math.min(max, Math.max(min, Math.round(safeValue)));
+ const safeValue = Number.isFinite(value) ? value : DEFAULT_ERASER_SIZE;
+ return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
};
const ERASER_SLIDER_MIN = 0;
@@ -19,13 +23,14 @@ const clampSliderRatio = (value: number): number => {
};
const getEraserSizeRatio = (size: number): number => {
- const { max, min } = appConfig.toolbar.eraser;
- return (clampEraserSize(size) - min) / (max - min);
+ return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
};
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
- const { max, min } = appConfig.toolbar.eraser;
- return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2);
+ return clampEraserSize(
+ ERASER_SIZE_MIN +
+ (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2
+ );
};
export const getEraserSliderRatioFromSize = (size: number): number =>
@@ -72,10 +77,8 @@ export class EraserSizeControl {
const sizeRatio = getEraserSizeRatio(size);
const scale =
- appConfig.toolbar.eraser.controlScaleMin +
- (appConfig.toolbar.eraser.controlScaleMax -
- appConfig.toolbar.eraser.controlScaleMin) *
- sizeRatio;
+ ERASER_CONTROL_SCALE_MIN +
+ (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
this.syncActiveState();
diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts
index ed8aa59..4f371c6 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,9 +1,10 @@
-import { appConfig } from '../config';
+const DESKTOP_AUTO_HIDE_MEDIA_QUERY =
+ '(min-width: 600px) and (hover: hover) and (pointer: fine)';
+const HIDE_DELAY_MS = 3000;
+const BOTTOM_REVEAL_DISTANCE_PX = 96;
export class MenuHider {
- private readonly desktopMediaQuery = window.matchMedia(
- appConfig.menuHider.desktopMediaQuery
- );
+ private readonly desktopMediaQuery = window.matchMedia(DESKTOP_AUTO_HIDE_MEDIA_QUERY);
private hideTimeout: number | undefined;
private isHidden = false;
private pointerInside = false;
@@ -95,7 +96,7 @@ export class MenuHider {
if (this.canAutoHide) {
this.hide();
}
- }, appConfig.menuHider.hideDelayMs);
+ }, HIDE_DELAY_MS);
}
private reveal(): void {
@@ -134,6 +135,6 @@ export class MenuHider {
private isNearViewportBottom(clientY: number): boolean {
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
- return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
+ return clientY >= viewportHeight - BOTTOM_REVEAL_DISTANCE_PX;
}
}
diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts
index 1ea8ddc..f058429 100644
--- a/src/page/mirror-segment-control.ts
+++ b/src/page/mirror-segment-control.ts
@@ -1,25 +1,48 @@
-import { appConfig } from '../config';
-import { settings } from '../settings';
+import {
+ MAX_MIRROR_SEGMENT_COUNT,
+ MIN_MIRROR_SEGMENT_COUNT,
+} from '../game-loop/stroke-mirroring';
+import { DEFAULT_MIRROR_SEGMENT_COUNT, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
+const MIRROR_SEGMENT_STEP = 1;
+const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
+const MIRROR_SEGMENT_FALLBACK_NAME = 'slices';
+const MIRROR_SEGMENT_NAMES = {
+ 2: 'halves',
+ 3: 'thirds',
+ 4: 'quarters',
+ 5: 'fifths',
+ 6: 'sixths',
+ 7: 'sevenths',
+ 8: 'eighths',
+ 9: 'ninths',
+ 10: 'tenths',
+ 11: 'elevenths',
+ 12: 'twelfths',
+} satisfies Record;
+
const clampMirrorSegmentCount = (value: number): number => {
- const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
- const safeValue = Number.isFinite(value) ? value : defaultCount;
- return Math.min(max, Math.max(min, Math.round(safeValue)));
+ const safeValue = Number.isFinite(value) ? value : DEFAULT_MIRROR_SEGMENT_COUNT;
+ return Math.min(
+ MAX_MIRROR_SEGMENT_COUNT,
+ Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(safeValue))
+ );
};
const getMirrorSegmentRatio = (count: number): number => {
- const { max, min } = appConfig.toolbar.mirror;
- return (count - min) / (max - min);
+ return (
+ (count - MIN_MIRROR_SEGMENT_COUNT) /
+ (MAX_MIRROR_SEGMENT_COUNT - MIN_MIRROR_SEGMENT_COUNT)
+ );
};
const formatMirrorSegmentCount = (count: number): string =>
count <= 1
- ? appConfig.toolbar.mirror.offLabel
+ ? MIRROR_SEGMENT_OFF_LABEL
: `${count} ${
- appConfig.toolbar.mirror.names[
- count as keyof typeof appConfig.toolbar.mirror.names
- ] ?? appConfig.toolbar.mirror.fallbackSegmentName
+ MIRROR_SEGMENT_NAMES[count as keyof typeof MIRROR_SEGMENT_NAMES] ??
+ MIRROR_SEGMENT_FALLBACK_NAME
}`;
interface MirrorSegmentControlOptions {
@@ -50,9 +73,9 @@ export class MirrorSegmentControl {
settings.mirrorSegmentCount = count;
}
- this.slider.min = appConfig.toolbar.mirror.min.toString();
- this.slider.max = appConfig.toolbar.mirror.max.toString();
- this.slider.step = appConfig.toolbar.mirror.step.toString();
+ this.slider.min = MIN_MIRROR_SEGMENT_COUNT.toString();
+ this.slider.max = MAX_MIRROR_SEGMENT_COUNT.toString();
+ this.slider.step = MIRROR_SEGMENT_STEP.toString();
this.slider.value = count.toString();
const label = formatMirrorSegmentCount(count);
diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts
index b759c4f..4897020 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../../config';
import { getRenderQualityBrushSize } from '../../config/brush-size';
import {
createCachedBufferWrite,
@@ -40,6 +39,7 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
: 1;
const UNIFORM_COUNT = 16;
+const MAX_BRUSH_LINE_COUNT = 240;
const setBrushUniformValues = (
target: Float32Array,
@@ -93,7 +93,7 @@ export class BrushPipeline {
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
- this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount);
+ this.segments = new LineSegmentBuffer(device, MAX_BRUSH_LINE_COUNT);
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts
index 5157459..deeaa8a 100644
--- a/src/pipelines/common-state/common-state.ts
+++ b/src/pipelines/common-state/common-state.ts
@@ -1,11 +1,10 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../../config';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
-import { generateNoise } from '../../utils/graphics/noise';
+import { generateNoise, NOISE_TEXTURE_SIZE } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;
@@ -39,8 +38,8 @@ export class CommonState {
const noise = generateNoise({
device,
- width: appConfig.pipelines.common.noiseTextureSize,
- height: appConfig.pipelines.common.noiseTextureSize,
+ width: NOISE_TEXTURE_SIZE,
+ height: NOISE_TEXTURE_SIZE,
});
this.noise = noise.texture;
diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts
index b1c763d..ef9bfef 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../../config';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedBufferWrite,
@@ -29,12 +28,13 @@ type DiffusionUniformSettings = Pick<
| 'brushDecayAlphaOffset'
>;
+const MIN_DIFFUSION_RATE = 0.000001;
+
const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
1 /
- (Number.isFinite(diffusionRate) &&
- diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
+ (Number.isFinite(diffusionRate) && diffusionRate > MIN_DIFFUSION_RATE
? diffusionRate
- : appConfig.pipelines.diffusion.minDiffusionRate);
+ : MIN_DIFFUSION_RATE);
const setDiffusionUniformValues = (
target: Float32Array,
diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts
index 694777f..cf3e84a 100644
--- a/src/pipelines/eraser/eraser-texture-pipeline.ts
+++ b/src/pipelines/eraser/eraser-texture-pipeline.ts
@@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../../config';
import {
createCachedBufferWrite,
writeBufferIfChanged,
@@ -29,6 +28,7 @@ interface EraserTextureParameters {
}
const UNIFORM_COUNT = 8;
+const MAX_ERASER_TEXTURE_LINE_COUNT = 384;
const TARGET_FORMATS: Array = [
ERASER_MASK_TEXTURE_FORMAT,
TRAIL_SOURCE_TEXTURE_FORMAT,
@@ -50,10 +50,7 @@ export class EraserTexturePipeline {
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
- this.segments = new LineSegmentBuffer(
- device,
- appConfig.pipelines.eraser.maxTextureLineCount
- );
+ this.segments = new LineSegmentBuffer(device, MAX_ERASER_TEXTURE_LINE_COUNT);
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
diff --git a/src/settings.ts b/src/settings.ts
index c91ac83..bceadd9 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,10 +1,14 @@
import {
- appConfig,
+ defaultSettings,
normalizeRuntimeSettings,
+ runtimeControls,
type GardenRuntimeSettings,
} from './config';
import { writeBrowserStorage } from './utils/browser-storage';
-import { getInitialVibe, type VibePreset } from './vibes';
+import { getInitialVibe, VIBE_STORAGE_KEY, type VibePreset } from './vibes';
+
+export const DEFAULT_ERASER_SIZE = 96;
+export const DEFAULT_MIRROR_SEGMENT_COUNT = 8;
const preservedRuntimeSettingKeys = [
'eraserSize',
@@ -37,12 +41,12 @@ const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
normalizeRuntimeSettings(
{
- ...appConfig.defaultSettings,
- eraserSize: appConfig.toolbar.eraser.default,
- mirrorSegmentCount: appConfig.toolbar.mirror.default,
+ ...defaultSettings,
+ eraserSize: DEFAULT_ERASER_SIZE,
+ mirrorSegmentCount: DEFAULT_MIRROR_SEGMENT_COUNT,
...vibe.settings,
},
- appConfig.runtimeSettings.controls
+ runtimeControls
);
export let activeVibe = cloneVibePreset(getInitialVibe());
@@ -52,7 +56,7 @@ export const settings: GardenRuntimeSettings = {
};
export const rememberActiveVibeSelection = (): void => {
- writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
+ writeBrowserStorage(VIBE_STORAGE_KEY, activeVibe.id);
};
export const applyVibeSettings = (vibe: VibePreset) => {
@@ -66,10 +70,7 @@ export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe.colors.length - 1
);
- Object.assign(
- settings,
- normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
- );
+ Object.assign(settings, normalizeRuntimeSettings(nextSettings, runtimeControls));
rememberActiveVibeSelection();
diff --git a/src/style/_panels.scss b/src/style/_panels.scss
index 28717bb..3e92b02 100644
--- a/src/style/_panels.scss
+++ b/src/style/_panels.scss
@@ -1,24 +1,28 @@
@use 'mixins' as *;
html > body > aside.control-dock > .info-page {
- width: min(calc(100vw - 1rem), 560px);
- max-height: min(58vh, 520px);
- max-height: min(58dvh, 520px);
+ width: min(100%, 520px);
+ max-height: min(62vh, 480px);
+ max-height: min(62dvh, 480px);
margin: 0 auto 10px;
overflow-x: hidden;
overflow-y: auto;
- border: 1px solid rgb(255 255 255 / 78%);
+ overscroll-behavior: contain;
+ touch-action: pan-y;
+ border: 1px solid rgb(255 255 255 / 46%);
border-radius: 8px;
background:
- linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
- rgb(255 255 255);
- color: rgb(24 30 27);
+ linear-gradient(180deg, rgb(252 255 249 / 94%), rgb(234 241 232 / 91%)),
+ rgb(249 252 247 / 92%);
+ color: rgb(18 28 24);
box-shadow:
- 0 20px 54px rgb(0 0 0 / 38%),
- 0 2px 12px rgb(0 0 0 / 22%);
- backdrop-filter: blur(12px);
+ inset 0 1px 0 rgb(255 255 255 / 58%),
+ 0 16px 42px rgb(0 0 0 / 30%),
+ 0 2px 10px rgb(0 0 0 / 18%);
+ backdrop-filter: blur(16px) saturate(118%);
scrollbar-width: thin;
- scrollbar-color: var(--main-color) transparent;
+ scrollbar-color: rgb(69 98 88 / 62%) transparent;
+ -webkit-overflow-scrolling: touch;
transition:
max-height var(--transition-time-long),
opacity var(--transition-time-long),
@@ -32,45 +36,179 @@ html > body > aside.control-dock > .info-page {
}
&::-webkit-scrollbar-thumb {
- background-color: var(--main-color);
+ background-color: rgb(69 98 88 / 62%);
border-radius: 8px;
}
&:focus-visible {
- outline: 2px solid white;
+ outline: 2px solid rgb(17 56 45);
outline-offset: 3px;
+ box-shadow:
+ 0 0 0 5px rgb(255 255 255 / 68%),
+ inset 0 1px 0 rgb(255 255 255 / 58%),
+ 0 16px 42px rgb(0 0 0 / 30%),
+ 0 2px 10px rgb(0 0 0 / 18%);
}
- > section {
+ .info-page__content {
display: flex;
flex-direction: column;
gap: 0.85rem;
- padding: var(--normal-margin);
+ padding: 18px 20px 16px;
+ }
- h1 {
- margin-bottom: 0;
- color: rgb(16 24 20);
- font-size: 2rem;
- line-height: 1.1;
+ .info-page__header {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ padding-bottom: 0.1rem;
+ }
+
+ .info-page__heading {
+ min-width: 0;
+ }
+
+ .info-page__mark {
+ display: grid;
+ flex: 0 0 auto;
+ place-items: center;
+ width: 1.8rem;
+ height: 1.8rem;
+ border: 1px solid rgb(42 74 65 / 24%);
+ border-radius: 8px;
+ background:
+ linear-gradient(180deg, rgb(255 255 255 / 74%), rgb(217 231 222 / 70%)),
+ rgb(232 240 235);
+ box-shadow:
+ inset 0 1px 0 rgb(255 255 255 / 76%),
+ 0 5px 14px rgb(21 44 39 / 12%);
+
+ &::before {
+ content: '';
+ width: 1rem;
+ height: 1rem;
+ background: rgb(19 57 48);
+ mask: url('../../assets/icons/info.svg') center / contain no-repeat;
+ }
+ }
+
+ .info-page__close {
+ position: relative;
+ flex: 0 0 auto;
+ width: 2rem;
+ height: 2rem;
+ margin-left: auto;
+ border: 1px solid rgb(34 57 50 / 18%);
+ border-radius: 8px;
+ background: rgb(255 255 255 / 36%);
+ cursor: pointer;
+ transition:
+ background-color var(--transition-time),
+ border-color var(--transition-time),
+ transform var(--transition-time);
+
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ width: 0.9rem;
+ height: 0.9rem;
+ margin: auto;
+ background: rgb(28 45 39);
+ mask: url('../../assets/icons/close.svg') center / contain no-repeat;
}
- p {
- max-width: 54ch;
- margin-bottom: 0;
- color: rgb(42 48 45);
- font-size: 1.1rem;
- line-height: 1.65;
- hyphens: auto;
+ &:hover {
+ border-color: rgb(34 57 50 / 30%);
+ background: rgb(255 255 255 / 64%);
+ transform: translateY(-1px);
}
- a {
- color: rgb(0 84 120);
- font-weight: 400;
+ &:focus-visible {
+ outline: 2px solid rgb(17 56 45);
+ outline-offset: 2px;
+ box-shadow: 0 0 0 4px rgb(255 255 255 / 72%);
+ }
+ }
- &:focus-visible {
- outline: 2px solid currentColor;
- outline-offset: 3px;
- }
+ .info-page__eyebrow {
+ margin-bottom: 0.12rem;
+ color: rgb(73 91 85);
+ font-size: 0.68rem;
+ font-weight: 700;
+ line-height: 1.2;
+ letter-spacing: 0;
+ text-transform: uppercase;
+ }
+
+ h2 {
+ overflow-wrap: break-word;
+ margin-bottom: 0;
+ color: rgb(9 21 17);
+ font-size: 1.12rem;
+ font-weight: 700;
+ line-height: 1.18;
+ }
+
+ .info-page__lede,
+ .info-page__notes,
+ .info-page__meta {
+ max-width: 56ch;
+ overflow-wrap: break-word;
+ color: rgb(25 35 32);
+ font-size: 0.95rem;
+ line-height: 1.56;
+ }
+
+ .info-page__lede,
+ .info-page__meta {
+ margin-bottom: 0;
+ hyphens: auto;
+ }
+
+ .info-page__notes {
+ display: grid;
+ gap: 0.45rem;
+ margin: 0.1rem 0;
+ padding-left: 1.1rem;
+ list-style: disc;
+
+ li {
+ padding-left: 0.1rem;
+ }
+
+ li::marker {
+ color: rgb(25 108 82);
+ font-size: 0.85em;
+ }
+ }
+
+ .info-page__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ align-items: baseline;
+ margin-top: 0.2rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid rgb(43 66 57 / 16%);
+ color: rgb(67 82 77);
+ font-size: 0.8rem;
+ line-height: 1.45;
+ }
+
+ a {
+ color: rgb(0 83 105);
+ font-weight: 700;
+ text-decoration-color: rgb(0 83 105 / 34%);
+ text-underline-offset: 0.18em;
+
+ &:hover {
+ text-decoration-color: currentColor;
+ }
+
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 3px;
}
}
@@ -81,16 +219,51 @@ html > body > aside.control-dock > .info-page {
opacity: 0;
pointer-events: none;
box-shadow: none;
- transform: translateY(8px);
+ transform: translateY(6px) scale(0.985);
visibility: hidden;
}
@include on-small-screen {
- max-height: min(54vh, 500px);
- max-height: min(54dvh, 500px);
+ width: min(100%, 520px);
+ max-height: min(58vh, 500px);
+ max-height: min(58dvh, 500px);
- > section {
- padding: var(--small-margin);
+ .info-page__content {
+ gap: 0.75rem;
+ padding: 14px;
+ }
+
+ .info-page__lede,
+ .info-page__notes {
+ font-size: 0.95rem;
+ line-height: 1.52;
+ }
+
+ .info-page__meta {
+ font-size: 0.875rem;
+ line-height: 1.45;
}
}
+
+ @media (max-height: 420px) {
+ max-height: min(
+ 58vh,
+ max(
+ 10rem,
+ calc(
+ 100vh - 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+ )
+ )
+ );
+ max-height: min(
+ 58dvh,
+ max(
+ 10rem,
+ calc(
+ 100dvh -
+ 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+ )
+ )
+ );
+ }
}
diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts
index 78489c2..bff0468 100644
--- a/src/utils/delta-time-calculator.ts
+++ b/src/utils/delta-time-calculator.ts
@@ -1,6 +1,9 @@
-import { appConfig } from '../config';
import { clamp } from './math';
+export const MIN_DELTA_TIME_SECONDS = 1 / 240;
+
+const MAX_DELTA_TIME_SECONDS = 1 / 30;
+
export class DeltaTimeCalculator {
private previousTime: DOMHighResTimeStamp | null = null;
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
@@ -16,11 +19,7 @@ export class DeltaTimeCalculator {
const delta = currentTime - this.previousTime;
this.previousTime = currentTime;
- return clamp(
- delta / 1000,
- appConfig.deltaTime.minDeltaTimeSeconds,
- appConfig.deltaTime.maxDeltaTimeSeconds
- );
+ return clamp(delta / 1000, MIN_DELTA_TIME_SECONDS, MAX_DELTA_TIME_SECONDS);
}
private handleVisibilityChange() {
diff --git a/src/utils/graphics/noise.ts b/src/utils/graphics/noise.ts
index c7cdc96..4e171fd 100644
--- a/src/utils/graphics/noise.ts
+++ b/src/utils/graphics/noise.ts
@@ -1,7 +1,17 @@
-import { appConfig } from '../../config';
import { setUpFullScreenQuad } from './full-screen-quad';
import { smartCompile } from './smart-compile';
+export const NOISE_TEXTURE_SIZE = 2048;
+
+const NOISE_CHANNEL_SEEDS = [0, 1, 2, 3] as const;
+const NOISE_CLEAR_VALUE = { r: 1, g: 1, b: 1, a: 1 };
+const NOISE_DRAW_INSTANCE_COUNT = 1;
+const NOISE_DRAW_VERTEX_COUNT = 3;
+const NOISE_HASH_MULTIPLIER = 43758.5453123;
+const NOISE_HASH_X = 12.9898;
+const NOISE_HASH_Y = 78.233;
+const NOISE_TEXTURE_FORMAT = 'r8unorm';
+
export interface GeneratedNoiseTexture {
texture: GPUTexture;
view: GPUTextureView;
@@ -29,16 +39,16 @@ export const generateNoise = ({
return fract(sin(dot(
uv,
vec2(
- ${appConfig.pipelines.common.noiseHashX} + seed,
- ${appConfig.pipelines.common.noiseHashY} + seed
+ ${NOISE_HASH_X} + seed,
+ ${NOISE_HASH_Y} + seed
)
- )) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed);
+ )) * ${NOISE_HASH_MULTIPLIER} + seed);
}
@fragment
fn fragment(@location(0) uv: vec2) -> @location(0) vec4 {
return vec4(
- random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
+ random_with_seed(uv, ${NOISE_CHANNEL_SEEDS[0]}),
0.0,
0.0,
1.0,
@@ -48,7 +58,7 @@ export const generateNoise = ({
entryPoint: 'fragment',
targets: [
{
- format: appConfig.pipelines.common.noiseTextureFormat,
+ format: NOISE_TEXTURE_FORMAT,
},
],
},
@@ -63,7 +73,7 @@ export const generateNoise = ({
height,
depthOrArrayLayers: 1,
},
- format: appConfig.pipelines.common.noiseTextureFormat,
+ format: NOISE_TEXTURE_FORMAT,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
@@ -71,7 +81,7 @@ export const generateNoise = ({
colorAttachments: [
{
view: colorTexture.createView(),
- clearValue: appConfig.pipelines.common.noiseClearValue,
+ clearValue: NOISE_CLEAR_VALUE,
loadOp: 'clear',
storeOp: 'store',
},
@@ -82,10 +92,7 @@ export const generateNoise = ({
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
- passEncoder.draw(
- appConfig.pipelines.common.noiseDrawVertexCount,
- appConfig.pipelines.common.noiseDrawInstanceCount
- );
+ passEncoder.draw(NOISE_DRAW_VERTEX_COUNT, NOISE_DRAW_INSTANCE_COUNT);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
diff --git a/src/vibe-registry.ts b/src/vibe-registry.ts
index 0b0ae54..ad16554 100644
--- a/src/vibe-registry.ts
+++ b/src/vibe-registry.ts
@@ -1,7 +1,7 @@
-import { appConfig } from './config';
+import { vibePresets } from './config';
import type { VibeId, VibePreset } from './config/types';
-export const VIBE_PRESETS: Array = appConfig.vibes.presets;
+export const VIBE_PRESETS: Array = vibePresets;
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
VIBE_PRESETS.find((vibe) => vibe.id === vibeId);
diff --git a/src/vibes.ts b/src/vibes.ts
index 95d1f7c..28aed48 100644
--- a/src/vibes.ts
+++ b/src/vibes.ts
@@ -1,4 +1,4 @@
-import { appConfig } from './config';
+import { defaultVibeId } from './config';
import { VibeId, type VibePreset } from './config/types';
import { readBrowserStorage } from './utils/browser-storage';
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
@@ -8,6 +8,8 @@ export { VibeId };
export { getVibeById, VIBE_PRESETS };
export type { VibePreset };
+export const VIBE_STORAGE_KEY = 'fleeting-garden:vibe';
+
const VIBE_IDS = new Set(VIBE_PRESETS.map((vibe) => vibe.id));
const isVibeId = (value: unknown): value is VibeId =>
@@ -15,12 +17,11 @@ const isVibeId = (value: unknown): value is VibeId =>
export const getInitialVibe = (): VibePreset => {
const uriVibeId = getCurrentUriVibeId();
- const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
+ const storedVibeId = readBrowserStorage(VIBE_STORAGE_KEY);
const storedOrLegacyVibeId = isVibeId(storedVibeId)
? storedVibeId
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
- const initialVibeId =
- uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
+ const initialVibeId = uriVibeId ?? storedOrLegacyVibeId ?? defaultVibeId;
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
};