import { create } from "zustand"; import { resolveAreaNameForDungeon } from "./dungeonManifest"; import { DEFAULT_DUNGEON_ID, requireDungeonDefinition, type DungeonId } from "./dungeonRegistry"; import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes"; import type { ActionBindingLayer } from "./actionBindings"; import type { GmReviewTarget } from "./gmContentReview"; export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error"; export type InputMode = "keyboard" | "gamepad"; export type GameMode = "dungeon" | "manastorm"; export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "gm" | null; interface PlayerSnapshot { position: Vector3Tuple; yaw: number; distanceDelta?: number; grounded?: boolean; } export interface GameState { activeDungeonId: DungeonId; activeDifficultyId: string; gameMode: GameMode; gmTestMode: boolean; activeGmReviewTarget: GmReviewTarget | null; activeSpawn: WorldSpawn; sessionRevision: number; overlay: GameOverlay; paused: boolean; mapOpen: boolean; companionOpen: boolean; cameraLookActive: boolean; inputMode: InputMode; actionLayer: ActionBindingLayer; assetStatus: AssetStatus; assetMessage: string | null; playerPosition: Vector3Tuple; playerNavigationPosition: Vector3Tuple; playerGrounded: boolean; cameraYaw: number; areaName: string; distanceTravelled: number; hasMoved: boolean; resetRevision: number; /** Renderer relocation signal that does not imply a combat/encounter reset. */ relocationRevision: number; /** Explicit local reset request; dungeon activation performs its reset before hydration. */ encounterResetRevision: number; activateDungeon: ( dungeonId: DungeonId, difficultyId?: string, options?: { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn; readonly gmTestMode?: boolean; readonly gmReviewTarget?: GmReviewTarget | null; }, ) => void; openOverlay: (overlay: Exclude) => void; toggleOverlay: (overlay: Exclude) => void; closeOverlay: () => void; togglePause: () => void; setPaused: (paused: boolean) => void; toggleMap: () => void; closeMap: () => void; setCompanionOpen: (open: boolean) => void; setCameraLookActive: (active: boolean) => void; setInputMode: (mode: InputMode) => void; setActionLayer: (layer: ActionBindingLayer) => void; setAssetStatus: (status: AssetStatus, message?: string | null) => void; updatePlayerSnapshot: (snapshot: PlayerSnapshot) => void; relocatePlayer: (spawn: WorldSpawn, makeActiveSpawn?: boolean) => void; /** Returns the party to its saved run spawn without requesting a full dungeon reset. */ resetAfterDefeat: () => void; resetAtActiveSpawn: () => void; resetAtEntrance: () => void; } const initialDungeon = requireDungeonDefinition(DEFAULT_DUNGEON_ID); const entrance = initialDungeon.entrance; export const useGameStore = create((set) => ({ activeDungeonId: DEFAULT_DUNGEON_ID, activeDifficultyId: initialDungeon.defaultDifficultyId, gameMode: "dungeon", gmTestMode: false, activeGmReviewTarget: null, activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw }, sessionRevision: 0, overlay: null, paused: false, mapOpen: false, companionOpen: false, cameraLookActive: false, inputMode: "keyboard", actionLayer: "primary", assetStatus: "checking", assetMessage: null, playerPosition: entrance.footPosition, playerNavigationPosition: entrance.footPosition, playerGrounded: true, cameraYaw: entrance.yaw, areaName: resolveAreaNameForDungeon(initialDungeon, entrance.footPosition), distanceTravelled: 0, hasMoved: false, resetRevision: 0, relocationRevision: 0, encounterResetRevision: 0, activateDungeon: (activeDungeonId, requestedDifficultyId, options) => set((state) => { const definition = requireDungeonDefinition(activeDungeonId); const activeDifficultyId = definition.difficultyVariants.some((variant) => variant.id === requestedDifficultyId) ? requestedDifficultyId! : definition.defaultDifficultyId; const activeSpawn = options?.spawn ?? { footPosition: definition.entrance.footPosition, yaw: definition.entrance.yaw, }; return { activeDungeonId, activeDifficultyId, gameMode: options?.gameMode ?? "dungeon", gmTestMode: options?.gmTestMode ?? false, activeGmReviewTarget: options?.gmReviewTarget ?? null, activeSpawn, sessionRevision: state.sessionRevision + 1, overlay: null, paused: false, mapOpen: false, companionOpen: false, cameraLookActive: false, actionLayer: "primary", assetStatus: "checking", assetMessage: null, playerPosition: activeSpawn.footPosition, playerNavigationPosition: activeSpawn.footPosition, playerGrounded: true, cameraYaw: activeSpawn.yaw, areaName: resolveAreaNameForDungeon(definition, activeSpawn.footPosition), distanceTravelled: 0, hasMoved: false, resetRevision: state.resetRevision + 1, relocationRevision: state.relocationRevision + 1, }; }), openOverlay: (overlay) => set({ overlay, paused: true, mapOpen: overlay === "map", cameraLookActive: false, }), toggleOverlay: (overlay) => set((state) => { const nextOverlay = state.overlay === overlay ? null : overlay; return { overlay: nextOverlay, paused: nextOverlay !== null, mapOpen: nextOverlay === "map", cameraLookActive: false, }; }), closeOverlay: () => set({ overlay: null, paused: false, mapOpen: false, cameraLookActive: false }), togglePause: () => set((state) => { const nextOverlay = state.overlay === "pause" ? null : "pause"; return { overlay: nextOverlay, paused: nextOverlay !== null, mapOpen: false, cameraLookActive: false, }; }), setPaused: (paused) => set({ overlay: paused ? "pause" : null, paused, mapOpen: false, cameraLookActive: false, }), toggleMap: () => set((state) => { const nextOverlay = state.overlay === "map" ? null : "map"; return { overlay: nextOverlay, paused: nextOverlay !== null, mapOpen: nextOverlay === "map", cameraLookActive: false, }; }), closeMap: () => set((state) => state.overlay === "map" ? { overlay: null, paused: false, mapOpen: false } : { mapOpen: false }), setCompanionOpen: (companionOpen) => set((state) => ({ companionOpen, cameraLookActive: companionOpen ? false : state.cameraLookActive, })), setCameraLookActive: (cameraLookActive) => set((state) => state.cameraLookActive === cameraLookActive ? state : { cameraLookActive }), setInputMode: (inputMode) => set((state) => state.inputMode === inputMode ? state : { inputMode }), setActionLayer: (actionLayer) => set((state) => state.actionLayer === actionLayer ? state : { actionLayer }), setAssetStatus: (assetStatus, assetMessage = null) => set((state) => { const isStaleLoadingWrite = state.assetStatus === "ready" && (assetStatus === "checking" || assetStatus === "loading"); if (isStaleLoadingWrite) return state; return { assetStatus, assetMessage }; }), updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0, grounded = true }) => set((state) => { const definition = requireDungeonDefinition(state.activeDungeonId); return { playerPosition: position, playerNavigationPosition: grounded ? position : state.playerNavigationPosition, playerGrounded: grounded, cameraYaw: yaw, areaName: resolveAreaNameForDungeon(definition, position), distanceTravelled: state.distanceTravelled + Math.max(0, distanceDelta), hasMoved: state.hasMoved || distanceDelta > 0.02, }; }), relocatePlayer: (spawn, makeActiveSpawn = false) => set((state) => { const definition = requireDungeonDefinition(state.activeDungeonId); return { ...(makeActiveSpawn ? { activeSpawn: spawn } : {}), playerPosition: spawn.footPosition, playerNavigationPosition: spawn.footPosition, playerGrounded: true, cameraYaw: spawn.yaw, areaName: resolveAreaNameForDungeon(definition, spawn.footPosition), cameraLookActive: false, relocationRevision: state.relocationRevision + 1, }; }), resetAfterDefeat: () => set((state) => { const definition = requireDungeonDefinition(state.activeDungeonId); return { overlay: null, paused: false, mapOpen: false, cameraLookActive: false, playerPosition: state.activeSpawn.footPosition, playerNavigationPosition: state.activeSpawn.footPosition, playerGrounded: true, cameraYaw: state.activeSpawn.yaw, areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition), distanceTravelled: 0, hasMoved: false, actionLayer: "primary", resetRevision: state.resetRevision + 1, relocationRevision: state.relocationRevision + 1, }; }), resetAtActiveSpawn: () => set((state) => { const definition = requireDungeonDefinition(state.activeDungeonId); return { overlay: null, paused: false, mapOpen: false, cameraLookActive: false, playerPosition: state.activeSpawn.footPosition, playerNavigationPosition: state.activeSpawn.footPosition, playerGrounded: true, cameraYaw: state.activeSpawn.yaw, areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition), distanceTravelled: 0, hasMoved: false, actionLayer: "primary", resetRevision: state.resetRevision + 1, relocationRevision: state.relocationRevision + 1, encounterResetRevision: state.encounterResetRevision + 1, }; }), resetAtEntrance: () => set((state) => { const activeEntrance = requireDungeonDefinition(state.activeDungeonId).entrance; const definition = requireDungeonDefinition(state.activeDungeonId); const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw }; return { gameMode: "dungeon", gmTestMode: false, activeGmReviewTarget: null, activeSpawn, overlay: null, paused: false, mapOpen: false, cameraLookActive: false, playerPosition: activeEntrance.footPosition, playerNavigationPosition: activeEntrance.footPosition, playerGrounded: true, cameraYaw: activeEntrance.yaw, areaName: resolveAreaNameForDungeon(definition, activeEntrance.footPosition), distanceTravelled: 0, hasMoved: false, actionLayer: "primary", resetRevision: state.resetRevision + 1, relocationRevision: state.relocationRevision + 1, encounterResetRevision: state.encounterResetRevision + 1, }; }), }));