import { create } from "zustand"; import { classById, CLASSES } from "../app/characterCatalog"; import { useShellStore } from "../app/shellStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore"; import { PLAYER_AGGRO_ID } from "./aggro"; import { useCombatStore } from "./combatStore"; import { subscribeCombatEvents } from "./combatEvents"; import { advanceDpsMeter, dpsMeterSnapshot, emptyDpsMeter, recordDpsDamage, type DpsMeterActor } from "./dpsMeter"; import { onlinePlayerActorId } from "./onlineSessionRuntime"; import { usePartyStore } from "./partyStore"; import { useGameStore } from "./store"; export const useDpsMeterStore = create(() => emptyDpsMeter()); function meterActors(): DpsMeterActor[] { const combat = useCombatStore.getState(); const online = useOnlineSessionStore.getState().snapshot; return [ { id: PLAYER_AGGRO_ID, name: useShellStore.getState().activeCharacter?.name ?? "You", color: classById(combat.classId).color }, ...usePartyStore.getState().members.map((member) => ({ id: member.id, name: member.name, color: classById(member.classId).color })), ...(online?.players ?? []).filter((player) => player.accountId !== online?.localAccountId).map((player) => ({ id: onlinePlayerActorId(player.accountId), name: player.character?.name ?? player.username, color: CLASSES.find((entry) => entry.id === player.character?.classId)?.color ?? "#d5c28b", })), ]; } export function dpsSourceActor(sourceId: string): DpsMeterActor | null { const combat = useCombatStore.getState(); const localAccount = useOnlineSessionStore.getState().snapshot?.localAccountId; const visited = new Set(); let id = sourceId; while (!visited.has(id)) { visited.add(id); if (localAccount && id === onlinePlayerActorId(localAccount)) id = PLAYER_AGGRO_ID; const known = meterActors().find((actor) => actor.id === id); if (known) return known; const ownerId = combat.actors[id]?.ownerId ?? combat.summons.find((summon) => summon.id === id)?.ownerActorId; if (!ownerId) return null; id = ownerId; } return null; } export function currentDpsMeterSnapshot() { return dpsMeterSnapshot(useDpsMeterStore.getState(), meterActors()); } /** Owned by the gameplay runtime so hiding or moving the meter never loses damage. */ export function connectDpsMeter(): () => void { useDpsMeterStore.setState(emptyDpsMeter(), true); let suspendedPreviously = false; const sample = () => { const combat = useCombatStore.getState(); const game = useGameStore.getState(); const inCombat = Object.values(combat.mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned); const sharedOnline = Boolean(useOnlineSessionStore.getState().snapshot); const suspended = !sharedOnline && (game.paused || game.companionOpen || (typeof document !== "undefined" && document.hidden)); const state = useDpsMeterStore.getState(); const next = advanceDpsMeter(state, Date.now(), inCombat, suspendedPreviously); suspendedPreviously = suspended; if (next !== state) useDpsMeterStore.setState(next, true); }; const unsubscribeEvents = subscribeCombatEvents((event) => { if (event.kind !== "damage" || !useCombatStore.getState().mobs[event.targetActorId]) return; const actor = dpsSourceActor(event.sourceActorId); if (actor) useDpsMeterStore.setState(recordDpsDamage(useDpsMeterStore.getState(), event, actor), true); }); const unsubscribeCombat = useCombatStore.subscribe((state, previous) => { if (state.combatEvents !== previous.combatEvents && state.combatEvents.length === 0) { useDpsMeterStore.setState(emptyDpsMeter(), true); } else sample(); }); const unsubscribeGame = useGameStore.subscribe(sample); if (typeof document !== "undefined") document.addEventListener("visibilitychange", sample); const timer = setInterval(sample, 250); sample(); return () => { clearInterval(timer); unsubscribeEvents(); unsubscribeCombat(); unsubscribeGame(); if (typeof document !== "undefined") document.removeEventListener("visibilitychange", sample); useDpsMeterStore.setState(emptyDpsMeter(), true); }; }