This commit is contained in:
Andras Schmelczer 2026-05-13 21:07:10 +01:00
parent 34ac200437
commit 39b0160064
136 changed files with 7144 additions and 1965 deletions

View file

@ -1,26 +1,71 @@
import { isProduction } from './constants';
import GameLoop from './game-loop/game-loop';
import { GameRules } from './game-loop/game-rules';
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 { setUpSettingsPage } from './page/set-up-settings-page';
import { SettingsSlider } from './page/settings-slider';
import { resetSettings } from './settings';
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
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 formatMirrorSegmentCount = (count: number): string =>
count === appConfig.toolbar.mirror.default
? 'Mirror off'
: `${count} ${appConfig.toolbar.mirror.names[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: document.querySelector('aside') as HTMLDivElement,
infoButton: document.querySelector('button.info') as HTMLButtonElement,
infoElement: document.querySelector('.info-page') as HTMLDivElement,
settingsPage: document.querySelector('.settings-page') as HTMLDivElement,
settingsContent: document.querySelector('.settings-content') as HTMLDivElement,
applyDefaults: document.querySelector('#apply-defaults') as HTMLButtonElement,
minimizeFullScreenButton: document.querySelector(
'button.minimize-full-screen'
) as HTMLButtonElement,
@ -28,9 +73,107 @@ const elements = {
'button.maximize-full-screen'
) as HTMLButtonElement,
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
soundButton: document.querySelector('button.sound') as HTMLButtonElement,
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
canvas: document.querySelector('canvas') as HTMLCanvasElement,
eraserPreview: document.querySelector('.eraser-preview') as HTMLDivElement,
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
previousVibe: document.querySelector('.previous-vibe') as HTMLButtonElement,
nextVibe: document.querySelector('.next-vibe') as HTMLButtonElement,
swatches: Array.from(
document.querySelectorAll('.color-swatch')
) as Array<HTMLButtonElement>,
eraserSizeControl: document.querySelector('.eraser-size-control') as HTMLLabelElement,
eraserSizeSlider: document.querySelector('.eraser-size-slider') as HTMLInputElement,
mirrorSegmentControl: document.querySelector(
'.mirror-segment-control'
) as HTMLLabelElement,
mirrorSegmentSlider: document.querySelector(
'.mirror-segment-slider'
) as HTMLInputElement,
export4k: document.querySelector('.export-4k') as HTMLButtonElement,
exportStatus: document.querySelector('.export-status') as HTMLSpanElement,
prompt: document.querySelector('.garden-prompt') as HTMLDivElement,
};
let isAudioMuted = localStorage.getItem(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 () => {
@ -38,36 +181,48 @@ const main = async () => {
let shouldStop = false;
let game: GameLoop | null = null;
elements.errorContainer.setAttribute('aria-live', 'assertive');
ErrorHandler.addOnErrorListener((error, _metadata) => {
elements.errorContainer.innerHTML += `
<pre class="${error.severity}">${error.message}</div>
`;
game?.destroy();
shouldStop = true;
renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) {
game?.destroy();
shouldStop = true;
}
});
const syncRuntimeUi = () => {
renderEraserSizeUi(game);
renderMirrorSegmentUi();
renderPaletteUi(game);
};
const infoPageHandler = new CollapsiblePanelAnimator(
elements.infoButton,
elements.infoElement,
elements.aside
);
const settingsPageHandler = new CollapsiblePanelAnimator(
elements.settingsButton,
elements.settingsPage,
elements.aside
);
settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler);
infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler);
if (isProduction) {
infoPageHandler.open();
}
const configPane = new ConfigPane({
settingsButton: elements.settingsButton,
onConfigChange: syncRuntimeUi,
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() &&
!settingsPageHandler.isOpen &&
!configPane.isOpen &&
!infoPageHandler.isOpen
);
new FullScreenHandler(
@ -76,31 +231,113 @@ const main = async () => {
document.body
);
const fontsReady = document.fonts.ready.catch(() => undefined);
const gpu = await initializeGpu();
await fontsReady;
elements.restartButton.addEventListener('click', () => game?.destroy());
const deltaTimeCalculator = new DeltaTimeCalculator();
let sliders: Array<SettingsSlider<any>> = [];
elements.applyDefaults.addEventListener('click', () => {
resetSettings();
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
elements.soundButton.addEventListener('click', (event) => {
isAudioMuted = !isAudioMuted;
localStorage.setItem(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
renderAudioUi(game);
if (!isAudioMuted) {
game?.startAudio(event.isTrusted);
}
});
while (!shouldStop) {
const gameRules = new GameRules(performance.now() / 1000);
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
const deltaTimeCalculator = new DeltaTimeCalculator();
if (sliders.length === 0) {
sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
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);
while (!shouldStop) {
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
prompt: elements.prompt,
eraserPreview: elements.eraserPreview,
exportStatus: elements.exportStatus,
});
renderPaletteUi(game);
renderEraserSizeUi(game);
renderMirrorSegmentUi();
renderAudioUi(game);
await game.start();
}
} catch (e) {
const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
ErrorHandler.addError(Severity.ERROR, message);
ErrorHandler.addException(e);
console.error(e);
}
};