Release v0.1.1 2026-07-10
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user