import { useState, type CSSProperties } from "react"; import { classById, 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"; 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 MemberFrame({ member, selected, onSelect }: { member: PartyMember; selected: boolean; onSelect: () => void; }) { const characterClass = classById(member.classId); const race = raceById(member.raceId); 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, }: { member: OnlineGroupMember; player: OnlineSessionPlayer | null; selected: boolean; onSelect: () => void; }) { 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 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 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)} /> ); })} {members.map((member) => ( selectMember(member.id)} /> ))} {gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
) : null}
); }