WIP
This commit is contained in:
parent
34ac200437
commit
39b0160064
136 changed files with 7144 additions and 1965 deletions
315
src/index.ts
315
src/index.ts
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue