Release v0.1.1 2026-07-10

This commit is contained in:
Warren H
2026-07-10 23:47:37 -04:00
parent 537a311f52
commit 6726c600e4
40 changed files with 2987 additions and 192 deletions
+193
View File
@@ -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>
);
}