// Development-only visual QA surface. Loaded from scripts/combat-effects/rom-preview.html. import { useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { Canvas, useFrame } from "@react-three/fiber"; import { Grid, OrbitControls } from "@react-three/drei"; import { SRGBColorSpace, TextureLoader, type Texture } from "three"; import { RunewakerParticleEffect } from "../scene/RunewakerParticleEffect"; import { parseRunewakerParticleGraph, runeLayers, type RunewakerParticleGraph } from "../scene/runewakerParticleRuntime"; import type { CombatPresentationPhase } from "../game/combatPresentation"; interface Choice { readonly model: string; readonly graphUrl: string } interface Loaded { readonly graph: RunewakerParticleGraph; readonly textures: ReadonlyMap } function Effect({ loaded, phase, playing, replay, freezeAt }: { readonly loaded: Loaded; readonly phase: CombatPresentationPhase; readonly playing: boolean; readonly replay: number; readonly freezeAt: boolean; }) { const playback = useRef({ elapsedMs: 0, decaying: false }); useEffect(() => { playback.current.elapsedMs = 0; }, [replay, loaded]); useFrame((_, delta) => { if (!playing) return; playback.current.elapsedMs += Math.min(0.1, delta) * 1_000; if (freezeAt && playback.current.elapsedMs >= 250) playback.current.elapsedMs = 250; else if (playback.current.elapsedMs > 2_000) playback.current.elapsedMs = 0; }); return ; } function Preview() { const [choices, setChoices] = useState([]); const [selected, setSelected] = useState(""); const [loaded, setLoaded] = useState(null); const [error, setError] = useState(""); const [phase, setPhase] = useState("impact"); const [playing, setPlaying] = useState(true); const [freezeAt, setFreezeAt] = useState(false); const [replay, setReplay] = useState(0); const loader = useMemo(() => new TextureLoader(), []); useEffect(() => { void fetch("/assets/game/combat-effects/original/runewaker/manifest.json") .then((response) => response.json()) .then((index: { results: { model: string; graphUrl: string; status: string; rendererReady?: boolean; partialRendererReady?: boolean }[] }) => { const available = index.results.filter((entry) => entry.status === "decoded" || entry.rendererReady || entry.partialRendererReady) .map(({ model, graphUrl }) => ({ model, graphUrl })); setChoices(available); setSelected(available.find((entry) => entry.model.includes("electric_ball_t_freeze_hit"))?.graphUrl ?? available[0]?.graphUrl ?? ""); }).catch((reason) => setError(String(reason))); }, []); useEffect(() => { if (!selected) return; let cancelled = false; const ownedTextures: Texture[] = []; setLoaded(null); setError(""); void fetch(selected).then((response) => response.json()).then(async (value) => { const graph = parseRunewakerParticleGraph(value); const textures = new Map(); await Promise.all(graph.textures.map(async (asset) => { const texture = await loader.loadAsync(asset.url); texture.colorSpace = SRGBColorSpace; ownedTextures.push(texture); textures.set(asset.url, texture); })); if (!cancelled) setLoaded({ graph, textures }); }).catch((reason) => { if (!cancelled) setError(String(reason)); }); return () => { cancelled = true; ownedTextures.forEach((texture) => texture.dispose()); }; }, [selected, loader]); return <>
RuneWaker particle preview · {choices.length} sprite models
{error || (loaded ? `${loaded.graph.partialRendererReady ? "Partial original" : "Ready"} · ${runeLayers(loaded.graph, phase).length} sprite layers · ${loaded.graph.textures.length} textures` : "Loading RuneWaker media…")}
{loaded?.graph.partialRendererReady &&
Fidelity limit: {loaded.graph.omittedVisuals?.join(", ")}
}
{loaded && }
; } if (import.meta.env.DEV) { const root = createRoot(document.getElementById("root")!); root.render(); if (import.meta.hot) import.meta.hot.dispose(() => root.unmount()); }