Release v0.1.0 2026-07-10
This commit is contained in:
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef } from "react";
|
||||
import packageJson from "../package.json";
|
||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||
import { FrontEnd } from "./components/FrontEnd";
|
||||
import { useActiveHunter, useFrontendStore } from "./frontend/store";
|
||||
@@ -63,7 +64,7 @@ export default function App() {
|
||||
<main className="prototype-shell">
|
||||
<header className="prototype-header">
|
||||
<div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div>
|
||||
<p>Offline-first healer roguelike <i /> build 0.2</p>
|
||||
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
||||
</header>
|
||||
{screen === "game"
|
||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense>
|
||||
|
||||
@@ -1,13 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
|
||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>("top");
|
||||
const activeSurfaceRef = useRef(activeSurface);
|
||||
activeSurfaceRef.current = activeSurface;
|
||||
|
||||
useEffect(() => {
|
||||
if (!document.documentElement.classList.contains("native-platform")) return;
|
||||
let frame = 0;
|
||||
let selectHeld = false;
|
||||
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
|
||||
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="device-frame">
|
||||
<div className={`device-frame active-${activeSurface}`}>
|
||||
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
|
||||
{top}
|
||||
<div className={`surface-slot top-slot ${activeSurface === "top" ? "is-active" : ""}`}>{top}</div>
|
||||
<div className="hinge" aria-hidden="true"><i /><b>AYN THOR</b><i /></div>
|
||||
<div className="screen-label bottom-label"><span>Context display</span><small>620 × 540 CSS · 1240 × 1080 · 60Hz</small></div>
|
||||
{bottom}
|
||||
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
|
||||
<button
|
||||
className="native-display-switch"
|
||||
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
|
||||
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
||||
>
|
||||
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import type { BossId } from "../game/types";
|
||||
import { requestDisplaySurface } from "../platform/displayRouting";
|
||||
import { DualDisplayFrame } from "./DualDisplayFrame";
|
||||
|
||||
function FocusButton({
|
||||
@@ -151,6 +152,11 @@ function SaveScreen() {
|
||||
const openCreation = () => {
|
||||
setHunterName("");
|
||||
setDialog("create");
|
||||
requestDisplaySurface("top");
|
||||
};
|
||||
const openSaveDialog = (nextDialog: "copy" | "delete") => {
|
||||
setDialog(nextDialog);
|
||||
requestDisplaySurface("top");
|
||||
};
|
||||
|
||||
const actions = useMemo<MenuAction[]>(() => dialog === "create"
|
||||
@@ -170,8 +176,8 @@ function SaveScreen() {
|
||||
{ id: hasLocal ? "play" : "create", run: () => hasLocal ? playSlot(selectedSlotId) : openCreation() },
|
||||
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId) },
|
||||
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId) },
|
||||
{ id: "copy", run: () => setDialog("copy"), enabled: hasLocal },
|
||||
{ id: "delete", run: () => setDialog("delete"), enabled: hasLocal },
|
||||
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal },
|
||||
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal },
|
||||
{ id: "back", run: () => navigate("login") },
|
||||
], [accountId, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, selectSlot, selectedSlotId, slots, uploadSlot]);
|
||||
const controller = useMenuController(actions, { onBack: () => dialog ? setDialog(null) : navigate("login") });
|
||||
@@ -261,8 +267,8 @@ function SaveScreen() {
|
||||
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}>↓ Overwrite with online</FocusButton>
|
||||
</div>
|
||||
<div className="record-actions">
|
||||
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => setDialog("copy")}>Copy save</FocusButton>
|
||||
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => setDialog("delete")}>Delete save</FocusButton>
|
||||
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}>Copy save</FocusButton>
|
||||
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}>Delete save</FocusButton>
|
||||
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}>Back</FocusButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
|
||||
|
||||
if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
|
||||
document.documentElement.classList.add("native-platform");
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export type DisplaySurface = "top" | "bottom";
|
||||
|
||||
const DISPLAY_SURFACE_EVENT = "thor:display-surface";
|
||||
|
||||
export function requestDisplaySurface(surface: DisplaySurface) {
|
||||
window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface }));
|
||||
}
|
||||
|
||||
export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) {
|
||||
const onSurface = (event: Event) => listener((event as CustomEvent<DisplaySurface>).detail);
|
||||
window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface);
|
||||
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
|
||||
}
|
||||
+104
@@ -108,6 +108,15 @@ button:focus-visible {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.surface-slot {
|
||||
width: 100%;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.native-display-switch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.screen-label {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -1231,6 +1240,101 @@ button:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* Capacitor single-display fallback. Thor dual-display routing will replace this
|
||||
switcher once both Android display contexts share one authoritative game state. */
|
||||
.native-platform,
|
||||
.native-platform body,
|
||||
.native-platform #root,
|
||||
.native-platform .prototype-shell,
|
||||
.native-platform .device-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.native-platform .prototype-shell {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.native-platform .prototype-header,
|
||||
.native-platform .screen-label,
|
||||
.native-platform .hinge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.native-platform .device-frame {
|
||||
position: relative;
|
||||
display: block;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.native-platform .surface-slot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
place-items: center;
|
||||
background: #030706;
|
||||
}
|
||||
|
||||
.native-platform .surface-slot.is-active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.native-platform .top-slot > .display {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.native-platform .bottom-slot > .display {
|
||||
width: min(620px, 100%);
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.native-platform .display {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.native-platform .native-display-switch {
|
||||
position: fixed;
|
||||
right: max(10px, env(safe-area-inset-right));
|
||||
bottom: max(8px, env(safe-area-inset-bottom));
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
min-width: 76px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(232, 200, 114, 0.55);
|
||||
border-radius: 3px;
|
||||
color: var(--ink);
|
||||
background: rgba(3, 9, 8, 0.9);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.native-platform .device-frame.active-bottom .native-display-switch {
|
||||
top: max(7px, env(safe-area-inset-top));
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.native-platform .native-display-switch b {
|
||||
color: var(--gold-strong);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.native-platform .native-display-switch small {
|
||||
color: var(--muted);
|
||||
font-size: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Frontend shell — I Want To Heal */
|
||||
|
||||
.front-surface {
|
||||
|
||||
Reference in New Issue
Block a user