Files
healer-man/src/game/CombatBridge.tsx
T
2026-09-27 14:31:15 -04:00

253 lines
11 KiB
TypeScript

import { useEffect, useRef } from "react";
import { useShellStore } from "../app/shellStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useCombatStore } from "./combatStore";
import { activateCharacterUi } from "./uiLayoutStore";
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";
import { advanceDungeonRaceAffixes, resetDungeonRaceAffixEffects, resumeDungeonRaceAffixes,
suspendDungeonRaceAffixes } from "./dungeonRaceAffixRuntime";
import {
DUNGEON_SIMULATION_FIXED_STEP_MS,
useDungeonSimulationStore,
} from "./dungeonSimulationStore";
/**
* 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 });
activateCharacterUi(character);
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();
resetDungeonRaceAffixEffects();
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().gmTestMode) 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 advanceOneTick = (delta: number, epochNow: number, spectator: boolean) => {
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (
onlineSessionOwnsWorldSimulation()
&& game.gameMode === "dungeon"
&& (!spectator || simulation.mode === "full-combat")
) {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
if (onlineSessionOwnsWorldSimulation()) {
if (!spectator || simulation.mode === "full-combat") {
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) if (!event.entityId.startsWith("race-affix-"))
dispatchActiveDungeonEntityEvent(event);
advanceDungeonRaceAffixes(epochNow, combatEvents.events.filter((event) => (
event.kind === "entity-death" && !event.entityId.startsWith("race-affix-")
)).map((event) => event.entityId));
const registeredEntityIds = Object.keys(mobs).filter((id) => !id.startsWith("race-affix-"));
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
now: epochNow,
facts: {
registeredEntityIds,
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
},
});
}
};
const advance = () => {
const now = performance.now();
const wallDeltaMs = Math.min(1_000, Math.max(0, now - previous));
const delta = Math.min(0.25, wallDeltaMs / 1_000);
previous = now;
const simulation = useDungeonSimulationStore.getState();
if (simulation.sessionMode === "spectate-replay") {
simulation.consumeTicks(wallDeltaMs);
return;
}
if (simulation.sessionMode === "spectate-live") {
const assetStatus = useGameStore.getState().assetStatus;
if (assetStatus !== "ready" && assetStatus !== "proxy") return;
for (const tickNow of simulation.consumeTicks(wallDeltaMs)) {
advanceOneTick(DUNGEON_SIMULATION_FIXED_STEP_MS / 1_000, tickNow, true);
}
return;
}
const game = useGameStore.getState();
if (game.dungeonIntroActive || game.raceStartBlocked) return;
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
advanceOneTick(delta, Date.now(), false);
}
};
const reconcileTimer = () => {
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
const shouldRun = combatSimulationTimerShouldRun({
sharedOnline: onlineSessionIsActive(),
paused: simulation.sessionMode === "play" ? game.paused : false,
companionOpen: simulation.sessionMode === "play" ? game.companionOpen : false,
documentHidden: simulation.sessionMode === "play" ? document.hidden : false,
});
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();
resumeDungeonRaceAffixes();
timer = window.setInterval(advance, 100);
} else if (!shouldRun && timer !== null) {
suspendDungeonRaceAffixes();
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);
const unsubscribeSimulation = useDungeonSimulationStore.subscribe((state, previousState) => {
if (state.sessionMode !== previousState.sessionMode || state.runState !== previousState.runState) reconcileTimer();
});
document.addEventListener("visibilitychange", reconcileTimer);
reconcileTimer();
return () => {
if (timer !== null) window.clearInterval(timer);
unsubscribeGame();
unsubscribeOnlineSession();
unsubscribeSimulation();
document.removeEventListener("visibilitychange", reconcileTimer);
};
}, []);
return null;
}