forked from phenom/healer-man
Release Healer Man 0.1.5
This commit is contained in:
1 parent
f4c9cf356c
commit
55cfd43d66
62 files changed
+3030
-153
No files matched your search
@@ -0,0 +1,268 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user