forked from phenom/healer-man
562 lines
27 KiB
TypeScript
562 lines
27 KiB
TypeScript
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
|
import { HudSurfaceContext } from "./HudSurface";
|
|
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
|
|
import { DungeonInteractionPrompt } from "./DungeonInteractionPrompt";
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
useSyncExternalStore,
|
|
type CSSProperties,
|
|
} from "react";
|
|
import { useProgress } from "@react-three/drei";
|
|
import { classById, raceById } from "../app/characterCatalog";
|
|
import { useShellStore } from "../app/shellStore";
|
|
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
|
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
|
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
|
|
import { partyFightStillActive, respawnDefeatedPlayer } from "../game/deathRespawn";
|
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
|
import { resurrectManastormSession } from "../game/manastormSession";
|
|
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
|
import { useManastormStore } from "../game/manastormStore";
|
|
import { usePartyStore } from "../game/partyStore";
|
|
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
|
import { threatMeterRows } from "../game/threatMeter";
|
|
import { useGameStore } from "../game/store";
|
|
import { ActionBar, type ActionBarItem, type ActionBarItems } from "./ActionBar";
|
|
import { ExperienceBar } from "./ExperienceBar";
|
|
import { Minimap } from "./Minimap";
|
|
import { PlayerStatusPanel } from "./PlayerStatusPanel";
|
|
import { EncounterTimeline } from "./EncounterTimeline";
|
|
import { PartyFrames } from "./PartyFrames";
|
|
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
|
import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
|
|
import { TimedEffectStrip } from "./TimedEffectStrip";
|
|
import { AuraStrip } from "./AuraStrip";
|
|
import { ShelvedHud } from "@shelvedHud";
|
|
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
|
import { BossLootWindow } from "./BossLootWindow";
|
|
import { EditableHudElement } from "./EditableHudElement";
|
|
import { DpsMeter } from "./DpsMeter";
|
|
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
|
import { formatDungeonDuration } from "../game/dungeonRunTypes";
|
|
import { DesktopActionBars } from "./DesktopActionBars";
|
|
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
|
import { SummonFrames } from "./SummonFrames";
|
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
|
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
|
import {
|
|
activeDungeonEncounterRevision,
|
|
activeDungeonRunIsComplete,
|
|
subscribeDungeonEncounterSnapshots,
|
|
} from "../game/dungeonEncounterRuntime";
|
|
|
|
function LoadingOverlay() {
|
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
|
const gameMode = useGameStore((state) => state.gameMode);
|
|
const encounter = useManastormStore((state) => state.currentEncounter);
|
|
const dungeon = requireDungeonDefinition(activeDungeonId);
|
|
const assetStatus = useGameStore((state) => state.assetStatus);
|
|
const { active, progress, item } = useProgress();
|
|
|
|
if (assetStatus !== "checking" && assetStatus !== "loading") return null;
|
|
const networkComplete = !active && progress >= 100;
|
|
const value = active || networkComplete ? Math.max(3, Math.round(progress)) : 3;
|
|
const statusText = active && item
|
|
? "Streaming dungeon geometry"
|
|
: networkComplete
|
|
? "Finalizing collision and lighting"
|
|
: "Reading expedition assets";
|
|
const stagePresentation = resolveStagePresentation(gameMode, encounter, {
|
|
title: dungeon.title,
|
|
mapId: dungeon.mapId,
|
|
areaName: dungeon.title,
|
|
});
|
|
return (
|
|
<div className="loading-screen" role="status" aria-live="polite">
|
|
<div className="loading-screen__mark" aria-hidden="true">HM</div>
|
|
<p className="eyebrow">{stagePresentation.title} · Map {stagePresentation.mapId}</p>
|
|
<h1>Healer Man</h1>
|
|
<div className="loading-screen__track"><span style={{ width: `${value}%` }} /></div>
|
|
<p className="loading-screen__item">{statusText} - {value}%</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DefeatedPrompt({
|
|
gameMode,
|
|
resurrectionCharges,
|
|
onReturn,
|
|
sharedOnline = false,
|
|
checkpoints = [],
|
|
partyStillFighting = false,
|
|
}: {
|
|
gameMode: "dungeon" | "manastorm";
|
|
resurrectionCharges: number;
|
|
onReturn: (checkpointId?: string) => void;
|
|
sharedOnline?: boolean;
|
|
checkpoints?: readonly ResurrectionCheckpoint[];
|
|
partyStillFighting?: boolean;
|
|
}) {
|
|
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
|
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
|
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
|
|
return (
|
|
<section
|
|
className="defeated-prompt"
|
|
role="dialog"
|
|
aria-modal="false"
|
|
aria-labelledby="defeated-prompt-title"
|
|
>
|
|
<div className="defeated-prompt__panel">
|
|
<p className="eyebrow">Defeated</p>
|
|
<h2 id="defeated-prompt-title">You can still be resurrected</h2>
|
|
<p>
|
|
Stay down and wait for a party member to resurrect you, or choose to return now.
|
|
</p>
|
|
{canReturn ? (
|
|
<>
|
|
{gameMode === "dungeon" && checkpoints.length > 0 ? (
|
|
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint} />
|
|
) : null}
|
|
<button type="button" className="button button--primary" onClick={() => onReturn(gameMode === "dungeon" ? checkpointId : undefined)}>
|
|
{gameMode === "manastorm"
|
|
? `Resurrect now · ${resurrectionCharges} remaining`
|
|
: checkpointId === "entrance" ? "Respawn at entrance" : "Respawn at checkpoint"}
|
|
</button>
|
|
</>
|
|
) : sharedOnline ? (
|
|
<small>Your shared run is still active. A living player can resurrect you.</small>
|
|
) : (
|
|
<small>No shared resurrection charges remain. A living ally must resurrect you.</small>
|
|
)}
|
|
{gameMode === "dungeon" && !sharedOnline ? (
|
|
<small>{partyStillFighting
|
|
? "Your party will keep fighting where they are. Use Recall if you want to regroup."
|
|
: "Respawning resets the current encounter and restores the party."}</small>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function DungeonCompletePrompt({
|
|
dungeonTitle,
|
|
onFinish,
|
|
onResults,
|
|
durationMs,
|
|
finalBossOnly = false,
|
|
}: {
|
|
readonly dungeonTitle: string;
|
|
readonly onFinish: () => void;
|
|
readonly onResults?: () => void;
|
|
readonly durationMs?: number | null;
|
|
readonly finalBossOnly?: boolean;
|
|
}) {
|
|
return (
|
|
<section className="dungeon-complete-banner" role="status" aria-live="polite">
|
|
<span aria-hidden="true">◆</span>
|
|
<p>
|
|
<strong>{dungeonTitle} complete</strong>
|
|
<small>{finalBossOnly ? "Final boss defeated." : "Every required encounter is defeated."} Loot the room or finish the run.</small>
|
|
{durationMs != null ? <small>Time to final boss: {formatDungeonDuration(durationMs)}</small> : null}
|
|
</p>
|
|
<button type="button" className="button button--primary" onClick={onFinish}>
|
|
Finish dungeon
|
|
</button>
|
|
{onResults ? <button type="button" className="button" onClick={onResults}>Run results</button> : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onResurrect, interaction, onInteract }: {
|
|
surface?: "top" | "bottom";
|
|
onLayoutChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
|
|
completion?: boolean;
|
|
onFinish?: () => void;
|
|
onResurrect?: (checkpointId: string) => void;
|
|
interaction?: { prompt: string; name: string; label: string } | null;
|
|
onInteract?: () => void;
|
|
} = {}) {
|
|
const bindingLabel = useInputBindingLabels();
|
|
const ui = useUiLayoutStore();
|
|
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
|
|
const character = useShellStore((state) => state.activeCharacter);
|
|
const gameMode = useGameStore((state) => state.gameMode);
|
|
const areaName = useGameStore((state) => state.areaName);
|
|
const distance = useGameStore((state) => state.distanceTravelled);
|
|
const playerPosition = useGameStore((state) => state.playerPosition);
|
|
const cameraYaw = useGameStore((state) => state.cameraYaw);
|
|
const hasMoved = useGameStore((state) => state.hasMoved);
|
|
const cameraLookActive = useGameStore((state) => state.cameraLookActive);
|
|
const inputMode = useGameStore((state) => state.inputMode);
|
|
const actionLayer = useGameStore((state) => state.actionLayer);
|
|
const assetStatus = useGameStore((state) => state.assetStatus);
|
|
const assetMessage = useGameStore((state) => state.assetMessage);
|
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
|
const resurrectionCheckpoints = useResurrectionCheckpoints();
|
|
const dungeon = requireDungeonDefinition(activeDungeonId);
|
|
const encounter = useManastormStore((state) => state.currentEncounter);
|
|
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
|
|
const toggleMap = useGameStore((state) => state.toggleMap);
|
|
const classId = useCombatStore((state) => state.classId);
|
|
const level = useCombatStore((state) => state.level);
|
|
const xp = useCombatStore((state) => state.xp);
|
|
const xpToNext = useCombatStore((state) => state.xpToNext);
|
|
const health = useCombatStore((state) => state.health);
|
|
const maxHealth = useCombatStore((state) => state.maxHealth);
|
|
const resource = useCombatStore((state) => state.resource);
|
|
const maxResource = useCombatStore((state) => state.maxResource);
|
|
const resourcePools = useCombatStore((state) => state.resourcePools);
|
|
const maxResourcePools = useCombatStore((state) => state.maxResourcePools);
|
|
const resourceName = useCombatStore((state) => state.resourceName);
|
|
const abilities = useCombatStore((state) => state.abilities);
|
|
const talentRanks = useCombatStore((state) => state.talentRanks);
|
|
const talentModifiers = useCombatStore((state) => state.talentModifiers);
|
|
const auras = useCombatStore((state) => state.auras);
|
|
const actionBindings = useCombatStore((state) => state.actionBindings);
|
|
const cooldowns = useCombatStore((state) => state.cooldowns);
|
|
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
|
|
const activeCast = useCombatStore((state) => state.activeCast);
|
|
const pendingGroundTarget = useCombatStore((state) => state.pendingGroundTarget);
|
|
const feedback = useCombatStore((state) => state.feedback);
|
|
const target = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null);
|
|
const selectedFriendlyActorId = useCombatStore((state) => state.selectedFriendlyActorId);
|
|
const friendlyTarget = usePartyStore((state) => state.selectedMemberId
|
|
? state.members.find((member) => member.id === state.selectedMemberId) ?? null
|
|
: null);
|
|
const selfSelected = usePartyStore((state) => state.selfSelected);
|
|
const partyMembers = usePartyStore((state) => state.members);
|
|
const partyStillFighting = useCombatStore((state) => partyFightStillActive(partyMembers, state.mobs, state.actors));
|
|
const playerRole = usePartyStore((state) => state.playerRole);
|
|
const settings = useCombatStore((state) => state.settings);
|
|
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
|
|
const [clock, setClock] = useState(() => Date.now());
|
|
useSyncExternalStore(
|
|
subscribeDungeonEncounterSnapshots,
|
|
activeDungeonEncounterRevision,
|
|
activeDungeonEncounterRevision,
|
|
);
|
|
const runProgress = useDungeonRunStore((state) => state.progress);
|
|
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
|
|
const dungeonComplete = completion ?? (gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)));
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => setClock(Date.now()), 100);
|
|
return () => window.clearInterval(timer);
|
|
}, []);
|
|
|
|
const updateThreatMeterPosition = useCallback((threatMeterPosition: typeof settings.threatMeterPosition) => {
|
|
useCombatStore.getState().updateSettings({ threatMeterPosition });
|
|
}, []);
|
|
|
|
const classColor = classById(classId).color;
|
|
const resourceTone = resourceProfileForClass(classId).type;
|
|
const assignedAbilities = useMemo(() => {
|
|
const used = new Set(Object.values(actionBindings));
|
|
if (desktopBars) for (const bar of (ui.draft ?? ui.preferences.current.pc).bars) for (const slot of bar.slots) used.add(slot.abilityId);
|
|
return abilities.filter((ability) => used.has(ability.id));
|
|
}, [abilities, actionBindings, desktopBars, ui.draft, ui.preferences]);
|
|
const abilityItems = useMemo<Record<string, ActionBarItem>>(() => Object.fromEntries(assignedAbilities.map((catalogAbility) => {
|
|
const learnedTalentRank = catalogAbility.talentEntryId === undefined
|
|
? undefined
|
|
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
const ability = abilityAtLevel(catalogAbility, level, learnedTalentRank);
|
|
const talentLocked = ability.talentEntryId !== undefined && !learnedTalentRank;
|
|
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
|
|
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
|
|
const remaining = Math.max(personalRemaining, globalRemaining);
|
|
const abilityCosts = resolvedAbilityCosts(ability, talentModifiers, {
|
|
classId,
|
|
maxHealth,
|
|
maxResource,
|
|
maxResourcePools,
|
|
auras,
|
|
});
|
|
const cannotPay = abilityCosts.some((cost) => cost.resource === "health"
|
|
? health <= cost.amount
|
|
: (cost.resource === resourceTone ? resource : resourcePools[cost.resource] ?? 0) < cost.amount);
|
|
const displayCost = abilityCosts[0] ?? { resource: ability.cost.resource, amount: 0 };
|
|
const item: ActionBarItem = {
|
|
id: ability.id,
|
|
name: ability.name,
|
|
shortLabel: ability.sigil,
|
|
iconUrl: ability.icon,
|
|
description: ability.description,
|
|
color: classColor,
|
|
resourceCost: displayCost.amount,
|
|
resourceLabel: displayCost.resource === resourceTone ? resourceName : displayCost.resource.replace("-", " "),
|
|
cooldownRemaining: remaining / 1_000,
|
|
cooldownDuration: (personalRemaining >= globalRemaining ? ability.cooldownMs : ability.gcdMs) / 1_000,
|
|
disabled: talentLocked || level < ability.unlockLevel || health <= 0 || remaining > 0 || Boolean(activeCast) || cannotPay,
|
|
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
|
|
target: ability.target,
|
|
cooldownSeconds: ability.cooldownMs / 1_000,
|
|
castSeconds: ability.castTimeMs / 1_000,
|
|
range: ability.range.max,
|
|
talentLocked,
|
|
};
|
|
return [catalogAbility.id, item];
|
|
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
|
|
const actionItems = Object.fromEntries(ACTION_CONTROLS.map((control) => [control,
|
|
abilityItems[actionBindings[actionBindingId(actionLayer, control)] ?? ""] ?? null,
|
|
])) as ActionBarItems;
|
|
|
|
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
|
|
useGameStore.getState().actionLayer,
|
|
control,
|
|
useGameStore.getState().playerPosition,
|
|
Date.now(),
|
|
);
|
|
const targetPercent = target ? Math.max(0, Math.min(100, target.health / target.maxHealth * 100)) : 0;
|
|
const threatRows = target ? threatMeterRows(
|
|
target.threatByActor,
|
|
[
|
|
{
|
|
id: PLAYER_AGGRO_ID,
|
|
name: character?.name ?? "You",
|
|
role: playerRole,
|
|
alive: health > 0,
|
|
},
|
|
...partyMembers.map((member) => ({
|
|
id: member.id,
|
|
name: member.name,
|
|
role: member.role,
|
|
alive: member.health > 0,
|
|
})),
|
|
...(onlineSession?.players ?? [])
|
|
.filter((player) => player.accountId !== onlineSession?.localAccountId)
|
|
.map((player) => ({
|
|
id: onlinePlayerActorId(player.accountId),
|
|
name: player.character?.name ?? player.username,
|
|
role: player.role ?? "damage",
|
|
alive: player.health > 0,
|
|
})),
|
|
],
|
|
target.targetActorId,
|
|
target.forcedTarget,
|
|
clock,
|
|
) : [];
|
|
const onlineFriendlyAccountId = selectedFriendlyActorId
|
|
? onlineAccountIdFromActor(selectedFriendlyActorId)
|
|
: null;
|
|
const onlineFriendly = onlineFriendlyAccountId
|
|
? onlineSession?.players.find((player) => player.accountId === onlineFriendlyAccountId) ?? null
|
|
: null;
|
|
const selectedFriendly = selfSelected
|
|
? {
|
|
name: character?.name ?? "Adventurer",
|
|
detail: `You · Level ${level}`,
|
|
health,
|
|
maxHealth,
|
|
self: true,
|
|
targetId: PLAYER_AGGRO_ID,
|
|
}
|
|
: friendlyTarget
|
|
? {
|
|
name: friendlyTarget.name,
|
|
detail: `${raceById(friendlyTarget.raceId).name} · Level ${friendlyTarget.level}`,
|
|
health: friendlyTarget.health,
|
|
maxHealth: friendlyTarget.maxHealth,
|
|
self: false,
|
|
targetId: friendlyTarget.id,
|
|
}
|
|
: onlineFriendly
|
|
? {
|
|
name: onlineFriendly.character?.name ?? onlineFriendly.username,
|
|
detail: `Online player · Level ${onlineFriendly.character?.level ?? 1}`,
|
|
health: onlineFriendly.health,
|
|
maxHealth: onlineFriendly.maxHealth,
|
|
self: false,
|
|
targetId: onlinePlayerActorId(onlineFriendly.accountId),
|
|
}
|
|
: null;
|
|
const friendlyTargetPercent = selectedFriendly
|
|
? Math.max(0, Math.min(100, selectedFriendly.health / selectedFriendly.maxHealth * 100))
|
|
: 0;
|
|
const castingAbility = activeCast ? abilities.find((ability) => ability.id === activeCast.abilityId) : null;
|
|
const castProgress = activeCast
|
|
? Math.max(0, Math.min(100, (clock - activeCast.startedAt) / (activeCast.completesAt - activeCast.startedAt) * 100))
|
|
: 0;
|
|
const castRemaining = activeCast ? Math.max(0, activeCast.completesAt - clock) / 1_000 : 0;
|
|
const targetCastProgress = target?.activeCast
|
|
? Math.max(0, Math.min(100, (clock - target.activeCast.startedAt)
|
|
/ Math.max(1, target.activeCast.completesAt - target.activeCast.startedAt) * 100))
|
|
: 0;
|
|
const targetCastRemaining = target?.activeCast
|
|
? Math.max(0, target.activeCast.completesAt - clock) / 1_000
|
|
: 0;
|
|
const stagePresentation = resolveStagePresentation(gameMode, encounter, {
|
|
title: dungeon.title,
|
|
mapId: dungeon.mapId,
|
|
areaName,
|
|
});
|
|
const returnFromDefeat = (checkpointId?: string) => {
|
|
if (gameMode === "manastorm") {
|
|
resurrectManastormSession({ kind: "player" });
|
|
return;
|
|
}
|
|
respawnDefeatedPlayer(checkpointId ?? "entrance");
|
|
};
|
|
const finishDungeon = () => {
|
|
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
|
|
useShellStore.getState().returnToMainMenu();
|
|
};
|
|
|
|
return (
|
|
<HudSurfaceContext.Provider value={{ surface, onChange: onLayoutChange, onResurrect }}>
|
|
<div className={`hud hud-surface-${surface}`} style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
|
<ShelvedHud />
|
|
<DpsMeter />
|
|
{surface === "top" && <BossLootWindow />}
|
|
<EditableHudElement id="interaction"><DungeonInteractionPrompt interaction={interaction} onInteract={onInteract} /></EditableHudElement>
|
|
<EditableHudElement id="completion">{dungeonComplete ? (
|
|
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={onFinish ?? finishDungeon}
|
|
finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
|
|
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
|
|
) : null}</EditableHudElement>
|
|
<EditableHudElement id="loot">{feedback?.kind === "loot" ? (
|
|
<section className="loot-reward" role="status" aria-live="polite">
|
|
<span aria-hidden="true">◆</span>
|
|
<p><strong>Loot acquired</strong><small>{feedback.message}</small><em>Press I to inspect and equip</em></p>
|
|
</section>
|
|
) : null}</EditableHudElement>
|
|
<EditableHudElement id="party"><PartyFrames /></EditableHudElement>
|
|
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
|
|
|
|
{surface === "top" && pendingGroundTarget ? (
|
|
<section className="ground-target-reticle" role="status" aria-live="polite">
|
|
<div aria-hidden="true"><i /><b /></div>
|
|
<strong>Place ground effect</strong>
|
|
<small>Confirm: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-confirm")} · Cancel: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-cancel")}</small>
|
|
<span>
|
|
<button
|
|
type="button"
|
|
disabled={!pendingGroundTarget.valid}
|
|
onClick={() => useCombatStore.getState().confirmGroundTarget()}
|
|
>Confirm</button>
|
|
<button type="button" onClick={() => useCombatStore.getState().cancelGroundTarget()}>Cancel</button>
|
|
</span>
|
|
</section>
|
|
) : null}
|
|
|
|
<div className="hud-top-right">
|
|
<EditableHudElement id="minimap"><Minimap
|
|
position={playerPosition}
|
|
yaw={cameraYaw}
|
|
areaName={stagePresentation.areaName}
|
|
distanceTravelled={distance}
|
|
rotationMode={settings.minimapRotation}
|
|
onOpenMap={toggleMap}
|
|
/></EditableHudElement>
|
|
<EditableHudElement id="player"><PlayerStatusPanel now={clock} /></EditableHudElement>
|
|
<EditableHudElement id="timeline"><EncounterTimeline now={clock} /></EditableHudElement>
|
|
</div>
|
|
|
|
<EditableHudElement id="target">{target && settings.showTargetFrame && (
|
|
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}>
|
|
<header><strong>{target.name}</strong><span>{target.boss ? "Boss" : `Level ${target.level}`}</span></header>
|
|
<div className="target-frame__track"><span style={{ width: `${targetPercent}%` }} /></div>
|
|
{target.activeCast ? (
|
|
<div
|
|
className={`target-frame__cast ${target.activeCast.interruptible ? "" : "target-frame__cast--locked"}`}
|
|
aria-label={`${target.activeCast.name}, ${targetCastRemaining.toFixed(1)} seconds remaining`}
|
|
>
|
|
<header><strong>{target.activeCast.name}</strong><span>{targetCastRemaining.toFixed(1)}s</span></header>
|
|
<div className="target-frame__cast-track"><span style={{ width: `${targetCastProgress}%` }} /></div>
|
|
</div>
|
|
) : null}
|
|
<TimedEffectStrip targetId={target.id} kinds={["dot"]} className="timed-effect-strip--target" />
|
|
<AuraStrip targetId={target.id} className="aura-strip--target" />
|
|
<small>{Math.ceil(target.health)} / {target.maxHealth}</small>
|
|
</section>
|
|
)}</EditableHudElement>
|
|
|
|
<EditableHudElement id="threat">{target && settings.showThreatMeter && threatRows.length > 0 && (
|
|
<ThreatMeterWidget
|
|
targetName={target.name}
|
|
rows={threatRows}
|
|
position={settings.threatMeterPosition}
|
|
onPositionChange={updateThreatMeterPosition}
|
|
/>
|
|
)}</EditableHudElement>
|
|
|
|
<EditableHudElement id="friendly">{selectedFriendly && settings.showTargetFrame && (
|
|
<section className="target-frame target-frame--friendly" aria-label={`Friendly target ${selectedFriendly.name}${selectedFriendly.self ? ", self" : ""}`}>
|
|
<header>
|
|
<strong>{selectedFriendly.name}</strong>
|
|
<span>{selectedFriendly.detail}</span>
|
|
</header>
|
|
<div className="target-frame__track"><span style={{ width: `${friendlyTargetPercent}%` }} /></div>
|
|
<TimedEffectStrip
|
|
targetId={selectedFriendly.self ? null : selectedFriendly.targetId}
|
|
className="timed-effect-strip--target"
|
|
/>
|
|
<AuraStrip
|
|
targetId={selectedFriendly.targetId}
|
|
className="aura-strip--target"
|
|
/>
|
|
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
|
|
</section>
|
|
)}</EditableHudElement>
|
|
|
|
<div className="hud-bottom-stack">
|
|
<EditableHudElement id="cast">{activeCast && castingAbility && (
|
|
<section className={`cast-bar cast-bar--${activeCast.mode}`} aria-label={`${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${castingAbility.name}`}>
|
|
<header><strong>{castingAbility.name}</strong><span>{castRemaining.toFixed(1)}s</span></header>
|
|
<div className="cast-bar__track">
|
|
<span style={{ width: `${activeCast.mode === "channel" ? 100 - castProgress : castProgress}%` }} />
|
|
</div>
|
|
<small>{activeCast.mode === "channel" ? "Channeling" : "Casting"}</small>
|
|
</section>
|
|
)}</EditableHudElement>
|
|
|
|
<EditableHudElement id="hints">{!activeCast && (
|
|
<div className={`control-hint ${hasMoved && cameraLookActive ? "control-hint--settled" : ""}`}>
|
|
{cameraLookActive ? "Release right mouse for cursor · " : "Hold right mouse to look · "}
|
|
Forward: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "move-forward")} · Target: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "target-next")} · Pause: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "pause")}
|
|
</div>
|
|
)}</EditableHudElement>
|
|
|
|
{!desktopBars && <EditableHudElement id="actions"><ActionBar
|
|
items={actionItems}
|
|
activeLayer={actionLayer}
|
|
onActivate={castBinding}
|
|
inputMode={surface === "bottom" ? "gamepad" : inputMode}
|
|
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
|
|
/></EditableHudElement>}
|
|
</div>
|
|
<DesktopActionBars items={abilityItems} />
|
|
<EditableHudElement id="feedback">{desktopBars && settings.showFloatingCombatText && feedback?.message
|
|
? <div className="desktop-action-feedback" role="status">{feedback.message}</div> : null}</EditableHudElement>
|
|
<EditableHudElement id="experience"><ExperienceBar level={level} current={xp} required={xpToNext} /></EditableHudElement>
|
|
|
|
<EditableHudElement id="reticle">{cameraLookActive && <span className="reticle" aria-hidden="true" />}</EditableHudElement>
|
|
|
|
<EditableHudElement id="assets">{assetStatus === "proxy" && (
|
|
<div className="asset-notice" role="status">
|
|
<strong>Cave proxy active</strong>
|
|
<span>{assetMessage ?? `Add ${[
|
|
...dungeon.assets.visual,
|
|
...dungeon.assets.collision,
|
|
].map((asset) => asset.fileName).join(" and ")}.`}</span>
|
|
</div>
|
|
)}</EditableHudElement>
|
|
{surface === "top" && <LoadingOverlay />}
|
|
{surface === "top" && health <= 0 ? (
|
|
<DefeatedPrompt
|
|
gameMode={gameMode}
|
|
resurrectionCharges={resurrectionCharges}
|
|
onReturn={returnFromDefeat}
|
|
checkpoints={resurrectionCheckpoints}
|
|
partyStillFighting={partyStillFighting}
|
|
sharedOnline={onlineSession !== null}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</HudSurfaceContext.Provider>
|
|
);
|
|
}
|