fleeting-garden/src/index.ts
2026-05-22 07:54:38 +01:00

250 lines
8.1 KiB
TypeScript

import GameLoop from './game-loop/game-loop';
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 { 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 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 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();
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(infoButton, infoElement, aside);
new MenuHider(
aside,
() =>
FullScreenHandler.isInFullScreenMode() &&
!configPane?.isOpen &&
!infoPageHandler.isOpen
);
new FullScreenHandler(fullScreenButton, document.documentElement);
new VibeNavigator({
onChange: ({ vibeId, vibeName, source, userGesture }) => {
trackVibeChange({ vibeId, vibeName, source });
game?.onVibeChanged();
syncRuntimeUi();
configPane?.refresh();
game?.playVibeChangeAudio(userGesture);
},
});
restartButton.addEventListener('click', () => void destroyCurrentGame());
export4kButton.addEventListener('click', async () => {
const currentGame = game;
if (!currentGame || export4kButton.disabled) {
return;
}
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) {
document.body.classList.remove('is-loading');
if (hasRuntimeErrorListener) {
ErrorHandler.addException(e);
} else {
ErrorPresenter.renderStartup(e);
ErrorHandler.addException(e);
}
console.error(e);
}
};
main();