Release v0.1.0 2026-07-10

This commit is contained in:
Warren H
2026-07-10 22:03:51 -04:00
parent e0be0458aa
commit 537a311f52
65 changed files with 2303 additions and 56 deletions
+2 -1
View File
@@ -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>
+42 -4
View File
@@ -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>
);
}
+10 -4
View File
@@ -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>
+7
View File
@@ -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 />
+13
View File
@@ -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
View File
@@ -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 {