Files
i-want-to-heal-mmo/src/platform/BottomDisplayApp.tsx
T
2026-07-19 18:48:28 -04:00

273 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { emitControllerToken, isControllerDispatchActive, subscribeControllerMovement, subscribeControllerToken } from "../input/controller";
import { useGameStore } from "../game/store";
import { useFrontendStore } from "../frontend/store";
import type { AppScreen } from "../frontend/types";
import { FrontEnd } from "../components/FrontEnd";
import { createDualScreenChannel, type DualScreenMessage, type FrontendCommand, type GameCommand } from "./dualScreenSync";
import type { BossId } from "../game/types";
import type { DifficultySlug } from "../game/progression/loot";
import { useForcedThorDisplays } from "./useThorDualScreen";
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp";
import type { RoguelikePvpMatchConfig } from "../game/roguelikePvp";
const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33;
function screenTitle(screen: AppScreen) {
switch (screen) {
case "login": return "Sign in or continue offline";
case "saves": return "Choose hunter save";
case "home": return "Choose expedition";
case "class-help": return "Class field guide";
case "profile": return "Hunter profile";
case "gear": return "Gear upgrade";
case "appearance": return "Appearance Lab";
case "settings": return "Field settings";
case "mode": return "Prepare encounter";
case "game": return "Field console";
}
}
function CompanionStandby({ screen, hunterName, notice }: {
screen: AppScreen;
hunterName: string | null;
notice: string;
}) {
return (
<section className="display bottom-display companion-standby" aria-label="Thor context display">
<header>
<span>IH</span>
<div><small>AYN Thor · Context display</small><strong>I Want To Heal</strong></div>
</header>
<main>
<small>Current task</small>
<h1>{screenTitle(screen)}</h1>
<p>{hunterName ? `${hunterName}'s field console is linked to the upper display.` : "Upper display owns primary navigation. Lower display remains linked and ready."}</p>
{notice && <em>{notice}</em>}
</main>
<footer>
<span><b></b> Navigate</span>
<span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>
<span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>
</footer>
</section>
);
}
export function BottomDisplayApp() {
useForcedThorDisplays();
const channelRef = useRef<BroadcastChannel | null>(null);
const [surface, setSurface] = useState<{ screen: AppScreen; hunterName: string | null; notice: string }>({
screen: "login",
hunterName: null,
notice: "Linking upper display…",
});
const postFrontendCommand = useCallback((command: FrontendCommand) => {
if (isControllerDispatchActive()) return;
channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage);
}, []);
const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug, pvpMatch?: HockeyPvpMatchConfig | RoguelikePvpMatchConfig) => {
postFrontendCommand({ name: "launchGame", bossIds, difficultySlug, pvpMatch });
}, [postFrontendCommand]);
useEffect(() => {
const channel = createDualScreenChannel();
if (!channel) return;
channelRef.current = channel;
const sentControllerIds = new Set<string>();
let controllerSequence = 0;
let receivingControllerEcho = false;
let latestMovement = { moveX: 0, moveY: 0, lookX: 0, lookY: 0 };
const movementPublisher = createRateLimitedPublisher(() => {
channel.postMessage({
type: "controller-motion",
movement: { ...latestMovement },
} satisfies DualScreenMessage);
}, CONTROLLER_MOTION_SYNC_INTERVAL_MS);
const announceReady = () => channel.postMessage({ type: "companion-ready" } satisfies DualScreenMessage);
const announceClosing = () => {
movementPublisher.cancel();
channel.postMessage({ type: "companion-closing" } satisfies DualScreenMessage);
};
const postCommand = (command: GameCommand) => channel.postMessage({ type: "game-command", command } satisfies DualScreenMessage);
const postFrontend = (command: FrontendCommand) => {
if (!isControllerDispatchActive()) channel.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage);
};
useFrontendStore.setState({
restoreSession: () => Promise.resolve(false),
signIn: (username, password) => {
postFrontend({ name: "signIn", username, password });
return Promise.resolve(false);
},
createAccount: (username, password) => {
postFrontend({ name: "createAccount", username, password });
return Promise.resolve(false);
},
continueOffline: () => postFrontend({ name: "continueOffline" }),
signOut: () => postFrontend({ name: "signOut" }),
navigate: (screen) => postFrontend({ name: "navigate", screen }),
selectSlot: (slotId) => postFrontend({ name: "selectSlot", slotId }),
createSlot: (slotId, hunterName) => {
postFrontend({ name: "createSlot", slotId, hunterName });
return false;
},
playSlot: (slotId) => postFrontend({ name: "playSlot", slotId }),
deleteSlot: (slotId) => postFrontend({ name: "deleteSlot", slotId }),
copySlot: (sourceId, targetId) => postFrontend({ name: "copySlot", sourceId, targetId }),
uploadSlot: (slotId) => {
postFrontend({ name: "uploadSlot", slotId });
return Promise.resolve(false);
},
downloadSlot: (slotId) => {
postFrontend({ name: "downloadSlot", slotId });
return Promise.resolve();
},
selectMode: (mode) => postFrontend({ name: "selectMode", mode }),
selectBoss: (bossId) => postFrontend({ name: "selectBoss", bossId }),
selectDifficulty: (difficultySlug) => postFrontend({ name: "selectDifficulty", difficultySlug }),
selectGearOwner: (ownerId) => postFrontend({ name: "selectGearOwner", ownerId }),
selectGearSlot: (slotId) => postFrontend({ name: "selectGearSlot", slotId }),
selectGearWorkshopMode: (mode) => postFrontend({ name: "selectGearWorkshopMode", mode }),
selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }),
selectPassiveAbility: (abilityId) => postFrontend({ name: "selectPassiveAbility", abilityId }),
selectPassiveInfusion: (passiveId) => postFrontend({ name: "selectPassiveInfusion", passiveId }),
openClassHelp: () => postFrontend({ name: "openClassHelp" }),
selectGuideClass: (classId) => postFrontend({ name: "selectGuideClass", classId }),
selectGuideAbility: (abilityId) => postFrontend({ name: "selectGuideAbility", abilityId }),
selectProfileCollectionView: (view) => postFrontend({ name: "selectProfileCollectionView", view }),
selectProfileGroup: (groupId) => postFrontend({ name: "selectProfileGroup", groupId }),
selectProfileStat: (statId) => postFrontend({ name: "selectProfileStat", statId }),
openAppearanceLab: () => postFrontend({ name: "openAppearanceLab" }),
selectAppearanceClass: (classId) => postFrontend({ name: "selectAppearanceClass", classId }),
updateAppearanceDraft: (appearance) => postFrontend({ name: "updateAppearanceDraft", appearance }),
resetAppearanceDraft: () => postFrontend({ name: "resetAppearanceDraft" }),
saveAppearanceDraft: () => {
postFrontend({ name: "saveAppearanceDraft" });
return false;
},
closeAppearanceLab: () => postFrontend({ name: "closeAppearanceLab" }),
setAppearancePreviewMode: (mode) => postFrontend({ name: "setAppearancePreviewMode", mode }),
setAppearancePreviewAnimation: (animation) => postFrontend({ name: "setAppearancePreviewAnimation", animation }),
upgradeSelectedGear: () => {
postFrontend({ name: "upgradeSelectedGear" });
return false;
},
equipSelectedInfusion: () => {
postFrontend({ name: "equipSelectedInfusion" });
return false;
},
equipPassiveInfusion: (passiveId) => {
postFrontend({ name: "equipPassiveInfusion", passiveId });
return false;
},
selectHealerClass: (classId) => postFrontend({ name: "selectHealerClass", classId }),
updateSetting: (key, value) => postFrontend({ name: "updateSetting", key, value }),
});
useGameStore.setState({
startEncounter: () => postCommand({ name: "startEncounter" }),
restart: () => postCommand({ name: "restart" }),
castAbility: (abilityId) => {
postCommand({ name: "castAbility", abilityId });
return false;
},
selectMember: (memberId) => postCommand({ name: "selectMember", memberId }),
cycleMember: (direction) => postCommand({ name: "cycleMember", direction }),
setActiveTab: (tab) => postCommand({ name: "setActiveTab", tab }),
selectItem: (itemId) => postCommand({ name: "selectItem", itemId }),
setPaused: (paused) => postCommand({ name: "setPaused", paused }),
setPauseSelection: (selection) => postCommand({ name: "setPauseSelection", selection }),
setSelectedRunBuff: (buffId) => postCommand({ name: "setSelectedRunBuff", buffId }),
chooseRunBuff: (buffId) => {
postCommand({ name: "chooseRunBuff", buffId });
return false;
},
continueRoguelikeRound: () => {
postCommand({ name: "continueRoguelikeRound" });
return false;
},
startRogueTrialsEndless: () => {
postCommand({ name: "startRogueTrialsEndless" });
return false;
},
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
setHockeyPvpPostMatchSelection: (selection) => postCommand({ name: "setHockeyPvpPostMatchSelection", selection }),
selectRoguelikePvpBuff: (buffId) => postCommand({ name: "selectRoguelikePvpBuff", buffId }),
selectRoguelikePvpCurse: (curseId) => postCommand({ name: "selectRoguelikePvpCurse", curseId }),
setRoguelikePvpDraftStep: (step) => postCommand({ name: "setRoguelikePvpDraftStep", step }),
submitRoguelikePvpDraft: () => {
postCommand({ name: "submitRoguelikePvpDraft" });
return false;
},
dispatchRpgAction: (action) => {
postCommand({ name: "dispatchRpgAction", action });
return false;
},
setRpgFocusId: (focusId) => postCommand({ name: "setRpgFocusId", focusId }),
cycleRpgFocus: (direction) => postCommand({ name: "cycleRpgFocus", direction }),
moveRpgFocus: (direction) => postCommand({ name: "moveRpgFocus", direction }),
});
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "authoritative-ready") {
channel.postMessage({ type: "companion-ready" } satisfies DualScreenMessage);
return;
}
if (event.data.type === "controller-echo") {
if (sentControllerIds.delete(event.data.id)) return;
receivingControllerEcho = true;
emitControllerToken(event.data.event);
receivingControllerEcho = false;
return;
}
if (event.data.type === "app-state") {
const { screen, hunterName, notice, frontend, game } = event.data;
setSurface((current) => current.screen === screen
&& current.hunterName === hunterName
&& current.notice === notice
? current
: { screen, hunterName, notice });
if (frontend) useFrontendStore.setState(frontend);
if (game) useGameStore.setState(game);
}
};
const unsubscribeToken = subscribeControllerToken((event) => {
if (receivingControllerEcho) return;
controllerSequence += 1;
const id = `bottom-${controllerSequence}`;
sentControllerIds.add(id);
channel.postMessage({ type: "controller-token", id, event } satisfies DualScreenMessage);
});
const unsubscribeMovement = subscribeControllerMovement((movement) => {
latestMovement = movement;
movementPublisher.request();
});
window.addEventListener("pagehide", announceClosing);
window.addEventListener("pageshow", announceReady);
announceReady();
return () => {
unsubscribeToken();
unsubscribeMovement();
window.removeEventListener("pagehide", announceClosing);
window.removeEventListener("pageshow", announceReady);
movementPublisher.dispose();
announceClosing();
channelRef.current = null;
channel.close();
};
}, []);
return (
<main className="bottom-display-root">
{surface.screen === "game"
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} onHockeyPvpAction={(action) => postFrontendCommand({ name: "hockeyPvpPostMatch", action })} /></Suspense>
: surface.notice === "Linking upper display…"
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
: <FrontEnd onLaunch={launchGame} />}
</main>
);
}