273 lines
13 KiB
TypeScript
273 lines
13 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 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>
|
||
);
|
||
}
|