Wire up garden controls

This commit is contained in:
Andras Schmelczer 2026-05-24 10:58:21 +01:00
parent 018f8c9d4d
commit 839747304e
15 changed files with 1457 additions and 393 deletions

View file

@ -1,107 +1,251 @@
import { isProduction } from './constants';
import GameLoop from './game-loop/game-loop'; import GameLoop from './game-loop/game-loop';
import { GameRules } from './game-loop/game-rules';
import './index.scss'; import './index.scss';
import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics';
import { preloadPianoSamples } from './audio/piano-samples';
import { AudioControl } from './page/audio-control';
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
import { ConfigPane } from './page/config-pane';
import { EraserSizeControl } from './page/eraser-size-control';
import { ErrorPresenter } from './page/error-presenter';
import { FullScreenHandler } from './page/full-screen-handler'; import { FullScreenHandler } from './page/full-screen-handler';
import { MenuHider } from './page/menu-hider'; import { MenuHider } from './page/menu-hider';
import { setUpSettingsPage } from './page/set-up-settings-page'; import { MirrorSegmentControl } from './page/mirror-segment-control';
import { SettingsSlider } from './page/settings-slider'; import { PaletteControl } from './page/palette-control';
import { resetSettings } from './settings'; import { SplashScreen } from './page/splash-screen';
import { VibeNavigator } from './page/vibe-navigator';
import { getMaxSupportedAgentCount } from './pipelines/agents/agent-limits';
import { activeVibe } from './settings';
import { DeltaTimeCalculator } from './utils/delta-time-calculator'; import { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { queryRequiredElement } from './utils/dom';
import { ErrorHandler, Severity } from './utils/error-handler'; import { ErrorHandler, Severity } from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu'; import { initializeGpu } from './utils/graphics/initialize-gpu';
const elements = {
aside: document.querySelector('aside') as HTMLDivElement,
infoButton: document.querySelector('button.info') as HTMLButtonElement,
infoElement: document.querySelector('.info-page') as HTMLDivElement,
settingsPage: document.querySelector('.settings-page') as HTMLDivElement,
settingsContent: document.querySelector('.settings-content') as HTMLDivElement,
applyDefaults: document.querySelector('#apply-defaults') as HTMLButtonElement,
minimizeFullScreenButton: document.querySelector(
'button.minimize-full-screen'
) as HTMLButtonElement,
maximizeFullScreenButton: document.querySelector(
'button.maximize-full-screen'
) as HTMLButtonElement,
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
canvas: document.querySelector('canvas') as HTMLCanvasElement,
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
};
const main = async () => { const main = async () => {
let hasRuntimeErrorListener = false;
try { try {
initAnalytics();
let shouldStop = false; let shouldStop = false;
let hasStarted = false;
let game: GameLoop | null = null; let game: GameLoop | null = null;
let configPane: ConfigPane | null = null;
ErrorHandler.addOnErrorListener((error, _metadata) => { const getGame = () => game;
elements.errorContainer.innerHTML += ` const destroyCurrentGame = async () => {
<pre class="${error.severity}">${error.message}</div> const currentGame = game;
`; if (!currentGame) {
game?.destroy(); return;
shouldStop = true;
});
const infoPageHandler = new CollapsiblePanelAnimator(
elements.infoButton,
elements.infoElement,
elements.aside
);
const settingsPageHandler = new CollapsiblePanelAnimator(
elements.settingsButton,
elements.settingsPage,
elements.aside
);
settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler);
infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler);
if (isProduction) {
infoPageHandler.open();
} }
game = null;
await currentGame.destroy();
};
const errorPresenter = new ErrorPresenter(
queryRequiredElement('.errors-container', HTMLElement)
);
ErrorHandler.addOnErrorListener((error) => {
errorPresenter.render(error);
if (error.severity === Severity.ERROR) {
document.body.classList.remove('is-loading');
void destroyCurrentGame();
shouldStop = true;
}
});
hasRuntimeErrorListener = true;
const aside = queryRequiredElement('aside', HTMLElement);
const canvas = queryRequiredElement('canvas', HTMLCanvasElement);
const toolbarRow = queryRequiredElement('.toolbar-row', HTMLElement);
const eraserPreview = queryRequiredElement('.eraser-preview', HTMLDivElement);
const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement);
const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement);
const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement);
const settingsButton = queryRequiredElement(
'[data-control="settings"]',
HTMLButtonElement
);
const restartButton = queryRequiredElement(
'[data-control="restart"]',
HTMLButtonElement
);
const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
const infoElement = queryRequiredElement('.info-page', HTMLElement);
const fullScreenButton = queryRequiredElement(
'[data-control="full-screen"]',
HTMLButtonElement
);
const export4kButton = queryRequiredElement(
'[data-control="export"]',
HTMLButtonElement
);
const splash = new SplashScreen();
let eraserSizeControl: EraserSizeControl | null = null;
const paletteControl = new PaletteControl({
getGame,
onChange: () => configPane?.refresh(),
onModeChange: (isEraserActive) => eraserSizeControl?.setActive(isEraserActive),
});
eraserSizeControl = new EraserSizeControl({
getGame,
onActivate: () => paletteControl.setEraserActive(true),
onChange: () => configPane?.refresh(),
});
const mirrorSegmentControl = new MirrorSegmentControl({
onChange: () => {
paletteControl.setEraserActive(false);
configPane?.refresh();
},
});
const audioControl = new AudioControl({
getGame,
hasStarted: () => hasStarted,
startButton: splash.startButton,
});
const syncRuntimeUi = () => {
eraserSizeControl?.render();
eraserSizeControl?.setActive(paletteControl.isEraserActive);
mirrorSegmentControl.render();
paletteControl.render();
};
const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
new MenuHider( new MenuHider(
elements.aside, aside,
() => () =>
FullScreenHandler.isInFullScreenMode() && FullScreenHandler.isInFullScreenMode() &&
!settingsPageHandler.isOpen && !configPane?.isOpen &&
!infoPageHandler.isOpen !infoPageHandler.isOpen
); );
new FullScreenHandler( new FullScreenHandler(fullScreenButton, document.documentElement);
elements.minimizeFullScreenButton,
elements.maximizeFullScreenButton,
document.body
);
const gpu = await initializeGpu(); new VibeNavigator({
onChange: ({ vibeId, vibeName, source, userGesture }) => {
elements.restartButton.addEventListener('click', () => game?.destroy()); trackVibeChange({ vibeId, vibeName, source });
game?.onVibeChanged();
const deltaTimeCalculator = new DeltaTimeCalculator(); syncRuntimeUi();
let sliders: Array<SettingsSlider<any>> = []; configPane?.refresh();
game?.playVibeChangeAudio(userGesture);
elements.applyDefaults.addEventListener('click', () => { },
resetSettings();
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
}); });
while (!shouldStop) { restartButton.addEventListener('click', () => void destroyCurrentGame());
const gameRules = new GameRules(performance.now() / 1000);
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
if (sliders.length === 0) { export4kButton.addEventListener('click', async () => {
sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount); const currentGame = game;
if (!currentGame || export4kButton.disabled) {
return;
} }
await game.start(); export4kButton.disabled = true;
try {
await currentGame.exportSnapshot();
trackExport({ vibeId: activeVibe.id });
} catch (error) {
ErrorHandler.addException(error, { severity: Severity.WARNING });
} finally {
export4kButton.disabled = false;
}
});
// Samples load before Start is enabled so the first audible piano note
// always uses the sampler. The Start tap still unlocks the AudioContext.
splash.showLoadingBar();
const fontsReady = document.fonts.ready.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load fonts.',
severity: Severity.WARNING,
});
});
const gpuPromise = initializeGpu();
const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => {
const ratio = totalCount > 0 ? loadedCount / totalCount : 0;
splash.setLoadingStage(
`Loading piano samples ${loadedCount}/${totalCount}`,
ratio
);
}).then(
() => {
splash.setLoadingStage('Ready', 1);
},
(error: unknown) => {
splash.setLoadingStage('Piano unavailable', 1);
ErrorHandler.addException(error, {
fallbackMessage: 'Could not preload piano samples.',
severity: Severity.WARNING,
});
}
);
const gpu = await gpuPromise;
const gpuNavigator = navigator.gpu;
if (!gpuNavigator) {
throw new Error('WebGPU is no longer available after initialization.');
}
const canvasFormat = gpuNavigator.getPreferredCanvasFormat();
configPane = new ConfigPane({
maxSupportedAgentCount: getMaxSupportedAgentCount(gpu),
settingsButton,
onOpen: () => infoPageHandler.close(),
onConfigChange: () => {
game?.onVibeChanged();
syncRuntimeUi();
},
onRuntimeChange: syncRuntimeUi,
});
infoPageHandler.onOpen = configPane.close.bind(configPane);
await fontsReady;
await preloadPromise;
splash.hideLoadingBar();
const deltaTimeCalculator = new DeltaTimeCalculator();
let isFirstStart = true;
while (!shouldStop) {
const loop = new GameLoop(canvas, gpu, canvasFormat, deltaTimeCalculator, {
toolbar: toolbarRow,
prompt: promptElement,
eraserPreview,
grainOverlay,
exportStatus,
});
game = loop;
syncRuntimeUi();
audioControl.render();
if (isFirstStart) {
isFirstStart = false;
// Splash is in the DOM by default; enable the button now that the
// audio system (GameLoop) is constructed and ready to be unlocked.
await splash.awaitStart(() => {
hasStarted = true;
game?.startAudio(true);
trackStart();
});
requestAnimationFrame(() =>
requestAnimationFrame(() => document.body.classList.remove('is-loading'))
);
}
loop.attachPointerInput();
await loop.start();
if (game === loop) {
game = null;
}
} }
} catch (e) { } catch (e) {
const message = e instanceof Error ? (e.stack ?? e.message) : String(e); document.body.classList.remove('is-loading');
ErrorHandler.addError(Severity.ERROR, message); if (hasRuntimeErrorListener) {
console.error(e); ErrorHandler.addException(e);
} else {
ErrorPresenter.renderStartup(e);
ErrorHandler.addException(e);
}
} }
}; };

