Files
healer-man/src/platform/dualScreenSync.ts
T
2026-08-14 15:56:39 -04:00

359 lines
12 KiB
TypeScript

import { classById, type GenderId, type RaceId } from "../app/characterCatalog";
import { useGameStore } from "../game/store";
import type { GameMode, GameOverlay } from "../game/store";
import { 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 type { AbilityTarget } from "../game/abilityCatalog";
import type { DungeonId } from "../game/dungeonCatalog";
import { useManastormStore } from "../game/manastormStore";
import { resolveStagePresentation } from "../game/manastormStagePresentation";
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 {
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 {
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" };
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 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 abilities = ACTION_CONTROLS.flatMap((control): CompanionAbilitySnapshot[] => {
const abilityId = combat.actionBindings[actionBindingId(game.actionLayer, control)];
const ability = abilityId ? combat.abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
if (!ability) return [];
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: ability.cost.amount,
resourceLabel: combat.resourceName,
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
|| ability.cost.amount > combat.resource,
target: ability.target,
}];
});
return {
phase: shell.phase,
session: shell.session,
characters: shell.characters,
selectedCharacterId: shell.selectedCharacterId,
activeCharacter: shell.activeCharacter,
draft: shell.draft,
notice: shell.notice,
game: {
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 "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;
}
}