519 lines
27 KiB
TypeScript
519 lines
27 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";
|
|
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<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;
|
|
const PreloadedCompanionManastormPanel = getLoadedCompanionManastormPanel();
|
|
|
|
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 PreloadedCompanionManastormPanel ? (
|
|
<PreloadedCompanionManastormPanel selected={selected} send={send} />
|
|
) : (
|
|
<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} />;
|
|
}
|