import GameLoop from './game-loop/game-loop'; import './index.scss'; 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 { DeltaTimeCalculator } from './utils/delta-time-calculator'; import { queryRequiredElement, queryRequiredElements } from './utils/dom'; import { ErrorHandler, 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 ? 'Mirror off' : `${count} ${mirrorSegmentNames[count] ?? 'slices'}`; const renderRuntimeMessage = ( container: HTMLElement, error: Parameters[0]>[0] ) => { 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 elements = { aside: queryRequiredElement('aside', 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), 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), loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement), loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement), loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement), }; const setLoadingStage = (label: string, ratio: number) => { const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100); elements.loadingStatus.textContent = label; elements.loadingIndicator.style.setProperty('--loading-progress', `${percent}%`); elements.loadingProgress.setAttribute('aria-valuenow', String(percent)); }; let isAudioMuted = readBrowserStorage(appConfig.storage.audioMutedKey) === '1'; const renderAudioUi = (game: GameLoop | null) => { elements.soundButton.classList.toggle('muted', isAudioMuted); elements.soundButton.setAttribute('aria-pressed', String(isAudioMuted)); elements.soundButton.setAttribute( 'aria-label', isAudioMuted ? 'Unmute audio' : 'Mute audio' ); elements.soundButton.title = isAudioMuted ? 'Unmute audio' : 'Mute audio'; game?.setAudioMuted(isAudioMuted); }; const renderPaletteUi = (game: GameLoop | null) => { const isErasing = elements.eraserSizeControl.dataset.active === '1'; elements.swatches.forEach((swatch, index) => { swatch.style.backgroundColor = activeVibe.colors[index]; swatch.classList.toggle( 'active', settings.selectedColorIndex === index && !isErasing ); }); elements.eraserSizeControl.classList.toggle('active', isErasing); game?.setEraseMode(isErasing); document.documentElement.style.setProperty( '--garden-background', activeVibe.backgroundColor ); game?.onVibeChanged(); }; 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 () => { try { let shouldStop = false; let game: GameLoop | null = null; elements.errorContainer.setAttribute('aria-live', 'assertive'); ErrorHandler.addOnErrorListener((error, _metadata) => { renderRuntimeMessage(elements.errorContainer, error); if (error.severity === Severity.ERROR) { document.body.classList.remove('is-loading'); game?.destroy(); shouldStop = true; } }); const syncRuntimeUi = () => { renderEraserSizeUi(game); renderMirrorSegmentUi(); renderPaletteUi(game); }; const infoPageHandler = new CollapsiblePanelAnimator( elements.infoButton, elements.infoElement, elements.aside ); const configPane = new ConfigPane({ settingsButton: elements.settingsButton, onConfigChange: syncRuntimeUi, onOpenChange: (isOpen) => game?.setStatsOverlayPinned(isOpen), onRuntimeChange: syncRuntimeUi, onRuntimeReset: () => { resetSettings(); syncRuntimeUi(); }, onRestart: () => game?.destroy(), onVibeChange: (vibeId) => { applyVibeSettings(vibeId); syncRuntimeUi(); game?.playVibeChangeAudio(false); }, }); infoPageHandler.onOpen = configPane.close.bind(configPane); new MenuHider( elements.aside, () => FullScreenHandler.isInFullScreenMode() && !configPane.isOpen && !infoPageHandler.isOpen ); new FullScreenHandler( elements.minimizeFullScreenButton, elements.maximizeFullScreenButton, document.body ); elements.restartButton.addEventListener('click', () => game?.destroy()); elements.soundButton.addEventListener('click', (event) => { isAudioMuted = !isAudioMuted; writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0'); renderAudioUi(game); if (!isAudioMuted) { game?.startAudio(event.isTrusted); } }); elements.previousVibe.addEventListener('click', (event) => { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const vibe = VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length]; applyVibeSettings(vibe.id); configPane.refresh(); syncRuntimeUi(); game?.playVibeChangeAudio(event.isTrusted); }); elements.nextVibe.addEventListener('click', (event) => { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length]; applyVibeSettings(vibe.id); configPane.refresh(); syncRuntimeUi(); game?.playVibeChangeAudio(event.isTrusted); }); elements.swatches.forEach((swatch, index) => { swatch.addEventListener('click', () => { settings.selectedColorIndex = index; elements.eraserSizeControl.dataset.active = '0'; game?.setEraseMode(false); renderPaletteUi(game); configPane.refresh(); }); }); const activateEraser = () => { elements.eraserSizeControl.dataset.active = '1'; 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)); elements.eraserSizeControl.dataset.active = '1'; renderEraserSizeUi(game); renderPaletteUi(game); configPane.refresh(); }); elements.mirrorSegmentSlider.addEventListener('input', () => { settings.mirrorSegmentCount = clampMirrorSegmentCount( Number(elements.mirrorSegmentSlider.value) ); elements.eraserSizeControl.dataset.active = '0'; renderMirrorSegmentUi(); renderPaletteUi(game); configPane.refresh(); }); elements.export4k.addEventListener('click', async () => { if (!game || elements.export4k.disabled) { return; } elements.export4k.disabled = true; try { await game.export4K(); } 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(() => undefined); setLoadingStage('Connecting to GPU…', 0.1); const gpu = await initializeGpu(); setLoadingStage('Loading fonts…', 0.4); await fontsReady; setLoadingStage('Compiling shaders…', 0.7); const deltaTimeCalculator = new DeltaTimeCalculator(); let isFirstStart = true; while (!shouldStop) { game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, { prompt: elements.prompt, eraserPreview: elements.eraserPreview, exportStatus: elements.exportStatus, }); game.setStatsOverlayPinned(configPane.isOpen); 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'); ErrorHandler.addException(e); console.error(e); } }; main();