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