forked from phenom/healer-man
159 lines
7.1 KiB
TypeScript
159 lines
7.1 KiB
TypeScript
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>
|
||
);
|
||
}
|