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 (
{targetPopups.map((popup) => (
+{popup.amount}
))}
);
}
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}
);
}