Files
healer-man/src/ui/DungeonSimulationHud.tsx
T
2026-09-17 09:13:15 -04:00

159 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import type { DungeonSimulationSpeed } from "../game/dungeonSimulationTypes";
import { finalizeDungeonSimulation } from "../scene/DungeonSimulationSceneBridge";
import { useShellStore } from "../app/shellStore";
import { classById } from "../app/characterCatalog";
import { PARTY_ROLE_LABELS, usePartyStore } from "../game/partyStore";
const SPEEDS: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4];
function formatTime(elapsedMs: number): string {
const seconds = Math.floor(elapsedMs / 1_000);
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}.${Math.floor((elapsedMs % 1_000) / 100)}`;
}
function downloadReport(): void {
const simulation = useDungeonSimulationStore.getState();
const report = simulation.replay?.report;
if (!report) return;
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${report.dungeonId}-${report.seed}-SimulationReportV1.json`;
anchor.click();
URL.revokeObjectURL(url);
}
export function DungeonSimulationHud() {
const simulation = useDungeonSimulationStore();
const partyMembers = usePartyStore((state) => state.members);
const route = simulation.dungeonId
? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary")
: null;
const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null;
const replayDuration = simulation.replay?.durationMs ?? 0;
const fallbackMember = partyMembers.find((member) => member.health > 0) ?? partyMembers[0] ?? null;
const spectatedMember = partyMembers.find((member) => member.id === simulation.spectatedMemberId)
?? fallbackMember;
const returnToGmTools = () => {
useDungeonSimulationStore.getState().leave();
useShellStore.getState().openGmAdmin();
};
return (
<aside className="dungeon-sim-hud" aria-label="Dungeon simulation controls">
<div className="dungeon-sim-hud__header">
<div>
<small>{simulation.sessionMode === "spectate-replay" ? "Recorded replay" : simulation.mode.replace("-", " ")}</small>
<strong>{simulation.dungeonId ?? "Dungeon simulation"}</strong>
</div>
<time>{formatTime(simulation.elapsedMs)}</time>
</div>
<div className="dungeon-sim-hud__controls">
<button type="button" onClick={simulation.toggleRunning} disabled={simulation.runState === "finished"}>
{simulation.runState === "running" ? "Pause" : "Play"}
</button>
<button type="button" onClick={simulation.step} disabled={simulation.runState !== "paused"}>Step</button>
{SPEEDS.map((speed) => (
<button
type="button"
key={speed}
className={simulation.speed === speed ? "is-active" : undefined}
onClick={() => simulation.setSpeed(speed)}
>
{speed}×
</button>
))}
<button
type="button"
className={simulation.cameraMode === "follow-member" ? "is-active" : undefined}
aria-pressed={simulation.cameraMode === "follow-member"}
onClick={() => simulation.setCameraMode("follow-member")}
>
Follow member
</button>
<button
type="button"
className={simulation.cameraMode === "free-fly" ? "is-active" : undefined}
aria-pressed={simulation.cameraMode === "free-fly"}
onClick={() => simulation.setCameraMode("free-fly")}
>
Free camera
</button>
{simulation.sessionMode === "spectate-live" && simulation.runState !== "finished" ? (
<button type="button" className="is-danger" onClick={() => void finalizeDungeonSimulation("stopped")}>
Stop & save
</button>
) : null}
{simulation.replay && <button type="button" onClick={downloadReport}>Download JSON</button>}
{simulation.runState === "finished" && <button type="button" onClick={returnToGmTools}>Return to GM Tools</button>}
</div>
<section className="dungeon-sim-hud__spectate" aria-label="Spectate party member">
<header>
<span>{simulation.cameraMode === "free-fly" ? "Free camera" : "Following"}</span>
<strong>{simulation.cameraMode === "free-fly" ? "Detached" : spectatedMember?.name ?? "Waiting for party"}</strong>
</header>
<div>
{partyMembers.map((member) => {
const characterClass = classById(member.classId);
const selected = simulation.cameraMode === "follow-member" && member.id === spectatedMember?.id;
return (
<button
type="button"
key={member.id}
className={selected ? "is-active" : undefined}
aria-pressed={selected}
onClick={() => simulation.setSpectatedMember(member.id)}
>
<i style={{ background: characterClass.color }} aria-hidden="true" />
<span><strong>{member.name}</strong><small>{PARTY_ROLE_LABELS[member.role]} · {member.specialization}</small></span>
<em>{Math.max(0, Math.ceil(member.health))}</em>
</button>
);
})}
</div>
<p>{simulation.cameraMode === "free-fly"
? "Right-drag/Arrows look · WASD fly · E/Space up · Q/Ctrl down · Shift boost"
: "Choose a party member to follow · Right-drag/Arrows orbit"}</p>
</section>
{simulation.sessionMode === "spectate-replay" && (
<label className="dungeon-sim-hud__seek">
<span>Replay position</span>
<input
type="range"
min={0}
max={replayDuration}
step={100}
value={Math.min(simulation.elapsedMs, replayDuration)}
onChange={(event) => simulation.seekReplay(Number(event.target.value))}
/>
</label>
)}
<dl className="dungeon-sim-hud__status">
<div><dt>State</dt><dd>{simulation.runState}{simulation.result ? ` · ${simulation.result}` : ""}</dd></div>
<div><dt>Objective</dt><dd>{leg?.objectiveName ?? simulation.objectiveId ?? "Complete"}</dd></div>
<div><dt>Checkpoint</dt><dd>{leg ? `${Math.min(simulation.checkpointIndex + 1, leg.checkpoints.length)} / ${leg.checkpoints.length}` : "—"}</dd></div>
<div><dt>Route</dt><dd>{simulation.routeHash?.slice(0, 12) ?? "—"}</dd></div>
</dl>
<section className="dungeon-sim-hud__findings">
<header><strong>Findings</strong><span>{simulation.findings.length}</span></header>
<ol>
{[...simulation.findings].reverse().slice(0, 12).map((finding) => (
<li key={finding.id} data-severity={finding.severity}>
<time>{formatTime(finding.atMs)}</time>
<span>{finding.message}</span>
</li>
))}
</ol>
</section>
</aside>
);
}