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
IHLoading field consoleGameplay 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 ( + + ); + } + + 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 ( + + ); +} 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 ; - } - 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 ( - - ); -} - 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 && ( +
+
+ )} + +
+ ); + } + return (
Main viewport960 × 540 CSS · 1920 × 1080 · 120Hz
@@ -43,7 +84,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
{bottom}
+ ); +} + +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 && } + 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;