Zum Inhalt

src/ui/Canvas.tsx

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%' }}
    />
  );
};