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) => { 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 =
releasePointerLock()} onFocusCapture={() => { releasePointerLock(); useBiteSizedLayoutsStore.setState({ panelFocused: true }); }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) useBiteSizedLayoutsStore.setState({ panelFocused: false }); }}>

GM world authoring

Bite-Sized Editor

{inGame && }
{!collapsed &&

{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"}

{inGame &&

Click the world or Walk in scene, then use WASD. Hold right mouse to look; Space jumps. Panel controls pause movement.

} {!inGame && } {spot && <>
{layouts.editorMode !== "test" && <>
{position &&
{mob ? "Mob spawn" : selectedPoint === "boss" ? "Boss spawn" : "Party anchor"}
{["X", "Y", "Z"].map((axis, i) => )}
}

Dungeon creatures

{mob && }
{mob &&
}

Bosses are rolled per run. Randomized mode uses these points with creatures from its selected pool.

} } {(message || layouts.error) &&

{message || layouts.error}

}
}
; return inGame ? content : {content}; }