import { type Component, createEffect, on, onCleanup, onMount } from 'solid-js';
import { audioCapture } from '~/audio/AudioCapture';
import { Engine } from '~/engine/Engine';
import { type AdapterResult, requestAdapter } from '~/engine/adapter';
import { downloadBlob } from '~/lib/download';
import { captureFilename } from '~/lib/filename';
import { FpsMeter } from '~/lib/timing';
import { presetState } from '~/stores/presetStore';
import { getThumbnail, setThumbnail } from '~/stores/thumbnailStore';
import { setAudioLevels, uiState } from '~/stores/uiStore';
import { createRespawnCoordinator } from './respawnCoordinator';
interface Props {
onEngineReady: (engine: Engine, fps: FpsMeter) => void;
onEngineFail: () => void;
}
// Frames a preset must run before an automatic preview snapshot is taken.
const AUTO_SNAPSHOT_FRAMES = 240;
export const Canvas: Component<Props> = (props) => {
let canvasRef: HTMLCanvasElement | undefined;
let engine: Engine | null = null;
let rafId = 0;
let observer: ResizeObserver | null = null;
const fps = new FpsMeter();
// Auto-snapshot bookkeeping — reset whenever the active preset changes.
let framesSincePreset = 0;
let autoSnapDone = false;
const captureSnapshot = (): void => {
if (!engine) return;
const url = engine.snapshot();
if (url) setThumbnail(presetState.current.id, url);
};
onMount(async () => {
if (!canvasRef) return;
const adapter: AdapterResult | null = await requestAdapter();
if (!adapter) {
props.onEngineFail();
return;
}
engine = new Engine({
adapter,
canvas: canvasRef,
// Pass the live `edited` proxy (not `current.params`) so writeParams
// reads the same object the editor mutates. Without this, slider
// edits silently no-op until the user switches presets — selectPreset
// replaces `edited` top-level which then forces the proxy to refresh.
initialPreset: presetState.edited,
});
props.onEngineReady(engine, fps);
// Publish audio levels to the UI store ~15 Hz so the meter animates
// smoothly without re-rendering every frame.
let audioPublishFrame = 0;
const loop = () => {
if (!engine) return;
if (!uiState.paused) {
if (audioCapture.active) {
const frame = audioCapture.getFrame();
engine.setAudio(frame);
audioPublishFrame = (audioPublishFrame + 1) % 4;
if (audioPublishFrame === 0 && frame) {
setAudioLevels(frame.bass, frame.mid, frame.high);
}
}
// simSpeed: run multiple sim steps per rendered frame. Multiplies
// physics speed without changing audio/UI cadence.
const steps = Math.max(1, Math.min(8, Math.round(presetState.edited.simSpeed ?? 1)));
for (let s = 0; s < steps; s += 1) engine.tick();
fps.tick();
framesSincePreset += 1;
// auto-snapshot once per preset, only if it has no preview yet
if (!autoSnapDone && framesSincePreset > AUTO_SNAPSHOT_FRAMES) {
autoSnapDone = true;
if (!getThumbnail(presetState.current.id)) captureSnapshot();
}
}
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
observer = new ResizeObserver((entries) => {
const e = entries[0];
if (!e || !engine) return;
engine.resize(e.contentRect.width, e.contentRect.height);
});
observer.observe(canvasRef);
});
// Sync edited params live into the engine. The engine reads the proxy
// per frame; this effect fires only when `edited` is replaced as a whole
// (preset switch / reset), to repoint the engine at the new proxy.
createEffect(() => {
const params = presetState.edited;
engine?.setPreset(params);
});
// Single coordinator for all respawn paths — see respawnCoordinator.ts.
// Replaces three former effects that double-respawned on preset switch
// (preset-id-effect + debounced-seed-effect both firing) and that
// spuriously respawned on any species-property edit (because the seed
// effect tracked the whole species array, not the populationShare value).
createRespawnCoordinator({
performRespawn: () => engine?.respawn(),
onPresetSwitch: () => {
framesSincePreset = 0;
autoSnapDone = false;
},
});
// Manual preview capture (triggered from the editor)
createEffect(
on(
() => uiState.snapshotNonce,
() => captureSnapshot(),
{ defer: true },
),
);
// Full-resolution PNG export — captured on the next animation frame so the
// canvas reflects the latest tick, even when paused or mid-render.
createEffect(
on(
() => uiState.exportNonce,
() => {
if (!engine) return;
requestAnimationFrame(async () => {
const blob = await engine?.exportPng();
if (!blob) return;
downloadBlob(blob, captureFilename(presetState.current.name, 'png'));
});
},
{ defer: true },
),
);
onCleanup(() => {
cancelAnimationFrame(rafId);
observer?.disconnect();
engine?.dispose();
engine = null;
});
return (
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: '0', width: '100%', height: '100%' }}
/>
);
};