import { useEffect, useRef, useState, type CSSProperties } from "react"; import { classById, isRomClassId, raceById } from "../app/characterCatalog"; import { useShellStore } from "../app/shellStore"; import { useCombatStore } from "../game/combatStore"; import { PARTY_ROLE_LABELS, usePartyStore, type PartyCommand, type PartyMember, } from "../game/partyStore"; import { TimedEffectStrip } from "./TimedEffectStrip"; import { AuraStrip } from "./AuraStrip"; import { PLAYER_AGGRO_ID } from "../game/aggro"; import { useOnlineGroupStore } from "../app/onlineGroupStore"; import type { OnlineGroupMember } from "../app/onlineGroupTypes"; import { useOnlineSessionStore } from "../app/onlineSessionStore"; import type { OnlineSessionPlayer } from "../app/onlineSessionTypes"; import { onlinePlayerActorId, onlineSessionOwnsWorldSimulation } from "../game/onlineSessionRuntime"; import { auraDefinitionIsDispellableDebuff } from "./auraDisplay"; import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection"; import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination"; import { useGameStore } from "../game/store"; import { useHudSurface } from "./HudSurface"; import { PARTY_HEAL_POPUP_LIFETIME_MS, addedPartyHealPopups, appendPartyHealPopups, type PartyHealPopup, } from "./partyHealPopups"; const PARTY_COMMANDS: readonly { command: PartyCommand; label: string; keybind: string; description: string; }[] = [ { command: "attack", label: "Attack", keybind: "F1", description: "Advance toward the next boss and defeat every enemy along the route.", }, { command: "defend", label: "Defend", keybind: "F2", description: "Stay with you and fight only enemies that engage the group.", }, { command: "stop", label: "Stop", keybind: "F3", description: "Hold the party at its current position until another order is given.", }, ] as const; export function PartyCommandBar({ selectedControlId, onControlSelect, className = "", }: { selectedControlId?: string; onControlSelect?: (id: string) => void; className?: string; }) { const activeCommand = usePartyStore((state) => state.command); const setCommand = usePartyStore((state) => state.setCommand); const recallParty = usePartyStore((state) => state.recallParty); return (
{PARTY_COMMANDS.map(({ command, label, keybind, description }) => { const controlId = `party-command-${command}`; return ( ); })}
); } function healthPercent(current: number, maximum: number): number { if (maximum <= 0) return 0; return Math.max(0, Math.min(100, current / maximum * 100)); } function HealPopups({ popups, targetId }: { popups: readonly PartyHealPopup[]; targetId: string; }) { const targetPopups = popups.filter((popup) => popup.targetActorId === targetId); if (targetPopups.length === 0) return null; return ( ); } function usePartyHealPopups(enabled: boolean): readonly PartyHealPopup[] { const [popups, setPopups] = useState([]); const timers = useRef(new Map()); useEffect(() => { const clearPopups = () => { for (const timer of timers.current.values()) window.clearTimeout(timer); timers.current.clear(); setPopups([]); }; if (!enabled) { clearPopups(); return undefined; } const unsubscribe = useCombatStore.subscribe((state, previous) => { const inCombat = Object.values(state.mobs).some((mob) => ( mob.engaged && !mob.dead && !mob.despawned )); const wasInCombat = Object.values(previous.mobs).some((mob) => ( mob.engaged && !mob.dead && !mob.despawned )); if (!inCombat) { if (wasInCombat) clearPopups(); return; } const visibleTargetIds = new Set([ PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id), ...(useOnlineSessionStore.getState().snapshot?.players ?? []).map((player) => ( onlinePlayerActorId(player.accountId) )), ]); const added = addedPartyHealPopups(previous.combatEvents, state.combatEvents, true) .filter((popup) => visibleTargetIds.has(popup.targetActorId)); if (added.length === 0) return; setPopups((current) => appendPartyHealPopups(current, added)); for (const popup of added) { const existing = timers.current.get(popup.key); if (existing !== undefined) window.clearTimeout(existing); timers.current.set(popup.key, window.setTimeout(() => { timers.current.delete(popup.key); setPopups((current) => current.filter((candidate) => candidate.key !== popup.key)); }, PARTY_HEAL_POPUP_LIFETIME_MS)); } }); return () => { unsubscribe(); for (const timer of timers.current.values()) window.clearTimeout(timer); timers.current.clear(); }; }, [enabled]); return popups; } function MemberFrame({ member, selected, onSelect, healPopups }: { member: PartyMember; selected: boolean; onSelect: () => void; healPopups: readonly PartyHealPopup[]; }) { const characterClass = classById(member.classId); const race = raceById(member.raceId); const isRom = isRomClassId(member.classId); const classLabel = `${characterClass.name}${member.secondaryClassId ? `/${classById(member.secondaryClassId).name}` : ""}`; const percent = healthPercent(member.health, member.maxHealth); const absorb = useCombatStore((state) => state.auras.reduce((total, aura) => ( aura.targetId === member.id ? total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0) : total ), 0)); const absorbPercent = Math.min(100 - percent, healthPercent(absorb, member.maxHealth)); const dispellableCount = useCombatStore((state) => state.auras.reduce((total, aura) => ( aura.targetId === member.id && auraDefinitionIsDispellableDebuff(aura.definition) ? total + 1 : total ), 0)); const dispellableNames = useCombatStore((state) => state.auras .filter((aura) => aura.targetId === member.id && auraDefinitionIsDispellableDebuff(aura.definition)) .map((aura) => aura.definition.name) .join(", ")); const resourcePercent = healthPercent(member.resource, member.maxResource); return ( ); } function OnlineMemberFrame({ member, player, selected, onSelect, healPopups, }: { member: OnlineGroupMember; player: OnlineSessionPlayer | null; selected: boolean; onSelect: () => void; healPopups: readonly PartyHealPopup[]; }) { const character = member.character; if (!character) return null; const characterClass = classById(character.classId as Parameters[0]); const percent = player ? healthPercent(player.health, player.maxHealth) : 0; return ( ); } /** Compact roster for the player plus the selected number of targetable AI allies. */ export function PartyFrames() { const { onResurrect } = useHudSurface(); const character = useShellStore((state) => state.activeCharacter); const playerRole = useShellStore((state) => state.activeDungeonRole); const playerHealth = useCombatStore((state) => state.health); const playerMaxHealth = useCombatStore((state) => state.maxHealth); const playerResource = useCombatStore((state) => state.resource); const playerMaxResource = useCombatStore((state) => state.maxResource); const playerResourceName = useCombatStore((state) => state.resourceName); const showHealPopups = useCombatStore((state) => state.settings.showFloatingCombatText); const healPopups = usePartyHealPopups(showHealPopups); const playerAuraAbsorb = useCombatStore((state) => state.auras.reduce((total, aura) => ( aura.targetId === PLAYER_AGGRO_ID ? total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0) : total ), 0)); const playerShield = useCombatStore((state) => state.shield); const playerDispellableCount = useCombatStore((state) => state.auras.reduce((total, aura) => ( aura.targetId === PLAYER_AGGRO_ID && auraDefinitionIsDispellableDebuff(aura.definition) ? total + 1 : total ), 0)); const playerDispellableNames = useCombatStore((state) => state.auras .filter((aura) => aura.targetId === PLAYER_AGGRO_ID && auraDefinitionIsDispellableDebuff(aura.definition)) .map((aura) => aura.definition.name) .join(", ")); const members = usePartyStore((state) => state.members); const gameMode = useGameStore((state) => state.gameMode); const bossCombat = useCombatStore((state) => partyResurrectionBlockedByBoss(state.mobs)); const checkpoints = useResurrectionCheckpoints(); const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance"); const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance"; const selectedMemberId = usePartyStore((state) => state.selectedMemberId); const selfSelected = usePartyStore((state) => state.selfSelected); const routeBlocked = usePartyStore((state) => state.routeBlocked); const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null); const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group); const onlineActivity = useOnlineGroupStore((state) => state.activeActivity); const onlineSession = useOnlineSessionStore((state) => state.snapshot); const selectedOnlineActorId = useCombatStore((state) => state.selectedFriendlyActorId); const playerPercent = healthPercent(playerHealth, playerMaxHealth); const playerAbsorb = playerShield + playerAuraAbsorb; const playerAbsorbPercent = Math.min(100 - playerPercent, healthPercent(playerAbsorb, playerMaxHealth)); const onlineMembers = onlineActivity && onlineGroup ? onlineGroup.members.filter((member) => member.accountId !== sessionOwnerId && member.character) : []; const onlinePlayersByAccount = new Map( (onlineSession?.players ?? []).map((player) => [player.accountId, player]), ); const groupSize = members.length + onlineMembers.length + 1; const selectMember = (id: string) => { useCombatStore.getState().clearTarget(); usePartyStore.getState().selectMember(id); }; const selectSelf = () => { useCombatStore.getState().clearTarget(); usePartyStore.getState().selectSelf(); }; const selectOnlineMember = (accountId: string) => { useCombatStore.getState().clearTarget(); usePartyStore.getState().clearSelection(); useCombatStore.getState().selectFriendlyActor(onlinePlayerActorId(accountId)); }; return (
Party{routeBlocked ? "Route blocked" : `${groupSize} / ${groupSize}`}
{members.length ? : null} {onlineMembers.map((member) => { const actorId = onlinePlayerActorId(member.accountId); return ( selectOnlineMember(member.accountId)} healPopups={healPopups} /> ); })} {members.map((member) => ( selectMember(member.id)} healPopups={healPopups} /> ))} {gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
) : null}
); }