334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { vec2 } from 'gl-matrix';
|
|
|
|
import { GardenAudio } from '../audio/garden-audio';
|
|
import { gardenAudioConfig } from '../audio/garden-audio-config';
|
|
import { appConfig } from '../config';
|
|
import { activeVibe, settings } from '../settings';
|
|
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
|
import { sleep } from '../utils/sleep';
|
|
import { AgentPopulation } from './agent-population';
|
|
import { EraserPreview } from './eraser-preview';
|
|
import { Export4KRenderer } from './export-4k-renderer';
|
|
import { FramePerformance } from './frame-performance';
|
|
import { GameLoopResources } from './game-loop-resources';
|
|
import { GardenUi } from './game-loop-types';
|
|
import { IntroPrompt } from './intro-prompt';
|
|
import { GardenPointerInput } from './pointer-input';
|
|
import { RenderInputCache } from './render-input-cache';
|
|
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
|
|
|
export default class GameLoop {
|
|
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
|
appConfig.simulation.maxMirrorSegmentCount;
|
|
private static readonly DEV_STATS_INTERVAL_MS = 250;
|
|
|
|
private readonly resources: GameLoopResources;
|
|
private readonly audio = new GardenAudio(
|
|
gardenAudioConfig,
|
|
appConfig.audioEngine,
|
|
appConfig.simulation.maxMirrorSegmentCount
|
|
);
|
|
private readonly renderInputs = new RenderInputCache();
|
|
private readonly introPrompt: IntroPrompt;
|
|
private readonly eraserPreview: EraserPreview;
|
|
private readonly pointerInput: GardenPointerInput;
|
|
private readonly agentPopulation: AgentPopulation;
|
|
private readonly export4KRenderer: Export4KRenderer;
|
|
private readonly framePerformance = new FramePerformance();
|
|
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
|
private readonly devStatsElement: HTMLDivElement | null;
|
|
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
|
private readonly resizeListener = this.resize.bind(this);
|
|
private readonly keydownListener: (event: KeyboardEvent) => void;
|
|
|
|
private lastDevStatsUpdateAt = 0;
|
|
private isStatsOverlayPinned = false;
|
|
private hasFinished = false;
|
|
private readonly finished = Promise.withResolvers<void>();
|
|
|
|
public constructor(
|
|
private readonly canvas: HTMLCanvasElement,
|
|
device: GPUDevice,
|
|
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
|
ui: GardenUi
|
|
) {
|
|
this.resize();
|
|
this.devStatsElement = this.createDevStatsElement();
|
|
this.syncDevStatsVisibility();
|
|
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
|
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
|
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
|
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
|
|
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
|
this.pointerInput = new GardenPointerInput({
|
|
canvas,
|
|
audio: this.audio,
|
|
brushPipeline: this.resources.brushPipeline,
|
|
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
|
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
|
eraserPreview: this.eraserPreview,
|
|
getCanvasSize: () => this.canvasSize,
|
|
getDevicePixelRatio: () => this.devicePixelRatio,
|
|
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
|
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
|
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
|
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
|
});
|
|
this.export4KRenderer = new Export4KRenderer({
|
|
device,
|
|
renderPipeline: this.resources.renderPipeline,
|
|
statusElement: ui.exportStatus,
|
|
seed: this.seed,
|
|
getSourceSize: () => ({
|
|
width: this.canvas.width,
|
|
height: this.canvas.height,
|
|
}),
|
|
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
|
|
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
|
getVibeId: () => activeVibe.id,
|
|
});
|
|
this.keydownListener = () => {
|
|
this.audio.start(activeVibe, { userGesture: true });
|
|
this.introPrompt.complete();
|
|
};
|
|
|
|
window.addEventListener('resize', this.resizeListener);
|
|
window.addEventListener('keydown', this.keydownListener, { once: true });
|
|
this.pointerInput.attach();
|
|
}
|
|
|
|
public setEraseMode(isErasing: boolean): void {
|
|
this.pointerInput.setEraseMode(isErasing);
|
|
}
|
|
|
|
public updateEraserPreview(event?: PointerEvent): void {
|
|
this.pointerInput.updateEraserPreview(event);
|
|
}
|
|
|
|
public onVibeChanged(): void {
|
|
this.agentPopulation.onVibeChanged();
|
|
this.renderInputs.invalidate();
|
|
}
|
|
|
|
public setAudioMuted(isMuted: boolean): void {
|
|
this.audio.setMuted(isMuted);
|
|
}
|
|
|
|
public setStatsOverlayPinned(isPinned: boolean): void {
|
|
const wasVisible = this.shouldShowDevStats;
|
|
this.isStatsOverlayPinned = isPinned;
|
|
this.syncDevStatsVisibility();
|
|
|
|
if (!wasVisible && this.shouldShowDevStats) {
|
|
this.lastDevStatsUpdateAt = Number.NEGATIVE_INFINITY;
|
|
this.updateDevStats(performance.now());
|
|
}
|
|
}
|
|
|
|
public startAudio(userGesture = false): void {
|
|
this.audio.start(activeVibe, { userGesture });
|
|
}
|
|
|
|
public playVibeChangeAudio(userGesture = false): void {
|
|
this.audio.changeVibe(activeVibe, { userGesture });
|
|
}
|
|
|
|
public async start(): Promise<void> {
|
|
requestAnimationFrame(this.render);
|
|
return this.finished.promise;
|
|
}
|
|
|
|
public get maxAgentCount(): number {
|
|
return this.agentPopulation.maxAgentCount;
|
|
}
|
|
|
|
public async export4K(): Promise<void> {
|
|
return this.export4KRenderer.export();
|
|
}
|
|
|
|
public async destroy(): Promise<void> {
|
|
this.hasFinished = true;
|
|
await this.finished.promise;
|
|
|
|
window.removeEventListener('resize', this.resizeListener);
|
|
window.removeEventListener('keydown', this.keydownListener);
|
|
this.pointerInput.detach();
|
|
this.toolbarContrastMonitor.destroy();
|
|
this.devStatsElement?.remove();
|
|
this.introPrompt.destroy();
|
|
this.resources.destroy();
|
|
await this.audio.destroy();
|
|
}
|
|
|
|
private readonly render = async (time: DOMHighResTimeStamp) => {
|
|
if (this.hasFinished) {
|
|
this.finished.resolve();
|
|
return;
|
|
}
|
|
|
|
const frameCpuStartedAt = this.framePerformance.markCpuStart();
|
|
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
|
this.framePerformance.update(time);
|
|
this.agentPopulation.growBudget(
|
|
deltaTime,
|
|
this.framePerformance.smoothedFps,
|
|
this.framePerformance.refreshTargetFps
|
|
);
|
|
this.introPrompt.update();
|
|
this.resize();
|
|
this.resizeSimulationToCanvas();
|
|
|
|
const { channelColors, backgroundColor } = this.renderInputs.get();
|
|
const introProgress = this.introPrompt.progress;
|
|
const cameraZoom = 1;
|
|
const cameraCenter: [number, number] = [
|
|
this.canvas.width / 2,
|
|
this.canvas.height / 2,
|
|
];
|
|
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
|
|
const isErasing = this.pointerInput.isEraseMode;
|
|
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
|
this.renderInputs.updateAccentColor(accentColor);
|
|
this.audio.update({
|
|
vibe: activeVibe,
|
|
selectedColorIndex: settings.selectedColorIndex,
|
|
isErasing,
|
|
mirrorSegmentCount: this.mirrorSegmentCount,
|
|
});
|
|
|
|
this.resources.setFrameParameters({
|
|
time,
|
|
deltaTime,
|
|
canvasSize: this.canvasSize,
|
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
|
introProgress,
|
|
selectedColorIndex: settings.selectedColorIndex,
|
|
isErasing,
|
|
channelColors,
|
|
backgroundColor,
|
|
cameraCenter,
|
|
cameraZoom,
|
|
eraserPixelSize,
|
|
});
|
|
|
|
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
|
|
this.resources.executeFrame(
|
|
isErasing,
|
|
this.toolbarContrastMonitor.takeReadbackRequest(time)
|
|
);
|
|
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
|
|
|
|
this.pointerInput.clearSwipesIfIdle();
|
|
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
|
|
|
this.framePerformance.renderTelemetry({
|
|
frameCpuStartedAt,
|
|
encodeCpuMs,
|
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
|
agentBudgetMax: settings.agentBudgetMax,
|
|
canvas: this.canvas,
|
|
devicePixelRatio: this.devicePixelRatio,
|
|
});
|
|
this.updateDevStats(time);
|
|
|
|
if (settings.simulatedDelayMs > 0) {
|
|
await sleep(settings.simulatedDelayMs);
|
|
}
|
|
|
|
requestAnimationFrame(this.render);
|
|
};
|
|
|
|
private createDevStatsElement(): HTMLDivElement | null {
|
|
const container = this.canvas.parentElement;
|
|
if (!container) {
|
|
return null;
|
|
}
|
|
|
|
const element = document.createElement('div');
|
|
element.className = 'dev-stats-overlay';
|
|
element.setAttribute('aria-hidden', 'true');
|
|
container.appendChild(element);
|
|
return element;
|
|
}
|
|
|
|
private updateDevStats(time: DOMHighResTimeStamp): void {
|
|
if (
|
|
!this.devStatsElement ||
|
|
!this.shouldShowDevStats ||
|
|
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.lastDevStatsUpdateAt = time;
|
|
const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps);
|
|
this.devStatsElement.textContent = [
|
|
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`,
|
|
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
|
|
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
|
|
].join('\n');
|
|
}
|
|
|
|
private syncDevStatsVisibility(): void {
|
|
if (!this.devStatsElement) {
|
|
return;
|
|
}
|
|
|
|
const isVisible = this.shouldShowDevStats;
|
|
this.devStatsElement.hidden = !isVisible;
|
|
this.devStatsElement.setAttribute('aria-hidden', String(!isVisible));
|
|
}
|
|
|
|
private formatDevStatNumber(value: number): string {
|
|
return Math.max(0, Math.round(value)).toLocaleString('en-US');
|
|
}
|
|
|
|
private resize(): void {
|
|
const width = Math.max(
|
|
1,
|
|
Math.floor(this.canvas.clientWidth * this.devicePixelRatio)
|
|
);
|
|
const height = Math.max(
|
|
1,
|
|
Math.floor(this.canvas.clientHeight * this.devicePixelRatio)
|
|
);
|
|
|
|
if (this.canvas.width === width && this.canvas.height === height) {
|
|
return;
|
|
}
|
|
|
|
this.canvas.width = width;
|
|
this.canvas.height = height;
|
|
}
|
|
|
|
private resizeSimulationToCanvas(): void {
|
|
const scale = this.resources.resizeSimulationTo(this.canvasSize);
|
|
if (!scale) {
|
|
return;
|
|
}
|
|
|
|
this.agentPopulation.resizeAgents(scale);
|
|
this.pointerInput.scaleLastPointerPosition(scale);
|
|
}
|
|
|
|
private get canvasSize(): vec2 {
|
|
return vec2.fromValues(this.canvas.width, this.canvas.height);
|
|
}
|
|
|
|
private get devicePixelRatio(): number {
|
|
const ratio = window.devicePixelRatio;
|
|
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
|
}
|
|
|
|
private get mirrorSegmentCount(): number {
|
|
const count = Number.isFinite(settings.mirrorSegmentCount)
|
|
? settings.mirrorSegmentCount
|
|
: 1;
|
|
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
|
|
}
|
|
|
|
private get shouldShowDevStats(): boolean {
|
|
return import.meta.env.DEV || this.isStatsOverlayPinned;
|
|
}
|
|
}
|