forked from phenom/healer-man
111 lines
6.1 KiB
TypeScript
111 lines
6.1 KiB
TypeScript
// 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<string, Texture> }
|
|
|
|
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 <group position={[0, 1.1, 0]}>
|
|
<RunewakerParticleEffect graph={loaded.graph} textures={loaded.textures} phase={phase}
|
|
quality="high" active eventId={1} playbackRef={playback} />
|
|
</group>;
|
|
}
|
|
|
|
function Preview() {
|
|
const [choices, setChoices] = useState<readonly Choice[]>([]);
|
|
const [selected, setSelected] = useState("");
|
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
const [error, setError] = useState("");
|
|
const [phase, setPhase] = useState<CombatPresentationPhase>("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<string, Texture>();
|
|
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 <>
|
|
<div style={{ padding: "14px 20px", display: "flex", gap: 16, alignItems: "center", flexWrap: "wrap" }}>
|
|
<strong>RuneWaker particle preview · {choices.length} sprite models</strong>
|
|
<label>Model <select aria-label="Model" value={selected} onChange={(event) => setSelected(event.target.value)}
|
|
style={{ maxWidth: "55vw", padding: 8 }}>
|
|
{choices.map((choice) => <option key={choice.graphUrl} value={choice.graphUrl}>{choice.model}</option>)}
|
|
</select></label>
|
|
<label>Phase <select aria-label="Phase" value={phase} onChange={(event) => setPhase(event.target.value as CombatPresentationPhase)}>
|
|
<option value="cast-start">Cast start</option><option value="release">Release</option><option value="impact">Impact</option>
|
|
</select></label>
|
|
<button onClick={() => setPlaying(!playing)}>{playing ? "Pause" : "Play"}</button>
|
|
<button onClick={() => setReplay(replay + 1)}>Replay</button>
|
|
<label><input type="checkbox" checked={freezeAt} onChange={(event) => { setFreezeAt(event.target.checked); setReplay(replay + 1); }} />Freeze at 250 ms</label>
|
|
</div>
|
|
<div role="status" style={{ padding: "0 20px 8px" }}>{error || (loaded
|
|
? `${loaded.graph.partialRendererReady ? "Partial original" : "Ready"} · ${runeLayers(loaded.graph, phase).length} sprite layers · ${loaded.graph.textures.length} textures`
|
|
: "Loading RuneWaker media…")}</div>
|
|
{loaded?.graph.partialRendererReady && <div style={{ padding: "0 20px 8px" }}>
|
|
Fidelity limit: {loaded.graph.omittedVisuals?.join(", ")}
|
|
</div>}
|
|
<div style={{ height: "calc(100vh - 100px)" }}>
|
|
<Canvas camera={{ position: [3, 2.5, 5], fov: 45 }}>
|
|
<color attach="background" args={["#0b121b"]} />
|
|
<Grid args={[20, 20]} cellColor="#304051" sectionColor="#4a637d" fadeDistance={25} />
|
|
<mesh position={[-1.8, 0.9, 0]}><capsuleGeometry args={[0.25, 1.25, 6, 12]} /><meshBasicMaterial color="#35465c" /></mesh>
|
|
{loaded && <Effect key={`${selected}:${replay}`} loaded={loaded} phase={phase} playing={playing}
|
|
replay={replay} freezeAt={freezeAt} />}
|
|
<OrbitControls target={[0, 1, 0]} />
|
|
</Canvas>
|
|
</div>
|
|
</>;
|
|
}
|
|
|
|
if (import.meta.env.DEV) {
|
|
const root = createRoot(document.getElementById("root")!);
|
|
root.render(<Preview />);
|
|
if (import.meta.hot) import.meta.hot.dispose(() => root.unmount());
|
|
}
|