Wire up garden controls
This commit is contained in:
parent
018f8c9d4d
commit
839747304e
15 changed files with 1457 additions and 393 deletions
292
src/index.ts
292
src/index.ts
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
187
src/page/color-reaction-matrix-control.ts
Normal file
187
src/page/color-reaction-matrix-control.ts
Normal 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
365
src/page/config-pane.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/page/eraser-size-control.ts
Normal file
84
src/page/eraser-size-control.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/page/error-presenter.ts
Normal file
62
src/page/error-presenter.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
src/page/mirror-segment-control.ts
Normal file
66
src/page/mirror-segment-control.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/page/palette-control.ts
Normal file
76
src/page/palette-control.ts
Normal 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>;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
@ -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
61
src/page/splash-screen.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/page/vibe-navigator.ts
Normal file
85
src/page/vibe-navigator.ts
Normal 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
24
src/utils/dom.ts
Normal 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;
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue