This commit is contained in:
Andras Schmelczer 2026-05-18 08:11:52 +01:00
commit 15e99380b5
84 changed files with 1659 additions and 4726 deletions

View file

@ -2,16 +2,22 @@ import GameLoop from './game-loop/game-loop';
import './index.scss';
import { initAnalytics, trackExport, trackVibeChange } from './analytics';
import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics';
import {
APP_STORAGE_KEYS,
DEFAULT_AUDIO_VOLUME,
DISABLED_FLAG_VALUE,
ENABLED_FLAG_VALUE,
UNIT_INTERVAL_INPUT_MAX,
UNIT_INTERVAL_INPUT_MIN,
} from './app-constants';
import { preloadPianoSamples } from './audio/piano-samples';
import { appConfig } from './config';
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
import { ConfigPane } from './page/config-pane';
import { FullScreenHandler } from './page/full-screen-handler';
import { MenuHider } from './page/menu-hider';
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
import { clamp } from './utils/clamp';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
import {
@ -21,56 +27,165 @@ import {
Severity,
} from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu';
import { clamp01 } from './utils/math';
import { VIBE_PRESETS } from './vibes';
const AUDIO_VOLUME_STEP = 0.01;
const ERASER_CONTROL_SCALE_MAX = 1.33;
const ERASER_CONTROL_SCALE_MIN = 0.75;
const ERASER_SIZE_DEFAULT = 96;
const ERASER_SIZE_MAX = 240;
const ERASER_SIZE_MIN = 24;
const ERASER_SIZE_STEP = 1;
const MIRROR_SEGMENT_DEFAULT = 1;
const MIRROR_SEGMENT_MAX = 12;
const MIRROR_SEGMENT_MIN = 1;
const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
const MIRROR_SEGMENT_STEP = 1;
const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
const ELEMENT_TAGS = {
div: 'div',
pre: 'pre',
} as const;
const ARIA_ATTRIBUTES = {
label: 'aria-label',
live: 'aria-live',
pressed: 'aria-pressed',
role: 'role',
valueNow: 'aria-valuenow',
valueText: 'aria-valuetext',
} as const;
const ARIA_LIVE_VALUES = {
assertive: 'assertive',
polite: 'polite',
} as const;
const ARIA_ROLES = {
alert: 'alert',
status: 'status',
} as const;
const CSS_CLASSES = {
active: 'active',
errorsContainer: 'errors-container',
isLoading: 'is-loading',
muted: 'muted',
preDrawing: 'pre-drawing',
} as const;
const CSS_VARIABLES = {
eraserControlScale: '--eraser-control-scale',
eraserProgress: '--eraser-progress',
gardenBackground: '--garden-background',
loadingProgress: '--loading-progress',
mirrorAngle: '--mirror-angle',
mirrorProgress: '--mirror-progress',
volumeProgress: '--volume-progress',
} as const;
const DOM_EVENTS = {
click: 'click',
focus: 'focus',
input: 'input',
keydown: 'keydown',
pointerDown: 'pointerdown',
pointerUp: 'pointerup',
touchEnd: 'touchend',
touchStart: 'touchstart',
} as const;
const APP_SELECTORS = {
aside: 'aside',
canvas: 'canvas',
eraserPreview: '.eraser-preview',
eraserSizeControl: '.eraser-size-control',
eraserSizeSlider: '.eraser-size-slider',
errorContainer: '.errors-container',
export4k: '.export-4k',
exportStatus: '.export-status',
infoButton: 'button.info',
infoElement: '.info-page',
loadingBar: '.loading-bar',
loadingProgress: '.loading-progress',
loadingStatus: '.loading-status',
maximizeFullScreenButton: 'button.maximize-full-screen',
minimizeFullScreenButton: 'button.minimize-full-screen',
mirrorSegmentControl: '.mirror-segment-control',
mirrorSegmentSlider: '.mirror-segment-slider',
nextVibe: '.next-vibe',
previousVibe: '.previous-vibe',
prompt: '.garden-prompt',
restartButton: 'button.restart',
settingsButton: 'button.settings',
soundButton: 'button.sound',
splash: '.splash',
startButton: '.start-button',
swatches: '.color-swatch',
toolbarRow: '.toolbar-row',
volumeControl: '.volume-control',
volumeSlider: '.volume-slider',
} as const;
const AUDIO_LABELS = {
mutedPrefix: 'Muted',
mute: 'Mute audio',
unmute: 'Unmute audio',
volumeSuffix: 'volume',
} as const;
const LOADING_MESSAGES = {
fontsError: 'Could not load fonts.',
pianoSamplesError: 'Could not preload piano samples.',
ready: 'Ready',
} as const;
const VIBE_CHANGE_SOURCES = {
nextButton: 'next-button',
previousButton: 'previous-button',
settings: 'settings',
} as const;
const clampEraserSize = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.eraser.default;
return Math.min(
appConfig.toolbar.eraser.max,
Math.max(appConfig.toolbar.eraser.min, Math.round(safeValue))
);
const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
};
const getEraserSizeRatio = (size: number): number =>
(size - appConfig.toolbar.eraser.min) /
(appConfig.toolbar.eraser.max - appConfig.toolbar.eraser.min);
(size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
const clampMirrorSegmentCount = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.mirror.default;
const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT;
return Math.min(
appConfig.toolbar.mirror.max,
Math.max(appConfig.toolbar.mirror.min, Math.round(safeValue))
MIRROR_SEGMENT_MAX,
Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue))
);
};
const getMirrorSegmentRatio = (count: number): number =>
(count - appConfig.toolbar.mirror.min) /
(appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min);
const mirrorSegmentNames: Readonly<Record<number, string>> =
appConfig.toolbar.mirror.names;
(count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN);
const formatMirrorSegmentCount = (count: number): string =>
count === appConfig.toolbar.mirror.default
? appConfig.toolbar.mirror.offLabel
: `${count} ${mirrorSegmentNames[count] ?? appConfig.toolbar.mirror.fallbackSegmentName}`;
count === MIRROR_SEGMENT_DEFAULT
? MIRROR_SEGMENT_OFF_LABEL
: `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`;
const clampAudioVolume = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.volume.default;
return clamp(safeValue, appConfig.toolbar.volume.min, appConfig.toolbar.volume.max);
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
return clamp01(safeValue);
};
const getAudioVolumeRatio = (volume: number): number =>
(volume - appConfig.toolbar.volume.min) /
(appConfig.toolbar.volume.max - appConfig.toolbar.volume.min);
const getAudioVolumePercent = (volume: number): number =>
Math.round(getAudioVolumeRatio(volume) * 100);
Math.round(clampAudioVolume(volume) * 100);
const readInitialAudioVolume = (): number => {
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume);
return storedVolume === null
? appConfig.toolbar.volume.default
? DEFAULT_AUDIO_VOLUME
: clampAudioVolume(Number(storedVolume));
};
@ -82,13 +197,18 @@ type RuntimeUiError = Parameters<
>[0];
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
const message = document.createElement('pre');
const message = document.createElement(ELEMENT_TAGS.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'
ARIA_ATTRIBUTES.role,
error.severity === Severity.ERROR ? ARIA_ROLES.alert : ARIA_ROLES.status
);
message.setAttribute(
ARIA_ATTRIBUTES.live,
error.severity === Severity.ERROR
? ARIA_LIVE_VALUES.assertive
: ARIA_LIVE_VALUES.polite
);
container.append(message);
@ -105,54 +225,69 @@ const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
});
const renderStartupException = (exception: unknown) => {
const existingContainer = document.querySelector('.errors-container');
const existingContainer = document.querySelector(APP_SELECTORS.errorContainer);
const container =
existingContainer instanceof HTMLElement
? existingContainer
: document.createElement('div');
: document.createElement(ELEMENT_TAGS.div);
if (!(existingContainer instanceof HTMLElement)) {
container.className = 'errors-container';
container.className = CSS_CLASSES.errorsContainer;
document.body.append(container);
}
container.setAttribute('aria-live', 'assertive');
container.setAttribute(ARIA_ATTRIBUTES.live, ARIA_LIVE_VALUES.assertive);
renderRuntimeMessage(container, getRuntimeUiError(exception));
};
const queryAppElements = () => ({
aside: queryRequiredElement('aside', HTMLElement),
toolbarRow: queryRequiredElement('.toolbar-row', HTMLElement),
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
infoElement: queryRequiredElement('.info-page', HTMLElement),
aside: queryRequiredElement(APP_SELECTORS.aside, HTMLElement),
toolbarRow: queryRequiredElement(APP_SELECTORS.toolbarRow, HTMLElement),
infoButton: queryRequiredElement(APP_SELECTORS.infoButton, HTMLButtonElement),
infoElement: queryRequiredElement(APP_SELECTORS.infoElement, HTMLElement),
minimizeFullScreenButton: queryRequiredElement(
'button.minimize-full-screen',
APP_SELECTORS.minimizeFullScreenButton,
HTMLButtonElement
),
maximizeFullScreenButton: queryRequiredElement(
'button.maximize-full-screen',
APP_SELECTORS.maximizeFullScreenButton,
HTMLButtonElement
),
settingsButton: queryRequiredElement('button.settings', HTMLButtonElement),
soundButton: queryRequiredElement('button.sound', HTMLButtonElement),
volumeControl: queryRequiredElement('.volume-control', HTMLLabelElement),
volumeSlider: queryRequiredElement('.volume-slider', HTMLInputElement),
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
errorContainer: queryRequiredElement('.errors-container', HTMLElement),
previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement),
nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement),
swatches: queryRequiredElements('.color-swatch', HTMLButtonElement),
eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement),
eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement),
mirrorSegmentControl: queryRequiredElement('.mirror-segment-control', HTMLLabelElement),
mirrorSegmentSlider: queryRequiredElement('.mirror-segment-slider', HTMLInputElement),
export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
settingsButton: queryRequiredElement(APP_SELECTORS.settingsButton, HTMLButtonElement),
soundButton: queryRequiredElement(APP_SELECTORS.soundButton, HTMLButtonElement),
volumeControl: queryRequiredElement(APP_SELECTORS.volumeControl, HTMLLabelElement),
volumeSlider: queryRequiredElement(APP_SELECTORS.volumeSlider, HTMLInputElement),
restartButton: queryRequiredElement(APP_SELECTORS.restartButton, HTMLButtonElement),
canvas: queryRequiredElement(APP_SELECTORS.canvas, HTMLCanvasElement),
eraserPreview: queryRequiredElement(APP_SELECTORS.eraserPreview, HTMLDivElement),
errorContainer: queryRequiredElement(APP_SELECTORS.errorContainer, HTMLElement),
previousVibe: queryRequiredElement(APP_SELECTORS.previousVibe, HTMLButtonElement),
nextVibe: queryRequiredElement(APP_SELECTORS.nextVibe, HTMLButtonElement),
swatches: queryRequiredElements(APP_SELECTORS.swatches, HTMLButtonElement),
eraserSizeControl: queryRequiredElement(
APP_SELECTORS.eraserSizeControl,
HTMLLabelElement
),
eraserSizeSlider: queryRequiredElement(
APP_SELECTORS.eraserSizeSlider,
HTMLInputElement
),
mirrorSegmentControl: queryRequiredElement(
APP_SELECTORS.mirrorSegmentControl,
HTMLLabelElement
),
mirrorSegmentSlider: queryRequiredElement(
APP_SELECTORS.mirrorSegmentSlider,
HTMLInputElement
),
export4k: queryRequiredElement(APP_SELECTORS.export4k, HTMLButtonElement),
exportStatus: queryRequiredElement(APP_SELECTORS.exportStatus, HTMLSpanElement),
prompt: queryRequiredElement(APP_SELECTORS.prompt, HTMLDivElement),
loadingStatus: queryRequiredElement(APP_SELECTORS.loadingStatus, HTMLDivElement),
loadingProgress: queryRequiredElement(APP_SELECTORS.loadingProgress, HTMLDivElement),
splash: queryRequiredElement(APP_SELECTORS.splash, HTMLDivElement),
loadingBar: queryRequiredElement(APP_SELECTORS.loadingBar, HTMLDivElement),
startButton: queryRequiredElement(APP_SELECTORS.startButton, HTMLButtonElement),
});
type AppElements = ReturnType<typeof queryAppElements>;
@ -160,52 +295,62 @@ type AppElements = ReturnType<typeof queryAppElements>;
let elements: AppElements;
const setLoadingStage = (label: string, ratio: number) => {
const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
const percent = Math.round(clamp01(ratio) * 100);
elements.loadingStatus.textContent = label;
elements.loadingProgress.style.setProperty('--loading-progress', `${percent}%`);
elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
elements.loadingProgress.style.setProperty(
CSS_VARIABLES.loadingProgress,
`${percent}%`
);
elements.loadingProgress.setAttribute(ARIA_ATTRIBUTES.valueNow, String(percent));
};
let audioVolume = readInitialAudioVolume();
let isAudioMuted =
readBrowserStorage(appConfig.storage.audioMutedKey) === '1' ||
audioVolume <= appConfig.toolbar.volume.min;
readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE ||
audioVolume <= 0;
let isEraserActive = false;
const persistAudioUiState = () => {
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
writeBrowserStorage(
appConfig.storage.audioVolumeKey,
formatStoredAudioVolume(audioVolume)
APP_STORAGE_KEYS.audioMuted,
isAudioMuted ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
);
writeBrowserStorage(APP_STORAGE_KEYS.audioVolume, formatStoredAudioVolume(audioVolume));
};
const renderAudioUi = (game: GameLoop | null) => {
audioVolume = clampAudioVolume(audioVolume);
const isEffectivelyMuted = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min;
const isEffectivelyMuted = isAudioMuted || audioVolume <= 0;
const volumePercent = getAudioVolumePercent(audioVolume);
elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
elements.soundButton.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
elements.soundButton.setAttribute(ARIA_ATTRIBUTES.pressed, String(isEffectivelyMuted));
elements.soundButton.setAttribute(
'aria-label',
isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
ARIA_ATTRIBUTES.label,
isEffectivelyMuted ? AUDIO_LABELS.unmute : AUDIO_LABELS.mute
);
elements.soundButton.title = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio';
elements.soundButton.title = isEffectivelyMuted
? AUDIO_LABELS.unmute
: AUDIO_LABELS.mute;
elements.volumeSlider.min = appConfig.toolbar.volume.min.toString();
elements.volumeSlider.max = appConfig.toolbar.volume.max.toString();
elements.volumeSlider.step = appConfig.toolbar.volume.step.toString();
elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
elements.volumeSlider.setAttribute(
'aria-valuetext',
isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%`
ARIA_ATTRIBUTES.valueText,
isEffectivelyMuted
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}%`
: `${volumePercent}%`
);
elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
elements.volumeControl.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
elements.volumeControl.title = isEffectivelyMuted
? `Muted, ${volumePercent}% volume`
: `${volumePercent}% volume`;
elements.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`
: `${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`;
elements.volumeControl.style.setProperty(
CSS_VARIABLES.volumeProgress,
`${volumePercent}%`
);
game?.setAudioVolume(audioVolume);
game?.setAudioMuted(isEffectivelyMuted);
@ -215,14 +360,14 @@ const renderPaletteUi = (game: GameLoop | null) => {
elements.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = activeVibe.colors[index];
swatch.classList.toggle(
'active',
CSS_CLASSES.active,
settings.selectedColorIndex === index && !isEraserActive
);
});
elements.eraserSizeControl.classList.toggle('active', isEraserActive);
elements.eraserSizeControl.classList.toggle(CSS_CLASSES.active, isEraserActive);
game?.setEraseMode(isEraserActive);
document.documentElement.style.setProperty(
'--garden-background',
CSS_VARIABLES.gardenBackground,
activeVibe.backgroundColor
);
};
@ -233,21 +378,22 @@ const renderEraserSizeUi = (game: GameLoop | null) => {
settings.eraserSize = size;
}
elements.eraserSizeSlider.min = appConfig.toolbar.eraser.min.toString();
elements.eraserSizeSlider.max = appConfig.toolbar.eraser.max.toString();
elements.eraserSizeSlider.step = appConfig.toolbar.eraser.step.toString();
elements.eraserSizeSlider.min = ERASER_SIZE_MIN.toString();
elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString();
elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString();
elements.eraserSizeSlider.value = size.toString();
elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
elements.eraserSizeSlider.setAttribute(ARIA_ATTRIBUTES.valueText, `${size}px`);
const ratio = getEraserSizeRatio(size);
const scale =
appConfig.toolbar.eraser.controlScaleMin +
(appConfig.toolbar.eraser.controlScaleMax -
appConfig.toolbar.eraser.controlScaleMin) *
ratio;
elements.eraserSizeControl.style.setProperty('--eraser-progress', `${ratio * 100}%`);
ERASER_CONTROL_SCALE_MIN +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
elements.eraserSizeControl.style.setProperty(
'--eraser-control-scale',
CSS_VARIABLES.eraserProgress,
`${ratio * 100}%`
);
elements.eraserSizeControl.style.setProperty(
CSS_VARIABLES.eraserControlScale,
scale.toFixed(3)
);
game?.updateEraserPreview();
@ -259,19 +405,22 @@ const renderMirrorSegmentUi = () => {
settings.mirrorSegmentCount = count;
}
elements.mirrorSegmentSlider.min = appConfig.toolbar.mirror.min.toString();
elements.mirrorSegmentSlider.max = appConfig.toolbar.mirror.max.toString();
elements.mirrorSegmentSlider.step = appConfig.toolbar.mirror.step.toString();
elements.mirrorSegmentSlider.min = MIRROR_SEGMENT_MIN.toString();
elements.mirrorSegmentSlider.max = MIRROR_SEGMENT_MAX.toString();
elements.mirrorSegmentSlider.step = MIRROR_SEGMENT_STEP.toString();
elements.mirrorSegmentSlider.value = count.toString();
const label = formatMirrorSegmentCount(count);
const ratio = getMirrorSegmentRatio(count);
elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
elements.mirrorSegmentSlider.setAttribute(ARIA_ATTRIBUTES.valueText, label);
elements.mirrorSegmentControl.title = label;
elements.mirrorSegmentControl.classList.toggle('active', count > 1);
elements.mirrorSegmentControl.style.setProperty('--mirror-progress', `${ratio * 100}%`);
elements.mirrorSegmentControl.classList.toggle(CSS_CLASSES.active, count > 1);
elements.mirrorSegmentControl.style.setProperty(
'--mirror-angle',
CSS_VARIABLES.mirrorProgress,
`${ratio * 100}%`
);
elements.mirrorSegmentControl.style.setProperty(
CSS_VARIABLES.mirrorAngle,
`${(360 / count).toFixed(3)}deg`
);
};
@ -286,11 +435,14 @@ const main = async () => {
let configPane: ConfigPane | null = null;
elements = queryAppElements();
elements.errorContainer.setAttribute('aria-live', 'assertive');
elements.errorContainer.setAttribute(
ARIA_ATTRIBUTES.live,
ARIA_LIVE_VALUES.assertive
);
ErrorHandler.addOnErrorListener((error) => {
renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) {
document.body.classList.remove('is-loading');
document.body.classList.remove(CSS_CLASSES.isLoading);
game?.destroy();
shouldStop = true;
}
@ -325,6 +477,7 @@ const main = async () => {
const startAudioFromUserGesture = (event: Event) => {
if (
isAudioMuted ||
(event.target instanceof Node && elements.startButton.contains(event.target)) ||
(event.target instanceof Node && elements.soundButton.contains(event.target))
) {
return;
@ -333,22 +486,34 @@ const main = async () => {
game?.startAudio(true);
};
window.addEventListener('touchend', startAudioFromUserGesture, {
window.addEventListener(DOM_EVENTS.touchStart, startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener('pointerup', startAudioFromUserGesture, {
window.addEventListener(DOM_EVENTS.pointerDown, startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener('click', startAudioFromUserGesture, { capture: true });
window.addEventListener('keydown', startAudioFromUserGesture, { capture: true });
window.addEventListener(DOM_EVENTS.touchEnd, startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.pointerUp, startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener(DOM_EVENTS.click, startAudioFromUserGesture, {
capture: true,
});
window.addEventListener(DOM_EVENTS.keydown, startAudioFromUserGesture, {
capture: true,
});
elements.restartButton.addEventListener('click', () => game?.destroy());
elements.soundButton.addEventListener('click', () => {
const shouldUnmute = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min;
if (shouldUnmute && audioVolume <= appConfig.toolbar.volume.min) {
audioVolume = appConfig.toolbar.volume.default;
elements.restartButton.addEventListener(DOM_EVENTS.click, () => game?.destroy());
elements.soundButton.addEventListener(DOM_EVENTS.click, () => {
const shouldUnmute = isAudioMuted || audioVolume <= 0;
if (shouldUnmute && audioVolume <= 0) {
audioVolume = DEFAULT_AUDIO_VOLUME;
}
isAudioMuted = !shouldUnmute;
persistAudioUiState();
@ -357,9 +522,9 @@ const main = async () => {
game?.startAudio(true);
}
});
elements.volumeSlider.addEventListener('input', () => {
elements.volumeSlider.addEventListener(DOM_EVENTS.input, () => {
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
isAudioMuted = audioVolume <= appConfig.toolbar.volume.min;
isAudioMuted = audioVolume <= 0;
persistAudioUiState();
renderAudioUi(game);
if (!isAudioMuted) {
@ -383,16 +548,16 @@ const main = async () => {
game?.playVibeChangeAudio(true);
};
elements.previousVibe.addEventListener('click', () =>
selectRelativeVibe(-1, 'previous-button')
elements.previousVibe.addEventListener(DOM_EVENTS.click, () =>
selectRelativeVibe(-1, VIBE_CHANGE_SOURCES.previousButton)
);
elements.nextVibe.addEventListener('click', () =>
selectRelativeVibe(1, 'next-button')
elements.nextVibe.addEventListener(DOM_EVENTS.click, () =>
selectRelativeVibe(1, VIBE_CHANGE_SOURCES.nextButton)
);
elements.swatches.forEach((swatch, index) => {
swatch.addEventListener('click', () => {
swatch.addEventListener(DOM_EVENTS.click, () => {
settings.selectedColorIndex = index;
isEraserActive = false;
renderPaletteUi(game);
@ -405,11 +570,11 @@ const main = async () => {
renderPaletteUi(game);
};
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
elements.eraserSizeControl.addEventListener('click', activateEraser);
elements.eraserSizeSlider.addEventListener('focus', activateEraser);
elements.eraserSizeControl.addEventListener(DOM_EVENTS.pointerDown, activateEraser);
elements.eraserSizeControl.addEventListener(DOM_EVENTS.click, activateEraser);
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.focus, activateEraser);
elements.eraserSizeSlider.addEventListener('input', () => {
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.input, () => {
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
isEraserActive = true;
renderEraserSizeUi(game);
@ -417,7 +582,7 @@ const main = async () => {
configPane?.refresh();
});
elements.mirrorSegmentSlider.addEventListener('input', () => {
elements.mirrorSegmentSlider.addEventListener(DOM_EVENTS.input, () => {
settings.mirrorSegmentCount = clampMirrorSegmentCount(
Number(elements.mirrorSegmentSlider.value)
);
@ -427,7 +592,7 @@ const main = async () => {
configPane?.refresh();
});
elements.export4k.addEventListener('click', async () => {
elements.export4k.addEventListener(DOM_EVENTS.click, async () => {
if (!game || elements.export4k.disabled) {
return;
}
@ -448,14 +613,36 @@ const main = async () => {
renderMirrorSegmentUi();
renderAudioUi(game);
// Loading runs in the background while the splash (title + description +
// Start button) is shown. The Start tap is the user gesture that unlocks
// the AudioContext on iOS, and gates the intro.
const fontsReady = document.fonts.ready.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load fonts.',
fallbackMessage: LOADING_MESSAGES.fontsError,
severity: Severity.WARNING,
});
});
setLoadingStage('Connecting to GPU…', 0.1);
const gpu = await initializeGpu();
const gpuPromise = initializeGpu();
let isPreloadComplete = false;
const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => {
const ratio = totalCount > 0 ? loadedCount / totalCount : 0;
setLoadingStage(`Loading piano samples ${loadedCount}/${totalCount}…`, ratio);
}).then(
() => {
isPreloadComplete = true;
setLoadingStage(LOADING_MESSAGES.ready, 1);
},
(error: unknown) => {
isPreloadComplete = true;
ErrorHandler.addException(error, {
fallbackMessage: LOADING_MESSAGES.pianoSamplesError,
severity: Severity.WARNING,
});
}
);
const gpu = await gpuPromise;
configPane = new ConfigPane({
settingsButton: elements.settingsButton,
onConfigChange: () => {
@ -480,7 +667,7 @@ const main = async () => {
trackVibeChange({
vibeId: activePreset.id,
vibeName: activePreset.name,
source: 'settings',
source: VIBE_CHANGE_SOURCES.settings,
});
game?.onVibeChanged();
syncRuntimeUi();
@ -488,17 +675,7 @@ const main = async () => {
},
});
infoPageHandler.onOpen = configPane.close.bind(configPane);
setLoadingStage('Loading fonts…', 0.3);
await fontsReady;
setLoadingStage('Loading piano samples…', 0.45);
await preloadPianoSamples(({ loadedCount, totalCount }) => {
const sampleRatio = totalCount > 0 ? loadedCount / totalCount : 1;
setLoadingStage(
`Loading piano samples ${loadedCount}/${totalCount}…`,
0.45 + sampleRatio * 0.3
);
});
setLoadingStage('Compiling shaders…', 0.8);
const deltaTimeCalculator = new DeltaTimeCalculator();
@ -515,18 +692,48 @@ const main = async () => {
renderMirrorSegmentUi();
renderAudioUi(game);
const startPromise = game.start();
if (isFirstStart) {
isFirstStart = false;
setLoadingStage('Ready', 1);
// Splash is in the DOM by default; enable the button now that the
// audio system (GameLoop) is constructed and ready to be unlocked.
elements.startButton.disabled = false;
await new Promise<void>((resolve) => {
const onClick = () => {
elements.startButton.removeEventListener(DOM_EVENTS.click, onClick);
game?.startAudio(true);
trackStart();
elements.splash.hidden = true;
resolve();
};
elements.startButton.addEventListener(DOM_EVENTS.click, onClick);
});
if (!isPreloadComplete) {
elements.loadingBar.hidden = false;
void preloadPromise.finally(() => {
elements.loadingBar.hidden = true;
});
}
// Keep the toolbar/dock hidden until the user actually starts drawing.
document.body.classList.add(CSS_CLASSES.preDrawing);
elements.canvas.addEventListener(
DOM_EVENTS.pointerDown,
() => document.body.classList.remove(CSS_CLASSES.preDrawing),
{ once: true }
);
requestAnimationFrame(() =>
requestAnimationFrame(() => document.body.classList.remove('is-loading'))
requestAnimationFrame(() =>
document.body.classList.remove(CSS_CLASSES.isLoading)
)
);
}
await startPromise;
await game.start();
}
} catch (e) {
document.body.classList.remove('is-loading');
document.body.classList.remove(CSS_CLASSES.isLoading);
if (hasRuntimeErrorListener) {
ErrorHandler.addException(e);
} else {