forked from phenom/healer-man
133 lines
15 KiB
TypeScript
133 lines
15 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, normalizeBiteSizedConfig, selectBiteSizedRun, type BiteSizedSpot, type LayoutVector } from "../../shared/bite-sized-layouts.mjs";
|
||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||
import { useShellStore } from "../app/shellStore";
|
||
import { sessionCanUseGmTools } from "../app/releaseFeatures";
|
||
import { useGameStore } from "../game/store";
|
||
import { enterBiteSizedEditorSpot, leaveBiteSizedEditor } from "../app/biteSizedEditorRuntime";
|
||
import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
|
||
import { validateChangedBiteSizedSpots } from "../game/biteSizedLayoutValidation";
|
||
import { FrontSurface } from "./FrontSurface";
|
||
import { releasePointerLock } from "../game/inputManager";
|
||
import { focusBiteSizedEditorScene } from "../game/biteSizedEditorInput";
|
||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||
import "./biteSizedEditor.css";
|
||
|
||
export function BiteSizedEditor({ inGame = false }: { readonly inGame?: boolean }) {
|
||
const layouts = useBiteSizedLayoutsStore();
|
||
const session = useShellStore((s) => s.session);
|
||
const grounded = useGameStore((s) => s.playerGrounded);
|
||
const testComplete = useDungeonRunStore((s) => s.progress?.finishedAt != null);
|
||
const [dungeonId, setDungeonId] = useState(BITE_SIZED_LAYOUT_CATALOG.dungeons[0].id);
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const [search, setSearch] = useState("");
|
||
const [crossDungeon, setCrossDungeon] = useState(false);
|
||
const [creatureKey, setCreatureKey] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [message, setMessage] = useState("");
|
||
const current = layouts.draft.spots.find((s) => s.id === layouts.editingSpotId);
|
||
const activeDungeon = inGame && current ? current.dungeonId : dungeonId;
|
||
const spots = layouts.draft.spots.filter((s) => s.dungeonId === activeDungeon);
|
||
const spot = current?.dungeonId === activeDungeon ? current : spots[0];
|
||
const selectedPoint = layouts.editingPointId;
|
||
const mob = spot?.mobs.find((m) => m.id === selectedPoint);
|
||
const position = mob?.position ?? (selectedPoint === "boss" ? spot?.bossSpawn.footPosition : spot?.partySpawn.footPosition);
|
||
const yaw = mob?.yaw ?? (selectedPoint === "boss" ? spot?.bossSpawn.yaw : spot?.partySpawn.yaw) ?? 0;
|
||
const creatures = useMemo(() => BITE_SIZED_LAYOUT_CATALOG.creatures.filter((c) => c.kind === "mob"
|
||
&& (crossDungeon || c.sourceDungeonId === spot?.sourceDungeonId)
|
||
&& `${c.name} ${c.dungeonTitle}`.toLowerCase().includes(search.toLowerCase())).sort((a, b) => a.name.localeCompare(b.name)), [crossDungeon, search, spot?.sourceDungeonId]);
|
||
const creature = creatures.find((c) => `${c.sourceDungeonId}:${c.entityId}` === creatureKey) ?? creatures[0];
|
||
useEffect(() => { if (!inGame) void useBiteSizedLayoutsStore.getState().refresh(); }, [inGame]);
|
||
useEffect(() => () => { useBiteSizedLayoutsStore.setState({ panelFocused: false }); }, []);
|
||
if (!sessionCanUseGmTools(session) || (inGame && !layouts.editorMode)) return null;
|
||
const attempt = async (action: () => Promise<unknown>) => {
|
||
setBusy(true); setMessage("");
|
||
try { await action(); } catch (error) { setMessage(error instanceof Error ? error.message : "Action failed."); }
|
||
finally { setBusy(false); }
|
||
};
|
||
const select = (id: string) => useBiteSizedLayoutsStore.setState({ editingSpotId: id, editingPointId: "party" });
|
||
const updatePoint = (next: LayoutVector, facing = yaw) => {
|
||
if (!spot) return;
|
||
const changed = selectedPoint === "boss" ? { ...spot, bossSpawn: { footPosition: next, yaw: facing } }
|
||
: mob ? { ...spot, mobs: spot.mobs.map((m) => m.id === mob.id ? { ...m, position: next, yaw: facing } : m) }
|
||
: { ...spot, partySpawn: { footPosition: next, yaw: facing } };
|
||
layouts.updateSpot(changed);
|
||
};
|
||
const capture = () => { const game = useGameStore.getState(); if (game.playerGrounded) updatePoint([...game.playerPosition], game.cameraYaw); };
|
||
const preparePreview = async (next: BiteSizedSpot) => {
|
||
const currentDraft = useBiteSizedLayoutsStore.getState().draft;
|
||
const draft = { ...currentDraft, spots: currentDraft.spots.map((s) => s.id === next.id ? next : s) };
|
||
const selection = selectBiteSizedRun(draft, next.dungeonId, "gm-preview", { spotId: next.id });
|
||
await prepareBiteSizedLayoutContent(next.dungeonId, "gm-preview", selection);
|
||
};
|
||
const addMob = () => {
|
||
if (!spot || !creature) return;
|
||
const game = useGameStore.getState();
|
||
const id = `mob-${crypto.randomUUID()}`;
|
||
const next = { ...spot, mobs: [...spot.mobs, { id, sourceDungeonId: creature.sourceDungeonId, entityId: creature.entityId,
|
||
position: inGame ? [...game.playerPosition] as LayoutVector : spot.partySpawn.footPosition, yaw: inGame ? game.cameraYaw : 0 }] };
|
||
void attempt(async () => { await preparePreview(next); layouts.updateSpot(next); useBiteSizedLayoutsStore.setState({ editingPointId: id }); });
|
||
};
|
||
const createSpot = (duplicate: boolean) => {
|
||
if (!spot) return;
|
||
const id = `spot-${crypto.randomUUID()}`;
|
||
const game = useGameStore.getState();
|
||
const next: BiteSizedSpot = duplicate ? { ...spot, id, revision: 0, name: `${spot.name.slice(0, 90)} copy`, mobs: spot.mobs.map((m) => ({ ...m, id: `mob-${crypto.randomUUID()}` })) }
|
||
: { ...spot, id, revision: 0, name: "New spot", legacyLayout: false, mobs: [], bossActivation: "after-mobs",
|
||
partySpawn: inGame ? { footPosition: [...game.playerPosition], yaw: game.cameraYaw } : spot.partySpawn,
|
||
bossSpawn: inGame ? { footPosition: [...game.playerPosition], yaw: game.cameraYaw } : spot.partySpawn };
|
||
layouts.replaceDraft({ schemaVersion: 1, spots: [...layouts.draft.spots, next] }); select(id);
|
||
if (inGame) void attempt(() => enterBiteSizedEditorSpot(id));
|
||
};
|
||
const publish = () => void attempt(async () => {
|
||
const draft = normalizeBiteSizedConfig(useBiteSizedLayoutsStore.getState().draft);
|
||
for (const changed of draft.spots) {
|
||
const previous = layouts.config.spots.find((s) => s.id === changed.id);
|
||
if (!previous || biteSizedGameplayKey(previous) !== biteSizedGameplayKey(changed)) await preparePreview(changed);
|
||
}
|
||
await validateChangedBiteSizedSpots(draft.spots, layouts.config.spots);
|
||
if (await layouts.publish(session)) setMessage("Published. New runs use these layouts; active runs keep their saved spot.");
|
||
});
|
||
const content = <section className={`bite-sized-editor ${inGame ? "bite-sized-editor--world" : ""} ${collapsed ? "is-collapsed" : ""}`}
|
||
aria-label="Bite-Sized Editor" onPointerDown={() => releasePointerLock()}
|
||
onFocusCapture={() => { releasePointerLock(); useBiteSizedLayoutsStore.setState({ panelFocused: true }); }}
|
||
onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) useBiteSizedLayoutsStore.setState({ panelFocused: false }); }}>
|
||
<header><div><p className="eyebrow">GM world authoring</p><h1>Bite-Sized Editor</h1></div>
|
||
{inGame && <button onClick={() => { setCollapsed(!collapsed); useBiteSizedLayoutsStore.setState({ panelFocused: false }); }} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button>}
|
||
<button onClick={() => inGame ? leaveBiteSizedEditor() : useShellStore.getState().returnToMainMenu()}>{inGame ? "Spot browser" : "Main menu"}</button></header>
|
||
{!collapsed && <div className="bite-sized-editor__body">
|
||
<p className="bite-sized-editor__status">{layouts.dirty ? "Unsaved draft" : `Published revision ${layouts.revision}`} · {layouts.editorMode === "test" ? `${testComplete ? "Test complete" : "Test run"} — rewards disabled` : inGame ? "Combat disabled · walk to place points" : "Choose a spot to visit"}</p>
|
||
{inGame && <div><button onClick={focusBiteSizedEditorScene}>Walk in scene</button><p className="bite-sized-editor__hint">Click the world or Walk in scene, then use WASD. Hold right mouse to look; Space jumps. Panel controls pause movement.</p></div>}
|
||
{!inGame && <label>Dungeon<select aria-label="Dungeon" value={activeDungeon} onChange={(e) => { setDungeonId(e.target.value); useBiteSizedLayoutsStore.setState({ editingSpotId: null }); }}>{BITE_SIZED_LAYOUT_CATALOG.dungeons.map((d) => <option key={d.id} value={d.id}>{d.title}</option>)}</select></label>}
|
||
<label>Spot<select aria-label="Spot" value={spot?.id ?? ""} disabled={busy} onChange={(e) => { select(e.target.value); if (inGame) void attempt(() => enterBiteSizedEditorSpot(e.target.value)); }}>{spots.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</select></label>
|
||
{spot && <>
|
||
<div className="bite-sized-editor__actions"><button disabled={busy} onClick={() => void attempt(() => enterBiteSizedEditorSpot(spot.id))}>{layouts.editorMode === "test" ? "Return to editing" : "Go to spot"}</button>
|
||
<button disabled={busy} onClick={() => void attempt(() => enterBiteSizedEditorSpot(spot.id, "test"))}>Test run</button></div>
|
||
{layouts.editorMode !== "test" && <>
|
||
<label>Spot name<input value={spot.name} maxLength={100} onChange={(e) => layouts.updateSpot({ ...spot, name: e.target.value })} /></label>
|
||
<div className="bite-sized-editor__actions"><button disabled={busy || spots.length >= 80} onClick={() => createSpot(false)}>New spot</button><button disabled={busy || spots.length >= 80} onClick={() => createSpot(true)}>Duplicate spot</button>
|
||
<button disabled={busy || spots.length <= 1} onClick={() => { layouts.replaceDraft({ schemaVersion: 1, spots: layouts.draft.spots.filter((s) => s.id !== spot.id) }); const next = spots.find((s) => s.id !== spot.id)!; select(next.id); if (inGame) void attempt(() => enterBiteSizedEditorSpot(next.id)); }}>Remove spot</button></div>
|
||
<label>Boss arrival<select aria-label="Boss arrival" value={spot.bossActivation} onChange={(e) => layouts.updateSpot({ ...spot, bossActivation: e.target.value as BiteSizedSpot["bossActivation"] })}><option value="after-mobs">After all mobs die</option><option value="immediate">Present from start</option></select></label>
|
||
<div className="bite-sized-editor__points"><button aria-pressed={selectedPoint === "party"} onClick={() => useBiteSizedLayoutsStore.setState({ editingPointId: "party" })}>Party start</button><button aria-pressed={selectedPoint === "boss"} onClick={() => useBiteSizedLayoutsStore.setState({ editingPointId: "boss" })}>Random boss spawn</button></div>
|
||
<label>Mob points ({spot.mobs.length})<select value={mob?.id ?? ""} onChange={(e) => useBiteSizedLayoutsStore.setState({ editingPointId: e.target.value || "party" })}><option value="">Select a mob point</option>{spot.mobs.map((m, i) => <option key={m.id} value={m.id}>{i + 1}. {BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.sourceDungeonId === m.sourceDungeonId && c.entityId === m.entityId)?.name ?? m.entityId}</option>)}</select></label>
|
||
{position && <fieldset><legend>{mob ? "Mob spawn" : selectedPoint === "boss" ? "Boss spawn" : "Party anchor"}</legend>
|
||
<div className="bite-sized-editor__coordinates">{["X", "Y", "Z"].map((axis, i) => <label key={axis}>{axis}<input aria-label={`Spawn ${axis}`} type="number" step="0.1" value={position[i]} onChange={(e) => { const n = e.currentTarget.valueAsNumber; if (Number.isFinite(n)) { const next = [...position] as [number, number, number]; next[i] = n; updatePoint(next); } }} /></label>)}</div>
|
||
<label>Facing<input aria-label="Spawn facing" type="number" step="0.05" value={yaw} onChange={(e) => { if (Number.isFinite(e.currentTarget.valueAsNumber)) updatePoint(position, e.currentTarget.valueAsNumber); }} /></label>
|
||
<div className="bite-sized-editor__actions"><button disabled={!inGame || !grounded} onClick={capture}>Set here</button><button disabled={!inGame} onClick={() => { useBiteSizedLayoutsStore.setState({ panelFocused: false }); useGameStore.getState().relocatePlayer({ footPosition: position, yaw }); }}>Go to point</button></div>
|
||
</fieldset>}
|
||
<h2>Dungeon creatures</h2><label>Search mobs<input aria-label="Search mobs" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Name or source dungeon" /></label>
|
||
<label className="bite-sized-editor__check"><input type="checkbox" checked={crossDungeon} onChange={(e) => setCrossDungeon(e.target.checked)} />Other dungeons</label>
|
||
<label>Mob type<select aria-label="Mob type" value={creature ? `${creature.sourceDungeonId}:${creature.entityId}` : ""} onChange={(e) => setCreatureKey(e.target.value)}>{creatures.map((c) => <option key={`${c.sourceDungeonId}:${c.entityId}`} value={`${c.sourceDungeonId}:${c.entityId}`}>{c.name}{crossDungeon ? ` · ${c.dungeonTitle}` : ""}</option>)}</select></label>
|
||
<div className="bite-sized-editor__actions"><button disabled={busy || !creature || (inGame && !grounded) || spot.mobs.length >= 500} onClick={addMob}>{inGame ? "Add mob here" : "Add mob point"}</button>
|
||
{mob && <button disabled={busy || !creature} onClick={() => void attempt(async () => { const next = { ...spot, mobs: spot.mobs.map((m) => m.id === mob.id ? { ...m, sourceDungeonId: creature!.sourceDungeonId, entityId: creature!.entityId } : m) }; await preparePreview(next); layouts.updateSpot(next); })}>Change mob type</button>}</div>
|
||
{mob && <div className="bite-sized-editor__actions"><button disabled={busy} onClick={() => { const id = `mob-${crypto.randomUUID()}`; layouts.updateSpot({ ...spot, mobs: [...spot.mobs, { ...mob, id, position: [mob.position[0] + 1, mob.position[1], mob.position[2]] }] }); useBiteSizedLayoutsStore.setState({ editingPointId: id }); }}>Duplicate mob point</button><button onClick={() => { layouts.updateSpot({ ...spot, mobs: spot.mobs.filter((m) => m.id !== mob.id) }); useBiteSizedLayoutsStore.setState({ editingPointId: "party" }); }}>Remove mob point</button></div>}
|
||
<p className="bite-sized-editor__hint">Bosses are rolled per run. Randomized mode uses these points with creatures from its selected pool.</p>
|
||
</>}
|
||
</>}
|
||
{(message || layouts.error) && <p role="status">{message || layouts.error}</p>}
|
||
<footer><button disabled={!layouts.dirty || busy || layouts.saving} onClick={() => { layouts.discard(); setMessage("Draft discarded."); }}>Discard</button><button disabled={busy || layouts.loading} onClick={() => void layouts.refresh()}>Refresh published</button><button disabled={!layouts.dirty || busy || layouts.saving} onClick={publish}>{busy || layouts.saving ? "Working…" : "Save & publish"}</button></footer>
|
||
</div>}
|
||
</section>;
|
||
return inGame ? content : <FrontSurface className="bite-sized-editor-browser" ariaLabel="Bite-Sized spot browser">{content}</FrontSurface>;
|
||
}
|