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 { 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;
|
||||
let configPane: ConfigPane | null = null;
|
||||
const getGame = () => game;
|
||||
const destroyCurrentGame = async () => {
|
||||
const currentGame = game;
|
||||
if (!currentGame) {
|
||||
return;
|
||||
}
|
||||
|
||||
game = null;
|
||||
await currentGame.destroy();
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
try {
|
||||
let shouldStop = false;
|
||||
let game: GameLoop | null = null;
|
||||
|
||||
ErrorHandler.addOnErrorListener((error, _metadata) => {
|
||||
elements.errorContainer.innerHTML += `
|
||||
<pre class="${error.severity}">${error.message}</div>
|
||||
`;
|
||||
game?.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 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();
|
||||
}
|
||||
const syncRuntimeUi = () => {
|
||||
eraserSizeControl?.render();
|
||||
eraserSizeControl?.setActive(paletteControl.isEraserActive);
|
||||
mirrorSegmentControl.render();
|
||||
paletteControl.render();
|
||||
};
|
||||
|
||||
const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
|
||||
new MenuHider(
|
||||
elements.aside,
|
||||
aside,
|
||||
() =>
|
||||
FullScreenHandler.isInFullScreenMode() &&
|
||||
!settingsPageHandler.isOpen &&
|
||||
!configPane?.isOpen &&
|
||||
!infoPageHandler.isOpen
|
||||
);
|
||||
new FullScreenHandler(
|
||||
elements.minimizeFullScreenButton,
|
||||
elements.maximizeFullScreenButton,
|
||||
document.body
|
||||
);
|
||||
new FullScreenHandler(fullScreenButton, document.documentElement);
|
||||
|
||||
const gpu = await initializeGpu();
|
||||
|
||||
elements.restartButton.addEventListener('click', () => game?.destroy());
|
||||
|
||||
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||
let sliders: Array<SettingsSlider<any>> = [];
|
||||
|
||||
elements.applyDefaults.addEventListener('click', () => {
|
||||
resetSettings();
|
||||
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
|
||||
new VibeNavigator({
|
||||
onChange: ({ vibeId, vibeName, source, userGesture }) => {
|
||||
trackVibeChange({ vibeId, vibeName, source });
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
configPane?.refresh();
|
||||
game?.playVibeChangeAudio(userGesture);
|
||||
},
|
||||
});
|
||||
|
||||
while (!shouldStop) {
|
||||
const gameRules = new GameRules(performance.now() / 1000);
|
||||
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
|
||||
restartButton.addEventListener('click', () => void destroyCurrentGame());
|
||||
|
||||
if (sliders.length === 0) {
|
||||
sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
|
||||
export4kButton.addEventListener('click', async () => {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
this._isOpen = false;
|
||||
this.collapsibleContent.classList.add('hidden');
|
||||
this.toggleButton.classList.remove('active');
|
||||
this.onClose();
|
||||
if (!this._isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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