import { useInputBindingLabels } from "../input/useInputBindingLabels"; 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 { 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 { TimedEffectStrip } from "./TimedEffectStrip"; import { AuraStrip } from "./AuraStrip"; import { ManastormStatus } from "./ManastormStatus"; import { ThreatMeterWidget } from "./ThreatMeterWidget"; import { BossLootWindow } from "./BossLootWindow"; import { EditableHudElement } from "./EditableHudElement"; 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 (

{stagePresentation.title} · Map {stagePresentation.mapId}

Healer Man

{statusText} - {value}%

); } export function DefeatedPrompt({ gameMode, resurrectionCharges, onReturn, sharedOnline = false, }: { gameMode: "dungeon" | "manastorm"; resurrectionCharges: number; onReturn: () => void; sharedOnline?: boolean; }) { 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 ? ( ) : 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 ? ( Respawning resets the current encounter and restores the party. ) : null}
); } export function DungeonCompletePrompt({ dungeonTitle, onFinish, }: { readonly dungeonTitle: string; readonly onFinish: () => void; }) { return (

{dungeonTitle} complete Every required encounter is defeated. Loot the room or finish the run.

); } export function Hud() { 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 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 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 dungeonComplete = gameMode === "dungeon" && 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, }; 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 = () => { if (gameMode === "manastorm") { resurrectManastormSession({ kind: "player" }); return; } respawnDefeatedPlayer(); }; const finishDungeon = () => { useShellStore.getState().saveActiveDungeonRunCheckpoint(null); useShellStore.getState().returnToMainMenu(); }; return (
{dungeonComplete && health > 0 ? ( ) : null} {feedback?.kind === "loot" ? (

Loot acquired{feedback.message}Press I to inspect and equip

) : null}
{pendingGroundTarget ? (
Place ground effect Confirm: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-confirm")} · Cancel: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-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 ")}.`}
)}
{health <= 0 ? ( ) : null}
); }