Files
healer-man/src/ui/GmWorldEditor.tsx
T
2026-08-18 16:22:19 -04:00

269 lines
13 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 { useMemo, type ReactNode } from "react";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import type { Vector3Tuple, WorldSpawn } from "../game/dungeonTypes";
import {
authoredStageConfigForBoss,
isGameMasterSession,
resolvedBossAdminCatalog,
type ManastormAdminStageConfig,
type ResolvedBossAdminEntry,
} from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
import { getMobRuntimeTransform } from "../game/mobRuntimeRegistry";
import { useGameStore } from "../game/store";
const AXES = ["X", "Y", "Z"] as const;
function formatCoordinate(value: number): string {
return Number.isFinite(value) ? value.toFixed(2) : "—";
}
function VectorEditor(props: {
readonly label: string;
readonly value: Vector3Tuple;
readonly onChange: (value: Vector3Tuple) => void;
readonly extra?: ReactNode;
}) {
const setAxis = (axis: number, raw: number) => {
if (!Number.isFinite(raw)) return;
const next = [...props.value] as [number, number, number];
next[axis] = raw;
props.onChange(next);
};
const nudge = (axis: number, amount: number) => setAxis(axis, props.value[axis] + amount);
return (
<fieldset className="gm-vector-editor">
<legend>{props.label}</legend>
<div className="gm-vector-editor__coordinates">
{AXES.map((axis, index) => (
<label key={axis}>
<span>{axis}</span>
<input
aria-label={`${props.label} ${axis}`}
type="number"
step="0.1"
value={props.value[index]}
onChange={(event) => setAxis(index, event.currentTarget.valueAsNumber)}
/>
<span className="gm-nudges">
<button type="button" aria-label={`Nudge ${props.label} ${axis} down`} onClick={() => nudge(index, -0.25)}>−</button>
<button type="button" aria-label={`Nudge ${props.label} ${axis} up`} onClick={() => nudge(index, 0.25)}>+</button>
</span>
</label>
))}
</div>
{props.extra}
</fieldset>
);
}
function targetSummary(target: ReturnType<typeof useCombatStore.getState>["mobs"][string] | null, id: string | null): string {
if (!target || !id) return "No hostile target selected";
return `${target.name} · ${target.boss ? "Boss" : "Mob"} · ${id}`;
}
export function GmWorldEditor() {
const overlay = useGameStore((state) => state.overlay);
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const playerPosition = useGameStore((state) => state.playerPosition);
const playerGrounded = useGameStore((state) => state.playerGrounded);
const cameraYaw = useGameStore((state) => state.cameraYaw);
const closeOverlay = useGameStore((state) => state.closeOverlay);
const session = useShellStore((state) => state.session);
const selectedTargetId = useCombatStore((state) => state.selectedTargetId);
const selectedTarget = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null);
const draft = useManastormAdminStore((state) => state.draft);
const dirty = useManastormAdminStore((state) => state.dirty);
const saving = useManastormAdminStore((state) => state.saving);
const error = useManastormAdminStore((state) => state.error);
const editingBossKey = useManastormAdminStore((state) => state.editingBossKey);
const replaceDraft = useManastormAdminStore((state) => state.replaceDraft);
const publish = useManastormAdminStore((state) => state.publish);
const setEditingBossKey = useManastormAdminStore((state) => state.setEditingBossKey);
const catalog = useMemo(() => resolvedBossAdminCatalog(draft), [draft]);
const dungeon = requireDungeonDefinition(activeDungeonId);
const dungeonBosses = catalog.filter((entry) => entry.dungeonId === activeDungeonId);
const entry = catalog.find((candidate) => candidate.key === editingBossKey && candidate.dungeonId === activeDungeonId) ?? null;
const stage = entry ? authoredStageConfigForBoss(entry, draft) : null;
const targetTransform = selectedTargetId ? getMobRuntimeTransform(selectedTargetId) : null;
if (overlay !== "gm" || !isGameMasterSession(session)) return null;
const updateStage = (activeEntry: ResolvedBossAdminEntry, nextStage: ManastormAdminStageConfig) => {
const previous = draft.bosses[activeEntry.key];
replaceDraft({
...draft,
bosses: {
...draft.bosses,
[activeEntry.key]: {
status: previous?.status === "not-ready" ? "not-ready" : "needs-tested",
stage: nextStage,
},
},
});
};
const captureDungeonAnchor = () => {
if (!playerGrounded) return;
replaceDraft({
...draft,
dungeonSpawns: {
...draft.dungeonSpawns,
[activeDungeonId]: { footPosition: [...playerPosition] as Vector3Tuple, yaw: cameraYaw },
},
});
};
const captureBoss = () => {
if (!selectedTarget?.boss || !selectedTargetId || !targetTransform) return;
const matching = dungeonBosses.find((candidate) => candidate.bossRuntimeId === selectedTargetId)
?? dungeonBosses.find((candidate) => candidate.name.toLowerCase() === selectedTarget.name.toLowerCase());
if (!matching) return;
const targetStage = authoredStageConfigForBoss(matching, draft);
if (!targetStage) return;
setEditingBossKey(matching.key);
updateStage(matching, {
...targetStage,
bossRuntimeId: selectedTargetId,
bossPosition: [...targetTransform.position] as Vector3Tuple,
bossYaw: targetTransform.yaw,
});
};
const setGroupSpawn = (spawn: WorldSpawn) => {
if (!entry || !stage) return;
updateStage(entry, { ...stage, groupSpawn: spawn });
};
const setLinked = (ids: readonly string[]) => {
if (!entry || !stage) return;
const unique = [...new Set(ids)].filter((id) => id !== stage.bossRuntimeId);
updateStage(entry, {
...stage,
linkedMobRuntimeIds: unique,
requiredKillCount: Math.min(stage.requiredKillCount, unique.length),
});
};
const canAddTarget = Boolean(
entry
&& stage
&& selectedTargetId
&& selectedTarget
&& !selectedTarget.boss
&& !stage.linkedMobRuntimeIds.includes(selectedTargetId),
);
return (
<div className="gm-world-editor" role="dialog" aria-modal="true" aria-labelledby="gm-world-title">
<header className="gm-world-editor__header">
<div>
<p className="eyebrow">Live world authoring</p>
<h2 id="gm-world-title">{dungeon.title}</h2>
</div>
<span className={dirty ? "is-dirty" : ""}>{dirty ? "Unsaved changes" : "Published revision loaded"}</span>
<button className="icon-button" type="button" aria-label="Close GM editor" onClick={closeOverlay}>×</button>
</header>
<div className="gm-world-editor__telemetry">
<span>Player <b>{playerPosition.map(formatCoordinate).join(", ")}</b></span>
<span>Yaw <b>{formatCoordinate(cameraYaw)}</b></span>
<span>Grounded <b>{playerGrounded ? "Yes" : "No"}</b></span>
<span>Target <b>{targetSummary(selectedTarget, selectedTargetId)}</b></span>
</div>
<section className="gm-world-editor__section">
<label className="gm-world-editor__boss-select">
<span>Boss record</span>
<select value={entry?.key ?? ""} onChange={(event) => setEditingBossKey(event.currentTarget.value || null)}>
<option value="">Select a boss in this dungeon</option>
{dungeonBosses.map((boss) => <option key={boss.key} value={boss.key}>{boss.name} · {boss.status}</option>)}
</select>
</label>
<div className="gm-inline-actions">
<button type="button" disabled={!selectedTarget?.boss || !targetTransform} onClick={captureBoss}>Capture targeted boss</button>
<button type="button" disabled={!playerGrounded} onClick={captureDungeonAnchor}>Set dungeon group anchor here</button>
</div>
</section>
{entry && stage ? (
<div className="gm-world-editor__scroll">
<section className="gm-world-editor__section">
<h3>{entry.name}</h3>
<p>{entry.status === "not-ready" ? "Not ready" : "Edits return this boss to Needs tested"} · Map {stage.mapId}</p>
<VectorEditor
label="Boss spawn"
value={stage.bossPosition}
onChange={(bossPosition) => updateStage(entry, { ...stage, bossPosition })}
extra={(
<label className="gm-yaw-editor"><span>Boss yaw</span><input type="number" step="0.05" value={stage.bossYaw} onChange={(event) => {
if (Number.isFinite(event.currentTarget.valueAsNumber)) updateStage(entry, { ...stage, bossYaw: event.currentTarget.valueAsNumber });
}} /><span className="gm-nudges"><button type="button" aria-label="Nudge boss yaw down" onClick={() => updateStage(entry, { ...stage, bossYaw: stage.bossYaw - 0.05 })}>−</button><button type="button" aria-label="Nudge boss yaw up" onClick={() => updateStage(entry, { ...stage, bossYaw: stage.bossYaw + 0.05 })}>+</button></span></label>
)}
/>
<VectorEditor
label="Party group anchor"
value={stage.groupSpawn.footPosition}
onChange={(footPosition) => setGroupSpawn({ ...stage.groupSpawn, footPosition })}
extra={(
<div className="gm-yaw-editor">
<label><span>Group yaw</span><input type="number" step="0.05" value={stage.groupSpawn.yaw} onChange={(event) => {
if (Number.isFinite(event.currentTarget.valueAsNumber)) setGroupSpawn({ ...stage.groupSpawn, yaw: event.currentTarget.valueAsNumber });
}} /><span className="gm-nudges"><button type="button" aria-label="Nudge group yaw down" onClick={() => setGroupSpawn({ ...stage.groupSpawn, yaw: stage.groupSpawn.yaw - 0.05 })}>−</button><button type="button" aria-label="Nudge group yaw up" onClick={() => setGroupSpawn({ ...stage.groupSpawn, yaw: stage.groupSpawn.yaw + 0.05 })}>+</button></span></label>
<button type="button" disabled={!playerGrounded} onClick={() => setGroupSpawn({ footPosition: [...playerPosition] as Vector3Tuple, yaw: cameraYaw })}>Capture player</button>
</div>
)}
/>
<VectorEditor
label="Exit portal"
value={stage.portalPosition}
onChange={(portalPosition) => updateStage(entry, { ...stage, portalPosition })}
extra={<button type="button" disabled={!playerGrounded} onClick={() => updateStage(entry, { ...stage, portalPosition: [...playerPosition] as Vector3Tuple })}>Capture player position</button>}
/>
</section>
<section className="gm-world-editor__section">
<div className="gm-world-editor__linked-heading">
<div><h3>Chaotic Link prerequisites</h3><p>Target an exact non-boss mob, then add its runtime ID.</p></div>
<button type="button" disabled={!canAddTarget} onClick={() => selectedTargetId && setLinked([...stage.linkedMobRuntimeIds, selectedTargetId])}>Add targeted mob</button>
</div>
<label className="gm-required-kills">
<span>Required kills</span>
<input
type="number"
min={0}
max={stage.linkedMobRuntimeIds.length}
value={stage.requiredKillCount}
onChange={(event) => {
const value = event.currentTarget.valueAsNumber;
if (Number.isInteger(value) && value >= 0 && value <= stage.linkedMobRuntimeIds.length) {
updateStage(entry, { ...stage, requiredKillCount: value });
}
}}
/>
<small>of {stage.linkedMobRuntimeIds.length} linked mobs</small>
</label>
<div className="gm-linked-list">
{stage.linkedMobRuntimeIds.map((id) => (
<div key={id}><code>{id}</code><button type="button" onClick={() => setLinked(stage.linkedMobRuntimeIds.filter((candidate) => candidate !== id))}>Remove</button></div>
))}
{!stage.linkedMobRuntimeIds.length ? <p>No prerequisite mobs selected. The boss begins unempowered.</p> : null}
</div>
</section>
</div>
) : (
<section className="gm-world-editor__empty">
<p>Select a boss record, or target a live boss and capture it.</p>
</section>
)}
<footer className="gm-world-editor__footer">
{error ? <p role="alert">{error}</p> : <p>Markers: <i className="is-party" /> party · <i className="is-boss" /> boss · <i className="is-portal" /> portal · <i className="is-linked" /> linked</p>}
<button type="button" disabled={!dirty || saving} onClick={() => { void publish(session); }}>{saving ? "Publishing…" : "Save & publish"}</button>
</footer>
</div>
);
}