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 { useForcedThorDisplays } from "./useThorDualScreen"; import { createRateLimitedPublisher } from "./rateLimitedPublisher"; 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 "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[]) => { postFrontendCommand({ name: "launchGame", bossIds }); }, [postFrontendCommand]); useEffect(() => { const channel = createDualScreenChannel(); if (!channel) return; channelRef.current = channel; const sentControllerIds = new Set(); let controllerSequence = 0; let receivingControllerEcho = false; let latestMovement = { x: 0, y: 0 }; const movementPublisher = createRateLimitedPublisher(() => { channel.postMessage({ type: "controller-motion", movement: { x: latestMovement.x, y: latestMovement.y }, } 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({ 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 }), downloadSlot: (slotId) => postFrontend({ name: "downloadSlot", slotId }), selectMode: (mode) => postFrontend({ name: "selectMode", mode }), selectBoss: (bossId) => postFrontend({ name: "selectBoss", bossId }), 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 }), }); 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" ? }> : surface.notice === "Linking upper display…" ? : }
); }