Compare commits

..
2 Commits
Author SHA1 Message Date
Warren H 7b522e3bc8 Release v0.1.18 2026-07-19 2026-07-19 13:58:47 -04:00
Warren H 016a012c78 Release v0.1.17 2026-07-19 2026-07-19 13:02:46 -04:00
33 changed files with 1345 additions and 139 deletions
+14 -8
View File
@@ -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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.16",
"version": "0.1.18",
"type": "module",
"scripts": {
"predev": "node scripts/sync_basis_transcoder.mjs",
+5 -1
View File
@@ -26,6 +26,10 @@ function GameLoadingScreen() {
);
}
function TacticalLoadingScreen() {
return <section className="display bottom-display game-loading is-lower"><span>IH</span><strong>Loading field console</strong><small>Gameplay remains active</small></section>;
}
function MainApp() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
@@ -225,7 +229,7 @@ function MainApp() {
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} /></Suspense>} /></Suspense>
: <FrontEnd onLaunch={launchGame} />}
</main>
);
+72
View File
@@ -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 (
<button className={`${classes} is-empty`} disabled aria-label={`Empty ${abilityId}`}>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon"></span>
<span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
</button>
);
}
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 (
<button
className={`${classes} ${remaining > 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}`}
>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
{remaining <= 0 && globalRemaining > 0 && (
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as CSSProperties}>
<b>{globalRemaining.toFixed(1)}</b>
</span>
)}
</button>
);
}
+79 -61
View File
@@ -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 <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon"></span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
}
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 (
<button
className={`ability ability-${abilityId} ${remaining > 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}`}
>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
</span>
)}
{remaining <= 0 && globalRemaining > 0 && (
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as React.CSSProperties}>
<b>{globalRemaining.toFixed(1)}</b>
</span>
)}
</button>
);
}
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);
+53 -12
View File
@@ -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<DisplaySurface>(() => 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 <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
}
if (layout === "single") {
const contextOpen = activeSurface === "bottom";
return (
<div className={`single-display-frame ${contextOpen ? "context-open" : ""}`}>
<div className="single-primary-surface">{top}</div>
{contextOpen && (
<div className="single-context-layer" role="dialog" aria-modal="true" aria-label={`${contextLabel} interface`}>
<button className="single-context-backdrop" onClick={() => requestDisplaySurface("top")} aria-label={`Close ${contextLabel.toLowerCase()} interface`} />
<div className="single-context-surface">{bottom}</div>
</div>
)}
<button
className="single-context-toggle"
onClick={toggleSurface}
aria-expanded={contextOpen}
aria-label={contextOpen ? "Return to main view" : `Open ${contextLabel.toLowerCase()} interface`}
>
<b>{contextOpen ? "Return" : contextLabel}</b>
<small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button>
</div>
);
}
return (
<div className={`device-frame active-${activeSurface}`}>
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
@@ -43,7 +84,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
<button
className="native-display-switch"
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
onClick={toggleSurface}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
+19 -1
View File
@@ -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 <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
@@ -225,7 +229,20 @@ function PhaseOverlay() {
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{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"}</small>
<small>{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"}</small>
</div>
);
}
function SingleScreenAbilityBar() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
if (!isSingleScreenLayout() || phase !== "combat" || runMode === "rpg-roguelike") return null;
return (
<div className="single-ability-bar" aria-label="Equipped abilities">
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} compact />)}
</div>
);
}
@@ -402,6 +419,7 @@ export function TopScreen({
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div>
<SingleScreenAbilityBar />
<GoalPopup />
<BlockbreakerScorePopup />
<AetherScorePopup />
@@ -0,0 +1,38 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { createRpgRoguelikeRun, reduceRpgRoguelikeRun } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
import { RpgRunOverlay } from "./RpgRunOverlay";
import { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
describe("RPG roguelike party role UI", () => {
it("renders clear visual and accessible Tank/DPS badges", () => {
const tank = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Tank" }));
const damage = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Damage" }));
expect(tank).toContain('class="rpg-role-badge is-tank"');
expect(tank).toContain('aria-label="Role: Tank"');
expect(damage).toContain('class="rpg-role-badge is-damage"');
expect(damage).toContain('aria-label="Role: DPS"');
});
it("shows role and composition in current-party lists on both displays", () => {
let run = createRpgRoguelikeRun({ seed: 73 });
const damage = run.partyDraft!.offers.find((candidate) => candidate.role === "Damage")!;
run = reduceRpgRoguelikeRun(run, { type: "party-recruit", candidateId: damage.candidateId });
run = reduceRpgRoguelikeRun(run, { type: "party-next-wave" });
const props = { run, onAction: () => undefined };
const main = renderToStaticMarkup(createElement(RpgRunOverlay, props));
const tactical = renderToStaticMarkup(createElement(RpgRunTacticalPanel, props));
expect(main).toContain('aria-label="Current party, 1 of 4: 0 Tanks · 1 DPS"');
expect(main).toMatch(/class="rpg-picked-chip[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(main).toContain('aria-label="Role: DPS"');
expect(tactical).toContain('aria-label="Current party: 0 Tanks · 1 DPS"');
expect(tactical).toMatch(/class="rpg-card-action[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
expect(tactical).toContain('aria-label="Role: DPS"');
});
});
@@ -0,0 +1,15 @@
import type { PartyRole } from "../../game/rpgRoguelike";
import { partyRolePresentation } from "../../game/rpgRoguelike";
export function PartyRoleBadge({ role }: { readonly role: PartyRole }) {
const presentation = partyRolePresentation(role);
return (
<span
className={`rpg-role-badge is-${presentation.className}`}
aria-label={`Role: ${presentation.label}`}
>
<i aria-hidden="true">{presentation.icon}</i>
<b>{presentation.label}</b>
</span>
);
}
+20 -7
View File
@@ -8,6 +8,8 @@ import {
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -17,6 +19,8 @@ import {
currentBoss,
FocusButton,
GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
@@ -26,6 +30,7 @@ import {
type RpgRunUiContext,
type RpgRunUiProps,
} from "./RpgRunUiShared";
import { PartyRoleBadge } from "./PartyRoleBadge";
import "./rpgRoguelike.css";
function DraftFooter({ context, focusId, action, disabled, label, hint }: {
@@ -89,15 +94,15 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
className="rpg-card-action"
disabled={recruited && !canRemove}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
)}
/>
);
})}
</div>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}>
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}: ${partyCompositionLabel(run.roster)}`}>
<strong><span>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</span><small>{partyCompositionLabel(run.roster)}</small></strong>
{run.roster.map((member) => (
<FocusButton
key={member.instanceId}
@@ -106,8 +111,13 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className={`rpg-picked-chip rarity-${member.rarity}`}
disabled={!canRemove}
label={`Remove ${member.name}`}
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton>
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
>
<i style={{ background: member.color }} />
<span className="rpg-picked-copy"><strong>{member.name}</strong><small>{member.className}</small></span>
<PartyRoleBadge role={member.role} />
<b className="rpg-picked-remove" aria-hidden="true">×</b>
</FocusButton>
))}
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)}
</div>
@@ -268,8 +278,11 @@ function Rewards({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
className="rpg-reward-card"
style={runAccentStyle(summary.accent)}
label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}
>
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p><b>Claim</b>
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p>
{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}
<b>Claim</b>
</FocusButton>
);
})}
@@ -304,7 +317,7 @@ function Shop({ context }: { context: RpgRunUiContext }) {
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
className="rpg-card-action"
disabled={disabled}
label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`}
label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
} />
);
@@ -9,6 +9,8 @@ import {
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
partyCompositionLabel,
partyRolePresentation,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
@@ -18,6 +20,8 @@ import {
currentBoss,
FocusButton,
GearCard,
GearStatComparison,
gearComparisonLabel,
PartyCard,
rewardSummary,
RoutePips,
@@ -33,8 +37,11 @@ import "./rpgRoguelike.css";
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context;
return (
<section className="rpg-tactical-section">
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header>
<section className="rpg-tactical-section" aria-label={`Current party: ${partyCompositionLabel(run.roster)}`}>
<header>
<h3>Party</h3>
<span className="rpg-party-composition"><b>{partyCompositionLabel(run.roster)}</b><small>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</small></span>
</header>
<div className="rpg-tactical-party-grid">
{run.roster.map((member) => (
<PartyCard key={member.instanceId} member={member} compact action={interactive ? (
@@ -43,7 +50,7 @@ function TacticalParty({ context, interactive = false }: { context: RpgRunUiCont
focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className="rpg-card-action"
label={`Remove ${member.name}`}
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
><span>Remove</span></FocusButton>
) : undefined} />
))}
@@ -116,6 +123,7 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
{draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
const role = partyRolePresentation(candidate.role);
return (
<FocusButton
key={candidate.candidateId}
@@ -126,9 +134,10 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
style={runAccentStyle(candidate.color)}
disabled={disabled}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
>
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i>
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<i>{role.icon}</i>
<span><small>{candidate.rarity} · {role.label}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b>
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
</FocusButton>
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
{chest.choices.map((choice) => {
const summary = rewardSummary(choice);
return (
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b>
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)} label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p>{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}</span><b>Claim</b>
</FocusButton>
);
})}
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
<div className="rpg-tactical-shop-grid">
{shop.offers.map((offer) => (
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`Buy ${offer.item.name}`}>
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}>
<span>{offer.sold ? "Sold" : "Buy"}</span>
</FocusButton>
} />
+43 -5
View File
@@ -10,9 +10,10 @@ import type {
RpgRoguelikeRunState,
RunGearItem,
} from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
import { PartyRoleBadge } from "./PartyRoleBadge";
export interface RpgRunUiProps {
readonly run: RpgRoguelikeRunState;
@@ -94,11 +95,14 @@ export function FocusButton({
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
const childRect = button.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
if (parent.scrollHeight > parent.clientHeight) {
const overflow = getComputedStyle(parent);
const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
}
if (parent.scrollWidth > parent.clientWidth) {
if (canScrollX && parent.scrollWidth > parent.clientWidth) {
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
}
@@ -207,7 +211,7 @@ export function PartyCard({
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(entry.color)}
>
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div>
<div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
<h3>{entry.name}</h3>
<p>{entry.className}</p>
{!compact && (
@@ -265,6 +269,39 @@ export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): str
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
}
export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string {
const comparison = compareRunGear(run.equipment, item);
const ownerName = gearOwnerName(run, item);
const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none";
const change = comparison.delta >= 0
? `Gain ${comparison.delta} percentage points`
: `Lose ${Math.abs(comparison.delta)} percentage points`;
return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`;
}
export function GearStatComparison({ run, item }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
}) {
const ownerName = gearOwnerName(run, item);
const comparison = compareRunGear(run.equipment, item);
const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none";
const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`;
return (
<span
className="rpg-gear-comparison"
aria-label={gearComparisonLabel(run, item)}
>
<strong>{ownerName} · {comparison.effectLabel}<em>{delta}</em></strong>
<span>
<span><small>Current · {currentRank}</small><b>+{comparison.currentValue}%</b></span>
<i aria-hidden="true"></i>
<span><small>Replacement · +{item.enhancement}</small><b>+{comparison.replacementValue}%</b></span>
</span>
</span>
);
}
export function GearCard({ run, item, price, action }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
<div>
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
<h3>{item.name}</h3>
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p>
{price !== undefined && <p className="rpg-gear-price"> {price}</p>}
<GearStatComparison run={run} item={item} />
</div>
{action}
</article>
+184 -10
View File
@@ -235,6 +235,7 @@
.rpg-card-kicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
color: var(--rarity-color, #e7e9ec);
@@ -249,6 +250,41 @@
font-weight: 600;
}
.rpg-role-badge {
min-width: 44px;
padding: 2px 5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 8px;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
}
.rpg-role-badge.is-tank {
color: #9bd9ff;
background: rgba(65, 145, 199, 0.2);
}
.rpg-role-badge.is-damage {
color: #ffc27c;
background: rgba(204, 111, 55, 0.19);
}
.rpg-role-badge > i {
font-size: 9px;
font-style: normal;
}
.rpg-role-badge > b {
color: inherit;
font-weight: 800;
}
.rpg-party-card h3,
.rpg-spell-card h3,
.rpg-gear-card h3 {
@@ -389,6 +425,18 @@
text-transform: uppercase;
}
.rpg-picked-strip > strong {
width: 108px;
flex: 0 0 108px;
}
.rpg-picked-strip > strong > small {
color: var(--rpg-gold);
font-size: 8px;
letter-spacing: 0;
white-space: nowrap;
}
.rpg-picked-chip,
.rpg-empty-chip,
.rpg-spellbook-chip {
@@ -401,9 +449,12 @@
.rpg-picked-chip {
position: relative;
padding: 4px 18px 4px 10px;
padding: 4px 6px 4px 10px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
align-content: center;
gap: 5px;
cursor: pointer;
text-align: left;
}
@@ -414,18 +465,24 @@
width: 3px;
}
.rpg-picked-chip > small {
.rpg-picked-copy {
min-width: 0;
display: grid;
}
.rpg-picked-copy > strong,
.rpg-picked-copy > small {
overflow: hidden;
color: var(--rpg-muted);
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-picked-chip > b {
position: absolute;
right: 6px;
.rpg-picked-copy > strong { color: var(--rpg-ink); font-size: 10px; }
.rpg-picked-copy > small { color: var(--rpg-muted); font-size: 8px; }
.rpg-picked-chip > .rpg-picked-remove {
color: #82958e;
font-size: 14px;
}
.rpg-empty-chip {
@@ -870,7 +927,7 @@
}
.rpg-gear-card {
min-height: 72px;
min-height: 112px;
padding: 8px 8px 22px 43px;
}
@@ -904,6 +961,12 @@
font-size: 9px;
}
.rpg-gear-card .rpg-gear-price {
margin: 2px 0 0;
color: var(--rpg-gold);
font-weight: 700;
}
.rpg-shop-side-list {
min-height: 0;
overflow-y: auto;
@@ -1118,6 +1181,22 @@
font-size: 9px;
}
.rpg-party-composition {
display: flex;
align-items: baseline;
gap: 7px;
}
.rpg-party-composition > b {
color: var(--rpg-gold);
font-size: 9px;
}
.rpg-party-composition > small {
color: var(--rpg-muted);
font-size: 8px;
}
.rpg-tactical-list {
display: grid;
gap: 4px;
@@ -1400,7 +1479,7 @@ button.rpg-tactical-spell {
}
.rpg-tactical-reward {
min-height: 94px;
min-height: 130px;
padding: 9px 64px 9px 49px;
position: relative;
cursor: pointer;
@@ -1457,12 +1536,88 @@ button.rpg-tactical-spell {
text-transform: uppercase;
}
.rpg-gear-comparison {
min-width: 0;
margin-top: 7px;
padding-top: 6px;
display: grid;
gap: 5px;
color: #dce8e2;
border-top: 1px solid var(--rpg-line);
font-style: normal;
}
.rpg-gear-comparison > strong {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
overflow: hidden;
color: #dce8e2;
font-family: "Rajdhani", "Avenir Next Condensed", sans-serif;
font-size: 9px;
letter-spacing: 0.04em;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-gear-comparison > strong > em {
flex: 0 0 auto;
padding: 2px 4px;
color: #9de1b8;
border-radius: 2px;
background: rgba(70, 167, 108, 0.15);
font-size: 8px;
font-style: normal;
}
.rpg-gear-comparison > span {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 5px;
}
.rpg-gear-comparison > span > span {
min-width: 0;
display: grid;
gap: 1px;
}
.rpg-gear-comparison small,
.rpg-tactical-reward .rpg-gear-comparison small {
overflow: hidden;
color: #82958e;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.1;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.rpg-gear-comparison b {
color: #f1f7f4;
font-size: 12px;
line-height: 1.1;
}
.rpg-gear-comparison > span > i {
color: #75bceb;
font-size: 11px;
font-style: normal;
}
.rpg-run-tactical .rpg-tactical-shop-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rpg-run-tactical .rpg-gear-card {
min-height: 80px;
min-height: 112px;
}
.rpg-service-row {
@@ -1724,6 +1879,15 @@ button.rpg-tactical-spell {
min-height: 92px;
}
.rpg-reward-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
.rpg-reward-card {
min-height: 220px;
}
.rpg-picked-strip,
.rpg-spellbook-strip {
overflow-x: auto;
@@ -1735,6 +1899,10 @@ button.rpg-tactical-spell {
min-width: 100px;
}
.rpg-picked-chip {
min-width: 142px;
}
.rpg-shop-layout {
grid-template-columns: 1fr;
overflow-y: auto;
@@ -1751,6 +1919,12 @@ button.rpg-tactical-spell {
.rpg-tactical-offer > b {
display: none;
}
.rpg-party-composition {
display: grid;
gap: 0;
text-align: right;
}
}
/* Single-display browser fallback gets a usable full-height draft surface. */
+27 -2
View File
@@ -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]);
});
});
+62 -3
View File
@@ -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],
+61 -3
View File
@@ -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({
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
rpgEncounterDifficulty,
} from "./difficulty";
describe("RPG Roguelike encounter difficulty", () => {
it("gives a solo boss the normal two-boss encounter health budget", () => {
expect(rpgEncounterDifficulty("boss-room", 0)).toEqual({
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
});
expect(RPG_SOLO_BOSS_HEALTH_MULTIPLIER).toBeGreaterThan(1.5);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeGreaterThan(1);
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeLessThan(2);
});
it("raises boss durability per room and pressure per act", () => {
const first = rpgEncounterDifficulty("boss-room", 0);
const lateActOne = rpgEncounterDifficulty("boss-room", 2);
const firstActTwo = rpgEncounterDifficulty("boss-room", 3);
expect(lateActOne.healthMultiplier).toBeGreaterThan(first.healthMultiplier);
expect(lateActOne.damageMultiplier).toBe(first.damageMultiplier);
expect(firstActTwo.healthMultiplier).toBeGreaterThan(lateActOne.healthMultiplier);
expect(firstActTwo.damageMultiplier).toBeGreaterThan(lateActOne.damageMultiplier);
});
it("leaves existing two-boss hallway challenge damage tuning intact", () => {
expect(rpgEncounterDifficulty("hallway-challenge", 0)).toEqual({
healthMultiplier: 0.82,
damageMultiplier: 1,
});
const actTwo = rpgEncounterDifficulty("hallway-challenge", 3);
expect(actTwo.healthMultiplier).toBeCloseTo(0.9);
expect(actTwo.damageMultiplier).toBe(1);
});
it("normalizes invalid route indexes to the first room", () => {
expect(rpgEncounterDifficulty("boss-room", -4)).toEqual(rpgEncounterDifficulty("boss-room", 0));
expect(rpgEncounterDifficulty("boss-room", 1.9)).toEqual(rpgEncounterDifficulty("boss-room", 1));
});
});
+43
View File
@@ -0,0 +1,43 @@
import { BOSSES_PER_ACT } from "./types";
export type RpgEncounterKind = "hallway-challenge" | "boss-room";
export interface RpgEncounterDifficulty {
readonly healthMultiplier: number;
readonly damageMultiplier: number;
}
/**
* Party rotations are calibrated around two simultaneous bosses. A solo RPG
* boss therefore carries the full shared health budget, while incoming
* damage stays below a full 2x multiplier so one unavoidable hit cannot stand
* in for two independently targeted mechanics.
*/
export const RPG_SOLO_BOSS_HEALTH_MULTIPLIER = 2;
export const RPG_SOLO_BOSS_DAMAGE_MULTIPLIER = 1.5;
const RPG_BOSS_HEALTH_GROWTH_PER_ROOM = 0.14;
const RPG_BOSS_DAMAGE_GROWTH_PER_ACT = 0.08;
const RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER = 0.82;
const RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT = 0.08;
/** Central mode-specific tuning hook used only when an RPG encounter begins. */
export function rpgEncounterDifficulty(
kind: RpgEncounterKind,
requestedBossIndex: number,
): RpgEncounterDifficulty {
const bossIndex = Math.max(0, Math.floor(requestedBossIndex));
const act = Math.floor(bossIndex / BOSSES_PER_ACT);
if (kind === "hallway-challenge") {
return {
healthMultiplier: RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER + act * RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT,
damageMultiplier: 1,
};
}
return {
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER * (1 + bossIndex * RPG_BOSS_HEALTH_GROWTH_PER_ROOM),
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER * (1 + act * RPG_BOSS_DAMAGE_GROWTH_PER_ACT),
};
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "../bossCatalog";
import { createClassInventory } from "../healers";
import { useGameStore } from "../store";
import type { BossId } from "../types";
import { rpgEncounterDifficulty } from "./difficulty";
function simulateSoloBoss(bossId: BossId, maxSeconds = 120): number {
useGameStore.getState().configureHealer(
"priest",
"Calibration",
createClassInventory("priest"),
bossId,
"encounter",
);
useGameStore.getState().startEncounter();
const profile = rpgEncounterDifficulty("boss-room", 0);
useGameStore.setState((state) => {
const maxHp = Math.round(state.boss.maxHp * profile.healthMultiplier);
return { boss: { ...state.boss, maxHp, hp: maxHp } };
});
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
}));
useGameStore.getState().tick(0.1);
}
return useGameStore.getState().time;
}
describe("RPG Roguelike solo boss duration calibration", () => {
it.each(AVAILABLE_BOSS_IDS)("keeps %s near the two-boss rotation duration budget", (bossId) => {
const duration = simulateSoloBoss(bossId);
expect(useGameStore.getState().phase).toBe("victory");
expect(duration).toBeGreaterThanOrEqual(50);
// Shared baseline targets 5090 seconds, with 100 seconds as the hard cap.
expect(duration).toBeLessThanOrEqual(100);
});
});
+1
View File
@@ -7,3 +7,4 @@ export * from "./rewards";
export * from "./run";
export * from "./playSpace";
export * from "./uiModel";
export * from "./difficulty";
+31
View File
@@ -18,6 +18,14 @@ import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types";
export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"];
export interface RunGearComparison {
readonly currentItem: RunGearItem | undefined;
readonly effectLabel: string;
readonly currentValue: number;
readonly replacementValue: number;
readonly delta: number;
}
const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = {
weapon: { label: "Weapon", statId: "damage" },
armor: { label: "Armor", statId: "maxHealth" },
@@ -36,6 +44,29 @@ export function equippedRunGear(
return equipment[ownerId]?.[slotId];
}
/** Player weapons convert their damage budget into healing power in combat. */
export function runGearEffectLabel(ownerId: RunGearOwnerId, statId: RunGearStatId): string {
if (statId === "damage") return ownerId === "player" ? "Healing power" : "Damage";
if (statId === "maxHealth") return "Max health";
return "Haste";
}
/** Comparison data shared by chest and shop projections on either display. */
export function compareRunGear(
equipment: RunEquipment,
item: RunGearItem,
): RunGearComparison {
const currentItem = equippedRunGear(equipment, item.ownerId, item.slotId);
const currentValue = currentItem?.statId === item.statId ? currentItem.statValue : 0;
return {
currentItem,
effectLabel: runGearEffectLabel(item.ownerId, item.statId),
currentValue,
replacementValue: item.statValue,
delta: item.statValue - currentValue,
};
}
export function createRunGearItem(
id: string,
ownerId: RunGearOwnerId,
@@ -12,6 +12,7 @@ import {
assignRosterToCombatSlots,
autoEquipRunGear,
challengeObjective,
compareRunGear,
createRandomState,
createRpgRoguelikeRun,
createRunGearItem,
@@ -257,6 +258,32 @@ describe("RPG Roguelike deterministic domain", () => {
expect(third.bag).toContain(weaker);
});
it("describes current and replacement gear buffs using combat-facing stat names", () => {
const playerWeapon = createRunGearItem("player-current", "player", "weapon", 2);
const playerUpgrade = createRunGearItem("player-upgrade", "player", "weapon", 5);
const companionUpgrade = createRunGearItem("companion-upgrade", "tank-instance", "weapon", 3);
const armor = createRunGearItem("companion-armor", "tank-instance", "armor", 1);
const trinket = createRunGearItem("companion-trinket", "tank-instance", "trinket", 4);
const equipment = { player: { weapon: playerWeapon } };
expect(compareRunGear(equipment, playerUpgrade)).toEqual({
currentItem: playerWeapon,
effectLabel: "Healing power",
currentValue: 12,
replacementValue: 24,
delta: 12,
});
expect(compareRunGear(equipment, companionUpgrade)).toMatchObject({
currentItem: undefined,
effectLabel: "Damage",
currentValue: 0,
replacementValue: 16,
delta: 16,
});
expect(compareRunGear(equipment, armor).effectLabel).toBe("Max health");
expect(compareRunGear(equipment, trinket).effectLabel).toBe("Haste");
});
it("supports shop buy, sell, rest, revive, and leave", () => {
let state = finishDrafts(501);
const generated = generateRunShop(state.random, state, 1);
+23
View File
@@ -5,6 +5,8 @@ import {
createRpgRoguelikeRun,
createRunGearItem,
moveRpgFocus,
partyCompositionLabel,
partyRolePresentation,
reduceRpgRoguelikeRun,
rpgFocusId,
rpgFocusItems,
@@ -27,6 +29,27 @@ function reachSpellDraft(seed = 810): RpgRoguelikeRunState {
}
describe("RPG Roguelike semantic UI focus", () => {
it("presents party roles as Tank/DPS and summarizes duplicate tanks", () => {
expect(partyRolePresentation("Tank")).toEqual({ label: "Tank", icon: "⬡", className: "tank" });
expect(partyRolePresentation("Damage")).toEqual({ label: "DPS", icon: "⚔", className: "damage" });
expect(partyCompositionLabel([
{ role: "Tank" },
{ role: "Tank" },
{ role: "Damage" },
{ role: "Damage" },
])).toBe("2 Tanks · 2 DPS");
});
it("includes each party role in controller action labels", () => {
const state = createRpgRoguelikeRun({ seed: 19 });
const labels = new Map(rpgFocusItems(state).map((item) => [item.id, item.label]));
for (const candidate of state.partyDraft!.offers) {
expect(labels.get(rpgFocusId.partyOffer(candidate.candidateId))).toBe(
`Recruit ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
);
}
});
it("moves horizontally within card rows and vertically between action groups", () => {
let state = createRpgRoguelikeRun({ seed: 120 });
const offers = state.partyDraft!.offers;
+26 -3
View File
@@ -1,4 +1,4 @@
import type { RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import type { PartyRole, RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
import { canRemovePartyMember } from "./run";
import {
MAX_ACTIVE_ROSTER,
@@ -22,6 +22,29 @@ export interface RpgFocusItem {
export type RpgFocusDirection = "left" | "right" | "up" | "down";
export interface PartyRolePresentation {
readonly label: "Tank" | "DPS";
readonly icon: "⬡" | "⚔";
readonly className: "tank" | "damage";
}
const PARTY_ROLE_PRESENTATIONS: Record<PartyRole, PartyRolePresentation> = {
Tank: { label: "Tank", icon: "⬡", className: "tank" },
Damage: { label: "DPS", icon: "⚔", className: "damage" },
};
/** Player-facing role copy used by party cards, selected-party lists, and controller labels. */
export function partyRolePresentation(role: PartyRole): PartyRolePresentation {
return PARTY_ROLE_PRESENTATIONS[role];
}
/** Compact composition summary for draft surfaces where duplicate tanks must be obvious. */
export function partyCompositionLabel(members: readonly { readonly role: PartyRole }[]): string {
const tankCount = members.reduce((count, member) => count + (member.role === "Tank" ? 1 : 0), 0);
const damageCount = members.length - tankCount;
return `${tankCount} ${tankCount === 1 ? "Tank" : "Tanks"} · ${damageCount} DPS`;
}
export const rpgFocusId = {
partyOffer: (candidateId: string) => `party-offer:${candidateId}`,
partyMember: (memberId: string) => `party-member:${memberId}`,
@@ -73,7 +96,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return [];
return [action(
rpgFocusId.partyOffer(candidate.candidateId),
`${recruited ? "Remove" : "Recruit"} ${candidate.name}`,
`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
recruited
? { type: "party-remove", memberId: candidate.candidateId }
: { type: "party-recruit", candidateId: candidate.candidateId },
@@ -81,7 +104,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
});
const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action(
rpgFocusId.partyMember(member.instanceId),
`Remove ${member.name}`,
`Remove ${member.name}, ${partyRolePresentation(member.role).label}`,
{ type: "party-remove", memberId: member.instanceId },
)) : [];
return [
+26 -1
View File
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it } from "vitest";
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { createClassInventory } from "./healers";
import { createDefaultGearProgress } from "./progression/gear";
import { equipPassiveInfusion } from "./progression/infusions";
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE } from "./rpgRoguelike";
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
import { useGameStore } from "./store";
function finishRpgDrafts() {
@@ -113,6 +114,30 @@ describe("RPG Roguelike store integration", () => {
expect(state.endlessMode).toBe(true);
});
it("keeps dual hallway tuning and applies the solo boss-room difficulty profile", () => {
finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
const challenge = useGameStore.getState();
const challengeDifficulty = rpgEncounterDifficulty("hallway-challenge", 0);
expect(challenge.additionalBosses).toHaveLength(1);
expect(challenge.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[challenge.boss.id].maxHp * challengeDifficulty.healthMultiplier,
));
expect(challenge.difficultyDamageMultiplier).toBe(challengeDifficulty.damageMultiplier);
completeCurrentChallenge();
useGameStore.getState().dispatchRpgAction({ type: "boss-start" });
const bossRoom = useGameStore.getState();
const bossDifficulty = rpgEncounterDifficulty("boss-room", 0);
expect(bossRoom.additionalBosses).toHaveLength(0);
expect(bossRoom.boss.maxHp).toBe(Math.round(
BOSS_DEFINITIONS[bossRoom.boss.id].maxHp * bossDifficulty.healthMultiplier,
));
expect(bossRoom.difficultyDamageMultiplier).toBe(bossDifficulty.damageMultiplier);
});
it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => {
finishRpgDrafts();
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
+13 -1
View File
@@ -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);
+20 -11
View File
@@ -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,
@@ -120,7 +122,6 @@ import {
} from "./aetherAssault";
import {
assignRosterToCombatSlots,
BOSSES_PER_ACT,
createRpgRoguelikeRun,
reduceRpgRoguelikeRun,
selectCurrentBossId,
@@ -136,6 +137,7 @@ import {
spellRankPowerMultiplier,
type RpgPartyDamageProfiles,
} from "./rpgRoguelike/combatAdapter";
import { rpgEncounterDifficulty } from "./rpgRoguelike/difficulty";
import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace";
import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel";
@@ -266,6 +268,7 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
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,
@@ -700,9 +704,12 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
? [currentBossId, challengePartner]
: [currentBossId];
const layout: EncounterLayout = challenge ? "hockey" : "standard";
const act = Math.floor(run.bossIndex / BOSSES_PER_ACT);
const healthMultiplier = (challenge ? 0.82 + act * 0.08 : 1 + run.bossIndex * 0.14)
* DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier;
const modeDifficulty = rpgEncounterDifficulty(
challenge ? "hallway-challenge" : "boss-room",
run.bossIndex,
);
const baseDifficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const healthMultiplier = modeDifficulty.healthMultiplier * baseDifficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
bossId,
index,
@@ -747,6 +754,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
party,
gearModifiers: projection.gearModifiers,
healingMultiplier: projection.gearModifiers.aelia.healingPower,
difficultyDamageMultiplier: modeDifficulty.damageMultiplier * baseDifficulty.damageMultiplier,
partyCombat: createPartyCombatState(party),
partyDamageEvents: [],
partyPositions: freshPartyPositions(bossIds, layout),
@@ -1106,7 +1114,7 @@ export const useGameStore = create<GameState>((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;
@@ -1121,8 +1129,8 @@ export const useGameStore = create<GameState>((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,
@@ -2104,7 +2112,8 @@ export const useGameStore = create<GameState>((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) {
@@ -2186,7 +2195,7 @@ export const useGameStore = create<GameState>((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");
@@ -2254,7 +2263,7 @@ export const useGameStore = create<GameState>((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,
+14
View File
@@ -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");
+3
View File
@@ -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();
+19
View File
@@ -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");
});
});
+29
View File
@@ -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";
}
+10 -1
View File
@@ -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<DisplaySurface>(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<DisplaySurface>).detail);
const onSurface = (event: Event) => {
currentSurface = (event as CustomEvent<DisplaySurface>).detail;
listener(currentSurface);
};
window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface);
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
}
+265
View File
@@ -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;