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"; 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 "profile": return "Hunter profile"; case "gear": return "Gear upgrade"; 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 (
IH
AYN Thor · Context displayI Want To Heal
Current task

{screenTitle(screen)}

{hunterName ? `${hunterName}'s field console is linked to the upper display.` : "Upper display owns primary navigation. Lower display remains linked and ready."}

{notice && {notice}}
); } export function BottomDisplayApp() { useForcedThorDisplays(); const channelRef = useRef(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) => { postFrontendCommand({ name: "launchGame", bossIds, difficultySlug }); }, [postFrontendCommand]); useEffect(() => { const channel = createDualScreenChannel(); if (!channel) return; channelRef.current = channel; const sentControllerIds = new Set(); 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(); }, 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 }), 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 }), }); channel.onmessage = (event: MessageEvent) => { 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 (
{surface.screen === "game" ? }> postFrontendCommand({ name: "exitGame" })} /> : surface.notice === "Linking upper display…" ? : }
); }