forked from phenom/healer-man
Release Healer Man 0.1.43
This commit is contained in:
1 parent
c7e8163790
commit
ccc01396cd
86 files changed
+2951
-198
No files matched your search
@@ -15,6 +15,7 @@ import {
|
||||
import { normalizeInputPreferences } from "../input/keybinds";
|
||||
import { publishInputPreferences } from "../input/keybindRuntime";
|
||||
import { useForcedThorDisplays } from "./useThorDualScreen";
|
||||
import { RemoteDungeonHud } from "./RemoteDungeonHud";
|
||||
|
||||
export function BottomDisplayApp() {
|
||||
useForcedThorDisplays();
|
||||
@@ -78,5 +79,7 @@ export function BottomDisplayApp() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <main className="bottom-display-root"><CompanionPanel snapshot={snapshot} send={send} preferControllerBinds /></main>;
|
||||
return <main className="bottom-display-root">{snapshot.phase === "game"
|
||||
? <RemoteDungeonHud snapshot={snapshot} send={send} />
|
||||
: <CompanionPanel snapshot={snapshot} send={send} preferControllerBinds />}</main>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { clearBossTestingDungeons, registerBossTestingDungeon } from "../game/dungeonRegistry";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { DungeonCompanionHud } from "../ui/DungeonCompanionHud";
|
||||
import type { CompanionCommand, CompanionSnapshot } from "./dualScreenSync";
|
||||
|
||||
export function RemoteDungeonHud({ snapshot, send }: {
|
||||
snapshot: CompanionSnapshot;
|
||||
send: (command: CompanionCommand) => void;
|
||||
}) {
|
||||
const hud = snapshot.game.hud;
|
||||
useLayoutEffect(() => {
|
||||
if (hud.bossTestingDungeon) registerBossTestingDungeon(hud.bossTestingDungeon.definition, hud.bossTestingDungeon.sourceDungeonId);
|
||||
const session = useShellStore.getState().session;
|
||||
useShellStore.setState({ ...hud.shell,
|
||||
session: session?.ownerId === snapshot.session?.ownerId ? session : snapshot.session,
|
||||
});
|
||||
useUiLayoutStore.setState(hud.ui);
|
||||
useGameStore.setState({ ...hud.game,
|
||||
toggleMap: () => send({ name: "toggleMap" }),
|
||||
openOverlay: (overlay) => send({ name: "openOverlay", overlay }),
|
||||
});
|
||||
if (!hud.bossTestingDungeon) clearBossTestingDungeons();
|
||||
useCombatStore.setState({ ...hud.combat,
|
||||
castActionBinding: (layer, control) => { send({ name: "castActionBinding", layer, control }); return { ok: false, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 }; },
|
||||
selectFriendlyActor: (id) => { send({ name: "selectFriendlyActor", id }); return true; },
|
||||
clearTarget: () => send({ name: "clearTarget" }),
|
||||
updateSettings: (patch) => { if (patch.threatMeterPosition) send({ name: "moveThreatMeter", position: patch.threatMeterPosition }); },
|
||||
});
|
||||
usePartyStore.setState({ ...hud.party,
|
||||
selectMember: (memberId) => { if (memberId) send({ name: "selectPartyMember", memberId }); else send({ name: "clearPartySelection" }); return true; },
|
||||
selectSelf: () => send({ name: "selectPlayer" }),
|
||||
clearSelection: () => send({ name: "clearPartySelection" }),
|
||||
setCommand: (command) => send({ name: "setPartyCommand", command }),
|
||||
recallParty: () => send({ name: "recallParty" }),
|
||||
});
|
||||
useManastormStore.setState(hud.manastorm);
|
||||
useDungeonRunStore.setState({ progress: hud.run });
|
||||
useOnlineSessionStore.setState({ snapshot: hud.onlineSession });
|
||||
useOnlineGroupStore.setState(hud.onlineGroup);
|
||||
useDpsMeterStore.setState(hud.dps);
|
||||
}, [hud, send, snapshot.session]);
|
||||
return <DungeonCompanionHud send={send} remote completion={hud.completion} interaction={hud.interaction} />;
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessa
|
||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
|
||||
|
||||
export function connectAuthoritativeDualScreen(
|
||||
channel: BroadcastChannel,
|
||||
@@ -38,6 +42,11 @@ export function connectAuthoritativeDualScreen(
|
||||
const unsubscribeDps = useDpsMeterStore.subscribe(() => {
|
||||
if (companionReady) gamePublisher.request();
|
||||
});
|
||||
const request = () => { if (companionReady) gamePublisher.request(); };
|
||||
const unsubscribeOnline = useOnlineSessionStore.subscribe(request);
|
||||
const unsubscribeGroup = useOnlineGroupStore.subscribe(request);
|
||||
const unsubscribeRun = useDungeonRunStore.subscribe(request);
|
||||
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(request);
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === "companion-ready") {
|
||||
companionReady = true;
|
||||
@@ -62,6 +71,10 @@ export function connectAuthoritativeDualScreen(
|
||||
unsubscribeParty();
|
||||
unsubscribeUi();
|
||||
unsubscribeDps();
|
||||
unsubscribeOnline();
|
||||
unsubscribeGroup();
|
||||
unsubscribeRun();
|
||||
unsubscribeEncounters();
|
||||
gamePublisher.dispose();
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,10 @@ 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";
|
||||
|
||||
@@ -95,6 +99,7 @@ export interface CompanionPartyObjectiveSnapshot {
|
||||
}
|
||||
|
||||
export interface CompanionGameSnapshot {
|
||||
hud: HudSnapshot;
|
||||
dpsMeter: {
|
||||
values: DpsMeterSnapshot;
|
||||
layout: HudElementLayout;
|
||||
@@ -172,7 +177,15 @@ export type CompanionCommand =
|
||||
| { name: "setPartyCommand"; command: PartyCommand }
|
||||
| { name: "recallParty" }
|
||||
| { name: "resetAtEntrance" }
|
||||
| { name: "editDpsMeter"; update: Partial<HudElementLayout> };
|
||||
| { 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" }
|
||||
@@ -265,6 +278,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
||||
draft: shell.draft,
|
||||
notice: shell.notice,
|
||||
game: {
|
||||
hud: currentHudSnapshot(),
|
||||
dpsMeter: {
|
||||
values: currentDpsMeterSnapshot(),
|
||||
layout: uiLayout.elements.dps,
|
||||
@@ -342,6 +356,24 @@ export function executeCompanionCommand(command: CompanionCommand): void {
|
||||
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> = {};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { currentHudSnapshot } from "./hudSnapshot";
|
||||
import { executeCompanionCommand } from "./dualScreenSync";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { normalizeUiPreferences } from "../game/uiLayout";
|
||||
import { BOSS_TESTING_CATALOG, createBossTestingRoom } from "../game/bossTesting";
|
||||
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
|
||||
describe("remote HUD presentation", () => {
|
||||
it("carries the isolated boss room to the lower display without simulation methods", () => {
|
||||
const original = useGameStore.getState();
|
||||
const boss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === "wailing-caverns")!;
|
||||
const room = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
|
||||
try {
|
||||
registerBossTestingDungeon(room.definition, boss.dungeonId);
|
||||
useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true });
|
||||
const snapshot = structuredClone(currentHudSnapshot());
|
||||
expect(snapshot.bossTestingDungeon?.definition).toEqual(room.definition);
|
||||
expect(snapshot.game.bossTest).toEqual(room.session);
|
||||
clearBossTestingDungeons();
|
||||
registerBossTestingDungeon(snapshot.bossTestingDungeon!.definition, snapshot.bossTestingDungeon!.sourceDungeonId);
|
||||
expect(requireDungeonDefinition(snapshot.game.activeDungeonId).staticSpawns).toHaveLength(1);
|
||||
} finally {
|
||||
useGameStore.setState(original);
|
||||
clearBossTestingDungeons();
|
||||
}
|
||||
});
|
||||
it("contains cloneable presentation data without simulation methods", () => {
|
||||
const snapshot = currentHudSnapshot();
|
||||
expect(() => structuredClone(snapshot)).not.toThrow();
|
||||
expect(snapshot.combat).not.toHaveProperty("tick");
|
||||
expect(snapshot.game).not.toHaveProperty("activateDungeon");
|
||||
expect(snapshot.party).not.toHaveProperty("recallParty");
|
||||
});
|
||||
it("accepts bounded moves only while the authoritative editor is open", () => {
|
||||
const originalUi = useUiLayoutStore.getState();
|
||||
const originalGame = useGameStore.getState();
|
||||
try {
|
||||
const draft = normalizeUiPreferences(null).current.mobile;
|
||||
useUiLayoutStore.setState({ draft });
|
||||
useGameStore.setState({ overlay: null });
|
||||
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: .8, y: .7 } } });
|
||||
expect(useUiLayoutStore.getState().draft).toBe(draft);
|
||||
useGameStore.setState({ overlay: "ui-layout" });
|
||||
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: 5, y: -1 }, width: 99999, screen: "hidden" } });
|
||||
expect(useUiLayoutStore.getState().draft?.elements.party).toMatchObject({ position: { x: 1, y: 0 }, width: 2000, visible: true });
|
||||
expect(useUiLayoutStore.getState().draft?.elements.party.screen).toBeUndefined();
|
||||
} finally {
|
||||
useUiLayoutStore.setState(originalUi);
|
||||
useGameStore.setState(originalGame);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { activeDungeonRunIsComplete } from "../game/dungeonEncounterRuntime";
|
||||
import { activeDungeonInteractionCandidate } from "../game/dungeonWorldInteraction";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
|
||||
function pick<T, K extends keyof T>(state: T, keys: readonly K[]): Pick<T, K> {
|
||||
return Object.fromEntries(keys.map((key) => [key, state[key]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
/** Presentation data only. The lower WebView never starts another simulation. */
|
||||
export function currentHudSnapshot() {
|
||||
const game = useGameStore.getState();
|
||||
const run = useDungeonRunStore.getState().progress;
|
||||
const candidate = game.overlay ? null : activeDungeonInteractionCandidate();
|
||||
return {
|
||||
game: pick(game, ["activeDungeonId", "gameMode", "gmTestMode", "bossTest", "areaName", "distanceTravelled", "playerPosition", "cameraYaw", "hasMoved", "cameraLookActive", "inputMode", "actionLayer", "assetStatus", "assetMessage", "overlay", "paused", "companionOpen"]),
|
||||
bossTestingDungeon: game.bossTest ? { definition: requireDungeonDefinition(game.activeDungeonId), sourceDungeonId: game.bossTest.sourceDungeonId } : null,
|
||||
combat: pick(useCombatStore.getState(), ["classId", "secondaryClassId", "specializationId", "level", "xp", "xpToNext", "health", "maxHealth", "shield", "shieldExpiresAt", "resource", "maxResource", "resourcePools", "maxResourcePools", "resourceName", "abilities", "talentRanks", "talentModifiers", "auras", "effects", "summons", "actors", "actionBindings", "cooldowns", "globalCooldownEndsAt", "activeCast", "pendingGroundTarget", "feedback", "selectedTargetId", "selectedFriendlyActorId", "mobs", "settings"]),
|
||||
party: pick(usePartyStore.getState(), ["members", "selectedMemberId", "selfSelected", "playerRole", "command", "routeBlocked"]),
|
||||
manastorm: pick(useManastormStore.getState(), ["currentEncounter", "resurrectionCharges"]),
|
||||
shell: pick(useShellStore.getState(), ["activeCharacter", "activeDungeonRole"]),
|
||||
onlineSession: useOnlineSessionStore.getState().snapshot,
|
||||
onlineGroup: pick(useOnlineGroupStore.getState(), ["snapshot", "activeActivity"]),
|
||||
ui: pick(useUiLayoutStore.getState(), ["preferences", "draft", "device", "selectedElement"]),
|
||||
run,
|
||||
dps: useDpsMeterStore.getState(),
|
||||
completion: game.gameMode === "dungeon" && (activeDungeonRunIsComplete(game.activeDungeonId) || (run?.dungeonId === game.activeDungeonId && run.finishedAt !== null)),
|
||||
interaction: candidate ? { prompt: candidate.object.interaction!.prompt, name: candidate.object.name, label: candidate.state.label } : null,
|
||||
};
|
||||
}
|
||||
export type HudSnapshot = ReturnType<typeof currentHudSnapshot>;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { THOR_DISPLAYS, THOR_UI_PIXELS_PER_INCH } from "./thorDisplayProfile";
|
||||
|
||||
describe("Thor physical display sizing", () => {
|
||||
it("uses millimetres for relative panel size, with equal physical touch targets", () => {
|
||||
const { top, bottom } = THOR_DISPLAYS;
|
||||
expect(top.widthMm).toBeCloseTo(132.83, 1);
|
||||
expect(bottom.widthMm).toBeCloseTo(75.12, 1);
|
||||
expect(bottom.cssWidth / top.cssWidth).toBeCloseTo(bottom.widthMm / top.widthMm, 2);
|
||||
expect(bottom.deviceScaleFactor).toBeGreaterThan(top.deviceScaleFactor);
|
||||
for (const panel of [top, bottom]) {
|
||||
expect(44 * panel.deviceScaleFactor / panel.pixelsPerInch * 25.4).toBeCloseTo(44 / THOR_UI_PIXELS_PER_INCH * 25.4);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/** AYN Thor panel dimensions, at a common physical UI density (160 CSS px/in). */
|
||||
export const THOR_UI_PIXELS_PER_INCH = 160;
|
||||
export function physicalDisplayProfile(width: number, height: number, diagonalInches: number) {
|
||||
const pixelsPerInch = Math.hypot(width, height) / diagonalInches;
|
||||
return {
|
||||
width, height, diagonalInches, pixelsPerInch,
|
||||
widthMm: width / pixelsPerInch * 25.4,
|
||||
heightMm: height / pixelsPerInch * 25.4,
|
||||
cssWidth: Math.round(width / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
|
||||
cssHeight: Math.round(height / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
|
||||
deviceScaleFactor: pixelsPerInch / THOR_UI_PIXELS_PER_INCH,
|
||||
};
|
||||
}
|
||||
export const THOR_DISPLAYS = {
|
||||
top: physicalDisplayProfile(1920, 1080, 6),
|
||||
bottom: physicalDisplayProfile(1240, 1080, 3.92),
|
||||
};
|
||||
Reference in new issue
Block a user