import { useCallback, useEffect, useMemo, useState, 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 { useCombatStore } from "../game/combatStore"; import { requireDungeonDefinition } from "../game/dungeonRegistry"; 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 { PlayerFrame } from "./PlayerFrame"; import { PartyFrames } from "./PartyFrames"; import { TimedEffectStrip } from "./TimedEffectStrip"; import { AuraStrip } from "./AuraStrip"; import { ManastormStatus } from "./ManastormStatus"; import { ThreatMeterWidget } from "./ThreatMeterWidget"; import { BossLootWindow } from "./BossLootWindow"; 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 Hud() { 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 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 shield = useCombatStore((state) => state.shield); const resource = useCombatStore((state) => state.resource); const maxResource = useCombatStore((state) => state.maxResource); const resourcePools = useCombatStore((state) => state.resourcePools); const resourceName = useCombatStore((state) => state.resourceName); const abilities = useCombatStore((state) => state.abilities); const talentRanks = useCombatStore((state) => state.talentRanks); 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 feedback = useCombatStore((state) => state.feedback); const target = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null); 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 [clock, setClock] = useState(() => Date.now()); 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 actionItems = useMemo(() => Object.fromEntries(ACTION_CONTROLS.map((control) => { const abilityId = actionBindings[actionBindingId(actionLayer, control)]; const catalogAbility = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null; if (!catalogAbility) return [control, null]; 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 = ability.costs?.length ? ability.costs : [ability.cost]; const cannotPay = abilityCosts.some((cost) => cost.resource === "health" ? health <= cost.amount : (resourcePools[cost.resource] ?? (cost.resource === resourceTone ? resource : 0)) < cost.amount); const displayCost = abilityCosts[0] ?? ability.cost; 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 [control, item]; })) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, classColor, clock, cooldowns, globalCooldownEndsAt, health, level, resource, resourceName, resourcePools, resourceTone, talentRanks]); 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, })), ], target.targetActorId, target.forcedTarget, clock, ) : []; const selectedFriendly = selfSelected ? { name: character?.name ?? "Adventurer", detail: `You · Level ${level}`, health, maxHealth, self: true, } : friendlyTarget ? { name: friendlyTarget.name, detail: `${raceById(friendlyTarget.raceId).name} · Level ${friendlyTarget.level}`, health: friendlyTarget.health, maxHealth: friendlyTarget.maxHealth, self: false, } : 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, }); return (
{feedback?.kind === "loot" ? (

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

) : 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 && (
{inputMode === "gamepad" ? "Left stick move | L1 / L2 layers | Face / R1 / R2 / D-pad abilities | R3 target | Start pause" : cameraLookActive ? "Release right mouse for cursor | WASD move | Arrow keys look | 1-8 abilities | Shift / Alt layers" : "Right mouse / Arrow keys look | Click party frames to target | WASD move | Tab enemy"}
)}
{cameraLookActive &&
); }