Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
437e70fc58 | ||
|
|
7b522e3bc8 |
@@ -41,11 +41,16 @@ platform tools and a connected Thor, build and install it with:
|
|||||||
pnpm android:install
|
pnpm android:install
|
||||||
```
|
```
|
||||||
|
|
||||||
The first Android milestone uses the complete single-display fallback. Press
|
The Android host routes the main and tactical surfaces to separate physical
|
||||||
Select (or Tab with a keyboard) to switch between the main game surface and the
|
Thor displays while preserving one authoritative game state. If only one
|
||||||
620 × 540 tactical surface. Native routing to both physical Thor displays is the
|
display is available, Select (or Tab with a keyboard) opens the tactical surface
|
||||||
next milestone; it needs two Android display contexts backed by one shared game
|
over the main game view.
|
||||||
state rather than two independent WebViews.
|
|
||||||
|
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
|
## TrueNAS deployment
|
||||||
|
|
||||||
@@ -140,6 +145,7 @@ outside the repository.
|
|||||||
- `Q` and `E` / D-pad: cycle party target
|
- `Q` and `E` / D-pad: cycle party target
|
||||||
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
||||||
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
|
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
|
||||||
|
- `Select` / `Tab`: open or close the tactical interface on one-screen devices
|
||||||
- `M`: tactical map
|
- `M`: tactical map
|
||||||
- `I`: inventory and item tooltip
|
- `I`: inventory and item tooltip
|
||||||
- `Enter` / `START`: begin or reset encounter
|
- `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
|
- 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
|
- 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
|
The Android build uses distinct display contexts that project one authoritative
|
||||||
physical Thor displays still needs distinct Android display contexts that project
|
game state across both physical Thor displays. PC and Steam Deck use the same top
|
||||||
one authoritative game state.
|
surface with an adaptive tactical overlay.
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.17",
|
"version": "0.1.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/sync_basis_transcoder.mjs",
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
|||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
||||||
|
const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
|
||||||
const authAttempts = new Map();
|
const authAttempts = new Map();
|
||||||
|
|
||||||
function apiError(message, status = 400) {
|
function apiError(message, status = 400) {
|
||||||
@@ -650,6 +651,7 @@ export function createGameApiHandler(options = {}) {
|
|||||||
match: {
|
match: {
|
||||||
id: match.id,
|
id: match.id,
|
||||||
seed: match.seed,
|
seed: match.seed,
|
||||||
|
countdownEndsAtMs: match.countdownEndsAtMs,
|
||||||
opponentName: opponent.hunterName,
|
opponentName: opponent.hunterName,
|
||||||
role: ticket.side,
|
role: ticket.side,
|
||||||
},
|
},
|
||||||
@@ -693,6 +695,7 @@ export function createGameApiHandler(options = {}) {
|
|||||||
id: matchId,
|
id: matchId,
|
||||||
seed: randomBytes(4).readUInt32BE(0) || 1,
|
seed: randomBytes(4).readUInt32BE(0) || 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
players: { host: opponent, guest: ticket },
|
players: { host: opponent, guest: ticket },
|
||||||
snapshots: { host: null, guest: null },
|
snapshots: { host: null, guest: null },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
|||||||
assert.equal(betaQueue.body.status, "matched");
|
assert.equal(betaQueue.body.status, "matched");
|
||||||
assert.equal(betaQueue.body.match.role, "guest");
|
assert.equal(betaQueue.body.match.role, "guest");
|
||||||
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
||||||
|
assert.ok(betaQueue.body.match.countdownEndsAtMs > Date.now());
|
||||||
|
|
||||||
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
||||||
headers: { Authorization: `Bearer ${alphaToken}` },
|
headers: { Authorization: `Bearer ${alphaToken}` },
|
||||||
@@ -236,6 +237,7 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
|||||||
assert.equal(alphaMatched.body.match.role, "host");
|
assert.equal(alphaMatched.body.match.role, "host");
|
||||||
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
||||||
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
|
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
|
||||||
|
assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
|
||||||
|
|
||||||
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
||||||
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
|
|||||||
+24
-1
@@ -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() {
|
function MainApp() {
|
||||||
useForcedThorDisplays();
|
useForcedThorDisplays();
|
||||||
useAuthoritativeDualScreenSync();
|
useAuthoritativeDualScreenSync();
|
||||||
@@ -147,6 +151,25 @@ function MainApp() {
|
|||||||
};
|
};
|
||||||
}, [screen]);
|
}, [screen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screen !== "game") return;
|
||||||
|
let timer: number | undefined;
|
||||||
|
const autoStart = () => {
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
if (state.runMode !== "hockey-healing-pvp" || state.phase !== "briefing") return;
|
||||||
|
const remaining = state.hockeyPvp.countdownEndsAtMs - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
state.startEncounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(autoStart, remaining + 16);
|
||||||
|
};
|
||||||
|
autoStart();
|
||||||
|
return () => {
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [screen]);
|
||||||
|
|
||||||
useActionBindings(screen === "game", leaveGame);
|
useActionBindings(screen === "game", leaveGame);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -225,7 +248,7 @@ function MainApp() {
|
|||||||
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
||||||
</header>
|
</header>
|
||||||
{screen === "game"
|
{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} />}
|
: <FrontEnd onLaunch={launchGame} />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { ABILITY_ORDER } from "../game/data";
|
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 { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||||
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
import { BARRIER_RADIUS, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
|
||||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||||
import type { BottomTab, PartyMember } from "../game/types";
|
import type { BottomTab, PartyMember } from "../game/types";
|
||||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
HOCKEY_PVP_GOAL_Z,
|
HOCKEY_PVP_GOAL_Z,
|
||||||
HOCKEY_PVP_SIDE_OFFSET_Z,
|
HOCKEY_PVP_SIDE_OFFSET_Z,
|
||||||
} from "../game/hockeyHealingPvp";
|
} from "../game/hockeyHealingPvp";
|
||||||
import { bottomTabsFor } from "../game/bottomTabs";
|
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||||
|
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
||||||
import {
|
import {
|
||||||
BLOCKBREAKER_BREACH_DAMAGE,
|
BLOCKBREAKER_BREACH_DAMAGE,
|
||||||
BLOCKBREAKER_BRICK_COLORS,
|
BLOCKBREAKER_BRICK_COLORS,
|
||||||
@@ -31,9 +32,82 @@ import {
|
|||||||
blockbreakerTimeMultiplier,
|
blockbreakerTimeMultiplier,
|
||||||
} from "../game/blockbreaker";
|
} from "../game/blockbreaker";
|
||||||
import { aetherShipColor } from "./aetherAssaultVisuals";
|
import { aetherShipColor } from "./aetherAssaultVisuals";
|
||||||
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
|
|
||||||
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
|
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
|
||||||
import { isBeaconOfLightTarget } from "../game/healerMechanics";
|
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() {
|
function RewardSummary() {
|
||||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||||
@@ -113,62 +187,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() {
|
function AbilityTray() {
|
||||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||||
const healer = HEALER_CLASSES[healerClassId];
|
const healer = HEALER_CLASSES[healerClassId];
|
||||||
@@ -234,6 +252,8 @@ function BriefingPanel() {
|
|||||||
const blockbreakerMode = activityMode === "blockbreaker";
|
const blockbreakerMode = activityMode === "blockbreaker";
|
||||||
const aetherMode = activityMode === "aether-assault";
|
const aetherMode = activityMode === "aether-assault";
|
||||||
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
||||||
|
const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
|
||||||
|
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, countdownEndsAtMs);
|
||||||
return (
|
return (
|
||||||
<div className="briefing-panel">
|
<div className="briefing-panel">
|
||||||
<div className="briefing-class">
|
<div className="briefing-class">
|
||||||
@@ -241,7 +261,7 @@ function BriefingPanel() {
|
|||||||
<span>Chosen discipline</span>
|
<span>Chosen discipline</span>
|
||||||
<h2>{healer.specialization}</h2>
|
<h2>{healer.specialization}</h2>
|
||||||
<p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</>}</p>
|
<p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</>}</p>
|
||||||
<button className="start-button" onClick={startEncounter}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? `Face ${opponentName}` : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
|
<button className="start-button" onClick={startEncounter} disabled={pvpMode}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? pvpCountdownSeconds > 0 ? `Match starts in ${pvpCountdownSeconds}` : "Match starting now" : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{pvpMode ? "Automatic start" : `${DEFAULT_CONTROLLER_GLYPHS.start} / ENTER`}</small></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="briefing-kit">
|
<div className="briefing-kit">
|
||||||
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
||||||
@@ -706,6 +726,7 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
||||||
|
useSingleScreenTacticalInput();
|
||||||
const activeTab = useGameStore((state) => state.activeTab);
|
const activeTab = useGameStore((state) => state.activeTab);
|
||||||
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
|
|||||||
@@ -1,39 +1,80 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||||
|
import { resolveDisplayLayout } from "../platform/displayLayout";
|
||||||
import { subscribeControllerToken } from "../input/controller";
|
import { subscribeControllerToken } from "../input/controller";
|
||||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
|
||||||
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
|
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 [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
|
||||||
const activeSurfaceRef = useRef(activeSurface);
|
const activeSurfaceRef = useRef(activeSurface);
|
||||||
activeSurfaceRef.current = activeSurface;
|
activeSurfaceRef.current = activeSurface;
|
||||||
|
const showSurface = useCallback((surface: DisplaySurface) => {
|
||||||
|
activeSurfaceRef.current = surface;
|
||||||
|
setActiveSurface(surface);
|
||||||
|
}, []);
|
||||||
|
const toggleSurface = useCallback(() => {
|
||||||
|
requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!document.documentElement.classList.contains("native-platform")) return;
|
|
||||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
|
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
|
||||||
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
|
if (layout === "thor-preview") return;
|
||||||
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
|
requestDisplaySurface("top");
|
||||||
|
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
|
||||||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||||
if (token === "Button8" && !repeat) toggle();
|
if (token === "Button8" && !repeat) toggleSurface();
|
||||||
});
|
});
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Tab" || event.repeat) return;
|
if (event.repeat) return;
|
||||||
|
if (event.key === "Escape" && activeSurfaceRef.current === "bottom") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggle();
|
requestDisplaySurface("top");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
event.preventDefault();
|
||||||
|
toggleSurface();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
unsubscribeSurface();
|
unsubscribeSurface();
|
||||||
unsubscribeController();
|
unsubscribeController();
|
||||||
|
requestDisplaySurface("top");
|
||||||
};
|
};
|
||||||
}, [dedicatedSurface]);
|
}, [dedicatedSurface, layout, showSurface, toggleSurface]);
|
||||||
|
|
||||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
|
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
|
||||||
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
|
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 (
|
return (
|
||||||
<div className={`device-frame active-${activeSurface}`}>
|
<div className={`device-frame active-${activeSurface}`}>
|
||||||
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
|
<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>
|
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
|
||||||
<button
|
<button
|
||||||
className="native-display-switch"
|
className="native-display-switch"
|
||||||
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
|
onClick={toggleSurface}
|
||||||
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
||||||
>
|
>
|
||||||
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { DualDisplayFrame } from "./DualDisplayFrame";
|
|||||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
import {
|
import {
|
||||||
HOCKEY_PVP_GOAL_DAMAGE,
|
HOCKEY_PVP_GOAL_DAMAGE,
|
||||||
|
HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
||||||
hockeyPvpBossAt,
|
hockeyPvpBossAt,
|
||||||
randomHockeyPvpCpuName,
|
randomHockeyPvpCpuName,
|
||||||
@@ -1593,6 +1594,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
||||||
opponentName: randomHockeyPvpCpuName(),
|
opponentName: randomHockeyPvpCpuName(),
|
||||||
role: "cpu",
|
role: "cpu",
|
||||||
|
countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const cancelPvpQueue = () => {
|
const cancelPvpQueue = () => {
|
||||||
@@ -1624,6 +1626,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
completePvpQueue({
|
completePvpQueue({
|
||||||
matchId: joined.match.id,
|
matchId: joined.match.id,
|
||||||
seed: joined.match.seed,
|
seed: joined.match.seed,
|
||||||
|
countdownEndsAtMs: joined.match.countdownEndsAtMs,
|
||||||
opponentName: joined.match.opponentName,
|
opponentName: joined.match.opponentName,
|
||||||
role: joined.match.role,
|
role: joined.match.role,
|
||||||
});
|
});
|
||||||
@@ -1638,6 +1641,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
completePvpQueue({
|
completePvpQueue({
|
||||||
matchId: result.match.id,
|
matchId: result.match.id,
|
||||||
seed: result.match.seed,
|
seed: result.match.seed,
|
||||||
|
countdownEndsAtMs: result.match.countdownEndsAtMs,
|
||||||
opponentName: result.match.opponentName,
|
opponentName: result.match.opponentName,
|
||||||
role: result.match.role,
|
role: result.match.role,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import { tankAuraProtects } from "../game/partyCombat";
|
|||||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
||||||
|
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||||
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
||||||
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
||||||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||||||
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
|
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 }))));
|
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
|
||||||
GameScene.displayName = "MemoizedGameScene";
|
GameScene.displayName = "MemoizedGameScene";
|
||||||
@@ -165,6 +169,11 @@ function PhaseOverlay() {
|
|||||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||||
|
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(
|
||||||
|
activityMode === "hockey-healing-pvp" && phase === "briefing",
|
||||||
|
hockeyPvp.countdownEndsAtMs,
|
||||||
|
);
|
||||||
|
const singleScreen = isSingleScreenLayout();
|
||||||
if (runMode === "rpg-roguelike") return null;
|
if (runMode === "rpg-roguelike") return null;
|
||||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||||
@@ -219,13 +228,35 @@ function PhaseOverlay() {
|
|||||||
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
|
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
|
||||||
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
|
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
|
||||||
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
|
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
|
||||||
|
const briefingPrompt = pvpMode
|
||||||
|
? pvpCountdownSeconds > 0
|
||||||
|
? `Match starts automatically in ${pvpCountdownSeconds}`
|
||||||
|
: "Match starting now"
|
||||||
|
: singleScreen
|
||||||
|
? "Press Start / Enter to begin"
|
||||||
|
: "Begin from lower display";
|
||||||
return (
|
return (
|
||||||
<div className={`phase-overlay phase-${phase}`}>
|
<div className={`phase-overlay phase-${phase}`}>
|
||||||
<div className="phase-sigil">✦</div>
|
<div className="phase-sigil">✦</div>
|
||||||
<span>{eyebrow}</span>
|
<span>{eyebrow}</span>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{copy}</p>
|
<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>{phase === "briefing"
|
||||||
|
? briefingPrompt
|
||||||
|
: singleScreen
|
||||||
|
? showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart"
|
||||||
|
: 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -402,6 +433,7 @@ export function TopScreen({
|
|||||||
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>1–6</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>}
|
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b>☰</b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
|
||||||
</div>
|
</div>
|
||||||
|
<SingleScreenAbilityBar />
|
||||||
<GoalPopup />
|
<GoalPopup />
|
||||||
<BlockbreakerScorePopup />
|
<BlockbreakerScorePopup />
|
||||||
<AetherScorePopup />
|
<AetherScorePopup />
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface HockeyPvpQueueResult {
|
|||||||
match?: {
|
match?: {
|
||||||
id: string;
|
id: string;
|
||||||
seed: number;
|
seed: number;
|
||||||
|
countdownEndsAtMs: number;
|
||||||
opponentName: string;
|
opponentName: string;
|
||||||
role: Exclude<HockeyPvpRole, "cpu">;
|
role: Exclude<HockeyPvpRole, "cpu">;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,18 +5,27 @@ import {
|
|||||||
advanceHockeyPvpPuck,
|
advanceHockeyPvpPuck,
|
||||||
createHockeyPvpState,
|
createHockeyPvpState,
|
||||||
hockeyPvpBossAt,
|
hockeyPvpBossAt,
|
||||||
|
hockeyPvpCountdownSeconds,
|
||||||
hockeyPvpDampeningPercent,
|
hockeyPvpDampeningPercent,
|
||||||
hockeyPvpHealingEffectiveness,
|
hockeyPvpHealingEffectiveness,
|
||||||
hockeyPvpPuckSpeed,
|
hockeyPvpPuckSpeed,
|
||||||
mirrorHockeyPvpPuck,
|
mirrorHockeyPvpPuck,
|
||||||
|
reconcileHockeyPvpPuck,
|
||||||
} from "./hockeyHealingPvp";
|
} from "./hockeyHealingPvp";
|
||||||
|
|
||||||
describe("Healing Hockey PVP", () => {
|
describe("Healing Hockey PVP", () => {
|
||||||
it("starts rallies at the faster default puck speed", () => {
|
it("starts rallies at the faster default puck speed", () => {
|
||||||
const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" });
|
const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" });
|
||||||
|
|
||||||
expect(hockeyPvpPuckSpeed(0)).toBe(7.2);
|
expect(hockeyPvpPuckSpeed(0)).toBe(7.8);
|
||||||
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.2);
|
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts down five whole seconds and never returns a negative value", () => {
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 5_000)).toBe(5);
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 9_001)).toBe(1);
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 10_000)).toBe(0);
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 12_000)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds five percent global dampening for every boss killed by either party", () => {
|
it("adds five percent global dampening for every boss killed by either party", () => {
|
||||||
@@ -76,4 +85,28 @@ describe("Healing Hockey PVP", () => {
|
|||||||
lastGoalSide: "local",
|
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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface HockeyPvpMatchConfig {
|
|||||||
seed: number;
|
seed: number;
|
||||||
opponentName: string;
|
opponentName: string;
|
||||||
role: HockeyPvpRole;
|
role: HockeyPvpRole;
|
||||||
|
countdownEndsAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpPuckState {
|
export interface HockeyPvpPuckState {
|
||||||
@@ -25,6 +26,7 @@ export interface HockeyPvpPuckState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
|
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
|
||||||
|
countdownEndsAtMs: number;
|
||||||
status: "inactive" | "live" | "won" | "lost";
|
status: "inactive" | "live" | "won" | "lost";
|
||||||
aimDirection: WorldPosition;
|
aimDirection: WorldPosition;
|
||||||
opponentBossKills: number;
|
opponentBossKills: number;
|
||||||
@@ -59,13 +61,17 @@ export const HOCKEY_PVP_GOAL_HALF_WIDTH = 8;
|
|||||||
export const HOCKEY_PVP_PUCK_RADIUS = 0.42;
|
export const HOCKEY_PVP_PUCK_RADIUS = 0.42;
|
||||||
export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
|
export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
|
||||||
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
|
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_DAMPENING_PER_BOSS_PERCENT = 5;
|
||||||
|
export const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
|
||||||
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
|
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
const STARTING_SPEED = 7.2;
|
const STARTING_SPEED = 7.8;
|
||||||
const MAX_SPEED = 11.5;
|
const MAX_SPEED = 12.2;
|
||||||
const MAX_SUBSTEPS = 10;
|
const MAX_SUBSTEPS = 10;
|
||||||
const MAX_SUBSTEP_DISTANCE = 0.32;
|
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;
|
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 {
|
export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number {
|
||||||
@@ -95,6 +101,10 @@ export function hockeyPvpPuckSpeed(totalReturns: number) {
|
|||||||
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16);
|
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hockeyPvpCountdownSeconds(countdownEndsAtMs: number, nowMs = Date.now()) {
|
||||||
|
return Math.max(0, Math.ceil((countdownEndsAtMs - nowMs) / 1_000));
|
||||||
|
}
|
||||||
|
|
||||||
function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition {
|
function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition {
|
||||||
const x = SERVE_LANES[serveIndex % SERVE_LANES.length] * HOCKEY_PVP_GOAL_HALF_WIDTH;
|
const x = SERVE_LANES[serveIndex % SERVE_LANES.length] * HOCKEY_PVP_GOAL_HALF_WIDTH;
|
||||||
const z = side === "local" ? HOCKEY_PVP_GOAL_Z : -HOCKEY_PVP_GOAL_Z;
|
const z = side === "local" ? HOCKEY_PVP_GOAL_Z : -HOCKEY_PVP_GOAL_Z;
|
||||||
@@ -107,6 +117,7 @@ export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpSt
|
|||||||
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
|
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
|
||||||
return {
|
return {
|
||||||
...match,
|
...match,
|
||||||
|
countdownEndsAtMs: config?.countdownEndsAtMs ?? 0,
|
||||||
status: config ? "live" : "inactive",
|
status: config ? "live" : "inactive",
|
||||||
puckPosition: [0, 0],
|
puckPosition: [0, 0],
|
||||||
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
|
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
|
||||||
@@ -161,6 +172,59 @@ function resetAfterGoal(state: HockeyPvpPuckState, side: HockeyPvpGoalSide) {
|
|||||||
state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns);
|
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(
|
export function advanceHockeyPvpPuck(
|
||||||
source: HockeyPvpState,
|
source: HockeyPvpState,
|
||||||
step: {
|
step: {
|
||||||
@@ -171,7 +235,10 @@ export function advanceHockeyPvpPuck(
|
|||||||
opponentAimDirection: WorldPosition;
|
opponentAimDirection: WorldPosition;
|
||||||
},
|
},
|
||||||
): HockeyPvpState {
|
): 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 = {
|
const state: HockeyPvpState = {
|
||||||
...source,
|
...source,
|
||||||
puckPosition: [...source.puckPosition],
|
puckPosition: [...source.puckPosition],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createClassInventory } from "./healers";
|
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 { upcomingEncounterMechanic, useGameStore } from "./store";
|
||||||
import { freshParty } from "./data";
|
import { freshParty } from "./data";
|
||||||
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear";
|
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear";
|
||||||
@@ -23,6 +23,8 @@ function createMaxedGear() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Healing Hockey PVP encounter integration", () => {
|
describe("Healing Hockey PVP encounter integration", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useGameStore.getState().configureHealer(
|
useGameStore.getState().configureHealer(
|
||||||
"priest",
|
"priest",
|
||||||
@@ -42,6 +44,28 @@ describe("Healing Hockey PVP encounter integration", () => {
|
|||||||
expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0));
|
expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0));
|
||||||
expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id);
|
expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id);
|
||||||
expect(state.hockeyPvp.opponentName).toBe("CPU Aster");
|
expect(state.hockeyPvp.opponentName).toBe("CPU Aster");
|
||||||
|
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores start input until shared five-second countdown ends", () => {
|
||||||
|
const now = vi.spyOn(Date, "now").mockReturnValue(5_000);
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
"priest",
|
||||||
|
"Aelia",
|
||||||
|
createClassInventory("priest"),
|
||||||
|
[hockeyPvpBossAt(MATCH.seed, 0)],
|
||||||
|
"hockey-healing-pvp",
|
||||||
|
undefined,
|
||||||
|
"initiate",
|
||||||
|
{ ...MATCH, countdownEndsAtMs: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
expect(useGameStore.getState().phase).toBe("briefing");
|
||||||
|
|
||||||
|
now.mockReturnValue(10_000);
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
expect(useGameStore.getState().phase).toBe("combat");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes both parties to default base gear without changing saved upgrades", () => {
|
it("normalizes both parties to default base gear without changing saved upgrades", () => {
|
||||||
@@ -170,13 +194,16 @@ describe("Healing Hockey PVP encounter integration", () => {
|
|||||||
expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id);
|
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().startEncounter();
|
||||||
useGameStore.getState().setActiveTab("map");
|
useGameStore.getState().setActiveTab("map");
|
||||||
useGameStore.setState((state) => ({
|
useGameStore.setState((state) => ({
|
||||||
hockeyPvpOpponent: {
|
hockeyPvpOpponent: {
|
||||||
...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);
|
useGameStore.getState().tick(0.01);
|
||||||
@@ -185,6 +212,60 @@ describe("Healing Hockey PVP encounter integration", () => {
|
|||||||
expect(useGameStore.getState().activeTab).toBe("combat");
|
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", () => {
|
it("keeps HUD mechanic data defined during instant boss replacement", () => {
|
||||||
useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } }));
|
useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } }));
|
||||||
expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({
|
expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({
|
||||||
|
|||||||
+13
-1
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { BULL_CHARGE } from "./bossMechanics";
|
import { BULL_CHARGE } from "./bossMechanics";
|
||||||
import { distance, pointToSegmentDistance } from "./geometry";
|
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 { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||||
import { healingEffect } from "./healerEffects";
|
import { healingEffect } from "./healerEffects";
|
||||||
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
|
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", () => {
|
it("uses a three-second Purify cooldown for every healer class", () => {
|
||||||
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
|
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
|
||||||
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
|
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
|
||||||
|
|||||||
+17
-9
@@ -70,6 +70,7 @@ import {
|
|||||||
type HockeyHealingState,
|
type HockeyHealingState,
|
||||||
} from "./hockeyHealing";
|
} from "./hockeyHealing";
|
||||||
import {
|
import {
|
||||||
|
HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER,
|
||||||
HOCKEY_PVP_GOAL_DAMAGE,
|
HOCKEY_PVP_GOAL_DAMAGE,
|
||||||
advanceHockeyPvpCpuGoalie,
|
advanceHockeyPvpCpuGoalie,
|
||||||
advanceHockeyPvpPuck,
|
advanceHockeyPvpPuck,
|
||||||
@@ -77,6 +78,7 @@ import {
|
|||||||
hockeyPvpHealingEffectiveness,
|
hockeyPvpHealingEffectiveness,
|
||||||
hockeyPvpBossAt,
|
hockeyPvpBossAt,
|
||||||
mirrorHockeyPvpPuck,
|
mirrorHockeyPvpPuck,
|
||||||
|
reconcileHockeyPvpPuck,
|
||||||
type HockeyPvpMatchConfig,
|
type HockeyPvpMatchConfig,
|
||||||
type HockeyPvpRemoteSnapshot,
|
type HockeyPvpRemoteSnapshot,
|
||||||
type HockeyPvpState,
|
type HockeyPvpState,
|
||||||
@@ -266,6 +268,7 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
|
|||||||
|
|
||||||
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
|
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
|
||||||
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
|
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_RADIUS = 4;
|
||||||
export const BARRIER_DAMAGE_REDUCTION = 0.3;
|
export const BARRIER_DAMAGE_REDUCTION = 0.3;
|
||||||
@@ -628,7 +631,8 @@ function initialState(
|
|||||||
runModifiers,
|
runModifiers,
|
||||||
healingMultiplier: gearModifiers.aelia.healingPower,
|
healingMultiplier: gearModifiers.aelia.healingPower,
|
||||||
difficultySlug,
|
difficultySlug,
|
||||||
difficultyDamageMultiplier: difficulty.damageMultiplier,
|
difficultyDamageMultiplier: difficulty.damageMultiplier
|
||||||
|
* (runMode === "hockey-healing-pvp" ? HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER : 1),
|
||||||
gearProgress,
|
gearProgress,
|
||||||
gearModifiers,
|
gearModifiers,
|
||||||
time: 0,
|
time: 0,
|
||||||
@@ -811,6 +815,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
|
|
||||||
startEncounter: () => {
|
startEncounter: () => {
|
||||||
const current = get();
|
const current = get();
|
||||||
|
if (current.runMode === "hockey-healing-pvp"
|
||||||
|
&& current.phase === "briefing"
|
||||||
|
&& Date.now() < current.hockeyPvp.countdownEndsAtMs) return;
|
||||||
if (current.runMode === "rpg-roguelike" && current.rpgRun) {
|
if (current.runMode === "rpg-roguelike" && current.rpgRun) {
|
||||||
if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
|
if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
|
||||||
else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" });
|
else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" });
|
||||||
@@ -832,7 +839,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
difficultySlug,
|
difficultySlug,
|
||||||
seenBossIds,
|
seenBossIds,
|
||||||
runMode === "hockey-healing-pvp"
|
runMode === "hockey-healing-pvp"
|
||||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
|
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
|
||||||
: undefined,
|
: undefined,
|
||||||
abilityLoadout,
|
abilityLoadout,
|
||||||
),
|
),
|
||||||
@@ -885,7 +892,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
difficultySlug,
|
difficultySlug,
|
||||||
[],
|
[],
|
||||||
runMode === "hockey-healing-pvp"
|
runMode === "hockey-healing-pvp"
|
||||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
|
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
|
||||||
: undefined,
|
: undefined,
|
||||||
abilityLoadout,
|
abilityLoadout,
|
||||||
));
|
));
|
||||||
@@ -1110,7 +1117,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
|
applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
|
||||||
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
|
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
|
||||||
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
|
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
|
||||||
? mirrorHockeyPvpPuck(hostPuck)
|
? reconcileHockeyPvpPuck(state.hockeyPvp, mirrorHockeyPvpPuck(hostPuck))
|
||||||
: undefined;
|
: undefined;
|
||||||
const previousLocalGoals = state.hockeyPvp.localGoalsConceded;
|
const previousLocalGoals = state.hockeyPvp.localGoalsConceded;
|
||||||
const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals;
|
const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals;
|
||||||
@@ -1125,8 +1132,8 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
})
|
})
|
||||||
: state.party;
|
: state.party;
|
||||||
const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
|
const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
|
||||||
const opponentWiped = isPartyWiped(opponentParty);
|
const opponentWiped = areAllNonHealerAlliesDefeated(opponentParty);
|
||||||
const localWiped = isPartyWiped(party);
|
const localWiped = areAllNonHealerAlliesDefeated(party);
|
||||||
const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase;
|
const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase;
|
||||||
return {
|
return {
|
||||||
party,
|
party,
|
||||||
@@ -2108,7 +2115,8 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost";
|
const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost";
|
||||||
const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost";
|
const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost";
|
||||||
const pvpMode = state.activityMode === "hockey-healing-pvp";
|
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 rpgChallengeActive = rpgRun?.phase === "challenge-active";
|
||||||
const rpgBossActive = rpgRun?.phase === "boss-combat";
|
const rpgBossActive = rpgRun?.phase === "boss-combat";
|
||||||
if (rpgChallengeActive && rpgRun) {
|
if (rpgChallengeActive && rpgRun) {
|
||||||
@@ -2190,7 +2198,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
phase = "combat";
|
phase = "combat";
|
||||||
endlessMode = false;
|
endlessMode = false;
|
||||||
} else if (pvpMode) {
|
} else if (pvpMode) {
|
||||||
if (partyWiped) {
|
if (localPvpTeamDefeated) {
|
||||||
phase = "defeat";
|
phase = "defeat";
|
||||||
hockeyPvp.status = "lost";
|
hockeyPvp.status = "lost";
|
||||||
combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger");
|
combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger");
|
||||||
@@ -2258,7 +2266,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
hockeyPvp,
|
hockeyPvp,
|
||||||
hockeyPvpOpponent,
|
hockeyPvpOpponent,
|
||||||
runBuffInputUnlockAt,
|
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,
|
activeCast,
|
||||||
combatLog,
|
combatLog,
|
||||||
scenePulse: pulse,
|
scenePulse: pulse,
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import { isRunBuffInputLocked, useGameStore } from "./store";
|
|||||||
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
|
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
|
||||||
import { cycleBottomTab } from "./bottomTabs";
|
import { cycleBottomTab } from "./bottomTabs";
|
||||||
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
|
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) {
|
function cycleRunBuff(direction: 1 | -1) {
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
@@ -35,9 +45,11 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
if (event.repeat) return;
|
if (event.repeat) return;
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
|
if (tacticalOverlayOwnsInput()) return;
|
||||||
if (store.paused) {
|
if (store.paused) {
|
||||||
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
|
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
|
||||||
if (key === "escape") store.setPaused(false);
|
if (key === "escape") store.setPaused(false);
|
||||||
@@ -124,6 +136,8 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
|||||||
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
|
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
|
if (isSingleScreenLayout() && token === "Button8") return;
|
||||||
|
if (tacticalOverlayOwnsInput()) return;
|
||||||
if (store.paused) {
|
if (store.paused) {
|
||||||
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
|
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
|
||||||
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
|
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { hockeyPvpCountdownSeconds } from "./hockeyHealingPvp";
|
||||||
|
|
||||||
|
export function useHockeyPvpCountdownSeconds(active: boolean, countdownEndsAtMs: number) {
|
||||||
|
const [seconds, setSeconds] = useState(() =>
|
||||||
|
active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setSeconds(active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
|
||||||
|
update();
|
||||||
|
if (!active) return;
|
||||||
|
const timer = window.setInterval(update, 100);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [active, countdownEndsAtMs]);
|
||||||
|
|
||||||
|
return active ? seconds : 0;
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import { Capacitor } from "@capacitor/core";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { BottomDisplayApp } from "./platform/BottomDisplayApp";
|
import { BottomDisplayApp } from "./platform/BottomDisplayApp";
|
||||||
import { startControllerInput } from "./input/controller";
|
import { startControllerInput } from "./input/controller";
|
||||||
|
import { currentDisplayLayout } from "./platform/displayLayout";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
|
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
|
||||||
const displayMode = new URLSearchParams(window.location.search).get("display");
|
const displayMode = new URLSearchParams(window.location.search).get("display");
|
||||||
|
const displayLayout = currentDisplayLayout();
|
||||||
|
|
||||||
if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
|
if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
|
||||||
document.documentElement.classList.add("native-platform");
|
document.documentElement.classList.add("native-platform");
|
||||||
@@ -15,6 +17,7 @@ if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
|
|||||||
if (displayMode === "top" || displayMode === "bottom") {
|
if (displayMode === "top" || displayMode === "bottom") {
|
||||||
document.documentElement.dataset.displaySurface = displayMode;
|
document.documentElement.dataset.displaySurface = displayMode;
|
||||||
}
|
}
|
||||||
|
document.documentElement.dataset.displayLayout = displayLayout;
|
||||||
|
|
||||||
startControllerInput();
|
startControllerInput();
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
export type DisplaySurface = "top" | "bottom";
|
export type DisplaySurface = "top" | "bottom";
|
||||||
|
|
||||||
const DISPLAY_SURFACE_EVENT = "thor:display-surface";
|
const DISPLAY_SURFACE_EVENT = "thor:display-surface";
|
||||||
|
let currentSurface: DisplaySurface = "top";
|
||||||
|
|
||||||
export function requestDisplaySurface(surface: DisplaySurface) {
|
export function requestDisplaySurface(surface: DisplaySurface) {
|
||||||
|
currentSurface = surface;
|
||||||
window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface }));
|
window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDisplaySurface() {
|
||||||
|
return currentSurface;
|
||||||
|
}
|
||||||
|
|
||||||
export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) {
|
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);
|
window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface);
|
||||||
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
|
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
|
||||||
}
|
}
|
||||||
|
|||||||
+265
@@ -118,6 +118,224 @@ button:focus-visible {
|
|||||||
display: none;
|
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 {
|
.screen-label {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1066,6 +1284,19 @@ button:focus-visible {
|
|||||||
gap: 4%;
|
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 {
|
||||||
--ability-color: #ddd;
|
--ability-color: #ddd;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1086,6 +1317,40 @@ button:focus-visible {
|
|||||||
transition: filter 100ms ease, transform 100ms ease, border-color 100ms ease;
|
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 {
|
.ability::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Reference in New Issue
Block a user