import { lazy, Suspense, useState, type CSSProperties } from "react"; import { classById, FACTIONS, raceById } from "../app/characterCatalog"; import type { CharacterProfile } from "../app/types"; import { useShellStore } from "../app/shellStore"; import { useCombatStore } from "../game/combatStore"; import { partyRolesForClass, PARTY_ROLE_LABELS, usePartyStore, } from "../game/partyStore"; import { useGameStore } from "../game/store"; import type { GameOverlay } from "../game/store"; import { ACTION_CONTROLS } from "../game/actionBindings"; import { DUNGEON_CATALOG, DUNGEON_CATEGORIES, dungeonsByCategory, type DungeonCategoryId, } from "../game/dungeonCatalog"; import { currentCompanionSnapshot, executeCompanionCommand, type CompanionCommand, type CompanionSnapshot, } from "../platform/dualScreenSync"; import { CharacterPreview } from "./CharacterPreview"; import { ActionBar, type ActionBarItem, type ActionBarItems } from "./ActionBar"; import { DungeonMapGraphic } from "./DungeonMapGraphic"; import { ExperienceBar } from "./ExperienceBar"; import { BrandMark, FrontSurface } from "./FrontSurface"; import { getLoadedCompanionManastormPanel, loadCompanionManastormPanel, } from "./gameplayResources"; const CompanionManastormPanel = lazy(() => loadCompanionManastormPanel().then((module) => ({ default: module.CompanionManastormPanel, }))); function safePercent(current: number, maximum: number): number { if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0; return Math.max(0, Math.min(100, (current / maximum) * 100)); } function overlayName(overlay: GameOverlay, romSkills = false): string { if (overlay === "party") return "Party management"; if (overlay === "inventory") return "Inventory & equipment"; if (overlay === "spellbook") return "Spellbook"; if (overlay === "talents") return romSkills ? "Skills" : "Talents"; if (overlay === "bindings") return "Ability controls"; if (overlay === "options") return "Options"; if (overlay === "map") return "Dungeon map"; if (overlay === "pause") return "Pause menu"; return "Tactical view"; } function CompanionDungeonSelection({ selected, send, }: { selected: CharacterProfile | null; send: (command: CompanionCommand) => void; }) { const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? null; const [selectedCategory, setSelectedCategory] = useState(firstAvailable?.category ?? "wow"); const [selectedDungeonId, setSelectedDungeonId] = useState(firstAvailable?.id ?? ""); const categoryDungeons = dungeonsByCategory(selectedCategory); const selectedDungeon = categoryDungeons.find((dungeon) => dungeon.id === selectedDungeonId) ?? categoryDungeons.find((dungeon) => dungeon.available) ?? categoryDungeons[0]; const roles = selected ? partyRolesForClass(selected.classId) : []; const selectCategory = (category: DungeonCategoryId) => { const dungeons = dungeonsByCategory(category); const nextDungeon = dungeons.find((dungeon) => dungeon.available) ?? dungeons[0]; setSelectedCategory(category); setSelectedDungeonId(nextDungeon?.id ?? ""); }; return (
CHOOSE DUNGEON / ROLE
{DUNGEON_CATEGORIES.map((category) => ( ))}
category.id === selectedCategory)?.label} dungeons`} > {categoryDungeons.map((dungeon, index) => ( ))}
{selectedDungeon ? (

{selectedDungeon.title}

Choose your role

{roles.map((role) => ( ))}
) :

No dungeons are available yet.

}
Select a role to enter
); } export function CompanionPanel({ snapshot, send, preferControllerBinds = false }: { snapshot: CompanionSnapshot; send: (command: CompanionCommand) => void; preferControllerBinds?: boolean; }) { const selected = snapshot.characters.find((character) => character.id === snapshot.selectedCharacterId) ?? null; const active = snapshot.activeCharacter ?? selected; const PreloadedCompanionManastormPanel = getLoadedCompanionManastormPanel(); if (snapshot.phase === "login") { return (
SECONDARY DISPLAY

Ready on both screens

One roster. Two ways to play.

01Local accountSalted password verifier on this device
02Offline playNo sign-in or network required
03PC fallbackThis panel opens as an overlay
Top screen owns account entryTHOR / 620 x 540
); } if (snapshot.phase === "characters") { const race = selected ? raceById(selected.raceId) : null; const characterClass = selected ? classById(selected.classId) : null; return (
{snapshot.session?.kind === "offline" ? "OFFLINE ROSTER" : snapshot.session?.displayName}
{snapshot.characters.map((character) => ( ))} {!snapshot.characters.length &&

No characters saved yet.

}
{selected && race && characterClass ? (
Faction
{FACTIONS[race.faction].name}
Class
{characterClass.name}
Role
{characterClass.role}
Location
{selected.location}
) :

Create your first adventurer on the upper display.

}
); } if (snapshot.phase === "create-character") { const race = raceById(snapshot.draft.raceId); const characterClass = classById(snapshot.draft.classId); const labels = race.labels[snapshot.draft.gender]; return (
CHARACTER FORGE

{FACTIONS[race.faction].name} / {snapshot.draft.gender}

{race.name} {characterClass.name}

{labels.skinColor}
{snapshot.draft.appearance.skinColor + 1}/{race.counts[snapshot.draft.gender].skinColor}
{labels.face}
{snapshot.draft.appearance.face + 1}/{race.counts[snapshot.draft.gender].face}
{labels.hairStyle}
{snapshot.draft.appearance.hairStyle + 1}/{race.counts[snapshot.draft.gender].hairStyle}
{labels.feature}
{snapshot.draft.appearance.feature + 1}/{race.counts[snapshot.draft.gender].feature}
); } if (snapshot.phase === "main-menu") { const race = selected ? raceById(selected.raceId) : null; const characterClass = selected ? classById(selected.classId) : null; const modes = [ { id: "dungeons", label: "Dungeons", detail: "Choose an expedition", available: true }, { id: "manastorms", label: "Manastorms", detail: "Enter the storm", available: true }, { id: "roguelike", label: "Roguelike RPG", detail: "Coming later", available: false }, { id: "trials", label: "Trials", detail: "Coming later", available: false }, { id: "options", label: "Options", detail: "Coming later", available: false }, { id: "profile", label: "Player Profile", detail: "Coming later", available: false }, ] as const; return (
{selected ? `${selected.name.toUpperCase()} / MAIN MENU` : "MAIN MENU"}
{modes.map((mode, index) => ( ))}
{selected && race && characterClass ? (
Adventurer
{selected.name}
Level
{selected.level}
Race
{race.name}
Class
{characterClass.name}
) :

Return to the roster and select an adventurer.

}
); } if (snapshot.phase === "dungeons") { return ; } if (snapshot.phase === "manastorms") { return PreloadedCompanionManastormPanel ? ( ) : ( Loading Manastorm data…}> ); } const race = active ? raceById(active.raceId) : null; const characterClass = active ? classById(active.classId) : null; const skillMenuLabel = active?.categoryId === "rom" ? "Skills" : "Talents"; const romSkills = active?.categoryId === "rom"; const heading = Math.round((snapshot.game.cameraYaw * 180) / Math.PI); const player = snapshot.game.player; const target = snapshot.game.selectedTarget; const healthPercent = safePercent(player.health, player.maxHealth); const resourcePercent = safePercent(player.resource, player.maxResource); const targetPercent = target ? safePercent(target.health, target.maxHealth) : 0; const actionItems = Object.fromEntries(ACTION_CONTROLS.map((control) => { const ability = snapshot.game.abilities.find((candidate) => candidate.control === control) ?? null; if (!ability) return [control, null]; const item: ActionBarItem = { id: ability.id, name: ability.name, shortLabel: ability.shortLabel, iconUrl: ability.icon, description: ability.description, color: ability.color, resourceCost: ability.resourceCost, resourceLabel: ability.resourceLabel, cooldownRemaining: ability.cooldownRemaining, cooldownDuration: ability.cooldownDuration, disabled: ability.disabled, lockedUntilLevel: ability.lockedUntilLevel, target: ability.target, }; return [control, item]; })) as ActionBarItems; const actionInputMode = preferControllerBinds || snapshot.game.inputMode === "gamepad" ? "gamepad" : "keyboard"; const partyCommand = snapshot.game.party.command; const partyObjective = snapshot.game.party.objective; const partyCommandStatus = snapshot.game.party.routeBlocked ? "Route blocked - holding safely" : partyCommand === "attack" ? partyObjective ? `Objective · ${partyObjective.name}` : "All bosses defeated" : partyCommand === "defend" ? "Defending the healer" : "Holding position"; const compactPartyCommandStyle = { position: "static", display: "inline-flex", minWidth: 0, height: 14, gridTemplateColumns: "none", gridTemplateRows: "none", alignItems: "center", justifyContent: "center", gap: 0, padding: "0 5px", fontSize: 6, lineHeight: 1, textAlign: "center", } as CSSProperties; const toggleDestination = (overlay: Exclude) => send( snapshot.game.overlay === overlay ? { name: "closeOverlay" } : { name: "openOverlay", overlay }, ); return (
{race?.sigil ?? "HM"} {active?.name ?? "Adventurer"} {snapshot.game.overlay ? `${overlayName(snapshot.game.overlay, romSkills)} open upstairs` : `${characterClass?.name ?? "Explorer"} / Level ${player.level}`}
Party · 5 / 5 {partyCommandStatus}
{(["attack", "defend", "stop"] as const).map((command) => ( ))}
{snapshot.game.party.members.map((member) => { const memberClass = classById(member.classId); const memberRace = raceById(member.raceId); const percent = safePercent(member.health, member.maxHealth); return ( ); })}
Health {Math.round(player.health)} / {Math.round(player.maxHealth)}{player.shield > 0 ? ` +${Math.round(player.shield)}` : ""}
{player.resourceName} {Math.round(player.resource)} / {Math.round(player.maxResource)}
{snapshot.game.activeCast && (
{snapshot.game.activeCast.mode === "channel" ? "Channeling" : "Casting"} {snapshot.game.activeCast.name} · {snapshot.game.activeCast.remaining.toFixed(1)}s
)}
{target?.boss ? "Boss target" : "Selected target"} {target?.name ?? "No target"}
{target ? ( <>
Level {target.level} / {target.dead ? "Defeated" : `${Math.round(target.health)} of ${Math.round(target.maxHealth)} health`} ) : Choose a nearby enemy to use hostile abilities.}
{target && }
{snapshot.game.overlay ? "Upper display" : "Current area"} {snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName} {Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees
send({ name: "castActionBinding", layer: snapshot.game.actionLayer, control })} feedback={snapshot.game.companionModal ? "Return to the playfield to cast on a single-screen PC." : snapshot.game.overlay ? `${overlayName(snapshot.game.overlay, romSkills)} is open on the upper display.` : snapshot.game.feedback} />
{snapshot.game.gameMode === "manastorm" ? : }
); } export function LocalCompanionPanel() { useShellStore(); useGameStore(); useCombatStore(); usePartyStore(); return ; }