Files
healer-man/src/ui/PartyFrames.tsx
T
2026-08-30 14:16:56 -04:00

307 lines
13 KiB
TypeScript

import 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 } from "../game/onlineSessionRuntime";
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 (
<div className={`party-command-bar ${className}`.trim()} role="group" aria-label="Party orders">
{PARTY_COMMANDS.map(({ command, label, keybind, description }) => {
const controlId = `party-command-${command}`;
return (
<button
key={command}
type="button"
className={`${activeCommand === command ? "is-active" : ""} ${selectedControlId === controlId ? "is-controller-selected" : ""}`.trim()}
aria-pressed={activeCommand === command}
aria-label={`${label} party order. ${description}`}
title={description}
onPointerEnter={() => onControlSelect?.(controlId)}
onFocus={() => onControlSelect?.(controlId)}
onClick={() => {
onControlSelect?.(controlId);
setCommand(command);
}}
>
<strong>{label}</strong>
<kbd>{keybind}</kbd>
</button>
);
})}
<button
type="button"
className={selectedControlId === "party-command-recall" ? "is-controller-selected" : ""}
aria-label="Recall party. Teleport every companion to your position without changing the current party order."
title="Teleport every companion to you without changing the current order."
onPointerEnter={() => onControlSelect?.("party-command-recall")}
onFocus={() => onControlSelect?.("party-command-recall")}
onClick={() => {
onControlSelect?.("party-command-recall");
recallParty();
}}
>
<strong>Recall</strong>
<kbd>F4</kbd>
</button>
</div>
);
}
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 && aura.definition.disposition === "debuff" && aura.definition.dispelCategory
? total + 1
: total
), 0));
const dispellableNames = useCombatStore((state) => state.auras
.filter((aura) => aura.targetId === member.id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory)
.map((aura) => aura.definition.name)
.join(", "));
const resourcePercent = healthPercent(member.resource, member.maxResource);
return (
<button
type="button"
className={`party-frame ${selected ? "is-selected" : ""} ${member.health <= 0 ? "is-down" : ""}`}
onClick={onSelect}
aria-pressed={selected}
aria-label={`Target ${member.name}, ${member.gender} ${race.name}, ${member.specialization} ${characterClass.name}`}
>
<span className="party-frame__sigil" style={{ "--party-class-color": characterClass.color } as CSSProperties}>{characterClass.sigil}</span>
<span className="party-frame__body">
<span className="party-frame__identity"><strong>{member.name}</strong><small>{PARTY_ROLE_LABELS[member.role]} · {race.name} · {member.specialization}</small></span>
{dispellableCount ? <span className="party-frame__dispel" title={dispellableNames}>DISPEL {dispellableCount}</span> : null}
<span className="party-frame__health" role="progressbar" aria-label={`${member.name} health`} aria-valuemin={0} aria-valuemax={member.maxHealth} aria-valuenow={Math.max(0, member.health)}>
<i style={{ width: `${percent}%` }} />
{absorb > 0 ? <em style={{ left: `${percent}%`, width: `${absorbPercent}%` }} /> : null}
<b>{Math.ceil(member.health)} / {member.maxHealth}</b>
</span>
<span className={`party-frame__resource party-frame__resource--${member.resourceType}`} title={`${Math.floor(member.resource)} / ${member.maxResource} ${member.resourceName}`}>
<i style={{ width: `${resourcePercent}%` }} />
</span>
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
<AuraStrip targetId={member.id} className="aura-strip--party" hideLongTerm />
</span>
</button>
);
}
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<typeof classById>[0]);
const percent = player ? healthPercent(player.health, player.maxHealth) : 0;
return (
<button
type="button"
className={`party-frame party-frame--online ${selected ? "is-selected" : ""} ${player && player.health <= 0 ? "is-down" : ""}`}
aria-label={`Target ${character.name}, online player`}
aria-pressed={selected}
disabled={!player}
onClick={onSelect}
>
<span className="party-frame__sigil" style={{ "--party-class-color": characterClass.color } as CSSProperties}>{characterClass.sigil}</span>
<span className="party-frame__body">
<span className="party-frame__identity"><strong>{character.name}</strong><small>{member.role ? PARTY_ROLE_LABELS[member.role] : "Player"} · {member.username}</small></span>
{player ? (
<>
<span className="party-frame__health" role="progressbar" aria-label={`${character.name} health`} aria-valuemin={0} aria-valuemax={player.maxHealth} aria-valuenow={Math.max(0, player.health)}>
<i style={{ width: `${percent}%` }} />
<b>{Math.ceil(player.health)} / {player.maxHealth}</b>
</span>
<span className="party-frame__resource" title={`${Math.floor(player.resource)} / ${player.maxResource} ${player.resourceName}`}>
<i style={{ width: `${healthPercent(player.resource, player.maxResource)}%` }} />
</span>
<span className="party-frame__online-status"><i /> Live</span>
</>
) : <span className="party-frame__online-status">Connecting…</span>}
</span>
</button>
);
}
/** 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 members = usePartyStore((state) => state.members);
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]),
);
if (!members.length && !onlineMembers.length) return null;
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 (
<section className="party-frames" aria-label={`${groupSize} player party`}>
<header><span>Party</span><b className={routeBlocked ? "is-warning" : ""}>{routeBlocked ? "Route blocked" : `${groupSize} / ${groupSize}`}</b></header>
{members.length ? <PartyCommandBar /> : null}
<button
type="button"
className={`party-frame party-frame--player ${selfSelected ? "is-selected" : ""} ${playerHealth <= 0 ? "is-down" : ""}`}
onClick={selectSelf}
aria-pressed={selfSelected}
aria-label={`Target ${character?.name ?? "Player"}, you`}
>
<span className="party-frame__sigil">YOU</span>
<span className="party-frame__body">
<span className="party-frame__identity"><strong>{character?.name ?? "Adventurer"}</strong><small>You · {PARTY_ROLE_LABELS[playerRole]}</small></span>
<span className="party-frame__health" role="progressbar" aria-label="Player health" aria-valuemin={0} aria-valuemax={playerMaxHealth} aria-valuenow={Math.max(0, playerHealth)}>
<i style={{ width: `${playerPercent}%` }} />
{playerAbsorb > 0 ? <em style={{ left: `${playerPercent}%`, width: `${playerAbsorbPercent}%` }} /> : null}
<b>{Math.ceil(playerHealth)} / {playerMaxHealth}</b>
</span>
<span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${playerMaxResource} ${playerResourceName}`}>
<i style={{ width: `${healthPercent(playerResource, playerMaxResource)}%` }} />
</span>
<TimedEffectStrip
targetId={null}
targetIds={[null, PLAYER_AGGRO_ID]}
className="timed-effect-strip--party"
/>
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" hideLongTerm />
</span>
</button>
{onlineMembers.map((member) => {
const actorId = onlinePlayerActorId(member.accountId);
return (
<OnlineMemberFrame
key={member.accountId}
member={member}
player={onlinePlayersByAccount.get(member.accountId) ?? null}
selected={selectedOnlineActorId === actorId}
onSelect={() => selectOnlineMember(member.accountId)}
/>
);
})}
{members.map((member) => (
<MemberFrame
key={member.id}
member={member}
selected={member.id === selectedMemberId}
onSelect={() => selectMember(member.id)}
/>
))}
</section>
);
}