321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { vec2 } from 'gl-matrix';
|
|
|
|
import { GardenAudio } from '../audio/garden-audio';
|
|
import { appConfig } from '../config';
|
|
import { activeVibe, settings } from '../settings';
|
|
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
|
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
|
import { AgentPopulation } from './agent-population';
|
|
import { DevStatsOverlay } from './dev-stats-overlay';
|
|
import { EraserPreview } from './eraser-preview';
|
|
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
|
|
import { FramePerformance } from './frame-performance';
|
|
import { GameLoopResources } from './game-loop-resources';
|
|
import { GardenUi } from './game-loop-types';
|
|
import { getInternalRenderSize } from './internal-render-size';
|
|
import { IntroPrompt } from './intro-prompt';
|
|
import { GardenPointerInput } from './pointer-input';
|
|
import { PipelineStrokeOutput } from './stroke-output';
|
|
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
|
|
|
export default class GameLoop {
|
|
private readonly resources: GameLoopResources;
|
|
private readonly audio = new GardenAudio(appConfig.audio);
|
|
private readonly introPrompt: IntroPrompt;
|
|
private readonly eraserPreview: EraserPreview;
|
|
private readonly pointerInput: GardenPointerInput;
|
|
private readonly agentPopulation: AgentPopulation;
|
|
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
|
|
private readonly framePerformance = new FramePerformance();
|
|
private devStatsOverlay: DevStatsOverlay | null = null;
|
|
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
|
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
|
private readonly seed = this.seedValue.toString(16);
|
|
private readonly resizeListener = this.resize.bind(this);
|
|
private readonly _canvasSize: vec2 = vec2.create();
|
|
|
|
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
|
private previousAccentColor = '';
|
|
private hasFinished = false;
|
|
private readonly finished = Promise.withResolvers<void>();
|
|
|
|
public constructor(
|
|
private readonly canvas: HTMLCanvasElement,
|
|
private readonly device: GPUDevice,
|
|
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
|
ui: GardenUi
|
|
) {
|
|
this.resize();
|
|
this.resources = new GameLoopResources(
|
|
canvas,
|
|
device,
|
|
this.canvasSize,
|
|
this.framePerformance.adaptiveCapInitial
|
|
);
|
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
|
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
|
this.agentPopulation = new AgentPopulation(
|
|
this.resources.agentGenerationPipeline,
|
|
this.seedValue,
|
|
() => this.canvasPixelRatio,
|
|
this.framePerformance
|
|
);
|
|
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
|
this.pointerInput = new GardenPointerInput({
|
|
canvas,
|
|
audio: this.audio,
|
|
strokeOutput: new PipelineStrokeOutput(
|
|
this.resources.brushPipeline,
|
|
this.resources.eraserAgentPipeline,
|
|
this.resources.eraserTexturePipeline
|
|
),
|
|
getCanvasPixelRatio: () => this.canvasPixelRatio,
|
|
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
|
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
|
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
|
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
|
});
|
|
this.eraserPreview = new EraserPreview(
|
|
canvas,
|
|
ui.eraserPreview,
|
|
() => this.pointerInput.isSwipeActive
|
|
);
|
|
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
|
|
device,
|
|
renderPipeline: this.resources.renderPipeline,
|
|
statusElement: ui.exportStatus,
|
|
seed: this.seed,
|
|
getSourceSize: () => {
|
|
const size = this.resources.textures.trailMapA.getSize();
|
|
return {
|
|
width: size[0],
|
|
height: size[1],
|
|
};
|
|
},
|
|
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
|
|
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
|
getVibeId: () => activeVibe.id,
|
|
});
|
|
|
|
window.addEventListener('resize', this.resizeListener);
|
|
this.eraserPreview.attach();
|
|
this.syncDevStatsOverlay();
|
|
}
|
|
|
|
public attachPointerInput(): void {
|
|
this.pointerInput.attach();
|
|
}
|
|
|
|
public setEraseMode(isErasing: boolean): void {
|
|
this.pointerInput.setEraseMode(isErasing);
|
|
this.eraserPreview.setEraseMode(isErasing);
|
|
}
|
|
|
|
public updateEraserPreview(event?: PointerEvent): void {
|
|
this.eraserPreview.update(event);
|
|
}
|
|
|
|
public onVibeChanged(): void {
|
|
this.agentPopulation.onVibeChanged();
|
|
this.syncDevStatsOverlay();
|
|
}
|
|
|
|
public setAudioMuted(isMuted: boolean): void {
|
|
this.audio.setMuted(isMuted);
|
|
}
|
|
|
|
public setAudioVolume(volume: number): void {
|
|
this.audio.setMasterVolume(volume);
|
|
}
|
|
|
|
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 async exportSnapshot(): Promise<void> {
|
|
return this.exportSnapshotRenderer.export();
|
|
}
|
|
|
|
public async destroy(): Promise<void> {
|
|
this.hasFinished = true;
|
|
await this.finished.promise;
|
|
|
|
window.removeEventListener('resize', this.resizeListener);
|
|
this.pointerInput.detach();
|
|
this.eraserPreview.detach();
|
|
this.devStatsOverlay?.destroy();
|
|
this.devStatsOverlay = null;
|
|
this.toolbarContrastMonitor.destroy();
|
|
this.introPrompt.destroy();
|
|
await this.agentPopulation.waitForCompaction();
|
|
this.resources.destroy();
|
|
await this.audio.destroy();
|
|
}
|
|
|
|
private readonly render = (time: DOMHighResTimeStamp) => {
|
|
if (this.hasFinished) {
|
|
this.finished.resolve();
|
|
return;
|
|
}
|
|
|
|
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
|
this.framePerformance.update(time);
|
|
this.agentPopulation.updateAdaptiveCap();
|
|
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
|
|
this.resize();
|
|
this.resizeSimulationToCanvas(time);
|
|
this.regenerateIntroAfterSettledResize(time);
|
|
|
|
const channelColors = activeVibe.colors;
|
|
const backgroundColor = activeVibe.backgroundColor;
|
|
const introProgress = this.introPrompt.progress;
|
|
const canvasPixelRatio = this.canvasPixelRatio;
|
|
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
|
|
const isErasing = this.pointerInput.isEraseMode;
|
|
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
|
this.updateAccentColor(accentColor);
|
|
this.audio.update({
|
|
vibe: activeVibe,
|
|
isErasing,
|
|
});
|
|
|
|
this.resources.setFrameParameters({
|
|
time,
|
|
deltaTime,
|
|
canvasSize: this.canvasSize,
|
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
|
canvasPixelRatio,
|
|
introProgress,
|
|
selectedColorIndex: settings.selectedColorIndex,
|
|
channelColors,
|
|
backgroundColor,
|
|
eraserPixelSize,
|
|
});
|
|
|
|
this.resources.executeFrame(
|
|
isErasing,
|
|
this.toolbarContrastMonitor.takeReadbackRequest(time)
|
|
);
|
|
|
|
this.pointerInput.clearSwipesIfIdle();
|
|
this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
|
this.devStatsOverlay?.update({
|
|
time,
|
|
fps: this.framePerformance.measuredFps,
|
|
agentCount: this.agentPopulation.activeAgentCount,
|
|
frameTimeMs: this.framePerformance.measuredFrameTimeMs,
|
|
renderWidth: this.canvas.width,
|
|
renderHeight: this.canvas.height,
|
|
});
|
|
|
|
requestAnimationFrame(this.render);
|
|
};
|
|
|
|
private syncDevStatsOverlay(): void {
|
|
if (appConfig.tuningPane.showFpsOverlay) {
|
|
this.devStatsOverlay ??= new DevStatsOverlay(
|
|
this.canvas.parentElement ?? document.body
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.devStatsOverlay?.destroy();
|
|
this.devStatsOverlay = null;
|
|
}
|
|
|
|
private updateAccentColor(color: RgbColor): void {
|
|
const accentColor = rgbColorToCss(color);
|
|
if (this.previousAccentColor === accentColor) {
|
|
return;
|
|
}
|
|
|
|
this.previousAccentColor = accentColor;
|
|
document.documentElement.style.setProperty('--accent-color', accentColor);
|
|
}
|
|
|
|
private resize(): void {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const { width, height } = getInternalRenderSize({
|
|
clientHeight: rect.height || this.canvas.clientHeight,
|
|
clientWidth: rect.width || this.canvas.clientWidth,
|
|
maxPixelScale: appConfig.simulation.maxDevicePixelRatio,
|
|
maxTextureDimension: this.device.limits.maxTextureDimension2D,
|
|
targetAreaMegapixels: settings.internalRenderAreaMegapixels,
|
|
});
|
|
|
|
if (this.canvas.width === width && this.canvas.height === height) {
|
|
return;
|
|
}
|
|
|
|
this.canvas.width = width;
|
|
this.canvas.height = height;
|
|
}
|
|
|
|
private resizeSimulationToCanvas(time: DOMHighResTimeStamp): void {
|
|
const scale = this.resources.resizeSimulationTo(this.canvasSize);
|
|
if (!scale) {
|
|
return;
|
|
}
|
|
|
|
this.agentPopulation.resizeAgents(scale);
|
|
this.pointerInput.scaleLastPointerPosition(scale);
|
|
|
|
if (this.introPrompt.shouldRegenerateTitleOnResize) {
|
|
this.pendingIntroResizeAt = time;
|
|
}
|
|
}
|
|
|
|
private regenerateIntroAfterSettledResize(time: DOMHighResTimeStamp): void {
|
|
if (this.pendingIntroResizeAt === null) {
|
|
return;
|
|
}
|
|
|
|
if (!this.introPrompt.shouldRegenerateTitleOnResize) {
|
|
this.pendingIntroResizeAt = null;
|
|
return;
|
|
}
|
|
|
|
if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
|
|
return;
|
|
}
|
|
|
|
this.introPrompt.rewindToLeaveRemainingTime(
|
|
appConfig.simulation.intro.resizeMinimumRemainingSeconds
|
|
);
|
|
this.resources.clearSimulation();
|
|
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
|
|
this.pendingIntroResizeAt = null;
|
|
}
|
|
|
|
private get canvasSize(): vec2 {
|
|
vec2.set(this._canvasSize, this.canvas.width, this.canvas.height);
|
|
return this._canvasSize;
|
|
}
|
|
|
|
private get canvasPixelRatio(): number {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const xScale = rect.width > 0 ? this.canvas.width / rect.width : 1;
|
|
const yScale = rect.height > 0 ? this.canvas.height / rect.height : xScale;
|
|
const ratio = (xScale + yScale) / 2;
|
|
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
|
}
|
|
|
|
private get mirrorSegmentCount(): number {
|
|
const count = Number.isFinite(settings.mirrorSegmentCount)
|
|
? settings.mirrorSegmentCount
|
|
: appConfig.toolbar.mirror.min;
|
|
return Math.min(
|
|
appConfig.toolbar.mirror.max,
|
|
Math.max(appConfig.toolbar.mirror.min, Math.round(count))
|
|
);
|
|
}
|
|
}
|