448 lines
20 KiB
TypeScript
448 lines
20 KiB
TypeScript
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 (
|
|
<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 HealPopups({ popups, targetId }: {
|
|
popups: readonly PartyHealPopup[];
|
|
targetId: string;
|
|
}) {
|
|
const targetPopups = popups.filter((popup) => popup.targetActorId === targetId);
|
|
if (targetPopups.length === 0) return null;
|
|
return (
|
|
<span className="party-frame__heal-popups" aria-hidden="true">
|
|
{targetPopups.map((popup) => (
|
|
<span
|
|
key={popup.key}
|
|
className={`party-frame__heal-popup ${popup.critical ? "is-critical" : ""}`}
|
|
style={{ "--party-heal-x": `${popup.horizontalOffset}px` } as CSSProperties}
|
|
>+{popup.amount}</span>
|
|
))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function usePartyHealPopups(enabled: boolean): readonly PartyHealPopup[] {
|
|
const [popups, setPopups] = useState<PartyHealPopup[]>([]);
|
|
const timers = useRef(new Map<string, number>());
|
|
|
|
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 (
|
|
<button
|
|
type="button"
|
|
className={`party-frame ${selected ? "is-selected" : ""} ${dispellableCount ? "is-dispellable" : ""} ${member.health <= 0 ? "is-down" : ""}`}
|
|
onClick={onSelect}
|
|
aria-pressed={selected}
|
|
aria-label={isRom ? `Target ${member.name}, ${classLabel}` : `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 style={isRom ? { textTransform: "none" } : undefined}>{isRom ? classLabel : `${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
|
|
highlightDispellableDebuffs
|
|
/>
|
|
</span>
|
|
<HealPopups popups={healPopups} targetId={member.id} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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<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>
|
|
<HealPopups popups={healPopups} targetId={onlinePlayerActorId(member.accountId)} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<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" : ""} ${playerDispellableCount ? "is-dispellable" : ""} ${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>
|
|
{playerDispellableCount ? <span className="party-frame__dispel" title={playerDispellableNames}>DISPEL {playerDispellableCount}</span> : null}
|
|
<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
|
|
highlightDispellableDebuffs
|
|
/>
|
|
</span>
|
|
<HealPopups popups={healPopups} targetId={PLAYER_AGGRO_ID} />
|
|
</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)}
|
|
healPopups={healPopups}
|
|
/>
|
|
);
|
|
})}
|
|
{members.map((member) => (
|
|
<MemberFrame
|
|
key={member.id}
|
|
member={member}
|
|
selected={member.id === selectedMemberId}
|
|
onSelect={() => selectMember(member.id)}
|
|
healPopups={healPopups}
|
|
/>
|
|
))}
|
|
{gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
|
|
<div className="party-resurrection">
|
|
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint}
|
|
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()} />
|
|
<button
|
|
type="button"
|
|
className="party-rez-button"
|
|
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()}
|
|
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
|
|
? "Only the party's simulation host can resurrect NPC teammates."
|
|
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
|
|
onClick={() => (onResurrect ?? resurrectDeadPartyMembers)(checkpointId)}
|
|
>
|
|
<strong>Rez dead members</strong>
|
|
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
|
|
? "Simulation host only" : "Then return to you"}</small>
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|