Files
healer-man/src/ui/CompanionPanel.tsx
T
2026-08-14 15:56:39 -04:00

512 lines
26 KiB
TypeScript

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";
const CompanionManastormPanel = lazy(() => import("./CompanionManastormPanel").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<DungeonCategoryId>(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 (
<FrontSurface bottom className="companion-panel companion-roster companion-dungeons" ariaLabel="Dungeon and party role selection companion screen">
<header><BrandMark compact /><b>CHOOSE DUNGEON / ROLE</b></header>
<main>
<div className="companion-dungeon-browser">
<div className="companion-category-tabs" role="group" aria-label="Dungeon categories">
{DUNGEON_CATEGORIES.map((category) => (
<button
key={category.id}
type="button"
className={category.id === selectedCategory ? "is-active" : ""}
aria-pressed={category.id === selectedCategory}
title={category.fullName}
onClick={() => selectCategory(category.id)}
>
<strong>{category.label}</strong>
<small>{dungeonsByCategory(category.id).length}</small>
</button>
))}
</div>
<div
className="companion-mini-roster"
aria-label={`Available ${DUNGEON_CATEGORIES.find((category) => category.id === selectedCategory)?.label} dungeons`}
>
{categoryDungeons.map((dungeon, index) => (
<button
key={dungeon.id}
type="button"
className={dungeon.id === selectedDungeon?.id ? "is-active" : ""}
disabled={!dungeon.available}
onClick={() => setSelectedDungeonId(dungeon.id)}
>
<span>{String(index + 1).padStart(2, "0")}</span>
<strong>{dungeon.title}</strong>
<small>{dungeon.available ? dungeon.location : "Coming later"}</small>
</button>
))}
</div>
</div>
{selectedDungeon ? (
<div className="companion-selection-card companion-dungeon-role">
<CharacterPreview character={selected} compact />
<section>
<p className="companion-kicker">{selectedDungeon.title}</p>
<h2>Choose your role</h2>
<div className="companion-role-options" role="group" aria-label="Dungeon role">
{roles.map((role) => (
<button
key={role}
type="button"
onClick={() => send({
name: "enterDungeon",
dungeonId: selectedDungeon.id,
playerRole: role,
})}
>
<strong>{PARTY_ROLE_LABELS[role]}</strong>
<small>{role === "healer" ? "Tank + 3 DPS"
: role === "tank" ? "Healer + 3 DPS"
: "Tank + healer + 2 DPS"}</small>
</button>
))}
</div>
</section>
</div>
) : <div className="companion-selection-card"><p>No dungeons are available yet.</p></div>}
</main>
<footer className="companion-actions">
<button type="button" onClick={() => send({ name: "returnToMainMenu" })}>Back to main menu</button>
<span>Select a role to enter</span>
</footer>
</FrontSurface>
);
}
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;
if (snapshot.phase === "login") {
return (
<FrontSurface bottom className="companion-panel companion-login" ariaLabel="Healer Man companion login information">
<header><BrandMark compact /><b>SECONDARY DISPLAY</b></header>
<main>
<p className="companion-kicker">Ready on both screens</p>
<h2>One roster. Two ways to play.</h2>
<div className="companion-feature-grid">
<article><b>01</b><span><strong>Local account</strong><small>Salted password verifier on this device</small></span></article>
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
</div>
</main>
<footer><span>Top screen owns account entry</span><b>THOR / 620 x 540</b></footer>
</FrontSurface>
);
}
if (snapshot.phase === "characters") {
const race = selected ? raceById(selected.raceId) : null;
const characterClass = selected ? classById(selected.classId) : null;
return (
<FrontSurface bottom className="companion-panel companion-roster" ariaLabel="Character roster companion screen">
<header><BrandMark compact /><b>{snapshot.session?.kind === "offline" ? "OFFLINE ROSTER" : snapshot.session?.displayName}</b></header>
<main>
<div className="companion-mini-roster" aria-label="Character quick select">
{snapshot.characters.map((character) => (
<button key={character.id} type="button" className={character.id === snapshot.selectedCharacterId ? "is-active" : ""} onClick={() => send({ name: "selectCharacter", characterId: character.id })}>
<span>{raceById(character.raceId).sigil}</span><strong>{character.name}</strong><small>Lv {character.level}</small>
</button>
))}
{!snapshot.characters.length && <p>No characters saved yet.</p>}
</div>
<div className="companion-selection-card">
<CharacterPreview character={selected} compact />
{selected && race && characterClass ? (
<dl>
<div><dt>Faction</dt><dd>{FACTIONS[race.faction].name}</dd></div>
<div><dt>Class</dt><dd>{characterClass.name}</dd></div>
<div><dt>Role</dt><dd>{characterClass.role}</dd></div>
<div><dt>Location</dt><dd>{selected.location}</dd></div>
</dl>
) : <p>Create your first adventurer on the upper display.</p>}
</div>
</main>
<footer className="companion-actions">
<button type="button" onClick={() => send({ name: "openCreator" })}>New character</button>
<button type="button" disabled={!selected} onClick={() => send({ name: "openMainMenu" })}>Continue</button>
</footer>
</FrontSurface>
);
}
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 (
<FrontSurface bottom className="companion-panel companion-create" ariaLabel="Character creation companion screen">
<header><BrandMark compact /><b>CHARACTER FORGE</b></header>
<main>
<CharacterPreview draft={snapshot.draft} compact />
<section>
<p className="companion-kicker">{FACTIONS[race.faction].name} / {snapshot.draft.gender}</p>
<h2>{race.name} {characterClass.name}</h2>
<dl>
<div><dt>{labels.skinColor}</dt><dd>{snapshot.draft.appearance.skinColor + 1}/{race.counts[snapshot.draft.gender].skinColor}</dd></div>
<div><dt>{labels.face}</dt><dd>{snapshot.draft.appearance.face + 1}/{race.counts[snapshot.draft.gender].face}</dd></div>
<div><dt>{labels.hairStyle}</dt><dd>{snapshot.draft.appearance.hairStyle + 1}/{race.counts[snapshot.draft.gender].hairStyle}</dd></div>
<div><dt>{labels.feature}</dt><dd>{snapshot.draft.appearance.feature + 1}/{race.counts[snapshot.draft.gender].feature}</dd></div>
</dl>
</section>
</main>
<footer className="companion-actions">
<button type="button" onClick={() => send({ name: "randomizeDraft" })}>Randomize</button>
<button type="button" onClick={() => send({ name: "cancelCreator" })}>Back to roster</button>
</footer>
</FrontSurface>
);
}
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 (
<FrontSurface bottom className="companion-panel companion-roster companion-main-menu" ariaLabel="Healer Man main menu companion screen">
<header><BrandMark compact /><b>{selected ? `${selected.name.toUpperCase()} / MAIN MENU` : "MAIN MENU"}</b></header>
<main>
<div className="companion-mini-roster" aria-label="Game modes">
{modes.map((mode, index) => (
<button
key={mode.id}
type="button"
disabled={!mode.available}
onClick={mode.id === "dungeons"
? () => send({ name: "openDungeons" })
: mode.id === "manastorms"
? () => send({ name: "openManastorms" })
: undefined}
>
<span>{String(index + 1).padStart(2, "0")}</span>
<strong>{mode.label}</strong>
<small>{mode.detail}</small>
</button>
))}
</div>
<div className="companion-selection-card">
<CharacterPreview character={selected} compact />
{selected && race && characterClass ? (
<dl>
<div><dt>Adventurer</dt><dd>{selected.name}</dd></div>
<div><dt>Level</dt><dd>{selected.level}</dd></div>
<div><dt>Race</dt><dd>{race.name}</dd></div>
<div><dt>Class</dt><dd>{characterClass.name}</dd></div>
</dl>
) : <p>Return to the roster and select an adventurer.</p>}
</div>
</main>
<footer className="companion-actions">
<button type="button" onClick={() => send({ name: "returnToCharacters" })}>Change character</button>
<button type="button" disabled={!selected} onClick={() => send({ name: "openDungeons" })}>Dungeons</button>
<button type="button" disabled={!selected} onClick={() => send({ name: "openManastorms" })}>Manastorms</button>
</footer>
</FrontSurface>
);
}
if (snapshot.phase === "dungeons") {
return <CompanionDungeonSelection selected={selected} send={send} />;
}
if (snapshot.phase === "manastorms") {
return (
<Suspense fallback={<FrontSurface bottom className="companion-panel" ariaLabel="Loading Manastorm companion screen"><span>Loading Manastorm data…</span></FrontSurface>}>
<CompanionManastormPanel selected={selected} send={send} />
</Suspense>
);
}
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<GameOverlay, null>) => send(
snapshot.game.overlay === overlay ? { name: "closeOverlay" } : { name: "openOverlay", overlay },
);
return (
<FrontSurface bottom className="companion-panel companion-game" ariaLabel={`${snapshot.game.dungeonTitle} tactical companion screen`}>
<header>
<BrandMark compact />
<div className="companion-character-status">
<span>{race?.sigil ?? "HM"}</span>
<strong>{active?.name ?? "Adventurer"}</strong>
<small>{snapshot.game.overlay ? `${overlayName(snapshot.game.overlay, romSkills)} open upstairs` : `${characterClass?.name ?? "Explorer"} / Level ${player.level}`}</small>
</div>
</header>
<main className="companion-tactical">
<section className="companion-party-strip" aria-label="Touch party frames">
<header>
<strong>Party · 5 / 5</strong>
<small aria-live="polite">{partyCommandStatus}</small>
<div role="group" aria-label="Party command" style={{ display: "flex", gap: 3 }}>
{(["attack", "defend", "stop"] as const).map((command) => (
<button
key={command}
type="button"
className={partyCommand === command ? "is-selected" : ""}
style={compactPartyCommandStyle}
aria-pressed={partyCommand === command}
title={command === "attack"
? "Advance toward the next boss and clear enemies along the route."
: command === "defend"
? "Only fight enemies that attack the healer."
: "Hold the party at its current position."}
onClick={() => send({ name: "setPartyCommand", command })}
>{command[0].toUpperCase() + command.slice(1)}</button>
))}
<button
type="button"
style={compactPartyCommandStyle}
aria-label="Recall party to the player"
title="Teleport every companion to the player without changing the current order."
onClick={() => send({ name: "recallParty" })}
>Recall</button>
</div>
</header>
<div>
{snapshot.game.party.members.map((member) => {
const memberClass = classById(member.classId);
const memberRace = raceById(member.raceId);
const percent = safePercent(member.health, member.maxHealth);
return (
<button
key={member.id}
type="button"
className={`${member.id === snapshot.game.party.selectedMemberId ? "is-selected" : ""} ${member.health <= 0 ? "is-down" : ""}`}
style={{ "--party-class-color": memberClass.color } as CSSProperties}
aria-pressed={member.id === snapshot.game.party.selectedMemberId}
onClick={() => send({ name: "selectPartyMember", memberId: member.id })}
>
<b>{memberClass.sigil}</b>
<span><strong>{member.name}</strong><small>{PARTY_ROLE_LABELS[member.role]} · {memberRace.name} · {member.specialization}</small></span>
<i><span style={{ width: `${percent}%` }} /></i>
</button>
);
})}
</div>
</section>
<section className="companion-vitals companion-tactical__status" style={{ alignContent: "start", gap: 8, overflow: "hidden" }}>
<div>
<span>Health</span>
<i role="progressbar" aria-label="Player health" aria-valuemin={0} aria-valuemax={player.maxHealth} aria-valuenow={Math.max(0, player.health)}><b style={{ width: `${healthPercent}%` }} /></i>
<strong>{Math.round(player.health)} / {Math.round(player.maxHealth)}{player.shield > 0 ? ` +${Math.round(player.shield)}` : ""}</strong>
</div>
<div>
<span>{player.resourceName}</span>
<i role="progressbar" aria-label={player.resourceName} aria-valuemin={0} aria-valuemax={player.maxResource} aria-valuenow={Math.max(0, player.resource)}><b style={{ width: `${resourcePercent}%` }} /></i>
<strong>{Math.round(player.resource)} / {Math.round(player.maxResource)}</strong>
</div>
<ExperienceBar level={player.level} current={player.experience} required={player.experienceToNext} />
{snapshot.game.activeCast && (
<div className={`companion-cast companion-cast--${snapshot.game.activeCast.mode}`}>
<span>{snapshot.game.activeCast.mode === "channel" ? "Channeling" : "Casting"}</span>
<i><b style={{ width: `${snapshot.game.activeCast.mode === "channel"
? snapshot.game.activeCast.remaining / snapshot.game.activeCast.duration * 100
: (1 - snapshot.game.activeCast.remaining / snapshot.game.activeCast.duration) * 100}%` }} /></i>
<strong>{snapshot.game.activeCast.name} · {snapshot.game.activeCast.remaining.toFixed(1)}s</strong>
</div>
)}
<article className={`companion-target ${target?.boss ? "companion-target--boss" : ""} ${target?.dead ? "companion-target--dead" : ""}`}>
<header>
<span>{target?.boss ? "Boss target" : "Selected target"}</span>
<strong>{target?.name ?? "No target"}</strong>
</header>
{target ? (
<>
<div className="companion-target__track" role="progressbar" aria-label={`${target.name} health`} aria-valuemin={0} aria-valuemax={target.maxHealth} aria-valuenow={Math.max(0, target.health)}>
<span style={{ width: `${targetPercent}%` }} />
</div>
<small>Level {target.level} / {target.dead ? "Defeated" : `${Math.round(target.health)} of ${Math.round(target.maxHealth)} health`}</small>
</>
) : <small>Choose a nearby enemy to use hostile abilities.</small>}
<div className="companion-target__actions">
<button type="button" onClick={() => send({ name: "cycleTarget", direction: 1 })}>{target ? "Next" : "Find target"}</button>
{target && <button type="button" onClick={() => send({ name: "clearTarget" })}>Clear</button>}
</div>
</article>
</section>
<section className="companion-map-card">
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
<div>
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
</div>
</section>
<div className="companion-tactical__actions" style={{ gridColumn: "1 / 3", minWidth: 0 }}>
<ActionBar
items={actionItems}
activeLayer={snapshot.game.actionLayer}
inputMode={actionInputMode}
variant="compact"
onActivate={(control) => 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}
/>
</div>
</main>
<footer className="companion-actions companion-actions--game companion-menu-grid" aria-label="Game menus and expedition actions">
<button type="button" className={snapshot.game.overlay === "map" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "map"} onClick={() => toggleDestination("map")}>Map</button>
<button type="button" className={snapshot.game.overlay === "spellbook" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "spellbook"} onClick={() => toggleDestination("spellbook")}>Spells</button>
<button type="button" className={snapshot.game.overlay === "talents" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "talents"} onClick={() => toggleDestination("talents")}>{skillMenuLabel}</button>
<button type="button" className={snapshot.game.overlay === "inventory" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "inventory"} onClick={() => toggleDestination("inventory")}>Gear</button>
<button type="button" className={snapshot.game.overlay === "bindings" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "bindings"} onClick={() => toggleDestination("bindings")}>Controls</button>
<button type="button" className={snapshot.game.overlay === "options" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "options"} onClick={() => toggleDestination("options")}>Options</button>
<button type="button" className={snapshot.game.overlay === "pause" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "pause"} onClick={() => toggleDestination("pause")}>{snapshot.game.overlay === "pause" ? "Resume" : "Pause"}</button>
{snapshot.game.gameMode === "manastorm"
? <button type="button" onClick={() => send({ name: "abandonManastormRun" })}>Abandon</button>
: <button type="button" onClick={() => send({ name: "resetAtEntrance" })}>Entrance</button>}
<button type="button" onClick={() => send({ name: "returnToMainMenu" })}>Main menu</button>
</footer>
</FrontSurface>
);
}
export function LocalCompanionPanel() {
useShellStore();
useGameStore();
useCombatStore();
usePartyStore();
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
}