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 (
HM
{stagePresentation.title} · Map {stagePresentation.mapId}
Healer Man
{statusText} - {value}%
);
}
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 (
Defeated
You can still be resurrected
Stay down and wait for a party member to resurrect you, or choose to return now.
{canReturn ? (
<>
{gameMode === "dungeon" && checkpoints.length > 0 ? (
) : null}
onReturn(gameMode === "dungeon" ? checkpointId : undefined)}>
{gameMode === "manastorm"
? `Resurrect now · ${resurrectionCharges} remaining`
: checkpointId === "entrance" ? "Respawn at entrance" : "Respawn at checkpoint"}
>
) : sharedOnline ? (
Your shared run is still active. A living player can resurrect you.
) : (
No shared resurrection charges remain. A living ally must resurrect you.
)}
{gameMode === "dungeon" && !sharedOnline ? (
{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."}
) : null}
);
}
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 (
◆
{dungeonTitle} complete
{finalBossOnly ? "Final boss defeated." : "Every required encounter is defeated."} Loot the room or finish the run.
{durationMs != null ? Time to final boss: {formatDungeonDuration(durationMs)} : null}
Finish dungeon
{onResults ? Run results : null}
);
}
export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onResurrect, interaction, onInteract }: {
surface?: "top" | "bottom";
onLayoutChange?: (id: HudElementId, update: Partial) => 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>(() => 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 (
{surface === "top" &&
}
{dungeonComplete ? (
useGameStore.getState().openOverlay("dungeon-results") : undefined} />
) : null}
{feedback?.kind === "loot" ? (
◆
Loot acquired {feedback.message} Press I to inspect and equip
) : null}
{surface === "top" && pendingGroundTarget ? (
Place ground effect
Confirm: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-confirm")} · Cancel: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-cancel")}
useCombatStore.getState().confirmGroundTarget()}
>Confirm
useCombatStore.getState().cancelGroundTarget()}>Cancel
) : null}
{target && settings.showTargetFrame && (
{target.name} {target.boss ? "Boss" : `Level ${target.level}`}
{target.activeCast ? (
{target.activeCast.name} {targetCastRemaining.toFixed(1)}s
) : null}
{Math.ceil(target.health)} / {target.maxHealth}
)}
{target && settings.showThreatMeter && threatRows.length > 0 && (
)}
{selectedFriendly && settings.showTargetFrame && (
{selectedFriendly.name}
{selectedFriendly.detail}
{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}
)}
{activeCast && castingAbility && (
{castingAbility.name} {castRemaining.toFixed(1)}s
{activeCast.mode === "channel" ? "Channeling" : "Casting"}
)}
{!activeCast && (
{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")}
)}
{!desktopBars &&
}
{desktopBars && settings.showFloatingCombatText && feedback?.message
? {feedback.message}
: null}
{cameraLookActive && }
{assetStatus === "proxy" && (
Cave proxy active
{assetMessage ?? `Add ${[
...dungeon.assets.visual,
...dungeon.assets.collision,
].map((asset) => asset.fileName).join(" and ")}.`}
)}
{surface === "top" &&
}
{surface === "top" && health <= 0 ? (
) : null}
);
}