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