Add configurable vibe presets
This commit is contained in:
parent
e54bddc7db
commit
f8294934fd
34 changed files with 1701 additions and 341 deletions
199
src/config.ts
Normal file
199
src/config.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
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 {
|
||||
normalizeNumberControlValue,
|
||||
normalizeRuntimeSettings,
|
||||
} from './config/normalize-runtime-settings';
|
||||
|
||||
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;
|
||||
27
src/config/brush-size.test.ts
Normal file
27
src/config/brush-size.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS,
|
||||
getBrushRenderQualityScale,
|
||||
getRenderQualityBrushSize,
|
||||
} from './brush-size';
|
||||
|
||||
describe('render-quality brush sizing', () => {
|
||||
it('keeps brush sizes unchanged at the 7.3 MP baseline', () => {
|
||||
expect(
|
||||
getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS)
|
||||
).toBe(21);
|
||||
});
|
||||
|
||||
it('scales linear brush size with the square root of render area', () => {
|
||||
const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4;
|
||||
|
||||
expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2);
|
||||
expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5);
|
||||
});
|
||||
|
||||
it('falls back to baseline scaling for invalid render areas', () => {
|
||||
expect(getBrushRenderQualityScale(0)).toBe(1);
|
||||
expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5);
|
||||
});
|
||||
});
|
||||
19
src/config/brush-size.ts
Normal file
19
src/config/brush-size.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3;
|
||||
|
||||
const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number =>
|
||||
Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0
|
||||
? renderAreaMegapixels
|
||||
: BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS;
|
||||
|
||||
export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number =>
|
||||
Math.sqrt(
|
||||
getSafeRenderAreaMegapixels(renderAreaMegapixels) /
|
||||
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS
|
||||
);
|
||||
|
||||
export const getRenderQualityBrushSize = (
|
||||
brushSize: number,
|
||||
renderAreaMegapixels: number
|
||||
): number =>
|
||||
Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) *
|
||||
getBrushRenderQualityScale(renderAreaMegapixels);
|
||||
14
src/config/color-interactions.ts
Normal file
14
src/config/color-interactions.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { NumberControlConfig } from './types';
|
||||
|
||||
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||
folder: 'Color Reactions',
|
||||
label,
|
||||
min: -1,
|
||||
max: 1,
|
||||
step: 1,
|
||||
options: {
|
||||
'Move Toward': 1,
|
||||
Ignore: 0,
|
||||
'Move Away': -1,
|
||||
},
|
||||
});
|
||||
74
src/config/default-settings.ts
Normal file
74
src/config/default-settings.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
// Mirrors the historical render-scale cap so the default render area stays
|
||||
// roughly equivalent to native rendering on high-DPR phones without the
|
||||
// pipeline applying its own clamp. The slider can override freely.
|
||||
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
|
||||
|
||||
const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
||||
const rawDpr =
|
||||
typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio)
|
||||
? window.devicePixelRatio
|
||||
: 1;
|
||||
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
|
||||
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
||||
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
|
||||
return Math.min(
|
||||
INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||
Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels)
|
||||
);
|
||||
};
|
||||
|
||||
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||
selectedColorIndex: 0,
|
||||
|
||||
introNearDistanceMin: 28,
|
||||
introNearDistanceInner: 4,
|
||||
introNearSensorOffsetMultiplier: 0.75,
|
||||
introTargetAngleBlend: 0.2,
|
||||
introProgressCutoff: 0.999,
|
||||
introTurnRateMultiplier: 3.4,
|
||||
introRandomTurnMultiplier: 0.18,
|
||||
introStepStopDistance: 0.5,
|
||||
randomTimeScale: 0.34816,
|
||||
|
||||
diffusionRateTrails: 0.22,
|
||||
decayRateBrush: 18,
|
||||
diffusionDecayRateDivisor: 1000,
|
||||
diffusionNeighborDivisor: 8,
|
||||
brushDecayAlphaOffset: 1.001,
|
||||
brushEffectDuration: 8,
|
||||
|
||||
brushCurveResolution: 12,
|
||||
brushCurveMinBrushRadius: 1,
|
||||
brushCurveMinSegmentSpacing: 4,
|
||||
brushCurveMirrorResolutionExponent: 0.5,
|
||||
brushCurveSegmentBrushRadiusRatio: 0.65,
|
||||
brushSmoothingMinSampleDistance: 0.5,
|
||||
|
||||
brushAlpha: 1,
|
||||
brushDiscardThreshold: 0.02,
|
||||
brushGrainNoiseScale: 22,
|
||||
brushGrainNoiseOffsetX: 0.31,
|
||||
brushGrainNoiseOffsetY: 0.67,
|
||||
brushGrainMinStrength: 0.45,
|
||||
brushGrainMaxStrength: 1,
|
||||
|
||||
eraserClearAlpha: 0,
|
||||
eraserClearBlue: 0,
|
||||
eraserClearGreen: 0,
|
||||
eraserClearRed: 0,
|
||||
eraserLineDistanceEpsilon: 0.0001,
|
||||
eraserMaskAlphaThreshold: 0.5,
|
||||
|
||||
adaptiveCapInitial: 1_000_000,
|
||||
adaptiveCapMin: 50_000,
|
||||
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
|
||||
maxAgentCount: 1_500_000,
|
||||
|
||||
renderTraceNormalizationFloor: 1,
|
||||
renderBrushColorBase: 1.2,
|
||||
renderBrushColorStrengthMultiplier: 1.6,
|
||||
};
|
||||
65
src/config/normalize-runtime-settings.test.ts
Normal file
65
src/config/normalize-runtime-settings.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeNumberControlValue,
|
||||
normalizeRuntimeSettings,
|
||||
} from './normalize-runtime-settings';
|
||||
import type { GardenRuntimeSettings } from './types';
|
||||
|
||||
describe('normalizeNumberControlValue', () => {
|
||||
it('clamps and rounds numeric controls', () => {
|
||||
expect(
|
||||
normalizeNumberControlValue(12.6, {
|
||||
folder: 'Test',
|
||||
integer: true,
|
||||
max: 10,
|
||||
min: 0,
|
||||
})
|
||||
).toBe(10);
|
||||
|
||||
expect(
|
||||
normalizeNumberControlValue(Number.NaN, {
|
||||
folder: 'Test',
|
||||
min: 3,
|
||||
})
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps only declared option values', () => {
|
||||
expect(
|
||||
normalizeNumberControlValue(2, {
|
||||
folder: 'Test',
|
||||
options: { off: 0, on: 2 },
|
||||
})
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
normalizeNumberControlValue(3, {
|
||||
folder: 'Test',
|
||||
options: { off: 0, on: 2 },
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRuntimeSettings', () => {
|
||||
it('normalizes configured runtime keys and leaves hidden keys alone', () => {
|
||||
const settings = {
|
||||
brushSize: 99,
|
||||
selectedColorIndex: 7,
|
||||
} as GardenRuntimeSettings;
|
||||
|
||||
expect(
|
||||
normalizeRuntimeSettings(settings, {
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
max: 12,
|
||||
min: 1,
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
brushSize: 12,
|
||||
selectedColorIndex: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/config/normalize-runtime-settings.ts
Normal file
46
src/config/normalize-runtime-settings.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type {
|
||||
GardenAppConfig,
|
||||
GardenRuntimeSettings,
|
||||
NumberControlConfig,
|
||||
} from './types';
|
||||
|
||||
type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
|
||||
|
||||
export const normalizeNumberControlValue = (
|
||||
value: number,
|
||||
config: NumberControlConfig
|
||||
): number => {
|
||||
if (config.options) {
|
||||
const optionValues = Object.values(config.options);
|
||||
if (optionValues.includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
|
||||
}
|
||||
|
||||
const min = config.min ?? Number.NEGATIVE_INFINITY;
|
||||
const max = config.max ?? Number.POSITIVE_INFINITY;
|
||||
const fallbackValue = config.min ?? 0;
|
||||
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
|
||||
const clampedValue = Math.min(max, Math.max(min, finiteValue));
|
||||
return config.integer ? Math.round(clampedValue) : clampedValue;
|
||||
};
|
||||
|
||||
export const normalizeRuntimeSettings = (
|
||||
settings: GardenRuntimeSettings,
|
||||
controls: RuntimeSettingControls
|
||||
): GardenRuntimeSettings => {
|
||||
const normalized = { ...settings };
|
||||
|
||||
(
|
||||
Object.entries(controls) as Array<
|
||||
[keyof GardenRuntimeSettings, NumberControlConfig | undefined]
|
||||
>
|
||||
).forEach(([key, config]) => {
|
||||
if (config) {
|
||||
normalized[key] = normalizeNumberControlValue(normalized[key], config);
|
||||
}
|
||||
});
|
||||
|
||||
return normalized;
|
||||
};
|
||||
147
src/config/runtime-controls.ts
Normal file
147
src/config/runtime-controls.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { colorInteractionControl } from './color-interactions';
|
||||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
||||
const formatRadiansAsDegrees = (value: number): string =>
|
||||
`${Math.round((value * 180) / Math.PI)} deg`;
|
||||
const formatCompactNumber = (value: number): string => {
|
||||
if (value >= 1_000_000) {
|
||||
const millions = value / 1_000_000;
|
||||
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return `${value}`;
|
||||
};
|
||||
|
||||
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
|
||||
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
|
||||
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
|
||||
color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
|
||||
color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
|
||||
color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
|
||||
color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
|
||||
color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
|
||||
color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
|
||||
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
label: 'Brush Size',
|
||||
min: 1,
|
||||
max: 36,
|
||||
step: 0.25,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Brush',
|
||||
label: 'Density',
|
||||
min: 0.01,
|
||||
max: 0.38,
|
||||
step: 0.001,
|
||||
},
|
||||
strokeAngleJitterRadians: {
|
||||
folder: 'Brush',
|
||||
format: formatRadiansAsDegrees,
|
||||
label: 'Spawn Spread',
|
||||
min: 0,
|
||||
max: Math.PI,
|
||||
step: 0.01,
|
||||
},
|
||||
sensorOffsetDistance: {
|
||||
folder: 'Movement',
|
||||
label: 'Sensor Reach',
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetAngle: {
|
||||
folder: 'Movement',
|
||||
label: 'Sensor Angle',
|
||||
min: 0,
|
||||
max: 180,
|
||||
step: 1,
|
||||
},
|
||||
moveSpeed: {
|
||||
folder: 'Movement',
|
||||
label: 'Travel Speed',
|
||||
min: 10,
|
||||
max: 500,
|
||||
step: 1,
|
||||
},
|
||||
turnSpeed: {
|
||||
folder: 'Movement',
|
||||
label: 'Turning Speed',
|
||||
min: 1,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
forwardRotationScale: {
|
||||
folder: 'Movement',
|
||||
format: formatPercent,
|
||||
label: 'Forward Focus',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
turnWhenLost: {
|
||||
folder: 'Movement',
|
||||
label: 'Wander Turn',
|
||||
min: 0,
|
||||
max: Math.PI * 2,
|
||||
step: 0.01,
|
||||
},
|
||||
individualTrailWeight: {
|
||||
folder: 'Movement',
|
||||
label: 'Trail Strength',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
diffusionRateTrails: {
|
||||
folder: 'Movement',
|
||||
label: 'Diffusion Rate',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
decayRateTrails: {
|
||||
folder: 'Movement',
|
||||
label: 'Trail Fade',
|
||||
min: 800,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
},
|
||||
|
||||
clarity: {
|
||||
folder: 'Look',
|
||||
label: 'Sharpness',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
backgroundGrainStrength: {
|
||||
folder: 'Look',
|
||||
label: 'Background Grain',
|
||||
min: 0,
|
||||
max: 0.12,
|
||||
step: 0.001,
|
||||
},
|
||||
|
||||
maxAgentCount: {
|
||||
folder: 'Performance',
|
||||
format: formatCompactNumber,
|
||||
integer: true,
|
||||
label: 'Population Limit',
|
||||
min: 0,
|
||||
step: 10_000,
|
||||
},
|
||||
internalRenderAreaMegapixels: {
|
||||
folder: 'Performance',
|
||||
label: 'Render Quality (MP)',
|
||||
min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min,
|
||||
max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||
step: 0.1,
|
||||
},
|
||||
};
|
||||
4
src/config/runtime-setting-bounds.ts
Normal file
4
src/config/runtime-setting-bounds.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS = {
|
||||
min: 0.5,
|
||||
max: 16.6,
|
||||
} as const;
|
||||
268
src/config/types.ts
Normal file
268
src/config/types.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import type {
|
||||
GardenAudioConfig,
|
||||
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';
|
||||
import type { RenderSettings } from '../pipelines/render/render-pipeline';
|
||||
import type { RgbColor } from '../utils/rgb-color';
|
||||
|
||||
export interface NumberControlConfig {
|
||||
format?: (value: number) => string;
|
||||
folder: string;
|
||||
integer?: boolean;
|
||||
label?: string;
|
||||
max?: number;
|
||||
min?: number;
|
||||
options?: Record<string, number>;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export type GardenRuntimeSettings = {
|
||||
adaptiveCapInitial: number;
|
||||
adaptiveCapMin: number;
|
||||
backgroundGrainStrength: number;
|
||||
brushCurveResolution: number;
|
||||
brushCurveMinBrushRadius: number;
|
||||
brushCurveMinSegmentSpacing: number;
|
||||
brushCurveMirrorResolutionExponent: number;
|
||||
brushCurveSegmentBrushRadiusRatio: number;
|
||||
brushEffectDuration: number;
|
||||
brushSmoothingMinSampleDistance: number;
|
||||
eraserClearAlpha: number;
|
||||
eraserClearBlue: number;
|
||||
eraserClearGreen: number;
|
||||
eraserClearRed: number;
|
||||
eraserLineDistanceEpsilon: number;
|
||||
eraserMaskAlphaThreshold: number;
|
||||
eraserSize: number;
|
||||
internalRenderAreaMegapixels: number;
|
||||
mirrorSegmentCount: number;
|
||||
maxAgentCount: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
strokeAngleJitterRadians: number;
|
||||
} & AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
RenderSettings;
|
||||
|
||||
type RuntimeSettingControlConfig = Partial<
|
||||
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
||||
>;
|
||||
|
||||
export type GardenVibeSettings = Pick<
|
||||
GardenRuntimeSettings,
|
||||
| 'backgroundGrainStrength'
|
||||
| 'brushSize'
|
||||
| 'clarity'
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
| 'decayRateTrails'
|
||||
| 'forwardRotationScale'
|
||||
| 'individualTrailWeight'
|
||||
| 'moveSpeed'
|
||||
| 'sensorOffsetAngle'
|
||||
| 'sensorOffsetDistance'
|
||||
| 'spawnPerPixel'
|
||||
| 'strokeAngleJitterRadians'
|
||||
| 'turnSpeed'
|
||||
| 'turnWhenLost'
|
||||
>;
|
||||
|
||||
type GardenDefaultSettings = Omit<
|
||||
GardenRuntimeSettings,
|
||||
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
|
||||
>;
|
||||
|
||||
export enum VibeId {
|
||||
AuroraMycelium = 'aurora-mycelium',
|
||||
VelvetObservatory = 'velvet-observatory',
|
||||
LichenSignal = 'lichen-signal',
|
||||
TidepoolLantern = 'tidepool-lantern',
|
||||
PaperLanternFog = 'paper-lantern-fog',
|
||||
ChromePollen = 'chrome-pollen',
|
||||
}
|
||||
|
||||
export interface VibePreset {
|
||||
id: VibeId;
|
||||
name: string;
|
||||
colors: [RgbColor, RgbColor, RgbColor];
|
||||
backgroundColor: RgbColor;
|
||||
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<number, string>;
|
||||
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<VibePreset>;
|
||||
};
|
||||
}
|
||||
81
src/config/vibe-presets.test.ts
Normal file
81
src/config/vibe-presets.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runtimeControls } from './runtime-controls';
|
||||
import { vibePresets } from './vibe-presets';
|
||||
|
||||
const FINAL_VIBE_NAMES = [
|
||||
'Aurora Mycelium Copy',
|
||||
'Velvet Observatory Copy',
|
||||
'Lichen Signal',
|
||||
'Tidepool Lantern',
|
||||
'Paper Lantern Fog',
|
||||
'Chrome Pollen',
|
||||
];
|
||||
|
||||
const BLENDED_BRUSH_SIZE_MIN = 17;
|
||||
const BLENDED_CLARITY_MAX = 0.56;
|
||||
const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
|
||||
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
|
||||
|
||||
// Performance guardrails — bumping any of these is an explicit perf trade-off.
|
||||
const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38;
|
||||
const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36;
|
||||
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
|
||||
const HIGH_DENSITY_DECAY_LIMIT = 940;
|
||||
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
|
||||
const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055;
|
||||
|
||||
describe('vibePresets', () => {
|
||||
it('keeps the classic preset set distinct', () => {
|
||||
expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES);
|
||||
|
||||
const ids = vibePresets.map((preset) => preset.id);
|
||||
expect(new Set(ids).size).toBe(vibePresets.length);
|
||||
});
|
||||
|
||||
it('includes both blended and visibly particulate styles', () => {
|
||||
const blendedNames = vibePresets
|
||||
.filter(
|
||||
(preset) =>
|
||||
preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN &&
|
||||
preset.settings.clarity <= BLENDED_CLARITY_MAX
|
||||
)
|
||||
.map((preset) => preset.name);
|
||||
const softParticleNames = vibePresets
|
||||
.filter(
|
||||
(preset) =>
|
||||
preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX &&
|
||||
preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX
|
||||
)
|
||||
.map((preset) => preset.name);
|
||||
|
||||
expect(blendedNames).toEqual(['Tidepool Lantern']);
|
||||
expect(softParticleNames).toEqual(['Chrome Pollen']);
|
||||
});
|
||||
|
||||
it('stays inside interactive performance guardrails', () => {
|
||||
const violations = vibePresets.flatMap((preset) => {
|
||||
const { name, settings } = preset;
|
||||
const presetViolations: Array<string> = [];
|
||||
|
||||
if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) {
|
||||
presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`);
|
||||
}
|
||||
if (settings.brushSize > MAX_BRUSH_SIZE) {
|
||||
presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`);
|
||||
}
|
||||
if (
|
||||
settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD &&
|
||||
(settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT ||
|
||||
settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT ||
|
||||
settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT)
|
||||
) {
|
||||
presetViolations.push(`${name} combines high density with too much persistence`);
|
||||
}
|
||||
|
||||
return presetViolations;
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
366
src/config/vibe-presets.ts
Normal file
366
src/config/vibe-presets.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import {
|
||||
defaultGardenAudioVibeSettings,
|
||||
type GardenAudioChord,
|
||||
} from '../audio/garden-audio-config';
|
||||
import { VibeId, type GardenVibeSettings, type VibePreset } from './types';
|
||||
|
||||
type ColorReactionSettings = Pick<
|
||||
GardenVibeSettings,
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
>;
|
||||
|
||||
const colorReactions = {
|
||||
auroraMycelium: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 0,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 0,
|
||||
color3ToColor1: -1,
|
||||
color3ToColor2: -1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
velvetObservatory: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: -1,
|
||||
color1ToColor3: -1,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: -1,
|
||||
color3ToColor1: -1,
|
||||
color3ToColor2: -1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
lichenSignal: {
|
||||
color1ToColor1: 0,
|
||||
color1ToColor2: -1,
|
||||
color1ToColor3: 1,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 0,
|
||||
color2ToColor3: -1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: -1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
tidepoolLantern: {
|
||||
color1ToColor1: 0,
|
||||
color1ToColor2: 1,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: 0,
|
||||
color2ToColor2: 0,
|
||||
color2ToColor3: 1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 0,
|
||||
},
|
||||
paperLanternFog: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 1,
|
||||
color1ToColor3: 1,
|
||||
color2ToColor1: 1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
chromePollen: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 0,
|
||||
color1ToColor3: 1,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 0,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
} satisfies Record<string, ColorReactionSettings>;
|
||||
|
||||
const musicScales = {
|
||||
dorian: [0, 2, 3, 5, 7, 9, 10],
|
||||
lydian: [0, 2, 4, 6, 7, 9, 11],
|
||||
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
||||
naturalMinor: [0, 2, 3, 5, 7, 8, 10],
|
||||
} satisfies Record<string, Array<number>>;
|
||||
|
||||
const musicProgressions = {
|
||||
aurora: [
|
||||
{ rootOffset: 0, quality: 'sus2' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'sus4' },
|
||||
],
|
||||
chrome: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 2, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'sus2' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
],
|
||||
lichen: [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
],
|
||||
paperLantern: [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'minor' },
|
||||
{ rootOffset: 10, quality: 'sus4' },
|
||||
],
|
||||
tidepool: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'sus2' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
],
|
||||
velvet: [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'sus4' },
|
||||
],
|
||||
} satisfies Record<string, Array<GardenAudioChord>>;
|
||||
|
||||
export const defaultVibeId = VibeId.AuroraMycelium;
|
||||
|
||||
export const vibePresets: Array<VibePreset> = [
|
||||
{
|
||||
id: VibeId.AuroraMycelium,
|
||||
name: 'Aurora Mycelium Copy',
|
||||
colors: [
|
||||
[251, 210, 94],
|
||||
[154, 99, 255],
|
||||
[255, 31, 199],
|
||||
],
|
||||
backgroundColor: [6, 13, 22],
|
||||
settings: {
|
||||
...colorReactions.auroraMycelium,
|
||||
backgroundGrainStrength: 0.003,
|
||||
brushSize: 8.75,
|
||||
clarity: 1,
|
||||
decayRateTrails: 973,
|
||||
forwardRotationScale: 0.37,
|
||||
individualTrailWeight: 0.053000000000000005,
|
||||
moveSpeed: 144,
|
||||
sensorOffsetAngle: 35,
|
||||
sensorOffsetDistance: 52,
|
||||
spawnPerPixel: 0.13999999999999999,
|
||||
strokeAngleJitterRadians: 0.45,
|
||||
turnSpeed: 13,
|
||||
turnWhenLost: 0,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.12000000000000002,
|
||||
bpm: 60,
|
||||
rampUpIntensity: 0.7,
|
||||
rampUpTime: 0.14,
|
||||
noteLength: 0.8599999999999999,
|
||||
notePitchOffset: -2,
|
||||
brightness: 0.84,
|
||||
scale: musicScales.lydian,
|
||||
progression: musicProgressions.aurora,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.VelvetObservatory,
|
||||
name: 'Velvet Observatory Copy',
|
||||
colors: [
|
||||
[178, 76, 62],
|
||||
[2, 174, 255],
|
||||
[213, 193, 9],
|
||||
],
|
||||
backgroundColor: [7, 4, 22],
|
||||
settings: {
|
||||
...colorReactions.velvetObservatory,
|
||||
backgroundGrainStrength: 0.005,
|
||||
brushSize: 9.75,
|
||||
clarity: 1,
|
||||
decayRateTrails: 974,
|
||||
forwardRotationScale: 0,
|
||||
individualTrailWeight: 0.232,
|
||||
moveSpeed: 121,
|
||||
sensorOffsetAngle: 24,
|
||||
sensorOffsetDistance: 17,
|
||||
spawnPerPixel: 0.11499999999999999,
|
||||
strokeAngleJitterRadians: 0.17,
|
||||
turnSpeed: 33,
|
||||
turnWhenLost: 0.42,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.24000000000000002,
|
||||
bpm: 72,
|
||||
rampUpIntensity: 1.42,
|
||||
rampUpTime: 0.07,
|
||||
noteLength: 0.7,
|
||||
notePitchOffset: 0,
|
||||
brightness: 0.94,
|
||||
scale: musicScales.naturalMinor,
|
||||
progression: musicProgressions.velvet,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.LichenSignal,
|
||||
name: 'Lichen Signal',
|
||||
colors: [
|
||||
[183, 216, 92],
|
||||
[65, 166, 128],
|
||||
[238, 120, 76],
|
||||
],
|
||||
backgroundColor: [0, 0, 0],
|
||||
settings: {
|
||||
...colorReactions.lichenSignal,
|
||||
backgroundGrainStrength: 0.02,
|
||||
brushSize: 6.5,
|
||||
clarity: 0.74,
|
||||
decayRateTrails: 962,
|
||||
forwardRotationScale: 0.3,
|
||||
individualTrailWeight: 0.052,
|
||||
moveSpeed: 72,
|
||||
sensorOffsetAngle: 42,
|
||||
sensorOffsetDistance: 54,
|
||||
spawnPerPixel: 0.16,
|
||||
strokeAngleJitterRadians: 3.14,
|
||||
turnSpeed: 44,
|
||||
turnWhenLost: 0.92,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.13,
|
||||
bpm: 68,
|
||||
rampUpIntensity: 1.46,
|
||||
rampUpTime: 0.1,
|
||||
noteLength: 0.6,
|
||||
notePitchOffset: -3,
|
||||
brightness: 1.21,
|
||||
scale: musicScales.dorian,
|
||||
progression: musicProgressions.lichen,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.TidepoolLantern,
|
||||
name: 'Tidepool Lantern',
|
||||
colors: [
|
||||
[30, 219, 194],
|
||||
[61, 118, 255],
|
||||
[255, 191, 91],
|
||||
],
|
||||
backgroundColor: [4, 18, 29],
|
||||
settings: {
|
||||
...colorReactions.tidepoolLantern,
|
||||
backgroundGrainStrength: 0.018,
|
||||
brushSize: 17,
|
||||
clarity: 0.56,
|
||||
decayRateTrails: 968,
|
||||
forwardRotationScale: 0.38,
|
||||
individualTrailWeight: 0.06,
|
||||
moveSpeed: 88,
|
||||
sensorOffsetAngle: 64,
|
||||
sensorOffsetDistance: 46,
|
||||
spawnPerPixel: 0.22,
|
||||
strokeAngleJitterRadians: 1.8,
|
||||
turnSpeed: 66,
|
||||
turnWhenLost: 1.05,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.08,
|
||||
bpm: 84,
|
||||
rampUpIntensity: 0.95,
|
||||
rampUpTime: 0.08,
|
||||
noteLength: 0.46,
|
||||
notePitchOffset: 0,
|
||||
brightness: 0.98,
|
||||
scale: musicScales.mixolydian,
|
||||
progression: musicProgressions.tidepool,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.PaperLanternFog,
|
||||
name: 'Paper Lantern Fog',
|
||||
colors: [
|
||||
[255, 176, 108],
|
||||
[239, 90, 108],
|
||||
[128, 213, 184],
|
||||
],
|
||||
backgroundColor: [30, 23, 20],
|
||||
settings: {
|
||||
...colorReactions.paperLanternFog,
|
||||
backgroundGrainStrength: 0.038,
|
||||
brushSize: 3.5,
|
||||
clarity: 1,
|
||||
decayRateTrails: 999,
|
||||
forwardRotationScale: 0.24,
|
||||
individualTrailWeight: 0.937,
|
||||
moveSpeed: 28,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 66,
|
||||
spawnPerPixel: 0.055,
|
||||
strokeAngleJitterRadians: 0,
|
||||
turnSpeed: 30,
|
||||
turnWhenLost: 1.52,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.33,
|
||||
bpm: 127,
|
||||
rampUpIntensity: 0.66,
|
||||
rampUpTime: 0.03,
|
||||
noteLength: 0.92,
|
||||
notePitchOffset: 10,
|
||||
brightness: 1.42,
|
||||
scale: musicScales.naturalMinor,
|
||||
progression: musicProgressions.paperLantern,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.ChromePollen,
|
||||
name: 'Chrome Pollen',
|
||||
colors: [
|
||||
[178, 34, 34],
|
||||
[255, 214, 48],
|
||||
[77, 240, 157],
|
||||
],
|
||||
backgroundColor: [7, 12, 11],
|
||||
settings: {
|
||||
...colorReactions.chromePollen,
|
||||
backgroundGrainStrength: 0.012,
|
||||
brushSize: 4.5,
|
||||
clarity: 0.1,
|
||||
decayRateTrails: 922,
|
||||
forwardRotationScale: 0.5,
|
||||
individualTrailWeight: 0.026,
|
||||
moveSpeed: 86,
|
||||
sensorOffsetAngle: 46,
|
||||
sensorOffsetDistance: 14,
|
||||
spawnPerPixel: 0.36,
|
||||
strokeAngleJitterRadians: 3,
|
||||
turnSpeed: 34,
|
||||
turnWhenLost: 1.35,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.11,
|
||||
bpm: 150,
|
||||
rampUpIntensity: 2,
|
||||
rampUpTime: 0.06,
|
||||
noteLength: 1.8,
|
||||
notePitchOffset: -12,
|
||||
brightness: 0.5,
|
||||
scale: musicScales.lydian,
|
||||
progression: musicProgressions.chrome,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const isProduction: boolean = import.meta.env.PROD;
|
||||
111
src/settings.ts
111
src/settings.ts
|
|
@ -1,54 +1,77 @@
|
|||
import { GameLoopSettings } from './game-loop/game-loop-settings';
|
||||
import { AgentSettings } from './pipelines/agents/agent-settings';
|
||||
import { BrushSettings } from './pipelines/brush/brush-settings';
|
||||
import { DiffusionSettings } from './pipelines/diffusion/diffusion-settings';
|
||||
import { RenderSettings } from './pipelines/render/render-settings';
|
||||
import { persist } from './utils/persist';
|
||||
import {
|
||||
appConfig,
|
||||
normalizeRuntimeSettings,
|
||||
type GardenRuntimeSettings,
|
||||
} from './config';
|
||||
import { writeBrowserStorage } from './utils/browser-storage';
|
||||
import { getInitialVibe, type VibePreset } from './vibes';
|
||||
|
||||
const initialValues: GameLoopSettings &
|
||||
AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
RenderSettings = {
|
||||
agentCount: 1_001_500,
|
||||
const preservedRuntimeSettingKeys = [
|
||||
'eraserSize',
|
||||
'adaptiveCapInitial',
|
||||
'adaptiveCapMin',
|
||||
'internalRenderAreaMegapixels',
|
||||
'maxAgentCount',
|
||||
'mirrorSegmentCount',
|
||||
] satisfies ReadonlyArray<keyof GardenRuntimeSettings>;
|
||||
|
||||
currentGenerationAggression: -5,
|
||||
nextGenerationAggression: 0.2,
|
||||
const cloneRgbColor = <T extends [number, number, number]>(color: T): T =>
|
||||
[...color] as T;
|
||||
|
||||
moveSpeed: 74,
|
||||
turnSpeed: 45,
|
||||
sensorOffsetAngle: 31,
|
||||
sensorOffsetDistance: 43,
|
||||
turnWhenLost: 0.01,
|
||||
const cloneVibeAudio = (audio: VibePreset['audio']): VibePreset['audio'] => ({
|
||||
...audio,
|
||||
...(audio.scale ? { scale: [...audio.scale] } : {}),
|
||||
...(audio.progression
|
||||
? { progression: audio.progression.map((chord) => ({ ...chord })) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
brushTrailWeight: 500,
|
||||
individualTrailWeight: 0.05,
|
||||
const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
|
||||
...vibe,
|
||||
colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
|
||||
backgroundColor: cloneRgbColor(vibe.backgroundColor),
|
||||
settings: { ...vibe.settings },
|
||||
audio: cloneVibeAudio(vibe.audio),
|
||||
});
|
||||
|
||||
diffusionRateTrails: 0,
|
||||
decayRateTrails: 944,
|
||||
diffusionRateBrush: 0.35,
|
||||
decayRateBrush: 18,
|
||||
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
|
||||
normalizeRuntimeSettings(
|
||||
{
|
||||
...appConfig.defaultSettings,
|
||||
eraserSize: appConfig.toolbar.eraser.default,
|
||||
mirrorSegmentCount: appConfig.toolbar.mirror.default,
|
||||
...vibe.settings,
|
||||
},
|
||||
appConfig.runtimeSettings.controls
|
||||
);
|
||||
|
||||
clarity: 0.7,
|
||||
brushSize: 12,
|
||||
export let activeVibe = cloneVibePreset(getInitialVibe());
|
||||
|
||||
brushSizeVariation: 0.5, // hidden on the UI
|
||||
|
||||
startColorHue: 200,
|
||||
|
||||
maxAgentCountUpperLimit: Number.POSITIVE_INFINITY, // requires restart
|
||||
|
||||
// debug options
|
||||
renderSpeed: 1,
|
||||
simulatedDelayMs: 0,
|
||||
export const settings: GardenRuntimeSettings = {
|
||||
...buildSettings(activeVibe),
|
||||
};
|
||||
|
||||
export const settings: { [key: string]: number } & GameLoopSettings &
|
||||
AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
RenderSettings = persist({ ...initialValues });
|
||||
|
||||
export const resetSettings = () => {
|
||||
Object.assign(settings, initialValues);
|
||||
export const rememberActiveVibeSelection = (): void => {
|
||||
writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
|
||||
};
|
||||
|
||||
export const applyVibeSettings = (vibe: VibePreset) => {
|
||||
activeVibe = cloneVibePreset(vibe);
|
||||
const nextSettings = buildSettings(activeVibe);
|
||||
preservedRuntimeSettingKeys.forEach((key) => {
|
||||
nextSettings[key] = settings[key];
|
||||
});
|
||||
nextSettings.selectedColorIndex = Math.min(
|
||||
settings.selectedColorIndex,
|
||||
activeVibe.colors.length - 1
|
||||
);
|
||||
|
||||
Object.assign(
|
||||
settings,
|
||||
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
|
||||
);
|
||||
|
||||
rememberActiveVibeSelection();
|
||||
|
||||
return activeVibe;
|
||||
};
|
||||
|
|
|
|||
18
src/utils/browser-storage.ts
Normal file
18
src/utils/browser-storage.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export const readBrowserStorage = (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeBrowserStorage = (key: string, value: string): void => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Storage can be unavailable in private browsing or embedded contexts.',
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
export const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export const exponentialDecay = ({
|
||||
accumulator,
|
||||
nextValue,
|
||||
biasOfNextValue,
|
||||
}: {
|
||||
accumulator: number;
|
||||
nextValue: number;
|
||||
biasOfNextValue: number;
|
||||
}) => accumulator * (1 - biasOfNextValue) + nextValue * biasOfNextValue;
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatNumber } from './format-number';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('renders integers without decimals', () => {
|
||||
expect(formatNumber(42)).toBe('42 ');
|
||||
});
|
||||
it('renders fractional values with two decimals', () => {
|
||||
expect(formatNumber(3.14159)).toBe('3.14 ');
|
||||
});
|
||||
it('renders thousands compactly', () => {
|
||||
expect(formatNumber(2500)).toBe('2.5 thousand ');
|
||||
});
|
||||
it('renders millions compactly', () => {
|
||||
expect(formatNumber(1_500_000)).toBe('1.5 million ');
|
||||
});
|
||||
it('appends the unit when provided', () => {
|
||||
expect(formatNumber(5, 'agents')).toBe('5 agents');
|
||||
expect(formatNumber(2_000_000, 'agents')).toBe('2.0 million agents');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export const formatNumber = (value: number, unit = ''): string => {
|
||||
if (value >= 1e6) {
|
||||
return `${(value / 1e6).toFixed(1)} million ${unit}`;
|
||||
}
|
||||
|
||||
if (value >= 1e3) {
|
||||
return `${(value / 1e3).toFixed(1)} thousand ${unit}`;
|
||||
}
|
||||
|
||||
return `${value === Math.floor(value) ? value : value.toFixed(2)} ${unit}`;
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { hsl } from './hsl';
|
||||
|
||||
describe('hsl', () => {
|
||||
it('produces pure red at hue 0', () => {
|
||||
const [r, g, b] = hsl(0, 100, 50);
|
||||
expect(r).toBeCloseTo(1);
|
||||
expect(g).toBeCloseTo(0);
|
||||
expect(b).toBeCloseTo(0);
|
||||
});
|
||||
it('produces pure green at hue 120', () => {
|
||||
const [r, g, b] = hsl(120, 100, 50);
|
||||
expect(r).toBeCloseTo(0);
|
||||
expect(g).toBeCloseTo(1);
|
||||
expect(b).toBeCloseTo(0);
|
||||
});
|
||||
it('produces pure blue at hue 240', () => {
|
||||
const [r, g, b] = hsl(240, 100, 50);
|
||||
expect(r).toBeCloseTo(0);
|
||||
expect(g).toBeCloseTo(0);
|
||||
expect(b).toBeCloseTo(1);
|
||||
});
|
||||
it('produces gray at saturation 0', () => {
|
||||
const [r, g, b] = hsl(180, 0, 50);
|
||||
expect(r).toBeCloseTo(0.5);
|
||||
expect(g).toBeCloseTo(0.5);
|
||||
expect(b).toBeCloseTo(0.5);
|
||||
});
|
||||
it('produces black at lightness 0', () => {
|
||||
const [r, g, b] = hsl(0, 100, 0);
|
||||
expect(r).toBe(0);
|
||||
expect(g).toBe(0);
|
||||
expect(b).toBe(0);
|
||||
});
|
||||
it('produces white at lightness 100', () => {
|
||||
const [r, g, b] = hsl(0, 100, 100);
|
||||
expect(r).toBeCloseTo(1);
|
||||
expect(g).toBeCloseTo(1);
|
||||
expect(b).toBeCloseTo(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
import { rgb } from './rgb';
|
||||
|
||||
export const hsl = (hue: number, saturation: number, lightness: number): vec3 => {
|
||||
hue /= 360;
|
||||
saturation /= 100;
|
||||
lightness /= 100;
|
||||
let r: number, g: number, b: number;
|
||||
|
||||
if (saturation == 0) {
|
||||
r = g = b = lightness;
|
||||
} else {
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q =
|
||||
lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
|
||||
r = hue2rgb(p, q, hue + 1 / 3);
|
||||
g = hue2rgb(p, q, hue);
|
||||
b = hue2rgb(p, q, hue - 1 / 3);
|
||||
}
|
||||
|
||||
return rgb(r, g, b);
|
||||
};
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { clamp, clamp01 } from './clamp';
|
||||
import { exponentialDecay } from './exponential-decay';
|
||||
import { mix } from './mix';
|
||||
|
||||
describe('clamp', () => {
|
||||
it('returns value when within bounds', () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
});
|
||||
it('clamps below to lower bound', () => {
|
||||
expect(clamp(-3, 0, 10)).toBe(0);
|
||||
});
|
||||
it('clamps above to upper bound', () => {
|
||||
expect(clamp(42, 0, 10)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clamp01', () => {
|
||||
it('passes through values in [0, 1]', () => {
|
||||
expect(clamp01(0.25)).toBe(0.25);
|
||||
});
|
||||
it('clamps negatives to 0', () => {
|
||||
expect(clamp01(-1)).toBe(0);
|
||||
});
|
||||
it('clamps above 1 to 1', () => {
|
||||
expect(clamp01(2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mix', () => {
|
||||
it('returns from at q=0', () => {
|
||||
expect(mix(10, 20, 0)).toBe(10);
|
||||
});
|
||||
it('returns to at q=1', () => {
|
||||
expect(mix(10, 20, 1)).toBe(20);
|
||||
});
|
||||
it('interpolates at q=0.5', () => {
|
||||
expect(mix(10, 20, 0.5)).toBe(15);
|
||||
});
|
||||
it('extrapolates outside [0, 1]', () => {
|
||||
expect(mix(0, 10, 2)).toBe(20);
|
||||
expect(mix(0, 10, -1)).toBe(-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exponentialDecay', () => {
|
||||
it('returns nextValue when bias is 1', () => {
|
||||
expect(exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 1 })).toBe(
|
||||
10
|
||||
);
|
||||
});
|
||||
it('returns accumulator when bias is 0', () => {
|
||||
expect(exponentialDecay({ accumulator: 5, nextValue: 10, biasOfNextValue: 0 })).toBe(
|
||||
5
|
||||
);
|
||||
});
|
||||
it('blends with given bias', () => {
|
||||
expect(
|
||||
exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 0.25 })
|
||||
).toBe(2.5);
|
||||
});
|
||||
});
|
||||
29
src/utils/math.ts
Normal file
29
src/utils/math.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
export const clamp01 = (value: number): number => clamp(value, 0, 1);
|
||||
|
||||
export const mix = (from: number, to: number, amount: number): number =>
|
||||
from + (to - from) * amount;
|
||||
|
||||
export const mixAngle = (from: number, to: number, amount: number): number => {
|
||||
const delta = Math.atan2(Math.sin(to - from), Math.cos(to - from));
|
||||
return from + delta * amount;
|
||||
};
|
||||
|
||||
export const approach = (
|
||||
current: number,
|
||||
target: number,
|
||||
elapsedSeconds: number,
|
||||
timeConstantSeconds: number
|
||||
): number => {
|
||||
const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds));
|
||||
return mix(current, target, amount);
|
||||
};
|
||||
|
||||
export const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
||||
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
||||
return amount * amount * (3 - 2 * amount);
|
||||
};
|
||||
|
||||
export const easeOutQuad = (value: number): number => value * (2 - value);
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const mix = (from: number, to: number, q: number) => from + (to - from) * q;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
export const persist = <T extends Record<string, number>>(wrapee: T): T => {
|
||||
const keys = Object.keys(wrapee);
|
||||
keys.sort();
|
||||
|
||||
const keysToShortKeys = Object.fromEntries(keys.map((key) => [key, key]));
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const newParams = new URLSearchParams();
|
||||
keys.forEach((key) => {
|
||||
if (params.has(keysToShortKeys[key])) {
|
||||
(wrapee as any)[key] = Number(params.get(keysToShortKeys[key]));
|
||||
newParams.set(keysToShortKeys[key], params.get(keysToShortKeys[key])!);
|
||||
}
|
||||
});
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
`${window.location.pathname}?${newParams.toString()}`
|
||||
);
|
||||
|
||||
return new Proxy(wrapee, {
|
||||
set: (target, key: string, value: number) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
params.set(keysToShortKeys[key], value.toString());
|
||||
|
||||
(target as any)[key] = value;
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
`${window.location.pathname}?${params.toString()}`
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { Random } from './random';
|
||||
|
||||
describe('Random', () => {
|
||||
beforeEach(() => {
|
||||
Random.seed = 42;
|
||||
});
|
||||
|
||||
it('produces values in [0, 1)', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const v = Random.getRandom();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('is deterministic for the same seed', () => {
|
||||
Random.seed = 42;
|
||||
const a = Array.from({ length: 8 }, () => Random.getRandom());
|
||||
Random.seed = 42;
|
||||
const b = Array.from({ length: 8 }, () => Random.getRandom());
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('produces different sequences for different seeds', () => {
|
||||
Random.seed = 1;
|
||||
const a = Array.from({ length: 4 }, () => Random.getRandom());
|
||||
Random.seed = 2;
|
||||
const b = Array.from({ length: 4 }, () => Random.getRandom());
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it('randomBetween stays within [from, to)', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const v = Random.randomBetween(-10, 10);
|
||||
expect(v).toBeGreaterThanOrEqual(-10);
|
||||
expect(v).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript, Mulberry32
|
||||
export abstract class Random {
|
||||
private static _seed = 42;
|
||||
|
||||
public static set seed(value: number) {
|
||||
Random._seed = value;
|
||||
}
|
||||
|
||||
public static getRandomInt(): number {
|
||||
let t = (Random._seed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
}
|
||||
|
||||
public static getRandom(): number {
|
||||
return Random.getRandomInt() / 4294967296;
|
||||
}
|
||||
|
||||
public static randomBetween(from: number, to: number): number {
|
||||
return from + Random.getRandom() * (to - from);
|
||||
}
|
||||
}
|
||||
42
src/utils/rgb-color.ts
Normal file
42
src/utils/rgb-color.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export type RgbColor = [red: number, green: number, blue: number];
|
||||
|
||||
const RGB_CHANNEL_MAX = 255;
|
||||
|
||||
const toFiniteRgbChannel = (value: number): number =>
|
||||
Number.isFinite(value) ? value : 0;
|
||||
|
||||
const clampRgbChannel = (value: number): number =>
|
||||
Math.min(RGB_CHANNEL_MAX, Math.max(0, Math.round(toFiniteRgbChannel(value))));
|
||||
|
||||
export const rgbColorToCss = ([red, green, blue]: RgbColor): string =>
|
||||
`rgb(${clampRgbChannel(red)}, ${clampRgbChannel(green)}, ${clampRgbChannel(blue)})`;
|
||||
|
||||
export const rgbColorToHex = ([red, green, blue]: RgbColor): string =>
|
||||
`#${[red, green, blue]
|
||||
.map((channel) => clampRgbChannel(channel).toString(16).padStart(2, '0'))
|
||||
.join('')}`;
|
||||
|
||||
export const hexColorToRgbColor = (value: string): RgbColor | null => {
|
||||
const match = value.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shorthandOrHex = match[1];
|
||||
const hex =
|
||||
shorthandOrHex.length === 3
|
||||
? shorthandOrHex
|
||||
.split('')
|
||||
.map((channel) => `${channel}${channel}`)
|
||||
.join('')
|
||||
: shorthandOrHex;
|
||||
|
||||
return [
|
||||
Number.parseInt(hex.slice(0, 2), 16),
|
||||
Number.parseInt(hex.slice(2, 4), 16),
|
||||
Number.parseInt(hex.slice(4, 6), 16),
|
||||
];
|
||||
};
|
||||
|
||||
export const rgbChannelToUnit = (value: number): number =>
|
||||
Math.min(1, Math.max(0, toFiniteRgbChannel(value) / RGB_CHANNEL_MAX));
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const sleep = (ms: number): Promise<void> => {
|
||||
return new Promise<void>((resolve, _) => setTimeout(resolve, ms));
|
||||
};
|
||||
7
src/vibe-registry.ts
Normal file
7
src/vibe-registry.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { appConfig } from './config';
|
||||
import type { VibeId, VibePreset } from './config/types';
|
||||
|
||||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||
|
||||
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
|
||||
VIBE_PRESETS.find((vibe) => vibe.id === vibeId);
|
||||
54
src/vibe-uri.test.ts
Normal file
54
src/vibe-uri.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { VibeId } from './config/types';
|
||||
import { createVibeUri, getVibeIdFromUri } from './vibe-uri';
|
||||
|
||||
describe('vibe URI handling', () => {
|
||||
it('loads vibes from slug IDs and display names', () => {
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=aurora-mycelium')).toBe(
|
||||
VibeId.AuroraMycelium
|
||||
);
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
|
||||
VibeId.AuroraMycelium
|
||||
);
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe(
|
||||
VibeId.VelvetObservatory
|
||||
);
|
||||
});
|
||||
|
||||
it('uses query values before path or hash fallbacks', () => {
|
||||
expect(
|
||||
getVibeIdFromUri(
|
||||
'https://example.test/chrome-pollen?vibe=lichen-signal#vibe=aurora-mycelium'
|
||||
)
|
||||
).toBe(VibeId.LichenSignal);
|
||||
});
|
||||
|
||||
it('accepts explicit path segments and hash fallbacks', () => {
|
||||
expect(getVibeIdFromUri('https://example.test/vibes/tidepool-lantern')).toBe(
|
||||
VibeId.TidepoolLantern
|
||||
);
|
||||
expect(getVibeIdFromUri('https://example.test/#paper-lantern-fog')).toBe(
|
||||
VibeId.PaperLanternFog
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores unknown or malformed vibe values', () => {
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=missing')).toBeNull();
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=%E0%A4%A')).toBeNull();
|
||||
expect(getVibeIdFromUri('not a url')).toBeNull();
|
||||
});
|
||||
|
||||
it('creates a canonical query URI without dropping other URL parts', () => {
|
||||
expect(
|
||||
createVibeUri('https://example.test/garden?debug=1#panel', VibeId.ChromePollen)
|
||||
).toBe('/garden?debug=1&vibe=chrome-pollen#panel');
|
||||
|
||||
expect(
|
||||
createVibeUri(
|
||||
'https://example.test/garden?vibe=aurora-mycelium&debug=1',
|
||||
VibeId.LichenSignal
|
||||
)
|
||||
).toBe('/garden?vibe=lichen-signal&debug=1');
|
||||
});
|
||||
});
|
||||
148
src/vibe-uri.ts
Normal file
148
src/vibe-uri.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import type { VibeId } from './config/types';
|
||||
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
|
||||
|
||||
const VIBE_URI_QUERY_PARAM = 'vibe';
|
||||
const FALLBACK_URL_ORIGIN = 'https://fleeting.garden';
|
||||
|
||||
const slugifyVibeName = (value: string): string =>
|
||||
value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const safeDecodeURIComponent = (value: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeVibeIdentifier = (value: string): string =>
|
||||
slugifyVibeName(safeDecodeURIComponent(value).replace(/^[#/\\?\s]+|[/\\?\s]+$/g, ''));
|
||||
|
||||
const vibeIdByIdentifier = new Map<string, VibeId>();
|
||||
|
||||
for (const vibe of VIBE_PRESETS) {
|
||||
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.id), vibe.id);
|
||||
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.name), vibe.id);
|
||||
}
|
||||
|
||||
const toUrl = (url: string | URL): URL | null => {
|
||||
try {
|
||||
return new URL(url, FALLBACK_URL_ORIGIN);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveVibeId = (value: string | null | undefined): VibeId | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return vibeIdByIdentifier.get(normalizeVibeIdentifier(value)) ?? null;
|
||||
};
|
||||
|
||||
const getHashSearchParam = (hash: string): string | null => {
|
||||
const hashValue = hash.replace(/^#/, '');
|
||||
if (!hashValue.includes('=')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchText = hashValue.startsWith('?') ? hashValue.slice(1) : hashValue;
|
||||
try {
|
||||
return new URLSearchParams(searchText).get(VIBE_URI_QUERY_PARAM);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getPathVibeCandidates = (pathname: string): Array<string> => {
|
||||
const segments = pathname.split('/').map(safeDecodeURIComponent).filter(Boolean);
|
||||
const explicitVibeIndex = segments.findIndex((segment) =>
|
||||
['vibe', 'vibes'].includes(segment.toLowerCase())
|
||||
);
|
||||
|
||||
return [
|
||||
explicitVibeIndex >= 0 ? segments[explicitVibeIndex + 1] : undefined,
|
||||
segments.at(-1),
|
||||
].filter((candidate): candidate is string => typeof candidate === 'string');
|
||||
};
|
||||
|
||||
export const getVibeIdFromUri = (url: string | URL): VibeId | null => {
|
||||
const parsedUrl = toUrl(url);
|
||||
if (!parsedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
parsedUrl.searchParams.get(VIBE_URI_QUERY_PARAM),
|
||||
getHashSearchParam(parsedUrl.hash),
|
||||
...getPathVibeCandidates(parsedUrl.pathname),
|
||||
parsedUrl.hash.replace(/^#/, ''),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const vibeId = resolveVibeId(candidate);
|
||||
if (vibeId) {
|
||||
return vibeId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCurrentUriVibeId = (): VibeId | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getVibeIdFromUri(window.location.href);
|
||||
};
|
||||
|
||||
const getVibeSlug = (vibeId: VibeId): string => {
|
||||
const vibe = getVibeById(vibeId);
|
||||
return vibe ? vibe.id : vibeId;
|
||||
};
|
||||
|
||||
export const createVibeUri = (url: string | URL, vibeId: VibeId): string => {
|
||||
const parsedUrl = toUrl(url);
|
||||
if (!parsedUrl) {
|
||||
return `?${VIBE_URI_QUERY_PARAM}=${encodeURIComponent(getVibeSlug(vibeId))}`;
|
||||
}
|
||||
|
||||
parsedUrl.searchParams.set(VIBE_URI_QUERY_PARAM, getVibeSlug(vibeId));
|
||||
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||
};
|
||||
|
||||
export const writeCurrentVibeUri = (
|
||||
vibeId: VibeId,
|
||||
mode: 'push' | 'replace' = 'replace'
|
||||
): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUri = createVibeUri(window.location.href, vibeId);
|
||||
const currentUri = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (nextUri === currentUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState =
|
||||
typeof window.history.state === 'object' && window.history.state !== null
|
||||
? { ...window.history.state, vibeId }
|
||||
: { vibeId };
|
||||
|
||||
if (mode === 'push') {
|
||||
window.history.pushState(nextState, '', nextUri);
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.replaceState(nextState, '', nextUri);
|
||||
};
|
||||
26
src/vibes.ts
Normal file
26
src/vibes.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { appConfig } from './config';
|
||||
import { VibeId, type VibePreset } from './config/types';
|
||||
import { readBrowserStorage } from './utils/browser-storage';
|
||||
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
|
||||
import { getCurrentUriVibeId, getVibeIdFromUri } from './vibe-uri';
|
||||
|
||||
export { VibeId };
|
||||
export { getVibeById, VIBE_PRESETS };
|
||||
export type { VibePreset };
|
||||
|
||||
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||
|
||||
const isVibeId = (value: unknown): value is VibeId =>
|
||||
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
||||
|
||||
export const getInitialVibe = (): VibePreset => {
|
||||
const uriVibeId = getCurrentUriVibeId();
|
||||
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
||||
const storedOrLegacyVibeId = isVibeId(storedVibeId)
|
||||
? storedVibeId
|
||||
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
|
||||
const initialVibeId =
|
||||
uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
|
||||
|
||||
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue