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'; 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); 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 devStatsElement: HTMLDivElement | null = 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 hasFinished = false; private readonly finished = Promise.withResolvers(); public constructor( private readonly canvas: HTMLCanvasElement, device: GPUDevice, private readonly deltaTimeCalculator: DeltaTimeCalculator, ui: GardenUi ) { this.resize(); if (import.meta.env.DEV) { this.devStatsElement = this.createDevStatsElement(); } this.resources = new GameLoopResources(canvas, device, this.canvasSize); this.introPrompt = new IntroPrompt(ui.prompt); this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview); 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 = (event: KeyboardEvent) => { this.audio.start(activeVibe, { userGesture: event.isTrusted }); 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 startAudio(userGesture = false): void { this.audio.start(activeVibe, { userGesture }); } public playVibeChangeAudio(userGesture = false): void { this.audio.changeVibe(activeVibe, { userGesture }); } public async start(): Promise { requestAnimationFrame(this.render); return this.finished.promise; } public get maxAgentCount(): number { return this.agentPopulation.maxAgentCount; } public async export4K(): Promise { return this.export4KRenderer.export(); } public async destroy(): Promise { this.hasFinished = true; await this.finished.promise; window.removeEventListener('resize', this.resizeListener); window.removeEventListener('keydown', this.keydownListener); this.pointerInput.detach(); 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(deltaTime); this.agentPopulation.growBudget( deltaTime, this.framePerformance.smoothedFps, this.framePerformance.refreshTargetFps ); this.introPrompt.update(); this.resize(); this.resizeSimulationToCanvas(); const scaledTime = time * settings.renderSpeed; 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: scaledTime, 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(settings.renderSpeed, isErasing); 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, targetAgentBudget: this.agentPopulation.targetAgentBudget, canvas: this.canvas, devicePixelRatio: this.devicePixelRatio, renderSpeed: settings.renderSpeed, }); 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 || time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS ) { return; } this.lastDevStatsUpdateAt = time; this.devStatsElement.textContent = [ `FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round( this.framePerformance.refreshTargetFps )}`, `Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`, `Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`, `Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`, ].join('\n'); } 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))); } }