Some checks failed
Deploy to Pages / build (pull_request) Failing after 1m56s
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
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<Record<number, string>> =
|
|
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<Parameters<typeof ErrorHandler.addOnErrorListener>[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();
|