forked from phenom/healer-man
359 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|