more
This commit is contained in:
parent
2fe3c69963
commit
6bc125be1c
104 changed files with 3084 additions and 2410 deletions
638
src/index.ts
638
src/index.ts
|
|
@ -3,356 +3,24 @@ import GameLoop from './game-loop/game-loop';
|
|||
import './index.scss';
|
||||
|
||||
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 { 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 { activeVibe, applyVibeSettings, settings } from './settings';
|
||||
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
|
||||
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, queryRequiredElements } from './utils/dom';
|
||||
import {
|
||||
ErrorHandler,
|
||||
getErrorMessage,
|
||||
RuntimeError,
|
||||
Severity,
|
||||
} from './utils/error-handler';
|
||||
import { queryRequiredElement } from './utils/dom';
|
||||
import { ErrorHandler, Severity } from './utils/error-handler';
|
||||
import { initializeGpu } from './utils/graphics/initialize-gpu';
|
||||
import { clamp01 } from './utils/math';
|
||||
import { rgbColorToCss } from './utils/rgb-color';
|
||||
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 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 clampEraserSize = (value: number): number => {
|
||||
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 - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
|
||||
|
||||
const clampMirrorSegmentCount = (value: number): number => {
|
||||
const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT;
|
||||
return Math.min(
|
||||
MIRROR_SEGMENT_MAX,
|
||||
Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue))
|
||||
);
|
||||
};
|
||||
|
||||
const getMirrorSegmentRatio = (count: number): number =>
|
||||
(count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN);
|
||||
|
||||
const formatMirrorSegmentCount = (count: number): string =>
|
||||
count === MIRROR_SEGMENT_DEFAULT
|
||||
? MIRROR_SEGMENT_OFF_LABEL
|
||||
: `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`;
|
||||
|
||||
const clampAudioVolume = (value: number): number => {
|
||||
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
|
||||
return clamp01(safeValue);
|
||||
};
|
||||
|
||||
const getAudioVolumePercent = (volume: number): number =>
|
||||
Math.round(clampAudioVolume(volume) * 100);
|
||||
|
||||
const readInitialAudioVolume = (): number => {
|
||||
const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume);
|
||||
return storedVolume === null
|
||||
? DEFAULT_AUDIO_VOLUME
|
||||
: clampAudioVolume(Number(storedVolume));
|
||||
};
|
||||
|
||||
const formatStoredAudioVolume = (volume: number): string =>
|
||||
clampAudioVolume(volume).toFixed(2);
|
||||
|
||||
type RuntimeUiError = Parameters<
|
||||
Parameters<typeof ErrorHandler.addOnErrorListener>[0]
|
||||
>[0];
|
||||
|
||||
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
|
||||
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 } : {}),
|
||||
});
|
||||
|
||||
const renderStartupException = (exception: unknown) => {
|
||||
const existingContainer = document.querySelector(APP_SELECTORS.errorContainer);
|
||||
const container =
|
||||
existingContainer instanceof HTMLElement
|
||||
? existingContainer
|
||||
: document.createElement('div');
|
||||
|
||||
if (!(existingContainer instanceof HTMLElement)) {
|
||||
container.className = 'errors-container';
|
||||
document.body.append(container);
|
||||
}
|
||||
|
||||
container.setAttribute('aria-live', 'assertive');
|
||||
renderRuntimeMessage(container, getRuntimeUiError(exception));
|
||||
};
|
||||
|
||||
const queryAppElements = () => ({
|
||||
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(
|
||||
APP_SELECTORS.minimizeFullScreenButton,
|
||||
HTMLButtonElement
|
||||
),
|
||||
maximizeFullScreenButton: queryRequiredElement(
|
||||
APP_SELECTORS.maximizeFullScreenButton,
|
||||
HTMLButtonElement
|
||||
),
|
||||
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>;
|
||||
|
||||
let elements: AppElements;
|
||||
|
||||
const setLoadingStage = (label: string, ratio: number) => {
|
||||
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));
|
||||
};
|
||||
|
||||
let audioVolume = readInitialAudioVolume();
|
||||
let isAudioMuted =
|
||||
readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE ||
|
||||
audioVolume <= 0;
|
||||
let isEraserActive = false;
|
||||
|
||||
const persistAudioUiState = () => {
|
||||
writeBrowserStorage(
|
||||
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 <= 0;
|
||||
const volumePercent = getAudioVolumePercent(audioVolume);
|
||||
|
||||
elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
|
||||
elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
|
||||
elements.soundButton.setAttribute(
|
||||
'aria-label',
|
||||
isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
|
||||
);
|
||||
elements.soundButton.title = isEffectivelyMuted
|
||||
? 'Unmute audio'
|
||||
: 'Mute audio';
|
||||
|
||||
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}%`
|
||||
);
|
||||
elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
|
||||
elements.volumeControl.title = isEffectivelyMuted
|
||||
? `${'Muted'}, ${volumePercent}% ${'volume'}`
|
||||
: `${volumePercent}% ${'volume'}`;
|
||||
elements.volumeControl.style.setProperty(
|
||||
'--volume-progress',
|
||||
`${volumePercent}%`
|
||||
);
|
||||
|
||||
game?.setAudioVolume(audioVolume);
|
||||
game?.setAudioMuted(isEffectivelyMuted);
|
||||
};
|
||||
|
||||
const renderPaletteUi = (game: GameLoop | null) => {
|
||||
elements.swatches.forEach((swatch, index) => {
|
||||
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
|
||||
swatch.classList.toggle(
|
||||
'active',
|
||||
settings.selectedColorIndex === index && !isEraserActive
|
||||
);
|
||||
});
|
||||
elements.eraserSizeControl.classList.toggle('active', isEraserActive);
|
||||
game?.setEraseMode(isEraserActive);
|
||||
document.documentElement.style.setProperty(
|
||||
'--garden-background',
|
||||
rgbColorToCss(activeVibe.backgroundColor)
|
||||
);
|
||||
};
|
||||
|
||||
const renderEraserSizeUi = (game: GameLoop | null) => {
|
||||
const size = clampEraserSize(settings.eraserSize);
|
||||
if (settings.eraserSize !== size) {
|
||||
settings.eraserSize = size;
|
||||
}
|
||||
|
||||
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`);
|
||||
|
||||
const ratio = getEraserSizeRatio(size);
|
||||
const scale =
|
||||
ERASER_CONTROL_SCALE_MIN +
|
||||
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
|
||||
elements.eraserSizeControl.style.setProperty(
|
||||
'--eraser-progress',
|
||||
`${ratio * 100}%`
|
||||
);
|
||||
elements.eraserSizeControl.style.setProperty(
|
||||
'--eraser-control-scale',
|
||||
scale.toFixed(3)
|
||||
);
|
||||
game?.updateEraserPreview();
|
||||
};
|
||||
|
||||
const renderMirrorSegmentUi = () => {
|
||||
const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
|
||||
if (settings.mirrorSegmentCount !== count) {
|
||||
settings.mirrorSegmentCount = count;
|
||||
}
|
||||
|
||||
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.mirrorSegmentControl.title = label;
|
||||
elements.mirrorSegmentControl.classList.toggle('active', count > 1);
|
||||
elements.mirrorSegmentControl.style.setProperty(
|
||||
'--mirror-progress',
|
||||
`${ratio * 100}%`
|
||||
);
|
||||
elements.mirrorSegmentControl.style.setProperty(
|
||||
'--mirror-angle',
|
||||
`${(360 / count).toFixed(3)}deg`
|
||||
);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
let hasRuntimeErrorListener = false;
|
||||
|
|
@ -363,14 +31,13 @@ const main = async () => {
|
|||
let hasStarted = false;
|
||||
let game: GameLoop | null = null;
|
||||
let configPane: ConfigPane | null = null;
|
||||
const getGame = () => game;
|
||||
|
||||
elements = queryAppElements();
|
||||
elements.errorContainer.setAttribute(
|
||||
'aria-live',
|
||||
'assertive'
|
||||
const errorPresenter = new ErrorPresenter(
|
||||
queryRequiredElement('.errors-container', HTMLElement)
|
||||
);
|
||||
ErrorHandler.addOnErrorListener((error) => {
|
||||
renderRuntimeMessage(elements.errorContainer, error);
|
||||
errorPresenter.render(error);
|
||||
if (error.severity === Severity.ERROR) {
|
||||
document.body.classList.remove('is-loading');
|
||||
game?.destroy();
|
||||
|
|
@ -379,174 +46,100 @@ const main = async () => {
|
|||
});
|
||||
hasRuntimeErrorListener = true;
|
||||
|
||||
const syncRuntimeUi = (activeGame = game) => {
|
||||
renderEraserSizeUi(game);
|
||||
renderMirrorSegmentUi();
|
||||
renderPaletteUi(activeGame);
|
||||
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('button.settings', HTMLButtonElement);
|
||||
const restartButton = queryRequiredElement('button.restart', HTMLButtonElement);
|
||||
const infoButton = queryRequiredElement('button.info', HTMLButtonElement);
|
||||
const infoElement = queryRequiredElement('.info-page', HTMLElement);
|
||||
const minimizeFullScreenButton = queryRequiredElement(
|
||||
'button.minimize-full-screen',
|
||||
HTMLButtonElement
|
||||
);
|
||||
const maximizeFullScreenButton = queryRequiredElement(
|
||||
'button.maximize-full-screen',
|
||||
HTMLButtonElement
|
||||
);
|
||||
const export4kButton = queryRequiredElement('.export-4k', HTMLButtonElement);
|
||||
|
||||
const splash = new SplashScreen();
|
||||
const paletteControl = new PaletteControl({
|
||||
getGame,
|
||||
onChange: () => configPane?.refresh(),
|
||||
});
|
||||
const eraserSizeControl = new EraserSizeControl({
|
||||
getGame,
|
||||
onActivate: () => paletteControl.setEraserActive(true),
|
||||
onChange: () => configPane?.refresh(),
|
||||
});
|
||||
const mirrorSegmentControl = new MirrorSegmentControl({
|
||||
onChange: () => {
|
||||
paletteControl.setEraserActive(false);
|
||||
configPane?.refresh();
|
||||
},
|
||||
});
|
||||
const audioControl = new AudioControl({
|
||||
getGame,
|
||||
hasStarted: () => hasStarted,
|
||||
startButton: splash.startButton,
|
||||
});
|
||||
|
||||
const syncRuntimeUi = () => {
|
||||
eraserSizeControl.render();
|
||||
mirrorSegmentControl.render();
|
||||
paletteControl.render();
|
||||
};
|
||||
|
||||
const infoPageHandler = new CollapsiblePanelAnimator(
|
||||
elements.infoButton,
|
||||
elements.infoElement,
|
||||
elements.aside
|
||||
);
|
||||
|
||||
const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
|
||||
new MenuHider(
|
||||
elements.aside,
|
||||
aside,
|
||||
() =>
|
||||
FullScreenHandler.isInFullScreenMode() &&
|
||||
!configPane?.isOpen &&
|
||||
!infoPageHandler.isOpen
|
||||
);
|
||||
new FullScreenHandler(
|
||||
elements.minimizeFullScreenButton,
|
||||
elements.maximizeFullScreenButton,
|
||||
minimizeFullScreenButton,
|
||||
maximizeFullScreenButton,
|
||||
document.documentElement
|
||||
);
|
||||
|
||||
const startAudioFromUserGesture = (event: Event) => {
|
||||
if (
|
||||
!hasStarted ||
|
||||
isAudioMuted ||
|
||||
(event.target instanceof Node && elements.startButton.contains(event.target)) ||
|
||||
(event.target instanceof Node && elements.soundButton.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
game?.startAudio(true);
|
||||
};
|
||||
|
||||
window.addEventListener('touchstart', startAudioFromUserGesture, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener('pointerdown', startAudioFromUserGesture, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener('touchend', startAudioFromUserGesture, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener('pointerup', startAudioFromUserGesture, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener('click', startAudioFromUserGesture, {
|
||||
capture: true,
|
||||
});
|
||||
window.addEventListener('keydown', startAudioFromUserGesture, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
elements.restartButton.addEventListener('click', () => game?.destroy());
|
||||
elements.soundButton.addEventListener('click', () => {
|
||||
const shouldUnmute = isAudioMuted || audioVolume <= 0;
|
||||
if (shouldUnmute && audioVolume <= 0) {
|
||||
audioVolume = DEFAULT_AUDIO_VOLUME;
|
||||
}
|
||||
isAudioMuted = !shouldUnmute;
|
||||
persistAudioUiState();
|
||||
renderAudioUi(game);
|
||||
if (!isAudioMuted) {
|
||||
game?.startAudio(true);
|
||||
}
|
||||
});
|
||||
elements.volumeSlider.addEventListener('input', () => {
|
||||
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
|
||||
isAudioMuted = audioVolume <= 0;
|
||||
persistAudioUiState();
|
||||
renderAudioUi(game);
|
||||
if (!isAudioMuted) {
|
||||
game?.startAudio(true);
|
||||
}
|
||||
});
|
||||
|
||||
const selectRelativeVibe = (offset: number, source: string) => {
|
||||
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||
const vibe =
|
||||
VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
|
||||
const activePreset = applyVibeSettings(vibe);
|
||||
trackVibeChange({
|
||||
vibeId: activePreset.id,
|
||||
vibeName: activePreset.name,
|
||||
source,
|
||||
});
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
configPane?.refresh();
|
||||
game?.playVibeChangeAudio(true);
|
||||
};
|
||||
|
||||
elements.previousVibe.addEventListener('click', () =>
|
||||
selectRelativeVibe(-1, 'previous-button')
|
||||
);
|
||||
|
||||
elements.nextVibe.addEventListener('click', () =>
|
||||
selectRelativeVibe(1, 'next-button')
|
||||
);
|
||||
|
||||
elements.swatches.forEach((swatch, index) => {
|
||||
swatch.addEventListener('click', () => {
|
||||
settings.selectedColorIndex = index;
|
||||
isEraserActive = false;
|
||||
renderPaletteUi(game);
|
||||
new VibeNavigator({
|
||||
onChange: ({ vibeId, vibeName, source }) => {
|
||||
trackVibeChange({ vibeId, vibeName, source });
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
configPane?.refresh();
|
||||
});
|
||||
game?.playVibeChangeAudio(true);
|
||||
},
|
||||
});
|
||||
|
||||
const activateEraser = () => {
|
||||
isEraserActive = true;
|
||||
renderPaletteUi(game);
|
||||
};
|
||||
restartButton.addEventListener('click', () => game?.destroy());
|
||||
|
||||
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
|
||||
elements.eraserSizeControl.addEventListener('click', activateEraser);
|
||||
elements.eraserSizeSlider.addEventListener('focus', activateEraser);
|
||||
|
||||
elements.eraserSizeSlider.addEventListener('input', () => {
|
||||
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
|
||||
isEraserActive = true;
|
||||
renderEraserSizeUi(game);
|
||||
renderPaletteUi(game);
|
||||
configPane?.refresh();
|
||||
});
|
||||
|
||||
elements.mirrorSegmentSlider.addEventListener('input', () => {
|
||||
settings.mirrorSegmentCount = clampMirrorSegmentCount(
|
||||
Number(elements.mirrorSegmentSlider.value)
|
||||
);
|
||||
isEraserActive = false;
|
||||
renderMirrorSegmentUi();
|
||||
renderPaletteUi(game);
|
||||
configPane?.refresh();
|
||||
});
|
||||
|
||||
elements.export4k.addEventListener('click', async () => {
|
||||
if (!game || elements.export4k.disabled) {
|
||||
export4kButton.addEventListener('click', async () => {
|
||||
if (!game || export4kButton.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.export4k.disabled = true;
|
||||
export4kButton.disabled = true;
|
||||
try {
|
||||
await game.exportSnapshot();
|
||||
trackExport({ vibeId: activeVibe.id });
|
||||
} catch (error) {
|
||||
ErrorHandler.addException(error, { severity: Severity.WARNING });
|
||||
} finally {
|
||||
elements.export4k.disabled = false;
|
||||
export4kButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
renderPaletteUi(game);
|
||||
renderEraserSizeUi(game);
|
||||
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.
|
||||
// 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.',
|
||||
|
|
@ -555,17 +148,18 @@ const main = async () => {
|
|||
});
|
||||
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);
|
||||
splash.setLoadingStage(
|
||||
`Loading piano samples ${loadedCount}/${totalCount}…`,
|
||||
ratio
|
||||
);
|
||||
}).then(
|
||||
() => {
|
||||
isPreloadComplete = true;
|
||||
setLoadingStage('Ready', 1);
|
||||
splash.setLoadingStage('Ready', 1);
|
||||
},
|
||||
(error: unknown) => {
|
||||
isPreloadComplete = true;
|
||||
splash.setLoadingStage('Piano unavailable', 1);
|
||||
ErrorHandler.addException(error, {
|
||||
fallbackMessage: 'Could not preload piano samples.',
|
||||
severity: Severity.WARNING,
|
||||
|
|
@ -575,7 +169,8 @@ const main = async () => {
|
|||
|
||||
const gpu = await gpuPromise;
|
||||
configPane = new ConfigPane({
|
||||
settingsButton: elements.settingsButton,
|
||||
maxSupportedAgentCount: getMaxSupportedAgentCount(gpu),
|
||||
settingsButton,
|
||||
onConfigChange: () => {
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
|
|
@ -584,59 +179,36 @@ const main = async () => {
|
|||
});
|
||||
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
||||
await fontsReady;
|
||||
await preloadPromise;
|
||||
splash.hideLoadingBar();
|
||||
|
||||
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||
|
||||
let isFirstStart = true;
|
||||
while (!shouldStop) {
|
||||
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
||||
toolbar: elements.toolbarRow,
|
||||
prompt: elements.prompt,
|
||||
eraserPreview: elements.eraserPreview,
|
||||
exportStatus: elements.exportStatus,
|
||||
game = new GameLoop(canvas, gpu, deltaTimeCalculator, {
|
||||
toolbar: toolbarRow,
|
||||
prompt: promptElement,
|
||||
eraserPreview,
|
||||
grainOverlay,
|
||||
exportStatus,
|
||||
});
|
||||
renderPaletteUi(game);
|
||||
renderEraserSizeUi(game);
|
||||
renderMirrorSegmentUi();
|
||||
renderAudioUi(game);
|
||||
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.
|
||||
elements.startButton.disabled = false;
|
||||
await new Promise<void>((resolve) => {
|
||||
const onClick = () => {
|
||||
elements.startButton.removeEventListener('click', onClick);
|
||||
hasStarted = true;
|
||||
game?.startAudio(true);
|
||||
trackStart();
|
||||
elements.splash.hidden = true;
|
||||
resolve();
|
||||
};
|
||||
elements.startButton.addEventListener('click', onClick);
|
||||
await splash.awaitStart(() => {
|
||||
hasStarted = true;
|
||||
game?.startAudio(true);
|
||||
trackStart();
|
||||
});
|
||||
|
||||
if (!isPreloadComplete) {
|
||||
elements.loadingBar.hidden = false;
|
||||
void preloadPromise.finally(() => {
|
||||
elements.loadingBar.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the dev stats overlay hidden until the user actually starts drawing.
|
||||
document.body.classList.add('pre-drawing');
|
||||
elements.canvas.addEventListener(
|
||||
'pointerdown',
|
||||
() => document.body.classList.remove('pre-drawing'),
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() =>
|
||||
document.body.classList.remove('is-loading')
|
||||
)
|
||||
requestAnimationFrame(() => document.body.classList.remove('is-loading'))
|
||||
);
|
||||
}
|
||||
game.attachPointerInput();
|
||||
|
|
@ -647,7 +219,7 @@ const main = async () => {
|
|||
if (hasRuntimeErrorListener) {
|
||||
ErrorHandler.addException(e);
|
||||
} else {
|
||||
renderStartupException(e);
|
||||
ErrorPresenter.renderStartup(e);
|
||||
ErrorHandler.addException(e);
|
||||
}
|
||||
console.error(e);
|
||||
|
|
|
|||
Loading…
Reference in a new issue