View file

@ -1,44 +1,90 @@
export class CollapsiblePanelAnimator { export class CollapsiblePanelAnimator {
private _isOpen = false; private _isOpen = false;
private focusBeforeOpen: HTMLElement | null = null;
public onOpen: () => unknown = () => {}; private readonly abortController = new AbortController();
public onClose: () => unknown = () => {}; public onOpen?: () => void;
public constructor( public constructor(
private readonly toggleButton: HTMLButtonElement, private readonly toggleButton: HTMLButtonElement,
private readonly collapsibleContent: HTMLElement, private readonly collapsibleContent: HTMLElement,
ignoreForCloseOnClick: HTMLElement ignoreForCloseOnClick: HTMLElement
) { ) {
toggleButton.addEventListener('click', this.toggle.bind(this)); const { signal } = this.abortController;
toggleButton.addEventListener('click', this.toggle, { signal });
window.addEventListener( window.addEventListener(
'click', 'click',
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close() (event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close(),
{ signal }
); );
window.addEventListener(
'keydown',
(event) => {
if (this._isOpen && event.key === 'Escape') {
event.preventDefault();
this.close();
}
},
{ signal }
);
this.syncAccessibility();
} }
public open() { public open() {
if (this._isOpen) {
return;
}
this.focusBeforeOpen =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
this._isOpen = true; this._isOpen = true;
this.collapsibleContent.classList.remove('hidden'); this.onOpen?.();
this.toggleButton.classList.add('active'); this.syncAccessibility();
this.onOpen(); this.focusPanel();
} }
public close() { public close() {
this._isOpen = false; if (!this._isOpen) {
this.collapsibleContent.classList.add('hidden'); return;
this.toggleButton.classList.remove('active');
this.onClose();
} }
public toggle() { const focusWasInside = this.collapsibleContent.contains(document.activeElement);
this._isOpen = false;
this.syncAccessibility();
if (focusWasInside) {
(this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
}
}
public readonly toggle = () => {
if (this._isOpen) { if (this._isOpen) {
this.close(); this.close();
} else { } else {
this.open(); this.open();
} }
};
public destroy(): void {
this.abortController.abort();
} }
public get isOpen() { public get isOpen() {
return this._isOpen; return this._isOpen;
} }
private syncAccessibility() {
this.collapsibleContent.classList.toggle('hidden', !this._isOpen);
this.toggleButton.classList.toggle('active', this._isOpen);
this.toggleButton.setAttribute('aria-expanded', String(this._isOpen));
this.collapsibleContent.setAttribute('aria-hidden', String(!this._isOpen));
this.collapsibleContent.inert = !this._isOpen;
}
private focusPanel() {
requestAnimationFrame(() => {
if (this._isOpen) {
this.collapsibleContent.focus({ preventScroll: true });
}
});
}
} }

View file

@ -0,0 +1,187 @@
import type { FolderApi } from '@tweakpane/core';
import { appConfig, normalizeNumberControlValue } from '../config';
import { activeVibe, settings } from '../settings';
import { rgbColorToCss } from '../utils/rgb-color';
type PaneContainer = Pick<FolderApi, 'addFolder'>;
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
const COLOR_REACTION_STATES = [
{ id: 'follow', label: 'Move Toward', value: 1 },
{ id: 'ignore', label: 'Ignore', value: 0 },
{ id: 'avoid', label: 'Move Away', value: -1 },
] as const;
const colorReactionRows = [
{
colorIndex: 0,
label: COLOR_REACTION_LABELS[0],
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
},
{
colorIndex: 1,
label: COLOR_REACTION_LABELS[1],
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
},
{
colorIndex: 2,
label: COLOR_REACTION_LABELS[2],
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
},
] as const;
const getColorReactionStateIndex = (value: number): number =>
COLOR_REACTION_STATES.findIndex((state) => state.value === value);
const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
const getNextColorReactionState = (
value: number
): (typeof COLOR_REACTION_STATES)[number] => {
const index = getColorReactionStateIndex(value);
return COLOR_REACTION_STATES[
((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
];
};
export class ColorReactionMatrixControl {
private readonly buttons = new Map<
ColorReactionKey,
{
element: HTMLButtonElement;
sourceColorIndex: number;
targetColorIndex: number;
}
>();
private readonly swatches: Array<{
colorIndex: number;
element: HTMLElement;
}> = [];
public constructor(private readonly onRuntimeChange: () => void) {}
public addTo(container: PaneContainer): void {
const folder = container.addFolder({
title: 'Color Behavior',
expanded: true,
});
const matrix = document.createElement('div');
matrix.className = 'color-reaction-matrix';
matrix.appendChild(this.createCorner());
colorReactionRows.forEach((row) => {
matrix.appendChild(this.createHeader(row.colorIndex, row.label));
});
colorReactionRows.forEach((row) => {
matrix.appendChild(this.createHeader(row.colorIndex, row.label));
row.keys.forEach((key, columnIndex) => {
matrix.appendChild(this.createCell(key, row.colorIndex, columnIndex));
});
});
const matrixBlade = folder.addBlade({ view: 'separator' });
matrixBlade.element.classList.add('color-reaction-matrix-blade');
matrixBlade.element.replaceChildren(matrix);
this.sync();
}
public sync(): void {
this.buttons.forEach(({ element, sourceColorIndex, targetColorIndex }, key) => {
this.syncButton(element, key, sourceColorIndex, targetColorIndex);
});
this.swatches.forEach(({ colorIndex, element }) => {
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
});
}
private createCorner(): HTMLDivElement {
const corner = document.createElement('div');
corner.className = 'color-reaction-matrix__corner';
return corner;
}
private createHeader(colorIndex: number, label: string): HTMLDivElement {
const header = document.createElement('div');
header.className = 'color-reaction-matrix__header';
header.setAttribute('aria-label', label);
header.title = label;
const swatch = document.createElement('span');
swatch.className = 'color-reaction-matrix__swatch';
this.swatches.push({ colorIndex, element: swatch });
header.appendChild(swatch);
return header;
}
private createCell(
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): HTMLDivElement {
const cell = document.createElement('div');
cell.className = 'color-reaction-matrix__cell';
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return cell;
}
const button = document.createElement('button');
button.className = 'color-reaction-matrix__button';
button.type = 'button';
const icon = document.createElement('span');
icon.className = 'color-reaction-matrix__icon';
button.appendChild(icon);
button.addEventListener('click', () => {
const currentValue = normalizeNumberControlValue(settings[key], config);
const nextState = getNextColorReactionState(currentValue);
settings[key] = nextState.value;
this.syncButton(button, key, sourceColorIndex, targetColorIndex);
this.onRuntimeChange();
});
this.buttons.set(key, {
element: button,
sourceColorIndex,
targetColorIndex,
});
cell.appendChild(button);
return cell;
}
private syncButton(
button: HTMLButtonElement,
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): void {
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return;
}
settings[key] = normalizeNumberControlValue(settings[key], config);
const state = getColorReactionState(settings[key]);
const nextState = getNextColorReactionState(settings[key]);
const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
button.dataset.reaction = state.id;
button.setAttribute(
'aria-label',
`${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
);
button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
}
}

365
src/page/config-pane.ts Normal file
View file

@ -0,0 +1,365 @@
import type { BindingParams, FolderApi } from '@tweakpane/core';
import { Pane } from 'tweakpane';
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import {
appConfig,
normalizeNumberControlValue,
type GardenRuntimeSettings,
type NumberControlConfig,
} from '../config';
import { activeVibe, settings } from '../settings';
import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
type RuntimeControlKey = keyof GardenRuntimeSettings & string;
type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
type NumberPropertyKey<T> = {
[Key in keyof T]-?: T[Key] extends number ? Key : never;
}[keyof T] &
string;
type VibeNumberKey = NumberPropertyKey<GardenAudioVibeSettings>;
interface PaneState extends GardenAudioVibeSettings {
backgroundColor: string;
color1: string;
color2: string;
color3: string;
}
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
const MUSIC_CONTROLS: ReadonlyArray<{
key: VibeNumberKey;
label: string;
min: number;
max: number;
step: number;
}> = [
{ key: 'idleIntensity', label: 'Ambient Notes', min: 0, max: 1, step: 0.01 },
{ key: 'bpm', label: 'Tempo', min: 48, max: 150, step: 1 },
{ key: 'rampUpIntensity', label: 'Touch Energy', min: 0, max: 2, step: 0.01 },
{ key: 'rampUpTime', label: 'Response Time', min: 0.01, max: 0.4, step: 0.01 },
{ key: 'noteLength', label: 'Note Length', min: 0.1, max: 1.8, step: 0.01 },
{ key: 'notePitchOffset', label: 'Pitch Shift', min: -12, max: 12, step: 1 },
{ key: 'brightness', label: 'Tone Brightness', min: 0.5, max: 1.5, step: 0.01 },
];
interface ConfigPaneOptions {
maxSupportedAgentCount: number;
onConfigChange: () => void;
onOpen?: () => void;
onRuntimeChange: () => void;
settingsButton: HTMLButtonElement;
}
const getRuntimeControlKeys = (folder: string): Array<RuntimeControlKey> =>
(
Object.entries(appConfig.runtimeSettings.controls) as Array<
[RuntimeControlKey, NumberControlConfig | undefined]
>
)
.filter(([, config]) => config?.folder === folder)
.map(([key]) => key);
const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
const params: BindingParams = {
label: config.label,
options: config.options,
step: config.step,
};
if (config.format !== undefined) {
params.format = config.format;
}
if (config.min !== undefined) {
params.min = config.min;
}
if (config.max !== undefined) {
params.max = config.max;
}
return params;
};
export class ConfigPane {
private readonly container: HTMLDivElement;
private readonly closeButton: HTMLButtonElement;
private readonly colorReactionMatrix: ColorReactionMatrixControl;
private readonly pane: Pane;
private readonly state: PaneState = {
backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
color1: rgbColorToHex(activeVibe.colors[0]),
color2: rgbColorToHex(activeVibe.colors[1]),
color3: rgbColorToHex(activeVibe.colors[2]),
...activeVibe.audio,
};
public constructor(private readonly options: ConfigPaneOptions) {
this.colorReactionMatrix = new ColorReactionMatrixControl(
this.options.onRuntimeChange
);
this.container = document.createElement('div');
this.container.className = 'config-pane-container';
this.closeButton = document.createElement('button');
this.closeButton.className = 'config-pane-close';
this.closeButton.type = 'button';
this.closeButton.setAttribute('aria-label', 'Hide config overlay');
this.closeButton.title = 'Hide config overlay';
this.closeButton.addEventListener('click', () => this.setHidden(true));
this.container.appendChild(this.closeButton);
document.body.appendChild(this.container);
this.pane = new Pane({
container: this.container,
title: appConfig.tuningPane.title,
expanded: true,
});
this.pane.hidden = appConfig.tuningPane.startHidden;
this.pane.element.classList.add('config-pane');
this.pane.element.id = 'config-pane';
this.options.settingsButton.setAttribute('aria-controls', this.pane.element.id);
this.options.settingsButton.addEventListener('click', this.toggle);
document.addEventListener('pointerdown', this.dismissOnOutsidePointerDown, {
passive: true,
});
document.addEventListener('keydown', this.dismissOnEscape);
this.setUpTuningPane(this.pane);
this.syncOpenState();
}
public get isOpen(): boolean {
return !this.pane.hidden;
}
public refresh(): void {
this.syncVibeState();
this.pane.refresh();
this.colorReactionMatrix.sync();
this.syncOpenState();
}
public close(): void {
this.setHidden(true);
}
private readonly toggle = () => {
this.setHidden(!this.pane.hidden);
};
private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => {
if (!this.isOpen || !(event.target instanceof Node)) {
return;
}
if (
this.container.contains(event.target) ||
this.options.settingsButton.contains(event.target)
) {
return;
}
this.setHidden(true);
};
private readonly dismissOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && this.isOpen) {
this.setHidden(true);
}
};
private setHidden(isHidden: boolean): void {
const wasOpen = this.isOpen;
this.pane.hidden = isHidden;
this.syncOpenState();
if (!wasOpen && this.isOpen) {
this.options.onOpen?.();
}
}
private setUpTuningPane(container: PaneContainer): void {
this.setUpVibeSection(container);
this.addRuntimeSection(container, runtimeFolderOrder[0], true);
this.addRuntimeSection(container, runtimeFolderOrder[1], true);
this.colorReactionMatrix.addTo(container);
this.addRuntimeSection(container, runtimeFolderOrder[2], true);
const performanceFolder = this.addRuntimeSection(
container,
runtimeFolderOrder[3],
true
);
this.addFpsOverlayBinding(performanceFolder);
this.setUpMusicSection(container);
this.colorReactionMatrix.sync();
}
private setUpVibeSection(container: PaneContainer): void {
const folder = container.addFolder({
title: 'Colors',
expanded: true,
});
this.addColorBinding(folder, 'color1', '', (color) => {
activeVibe.colors[0] = color;
});
this.addColorBinding(folder, 'color2', '', (color) => {
activeVibe.colors[1] = color;
});
this.addColorBinding(folder, 'color3', '', (color) => {
activeVibe.colors[2] = color;
});
this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => {
activeVibe.backgroundColor = color;
});
if (import.meta.env.DEV) {
folder
.addButton({ title: 'Copy Vibe Preset' })
.on('click', () => void this.copyVibePresetToClipboard());
}
}
private addColorBinding(
container: PaneContainer,
key: VibeColorKey,
label: string,
updateColor: (color: RgbColor) => void
): void {
container
.addBinding(this.state, key, {
label,
view: 'color',
} as BindingParams)
.on('change', ({ value }) => {
const color = hexColorToRgbColor(String(value));
if (!color) {
this.syncVibeState();
this.pane.refresh();
return;
}
updateColor(color);
this.colorReactionMatrix.sync();
this.options.onConfigChange();
});
}
private addRuntimeSection(
container: PaneContainer,
title: string,
expanded: boolean
): PaneContainer {
const folder = container.addFolder({ title, expanded });
getRuntimeControlKeys(title).forEach((key) => this.addRuntimeBinding(folder, key));
return folder;
}
private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void {
const config = this.getRuntimeControlConfig(key);
if (!config) {
return;
}
settings[key] = normalizeNumberControlValue(settings[key], config);
container
.addBinding(settings, key, getNumberBindingParams(config))
.on('change', () => {
const nextValue = normalizeNumberControlValue(settings[key], config);
if (nextValue !== settings[key]) {
settings[key] = nextValue;
this.pane.refresh();
}
this.options.onRuntimeChange();
});
}
private getRuntimeControlConfig(
key: RuntimeControlKey
): NumberControlConfig | undefined {
const config = appConfig.runtimeSettings.controls[key];
if (!config || key !== 'maxAgentCount') {
return config;
}
return {
...config,
max: Math.max(config.min ?? 0, Math.floor(this.options.maxSupportedAgentCount)),
};
}
private addFpsOverlayBinding(container: PaneContainer): void {
container
.addBinding(appConfig.tuningPane, 'showFpsOverlay', {
label: 'Show FPS',
})
.on('change', () => this.options.onConfigChange());
}
private setUpMusicSection(container: PaneContainer): void {
const folder = container.addFolder({ title: 'Music', expanded: true });
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
this.addVibeNumberBinding(folder, key, { folder: 'Music', label, min, max, step });
});
}
private addVibeNumberBinding(
container: PaneContainer,
key: VibeNumberKey,
config: NumberControlConfig
): void {
this.state[key] = normalizeNumberControlValue(this.state[key], config);
container
.addBinding(this.state, key, getNumberBindingParams(config))
.on('change', () => {
const nextValue = normalizeNumberControlValue(this.state[key], config);
if (nextValue !== this.state[key]) {
this.state[key] = nextValue;
this.pane.refresh();
}
activeVibe.audio[key] = nextValue;
this.options.onConfigChange();
});
}
private async copyVibePresetToClipboard(): Promise<void> {
const settingKeys = Object.keys(activeVibe.settings) as Array<
keyof typeof activeVibe.settings
>;
const preset = {
name: `${activeVibe.name} Copy`,
colors: activeVibe.colors,
backgroundColor: activeVibe.backgroundColor,
settings: Object.fromEntries(settingKeys.map((key) => [key, settings[key]])),
audio: activeVibe.audio,
};
try {
await navigator.clipboard.writeText(JSON.stringify(preset, null, 2));
} catch (error) {
console.warn('Could not copy vibe preset to clipboard.', error);
}
}
private syncVibeState(): void {
this.state.color1 = rgbColorToHex(activeVibe.colors[0]);
this.state.color2 = rgbColorToHex(activeVibe.colors[1]);
this.state.color3 = rgbColorToHex(activeVibe.colors[2]);
this.state.backgroundColor = rgbColorToHex(activeVibe.backgroundColor);
Object.assign(this.state, activeVibe.audio);
}
private syncOpenState(): void {
const { settingsButton } = this.options;
const label = this.isOpen ? 'Hide config overlay' : 'Show config overlay';
settingsButton.classList.toggle('active', this.isOpen);
settingsButton.setAttribute('aria-expanded', String(this.isOpen));
settingsButton.setAttribute('aria-label', label);
settingsButton.title = label;
this.container.classList.toggle('config-pane-container--open', this.isOpen);
this.closeButton.hidden = !this.isOpen;
}
}

View file

@ -0,0 +1,84 @@
import { appConfig } from '../config';
import type GameLoop from '../game-loop/game-loop';
import { settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
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 getEraserSizeRatio = (size: number): number => {
const { max, min } = appConfig.toolbar.eraser;
return (size - min) / (max - min);
};
interface EraserSizeControlOptions {
getGame: () => GameLoop | null;
onActivate: () => void;
onChange: () => void;
}
export class EraserSizeControl {
private readonly control = queryRequiredElement(
'.eraser-size-control',
HTMLLabelElement
);
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
private isActive = false;
public constructor(private readonly options: EraserSizeControlOptions) {
this.control.addEventListener('pointerdown', this.activate);
this.control.addEventListener('click', this.activate);
this.slider.addEventListener('focus', this.activate);
this.slider.addEventListener('input', () => {
settings.eraserSize = clampEraserSize(Number(this.slider.value));
this.activate();
this.render();
this.options.onChange();
});
}
public render(): void {
const size = clampEraserSize(settings.eraserSize);
if (settings.eraserSize !== size) {
settings.eraserSize = size;
}
this.slider.min = appConfig.toolbar.eraser.min.toString();
this.slider.max = appConfig.toolbar.eraser.max.toString();
this.slider.step = appConfig.toolbar.eraser.step.toString();
this.slider.value = size.toString();
this.slider.setAttribute('aria-valuetext', `${size}px`);
const ratio = getEraserSizeRatio(size);
const scale =
appConfig.toolbar.eraser.controlScaleMin +
(appConfig.toolbar.eraser.controlScaleMax -
appConfig.toolbar.eraser.controlScaleMin) *
ratio;
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
this.syncActiveState();
this.options.getGame()?.updateEraserPreview();
}
public setActive(isActive: boolean): void {
this.isActive = isActive;
this.syncActiveState();
}
private readonly activate = () => {
this.setActive(true);
this.options.onActivate();
};
private syncActiveState(): void {
this.control.classList.toggle('active', this.isActive);
this.slider.setAttribute(
'aria-label',
this.isActive ? 'Eraser size, active' : 'Eraser size'
);
}
}

View file

@ -0,0 +1,62 @@
import {
ErrorHandler,
getErrorMessage,
RuntimeError,
Severity,
} from '../utils/error-handler';
type RuntimeUiError = Parameters<
Parameters<typeof ErrorHandler.addOnErrorListener>[0]
>[0];
const ERROR_CONTAINER_SELECTOR = '.errors-container';
const ERROR_CONTAINER_CLASS = 'errors-container';
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError): void => {
const message = document.createElement('pre');
message.className = error.severity;
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status');
message.setAttribute(
'aria-live',
error.severity === Severity.ERROR ? 'assertive' : 'polite'
);
container.append(message);
if (error.severity === Severity.ERROR) {
message.tabIndex = -1;
message.focus({ preventScroll: true });
}
};
const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
severity: Severity.ERROR,
message: getErrorMessage(exception),
...(exception instanceof RuntimeError ? { code: exception.code } : {}),
});
export class ErrorPresenter {
public constructor(private readonly container: HTMLElement) {
container.setAttribute('aria-live', 'assertive');
}
public render(error: RuntimeUiError): void {
renderRuntimeMessage(this.container, error);
}
public static renderStartup(exception: unknown): void {
const existingContainer = document.querySelector(ERROR_CONTAINER_SELECTOR);
const container =
existingContainer instanceof HTMLElement
? existingContainer
: document.createElement('div');
if (!(existingContainer instanceof HTMLElement)) {
container.className = ERROR_CONTAINER_CLASS;
document.body.append(container);
}
container.setAttribute('aria-live', 'assertive');
renderRuntimeMessage(container, getRuntimeUiError(exception));
}
}

View file

@ -1,43 +1,46 @@
export class FullScreenHandler { export class FullScreenHandler {
private readonly abortController = new AbortController();
public constructor( public constructor(
private readonly minimizeButton: HTMLElement, private readonly toggleButton: HTMLElement,
private readonly maximizeButton: HTMLElement,
target: HTMLElement target: HTMLElement
) { ) {
if (!document.fullscreenEnabled) { if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
minimizeButton.style.display = 'none'; toggleButton.hidden = true;
maximizeButton.style.display = 'none';
return; return;
} }
this.updateButtons(); this.updateButtons();
addEventListener('keydown', (e) => { const { signal } = this.abortController;
// on full screen request, only apply it to the target addEventListener('fullscreenchange', this.updateButtons, { signal });
if (e.key === 'F11') { toggleButton.addEventListener(
e.preventDefault(); 'click',
() => {
if (FullScreenHandler.isInFullScreenMode()) { if (FullScreenHandler.isInFullScreenMode()) {
document.exitFullscreen(); void document.exitFullscreen();
} else { return;
target.requestFullscreen();
} }
}
}); void target.requestFullscreen().catch(() => undefined);
addEventListener('fullscreenchange', this.updateButtons.bind(this)); },
maximizeButton.addEventListener('click', () => target.requestFullscreen()); { signal }
minimizeButton.addEventListener('click', () => document.exitFullscreen()); );
} }
public static isInFullScreenMode(): boolean { public static isInFullScreenMode(): boolean {
return document.fullscreenElement !== null; return document.fullscreenElement !== null;
} }
private updateButtons() { public destroy(): void {
this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode() this.abortController.abort();
? 'block'
: 'none';
this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
? 'none'
: 'block';
} }
private readonly updateButtons = (): void => {
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
this.toggleButton.classList.toggle('active', isInFullScreenMode);
this.toggleButton.setAttribute('aria-label', label);
this.toggleButton.title = label;
};
} }

View file

@ -1,17 +1,139 @@
import { appConfig } from '../config';
export class MenuHider { export class MenuHider {
private static readonly DEFAULT_TIME_TO_LIVE = 3500; private readonly desktopMediaQuery = window.matchMedia(
private static readonly INTERVAL = 50; appConfig.menuHider.desktopMediaQuery
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE; );
private hideTimeout: number | undefined;
private isHidden = false;
private pointerInside = false;
public constructor(element: HTMLElement, shouldBeHidden: () => boolean) { public constructor(
setInterval(() => { private readonly element: HTMLElement,
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL); private readonly shouldBeHidden: () => boolean
element.style.opacity = this.timeToLive == 0 && shouldBeHidden() ? '0' : '1'; ) {
}, MenuHider.INTERVAL); element.addEventListener('pointerenter', this.onPointerEnter);
element.addEventListener('pointerleave', this.onPointerLeave);
element.addEventListener('focusin', this.onFocusIn);
element.addEventListener('focusout', this.onFocusOut);
window.addEventListener('pointermove', this.onPointerMove, { passive: true });
document.addEventListener('fullscreenchange', this.onVisibilityContextChange);
this.desktopMediaQuery.addEventListener('change', this.onVisibilityContextChange);
element.addEventListener( this.reveal();
'mouseover', }
() => (this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE)
private get canAutoHide(): boolean {
return (
this.desktopMediaQuery.matches &&
this.shouldBeHidden() &&
!this.pointerInside &&
!this.element.contains(document.activeElement)
); );
} }
private readonly onPointerEnter = () => {
this.pointerInside = true;
this.reveal();
};
private readonly onPointerLeave = () => {
this.pointerInside = false;
this.scheduleHide();
};
private readonly onFocusIn = () => {
this.reveal();
};
private readonly onFocusOut = () => {
window.setTimeout(() => this.scheduleHide(), 0);
};
private readonly onPointerMove = (event: PointerEvent) => {
if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) {
this.reveal();
return;
}
if (this.isPointerOverDock(event.clientX, event.clientY)) {
this.pointerInside = true;
this.reveal();
return;
}
this.pointerInside = false;
if (this.isHidden) {
if (this.isNearViewportBottom(event.clientY)) {
this.reveal();
this.scheduleHide();
}
return;
}
this.scheduleHide();
};
private readonly onVisibilityContextChange = () => {
this.scheduleHide();
};
private scheduleHide(): void {
if (!this.canAutoHide) {
this.clearHideTimeout();
this.reveal();
return;
}
if (this.hideTimeout !== undefined) {
return;
}
this.hideTimeout = window.setTimeout(() => {
this.hideTimeout = undefined;
if (this.canAutoHide) {
this.hide();
}
}, appConfig.menuHider.hideDelayMs);
}
private reveal(): void {
if (!this.isHidden && this.hideTimeout === undefined) {
return;
}
this.clearHideTimeout();
this.isHidden = false;
this.element.classList.remove('menu-hidden');
}
private hide(): void {
this.isHidden = true;
this.element.classList.add('menu-hidden');
}
private clearHideTimeout(): void {
if (this.hideTimeout === undefined) {
return;
}
window.clearTimeout(this.hideTimeout);
this.hideTimeout = undefined;
}
private isPointerOverDock(clientX: number, clientY: number): boolean {
const rect = this.element.getBoundingClientRect();
return (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
);
}
private isNearViewportBottom(clientY: number): boolean {
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
}
} }

View file

@ -0,0 +1,66 @@
import { appConfig } from '../config';
import { settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
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 getMirrorSegmentRatio = (count: number): number => {
const { max, min } = appConfig.toolbar.mirror;
return (count - min) / (max - min);
};
const formatMirrorSegmentCount = (count: number): string =>
count <= 1
? appConfig.toolbar.mirror.offLabel
: `${count} ${
appConfig.toolbar.mirror.names[
count as keyof typeof appConfig.toolbar.mirror.names
] ?? appConfig.toolbar.mirror.fallbackSegmentName
}`;
interface MirrorSegmentControlOptions {
onChange: () => void;
}
export class MirrorSegmentControl {
private readonly control = queryRequiredElement(
'.mirror-segment-control',
HTMLLabelElement
);
private readonly slider = queryRequiredElement(
'.mirror-segment-slider',
HTMLInputElement
);
public constructor(private readonly options: MirrorSegmentControlOptions) {
this.slider.addEventListener('input', () => {
settings.mirrorSegmentCount = clampMirrorSegmentCount(Number(this.slider.value));
this.render();
this.options.onChange();
});
}
public render(): void {
const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
if (settings.mirrorSegmentCount !== count) {
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.value = count.toString();
const label = formatMirrorSegmentCount(count);
const ratio = getMirrorSegmentRatio(count);
this.slider.setAttribute('aria-valuetext', label);
this.control.title = label;
this.control.classList.toggle('active', count > 1);
this.control.style.setProperty('--mirror-progress', `${ratio * 100}%`);
this.control.style.setProperty('--mirror-angle', `${(360 / count).toFixed(3)}deg`);
}
}

View file

@ -0,0 +1,76 @@
import type GameLoop from '../game-loop/game-loop';
import { activeVibe, settings } from '../settings';
import { ErrorCode, RuntimeError } from '../utils/error-handler';
import { rgbColorToCss } from '../utils/rgb-color';
interface PaletteControlOptions {
getGame: () => GameLoop | null;
onChange: () => void;
onModeChange?: (isEraserActive: boolean) => void;
}
export class PaletteControl {
private readonly swatches = queryRequiredColorSwatches();
private isEraserActiveState = false;
public constructor(private readonly options: PaletteControlOptions) {
this.swatches.forEach((swatch, index) => {
swatch.addEventListener('click', () => {
settings.selectedColorIndex = index;
this.isEraserActiveState = false;
this.render();
this.options.onModeChange?.(false);
this.options.onChange();
});
});
}
public get isEraserActive(): boolean {
return this.isEraserActiveState;
}
public setEraserActive(active: boolean): void {
this.isEraserActiveState = active;
this.render();
this.options.onModeChange?.(active);
}
public render(): void {
this.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
const isActive = settings.selectedColorIndex === index && !this.isEraserActiveState;
swatch.classList.toggle('active', isActive);
swatch.setAttribute('aria-pressed', String(isActive));
});
this.options.getGame()?.setEraseMode(this.isEraserActiveState);
document.documentElement.style.setProperty(
'--garden-background',
rgbColorToCss(activeVibe.backgroundColor)
);
}
}
const queryRequiredColorSwatches = (): Array<HTMLButtonElement> => {
const selector = '.color-swatch';
const swatches = Array.from(document.querySelectorAll(selector));
const expectedCount = activeVibe.colors.length;
const hasExpectedSwatches =
swatches.length === expectedCount &&
swatches.every((swatch) => swatch instanceof HTMLButtonElement);
if (!hasExpectedSwatches) {
throw new RuntimeError(
ErrorCode.DOM_ELEMENT_MISSING,
`Expected ${expectedCount} color swatches.`,
{
details: {
actualCount: swatches.length,
expectedCount,
selector,
},
}
);
}
return swatches as Array<HTMLButtonElement>;
};

View file

@ -1,115 +0,0 @@
import { isProduction } from '../constants';
import { settings } from '../settings';
import { SettingsSlider, ValueScaling } from './settings-slider';
export const setUpSettingsPage = (
settingsPage: HTMLDivElement,
maxAgentCount: number
): Array<SettingsSlider<any>> => {
const sliders: Array<SettingsSlider<any>> = [
...(isProduction
? []
: [
new SettingsSlider(settings, 'renderSpeed', {
min: 1,
max: 10,
rounding: Math.round,
}),
]),
new SettingsSlider(settings, 'agentCount', {
min: 1,
max: maxAgentCount,
scaling: ValueScaling.Quadratic,
rounding: Math.round,
}),
new SettingsSlider(settings, 'currentGenerationAggression', {
min: -5,
max: 5,
}),
new SettingsSlider(settings, 'nextGenerationAggression', {
min: -5,
max: 5,
}),
new SettingsSlider(settings, 'moveSpeed', {
min: 10,
max: 500,
scaling: ValueScaling.Quadratic,
rounding: Math.round,
}),
new SettingsSlider(settings, 'turnSpeed', {
min: 1,
max: 200,
scaling: ValueScaling.Quadratic,
rounding: Math.round,
}),
new SettingsSlider(settings, 'sensorOffsetAngle', {
min: 0,
max: 90,
step: 1,
}),
new SettingsSlider(settings, 'sensorOffsetDistance', {
min: 0,
max: 200,
scaling: ValueScaling.Quadratic,
rounding: Math.round,
}),
new SettingsSlider(settings, 'turnWhenLost', {
min: 0,
max: 1,
}),
new SettingsSlider(settings, 'individualTrailWeight', {
min: 0,
max: 1,
}),
new SettingsSlider(settings, 'diffusionRateTrails', {
min: 0,
max: 2,
}),
new SettingsSlider(settings, 'decayRateTrails', {
min: 0.1,
max: 5000,
scaling: ValueScaling.Quadratic,
}),
new SettingsSlider(settings, 'diffusionRateBrush', {
min: 0.001,
max: 1,
}),
new SettingsSlider(settings, 'decayRateBrush', {
min: 0.1,
max: 100,
}),
new SettingsSlider(settings, 'brushSize', {
min: 1,
max: 30,
}),
new SettingsSlider(settings, 'clarity', {
min: 0.00001,
max: 1,
}),
];
const sliderContainerElement = document.createElement('div');
sliders.forEach((slider) => {
sliderContainerElement.appendChild(slider.element);
});
settingsPage.appendChild(sliderContainerElement);
return sliders;
};

View file

@ -1,146 +0,0 @@
import { formatNumber } from '../utils/format-number';
export enum ValueScaling {
Linear,
Quadratic,
Logarithmic,
}
export interface SliderConfiguration {
min: number;
max: number;
unit?: string;
step?: number;
onChangeCallback?: (value: number) => unknown;
scaling: ValueScaling;
rounding: (value: number) => number;
}
export class SettingsSlider<T extends Record<string, number>> {
private static readonly DEFAULT_STEP_COUNT = 20000;
private readonly slider: HTMLInputElement;
private readonly valueDisplay: HTMLSpanElement;
private readonly sliderWrapper: HTMLDivElement;
private readonly config: SliderConfiguration = {
min: 0,
max: 1,
scaling: ValueScaling.Linear,
rounding: (value) => value,
};
public constructor(
private readonly settings: T,
private readonly settingName: keyof T & string,
config: Partial<SliderConfiguration> = {}
) {
this.slider = SettingsSlider.createSlider();
this.valueDisplay = SettingsSlider.createValueDisplay();
this.sliderWrapper = SettingsSlider.createSliderWrapper(
this.settingName,
this.slider,
this.valueDisplay
);
this.slider.addEventListener('input', this.onChange.bind(this));
this.updateConfig(config);
}
private static createSlider() {
const input = document.createElement('input');
input.type = 'range';
return input;
}
private static createValueDisplay() {
return document.createElement('span');
}
private static createSliderWrapper(
name: string,
slider: HTMLInputElement,
valueDisplay: HTMLSpanElement
) {
const wrapper = document.createElement('div');
wrapper.classList.add('slider');
const label = document.createElement('label');
const title = document.createElement('p');
title.innerText = SettingsSlider.formatLabel(name);
title.appendChild(valueDisplay);
label.appendChild(title);
label.appendChild(slider);
wrapper.appendChild(label);
return wrapper;
}
private static formatLabel(value: string): string {
const formatted = value.replace(/([A-Z])/g, ' $1');
return (
formatted.charAt(0).toLocaleUpperCase() + formatted.slice(1).toLocaleLowerCase()
);
}
private onChange() {
this.settings[this.settingName] = this.config.rounding(
this.inverseScaling(Number(this.slider.value))
) as any;
this.config.onChangeCallback?.(this.settings[this.settingName]);
this.valueDisplay.innerText = formatNumber(
this.settings[this.settingName],
this.config.unit
);
}
public updateSliderValueBasedOnSource() {
this.slider.value = this.scaling(this.settings[this.settingName]).toString();
this.onChange();
}
public updateConfig(config: Partial<SliderConfiguration>) {
Object.assign(this.config, config);
if (this.config.step === undefined) {
this.config.step =
(this.scaling(this.config.max) - this.scaling(this.config.min)) /
SettingsSlider.DEFAULT_STEP_COUNT;
}
this.slider.min = this.scaling(this.config.min).toString();
this.slider.max = this.scaling(this.config.max).toString();
this.slider.step = this.config.step.toString();
this.slider.value = this.scaling(this.settings[this.settingName]).toString();
this.onChange();
}
public get element(): HTMLElement {
return this.sliderWrapper;
}
private get scaling(): (value: number) => number {
switch (this.config.scaling) {
case ValueScaling.Linear:
return (value) => value;
case ValueScaling.Quadratic:
return (value) => Math.sqrt(value);
case ValueScaling.Logarithmic:
return (value) => Math.log10(value);
}
}
private get inverseScaling(): (value: number) => number {
switch (this.config.scaling) {
case ValueScaling.Linear:
return (value) => value;
case ValueScaling.Quadratic:
return (value) => Math.pow(value, 2);
case ValueScaling.Logarithmic:
return (value) => Math.pow(10, value);
}
}
}

61
src/page/splash-screen.ts Normal file
View file

@ -0,0 +1,61 @@
import { queryRequiredElement } from '../utils/dom';
import { clamp01 } from '../utils/math';
export class SplashScreen {
public readonly startButton = queryRequiredElement('.start-button', HTMLButtonElement);
private readonly splash = queryRequiredElement('.splash', HTMLDivElement);
private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement);
private readonly loadingStatus = queryRequiredElement(
'.loading-status',
HTMLDivElement
);
private readonly loadingProgress = queryRequiredElement(
'.loading-progress',
HTMLDivElement
);
private setVisible(element: HTMLElement, isVisible: boolean): void {
element.dataset.visible = String(isVisible);
element.setAttribute('aria-hidden', String(!isVisible));
element.inert = !isVisible;
}
public setLoadingStage(label: string, ratio: number): void {
const percent = Math.round(clamp01(ratio) * 100);
this.loadingStatus.textContent = label;
this.loadingProgress.style.setProperty('--loading-progress', `${percent}%`);
this.loadingProgress.setAttribute('aria-valuenow', String(percent));
}
public awaitStart(onStart: () => void): Promise<void> {
this.startButton.disabled = false;
return new Promise<void>((resolve) => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || event.defaultPrevented) {
return;
}
event.preventDefault();
this.startButton.click();
};
const onClick = () => {
this.startButton.removeEventListener('click', onClick);
document.removeEventListener('keydown', onKeyDown);
onStart();
this.setVisible(this.splash, false);
resolve();
};
this.startButton.addEventListener('click', onClick);
document.addEventListener('keydown', onKeyDown);
});
}
public showLoadingBar(): void {
this.setVisible(this.loadingBar, true);
}
public hideLoadingBar(): void {
this.setVisible(this.loadingBar, false);
}
}

View file

@ -0,0 +1,85 @@
import { activeVibe, applyVibeSettings, rememberActiveVibeSelection } from '../settings';
import { queryRequiredElement } from '../utils/dom';
import { getCurrentUriVibeId, writeCurrentVibeUri } from '../vibe-uri';
import { getVibeById, VIBE_PRESETS, type VibeId } from '../vibes';
interface VibeSelection {
source: string;
userGesture: boolean;
vibeId: VibeId;
vibeName: string;
}
interface VibeNavigatorOptions {
onChange: (selection: VibeSelection) => void;
}
export class VibeNavigator {
private readonly abortController = new AbortController();
private readonly previousButton = queryRequiredElement(
'.previous-vibe',
HTMLButtonElement
);
private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement);
public constructor(private readonly options: VibeNavigatorOptions) {
rememberActiveVibeSelection();
writeCurrentVibeUri(activeVibe.id, 'replace');
const { signal } = this.abortController;
this.previousButton.addEventListener(
'click',
() => this.select(-1, 'previous-button'),
{ signal }
);
this.nextButton.addEventListener('click', () => this.select(1, 'next-button'), {
signal,
});
window.addEventListener('popstate', this.selectFromCurrentUri, { signal });
}
public destroy(): void {
this.abortController.abort();
}
private select(offset: number, source: string): void {
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
const currentIndex = current >= 0 ? current : 0;
const vibe =
VIBE_PRESETS[(currentIndex + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
const activePreset = applyVibeSettings(vibe);
writeCurrentVibeUri(activePreset.id, 'push');
this.notifyChange(activePreset, source, true);
}
private readonly selectFromCurrentUri = (): void => {
const vibeId = getCurrentUriVibeId();
if (!vibeId || vibeId === activeVibe.id) {
writeCurrentVibeUri(activeVibe.id, 'replace');
return;
}
const vibe = getVibeById(vibeId);
if (!vibe) {
writeCurrentVibeUri(activeVibe.id, 'replace');
return;
}
const activePreset = applyVibeSettings(vibe);
writeCurrentVibeUri(activePreset.id, 'replace');
this.notifyChange(activePreset, 'uri-popstate', false);
};
private notifyChange(
activePreset: typeof activeVibe,
source: string,
userGesture: boolean
): void {
this.options.onChange({
userGesture,
vibeId: activePreset.id,
vibeName: activePreset.name,
source,
});
}
}

24
src/utils/dom.ts Normal file
View file

@ -0,0 +1,24 @@
import { ErrorCode, RuntimeError } from './error-handler';
type ElementConstructor<T extends Element> = abstract new () => T;
export const queryRequiredElement = <T extends Element>(
selector: string,
constructor: ElementConstructor<T>
): T => {
const element = document.querySelector(selector);
if (!(element instanceof constructor)) {
throw new RuntimeError(
ErrorCode.DOM_ELEMENT_MISSING,
`Missing required DOM element: ${selector}`,
{
details: {
expectedType: constructor.name,
selector,
},
}
);
}
return element;
};