import GameLoop from './game-loop/game-loop'; import './index.scss'; import { initAnalytics, trackExport, trackVibeChange } from './analytics'; 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 { ErrorHandler, getErrorMessage, RuntimeError, Severity, } from './utils/error-handler'; import { initializeGpu } from './utils/graphics/initialize-gpu'; import { VIBE_PRESETS } from './vibes'; 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 getEraserSizeRatio = (size: number): number => (size - appConfig.toolbar.eraser.min) / (appConfig.toolbar.eraser.max - appConfig.toolbar.eraser.min); const clampMirrorSegmentCount = (value: number): number => { const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.mirror.default; return Math.min( appConfig.toolbar.mirror.max, Math.max(appConfig.toolbar.mirror.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> = appConfig.toolbar.mirror.names; const formatMirrorSegmentCount = (count: number): string => count === appConfig.toolbar.mirror.default ? appConfig.toolbar.mirror.offLabel : `${count} ${mirrorSegmentNames[count] ?? appConfig.toolbar.mirror.fallbackSegmentName}`; 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 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); const readInitialAudioVolume = (): number => { const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey); return storedVolume === null ? appConfig.toolbar.volume.default : clampAudioVolume(Number(storedVolume)); }; const formatStoredAudioVolume = (volume: number): string => clampAudioVolume(volume).toFixed(2); type RuntimeUiError = Parameters< Parameters[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('.errors-container'); 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('aside', HTMLElement), toolbarRow: queryRequiredElement('.toolbar-row', HTMLElement), infoButton: queryRequiredElement('button.info', HTMLButtonElement), infoElement: queryRequiredElement('.info-page', HTMLElement), minimizeFullScreenButton: queryRequiredElement( 'button.minimize-full-screen', HTMLButtonElement ), maximizeFullScreenButton: queryRequiredElement( 'button.maximize-full-screen', 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), }); type AppElements = ReturnType; let elements: AppElements; const setLoadingStage = (label: string, ratio: number) => { const percent = Math.round(Math.max(0, Math.min(1, 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(appConfig.storage.audioMutedKey) === '1' || audioVolume <= appConfig.toolbar.volume.min; let isEraserActive = false; const persistAudioUiState = () => { writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0'); writeBrowserStorage( appConfig.storage.audioVolumeKey, formatStoredAudioVolume(audioVolume) ); }; const renderAudioUi = (game: GameLoop | null) => { audioVolume = clampAudioVolume(audioVolume); const isEffectivelyMuted = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min; 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 = appConfig.toolbar.volume.min.toString(); elements.volumeSlider.max = appConfig.toolbar.volume.max.toString(); elements.volumeSlider.step = appConfig.toolbar.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 = 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', activeVibe.backgroundColor ); }; const renderEraserSizeUi = (game: GameLoop | null) => { const size = clampEraserSize(settings.eraserSize); if (settings.eraserSize !== size) { 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.value = size.toString(); elements.eraserSizeSlider.setAttribute('aria-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}%`); 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 = appConfig.toolbar.mirror.min.toString(); elements.mirrorSegmentSlider.max = appConfig.toolbar.mirror.max.toString(); elements.mirrorSegmentSlider.step = appConfig.toolbar.mirror.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; try { initAnalytics(); let shouldStop = false; let game: GameLoop | null = null; let configPane: ConfigPane | null = null; elements = queryAppElements(); elements.errorContainer.setAttribute('aria-live', 'assertive'); ErrorHandler.addOnErrorListener((error) => { renderRuntimeMessage(elements.errorContainer, error); if (error.severity === Severity.ERROR) { document.body.classList.remove('is-loading'); game?.destroy(); shouldStop = true; } }); hasRuntimeErrorListener = true; const syncRuntimeUi = (activeGame = game) => { renderEraserSizeUi(game); renderMirrorSegmentUi(); renderPaletteUi(activeGame); }; const infoPageHandler = new CollapsiblePanelAnimator( elements.infoButton, elements.infoElement, elements.aside ); new MenuHider( elements.aside, () => FullScreenHandler.isInFullScreenMode() && !configPane?.isOpen && !infoPageHandler.isOpen ); new FullScreenHandler( elements.minimizeFullScreenButton, elements.maximizeFullScreenButton, document.body ); const startAudioFromUserGesture = (event: Event) => { if ( isAudioMuted || (event.target instanceof Node && elements.soundButton.contains(event.target)) ) { return; } game?.startAudio(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 <= appConfig.toolbar.volume.min; if (shouldUnmute && audioVolume <= appConfig.toolbar.volume.min) { audioVolume = appConfig.toolbar.volume.default; } isAudioMuted = !shouldUnmute; persistAudioUiState(); renderAudioUi(game); if (!isAudioMuted) { game?.startAudio(true); } }); elements.volumeSlider.addEventListener('input', () => { audioVolume = clampAudioVolume(Number(elements.volumeSlider.value)); isAudioMuted = audioVolume <= appConfig.toolbar.volume.min; 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); configPane?.refresh(); }); }); const activateEraser = () => { isEraserActive = true; renderPaletteUi(game); }; 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) { return; } elements.export4k.disabled = true; try { await game.export4K(); trackExport({ vibeId: activeVibe.id }); } catch (error) { ErrorHandler.addException(error, { severity: Severity.WARNING }); } finally { elements.export4k.disabled = false; } }); renderPaletteUi(game); renderEraserSizeUi(game); renderMirrorSegmentUi(); renderAudioUi(game); const fontsReady = document.fonts.ready.catch((error) => { ErrorHandler.addException(error, { fallbackMessage: 'Could not load fonts.', severity: Severity.WARNING, }); }); setLoadingStage('Connecting to GPU…', 0.1); const gpu = await initializeGpu(); configPane = new ConfigPane({ settingsButton: elements.settingsButton, onConfigChange: () => { game?.onVibeChanged(); syncRuntimeUi(); }, onOpenChange: () => undefined, onRuntimeChange: syncRuntimeUi, onRuntimeReset: () => { resetSettings(); game?.onVibeChanged(); syncRuntimeUi(); }, onRestart: () => game?.destroy(), onVibeChange: (vibeId) => { const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId); if (!vibe) { return; } const activePreset = applyVibeSettings(vibe); trackVibeChange({ vibeId: activePreset.id, vibeName: activePreset.name, source: 'settings', }); game?.onVibeChanged(); syncRuntimeUi(); game?.playVibeChangeAudio(false); }, }); 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(); let isFirstStart = true; while (!shouldStop) { game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, { toolbar: elements.toolbarRow, prompt: elements.prompt, eraserPreview: elements.eraserPreview, exportStatus: elements.exportStatus, }); renderPaletteUi(game); renderEraserSizeUi(game); renderMirrorSegmentUi(); renderAudioUi(game); const startPromise = game.start(); if (isFirstStart) { isFirstStart = false; setLoadingStage('Ready', 1); requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('is-loading')) ); } await startPromise; } } catch (e) { document.body.classList.remove('is-loading'); if (hasRuntimeErrorListener) { ErrorHandler.addException(e); } else { renderStartupException(e); ErrorHandler.addException(e); } console.error(e); } }; main();