Files
healer-man/src/platform/dualScreenSync.ts
T
2026-09-17 09:13:15 -04:00

440 lines
17 KiB
TypeScript

import { getInputPreferences } from "../input/keybindRuntime";
import type { InputPreferences } from "../input/keybinds";
import { classById, type GenderId, type RaceId } from "../app/characterCatalog";
import { useGameStore } from "../game/store";
import type { GameMode, GameOverlay } from "../game/store";
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
import { activePartyBossObjectives } from "../game/partyRuntime";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import {
usePartyStore,
type PartyCommand,
type PartyMemberStatus,
type PartyRole,
} from "../game/partyStore";
import { useShellStore } from "../app/shellStore";
import type { ControllerMovement, ControllerTokenEvent } from "../input/controller";
import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from "../app/types";
import { requestDisplaySurface } from "./displayRouting";
import {
ACTION_CONTROLS,
actionBindingId,
type ActionBindingControl,
type ActionBindingLayer,
} from "../game/actionBindings";
import { abilityAtLevel, learnedAbilityRank, resourceProfileForClass, type AbilityTarget } from "../game/abilityCatalog";
import type { DungeonId } from "../game/dungeonCatalog";
import { useManastormStore } from "../game/manastormStore";
import { resolveStagePresentation } from "../game/manastormStagePresentation";
import { currentDpsMeterSnapshot } from "../game/dpsMeterStore";
import type { DpsMeterSnapshot } from "../game/dpsMeter";
import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
import { normalizeHudPosition } from "../game/hudLayout";
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
import { currentDisplayLayout } from "./displayLayout";
import { currentHudSnapshot, type HudSnapshot } from "./hudSnapshot";
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
import { interactWithActiveDungeonObject } from "../game/dungeonWorldInteraction";
import { resurrectDeadPartyMembers } from "../game/partyResurrection";
export { createDualScreenChannel } from "./dualScreenChannel";
export interface CompanionPlayerSnapshot {
level: number;
experience: number;
experienceToNext: number;
health: number;
maxHealth: number;
shield: number;
resource: number;
maxResource: number;
resourceName: string;
dead: boolean;
}
export interface CompanionTargetSnapshot {
id: string;
name: string;
boss: boolean;
level: number;
health: number;
maxHealth: number;
dead: boolean;
}
export interface CompanionAbilitySnapshot {
control: ActionBindingControl;
id: string;
name: string;
shortLabel: string;
description: string;
icon: string;
color: string;
resourceCost: number;
resourceLabel: string;
cooldownRemaining: number;
cooldownDuration: number;
disabled: boolean;
lockedUntilLevel?: number;
target: AbilityTarget;
}
export interface CompanionPartyMemberSnapshot {
id: string;
name: string;
classId: import("../app/characterCatalog").ClassId;
raceId: RaceId;
gender: GenderId;
specialization: string;
role: PartyRole;
level: number;
health: number;
maxHealth: number;
status: PartyMemberStatus;
}
export interface CompanionPartyObjectiveSnapshot {
id: string;
name: string;
}
export interface CompanionGameSnapshot {
hud: HudSnapshot;
dpsMeter: {
values: DpsMeterSnapshot;
layout: HudElementLayout;
display: "main" | "companion";
editing: boolean;
};
gameMode: GameMode;
dungeonId: string;
dungeonTitle: string;
areaName: string;
playerPosition: readonly [number, number, number];
distanceTravelled: number;
cameraYaw: number;
overlay: GameOverlay;
paused: boolean;
mapOpen: boolean;
companionModal: boolean;
inputMode: "keyboard" | "gamepad";
actionLayer: ActionBindingLayer;
assetStatus: string;
player: CompanionPlayerSnapshot;
selectedTarget: CompanionTargetSnapshot | null;
activeCast: {
name: string;
mode: "cast" | "channel";
remaining: number;
duration: number;
} | null;
abilities: CompanionAbilitySnapshot[];
party: {
members: CompanionPartyMemberSnapshot[];
selectedMemberId: string | null;
command: PartyCommand;
routeBlocked: boolean;
objective: CompanionPartyObjectiveSnapshot | null;
};
feedback: string | null;
}
export interface CompanionSnapshot {
keybinds?: InputPreferences;
phase: ShellPhase;
session: PlayerSession | null;
characters: CharacterProfile[];
selectedCharacterId: string | null;
activeCharacter: CharacterProfile | null;
draft: CharacterDraft;
notice: string;
game: CompanionGameSnapshot;
}
export type CompanionCommand =
| { name: "selectCharacter"; characterId: string }
| { name: "openCreator" }
| { name: "cancelCreator" }
| { name: "openMainMenu" }
| { name: "openDungeons" }
| { name: "openManastorms" }
| { name: "returnToMainMenu" }
| { name: "abandonManastormRun" }
| { name: "enterDungeon"; dungeonId: DungeonId; playerRole?: PartyRole }
| { name: "enterWorld" }
| { name: "returnToCharacters" }
| { name: "signOut" }
| { name: "randomizeDraft" }
| { name: "toggleMap" }
| { name: "togglePause" }
| { name: "openOverlay"; overlay: Exclude<GameOverlay, null> }
| { name: "closeOverlay" }
| { name: "castActionBinding"; layer: ActionBindingLayer; control: ActionBindingControl }
| { name: "cycleTarget"; direction: 1 | -1 }
| { name: "clearTarget" }
| { name: "selectPartyMember"; memberId: string }
| { name: "cyclePartyMember"; direction: 1 | -1 }
| { name: "setPartyCommand"; command: PartyCommand }
| { name: "recallParty" }
| { name: "resetAtEntrance" }
| { name: "editDpsMeter"; update: Partial<HudElementLayout> }
| { name: "editHudElement"; id: HudElementId; update: Partial<HudElementLayout> }
| { name: "selectFriendlyActor"; id: string | null }
| { name: "selectPlayer" }
| { name: "clearPartySelection" }
| { name: "resurrectParty"; checkpointId: string }
| { name: "interactDungeonObject" }
| { name: "finishDungeon" }
| { name: "moveThreatMeter"; position: { x: number; y: number } };
export type DualScreenMessage =
| { type: "companion-ready" }
| { type: "companion-closing" }
| { type: "authoritative-ready" }
| { type: "app-state"; snapshot: CompanionSnapshot }
| { type: "command"; command: CompanionCommand }
| { type: "controller-token"; event: ControllerTokenEvent }
| { type: "controller-motion"; movement: ControllerMovement };
function currentClock(): number {
return Date.now();
}
export function currentCompanionSnapshot(): CompanionSnapshot {
const shell = useShellStore.getState();
const game = useGameStore.getState();
const combat = useCombatStore.getState();
const party = usePartyStore.getState();
const manastorm = useManastormStore.getState();
const uiLayout = activeUiLayout();
const dungeon = requireDungeonDefinition(game.activeDungeonId);
const stagePresentation = resolveStagePresentation(game.gameMode, manastorm.currentEncounter, {
title: dungeon.title,
mapId: dungeon.mapId,
areaName: game.areaName,
});
const partyObjective = party.objectiveBossId
? activePartyBossObjectives().find((objective) => objective.id === party.objectiveBossId) ?? null
: null;
const now = currentClock();
const selectedTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] ?? null : null;
const globalCooldownRemaining = Math.max(0, combat.globalCooldownEndsAt - now);
const classColor = classById(combat.classId).color;
const activeResourceType = resourceProfileForClass(combat.classId).type;
const abilities = ACTION_CONTROLS.flatMap((control): CompanionAbilitySnapshot[] => {
const abilityId = combat.actionBindings[actionBindingId(game.actionLayer, control)];
const catalogAbility = abilityId
? combat.abilities.find((candidate) => candidate.id === abilityId) ?? null
: null;
if (!catalogAbility) return [];
const learnedTalentRank = learnedAbilityRank(catalogAbility, combat.talentRanks);
const ability = abilityAtLevel(catalogAbility, combat.level, learnedTalentRank);
const costs = resolvedAbilityCosts(ability, combat.talentModifiers, combat);
const displayCost = costs[0] ?? { resource: ability.cost.resource, amount: 0 };
const cannotPay = costs.some((cost) => cost.resource === "health"
? combat.health <= cost.amount
: (cost.resource === activeResourceType
? combat.resource
: combat.resourcePools[cost.resource] ?? 0) < cost.amount);
const personalCooldownRemaining = Math.max(0, (combat.cooldowns[ability.id] ?? 0) - now);
const remainingMs = Math.max(personalCooldownRemaining, globalCooldownRemaining);
const durationMs = personalCooldownRemaining >= globalCooldownRemaining
? ability.cooldownMs
: ability.gcdMs;
return [{
control,
id: ability.id,
name: ability.name,
shortLabel: ability.sigil,
description: ability.description,
icon: ability.icon,
color: classColor,
resourceCost: displayCost.amount,
resourceLabel: displayCost.resource === activeResourceType
? combat.resourceName
: displayCost.resource.replace("-", " "),
cooldownRemaining: remainingMs / 1_000,
cooldownDuration: durationMs / 1_000,
lockedUntilLevel: combat.level < ability.unlockLevel ? ability.unlockLevel : undefined,
disabled: game.overlay !== null
|| game.companionOpen
|| combat.level < ability.unlockLevel
|| combat.health <= 0
|| combat.activeCast !== null
|| remainingMs > 0
|| cannotPay,
target: ability.target,
}];
});
return {
keybinds: getInputPreferences(),
phase: shell.phase,
session: shell.session,
characters: shell.characters,
selectedCharacterId: shell.selectedCharacterId,
activeCharacter: shell.activeCharacter,
draft: shell.draft,
notice: shell.notice,
game: {
hud: currentHudSnapshot(),
dpsMeter: {
values: currentDpsMeterSnapshot(),
layout: uiLayout.elements.dps,
display: resolveDpsMeterDisplay(uiLayout, typeof window !== "undefined" && currentDisplayLayout() !== "single"),
editing: game.overlay === "ui-layout",
},
gameMode: game.gameMode,
dungeonId: dungeon.id,
dungeonTitle: stagePresentation.title,
areaName: stagePresentation.areaName,
playerPosition: game.playerPosition,
distanceTravelled: game.distanceTravelled,
cameraYaw: game.cameraYaw,
overlay: game.overlay,
paused: game.paused,
mapOpen: game.mapOpen,
companionModal: game.companionOpen,
inputMode: game.inputMode,
actionLayer: game.actionLayer,
assetStatus: game.assetStatus,
player: {
level: combat.level,
experience: combat.xp,
experienceToNext: combat.xpToNext,
health: combat.health,
maxHealth: combat.maxHealth,
shield: combat.shield,
resource: combat.resource,
maxResource: combat.maxResource,
resourceName: combat.resourceName,
dead: combat.health <= 0,
},
selectedTarget: selectedTarget ? {
id: selectedTarget.id,
name: selectedTarget.name,
boss: selectedTarget.boss,
level: selectedTarget.level,
health: selectedTarget.health,
maxHealth: selectedTarget.maxHealth,
dead: selectedTarget.dead,
} : null,
activeCast: combat.activeCast ? {
name: combat.abilities.find((ability) => ability.id === combat.activeCast!.abilityId)?.name ?? "Spell",
mode: combat.activeCast.mode,
remaining: Math.max(0, combat.activeCast.completesAt - now) / 1_000,
duration: Math.max(0.001, combat.activeCast.completesAt - combat.activeCast.startedAt) / 1_000,
} : null,
abilities,
party: {
members: party.members.map((member) => ({
id: member.id,
name: member.name,
classId: member.classId,
raceId: member.raceId,
gender: member.gender,
specialization: member.specialization,
role: member.role,
level: member.level,
health: member.health,
maxHealth: member.maxHealth,
status: member.status,
})),
selectedMemberId: party.selectedMemberId,
command: party.command,
routeBlocked: party.routeBlocked,
objective: partyObjective ? { id: partyObjective.id, name: partyObjective.name } : null,
},
feedback: combat.feedback?.message ?? null,
},
};
}
export function executeCompanionCommand(command: CompanionCommand): void {
const shell = useShellStore.getState();
const game = useGameStore.getState();
const combat = useCombatStore.getState();
switch (command.name) {
case "editHudElement": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft || !(command.id in HUD_ELEMENTS)) break;
const update: Partial<HudElementLayout> = {};
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.max(20, Math.min(2000, command.update.width));
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
[command.id]: { ...layout.elements[command.id], ...update },
} }));
useUiLayoutStore.setState({ selectedElement: command.id });
break;
}
case "selectFriendlyActor": combat.selectFriendlyActor(command.id); break;
case "selectPlayer": combat.clearTarget(); usePartyStore.getState().selectSelf(); break;
case "clearPartySelection": usePartyStore.getState().clearSelection(); break;
case "resurrectParty": resurrectDeadPartyMembers(command.checkpointId); break;
case "interactDungeonObject": if (!game.overlay) interactWithActiveDungeonObject(); break;
case "finishDungeon": if (currentHudSnapshot().completion) { shell.saveActiveDungeonRunCheckpoint(null); shell.returnToMainMenu(); } break;
case "moveThreatMeter": combat.updateSettings({ threatMeterPosition: normalizeHudPosition(command.position, { x: .8, y: .3 }) }); break;
case "editDpsMeter": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
const update: Partial<HudElementLayout> = {};
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.round(Math.max(160, Math.min(600, command.update.width)));
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: {
...layout.elements, dps: { ...layout.elements.dps, ...update },
} }));
useUiLayoutStore.setState({ selectedElement: "dps" });
break;
}
case "selectCharacter": shell.selectCharacter(command.characterId); break;
case "openCreator": shell.openCreator(); break;
case "cancelCreator": shell.cancelCreator(); break;
case "openMainMenu": shell.openMainMenu(); break;
case "openDungeons": shell.openDungeons(); break;
case "openManastorms": shell.openManastorms(); break;
case "returnToMainMenu": shell.returnToMainMenu(); break;
case "abandonManastormRun": shell.abandonManastormRun(); break;
case "enterDungeon": shell.enterDungeon(command.dungeonId, command.playerRole); break;
case "enterWorld": shell.enterWorld(); break;
case "returnToCharacters":
shell.returnToCharacters();
requestDisplaySurface("top");
break;
case "signOut": shell.signOut(); break;
case "randomizeDraft": shell.randomizeDraft(); break;
case "toggleMap": game.toggleMap(); break;
case "togglePause": game.togglePause(); break;
case "openOverlay":
game.openOverlay(command.overlay);
requestDisplaySurface("top");
break;
case "closeOverlay": game.closeOverlay(); break;
case "castActionBinding":
if (game.overlay === null && !game.companionOpen) combat.castActionBinding(command.layer, command.control, game.playerPosition, currentClock());
break;
case "cycleTarget":
usePartyStore.getState().clearSelection();
combat.cycleTarget(command.direction, game.playerPosition);
break;
case "clearTarget":
combat.clearTarget();
usePartyStore.getState().clearSelection();
break;
case "selectPartyMember":
combat.clearTarget();
usePartyStore.getState().selectMember(command.memberId);
break;
case "cyclePartyMember":
combat.clearTarget();
usePartyStore.getState().cycleMember(command.direction);
break;
case "setPartyCommand":
usePartyStore.getState().setCommand(command.command);
break;
case "recallParty":
usePartyStore.getState().recallParty();
break;
case "resetAtEntrance":
if (game.gameMode === "dungeon") game.resetAtEntrance();
requestDisplaySurface("top");
break;
}
}