diff --git a/src/index.ts b/src/index.ts index 1e2c2d9..74b98d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,107 +1,251 @@ -import { isProduction } from './constants'; import GameLoop from './game-loop/game-loop'; -import { GameRules } from './game-loop/game-rules'; 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 { 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 { MenuHider } from './page/menu-hider'; -import { setUpSettingsPage } from './page/set-up-settings-page'; -import { SettingsSlider } from './page/settings-slider'; -import { resetSettings } from './settings'; +import { MirrorSegmentControl } from './page/mirror-segment-control'; +import { PaletteControl } from './page/palette-control'; +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 { queryRequiredElement } from './utils/dom'; import { ErrorHandler, Severity } from './utils/error-handler'; 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 () => { + let hasRuntimeErrorListener = false; try { + initAnalytics(); + let shouldStop = false; + let hasStarted = false; let game: GameLoop | null = null; - - ErrorHandler.addOnErrorListener((error, _metadata) => { - elements.errorContainer.innerHTML += ` -
${error.message}
- `;
- game?.destroy();
- 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();
- }
-
- new MenuHider(
- elements.aside,
- () =>
- FullScreenHandler.isInFullScreenMode() &&
- !settingsPageHandler.isOpen &&
- !infoPageHandler.isOpen
- );
- new FullScreenHandler(
- elements.minimizeFullScreenButton,
- elements.maximizeFullScreenButton,
- document.body
- );
-
- const gpu = await initializeGpu();
-
- elements.restartButton.addEventListener('click', () => game?.destroy());
-
- const deltaTimeCalculator = new DeltaTimeCalculator();
- let sliders: Array> = [];
-
- elements.applyDefaults.addEventListener('click', () => {
- resetSettings();
- sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
- });
-
- while (!shouldStop) {
- const gameRules = new GameRules(performance.now() / 1000);
- game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
-
- if (sliders.length === 0) {
- sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
+ let configPane: ConfigPane | null = null;
+ const getGame = () => game;
+ const destroyCurrentGame = async () => {
+ const currentGame = game;
+ if (!currentGame) {
+ return;
}
- await game.start();
+ 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(
+ aside,
+ () =>
+ FullScreenHandler.isInFullScreenMode() &&
+ !configPane?.isOpen &&
+ !infoPageHandler.isOpen
+ );
+ new FullScreenHandler(fullScreenButton, document.documentElement);
+
+ new VibeNavigator({
+ onChange: ({ vibeId, vibeName, source, userGesture }) => {
+ trackVibeChange({ vibeId, vibeName, source });
+ game?.onVibeChanged();
+ syncRuntimeUi();
+ configPane?.refresh();
+ game?.playVibeChangeAudio(userGesture);
+ },
+ });
+
+ restartButton.addEventListener('click', () => void destroyCurrentGame());
+
+ export4kButton.addEventListener('click', async () => {
+ const currentGame = game;
+ if (!currentGame || export4kButton.disabled) {
+ return;
+ }
+
+ 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) {
- const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
- ErrorHandler.addError(Severity.ERROR, message);
- console.error(e);
+ document.body.classList.remove('is-loading');
+ if (hasRuntimeErrorListener) {
+ ErrorHandler.addException(e);
+ } else {
+ ErrorPresenter.renderStartup(e);
+ ErrorHandler.addException(e);
+ }
}
};
diff --git a/src/page/collapsible-panel-animator.ts b/src/page/collapsible-panel-animator.ts
index d4c91fa..a86a637 100644
--- a/src/page/collapsible-panel-animator.ts
+++ b/src/page/collapsible-panel-animator.ts
@@ -1,44 +1,90 @@
export class CollapsiblePanelAnimator {
private _isOpen = false;
-
- public onOpen: () => unknown = () => {};
- public onClose: () => unknown = () => {};
+ private focusBeforeOpen: HTMLElement | null = null;
+ private readonly abortController = new AbortController();
+ public onOpen?: () => void;
public constructor(
private readonly toggleButton: HTMLButtonElement,
private readonly collapsibleContent: HTMLElement,
ignoreForCloseOnClick: HTMLElement
) {
- toggleButton.addEventListener('click', this.toggle.bind(this));
+ const { signal } = this.abortController;
+ toggleButton.addEventListener('click', this.toggle, { signal });
window.addEventListener(
'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() {
+ if (this._isOpen) {
+ return;
+ }
+
+ this.focusBeforeOpen =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
this._isOpen = true;
- this.collapsibleContent.classList.remove('hidden');
- this.toggleButton.classList.add('active');
- this.onOpen();
+ this.onOpen?.();
+ this.syncAccessibility();
+ this.focusPanel();
}
public close() {
+ if (!this._isOpen) {
+ return;
+ }
+
+ const focusWasInside = this.collapsibleContent.contains(document.activeElement);
this._isOpen = false;
- this.collapsibleContent.classList.add('hidden');
- this.toggleButton.classList.remove('active');
- this.onClose();
+ this.syncAccessibility();
+
+ if (focusWasInside) {
+ (this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
+ }
}
- public toggle() {
+ public readonly toggle = () => {
if (this._isOpen) {
this.close();
} else {
this.open();
}
+ };
+
+ public destroy(): void {
+ this.abortController.abort();
}
public get 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 });
+ }
+ });
+ }
}
diff --git a/src/page/color-reaction-matrix-control.ts b/src/page/color-reaction-matrix-control.ts
new file mode 100644
index 0000000..ca78cbb
--- /dev/null
+++ b/src/page/color-reaction-matrix-control.ts
@@ -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;
+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`;
+ }
+}
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
new file mode 100644
index 0000000..3e6c397
--- /dev/null
+++ b/src/page/config-pane.ts
@@ -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;
+type RuntimeControlKey = keyof GardenRuntimeSettings & string;
+type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
+type NumberPropertyKey = {
+ [Key in keyof T]-?: T[Key] extends number ? Key : never;
+}[keyof T] &
+ string;
+type VibeNumberKey = NumberPropertyKey;
+
+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 =>
+ (
+ 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 {
+ 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;
+ }
+}
diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts
new file mode 100644
index 0000000..1099821
--- /dev/null
+++ b/src/page/eraser-size-control.ts
@@ -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'
+ );
+ }
+}
diff --git a/src/page/error-presenter.ts b/src/page/error-presenter.ts
new file mode 100644
index 0000000..835c902
--- /dev/null
+++ b/src/page/error-presenter.ts
@@ -0,0 +1,62 @@
+import {
+ ErrorHandler,
+ getErrorMessage,
+ RuntimeError,
+ Severity,
+} from '../utils/error-handler';
+
+type RuntimeUiError = Parameters<
+ Parameters[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));
+ }
+}
diff --git a/src/page/full-screen-handler.ts b/src/page/full-screen-handler.ts
index 84c9150..498c08c 100644
--- a/src/page/full-screen-handler.ts
+++ b/src/page/full-screen-handler.ts
@@ -1,43 +1,46 @@
export class FullScreenHandler {
+ private readonly abortController = new AbortController();
+
public constructor(
- private readonly minimizeButton: HTMLElement,
- private readonly maximizeButton: HTMLElement,
+ private readonly toggleButton: HTMLElement,
target: HTMLElement
) {
- if (!document.fullscreenEnabled) {
- minimizeButton.style.display = 'none';
- maximizeButton.style.display = 'none';
+ if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
+ toggleButton.hidden = true;
return;
}
this.updateButtons();
- addEventListener('keydown', (e) => {
- // on full screen request, only apply it to the target
- if (e.key === 'F11') {
- e.preventDefault();
+ const { signal } = this.abortController;
+ addEventListener('fullscreenchange', this.updateButtons, { signal });
+ toggleButton.addEventListener(
+ 'click',
+ () => {
if (FullScreenHandler.isInFullScreenMode()) {
- document.exitFullscreen();
- } else {
- target.requestFullscreen();
+ void document.exitFullscreen();
+ return;
}
- }
- });
- addEventListener('fullscreenchange', this.updateButtons.bind(this));
- maximizeButton.addEventListener('click', () => target.requestFullscreen());
- minimizeButton.addEventListener('click', () => document.exitFullscreen());
+
+ void target.requestFullscreen().catch(() => undefined);
+ },
+ { signal }
+ );
}
public static isInFullScreenMode(): boolean {
return document.fullscreenElement !== null;
}
- private updateButtons() {
- this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
- ? 'block'
- : 'none';
- this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
- ? 'none'
- : 'block';
+ public destroy(): void {
+ this.abortController.abort();
}
+
+ 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;
+ };
}
diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts
index 173a5b6..ed8aa59 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,17 +1,139 @@
+import { appConfig } from '../config';
+
export class MenuHider {
- private static readonly DEFAULT_TIME_TO_LIVE = 3500;
- private static readonly INTERVAL = 50;
- private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+ private readonly desktopMediaQuery = window.matchMedia(
+ appConfig.menuHider.desktopMediaQuery
+ );
+ private hideTimeout: number | undefined;
+ private isHidden = false;
+ private pointerInside = false;
- public constructor(element: HTMLElement, shouldBeHidden: () => boolean) {
- setInterval(() => {
- this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
- element.style.opacity = this.timeToLive == 0 && shouldBeHidden() ? '0' : '1';
- }, MenuHider.INTERVAL);
+ public constructor(
+ private readonly element: HTMLElement,
+ private readonly shouldBeHidden: () => boolean
+ ) {
+ 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(
- 'mouseover',
- () => (this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE)
+ this.reveal();
+ }
+
+ 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;
+ }
}
diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts
new file mode 100644
index 0000000..1ea8ddc
--- /dev/null
+++ b/src/page/mirror-segment-control.ts
@@ -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`);
+ }
+}
diff --git a/src/page/palette-control.ts b/src/page/palette-control.ts
new file mode 100644
index 0000000..9007d8f
--- /dev/null
+++ b/src/page/palette-control.ts
@@ -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 => {
+ 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;
+};
diff --git a/src/page/set-up-settings-page.ts b/src/page/set-up-settings-page.ts
deleted file mode 100644
index 238d704..0000000
--- a/src/page/set-up-settings-page.ts
+++ /dev/null
@@ -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> => {
- const sliders: Array> = [
- ...(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;
-};
diff --git a/src/page/settings-slider.ts b/src/page/settings-slider.ts
deleted file mode 100644
index d7ad26b..0000000
--- a/src/page/settings-slider.ts
+++ /dev/null
@@ -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> {
- 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 = {}
- ) {
- 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) {
- 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);
- }
- }
-}
diff --git a/src/page/splash-screen.ts b/src/page/splash-screen.ts
new file mode 100644
index 0000000..00d44a4
--- /dev/null
+++ b/src/page/splash-screen.ts
@@ -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 {
+ this.startButton.disabled = false;
+ return new Promise((resolve) => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter' || event.defaultPrevented) {
+ return;
+ }
+
+ event.preventDefault();
+ this.startButton.click();
+ };
+ const onClick = () => {
+ this.startButton.removeEventListener('click', onClick);
+ document.removeEventListener('keydown', onKeyDown);
+ onStart();
+ this.setVisible(this.splash, false);
+ resolve();
+ };
+
+ this.startButton.addEventListener('click', onClick);
+ document.addEventListener('keydown', onKeyDown);
+ });
+ }
+
+ public showLoadingBar(): void {
+ this.setVisible(this.loadingBar, true);
+ }
+
+ public hideLoadingBar(): void {
+ this.setVisible(this.loadingBar, false);
+ }
+}
diff --git a/src/page/vibe-navigator.ts b/src/page/vibe-navigator.ts
new file mode 100644
index 0000000..fc6ecc0
--- /dev/null
+++ b/src/page/vibe-navigator.ts
@@ -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,
+ });
+ }
+}
diff --git a/src/utils/dom.ts b/src/utils/dom.ts
new file mode 100644
index 0000000..b2cbcd2
--- /dev/null
+++ b/src/utils/dom.ts
@@ -0,0 +1,24 @@
+import { ErrorCode, RuntimeError } from './error-handler';
+
+type ElementConstructor = abstract new () => T;
+
+export const queryRequiredElement = (
+ selector: string,
+ constructor: ElementConstructor
+): 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;
+};