Files
healer-man/src/game/CombatBridge.tsx
T
2026-09-05 15:33:06 -04:00

173 lines
6.8 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 { useWailingEncounterStore } from "./wailingCavernsEncounter";
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 activeRole = useShellStore((state) => state.activeDungeonRole);
const resetRevision = useGameStore((state) => state.resetRevision);
const mountedResetRevision = useRef(resetRevision);
useEffect(() => {
if (!character) return;
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout(
useManastormStore.getState().activeSpellLoadout,
);
}
}, [activeRole, character?.id, gameMode]);
useEffect(() => {
if (mountedResetRevision.current === resetRevision) return;
mountedResetRevision.current = resetRevision;
useCombatStore.getState().resetEncounter();
useWailingEncounterStore.getState().reset();
}, [resetRevision]);
useEffect(() => {
if (!character) return;
const characterId = character.id;
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) 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.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"
&& game.activeDungeonId === "wailing-caverns"
) {
useWailingEncounterStore.getState().advance(
useCombatStore.getState().mobs,
epochNow,
);
}
}
};
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;
}