diff --git a/README.md b/README.md
index 75553da..00d71dc 100644
--- a/README.md
+++ b/README.md
@@ -41,11 +41,16 @@ platform tools and a connected Thor, build and install it with:
pnpm android:install
```
-The first Android milestone uses the complete single-display fallback. Press
-Select (or Tab with a keyboard) to switch between the main game surface and the
-620 × 540 tactical surface. Native routing to both physical Thor displays is the
-next milestone; it needs two Android display contexts backed by one shared game
-state rather than two independent WebViews.
+The Android host routes the main and tactical surfaces to separate physical
+Thor displays while preserving one authoritative game state. If only one
+display is available, Select (or Tab with a keyboard) opens the tactical surface
+over the main game view.
+
+PC and handheld browsers use the Thor top screen as a responsive, full-viewport
+game surface. The compact ability strip keeps combat controls visible; Select
+or Tab opens party, map, inventory, and other tactical detail. Use
+`?layout=thor-preview` to restore the stacked dual-screen hardware mockup for
+browser QA.
## TrueNAS deployment
@@ -140,6 +145,7 @@ outside the repository.
- `Q` and `E` / D-pad: cycle party target
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
+- `Select` / `Tab`: open or close the tactical interface on one-screen devices
- `M`: tactical map
- `I`: inventory and item tooltip
- `Enter` / `START`: begin or reset encounter
@@ -185,6 +191,6 @@ build. Remove the switch to return to modular rendering.
- Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom
- Lower-screen typography scales against its own container, never the main page viewport
-Current Android build is an installable single-display test host. Shipping to both
-physical Thor displays still needs distinct Android display contexts that project
-one authoritative game state.
+The Android build uses distinct display contexts that project one authoritative
+game state across both physical Thor displays. PC and Steam Deck use the same top
+surface with an adaptive tactical overlay.
diff --git a/package.json b/package.json
index aa6e950..80cda72 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
- "version": "0.1.17",
+ "version": "0.1.18",
"type": "module",
"scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs",
diff --git a/src/App.tsx b/src/App.tsx
index 95b204a..b5b2d07 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -26,6 +26,10 @@ function GameLoadingScreen() {
);
}
+function TacticalLoadingScreen() {
+ return IH Loading field console Gameplay remains active ;
+}
+
function MainApp() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
@@ -225,7 +229,7 @@ function MainApp() {
Offline-first healer roguelike v{packageJson.version}
{screen === "game"
- ? }> } bottom={ } />
+ ? }> } bottom={ }> } />
: }
);
diff --git a/src/components/AbilityButton.tsx b/src/components/AbilityButton.tsx
new file mode 100644
index 0000000..de0ddd3
--- /dev/null
+++ b/src/components/AbilityButton.tsx
@@ -0,0 +1,72 @@
+import type { CSSProperties } from "react";
+import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
+import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
+import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
+import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, useGameStore } from "../game/store";
+import type { AbilitySlotId } from "../game/types";
+
+export function AbilityButton({ abilityId, compact = false }: { abilityId: AbilitySlotId; compact?: boolean }) {
+ const healerClassId = useGameStore((state) => state.healerClassId);
+ const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
+ const time = useGameStore((state) => state.time);
+ const cooldowns = useGameStore((state) => state.cooldowns);
+ const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
+ const mana = useGameStore((state) => state.mana);
+ const phase = useGameStore((state) => state.phase);
+ const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
+ const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
+ const activeCast = useGameStore((state) => state.activeCast);
+ const castAbility = useGameStore((state) => state.castAbility);
+ const runModifiers = useGameStore((state) => state.runModifiers);
+ const healerMechanic = useGameStore((state) => state.healerMechanic);
+ const classes = `ability ability-${abilityId} ${compact ? "is-compact" : ""}`;
+
+ if (!ability) {
+ return (
+
+ {Number(abilityId.slice(-1))}
+ —
+ Empty No spell drafted
+ {ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}
+
+ );
+ }
+
+ const remaining = abilityRemaining(abilityId, time, cooldowns);
+ const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
+ const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
+ const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
+ const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
+ const globalRemaining = Math.max(0, globalCooldownUntil - time);
+ const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
+ const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
+ const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
+ const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
+ const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
+
+ return (
+ 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
+ style={{ "--ability-color": ability.color } as CSSProperties}
+ onClick={() => castAbility(abilityId)}
+ disabled={disabled}
+ title={ability.description}
+ aria-label={`${ability.name}. ${ability.description}`}
+ >
+ {Number(abilityId.slice(-1))}
+ {ability.icon}
+ {ability.shortName} {resourceCopy}
+ {ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}
+ {remaining > 0 && (
+
+ {remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}
+
+ )}
+ {remaining <= 0 && globalRemaining > 0 && (
+
+ {globalRemaining.toFixed(1)}
+
+ )}
+
+ );
+}
diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx
index 83bd1ae..650d53b 100644
--- a/src/components/BottomScreen.tsx
+++ b/src/components/BottomScreen.tsx
@@ -1,8 +1,8 @@
+import { useEffect } from "react";
import { ABILITY_ORDER } from "../game/data";
-import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
+import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
-import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
-import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
+import { BARRIER_RADIUS, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
@@ -21,7 +21,7 @@ import {
HOCKEY_PVP_GOAL_Z,
HOCKEY_PVP_SIDE_OFFSET_Z,
} from "../game/hockeyHealingPvp";
-import { bottomTabsFor } from "../game/bottomTabs";
+import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
import {
BLOCKBREAKER_BREACH_DAMAGE,
BLOCKBREAKER_BRICK_COLORS,
@@ -31,9 +31,82 @@ import {
blockbreakerTimeMultiplier,
} from "../game/blockbreaker";
import { aetherShipColor } from "./aetherAssaultVisuals";
-import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
import { isBeaconOfLightTarget } from "../game/healerMechanics";
+import { AbilityButton } from "./AbilityButton";
+import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting";
+import { isSingleScreenLayout } from "../platform/displayLayout";
+import { subscribeControllerToken } from "../input/controller";
+
+function moveTacticalSelection(direction: 1 | -1) {
+ const store = useGameStore.getState();
+ if (store.activeTab === "combat") {
+ store.cycleMember(direction);
+ return;
+ }
+ if (store.activeTab !== "pack" || store.inventory.length === 0) return;
+ const currentIndex = Math.max(0, store.inventory.findIndex((item) => item.id === store.selectedItemId));
+ const nextIndex = (currentIndex + direction + store.inventory.length) % store.inventory.length;
+ store.selectItem(store.inventory[nextIndex].id);
+}
+
+function useSingleScreenTacticalInput() {
+ useEffect(() => {
+ if (!isSingleScreenLayout()) return;
+ let active = getDisplaySurface() === "bottom";
+ const unsubscribeSurface = subscribeDisplaySurface((surface) => { active = surface === "bottom"; });
+ const cycleTab = (direction: 1 | -1) => {
+ const store = useGameStore.getState();
+ if (direction === 1) store.setActiveTab(cycleBottomTab(store.activeTab, store.runMode));
+ else {
+ const tabs = bottomTabsFor(store.runMode);
+ const currentIndex = tabs.indexOf(store.activeTab);
+ store.setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
+ }
+ };
+ const activatePhaseAction = () => {
+ const store = useGameStore.getState();
+ if (store.phase === "briefing") store.startEncounter();
+ else if (store.phase === "victory" || store.phase === "defeat") store.restart();
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!active || event.repeat) return;
+ const store = useGameStore.getState();
+ if (store.paused
+ || store.runMode === "rpg-roguelike"
+ || store.phase === "intermission"
+ || store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
+ const key = event.key.toLowerCase();
+ if (key === "arrowleft") cycleTab(-1);
+ else if (key === "arrowright") cycleTab(1);
+ else if (key === "arrowup") moveTacticalSelection(-1);
+ else if (key === "arrowdown") moveTacticalSelection(1);
+ else if (key === "enter") activatePhaseAction();
+ else return;
+ event.preventDefault();
+ };
+ const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
+ if (!active || repeat && !["Button12", "Button13", "Button14", "Button15"].includes(token)) return;
+ const store = useGameStore.getState();
+ if (store.paused
+ || store.runMode === "rpg-roguelike"
+ || store.phase === "intermission"
+ || store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) return;
+ if (token === "Button14") cycleTab(-1);
+ else if (token === "Button15") cycleTab(1);
+ else if (token === "Button12") moveTacticalSelection(-1);
+ else if (token === "Button13") moveTacticalSelection(1);
+ else if (!repeat && token === "Button0") activatePhaseAction();
+ else if (!repeat && token === "Button1") requestDisplaySurface("top");
+ });
+ window.addEventListener("keydown", onKeyDown);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ unsubscribeController();
+ unsubscribeSurface();
+ };
+ }, []);
+}
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -113,62 +186,6 @@ function PartyList() {
);
}
-function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
- const healerClassId = useGameStore((state) => state.healerClassId);
- const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
- const time = useGameStore((state) => state.time);
- const cooldowns = useGameStore((state) => state.cooldowns);
- const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
- const mana = useGameStore((state) => state.mana);
- const phase = useGameStore((state) => state.phase);
- const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
- const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
- const activeCast = useGameStore((state) => state.activeCast);
- const castAbility = useGameStore((state) => state.castAbility);
- const runModifiers = useGameStore((state) => state.runModifiers);
- const healerMechanic = useGameStore((state) => state.healerMechanic);
- if (!ability) {
- return {Number(abilityId.slice(-1))} — Empty No spell drafted {ABILITY_CONTROLLER_BINDINGS[abilityId].glyph} ;
- }
- const remaining = abilityRemaining(abilityId, time, cooldowns);
- const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
- const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
- const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
- const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
- const globalRemaining = Math.max(0, globalCooldownUntil - time);
- const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
- const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
- const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
- const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
- const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
-
- return (
- 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
- style={{ "--ability-color": ability.color } as React.CSSProperties}
- onClick={() => castAbility(abilityId)}
- disabled={disabled}
- title={ability.description}
- aria-label={`${ability.name}. ${ability.description}`}
- >
- {Number(abilityId.slice(-1))}
- {ability.icon}
- {ability.shortName} {resourceCopy}
- {ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}
- {remaining > 0 && (
-
- {remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}
-
- )}
- {remaining <= 0 && globalRemaining > 0 && (
-
- {globalRemaining.toFixed(1)}
-
- )}
-
- );
-}
-
function AbilityTray() {
const healerClassId = useGameStore((state) => state.healerClassId);
const healer = HEALER_CLASSES[healerClassId];
@@ -706,6 +723,7 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
}
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
+ useSingleScreenTacticalInput();
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
diff --git a/src/components/DualDisplayFrame.tsx b/src/components/DualDisplayFrame.tsx
index 1059e10..3fafa1a 100644
--- a/src/components/DualDisplayFrame.tsx
+++ b/src/components/DualDisplayFrame.tsx
@@ -1,39 +1,80 @@
-import { useEffect, useRef, useState, type ReactNode } from "react";
-import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
+import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
+import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
+import { resolveDisplayLayout } from "../platform/displayLayout";
import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
-export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
- const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
+export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
+ const params = new URLSearchParams(window.location.search);
+ const dedicatedSurface = params.get("display");
+ const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
const [activeSurface, setActiveSurface] = useState(() => dedicatedSurface === "bottom" ? "bottom" : "top");
const activeSurfaceRef = useRef(activeSurface);
activeSurfaceRef.current = activeSurface;
+ const showSurface = useCallback((surface: DisplaySurface) => {
+ activeSurfaceRef.current = surface;
+ setActiveSurface(surface);
+ }, []);
+ const toggleSurface = useCallback(() => {
+ requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
+ }, []);
useEffect(() => {
- if (!document.documentElement.classList.contains("native-platform")) return;
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
- const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
- const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
+ if (layout === "thor-preview") return;
+ requestDisplaySurface("top");
+ const unsubscribeSurface = subscribeDisplaySurface(showSurface);
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
- if (token === "Button8" && !repeat) toggle();
+ if (token === "Button8" && !repeat) toggleSurface();
});
const onKeyDown = (event: KeyboardEvent) => {
- if (event.key !== "Tab" || event.repeat) return;
+ if (event.repeat) return;
+ if (event.key === "Escape" && activeSurfaceRef.current === "bottom") {
+ event.preventDefault();
+ requestDisplaySurface("top");
+ return;
+ }
+ if (event.key !== "Tab") return;
event.preventDefault();
- toggle();
+ toggleSurface();
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
unsubscribeSurface();
unsubscribeController();
+ requestDisplaySurface("top");
};
- }, [dedicatedSurface]);
+ }, [dedicatedSurface, layout, showSurface, toggleSurface]);
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
return {dedicatedSurface === "top" ? top : bottom}
;
}
+ if (layout === "single") {
+ const contextOpen = activeSurface === "bottom";
+ return (
+
+
{top}
+ {contextOpen && (
+
+
requestDisplaySurface("top")} aria-label={`Close ${contextLabel.toLowerCase()} interface`} />
+ {bottom}
+
+ )}
+
+ {contextOpen ? "Return" : contextLabel}
+ {DEFAULT_CONTROLLER_GLYPHS.select} / TAB
+
+
+ );
+ }
+
return (
Main viewport 960 × 540 CSS · 1920 × 1080 · 120Hz
@@ -43,7 +84,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
{bottom}
setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
+ onClick={toggleSurface}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
>
{activeSurface === "top" ? "Tactical" : "Main"} {DEFAULT_CONTROLLER_GLYPHS.select} / TAB
diff --git a/src/components/TopScreen.tsx b/src/components/TopScreen.tsx
index 932bc10..cce9862 100644
--- a/src/components/TopScreen.tsx
+++ b/src/components/TopScreen.tsx
@@ -11,6 +11,9 @@ import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
+import { isSingleScreenLayout } from "../platform/displayLayout";
+import { ABILITY_ORDER } from "../game/data";
+import { AbilityButton } from "./AbilityButton";
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
GameScene.displayName = "MemoizedGameScene";
@@ -165,6 +168,7 @@ function PhaseOverlay() {
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
+ const singleScreen = isSingleScreenLayout();
if (runMode === "rpg-roguelike") return null;
if (phase === "intermission") return ;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
@@ -225,7 +229,20 @@ function PhaseOverlay() {
{eyebrow}
{title}
{copy}
- {phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}
+ {singleScreen
+ ? phase === "briefing" ? "Press Start / Enter to begin" : showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart"
+ : phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}
+
+ );
+}
+
+function SingleScreenAbilityBar() {
+ const phase = useGameStore((state) => state.phase);
+ const runMode = useGameStore((state) => state.runMode);
+ if (!isSingleScreenLayout() || phase !== "combat" || runMode === "rpg-roguelike") return null;
+ return (
+
+ {ABILITY_ORDER.map((abilityId) =>
)}
);
}
@@ -402,6 +419,7 @@ export function TopScreen({
WASD Move{aetherAssaultMode ? " + auto-fire" : ""} Q / E Target 1–6 Cast
{onExit && phase === "combat" ? setPaused(true) : onExit()}>☰ Menu {DEFAULT_CONTROLLER_GLYPHS.start} / ESC }
+
diff --git a/src/game/hockeyHealingPvp.test.ts b/src/game/hockeyHealingPvp.test.ts
index 0823116..10360aa 100644
--- a/src/game/hockeyHealingPvp.test.ts
+++ b/src/game/hockeyHealingPvp.test.ts
@@ -9,14 +9,15 @@ import {
hockeyPvpHealingEffectiveness,
hockeyPvpPuckSpeed,
mirrorHockeyPvpPuck,
+ reconcileHockeyPvpPuck,
} from "./hockeyHealingPvp";
describe("Healing Hockey PVP", () => {
it("starts rallies at the faster default puck speed", () => {
const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" });
- expect(hockeyPvpPuckSpeed(0)).toBe(7.2);
- expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.2);
+ expect(hockeyPvpPuckSpeed(0)).toBe(7.8);
+ expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
});
it("adds five percent global dampening for every boss killed by either party", () => {
@@ -76,4 +77,28 @@ describe("Healing Hockey PVP", () => {
lastGoalSide: "local",
});
});
+
+ it("predicts guest movement between snapshots and soft-corrects small drift", () => {
+ const guest = createHockeyPvpState({ matchId: "match", seed: 9, opponentName: "Rival", role: "guest" });
+ guest.puckVelocity = [3, 6];
+ const predicted = advanceHockeyPvpPuck(guest, {
+ delta: 0.1,
+ localPlayerPosition: [0, 8.5],
+ localAimDirection: [0, -1],
+ opponentPlayerPosition: [0, 8.5],
+ opponentAimDirection: [0, -1],
+ });
+ expect(predicted.puckPosition[0]).toBeCloseTo(0.3);
+ expect(predicted.puckPosition[1]).toBeCloseTo(0.6);
+ expect(predicted.localGoalsConceded).toBe(0);
+
+ const authoritative = { ...predicted, puckPosition: [0.6, 0.6] as [number, number] };
+ const reconciled = reconcileHockeyPvpPuck(predicted, authoritative);
+ expect(reconciled.puckPosition[0]).toBeGreaterThan(predicted.puckPosition[0]);
+ expect(reconciled.puckPosition[0]).toBeLessThan(authoritative.puckPosition[0]);
+
+ authoritative.goalSequence += 1;
+ authoritative.puckPosition = [0, 0];
+ expect(reconcileHockeyPvpPuck(predicted, authoritative).puckPosition).toEqual([0, 0]);
+ });
});
diff --git a/src/game/hockeyHealingPvp.ts b/src/game/hockeyHealingPvp.ts
index 7dab627..8942861 100644
--- a/src/game/hockeyHealingPvp.ts
+++ b/src/game/hockeyHealingPvp.ts
@@ -59,13 +59,16 @@ export const HOCKEY_PVP_GOAL_HALF_WIDTH = 8;
export const HOCKEY_PVP_PUCK_RADIUS = 0.42;
export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
+export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5;
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
-const STARTING_SPEED = 7.2;
-const MAX_SPEED = 11.5;
+const STARTING_SPEED = 7.8;
+const MAX_SPEED = 12.2;
const MAX_SUBSTEPS = 10;
const MAX_SUBSTEP_DISTANCE = 0.32;
+const GUEST_RECONCILIATION_BLEND = 0.35;
+const GUEST_RECONCILIATION_SNAP_DISTANCE = 3;
const SERVE_LANES = [0, -0.46, 0.58, -0.25, 0.34, -0.7, 0.74] as const;
export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number {
@@ -161,6 +164,59 @@ function resetAfterGoal(state: HockeyPvpPuckState, side: HockeyPvpGoalSide) {
state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns);
}
+function predictGuestPuck(source: HockeyPvpState, delta: number): HockeyPvpState {
+ const state: HockeyPvpState = {
+ ...source,
+ puckPosition: [...source.puckPosition],
+ puckVelocity: [...source.puckVelocity],
+ };
+ const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]);
+ const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * delta / MAX_SUBSTEP_DISTANCE)));
+ const subDelta = delta / substeps;
+ const minX = HOCKEY_PVP_ARENA_MIN_X + HOCKEY_PVP_PUCK_RADIUS;
+ const maxX = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_PUCK_RADIUS;
+
+ for (let index = 0; index < substeps; index += 1) {
+ const end: WorldPosition = [
+ state.puckPosition[0] + state.puckVelocity[0] * subDelta,
+ state.puckPosition[1] + state.puckVelocity[1] * subDelta,
+ ];
+ if (end[0] < minX || end[0] > maxX) {
+ end[0] = Math.max(minX, Math.min(maxX, end[0]));
+ state.puckVelocity[0] *= -1;
+ }
+ if (end[1] >= HOCKEY_PVP_GOAL_Z) {
+ end[1] = HOCKEY_PVP_GOAL_Z;
+ if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = -Math.abs(state.puckVelocity[1]);
+ } else if (end[1] <= -HOCKEY_PVP_GOAL_Z) {
+ end[1] = -HOCKEY_PVP_GOAL_Z;
+ if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = Math.abs(state.puckVelocity[1]);
+ }
+ state.puckPosition = end;
+ }
+ return state;
+}
+
+export function reconcileHockeyPvpPuck(
+ predicted: HockeyPvpPuckState,
+ authoritative: HockeyPvpPuckState,
+): HockeyPvpPuckState {
+ const errorX = authoritative.puckPosition[0] - predicted.puckPosition[0];
+ const errorZ = authoritative.puckPosition[1] - predicted.puckPosition[1];
+ const shouldSnap = authoritative.goalSequence !== predicted.goalSequence
+ || Math.hypot(errorX, errorZ) >= GUEST_RECONCILIATION_SNAP_DISTANCE;
+ return {
+ ...authoritative,
+ puckPosition: shouldSnap
+ ? [...authoritative.puckPosition]
+ : [
+ predicted.puckPosition[0] + errorX * GUEST_RECONCILIATION_BLEND,
+ predicted.puckPosition[1] + errorZ * GUEST_RECONCILIATION_BLEND,
+ ],
+ puckVelocity: [...authoritative.puckVelocity],
+ };
+}
+
export function advanceHockeyPvpPuck(
source: HockeyPvpState,
step: {
@@ -171,7 +227,10 @@ export function advanceHockeyPvpPuck(
opponentAimDirection: WorldPosition;
},
): HockeyPvpState {
- if (source.status !== "live" || source.role === "guest" || step.delta <= 0) return source;
+ if (source.status !== "live" || step.delta <= 0) return source;
+ // Guest predicts travel only. Host snapshots remain authoritative for contacts,
+ // goals, damage, and rally counters.
+ if (source.role === "guest") return predictGuestPuck(source, step.delta);
const state: HockeyPvpState = {
...source,
puckPosition: [...source.puckPosition],
diff --git a/src/game/hockeyHealingPvpStore.test.ts b/src/game/hockeyHealingPvpStore.test.ts
index bc99c9c..86ba2b1 100644
--- a/src/game/hockeyHealingPvpStore.test.ts
+++ b/src/game/hockeyHealingPvpStore.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
-import { HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt } from "./hockeyHealingPvp";
+import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
import { upcomingEncounterMechanic, useGameStore } from "./store";
import { freshParty } from "./data";
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear";
@@ -42,6 +42,7 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0));
expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id);
expect(state.hockeyPvp.opponentName).toBe("CPU Aster");
+ expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
});
it("normalizes both parties to default base gear without changing saved upgrades", () => {
@@ -170,13 +171,16 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id);
});
- it("wins when opponent party falls", () => {
+ it("wins when opponent companions fall while rival healer remains alive", () => {
useGameStore.getState().startEncounter();
useGameStore.getState().setActiveTab("map");
useGameStore.setState((state) => ({
hockeyPvpOpponent: {
...state.hockeyPvpOpponent,
- party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, hp: 0 })),
+ party: state.hockeyPvpOpponent.party.map((member) => ({
+ ...member,
+ hp: member.id === "aelia" ? member.hp : 0,
+ })),
},
}));
useGameStore.getState().tick(0.01);
@@ -185,6 +189,60 @@ describe("Healing Hockey PVP encounter integration", () => {
expect(useGameStore.getState().activeTab).toBe("combat");
});
+ it("loses when local companions fall while healer remains alive", () => {
+ useGameStore.getState().startEncounter();
+ useGameStore.setState((state) => ({
+ party: state.party.map((member) => ({
+ ...member,
+ hp: member.id === "aelia" ? member.hp : 0,
+ })),
+ }));
+
+ useGameStore.getState().tick(0.01);
+ expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBeGreaterThan(0);
+ expect(useGameStore.getState().phase).toBe("defeat");
+ expect(useGameStore.getState().hockeyPvp.status).toBe("lost");
+ });
+
+ it("wins an online match when remote companions fall while rival healer remains alive", () => {
+ useGameStore.getState().configureHealer(
+ "priest",
+ "Aelia",
+ createClassInventory("priest"),
+ [hockeyPvpBossAt(MATCH.seed, 0)],
+ "hockey-healing-pvp",
+ undefined,
+ "initiate",
+ { ...MATCH, matchId: "online-match", role: "guest" },
+ );
+ useGameStore.getState().startEncounter();
+ const state = useGameStore.getState();
+ const snapshot: HockeyPvpRemoteSnapshot = {
+ sequence: 1,
+ time: state.time,
+ party: state.hockeyPvpOpponent.party.map((member) => ({
+ ...member,
+ hp: member.id === "aelia" ? member.hp : 0,
+ })),
+ partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions),
+ boss: {
+ id: state.hockeyPvpOpponent.boss.id,
+ name: state.hockeyPvpOpponent.boss.name,
+ hp: state.hockeyPvpOpponent.boss.hp,
+ maxHp: state.hockeyPvpOpponent.boss.maxHp,
+ },
+ bossPosition: [...state.hockeyPvpOpponent.bossMotion.position],
+ bossMode: state.hockeyPvpOpponent.bossMotion.mode,
+ bossKills: 0,
+ playerPosition: [...state.hockeyPvp.opponentPlayerPosition],
+ aimDirection: [...state.hockeyPvp.opponentAimDirection],
+ };
+
+ useGameStore.getState().applyHockeyPvpRemoteSnapshot(snapshot);
+ expect(useGameStore.getState().phase).toBe("victory");
+ expect(useGameStore.getState().hockeyPvp.status).toBe("won");
+ });
+
it("keeps HUD mechanic data defined during instant boss replacement", () => {
useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } }));
expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({
diff --git a/src/game/store.test.ts b/src/game/store.test.ts
index bf50bd5..b75d50c 100644
--- a/src/game/store.test.ts
+++ b/src/game/store.test.ts
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry";
-import { BARRIER_RADIUS, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
+import { BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { healingEffect } from "./healerEffects";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
@@ -61,6 +61,18 @@ describe("Disc Priest combat simulation", () => {
});
});
+ it("regenerates mana at one-third the previous global rate", () => {
+ useGameStore.setState((state) => ({
+ mana: 0,
+ boss: { ...state.boss, nextMeleeAt: 999 },
+ bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
+ }));
+
+ useGameStore.getState().tick(1);
+ expect(MANA_REGEN_PER_SECOND).toBeCloseTo(3.2 / 3);
+ expect(useGameStore.getState().mana).toBeCloseTo(3.2 / 3);
+ });
+
it("uses a three-second Purify cooldown for every healer class", () => {
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
diff --git a/src/game/store.ts b/src/game/store.ts
index 95385e1..a7dfef5 100644
--- a/src/game/store.ts
+++ b/src/game/store.ts
@@ -70,6 +70,7 @@ import {
type HockeyHealingState,
} from "./hockeyHealing";
import {
+ HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER,
HOCKEY_PVP_GOAL_DAMAGE,
advanceHockeyPvpCpuGoalie,
advanceHockeyPvpPuck,
@@ -77,6 +78,7 @@ import {
hockeyPvpHealingEffectiveness,
hockeyPvpBossAt,
mirrorHockeyPvpPuck,
+ reconcileHockeyPvpPuck,
type HockeyPvpMatchConfig,
type HockeyPvpRemoteSnapshot,
type HockeyPvpState,
@@ -266,6 +268,7 @@ const emptyCooldowns = (): Record => ({
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
+export const MANA_REGEN_PER_SECOND = 3.2 / 3;
export const BARRIER_RADIUS = 4;
export const BARRIER_DAMAGE_REDUCTION = 0.3;
@@ -628,7 +631,8 @@ function initialState(
runModifiers,
healingMultiplier: gearModifiers.aelia.healingPower,
difficultySlug,
- difficultyDamageMultiplier: difficulty.damageMultiplier,
+ difficultyDamageMultiplier: difficulty.damageMultiplier
+ * (runMode === "hockey-healing-pvp" ? HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER : 1),
gearProgress,
gearModifiers,
time: 0,
@@ -1110,7 +1114,7 @@ export const useGameStore = create((set, get) => ({
applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
- ? mirrorHockeyPvpPuck(hostPuck)
+ ? reconcileHockeyPvpPuck(state.hockeyPvp, mirrorHockeyPvpPuck(hostPuck))
: undefined;
const previousLocalGoals = state.hockeyPvp.localGoalsConceded;
const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals;
@@ -1125,8 +1129,8 @@ export const useGameStore = create((set, get) => ({
})
: state.party;
const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
- const opponentWiped = isPartyWiped(opponentParty);
- const localWiped = isPartyWiped(party);
+ const opponentWiped = areAllNonHealerAlliesDefeated(opponentParty);
+ const localWiped = areAllNonHealerAlliesDefeated(party);
const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase;
return {
party,
@@ -2108,7 +2112,8 @@ export const useGameStore = create((set, get) => ({
const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost";
const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost";
const pvpMode = state.activityMode === "hockey-healing-pvp";
- const opponentWiped = pvpMode && isPartyWiped(hockeyPvpOpponent.party);
+ const localPvpTeamDefeated = pvpMode && allCompanionsDefeated;
+ const opponentWiped = pvpMode && areAllNonHealerAlliesDefeated(hockeyPvpOpponent.party);
const rpgChallengeActive = rpgRun?.phase === "challenge-active";
const rpgBossActive = rpgRun?.phase === "boss-combat";
if (rpgChallengeActive && rpgRun) {
@@ -2190,7 +2195,7 @@ export const useGameStore = create((set, get) => ({
phase = "combat";
endlessMode = false;
} else if (pvpMode) {
- if (partyWiped) {
+ if (localPvpTeamDefeated) {
phase = "defeat";
hockeyPvp.status = "lost";
combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger");
@@ -2258,7 +2263,7 @@ export const useGameStore = create((set, get) => ({
hockeyPvp,
hockeyPvpOpponent,
runBuffInputUnlockAt,
- mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
+ mana: Math.min(state.maxMana, state.mana + MANA_REGEN_PER_SECOND * (time - oldTime)),
activeCast,
combatLog,
scenePulse: pulse,
diff --git a/src/game/useGameLoop.ts b/src/game/useGameLoop.ts
index baa4237..9b3c45e 100644
--- a/src/game/useGameLoop.ts
+++ b/src/game/useGameLoop.ts
@@ -5,6 +5,16 @@ import { isRunBuffInputLocked, useGameStore } from "./store";
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
import { cycleBottomTab } from "./bottomTabs";
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
+import { getDisplaySurface } from "../platform/displayRouting";
+import { isSingleScreenLayout } from "../platform/displayLayout";
+
+function tacticalOverlayOwnsInput() {
+ const store = useGameStore.getState();
+ if (!isSingleScreenLayout() || getDisplaySurface() !== "bottom") return false;
+ if (store.paused || store.phase === "intermission") return false;
+ if (store.runMode === "rpg-roguelike" && rpgInputIsGated()) return false;
+ return !(store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode);
+}
function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState();
@@ -35,9 +45,11 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!enabled) return;
+ if (event.defaultPrevented) return;
if (event.repeat) return;
const store = useGameStore.getState();
const key = event.key.toLowerCase();
+ if (tacticalOverlayOwnsInput()) return;
if (store.paused) {
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "escape") store.setPaused(false);
@@ -124,6 +136,8 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
if (!enabled) return;
const store = useGameStore.getState();
+ if (isSingleScreenLayout() && token === "Button8") return;
+ if (tacticalOverlayOwnsInput()) return;
if (store.paused) {
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
diff --git a/src/main.tsx b/src/main.tsx
index 5b2125e..7fcc382 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -4,10 +4,12 @@ import { Capacitor } from "@capacitor/core";
import App from "./App";
import { BottomDisplayApp } from "./platform/BottomDisplayApp";
import { startControllerInput } from "./input/controller";
+import { currentDisplayLayout } from "./platform/displayLayout";
import "./styles.css";
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
const displayMode = new URLSearchParams(window.location.search).get("display");
+const displayLayout = currentDisplayLayout();
if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
document.documentElement.classList.add("native-platform");
@@ -15,6 +17,7 @@ if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
if (displayMode === "top" || displayMode === "bottom") {
document.documentElement.dataset.displaySurface = displayMode;
}
+document.documentElement.dataset.displayLayout = displayLayout;
startControllerInput();
diff --git a/src/platform/displayLayout.test.ts b/src/platform/displayLayout.test.ts
new file mode 100644
index 0000000..a973536
--- /dev/null
+++ b/src/platform/displayLayout.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { resolveDisplayLayout } from "./displayLayout";
+
+describe("resolveDisplayLayout", () => {
+ it("uses the top-screen-first single layout for ordinary browsers", () => {
+ expect(resolveDisplayLayout({})).toBe("single");
+ expect(resolveDisplayLayout({ layout: "single" })).toBe("single");
+ });
+
+ it("keeps the dual-screen hardware mockup behind an explicit preview", () => {
+ expect(resolveDisplayLayout({ layout: "thor-preview" })).toBe("thor-preview");
+ expect(resolveDisplayLayout({ layout: "dual" })).toBe("thor-preview");
+ });
+
+ it("never overrides a dedicated Android display surface", () => {
+ expect(resolveDisplayLayout({ display: "top", layout: "single" })).toBe("dedicated");
+ expect(resolveDisplayLayout({ display: "bottom", layout: "thor-preview" })).toBe("dedicated");
+ });
+});
diff --git a/src/platform/displayLayout.ts b/src/platform/displayLayout.ts
new file mode 100644
index 0000000..2ed10d2
--- /dev/null
+++ b/src/platform/displayLayout.ts
@@ -0,0 +1,29 @@
+export type DisplayLayout = "single" | "thor-preview" | "dedicated";
+
+export interface DisplayLayoutRequest {
+ display?: string | null;
+ layout?: string | null;
+}
+
+/**
+ * Physical Thor surfaces are selected explicitly by the Android host. Every
+ * ordinary browser viewport is a single-screen game unless a developer asks
+ * for the dual-screen hardware preview.
+ */
+export function resolveDisplayLayout({ display, layout }: DisplayLayoutRequest): DisplayLayout {
+ if (display === "top" || display === "bottom") return "dedicated";
+ if (layout === "thor-preview" || layout === "dual") return "thor-preview";
+ return "single";
+}
+
+export function currentDisplayLayout(search = window.location.search): DisplayLayout {
+ const params = new URLSearchParams(search);
+ return resolveDisplayLayout({
+ display: params.get("display"),
+ layout: params.get("layout"),
+ });
+}
+
+export function isSingleScreenLayout(search = window.location.search) {
+ return currentDisplayLayout(search) === "single";
+}
diff --git a/src/platform/displayRouting.ts b/src/platform/displayRouting.ts
index 978e51f..eb58752 100644
--- a/src/platform/displayRouting.ts
+++ b/src/platform/displayRouting.ts
@@ -1,13 +1,22 @@
export type DisplaySurface = "top" | "bottom";
const DISPLAY_SURFACE_EVENT = "thor:display-surface";
+let currentSurface: DisplaySurface = "top";
export function requestDisplaySurface(surface: DisplaySurface) {
+ currentSurface = surface;
window.dispatchEvent(new CustomEvent(DISPLAY_SURFACE_EVENT, { detail: surface }));
}
+export function getDisplaySurface() {
+ return currentSurface;
+}
+
export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) {
- const onSurface = (event: Event) => listener((event as CustomEvent).detail);
+ const onSurface = (event: Event) => {
+ currentSurface = (event as CustomEvent).detail;
+ listener(currentSurface);
+ };
window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface);
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
}
diff --git a/src/styles.css b/src/styles.css
index 52b6bf7..6eff202 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -118,6 +118,224 @@ button:focus-visible {
display: none;
}
+/* PC and handheld browsers use the Thor top surface as the canonical game
+ viewport. The lower surface becomes a disclosed tactical layer. */
+html[data-display-layout="single"],
+html[data-display-layout="single"] body,
+html[data-display-layout="single"] #root,
+html[data-display-layout="single"] .app-shell,
+.single-display-frame,
+.single-primary-surface {
+ width: 100%;
+ height: 100%;
+ min-height: 100%;
+ overflow: hidden;
+}
+
+html[data-display-layout="single"] .app-shell {
+ padding: 0;
+}
+
+html[data-display-layout="single"] .app-header {
+ display: none;
+}
+
+.single-display-frame {
+ position: relative;
+ isolation: isolate;
+ background: #030706;
+}
+
+.single-primary-surface {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+}
+
+.single-primary-surface > .display,
+.single-primary-surface > .top-display,
+.single-primary-surface > .front-surface {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: auto;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+}
+
+.single-primary-surface > .top-display {
+ container-name: single-game-screen;
+ container-type: size;
+}
+
+.single-context-layer {
+ position: fixed;
+ z-index: 90;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left));
+}
+
+.single-context-backdrop {
+ position: absolute;
+ inset: 0;
+ border: 0;
+ background: linear-gradient(90deg, rgba(1, 5, 4, 0.6), rgba(1, 5, 4, 0.86));
+ backdrop-filter: blur(5px);
+ cursor: pointer;
+}
+
+.single-context-surface {
+ position: relative;
+ z-index: 1;
+ width: min(100%, calc((100dvh - 28px) * 31 / 27));
+ max-width: 760px;
+ max-height: calc(100dvh - 28px);
+ aspect-ratio: 31 / 27;
+ overflow: hidden;
+ border: 1px solid rgba(232, 200, 114, 0.42);
+ border-radius: 8px;
+ background: #07110f;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.72), 0 0 32px rgba(94, 199, 176, 0.08);
+}
+
+.single-context-surface > .bottom-display,
+.single-context-surface > .front-surface {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: auto;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+}
+
+.single-context-toggle {
+ position: fixed;
+ right: max(14px, env(safe-area-inset-right));
+ bottom: max(12px, env(safe-area-inset-bottom));
+ z-index: 100;
+ min-width: 94px;
+ display: grid;
+ gap: 1px;
+ padding: 7px 10px;
+ border: 1px solid rgba(232, 200, 114, 0.62);
+ border-radius: 4px;
+ color: var(--ink);
+ background: rgba(3, 9, 8, 0.92);
+ box-shadow: 0 7px 22px rgba(0, 0, 0, 0.45);
+ text-align: left;
+ cursor: pointer;
+}
+
+.single-context-toggle b {
+ color: var(--gold-strong);
+ font-size: 10px;
+ line-height: 1;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+}
+
+.single-context-toggle small {
+ color: var(--muted);
+ font-size: 7px;
+ line-height: 1.2;
+}
+
+.single-display-frame.context-open .single-context-toggle {
+ top: max(18px, env(safe-area-inset-top));
+ right: max(18px, env(safe-area-inset-right));
+ bottom: auto;
+}
+
+html[data-display-layout="single"] .top-party {
+ width: clamp(185px, 20cqw, 330px);
+ gap: clamp(3px, .55cqh, 7px);
+}
+
+html[data-display-layout="single"] .top-party-member {
+ min-height: clamp(38px, 7.8cqh, 72px);
+ grid-template-columns: clamp(27px, 3.1cqw, 50px) minmax(0, 1fr);
+ gap: clamp(5px, .75cqw, 11px);
+ padding: clamp(3px, .45cqw, 7px) clamp(5px, .7cqw, 11px) clamp(6px, .8cqw, 12px) clamp(3px, .45cqw, 7px);
+}
+
+html[data-display-layout="single"] .portrait-dot {
+ width: clamp(26px, 3cqw, 48px);
+ height: clamp(26px, 3cqw, 48px);
+ font-size: clamp(10px, 1.05cqw, 17px);
+}
+
+html[data-display-layout="single"] .top-party-copy strong {
+ font-size: clamp(10px, 1.05cqw, 17px);
+}
+
+html[data-display-layout="single"] .top-party-copy small {
+ font-size: clamp(7px, .68cqw, 11px);
+}
+
+html[data-display-layout="single"] .microbar {
+ right: clamp(5px, .7cqw, 11px);
+ bottom: clamp(3px, .4cqh, 6px);
+ left: clamp(37px, 4.25cqw, 68px);
+ height: clamp(5px, .58cqh, 8px);
+}
+
+html[data-display-layout="single"] .microbar.player-health-bar {
+ bottom: clamp(9px, 1.05cqh, 15px);
+}
+
+html[data-display-layout="single"] .player-mana-bar {
+ right: clamp(5px, .7cqw, 11px);
+ bottom: clamp(3px, .4cqh, 6px);
+ left: clamp(37px, 4.25cqw, 68px);
+ height: clamp(3px, .38cqh, 6px);
+}
+
+html[data-display-layout="single"] .boss-bar-wrap {
+ width: min(39%, 660px);
+}
+
+html[data-display-layout="single"] .boss-name {
+ font-size: clamp(7px, .62cqw, 11px);
+}
+
+html[data-display-layout="single"] .boss-name strong {
+ font-size: clamp(12px, 1.2cqw, 20px);
+}
+
+html[data-display-layout="single"] .objective-chip span {
+ font-size: clamp(6px, .58cqw, 10px);
+}
+
+html[data-display-layout="single"] .objective-chip strong {
+ font-size: clamp(8px, .82cqw, 14px);
+}
+
+html[data-display-layout="single"] .casting-bar {
+ bottom: clamp(76px, 11cqh, 126px);
+}
+
+html[data-display-layout="single"] .control-hint {
+ display: none;
+}
+
+html[data-display-layout="single"] .encounter-callout {
+ bottom: clamp(74px, 10cqh, 118px);
+}
+
+@media (min-width: 1200px) and (min-height: 700px) {
+ .single-context-layer {
+ justify-items: end;
+ padding-right: max(24px, env(safe-area-inset-right));
+ }
+
+ .single-context-surface {
+ width: min(56vw, 720px);
+ }
+}
+
.screen-label {
width: 100%;
display: flex;
@@ -1066,6 +1284,19 @@ button:focus-visible {
gap: 4%;
}
+.single-ability-bar {
+ position: absolute;
+ z-index: 4;
+ bottom: max(12px, env(safe-area-inset-bottom));
+ left: 50%;
+ width: min(64vw, 760px);
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: clamp(3px, .45vw, 7px);
+ transform: translateX(-50%);
+ pointer-events: auto;
+}
+
.ability {
--ability-color: #ddd;
position: relative;
@@ -1086,6 +1317,40 @@ button:focus-visible {
transition: filter 100ms ease, transform 100ms ease, border-color 100ms ease;
}
+.ability.is-compact {
+ height: clamp(48px, 7.2dvh, 68px);
+ grid-template-columns: clamp(27px, 3.1vw, 37px) minmax(0, 1fr);
+ gap: clamp(3px, .45vw, 7px);
+ padding: 5px;
+ background: linear-gradient(145deg, color-mix(in srgb, var(--ability-color), #0d1b18 92%), rgba(3, 10, 8, .94));
+ box-shadow: 0 6px 18px rgba(0, 0, 0, .32);
+}
+
+.ability.is-compact .ability-icon {
+ width: clamp(27px, 3.1vw, 37px);
+ height: clamp(27px, 3.1vw, 37px);
+ font-size: clamp(14px, 1.45vw, 20px);
+}
+
+.ability.is-compact .ability-copy strong {
+ font-size: clamp(7px, .72vw, 11px);
+}
+
+.ability.is-compact .ability-copy small {
+ display: none;
+}
+
+.ability.is-compact .ability-key,
+.ability.is-compact .ability-pad {
+ font-size: clamp(5px, .46vw, 7px);
+}
+
+.ability.is-compact .cooldown-mask b {
+ width: clamp(28px, 3vw, 38px);
+ height: clamp(28px, 3vw, 38px);
+ font-size: clamp(11px, 1.1vw, 16px);
+}
+
.ability::after {
content: "";
position: absolute;