298 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}),
|
|
}));
|