Release v0.1.1 2026-07-10
This commit is contained in:
+10
@@ -6,6 +6,8 @@ import { useActiveHunter, useFrontendStore } from "./frontend/store";
|
||||
import { useGameStore } from "./game/store";
|
||||
import type { BossId } from "./game/types";
|
||||
import { useActionBindings, useGameLoop } from "./game/useGameLoop";
|
||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
||||
|
||||
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
||||
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
||||
@@ -20,6 +22,8 @@ function GameLoadingScreen() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useForcedThorDisplays();
|
||||
useAuthoritativeDualScreenSync();
|
||||
useGameLoop();
|
||||
const screen = useFrontendStore((state) => state.screen);
|
||||
const hunter = useActiveHunter();
|
||||
@@ -45,6 +49,12 @@ export default function App() {
|
||||
navigate("game");
|
||||
}, [hunter, navigate, touchActiveSave]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDualScreenLaunch = (event: Event) => launchGame((event as CustomEvent<readonly BossId[]>).detail);
|
||||
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
||||
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
||||
}, [launchGame]);
|
||||
|
||||
useActionBindings(screen === "game", leaveGame);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
|
||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>("top");
|
||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() =>
|
||||
new URLSearchParams(window.location.search).get("display") === "bottom" ? "bottom" : "top"
|
||||
);
|
||||
const activeSurfaceRef = useRef(activeSurface);
|
||||
activeSurfaceRef.current = activeSurface;
|
||||
|
||||
useEffect(() => {
|
||||
if (!document.documentElement.classList.contains("native-platform")) return;
|
||||
let frame = 0;
|
||||
let selectHeld = false;
|
||||
const dedicatedSurface = new URLSearchParams(window.location.search).has("display");
|
||||
if (dedicatedSurface) return;
|
||||
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
|
||||
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
|
||||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||
if (token === "Button8" && !repeat) toggle();
|
||||
});
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Tab" || event.repeat) return;
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
};
|
||||
const poll = () => {
|
||||
const held = navigator.getGamepads?.()[0]?.buttons[8]?.pressed ?? false;
|
||||
if (held && !selectHeld) toggle();
|
||||
selectHeld = held;
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
frame = requestAnimationFrame(poll);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
unsubscribeSurface();
|
||||
cancelAnimationFrame(frame);
|
||||
unsubscribeController();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
+43
-10
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } from "../frontend/data";
|
||||
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
@@ -51,15 +51,24 @@ function ControllerLegend({ back = false }: { back?: boolean }) {
|
||||
|
||||
function LoginScreen() {
|
||||
const signIn = useFrontendStore((state) => state.signIn);
|
||||
const createAccount = useFrontendStore((state) => state.createAccount);
|
||||
const continueOffline = useFrontendStore((state) => state.continueOffline);
|
||||
const notice = useFrontendStore((state) => state.notice);
|
||||
const [hunterId, setHunterId] = useState("wayfinder");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "sign-in", run: () => signIn(hunterId) },
|
||||
{ id: "username", run: () => usernameRef.current?.focus() },
|
||||
{ id: "password", run: () => passwordRef.current?.focus() },
|
||||
{ id: "sign-in", run: () => { void signIn(username, password); } },
|
||||
{ id: "create-account", run: () => { void createAccount(username, password); } },
|
||||
{ id: "offline", run: continueOffline },
|
||||
], [continueOffline, hunterId, signIn]);
|
||||
], [continueOffline, createAccount, password, signIn, username]);
|
||||
const controller = useMenuController(actions);
|
||||
|
||||
const submitSignIn = () => { void signIn(username, password); };
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
top={
|
||||
@@ -71,17 +80,41 @@ function LoginScreen() {
|
||||
<h1>Keep everyone standing.</h1>
|
||||
<p>Your save always lives on this device. Sign in only when you want a second copy for PC ↔ AYN Thor handoff.</p>
|
||||
</div>
|
||||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); signIn(hunterId); }}>
|
||||
<label htmlFor="hunter-id">Hunter ID</label>
|
||||
<input id="hunter-id" value={hunterId} onChange={(event) => setHunterId(event.target.value)} autoComplete="username" />
|
||||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input
|
||||
ref={usernameRef}
|
||||
id="account-username"
|
||||
className={controller.focusedId === "username" ? "is-controller-focused" : ""}
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
onFocus={() => controller.focus("username")}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="account-password">Password</label>
|
||||
<input
|
||||
ref={passwordRef}
|
||||
id="account-password"
|
||||
className={controller.focusedId === "password" ? "is-controller-focused" : ""}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onFocus={() => controller.focus("password")}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
|
||||
<span>Sign in & sync</span><small>Online saves enabled</small>
|
||||
</FocusButton>
|
||||
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
|
||||
<span>Create account</span><small>Required for first sync</small>
|
||||
</FocusButton>
|
||||
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
|
||||
<span>Continue with offline save</span><small>No account required</small>
|
||||
</FocusButton>
|
||||
</form>
|
||||
{notice && <div className="front-notice">{notice}</div>}
|
||||
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
|
||||
<ControllerLegend />
|
||||
</FrontSurface>
|
||||
}
|
||||
@@ -92,8 +125,8 @@ function LoginScreen() {
|
||||
<span className="context-kicker">How saving works</span>
|
||||
<ol>
|
||||
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
|
||||
<li><b>02</b><span><strong>Sync when ready</strong><small>Upload any slot after signing in.</small></span></li>
|
||||
<li><b>03</b><span><strong>Move devices</strong><small>Download the online copy and overwrite local.</small></span></li>
|
||||
<li><b>02</b><span><strong>Create or sign in</strong><small>Username and password unlock online sync.</small></span></li>
|
||||
<li><b>03</b><span><strong>Move devices</strong><small>Sign in, then upload or download an online copy.</small></span></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="device-route"><span>PC</span><i>↔</i><b>ONLINE COPY</b><i>↔</i><span>THOR</span></div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useAnimations, useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import { getControllerMovement } from "../input/controller";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { useGameStore } from "../game/store";
|
||||
import type { MemberId, PulseKind } from "../game/types";
|
||||
@@ -9,6 +10,12 @@ import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
|
||||
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
|
||||
const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href;
|
||||
const SPIDER_TEXTURE_URLS: Record<string, string> = {
|
||||
"Spinnen_Bein_tex_2.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_2.jpg", import.meta.url).href,
|
||||
"SH3.png": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/SH3.png", import.meta.url).href,
|
||||
"Spinnen_Bein_tex_COLOR_.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_COLOR_.jpg", import.meta.url).href,
|
||||
"haar_detail_NRM.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/haar_detail_NRM.jpg", import.meta.url).href,
|
||||
};
|
||||
const DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href;
|
||||
const PARTY_MODEL_URLS: Record<MemberId, string> = {
|
||||
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
|
||||
@@ -41,6 +48,11 @@ const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
|
||||
orin: "Spellcast_Shoot",
|
||||
vale: "Dualwield_Melee_Attack_Chop",
|
||||
};
|
||||
const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
|
||||
const angle = (index / 10) * Math.PI * 2;
|
||||
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
|
||||
});
|
||||
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function encounterBossAt(state: GameStoreState, bossIndex: number) {
|
||||
@@ -59,6 +71,13 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string)
|
||||
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
|
||||
}
|
||||
|
||||
const configureSpiderLoader: NonNullable<Parameters<typeof useGLTF>[3]> = (loader) => {
|
||||
loader.manager.setURLModifier((url) => {
|
||||
const fileName = url.slice(url.lastIndexOf("/") + 1);
|
||||
return SPIDER_TEXTURE_URLS[fileName] ?? url;
|
||||
});
|
||||
};
|
||||
|
||||
type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
|
||||
|
||||
type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
|
||||
@@ -208,11 +227,24 @@ function PartyCharacterModel({
|
||||
}
|
||||
|
||||
function Arena() {
|
||||
const columns = useMemo(() => {
|
||||
return Array.from({ length: 10 }, (_, index) => {
|
||||
const angle = (index / 10) * Math.PI * 2;
|
||||
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
|
||||
const pillarInstances = useRef<THREE.InstancedMesh>(null);
|
||||
const torchInstances = useRef<THREE.InstancedMesh>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const pillars = pillarInstances.current;
|
||||
const torches = torchInstances.current;
|
||||
if (!pillars || !torches) return;
|
||||
const matrix = new THREE.Matrix4();
|
||||
ARENA_COLUMNS.forEach(([x, z], index) => {
|
||||
matrix.makeTranslation(x, 1.1, z - 1);
|
||||
pillars.setMatrixAt(index, matrix);
|
||||
matrix.makeTranslation(x, 2.6, z - 1);
|
||||
torches.setMatrixAt(index, matrix);
|
||||
torches.setColorAt(index, ARENA_TORCH_COLORS[index % ARENA_TORCH_COLORS.length]);
|
||||
});
|
||||
pillars.instanceMatrix.needsUpdate = true;
|
||||
torches.instanceMatrix.needsUpdate = true;
|
||||
if (torches.instanceColor) torches.instanceColor.needsUpdate = true;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -229,19 +261,16 @@ function Arena() {
|
||||
<circleGeometry args={[2.1, 48]} />
|
||||
<meshStandardMaterial color="#27342d" roughness={1} />
|
||||
</mesh>
|
||||
{columns.map(([x, z], index) => (
|
||||
<group key={index} position={[x, 0, z - 1]}>
|
||||
<mesh castShadow receiveShadow position={[0, 1.1, 0]}>
|
||||
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
|
||||
<meshStandardMaterial color="#26342f" roughness={0.8} />
|
||||
</mesh>
|
||||
<pointLight color={index % 2 ? "#dd7b38" : "#6fc9ba"} intensity={2.2} distance={5} position={[0, 2.7, 0]} />
|
||||
<mesh position={[0, 2.6, 0]}>
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial color={index % 2 ? "#ff9a4f" : "#77ddce"} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
<instancedMesh ref={pillarInstances} args={[undefined, undefined, ARENA_COLUMNS.length]} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
|
||||
<meshStandardMaterial color="#26342f" roughness={0.8} />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={torchInstances} args={[undefined, undefined, ARENA_COLUMNS.length]}>
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial />
|
||||
</instancedMesh>
|
||||
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
|
||||
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
|
||||
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
|
||||
</group>
|
||||
);
|
||||
@@ -365,11 +394,9 @@ function PlayerCharacter() {
|
||||
if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) {
|
||||
inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
|
||||
inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
|
||||
const gamepad = navigator.getGamepads?.()[0];
|
||||
if (gamepad) {
|
||||
inputX += Math.abs(gamepad.axes[0] ?? 0) > 0.18 ? gamepad.axes[0] : 0;
|
||||
inputZ += Math.abs(gamepad.axes[1] ?? 0) > 0.18 ? gamepad.axes[1] : 0;
|
||||
}
|
||||
const controller = getControllerMovement();
|
||||
inputX += controller.x;
|
||||
inputZ += controller.y;
|
||||
}
|
||||
const length = Math.hypot(inputX, inputZ);
|
||||
if (length > 0.05) {
|
||||
@@ -587,7 +614,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
||||
const defeated = bossHp <= 0;
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const gltf = useGLTF(config.url, false, true);
|
||||
const gltf = useGLTF(config.url, false, true, kind === "vexa" ? configureSpiderLoader : undefined);
|
||||
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||
const { actions } = useAnimations(gltf.animations, model);
|
||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StorageAdapter } from "./saveRepository";
|
||||
import { AccountRepository } from "./accountRepository";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => { data.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
||||
const testHasher = async (password: string, salt: string) => {
|
||||
const checksum = [...password].reduce((total, character) => total + character.charCodeAt(0), 0);
|
||||
return `derived:${salt}:${checksum}`;
|
||||
};
|
||||
|
||||
describe("AccountRepository", () => {
|
||||
it("requires both a username and password", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
|
||||
await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
await expect(repository.create("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
await expect(repository.authenticate("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
});
|
||||
|
||||
it("requires account creation before sign-in", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
|
||||
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" });
|
||||
await expect(repository.create("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
|
||||
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
|
||||
});
|
||||
|
||||
it("rejects an incorrect password and duplicate account names", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
await repository.create("Wayfinder", "correct");
|
||||
|
||||
await expect(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" });
|
||||
await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" });
|
||||
});
|
||||
|
||||
it("persists only a derived password verifier", async () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new AccountRepository(storage, testHasher, () => "unique-salt");
|
||||
await repository.create("healer", "plaintext-secret");
|
||||
|
||||
const persisted = storage.getItem("i-want-to-heal:accounts:v1") ?? "";
|
||||
expect(persisted).toContain("derived:unique-salt:");
|
||||
expect(persisted).not.toContain("plaintext-secret");
|
||||
expect(JSON.parse(persisted).healer).not.toHaveProperty("password");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { StorageAdapter } from "./saveRepository";
|
||||
|
||||
const ACCOUNTS_KEY = "i-want-to-heal:accounts:v1";
|
||||
const PASSWORD_ITERATIONS = 120_000;
|
||||
|
||||
interface AccountRecord {
|
||||
username: string;
|
||||
salt: string;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
type AccountMap = Record<string, AccountRecord>;
|
||||
type PasswordHasher = (password: string, salt: string) => Promise<string>;
|
||||
|
||||
export type AccountResult =
|
||||
| { ok: true; username: string }
|
||||
| { ok: false; reason: "missing-credentials" | "account-exists" | "account-not-found" | "invalid-password" | "storage-unavailable" };
|
||||
|
||||
const fallbackMemory = new Map<string, string>();
|
||||
const fallbackStorage: StorageAdapter = {
|
||||
getItem: (key) => fallbackMemory.get(key) ?? null,
|
||||
setItem: (key, value) => { fallbackMemory.set(key, value); },
|
||||
};
|
||||
|
||||
function browserStorage(): StorageAdapter {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") return localStorage;
|
||||
} catch {
|
||||
// Android WebView can deny storage before its host is ready.
|
||||
}
|
||||
return fallbackStorage;
|
||||
}
|
||||
|
||||
function encodeBytes(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function hashPassword(password: string, salt: string) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits({
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
salt: decodeBytes(salt),
|
||||
iterations: PASSWORD_ITERATIONS,
|
||||
}, key, 256);
|
||||
return encodeBytes(new Uint8Array(bits));
|
||||
}
|
||||
|
||||
function randomSalt() {
|
||||
const salt = new Uint8Array(16);
|
||||
crypto.getRandomValues(salt);
|
||||
return encodeBytes(salt);
|
||||
}
|
||||
|
||||
function canonicalUsername(username: string) {
|
||||
return username.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function parseAccounts(raw: string | null): AccountMap {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AccountMap;
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local prototype account registry. Passwords are salted and derived before
|
||||
* persistence; replace this adapter with the server authentication API when
|
||||
* remote sync leaves local prototype storage.
|
||||
*/
|
||||
export class AccountRepository {
|
||||
constructor(
|
||||
private readonly storage: StorageAdapter = browserStorage(),
|
||||
private readonly hasher: PasswordHasher = hashPassword,
|
||||
private readonly createSalt: () => string = randomSalt,
|
||||
) {}
|
||||
|
||||
async create(usernameInput: string, password: string): Promise<AccountResult> {
|
||||
const username = usernameInput.trim();
|
||||
const canonical = canonicalUsername(username);
|
||||
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
|
||||
|
||||
const accounts = this.read();
|
||||
if (accounts[canonical]) return { ok: false, reason: "account-exists" };
|
||||
|
||||
try {
|
||||
const salt = this.createSalt();
|
||||
accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) };
|
||||
this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
|
||||
return { ok: true, username };
|
||||
} catch {
|
||||
return { ok: false, reason: "storage-unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(usernameInput: string, password: string): Promise<AccountResult> {
|
||||
const canonical = canonicalUsername(usernameInput);
|
||||
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
|
||||
|
||||
const account = this.read()[canonical];
|
||||
if (!account) return { ok: false, reason: "account-not-found" };
|
||||
|
||||
try {
|
||||
const passwordHash = await this.hasher(password, account.salt);
|
||||
return passwordHash === account.passwordHash
|
||||
? { ok: true, username: account.username }
|
||||
: { ok: false, reason: "invalid-password" };
|
||||
} catch {
|
||||
return { ok: false, reason: "storage-unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
private read() {
|
||||
return parseAccounts(this.storage.getItem(ACCOUNTS_KEY));
|
||||
}
|
||||
}
|
||||
+84
-5
@@ -1,10 +1,12 @@
|
||||
import { create } from "zustand";
|
||||
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
|
||||
import { SaveRepository } from "./saveRepository";
|
||||
import { AccountRepository, type AccountResult } from "./accountRepository";
|
||||
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
|
||||
|
||||
const repository = new SaveRepository();
|
||||
const accounts = new AccountRepository();
|
||||
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
|
||||
|
||||
function loadSettings(): GameSettings {
|
||||
@@ -24,7 +26,19 @@ function persistSettings(settings: GameSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
interface FrontendState {
|
||||
function accountNotice(result: Extract<AccountResult, { ok: false }>, action: "sign-in" | "create") {
|
||||
switch (result.reason) {
|
||||
case "missing-credentials": return "Enter both username and password.";
|
||||
case "account-exists": return "Account already exists. Sign in with its password.";
|
||||
case "account-not-found": return "Account not found. Create an account before enabling online sync.";
|
||||
case "invalid-password": return "Username or password is incorrect.";
|
||||
case "storage-unavailable": return action === "create"
|
||||
? "Account could not be saved on this device. Continue offline or try again."
|
||||
: "Account could not be verified on this device. Continue offline or try again.";
|
||||
}
|
||||
}
|
||||
|
||||
export interface FrontendState {
|
||||
screen: AppScreen;
|
||||
accountId: string | null;
|
||||
slots: SaveSlotState[];
|
||||
@@ -34,7 +48,8 @@ interface FrontendState {
|
||||
selectedBossId: BossId;
|
||||
settings: GameSettings;
|
||||
notice: string;
|
||||
signIn: (accountId: string) => void;
|
||||
signIn: (username: string, password: string) => Promise<boolean>;
|
||||
createAccount: (username: string, password: string) => Promise<boolean>;
|
||||
continueOffline: () => void;
|
||||
signOut: () => void;
|
||||
navigate: (screen: AppScreen) => void;
|
||||
@@ -70,9 +85,23 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
settings: loadSettings(),
|
||||
notice: "",
|
||||
|
||||
signIn: (rawAccountId) => {
|
||||
const accountId = rawAccountId.trim() || "wayfinder";
|
||||
set({ accountId, slots: repository.list(accountId), screen: "saves", notice: `Online sync connected as ${accountId}.` });
|
||||
signIn: async (username, password) => {
|
||||
const result = await accounts.authenticate(username, password);
|
||||
if (!result.ok) {
|
||||
set({ notice: accountNotice(result, "sign-in") });
|
||||
return false;
|
||||
}
|
||||
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Online sync connected as ${result.username}.` });
|
||||
return true;
|
||||
},
|
||||
createAccount: async (username, password) => {
|
||||
const result = await accounts.create(username, password);
|
||||
if (!result.ok) {
|
||||
set({ notice: accountNotice(result, "create") });
|
||||
return false;
|
||||
}
|
||||
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` });
|
||||
return true;
|
||||
},
|
||||
continueOffline: () => set({ accountId: null, slots: repository.list(null), screen: "saves", notice: "Offline saves ready." }),
|
||||
signOut: () => set({ accountId: null, slots: repository.list(null), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }),
|
||||
@@ -168,6 +197,56 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
clearNotice: () => set({ notice: "" }),
|
||||
}));
|
||||
|
||||
export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "signIn"
|
||||
| "createAccount"
|
||||
| "continueOffline"
|
||||
| "signOut"
|
||||
| "navigate"
|
||||
| "selectSlot"
|
||||
| "createSlot"
|
||||
| "playSlot"
|
||||
| "deleteSlot"
|
||||
| "copySlot"
|
||||
| "uploadSlot"
|
||||
| "downloadSlot"
|
||||
| "selectMode"
|
||||
| "selectBoss"
|
||||
| "selectHealerClass"
|
||||
| "updateActiveHealerInventory"
|
||||
| "updateSetting"
|
||||
| "touchActiveSave"
|
||||
| "recordBossVictory"
|
||||
| "clearNotice"
|
||||
>;
|
||||
|
||||
export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
const {
|
||||
signIn: _signIn,
|
||||
createAccount: _createAccount,
|
||||
continueOffline: _continueOffline,
|
||||
signOut: _signOut,
|
||||
navigate: _navigate,
|
||||
selectSlot: _selectSlot,
|
||||
createSlot: _createSlot,
|
||||
playSlot: _playSlot,
|
||||
deleteSlot: _deleteSlot,
|
||||
copySlot: _copySlot,
|
||||
uploadSlot: _uploadSlot,
|
||||
downloadSlot: _downloadSlot,
|
||||
selectMode: _selectMode,
|
||||
selectBoss: _selectBoss,
|
||||
selectHealerClass: _selectHealerClass,
|
||||
updateActiveHealerInventory: _updateActiveHealerInventory,
|
||||
updateSetting: _updateSetting,
|
||||
touchActiveSave: _touchActiveSave,
|
||||
recordBossVictory: _recordBossVictory,
|
||||
clearNotice: _clearNotice,
|
||||
...snapshot
|
||||
} = useFrontendStore.getState();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function useActiveHunter(): HunterSave | null {
|
||||
return useFrontendStore((state) => activeSave(state.slots, state.activeSlotId));
|
||||
}
|
||||
|
||||
@@ -173,6 +173,19 @@ describe("Disc Priest combat simulation", () => {
|
||||
expect(paused.castAbility("renew")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not publish unchanged player positions while idle", () => {
|
||||
let updates = 0;
|
||||
const unsubscribe = useGameStore.subscribe(() => { updates += 1; });
|
||||
const start = useGameStore.getState().playerPosition;
|
||||
|
||||
useGameStore.getState().setPlayerPosition([start[0], start[1]]);
|
||||
expect(updates).toBe(0);
|
||||
|
||||
useGameStore.getState().setPlayerPosition([start[0] + 0.25, start[1]]);
|
||||
expect(updates).toBe(1);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("telegraphs, executes, and recovers from a Bull charge", () => {
|
||||
while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1);
|
||||
const telegraph = useGameStore.getState().bossMotion;
|
||||
|
||||
+51
-5
@@ -43,7 +43,7 @@ export interface AdditionalBossState {
|
||||
motion: BossMotionState;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
export interface GameState {
|
||||
bossId: BossId;
|
||||
paused: boolean;
|
||||
pauseSelection: "resume" | "exit";
|
||||
@@ -261,10 +261,20 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
setPaused: (paused) => set({ paused, pauseSelection: "resume" }),
|
||||
togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
|
||||
setPauseSelection: (pauseSelection) => set({ pauseSelection }),
|
||||
setPlayerPosition: (playerPosition) => set((state) => ({
|
||||
playerPosition,
|
||||
partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
|
||||
})),
|
||||
setPlayerPosition: (playerPosition) => set((state) => {
|
||||
const current = state.playerPosition;
|
||||
const partyCurrent = state.partyPositions.aelia;
|
||||
if (current[0] === playerPosition[0]
|
||||
&& current[1] === playerPosition[1]
|
||||
&& partyCurrent[0] === playerPosition[0]
|
||||
&& partyCurrent[1] === playerPosition[1]) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
playerPosition,
|
||||
partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
|
||||
};
|
||||
}),
|
||||
|
||||
castAbility: (abilityId) => {
|
||||
const state = get();
|
||||
@@ -522,6 +532,42 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export type GameSnapshot = Omit<GameState,
|
||||
| "configureHealer"
|
||||
| "startEncounter"
|
||||
| "restart"
|
||||
| "tick"
|
||||
| "castAbility"
|
||||
| "selectMember"
|
||||
| "cycleMember"
|
||||
| "setActiveTab"
|
||||
| "selectItem"
|
||||
| "setPlayerPosition"
|
||||
| "setPaused"
|
||||
| "togglePause"
|
||||
| "setPauseSelection"
|
||||
>;
|
||||
|
||||
export function getGameSnapshot(): GameSnapshot {
|
||||
const {
|
||||
configureHealer: _configureHealer,
|
||||
startEncounter: _startEncounter,
|
||||
restart: _restart,
|
||||
tick: _tick,
|
||||
castAbility: _castAbility,
|
||||
selectMember: _selectMember,
|
||||
cycleMember: _cycleMember,
|
||||
setActiveTab: _setActiveTab,
|
||||
selectItem: _selectItem,
|
||||
setPlayerPosition: _setPlayerPosition,
|
||||
setPaused: _setPaused,
|
||||
togglePause: _togglePause,
|
||||
setPauseSelection: _setPauseSelection,
|
||||
...snapshot
|
||||
} = useGameStore.getState();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns: Record<AbilityId, number>) {
|
||||
return Math.max(0, cooldowns[abilityId] - time);
|
||||
}
|
||||
|
||||
+28
-38
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
import { ABILITY_ORDER } from "./data";
|
||||
import { useGameStore } from "./store";
|
||||
import type { AbilityId } from "./types";
|
||||
@@ -84,43 +85,32 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
let previousButtons: boolean[] = [];
|
||||
const poll = () => {
|
||||
const gamepad = navigator.getGamepads?.()[0];
|
||||
if (gamepad && enabled) {
|
||||
const buttons = gamepad.buttons.map((button) => button.pressed);
|
||||
const store = useGameStore.getState();
|
||||
if (store.paused) {
|
||||
if (buttons[12] && !previousButtons[12]) store.setPauseSelection("resume");
|
||||
if (buttons[13] && !previousButtons[13]) store.setPauseSelection("exit");
|
||||
if ((buttons[1] && !previousButtons[1]) || (buttons[9] && !previousButtons[9])) store.setPaused(false);
|
||||
if (buttons[0] && !previousButtons[0]) {
|
||||
if (store.pauseSelection === "resume") store.setPaused(false);
|
||||
else exitRef.current?.();
|
||||
}
|
||||
} else {
|
||||
for (const [button, ability] of Object.entries(gamepadAbilityMap)) {
|
||||
const index = Number(button);
|
||||
if (buttons[index] && !previousButtons[index]) store.castAbility(ability);
|
||||
}
|
||||
if (buttons[12] && !previousButtons[12]) store.cycleMember(-1);
|
||||
if (buttons[13] && !previousButtons[13]) store.cycleMember(1);
|
||||
if (buttons[8] && !previousButtons[8]) store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
|
||||
if (buttons[9] && !previousButtons[9]) {
|
||||
if (store.phase === "briefing") store.startEncounter();
|
||||
if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||
if (store.phase === "combat") store.setPaused(true);
|
||||
}
|
||||
}
|
||||
previousButtons = buttons;
|
||||
} else {
|
||||
previousButtons = [];
|
||||
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
|
||||
if (!enabled) return;
|
||||
const store = useGameStore.getState();
|
||||
if (store.paused) {
|
||||
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
|
||||
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
|
||||
if (repeat) return;
|
||||
if (token === "Button1" || token === "Button9") store.setPaused(false);
|
||||
if (token === "Button0") {
|
||||
if (store.pauseSelection === "resume") store.setPaused(false);
|
||||
else exitRef.current?.();
|
||||
}
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
frame = requestAnimationFrame(poll);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [enabled]);
|
||||
return;
|
||||
}
|
||||
if (repeat) return;
|
||||
if (token.startsWith("Button")) {
|
||||
const ability = gamepadAbilityMap[Number(token.slice("Button".length))];
|
||||
if (ability && store.phase === "combat") store.castAbility(ability);
|
||||
}
|
||||
if (token === "Button12") store.cycleMember(-1);
|
||||
if (token === "Button13") store.cycleMember(1);
|
||||
if (token === "Button8") store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
|
||||
if (token === "Button9" || (token === "Button0" && store.phase !== "combat")) {
|
||||
if (store.phase === "briefing") store.startEncounter();
|
||||
else if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||
else if (store.phase === "combat") store.setPaused(true);
|
||||
}
|
||||
}), [enabled]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getControllerMovement,
|
||||
resetControllerState,
|
||||
setExternalControllerMovement,
|
||||
subscribeControllerMovement,
|
||||
} from "./controller";
|
||||
|
||||
describe("controller movement normalization", () => {
|
||||
beforeEach(() => resetControllerState());
|
||||
|
||||
it("ignores dead-zone noise and quantizes analog jitter", () => {
|
||||
const updates: Array<{ x: number; y: number }> = [];
|
||||
const unsubscribe = subscribeControllerMovement((movement) => {
|
||||
updates.push({ x: movement.x, y: movement.y });
|
||||
});
|
||||
|
||||
setExternalControllerMovement({ x: 0.08, y: -0.1 });
|
||||
expect(updates).toHaveLength(0);
|
||||
|
||||
setExternalControllerMovement({ x: 0.5001, y: -0.5001 });
|
||||
setExternalControllerMovement({ x: 0.5002, y: -0.5002 });
|
||||
expect(updates).toEqual([{ x: 0.5, y: -0.5 }]);
|
||||
expect(getControllerMovement()).toEqual({ x: 0.5, y: -0.5 });
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
|
||||
export interface ControllerTokenEvent {
|
||||
token: string;
|
||||
repeat: boolean;
|
||||
}
|
||||
|
||||
export interface ControllerMovement {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
type TokenListener = (event: ControllerTokenEvent) => void;
|
||||
type MovementListener = (movement: Readonly<ControllerMovement>) => void;
|
||||
|
||||
const NATIVE_TOKEN_EVENT = "iwt-native-controller";
|
||||
const NATIVE_MOTION_EVENT = "iwt-native-controller-motion";
|
||||
const NATIVE_RESET_EVENT = "iwt-native-controller-reset";
|
||||
const INITIAL_REPEAT_MS = 280;
|
||||
const REPEAT_MS = 90;
|
||||
const AXIS_THRESHOLD = 0.55;
|
||||
const MOVEMENT_AXIS_STEPS = 128;
|
||||
const BUTTON_TOKENS = Array.from({ length: 32 }, (_, index) => `Button${index}`);
|
||||
const NEGATIVE_AXIS_TOKENS = Array.from({ length: 16 }, (_, index) => `Axis${index}-`);
|
||||
const POSITIVE_AXIS_TOKENS = Array.from({ length: 16 }, (_, index) => `Axis${index}+`);
|
||||
|
||||
const listeners = new Set<TokenListener>();
|
||||
const movementListeners = new Set<MovementListener>();
|
||||
const repeatAt = new Map<string, number>();
|
||||
const lastNativeTokenAt = new Map<string, number>();
|
||||
let previousTokens = new Set<string>();
|
||||
let currentTokens = new Set<string>();
|
||||
let movement: ControllerMovement = { x: 0, y: 0 };
|
||||
let stopService: (() => void) | null = null;
|
||||
let dispatchDepth = 0;
|
||||
|
||||
function connectedGamepad() {
|
||||
const gamepads = navigator.getGamepads?.();
|
||||
if (!gamepads) return null;
|
||||
for (let index = 0; index < gamepads.length; index += 1) {
|
||||
if (gamepads[index]) return gamepads[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tokensFor(gamepad: Gamepad, tokens: Set<string>) {
|
||||
tokens.clear();
|
||||
for (let index = 0; index < gamepad.buttons.length; index += 1) {
|
||||
const button = gamepad.buttons[index];
|
||||
if (button.pressed || button.value > 0.65) tokens.add(BUTTON_TOKENS[index] ?? `Button${index}`);
|
||||
}
|
||||
for (let index = 0; index < gamepad.axes.length; index += 1) {
|
||||
const value = gamepad.axes[index];
|
||||
if (value <= -AXIS_THRESHOLD) tokens.add(NEGATIVE_AXIS_TOKENS[index] ?? `Axis${index}-`);
|
||||
if (value >= AXIS_THRESHOLD) tokens.add(POSITIVE_AXIS_TOKENS[index] ?? `Axis${index}+`);
|
||||
}
|
||||
}
|
||||
|
||||
function canRepeat(token: string) {
|
||||
return token === "Button12"
|
||||
|| token === "Button13"
|
||||
|| token === "Button14"
|
||||
|| token === "Button15"
|
||||
|| token.startsWith("Axis0")
|
||||
|| token.startsWith("Axis1");
|
||||
}
|
||||
|
||||
export function emitControllerToken(event: ControllerTokenEvent) {
|
||||
dispatchDepth += 1;
|
||||
try {
|
||||
for (const listener of listeners) listener(event);
|
||||
} finally {
|
||||
dispatchDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function isControllerDispatchActive() {
|
||||
return dispatchDepth > 0;
|
||||
}
|
||||
|
||||
export function subscribeControllerToken(listener: TokenListener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeControllerMovement(listener: MovementListener) {
|
||||
movementListeners.add(listener);
|
||||
return () => {
|
||||
movementListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getControllerMovement(): Readonly<ControllerMovement> {
|
||||
return movement;
|
||||
}
|
||||
|
||||
export function setExternalControllerMovement(next: ControllerMovement) {
|
||||
setExternalControllerAxes(next.x, next.y);
|
||||
}
|
||||
|
||||
function setExternalControllerAxes(nextX: number, nextY: number) {
|
||||
const x = Math.abs(nextX) >= 0.12 ? Math.round(nextX * MOVEMENT_AXIS_STEPS) / MOVEMENT_AXIS_STEPS : 0;
|
||||
const y = Math.abs(nextY) >= 0.12 ? Math.round(nextY * MOVEMENT_AXIS_STEPS) / MOVEMENT_AXIS_STEPS : 0;
|
||||
if (x === movement.x && y === movement.y) return;
|
||||
movement = { x, y };
|
||||
for (const listener of movementListeners) listener(movement);
|
||||
}
|
||||
|
||||
export function resetControllerState() {
|
||||
previousTokens.clear();
|
||||
currentTokens.clear();
|
||||
repeatAt.clear();
|
||||
lastNativeTokenAt.clear();
|
||||
setExternalControllerAxes(0, 0);
|
||||
}
|
||||
|
||||
export function startControllerInput() {
|
||||
if (stopService) return stopService;
|
||||
|
||||
const onNativeToken = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ControllerTokenEvent>).detail;
|
||||
if (!detail?.token) return;
|
||||
const now = performance.now();
|
||||
const lastAt = lastNativeTokenAt.get(detail.token) ?? Number.NEGATIVE_INFINITY;
|
||||
// Some Android controllers report one D-pad edge through both key and hat-axis paths.
|
||||
if (now - lastAt < 24) return;
|
||||
lastNativeTokenAt.set(detail.token, now);
|
||||
emitControllerToken({ token: detail.token, repeat: Boolean(detail.repeat) });
|
||||
};
|
||||
const onNativeMotion = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ControllerMovement>).detail;
|
||||
if (detail) setExternalControllerMovement(detail);
|
||||
};
|
||||
const onReset = () => resetControllerState();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState !== "visible") resetControllerState();
|
||||
};
|
||||
|
||||
window.addEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
||||
window.addEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
||||
window.addEventListener(NATIVE_RESET_EVENT, onReset);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
let frame = 0;
|
||||
let hadConnectedGamepad = false;
|
||||
const poll = (now: number) => {
|
||||
const gamepad = connectedGamepad();
|
||||
if (!gamepad) {
|
||||
// Clear held input once on a disconnect edge, not on every idle frame.
|
||||
if (hadConnectedGamepad) resetControllerState();
|
||||
hadConnectedGamepad = false;
|
||||
} else {
|
||||
hadConnectedGamepad = true;
|
||||
setExternalControllerAxes(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0);
|
||||
tokensFor(gamepad, currentTokens);
|
||||
for (const token of currentTokens) {
|
||||
const pressed = !previousTokens.has(token);
|
||||
const nextRepeat = repeatAt.get(token) ?? 0;
|
||||
if (pressed || (canRepeat(token) && now >= nextRepeat)) {
|
||||
emitControllerToken({ token, repeat: !pressed });
|
||||
repeatAt.set(token, now + (pressed ? INITIAL_REPEAT_MS : REPEAT_MS));
|
||||
}
|
||||
}
|
||||
for (const token of repeatAt.keys()) {
|
||||
if (!currentTokens.has(token)) repeatAt.delete(token);
|
||||
}
|
||||
const scratch = previousTokens;
|
||||
previousTokens = currentTokens;
|
||||
currentTokens = scratch;
|
||||
}
|
||||
frame = window.requestAnimationFrame(poll);
|
||||
};
|
||||
|
||||
// Android key/motion events are authoritative. Browser Gamepad polling remains the fallback.
|
||||
if (!Capacitor.isNativePlatform()) frame = window.requestAnimationFrame(poll);
|
||||
|
||||
stopService = () => {
|
||||
window.removeEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
|
||||
window.removeEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
|
||||
window.removeEventListener(NATIVE_RESET_EVENT, onReset);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
resetControllerState();
|
||||
stopService = null;
|
||||
};
|
||||
return stopService;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { subscribeControllerToken } from "./controller";
|
||||
|
||||
export interface MenuAction {
|
||||
id: string;
|
||||
@@ -68,41 +69,16 @@ export function useMenuController(actions: MenuAction[], options: MenuController
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [confirm, move]);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
let previous: boolean[] = [];
|
||||
let heldDirection: Direction | null = null;
|
||||
let nextRepeatAt = 0;
|
||||
const poll = (now: number) => {
|
||||
const gamepad = navigator.getGamepads?.()[0];
|
||||
if (!gamepad) {
|
||||
previous = [];
|
||||
heldDirection = null;
|
||||
} else {
|
||||
const pressed = gamepad.buttons.map((button) => button.pressed);
|
||||
const stickX = Math.abs(gamepad.axes[0] ?? 0) >= 0.55 ? gamepad.axes[0] : 0;
|
||||
const stickY = Math.abs(gamepad.axes[1] ?? 0) >= 0.55 ? gamepad.axes[1] : 0;
|
||||
const direction: Direction | null = pressed[12] || stickY < 0 ? "up"
|
||||
: pressed[13] || stickY > 0 ? "down"
|
||||
: pressed[14] || stickX < 0 ? "left"
|
||||
: pressed[15] || stickX > 0 ? "right"
|
||||
: null;
|
||||
if (direction && (direction !== heldDirection || now >= nextRepeatAt)) {
|
||||
move(direction);
|
||||
nextRepeatAt = direction === heldDirection ? now + 120 : now + 360;
|
||||
heldDirection = direction;
|
||||
} else if (!direction) {
|
||||
heldDirection = null;
|
||||
}
|
||||
if (pressed[0] && !previous[0]) confirm();
|
||||
if (pressed[1] && !previous[1]) backRef.current?.();
|
||||
previous = pressed;
|
||||
}
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
frame = requestAnimationFrame(poll);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [confirm, move]);
|
||||
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
|
||||
const direction: Direction | null = token === "Button12" || token === "Axis1-" ? "up"
|
||||
: token === "Button13" || token === "Axis1+" ? "down"
|
||||
: token === "Button14" || token === "Axis0-" ? "left"
|
||||
: token === "Button15" || token === "Axis0+" ? "right"
|
||||
: null;
|
||||
if (direction) move(direction);
|
||||
else if (!repeat && token === "Button0") confirm();
|
||||
else if (!repeat && token === "Button1") backRef.current?.();
|
||||
}), [confirm, move]);
|
||||
|
||||
return {
|
||||
focusedId,
|
||||
|
||||
+9
-1
@@ -2,16 +2,24 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import App from "./App";
|
||||
import { BottomDisplayApp } from "./platform/BottomDisplayApp";
|
||||
import { startControllerInput } from "./input/controller";
|
||||
import "./styles.css";
|
||||
|
||||
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
|
||||
const displayMode = new URLSearchParams(window.location.search).get("display");
|
||||
|
||||
if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
|
||||
document.documentElement.classList.add("native-platform");
|
||||
}
|
||||
if (displayMode === "top" || displayMode === "bottom") {
|
||||
document.documentElement.dataset.displaySurface = displayMode;
|
||||
}
|
||||
|
||||
startControllerInput();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
{displayMode === "bottom" ? <BottomDisplayApp /> : <App />}
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AppScreen } from "../frontend/types";
|
||||
import type { FrontendSnapshot } from "../frontend/store";
|
||||
import { useFrontendStore } from "../frontend/store";
|
||||
import { emitControllerToken, setExternalControllerMovement, type ControllerMovement, type ControllerTokenEvent } from "../input/controller";
|
||||
import { getGameSnapshot, type GameSnapshot, useGameStore } from "../game/store";
|
||||
import type { AbilityId, BottomTab, MemberId } from "../game/types";
|
||||
import type { BossId, HealerClassId } from "../game/types";
|
||||
import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types";
|
||||
|
||||
const CHANNEL_NAME = "i-want-to-heal:thor-dual-screen:v1";
|
||||
|
||||
export type GameCommand =
|
||||
| { name: "startEncounter" }
|
||||
| { name: "restart" }
|
||||
| { name: "castAbility"; abilityId: AbilityId }
|
||||
| { name: "selectMember"; memberId: MemberId }
|
||||
| { name: "cycleMember"; direction: 1 | -1 }
|
||||
| { name: "setActiveTab"; tab: BottomTab }
|
||||
| { name: "selectItem"; itemId: string }
|
||||
| { name: "setPaused"; paused: boolean }
|
||||
| { name: "setPauseSelection"; selection: "resume" | "exit" };
|
||||
|
||||
export type FrontendCommand =
|
||||
| { name: "signIn"; username: string; password: string }
|
||||
| { name: "createAccount"; username: string; password: string }
|
||||
| { name: "continueOffline" }
|
||||
| { name: "signOut" }
|
||||
| { name: "navigate"; screen: AppScreen }
|
||||
| { name: "selectSlot"; slotId: SaveSlotId }
|
||||
| { name: "createSlot"; slotId: SaveSlotId; hunterName: string }
|
||||
| { name: "playSlot"; slotId: SaveSlotId }
|
||||
| { name: "deleteSlot"; slotId: SaveSlotId }
|
||||
| { name: "copySlot"; sourceId: SaveSlotId; targetId: SaveSlotId }
|
||||
| { name: "uploadSlot"; slotId: SaveSlotId }
|
||||
| { name: "downloadSlot"; slotId: SaveSlotId }
|
||||
| { name: "selectMode"; mode: GameModeId }
|
||||
| { name: "selectBoss"; bossId: BossId }
|
||||
| { name: "selectHealerClass"; classId: HealerClassId }
|
||||
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
|
||||
| { name: "launchGame"; bossIds: readonly BossId[] };
|
||||
|
||||
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
|
||||
|
||||
export type DualScreenMessage =
|
||||
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: GameSnapshot }
|
||||
| { type: "controller-token"; id: string; event: ControllerTokenEvent }
|
||||
| { type: "controller-echo"; id: string; event: ControllerTokenEvent }
|
||||
| { type: "controller-motion"; movement: ControllerMovement }
|
||||
| { type: "game-command"; command: GameCommand }
|
||||
| { type: "frontend-command"; command: FrontendCommand }
|
||||
| { type: "authoritative-ready" }
|
||||
| { type: "companion-ready" }
|
||||
| { type: "companion-closing" };
|
||||
|
||||
export function createDualScreenChannel() {
|
||||
return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL_NAME);
|
||||
}
|
||||
|
||||
export function executeGameCommand(command: GameCommand) {
|
||||
const game = useGameStore.getState();
|
||||
switch (command.name) {
|
||||
case "startEncounter": game.startEncounter(); break;
|
||||
case "restart": game.restart(); break;
|
||||
case "castAbility": game.castAbility(command.abilityId); break;
|
||||
case "selectMember": game.selectMember(command.memberId); break;
|
||||
case "cycleMember": game.cycleMember(command.direction); break;
|
||||
case "setActiveTab": game.setActiveTab(command.tab); break;
|
||||
case "selectItem": game.selectItem(command.itemId); break;
|
||||
case "setPaused": game.setPaused(command.paused); break;
|
||||
case "setPauseSelection": game.setPauseSelection(command.selection); break;
|
||||
}
|
||||
}
|
||||
|
||||
export function executeFrontendCommand(command: FrontendCommand) {
|
||||
const frontend = useFrontendStore.getState();
|
||||
switch (command.name) {
|
||||
case "signIn": void frontend.signIn(command.username, command.password); break;
|
||||
case "createAccount": void frontend.createAccount(command.username, command.password); break;
|
||||
case "continueOffline": frontend.continueOffline(); break;
|
||||
case "signOut": frontend.signOut(); break;
|
||||
case "navigate": frontend.navigate(command.screen); break;
|
||||
case "selectSlot": frontend.selectSlot(command.slotId); break;
|
||||
case "createSlot": frontend.createSlot(command.slotId, command.hunterName); break;
|
||||
case "playSlot": frontend.playSlot(command.slotId); break;
|
||||
case "deleteSlot": frontend.deleteSlot(command.slotId); break;
|
||||
case "copySlot": frontend.copySlot(command.sourceId, command.targetId); break;
|
||||
case "uploadSlot": frontend.uploadSlot(command.slotId); break;
|
||||
case "downloadSlot": frontend.downloadSlot(command.slotId); break;
|
||||
case "selectMode": frontend.selectMode(command.mode); break;
|
||||
case "selectBoss": frontend.selectBoss(command.bossId); break;
|
||||
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
|
||||
case "updateSetting": frontend.updateSetting(command.key, command.value); break;
|
||||
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: command.bossIds })); break;
|
||||
}
|
||||
}
|
||||
|
||||
export function receiveAuthoritativeMessage(message: DualScreenMessage) {
|
||||
if (message.type === "controller-token") emitControllerToken(message.event);
|
||||
if (message.type === "controller-motion") setExternalControllerMovement(message.movement);
|
||||
if (message.type === "game-command") executeGameCommand(message.command);
|
||||
if (message.type === "frontend-command") executeFrontendCommand(message.command);
|
||||
}
|
||||
|
||||
export function currentGameSnapshot() {
|
||||
return getGameSnapshot();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Capacitor, registerPlugin } from "@capacitor/core";
|
||||
|
||||
interface AndroidDisplay {
|
||||
id: number;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
refreshRate: number;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
interface ThorDualScreenPlugin {
|
||||
getDisplays: () => Promise<{ currentDisplayId: number; displays: AndroidDisplay[] }>;
|
||||
forceBothDisplays: () => Promise<AndroidDisplay & { opened: boolean; topOnActivity: boolean }>;
|
||||
addListener: (eventName: "displayDisconnected", listener: () => void) => Promise<{ remove: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
const plugin = registerPlugin<ThorDualScreenPlugin>("ThorDualScreen");
|
||||
|
||||
export function shouldOwnNativeDisplays() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return Capacitor.isNativePlatform() && params.get("role") !== "presentation";
|
||||
}
|
||||
|
||||
export function forceBothThorDisplays() {
|
||||
return plugin.forceBothDisplays();
|
||||
}
|
||||
|
||||
export function listenForDisplayDisconnect(listener: () => void) {
|
||||
return plugin.addListener("displayDisconnected", listener);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("createRateLimitedPublisher", () => {
|
||||
it("publishes immediately, then coalesces a burst into one trailing update", () => {
|
||||
vi.useFakeTimers();
|
||||
let now = 0;
|
||||
const publish = vi.fn();
|
||||
const limiter = createRateLimitedPublisher(publish, 100, () => now);
|
||||
|
||||
limiter.request();
|
||||
limiter.request();
|
||||
limiter.request();
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
|
||||
now = 99;
|
||||
vi.advanceTimersByTime(99);
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
|
||||
now = 100;
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(publish).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cancels pending work when the owning display unmounts", () => {
|
||||
vi.useFakeTimers();
|
||||
let now = 0;
|
||||
const publish = vi.fn();
|
||||
const limiter = createRateLimitedPublisher(publish, 100, () => now);
|
||||
|
||||
limiter.request();
|
||||
now = 1;
|
||||
limiter.request();
|
||||
limiter.dispose();
|
||||
now = 101;
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can cancel a pending publish and resume for a reconnected display", () => {
|
||||
vi.useFakeTimers();
|
||||
let now = 0;
|
||||
const publish = vi.fn();
|
||||
const limiter = createRateLimitedPublisher(publish, 100, () => now);
|
||||
|
||||
limiter.request();
|
||||
now = 1;
|
||||
limiter.request();
|
||||
limiter.cancel();
|
||||
now = 101;
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
|
||||
limiter.request();
|
||||
expect(publish).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface RateLimitedPublisher {
|
||||
request: () => void;
|
||||
cancel: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces bursty state updates while preserving an immediate leading publish.
|
||||
* The trailing publish always contains current state because `publish` reads it
|
||||
* when the timer fires.
|
||||
*/
|
||||
export function createRateLimitedPublisher(
|
||||
publish: () => void,
|
||||
intervalMs: number,
|
||||
now: () => number = () => performance.now(),
|
||||
): RateLimitedPublisher {
|
||||
let lastPublishedAt = Number.NEGATIVE_INFINITY;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const run = () => {
|
||||
timer = null;
|
||||
if (disposed) return;
|
||||
lastPublishedAt = now();
|
||||
publish();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
return {
|
||||
request: () => {
|
||||
if (disposed) return;
|
||||
const remaining = intervalMs - (now() - lastPublishedAt);
|
||||
if (remaining <= 0) {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
run();
|
||||
return;
|
||||
}
|
||||
if (timer === null) timer = setTimeout(run, remaining);
|
||||
},
|
||||
cancel,
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
cancel();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect } from "react";
|
||||
import { emitControllerToken, setExternalControllerMovement, subscribeControllerToken } from "../input/controller";
|
||||
import { getFrontendSnapshot, useFrontendStore } from "../frontend/store";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { createDualScreenChannel, currentGameSnapshot, receiveAuthoritativeMessage, type DualScreenMessage } from "./dualScreenSync";
|
||||
import { forceBothThorDisplays, listenForDisplayDisconnect, shouldOwnNativeDisplays } from "./nativeDualScreen";
|
||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||
|
||||
const GAME_SYNC_INTERVAL_MS = 100;
|
||||
|
||||
export function useForcedThorDisplays() {
|
||||
useEffect(() => {
|
||||
if (!shouldOwnNativeDisplays()) return;
|
||||
let disposed = false;
|
||||
let listener: { remove: () => Promise<void> } | null = null;
|
||||
const force = () => forceBothThorDisplays().catch(() => {
|
||||
// Native display listener retains force mode and opens when Thor reports panel again.
|
||||
});
|
||||
force();
|
||||
listenForDisplayDisconnect(() => {
|
||||
if (!disposed) force();
|
||||
}).then((handle) => {
|
||||
if (disposed) handle.remove();
|
||||
else listener = handle;
|
||||
}).catch(() => undefined);
|
||||
return () => {
|
||||
disposed = true;
|
||||
listener?.remove();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useAuthoritativeDualScreenSync() {
|
||||
useEffect(() => {
|
||||
const channel = createDualScreenChannel();
|
||||
if (!channel) return;
|
||||
let latest = useFrontendStore.getState();
|
||||
let companionReady = false;
|
||||
let receivingRelayedToken = false;
|
||||
let topTokenSequence = 0;
|
||||
const publish = (includeFrontend: boolean) => {
|
||||
const hunter = latest.activeSlotId
|
||||
? latest.slots.find((slot) => slot.id === latest.activeSlotId)?.local ?? null
|
||||
: null;
|
||||
channel.postMessage({
|
||||
type: "app-state",
|
||||
screen: latest.screen,
|
||||
hunterName: hunter?.hunterName ?? null,
|
||||
notice: latest.notice,
|
||||
frontend: includeFrontend ? getFrontendSnapshot() : undefined,
|
||||
game: latest.screen === "game" ? currentGameSnapshot() : undefined,
|
||||
} satisfies DualScreenMessage);
|
||||
};
|
||||
// Player motion and the simulation can update the same store several times per
|
||||
// frame interval. One 10 Hz companion snapshot is enough for tactical UI and
|
||||
// avoids repeatedly structured-cloning the full game state across displays.
|
||||
const gamePublisher = createRateLimitedPublisher(() => publish(false), GAME_SYNC_INTERVAL_MS);
|
||||
const unsubscribeFrontend = useFrontendStore.subscribe((state) => {
|
||||
latest = state;
|
||||
if (companionReady) publish(true);
|
||||
});
|
||||
const unsubscribeGame = useGameStore.subscribe(() => {
|
||||
if (companionReady && latest.screen === "game") gamePublisher.request();
|
||||
});
|
||||
const unsubscribeController = subscribeControllerToken((controllerEvent) => {
|
||||
if (!companionReady || receivingRelayedToken) return;
|
||||
topTokenSequence += 1;
|
||||
channel.postMessage({
|
||||
type: "controller-echo",
|
||||
id: `top-${topTokenSequence}`,
|
||||
event: controllerEvent,
|
||||
} satisfies DualScreenMessage);
|
||||
});
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === "companion-ready") {
|
||||
companionReady = true;
|
||||
publish(true);
|
||||
} else if (event.data.type === "companion-closing") {
|
||||
companionReady = false;
|
||||
gamePublisher.cancel();
|
||||
setExternalControllerMovement({ x: 0, y: 0 });
|
||||
} else if (event.data.type === "controller-token") {
|
||||
receivingRelayedToken = true;
|
||||
emitControllerToken(event.data.event);
|
||||
receivingRelayedToken = false;
|
||||
channel.postMessage({ ...event.data, type: "controller-echo" } satisfies DualScreenMessage);
|
||||
} else receiveAuthoritativeMessage(event.data);
|
||||
};
|
||||
// Lets a companion that opened first repeat its handshake without sending a
|
||||
// full state snapshot when no second display exists.
|
||||
channel.postMessage({ type: "authoritative-ready" } satisfies DualScreenMessage);
|
||||
return () => {
|
||||
unsubscribeFrontend();
|
||||
unsubscribeGame();
|
||||
unsubscribeController();
|
||||
gamePublisher.dispose();
|
||||
channel.close();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
+66
-2
@@ -1335,6 +1335,67 @@ button:focus-visible {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.native-platform[data-display-surface] .native-display-switch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.native-platform[data-display-surface="top"] .top-display,
|
||||
.native-platform[data-display-surface="top"] .front-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.native-platform[data-display-surface="bottom"] .bottom-display-root,
|
||||
.native-platform[data-display-surface="bottom"] .bottom-display-root > .bottom-display {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.native-platform[data-display-surface="bottom"] .bottom-display-root > .bottom-display {
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.companion-standby {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
padding: 6.5% 7%;
|
||||
background:
|
||||
radial-gradient(circle at 82% 12%, rgba(94, 199, 176, 0.15), transparent 34%),
|
||||
linear-gradient(145deg, #091713, #030807 72%);
|
||||
}
|
||||
|
||||
.companion-standby header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 4%;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.companion-standby header > span {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(232, 200, 114, 0.6);
|
||||
color: var(--gold);
|
||||
font-family: "Cinzel", serif;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.companion-standby header div { display: grid; gap: 2px; }
|
||||
.companion-standby header small,
|
||||
.companion-standby main > small { color: var(--muted); font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; }
|
||||
.companion-standby header strong { font-family: "Cinzel", serif; font-size: 18px; }
|
||||
.companion-standby main { align-self: center; }
|
||||
.companion-standby h1 { margin: 6px 0 8px; font-family: "Cinzel", serif; font-size: clamp(26px, 6cqw, 38px); font-weight: 500; }
|
||||
.companion-standby p { max-width: 440px; margin: 0; color: #9cb0a8; font-size: clamp(12px, 2.6cqw, 16px); line-height: 1.35; }
|
||||
.companion-standby em { display: block; margin-top: 12px; color: var(--gold); font-size: 11px; font-style: normal; }
|
||||
.companion-standby footer { display: flex; gap: 18px; padding-top: 4%; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; text-transform: uppercase; }
|
||||
.companion-standby footer b { color: var(--ink); }
|
||||
|
||||
/* Frontend shell — I Want To Heal */
|
||||
|
||||
.front-surface {
|
||||
@@ -1502,10 +1563,13 @@ button:focus-visible {
|
||||
.login-copy > span { color: var(--teal); font-size: 9px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.login-copy h1 { width: 390px; margin: 8px 0 9px; font-family: "Cinzel", serif; font-size: 36px; font-weight: 500; line-height: 1.05; }
|
||||
.login-copy p { width: 390px; margin: 0; color: #96aaa2; font-size: 13px; line-height: 1.45; }
|
||||
.login-panel { position: absolute; top: 105px; right: 44px; width: 300px; display: grid; gap: 9px; padding: 20px; border: 1px solid rgba(163, 198, 185, 0.22); border-top: 2px solid rgba(232,200,114,0.68); background: linear-gradient(145deg, rgba(14, 31, 26, 0.96), rgba(5, 15, 12, 0.97)); box-shadow: 0 20px 50px rgba(0,0,0,0.35); }
|
||||
.login-panel { position: absolute; top: 68px; right: 44px; width: 300px; display: grid; gap: 6px; padding: 15px 20px; border: 1px solid rgba(163, 198, 185, 0.22); border-top: 2px solid rgba(232,200,114,0.68); background: linear-gradient(145deg, rgba(14, 31, 26, 0.96), rgba(5, 15, 12, 0.97)); box-shadow: 0 20px 50px rgba(0,0,0,0.35); }
|
||||
.login-panel label { color: #81978e; font-size: 8px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
|
||||
.login-panel input { height: 36px; padding: 0 11px; border: 1px solid rgba(155,190,176,0.28); outline: 0; color: #e8f2ee; background: #071310; font: 600 12px "Rajdhani", sans-serif; }
|
||||
.login-panel input:focus { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(232,200,114,0.12); }
|
||||
.login-panel input:focus,
|
||||
.login-panel input.is-controller-focused { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(232,200,114,0.12); }
|
||||
.login-panel .front-primary,
|
||||
.login-panel .front-secondary { min-height: 40px; padding-top: 6px; padding-bottom: 6px; }
|
||||
.login-surface > .front-notice { position: absolute; right: 44px; bottom: 83px; width: 300px; }
|
||||
.login-surface > .controller-legend { position: absolute; right: 44px; bottom: 47px; }
|
||||
.login-context { padding: 0; }
|
||||
|
||||
Reference in New Issue
Block a user