194 lines
8.5 KiB
TypeScript
194 lines
8.5 KiB
TypeScript
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 (
|
||
<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>A</b> Select</span>
|
||
<span><b>B</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[]) => {
|
||
postFrontendCommand({ name: "launchGame", bossIds });
|
||
}, [postFrontendCommand]);
|
||
|
||
useEffect(() => {
|
||
const channel = createDualScreenChannel();
|
||
if (!channel) return;
|
||
channelRef.current = channel;
|
||
const sentControllerIds = new Set<string>();
|
||
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<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 /></Suspense>
|
||
: surface.notice === "Linking upper display…"
|
||
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
|
||
: <FrontEnd onLaunch={launchGame} />}
|
||
</main>
|
||
);
|
||
}
|