Files
healer-man/src/game/store.ts
T
2026-09-09 11:21:33 -04:00

298 lines
11 KiB
TypeScript

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<GameOverlay, null>) => void;
toggleOverlay: (overlay: Exclude<GameOverlay, null>) => 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<GameState>((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,
};
}),
}));