207 lines
8.5 KiB
TypeScript
207 lines
8.5 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { useShellStore } from "../app/shellStore";
|
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
|
import { useGameStore } from "./store";
|
|
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
|
import { useManastormStore } from "./manastormStore";
|
|
import {
|
|
activateDungeonEncounterDefinitions,
|
|
advanceDungeonEncounters,
|
|
dispatchActiveDungeonEntityEvent,
|
|
resetDungeonEncounters,
|
|
} from "./dungeonEncounterRuntime";
|
|
import {
|
|
deriveDungeonEncounterCombatEvents,
|
|
type DungeonEncounterMobCursor,
|
|
} from "./dungeonEncounterCombatEvents";
|
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
|
import {
|
|
onlineSessionIsActive,
|
|
onlineSessionOwnsWorldSimulation,
|
|
} from "./onlineSessionRuntime";
|
|
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
|
|
|
/**
|
|
* Connects the pure combat runtime to the active character and persistent
|
|
* roster. Three.js remains a renderer/position source; this bridge owns the
|
|
* low-frequency simulation and save boundary.
|
|
*/
|
|
export function CombatBridge() {
|
|
const character = useShellStore((state) => state.activeCharacter);
|
|
const gameMode = useGameStore((state) => state.gameMode);
|
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
|
const activeDifficultyId = useGameStore((state) => state.activeDifficultyId);
|
|
const activeRole = useShellStore((state) => state.activeDungeonRole);
|
|
const encounterResetRevision = useGameStore((state) => state.encounterResetRevision);
|
|
const mountedResetRevision = useRef(encounterResetRevision);
|
|
const encounterMobCursor = useRef<DungeonEncounterMobCursor>({});
|
|
|
|
useEffect(() => {
|
|
if (!character) return;
|
|
useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
|
|
useCombatStore.getState().synchronizeActors();
|
|
if (gameMode === "manastorm") {
|
|
activateManastormActiveSpellLoadout(
|
|
useManastormStore.getState().activeSpellLoadout,
|
|
);
|
|
}
|
|
}, [activeRole, character?.id, gameMode]);
|
|
|
|
useEffect(() => {
|
|
const definition = requireDungeonDefinition(activeDungeonId);
|
|
encounterMobCursor.current = {};
|
|
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? [], activeDifficultyId);
|
|
}, [activeDifficultyId, activeDungeonId]);
|
|
|
|
useEffect(() => {
|
|
if (mountedResetRevision.current === encounterResetRevision) return;
|
|
mountedResetRevision.current = encounterResetRevision;
|
|
encounterMobCursor.current = {};
|
|
useCombatStore.getState().resetEncounter();
|
|
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
|
|
}, [encounterResetRevision]);
|
|
|
|
useEffect(() => {
|
|
if (!character) return;
|
|
const characterId = character.id;
|
|
const ownerId = useShellStore.getState().session?.ownerId;
|
|
const unsubscribe = useCombatStore.subscribe((state, previousState) => {
|
|
// Save APIs intentionally target the active character. Avoid persisting a
|
|
// final combat notification during the render-to-effect-cleanup window
|
|
// after Character Select has already replaced the active character.
|
|
const shell = useShellStore.getState();
|
|
if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return;
|
|
if (useGameStore.getState().bossTest) return;
|
|
|
|
// Persisted collections are replaced immutably by combat actions. A
|
|
// reference/primitive comparison therefore detects the same changes as
|
|
// serializing each collection, without doing five full-store callbacks
|
|
// and JSON.stringify work for every 100 ms simulation notification.
|
|
if (
|
|
state.level !== previousState.level
|
|
|| state.xp !== previousState.xp
|
|
|| state.talentRanks !== previousState.talentRanks
|
|
) {
|
|
// Kills and talent changes are infrequent, so persist immediately. This
|
|
// also guarantees a fast return to Character Select cannot outrun a
|
|
// deferred save and lose the last level or talent point.
|
|
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
|
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
|
|
}
|
|
|
|
if (state.actionBindings !== previousState.actionBindings) {
|
|
// Manastorm's local spell-selection substitute temporarily rewrites the
|
|
// base controls. Never persist that run-only overlay to normal dungeons.
|
|
if (useGameStore.getState().gameMode !== "manastorm") {
|
|
shell.saveActiveActionBindings(state.actionBindings);
|
|
}
|
|
}
|
|
|
|
if (state.inventory !== previousState.inventory) {
|
|
shell.saveActiveInventory(state.inventory);
|
|
}
|
|
if (state.bossCards !== previousState.bossCards) {
|
|
shell.saveAccountBossCards(state.bossCards);
|
|
}
|
|
if (state.equipment !== previousState.equipment) {
|
|
shell.saveActiveEquipment(state.equipment);
|
|
}
|
|
if (state.settings !== previousState.settings) {
|
|
shell.saveActiveSettings(state.settings);
|
|
}
|
|
});
|
|
return unsubscribe;
|
|
}, [character?.id]);
|
|
|
|
useEffect(() => {
|
|
let previous = performance.now();
|
|
let timer: number | null = null;
|
|
|
|
const advance = () => {
|
|
const now = performance.now();
|
|
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
|
previous = now;
|
|
const game = useGameStore.getState();
|
|
const sharedOnline = onlineSessionIsActive();
|
|
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
|
const epochNow = Date.now();
|
|
const combat = useCombatStore.getState();
|
|
combat.setPlayerPosition(game.playerPosition);
|
|
combat.tick(delta, epochNow);
|
|
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
|
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
|
}
|
|
if (onlineSessionOwnsWorldSimulation()) {
|
|
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
|
advanceDungeonPartyCombat(epochNow);
|
|
}
|
|
useCombatStore.getState().synchronizeActors();
|
|
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
|
const mobs = useCombatStore.getState().mobs;
|
|
const combatEvents = deriveDungeonEncounterCombatEvents(
|
|
encounterMobCursor.current,
|
|
mobs,
|
|
epochNow,
|
|
);
|
|
encounterMobCursor.current = combatEvents.cursor;
|
|
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
|
|
const registeredEntityIds = Object.keys(mobs);
|
|
advanceDungeonEncounters(game.activeDungeonId, {
|
|
kind: "tick",
|
|
now: epochNow,
|
|
facts: {
|
|
registeredEntityIds,
|
|
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const reconcileTimer = () => {
|
|
const game = useGameStore.getState();
|
|
const shouldRun = combatSimulationTimerShouldRun({
|
|
sharedOnline: onlineSessionIsActive(),
|
|
paused: game.paused,
|
|
companionOpen: game.companionOpen,
|
|
documentHidden: document.hidden,
|
|
});
|
|
if (shouldRun && timer === null) {
|
|
// Reset the clock on resume so a long pause/background period cannot
|
|
// leak into the first simulation delta.
|
|
previous = performance.now();
|
|
timer = window.setInterval(advance, 100);
|
|
} else if (!shouldRun && timer !== null) {
|
|
window.clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
};
|
|
|
|
const unsubscribeGame = useGameStore.subscribe((state, previousState) => {
|
|
if (
|
|
state.paused !== previousState.paused
|
|
|| state.companionOpen !== previousState.companionOpen
|
|
) {
|
|
reconcileTimer();
|
|
}
|
|
});
|
|
// Online runtime configuration is intentionally module-local; the session
|
|
// store notification gives the bridge a reactive point at which to
|
|
// reconcile while preserving continuous shared-online simulation.
|
|
const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer);
|
|
document.addEventListener("visibilitychange", reconcileTimer);
|
|
reconcileTimer();
|
|
|
|
return () => {
|
|
if (timer !== null) window.clearInterval(timer);
|
|
unsubscribeGame();
|
|
unsubscribeOnlineSession();
|
|
document.removeEventListener("visibilitychange", reconcileTimer);
|
|
};
|
|
}, []);
|
|
|
|
return null;
|
|
}
|