Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018e060cdd | ||
|
|
437e70fc58 | ||
|
|
7b522e3bc8 | ||
|
|
016a012c78 |
@@ -41,11 +41,16 @@ platform tools and a connected Thor, build and install it with:
|
||||
pnpm android:install
|
||||
```
|
||||
|
||||
The first Android milestone uses the complete single-display fallback. Press
|
||||
Select (or Tab with a keyboard) to switch between the main game surface and the
|
||||
620 × 540 tactical surface. Native routing to both physical Thor displays is the
|
||||
next milestone; it needs two Android display contexts backed by one shared game
|
||||
state rather than two independent WebViews.
|
||||
The Android host routes the main and tactical surfaces to separate physical
|
||||
Thor displays while preserving one authoritative game state. If only one
|
||||
display is available, Select (or Tab with a keyboard) opens the tactical surface
|
||||
over the main game view.
|
||||
|
||||
PC and handheld browsers use the Thor top screen as a responsive, full-viewport
|
||||
game surface. The compact ability strip keeps combat controls visible; Select
|
||||
or Tab opens party, map, inventory, and other tactical detail. Use
|
||||
`?layout=thor-preview` to restore the stacked dual-screen hardware mockup for
|
||||
browser QA.
|
||||
|
||||
## TrueNAS deployment
|
||||
|
||||
@@ -140,6 +145,7 @@ outside the repository.
|
||||
- `Q` and `E` / D-pad: cycle party target
|
||||
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
||||
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
|
||||
- `Select` / `Tab`: open or close the tactical interface on one-screen devices
|
||||
- `M`: tactical map
|
||||
- `I`: inventory and item tooltip
|
||||
- `Enter` / `START`: begin or reset encounter
|
||||
@@ -185,6 +191,6 @@ build. Remove the switch to return to modular rendering.
|
||||
- Android layout targets: approximately 960×540 CSS pixels top and 620×540 CSS pixels bottom
|
||||
- Lower-screen typography scales against its own container, never the main page viewport
|
||||
|
||||
Current Android build is an installable single-display test host. Shipping to both
|
||||
physical Thor displays still needs distinct Android display contexts that project
|
||||
one authoritative game state.
|
||||
The Android build uses distinct display contexts that project one authoritative
|
||||
game state across both physical Thor displays. PC and Steam Deck use the same top
|
||||
surface with an adaptive tactical overlay.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "i-want-to-heal",
|
||||
"private": true,
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.20",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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 AUTH_WINDOW_MS = 15 * 60 * 1000;
|
||||
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
||||
const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
|
||||
const authAttempts = new Map();
|
||||
|
||||
function apiError(message, status = 400) {
|
||||
@@ -650,6 +651,8 @@ export function createGameApiHandler(options = {}) {
|
||||
match: {
|
||||
id: match.id,
|
||||
seed: match.seed,
|
||||
generation: match.generation,
|
||||
countdownEndsAtMs: match.countdownEndsAtMs,
|
||||
opponentName: opponent.hunterName,
|
||||
role: ticket.side,
|
||||
},
|
||||
@@ -692,9 +695,12 @@ export function createGameApiHandler(options = {}) {
|
||||
const match = {
|
||||
id: matchId,
|
||||
seed: randomBytes(4).readUInt32BE(0) || 1,
|
||||
generation: 1,
|
||||
createdAt: now,
|
||||
countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS,
|
||||
players: { host: opponent, guest: ticket },
|
||||
snapshots: { host: null, guest: null },
|
||||
rematch: null,
|
||||
};
|
||||
opponent.matchId = matchId;
|
||||
opponent.side = "host";
|
||||
@@ -722,6 +728,72 @@ export function createGameApiHandler(options = {}) {
|
||||
return { match, side };
|
||||
}
|
||||
|
||||
function hockeyPvpRematchResult(match, side, requestedGeneration) {
|
||||
const rematch = match.rematch;
|
||||
if (!rematch || rematch.fromGeneration !== requestedGeneration || !rematch.ready) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
const opponentSide = side === "host" ? "guest" : "host";
|
||||
return {
|
||||
status: "matched",
|
||||
match: {
|
||||
id: match.id,
|
||||
seed: rematch.seed,
|
||||
generation: rematch.toGeneration,
|
||||
countdownEndsAtMs: rematch.countdownEndsAtMs,
|
||||
opponentName: match.players[opponentSide].hunterName,
|
||||
role: side,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requestHockeyPvpRematch(session, matchId, payload) {
|
||||
const { match, side } = requireHockeyPvpMatch(session, matchId);
|
||||
const generation = Number(payload?.generation);
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid.");
|
||||
|
||||
if (generation < match.generation) {
|
||||
if (match.rematch?.fromGeneration !== generation || !match.rematch.ready) {
|
||||
throw apiError("PVP match generation is stale.", 409);
|
||||
}
|
||||
return hockeyPvpRematchResult(match, side, generation);
|
||||
}
|
||||
if (generation > match.generation) throw apiError("PVP match generation is invalid.", 409);
|
||||
|
||||
if (!match.rematch || match.rematch.fromGeneration !== generation) {
|
||||
match.rematch = {
|
||||
fromGeneration: generation,
|
||||
toGeneration: generation + 1,
|
||||
requested: { host: false, guest: false },
|
||||
ready: false,
|
||||
seed: 0,
|
||||
countdownEndsAtMs: 0,
|
||||
};
|
||||
}
|
||||
match.rematch.requested[side] = true;
|
||||
if (!match.rematch.ready && match.rematch.requested.host && match.rematch.requested.guest) {
|
||||
const now = Date.now();
|
||||
match.rematch.ready = true;
|
||||
match.rematch.seed = randomBytes(4).readUInt32BE(0) || 1;
|
||||
match.rematch.countdownEndsAtMs = now + HOCKEY_PVP_COUNTDOWN_MS;
|
||||
match.seed = match.rematch.seed;
|
||||
match.generation = match.rematch.toGeneration;
|
||||
match.countdownEndsAtMs = match.rematch.countdownEndsAtMs;
|
||||
match.snapshots = { host: null, guest: null };
|
||||
}
|
||||
return hockeyPvpRematchResult(match, side, generation);
|
||||
}
|
||||
|
||||
function cancelHockeyPvpRematch(session, matchId, payload) {
|
||||
const { match, side } = requireHockeyPvpMatch(session, matchId);
|
||||
const generation = Number(payload?.generation);
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid.");
|
||||
if (match.rematch?.fromGeneration === generation && !match.rematch.ready) {
|
||||
match.rematch.requested[side] = false;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function handle(request, response, next) {
|
||||
if (!request.url?.startsWith("/api/")) return next();
|
||||
setCorsHeaders(request, response);
|
||||
@@ -776,6 +848,7 @@ export function createGameApiHandler(options = {}) {
|
||||
if (pvpStateMatch && request.method === "PUT") {
|
||||
const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]);
|
||||
const payload = await readJson(request);
|
||||
if (payload?.generation !== match.generation) throw apiError("PVP match generation is stale.", 409);
|
||||
if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid.");
|
||||
match.snapshots[side] = payload.snapshot;
|
||||
return sendJson(response, 200, {
|
||||
@@ -783,6 +856,13 @@ export function createGameApiHandler(options = {}) {
|
||||
hostSnapshot: match.snapshots.host,
|
||||
});
|
||||
}
|
||||
const pvpRematchMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/);
|
||||
if (pvpRematchMatch && request.method === "POST") {
|
||||
return sendJson(response, 200, requestHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request)));
|
||||
}
|
||||
if (pvpRematchMatch && request.method === "DELETE") {
|
||||
return sendJson(response, 200, cancelHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request)));
|
||||
}
|
||||
if (path === "/api/saves" && request.method === "GET") {
|
||||
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
|
||||
}
|
||||
|
||||
@@ -228,6 +228,8 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
||||
assert.equal(betaQueue.body.status, "matched");
|
||||
assert.equal(betaQueue.body.match.role, "guest");
|
||||
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
||||
assert.equal(betaQueue.body.match.generation, 1);
|
||||
assert.ok(betaQueue.body.match.countdownEndsAtMs > Date.now());
|
||||
|
||||
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
||||
headers: { Authorization: `Bearer ${alphaToken}` },
|
||||
@@ -236,20 +238,62 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
||||
assert.equal(alphaMatched.body.match.role, "host");
|
||||
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.generation, betaQueue.body.match.generation);
|
||||
assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
|
||||
|
||||
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
||||
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot: hostSnapshot }),
|
||||
body: JSON.stringify({ generation: 1, snapshot: hostSnapshot }),
|
||||
});
|
||||
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot: { sequence: 1, party: [] } }),
|
||||
body: JSON.stringify({ generation: 1, snapshot: { sequence: 1, party: [] } }),
|
||||
});
|
||||
assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot);
|
||||
assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot);
|
||||
|
||||
const alphaRematchWaiting = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation: 1 }),
|
||||
});
|
||||
assert.equal(alphaRematchWaiting.body.status, "waiting");
|
||||
|
||||
const betaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation: 1 }),
|
||||
});
|
||||
assert.equal(betaRematchReady.body.status, "matched");
|
||||
assert.equal(betaRematchReady.body.match.generation, 2);
|
||||
assert.ok(betaRematchReady.body.match.countdownEndsAtMs > Date.now());
|
||||
|
||||
const alphaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation: 1 }),
|
||||
});
|
||||
assert.equal(alphaRematchReady.body.status, "matched");
|
||||
assert.equal(alphaRematchReady.body.match.generation, 2);
|
||||
assert.equal(alphaRematchReady.body.match.seed, betaRematchReady.body.match.seed);
|
||||
assert.equal(alphaRematchReady.body.match.countdownEndsAtMs, betaRematchReady.body.match.countdownEndsAtMs);
|
||||
|
||||
const staleExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation: 1, snapshot: hostSnapshot }),
|
||||
});
|
||||
assert.equal(staleExchange.response.status, 409);
|
||||
|
||||
const freshExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation: 2, snapshot: hostSnapshot }),
|
||||
});
|
||||
assert.equal(freshExchange.response.status, 200);
|
||||
});
|
||||
|
||||
test("invalid credentials cannot access server saves", async () => {
|
||||
|
||||
+105
-4
@@ -8,10 +8,12 @@ import type { BossId } from "./game/types";
|
||||
import type { DifficultySlug } from "./game/progression/loot";
|
||||
import { useActionBindings } from "./game/useGameLoop";
|
||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
||||
import { startSaveSyncCoordinator } from "./frontend/saveSync";
|
||||
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT, HOCKEY_PVP_POST_MATCH_EVENT } from "./platform/dualScreenSync";
|
||||
import { networkAppearsOnline, startSaveSyncCoordinator } from "./frontend/saveSync";
|
||||
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
|
||||
import { HOCKEY_PVP_COUNTDOWN_MS, HOCKEY_PVP_QUEUE_TIMEOUT_MS, hockeyPvpBossAt } from "./game/hockeyHealingPvp";
|
||||
import { onlineRepository } from "./frontend/onlineRepository";
|
||||
import { startHockeyPvpMatchmaking, startHockeyPvpRematch, type HockeyPvpMatchOperation } from "./frontend/hockeyPvpMatchmaking";
|
||||
|
||||
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
||||
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
||||
@@ -26,6 +28,10 @@ function GameLoadingScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalLoadingScreen() {
|
||||
return <section className="display bottom-display game-loading is-lower"><span>IH</span><strong>Loading field console</strong><small>Gameplay remains active</small></section>;
|
||||
}
|
||||
|
||||
function MainApp() {
|
||||
useForcedThorDisplays();
|
||||
useAuthoritativeDualScreenSync();
|
||||
@@ -45,10 +51,16 @@ function MainApp() {
|
||||
const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat);
|
||||
const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat);
|
||||
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
||||
const gamePhase = useGameStore((state) => state.phase);
|
||||
const gameRunMode = useGameStore((state) => state.runMode);
|
||||
const hockeyPvpCountdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
|
||||
const rewardedBossInstances = useRef(new Set<string>());
|
||||
const hockeyPvpPostMatchOperation = useRef<HockeyPvpMatchOperation | null>(null);
|
||||
const screenRef = useRef(screen);
|
||||
screenRef.current = screen;
|
||||
const leaveGame = useCallback(() => {
|
||||
hockeyPvpPostMatchOperation.current?.cancel();
|
||||
hockeyPvpPostMatchOperation.current = null;
|
||||
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
|
||||
const game = useGameStore.getState();
|
||||
// RPG Roguelike equipment belongs only to its current run. Never leak it
|
||||
@@ -58,6 +70,67 @@ function MainApp() {
|
||||
navigate("home");
|
||||
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
|
||||
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
|
||||
|
||||
const launchHockeyPvpMatch = useCallback((match: HockeyPvpMatchConfig) => {
|
||||
if (!hunter) return;
|
||||
const progress = hunter.healers[hunter.activeClassId];
|
||||
rewardedBossInstances.current.clear();
|
||||
clearRecentRewards();
|
||||
useGameStore.getState().configureHealer(
|
||||
hunter.activeClassId,
|
||||
hunter.hunterName,
|
||||
progress.inventory,
|
||||
[hockeyPvpBossAt(match.seed, 0)],
|
||||
"hockey-healing-pvp",
|
||||
hunter.gearProgress,
|
||||
"initiate",
|
||||
match,
|
||||
);
|
||||
touchActiveSave();
|
||||
}, [clearRecentRewards, hunter, touchActiveSave]);
|
||||
|
||||
const handleHockeyPvpPostMatchAction = useCallback((action: "rematch" | "requeue") => {
|
||||
const game = useGameStore.getState();
|
||||
if (game.runMode !== "hockey-healing-pvp"
|
||||
|| game.phase !== "victory" && game.phase !== "defeat"
|
||||
|| !hunter) return;
|
||||
game.setHockeyPvpPostMatchSelection(action);
|
||||
hockeyPvpPostMatchOperation.current?.cancel();
|
||||
hockeyPvpPostMatchOperation.current = null;
|
||||
|
||||
if (action === "rematch" && (game.hockeyPvp.role === "cpu" || !game.hockeyPvp.matchId)) {
|
||||
launchHockeyPvpMatch({
|
||||
matchId: null,
|
||||
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
||||
generation: game.hockeyPvp.generation + 1,
|
||||
opponentName: game.hockeyPvp.opponentName,
|
||||
role: "cpu",
|
||||
countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const operation = action === "rematch"
|
||||
? startHockeyPvpRematch({
|
||||
matchId: game.hockeyPvp.matchId!,
|
||||
generation: game.hockeyPvp.generation,
|
||||
})
|
||||
: startHockeyPvpMatchmaking({
|
||||
slotId: hunter.slotId,
|
||||
hunterName: hunter.hunterName,
|
||||
online: Boolean(accountId && networkAppearsOnline()),
|
||||
});
|
||||
game.setHockeyPvpPostMatchStatus(
|
||||
action === "rematch" ? "waiting-rematch" : "requeueing",
|
||||
action === "requeue" ? Date.now() + HOCKEY_PVP_QUEUE_TIMEOUT_MS : 0,
|
||||
);
|
||||
hockeyPvpPostMatchOperation.current = operation;
|
||||
void operation.result.then((match) => {
|
||||
if (!match || hockeyPvpPostMatchOperation.current !== operation) return;
|
||||
hockeyPvpPostMatchOperation.current = null;
|
||||
launchHockeyPvpMatch(match);
|
||||
});
|
||||
}, [accountId, hunter, launchHockeyPvpMatch]);
|
||||
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => {
|
||||
if (!hunter) return;
|
||||
const progress = hunter.healers[hunter.activeClassId];
|
||||
@@ -109,6 +182,14 @@ function MainApp() {
|
||||
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
||||
}, [leaveGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPostMatchAction = (event: Event) => {
|
||||
handleHockeyPvpPostMatchAction((event as CustomEvent<"rematch" | "requeue">).detail);
|
||||
};
|
||||
window.addEventListener(HOCKEY_PVP_POST_MATCH_EVENT, onPostMatchAction);
|
||||
return () => window.removeEventListener(HOCKEY_PVP_POST_MATCH_EVENT, onPostMatchAction);
|
||||
}, [handleHockeyPvpPostMatchAction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId));
|
||||
@@ -122,11 +203,12 @@ function MainApp() {
|
||||
if (stopped || exchangeActive) return;
|
||||
const state = useGameStore.getState();
|
||||
if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return;
|
||||
if (state.phase !== "briefing" && state.phase !== "combat") return;
|
||||
const snapshot = getHockeyPvpNetworkSnapshot();
|
||||
if (!snapshot) return;
|
||||
exchangeActive = true;
|
||||
try {
|
||||
const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, snapshot);
|
||||
const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, state.hockeyPvp.generation, snapshot);
|
||||
if (!stopped && result.opponentSnapshot) {
|
||||
useGameStore.getState().applyHockeyPvpRemoteSnapshot(
|
||||
result.opponentSnapshot,
|
||||
@@ -147,6 +229,25 @@ function MainApp() {
|
||||
};
|
||||
}, [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);
|
||||
};
|
||||
}, [gamePhase, gameRunMode, hockeyPvpCountdownEndsAtMs, screen]);
|
||||
|
||||
useActionBindings(screen === "game", leaveGame);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -225,7 +326,7 @@ function MainApp() {
|
||||
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
||||
</header>
|
||||
{screen === "game"
|
||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
|
||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame contextLabel="Tactical" top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<Suspense fallback={<TacticalLoadingScreen />}><BottomScreen onExit={leaveGame} onHockeyPvpAction={handleHockeyPvpPostMatchAction} /></Suspense>} /></Suspense>
|
||||
: <FrontEnd onLaunch={launchGame} />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+170
-72
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ABILITY_ORDER } from "../game/data";
|
||||
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
|
||||
import { HEALER_CLASSES } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||
import { BARRIER_RADIUS, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||
import type { BottomTab, PartyMember } from "../game/types";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
@@ -20,8 +20,11 @@ import {
|
||||
HOCKEY_PVP_GOAL_HALF_WIDTH,
|
||||
HOCKEY_PVP_GOAL_Z,
|
||||
HOCKEY_PVP_SIDE_OFFSET_Z,
|
||||
cycleHockeyPvpPostMatchSelection,
|
||||
type HockeyPvpPostMatchSelection,
|
||||
} from "../game/hockeyHealingPvp";
|
||||
import { bottomTabsFor } from "../game/bottomTabs";
|
||||
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
||||
import {
|
||||
BLOCKBREAKER_BREACH_DAMAGE,
|
||||
BLOCKBREAKER_BRICK_COLORS,
|
||||
@@ -31,9 +34,113 @@ import {
|
||||
blockbreakerTimeMultiplier,
|
||||
} from "../game/blockbreaker";
|
||||
import { aetherShipColor } from "./aetherAssaultVisuals";
|
||||
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
|
||||
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
|
||||
import { isBeaconOfLightTarget } from "../game/healerMechanics";
|
||||
import { AbilityButton } from "./AbilityButton";
|
||||
import { getDisplaySurface, requestDisplaySurface, subscribeDisplaySurface } from "../platform/displayRouting";
|
||||
import { isSingleScreenLayout } from "../platform/displayLayout";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
|
||||
function moveTacticalSelection(direction: 1 | -1) {
|
||||
const store = useGameStore.getState();
|
||||
if (store.activeTab === "combat") {
|
||||
store.cycleMember(direction);
|
||||
return;
|
||||
}
|
||||
if (store.activeTab !== "pack" || store.inventory.length === 0) return;
|
||||
const currentIndex = Math.max(0, store.inventory.findIndex((item) => item.id === store.selectedItemId));
|
||||
const nextIndex = (currentIndex + direction + store.inventory.length) % store.inventory.length;
|
||||
store.selectItem(store.inventory[nextIndex].id);
|
||||
}
|
||||
|
||||
function useSingleScreenTacticalInput(
|
||||
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void,
|
||||
onExit?: () => void,
|
||||
) {
|
||||
const actionRef = useRef(onHockeyPvpAction);
|
||||
const exitRef = useRef(onExit);
|
||||
actionRef.current = onHockeyPvpAction;
|
||||
exitRef.current = onExit;
|
||||
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.runMode === "hockey-healing-pvp") {
|
||||
if (store.hockeyPvp.postMatchSelection === "menu") exitRef.current?.();
|
||||
else actionRef.current?.(store.hockeyPvp.postMatchSelection);
|
||||
} 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 ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault();
|
||||
if (key === "arrowleft" || key === "arrowup") {
|
||||
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||
}
|
||||
if (key === "arrowright" || key === "arrowdown") {
|
||||
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||
}
|
||||
if (key === "enter") activatePhaseAction();
|
||||
if (key === "escape") exitRef.current?.();
|
||||
return;
|
||||
}
|
||||
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 ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||
if (["Button12", "Button14"].includes(token)) {
|
||||
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||
} else if (["Button13", "Button15"].includes(token)) {
|
||||
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||
} else if (!repeat && token === "Button0") activatePhaseAction();
|
||||
else if (!repeat && token === "Button1") exitRef.current?.();
|
||||
return;
|
||||
}
|
||||
if (token === "Button14") cycleTab(-1);
|
||||
else if (token === "Button15") cycleTab(1);
|
||||
else if (token === "Button12") moveTacticalSelection(-1);
|
||||
else if (token === "Button13") moveTacticalSelection(1);
|
||||
else if (!repeat && token === "Button0") activatePhaseAction();
|
||||
else if (!repeat && token === "Button1") requestDisplaySurface("top");
|
||||
});
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
unsubscribeController();
|
||||
unsubscribeSurface();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function RewardSummary() {
|
||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||
@@ -113,62 +220,6 @@ function PartyList() {
|
||||
);
|
||||
}
|
||||
|
||||
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
|
||||
const time = useGameStore((state) => state.time);
|
||||
const cooldowns = useGameStore((state) => state.cooldowns);
|
||||
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
|
||||
const mana = useGameStore((state) => state.mana);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
|
||||
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
||||
const activeCast = useGameStore((state) => state.activeCast);
|
||||
const castAbility = useGameStore((state) => state.castAbility);
|
||||
const runModifiers = useGameStore((state) => state.runModifiers);
|
||||
const healerMechanic = useGameStore((state) => state.healerMechanic);
|
||||
if (!ability) {
|
||||
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon">—</span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
|
||||
}
|
||||
const remaining = abilityRemaining(abilityId, time, cooldowns);
|
||||
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
|
||||
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
|
||||
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
|
||||
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
|
||||
const globalRemaining = Math.max(0, globalCooldownUntil - time);
|
||||
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
|
||||
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
||||
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
|
||||
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
|
||||
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`ability ability-${abilityId} ${remaining > 0 || globalRemaining > 0 ? "on-cooldown" : ""}`}
|
||||
style={{ "--ability-color": ability.color } as React.CSSProperties}
|
||||
onClick={() => castAbility(abilityId)}
|
||||
disabled={disabled}
|
||||
title={ability.description}
|
||||
aria-label={`${ability.name}. ${ability.description}`}
|
||||
>
|
||||
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
|
||||
<span className="ability-icon">{ability.icon}</span>
|
||||
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
|
||||
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
|
||||
{remaining > 0 && (
|
||||
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
|
||||
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
|
||||
</span>
|
||||
)}
|
||||
{remaining <= 0 && globalRemaining > 0 && (
|
||||
<span className="cooldown-mask global-cooldown" style={{ "--cooldown-progress": Math.min(1, globalRemaining / GLOBAL_COOLDOWN_SECONDS) } as React.CSSProperties}>
|
||||
<b>{globalRemaining.toFixed(1)}</b>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AbilityTray() {
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const healer = HEALER_CLASSES[healerClassId];
|
||||
@@ -234,6 +285,8 @@ function BriefingPanel() {
|
||||
const blockbreakerMode = activityMode === "blockbreaker";
|
||||
const aetherMode = activityMode === "aether-assault";
|
||||
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
||||
const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
|
||||
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, countdownEndsAtMs);
|
||||
return (
|
||||
<div className="briefing-panel">
|
||||
<div className="briefing-class">
|
||||
@@ -241,7 +294,7 @@ function BriefingPanel() {
|
||||
<span>Chosen discipline</span>
|
||||
<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>
|
||||
<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} seconds` : "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 className="briefing-kit">
|
||||
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
||||
@@ -260,7 +313,13 @@ function BriefingPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
function EndPanel({
|
||||
onExit,
|
||||
onHockeyPvpAction,
|
||||
}: {
|
||||
onExit?: () => void;
|
||||
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||
}) {
|
||||
const hunter = useActiveHunter();
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
@@ -279,6 +338,11 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||
const hockey = useGameStore((state) => state.hockey);
|
||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||
const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection);
|
||||
const requeueSeconds = useHockeyPvpCountdownSeconds(
|
||||
hockeyPvp.postMatchStatus === "requeueing",
|
||||
hockeyPvp.postMatchQueueEndsAtMs,
|
||||
);
|
||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||
@@ -286,11 +350,11 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
|
||||
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
|
||||
const pvpMatch = activityMode === "hockey-healing-pvp";
|
||||
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat;
|
||||
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat && !pvpMatch;
|
||||
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
|
||||
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
|
||||
return (
|
||||
<div className={`end-panel end-${phase}`}>
|
||||
<div className={`end-panel end-${phase} ${pvpMatch ? "is-pvp" : ""}`}>
|
||||
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
||||
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
|
||||
<h2>{showEndlessChoice ? "The trial can continue" : hockeyDefeat ? `${hockey.returns} pucks returned` : blockbreakerDefeat ? `${blockbreaker.score} points scored` : aetherDefeat ? `${aetherAssault.score} points scored` : pvpMatch ? phase === "victory" ? `${hockeyPvp.opponentName} fell first` : `${hockeyPvp.opponentName} wins` : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
|
||||
@@ -312,9 +376,36 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
onPointerEnter={() => setEndlessChoiceSelection("quit")}
|
||||
onClick={onExit}
|
||||
>Quit to Main Menu</button>
|
||||
</div> : <div className="end-actions">
|
||||
<button onClick={() => { if (pvpMatch) onExit?.(); else { restart(); startEncounter(); } }}>{pvpMatch ? "Find new opponent" : "Run again"}</button>
|
||||
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? "Return to main menu" : "Return to briefing"}</button>
|
||||
</div> : pvpMatch ? <>
|
||||
<div className="pvp-post-match-status" role="status" aria-live="polite">
|
||||
{hockeyPvp.postMatchStatus === "waiting-rematch"
|
||||
? `Waiting for ${hockeyPvp.opponentName} to accept rematch…`
|
||||
: hockeyPvp.postMatchStatus === "requeueing"
|
||||
? `Searching queue · CPU fallback in ${requeueSeconds}s`
|
||||
: "Choose rematch or enter queue for another opponent."}
|
||||
</div>
|
||||
<div className="end-actions pvp-end-actions">
|
||||
<button
|
||||
className={hockeyPvp.postMatchSelection === "rematch" ? "is-controller-selected" : ""}
|
||||
disabled={hockeyPvp.postMatchStatus === "waiting-rematch"}
|
||||
onPointerEnter={() => setHockeyPvpPostMatchSelection("rematch")}
|
||||
onClick={() => onHockeyPvpAction?.("rematch")}
|
||||
><span>{hockeyPvp.postMatchStatus === "waiting-rematch" ? "Rematch requested" : "Rematch"}</span><small>Same opponent</small></button>
|
||||
<button
|
||||
className={hockeyPvp.postMatchSelection === "requeue" ? "is-controller-selected" : ""}
|
||||
disabled={hockeyPvp.postMatchStatus === "requeueing"}
|
||||
onPointerEnter={() => setHockeyPvpPostMatchSelection("requeue")}
|
||||
onClick={() => onHockeyPvpAction?.("requeue")}
|
||||
><span>{hockeyPvp.postMatchStatus === "requeueing" ? `Queueing · ${requeueSeconds}s` : "Requeue"}</span><small>Find another rival</small></button>
|
||||
<button
|
||||
className={`secondary ${hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`}
|
||||
onPointerEnter={() => setHockeyPvpPostMatchSelection("menu")}
|
||||
onClick={onExit}
|
||||
>Main menu</button>
|
||||
</div>
|
||||
</> : <div className="end-actions">
|
||||
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
|
||||
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Return to main menu" : "Return to briefing"}</button>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -334,11 +425,14 @@ function IntermissionStatusPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function CombatPanel({ onExit }: { onExit?: () => void }) {
|
||||
function CombatPanel({ onExit, onHockeyPvpAction }: {
|
||||
onExit?: () => void;
|
||||
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||
}) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
if (phase === "briefing") return <BriefingPanel />;
|
||||
if (phase === "intermission") return <IntermissionStatusPanel />;
|
||||
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
|
||||
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />;
|
||||
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
||||
}
|
||||
|
||||
@@ -705,7 +799,11 @@ function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
||||
export function BottomScreen({ onExit, onHockeyPvpAction }: {
|
||||
onExit?: () => void;
|
||||
onHockeyPvpAction?: (action: Exclude<HockeyPvpPostMatchSelection, "menu">) => void;
|
||||
} = {}) {
|
||||
useSingleScreenTacticalInput(onHockeyPvpAction, onExit);
|
||||
const activeTab = useGameStore((state) => state.activeTab);
|
||||
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
@@ -732,7 +830,7 @@ export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
||||
</nav>
|
||||
</header>
|
||||
<main className="lower-content">
|
||||
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
|
||||
{activeTab === "combat" && <CombatPanel onExit={onExit} onHockeyPvpAction={onHockeyPvpAction} />}
|
||||
{activeTab === "map" && <MapPanel />}
|
||||
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
|
||||
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
|
||||
|
||||
@@ -1,39 +1,80 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
import { resolveDisplayLayout } from "../platform/displayLayout";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||
|
||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
||||
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
|
||||
export function DualDisplayFrame({ top, bottom, contextLabel = "Context" }: { top: ReactNode; bottom: ReactNode; contextLabel?: string }) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const dedicatedSurface = params.get("display");
|
||||
const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
|
||||
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "bottom" ? "bottom" : "top");
|
||||
const activeSurfaceRef = useRef(activeSurface);
|
||||
activeSurfaceRef.current = activeSurface;
|
||||
const showSurface = useCallback((surface: DisplaySurface) => {
|
||||
activeSurfaceRef.current = surface;
|
||||
setActiveSurface(surface);
|
||||
}, []);
|
||||
const toggleSurface = useCallback(() => {
|
||||
requestDisplaySurface(activeSurfaceRef.current === "top" ? "bottom" : "top");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!document.documentElement.classList.contains("native-platform")) return;
|
||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") return;
|
||||
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
|
||||
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
|
||||
if (layout === "thor-preview") return;
|
||||
requestDisplaySurface("top");
|
||||
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
|
||||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||
if (token === "Button8" && !repeat) toggle();
|
||||
if (token === "Button8" && !repeat) toggleSurface();
|
||||
});
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Tab" || event.repeat) return;
|
||||
if (event.repeat) return;
|
||||
if (event.key === "Escape" && activeSurfaceRef.current === "bottom") {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
requestDisplaySurface("top");
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
event.preventDefault();
|
||||
toggleSurface();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
unsubscribeSurface();
|
||||
unsubscribeController();
|
||||
requestDisplaySurface("top");
|
||||
};
|
||||
}, [dedicatedSurface]);
|
||||
}, [dedicatedSurface, layout, showSurface, toggleSurface]);
|
||||
|
||||
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
|
||||
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
|
||||
}
|
||||
|
||||
if (layout === "single") {
|
||||
const contextOpen = activeSurface === "bottom";
|
||||
return (
|
||||
<div className={`single-display-frame ${contextOpen ? "context-open" : ""}`}>
|
||||
<div className="single-primary-surface">{top}</div>
|
||||
{contextOpen && (
|
||||
<div className="single-context-layer" role="dialog" aria-modal="true" aria-label={`${contextLabel} interface`}>
|
||||
<button className="single-context-backdrop" onClick={() => requestDisplaySurface("top")} aria-label={`Close ${contextLabel.toLowerCase()} interface`} />
|
||||
<div className="single-context-surface">{bottom}</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="single-context-toggle"
|
||||
onClick={toggleSurface}
|
||||
aria-expanded={contextOpen}
|
||||
aria-label={contextOpen ? "Return to main view" : `Open ${contextLabel.toLowerCase()} interface`}
|
||||
>
|
||||
<b>{contextOpen ? "Return" : contextLabel}</b>
|
||||
<small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`device-frame active-${activeSurface}`}>
|
||||
<div className="screen-label"><span>Main viewport</span><small>960 × 540 CSS · 1920 × 1080 · 120Hz</small></div>
|
||||
@@ -43,7 +84,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
|
||||
<div className={`surface-slot bottom-slot ${activeSurface === "bottom" ? "is-active" : ""}`}>{bottom}</div>
|
||||
<button
|
||||
className="native-display-switch"
|
||||
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
|
||||
onClick={toggleSurface}
|
||||
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
||||
>
|
||||
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||||
|
||||
+17
-72
@@ -53,9 +53,9 @@ import {
|
||||
HOCKEY_PVP_GOAL_DAMAGE,
|
||||
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
||||
hockeyPvpBossAt,
|
||||
randomHockeyPvpCpuName,
|
||||
type HockeyPvpMatchConfig,
|
||||
} from "../game/hockeyHealingPvp";
|
||||
import { startHockeyPvpMatchmaking, type HockeyPvpMatchOperation } from "../frontend/hockeyPvpMatchmaking";
|
||||
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
|
||||
import {
|
||||
APPEARANCE_SLOT_DEFINITIONS,
|
||||
@@ -1544,10 +1544,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
||||
const [queueing, setQueueing] = useState(false);
|
||||
const [queueElapsed, setQueueElapsed] = useState(0);
|
||||
const queueActive = useRef(false);
|
||||
const queueTicket = useRef<string | null>(null);
|
||||
const queuePollTimer = useRef<number | null>(null);
|
||||
const queueCpuTimer = useRef<number | null>(null);
|
||||
const queueClockTimer = useRef<number | null>(null);
|
||||
const queueOperation = useRef<HockeyPvpMatchOperation | null>(null);
|
||||
const mode = MODE_COPY[modeId];
|
||||
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
||||
const progress = hunter?.healers[hunter.activeClassId];
|
||||
@@ -1567,41 +1564,19 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
||||
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
|
||||
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
|
||||
};
|
||||
const clearQueueTimers = () => {
|
||||
if (queuePollTimer.current !== null) window.clearTimeout(queuePollTimer.current);
|
||||
if (queueCpuTimer.current !== null) window.clearTimeout(queueCpuTimer.current);
|
||||
if (queueClockTimer.current !== null) window.clearInterval(queueClockTimer.current);
|
||||
queuePollTimer.current = null;
|
||||
queueCpuTimer.current = null;
|
||||
queueClockTimer.current = null;
|
||||
};
|
||||
const completePvpQueue = (match: HockeyPvpMatchConfig) => {
|
||||
if (!queueActive.current) return;
|
||||
queueActive.current = false;
|
||||
clearQueueTimers();
|
||||
queueOperation.current = null;
|
||||
setQueueing(false);
|
||||
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
|
||||
onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match);
|
||||
};
|
||||
const fallbackToCpu = () => {
|
||||
if (!queueActive.current) return;
|
||||
const ticketId = queueTicket.current;
|
||||
queueTicket.current = null;
|
||||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
||||
completePvpQueue({
|
||||
matchId: null,
|
||||
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
||||
opponentName: randomHockeyPvpCpuName(),
|
||||
role: "cpu",
|
||||
});
|
||||
};
|
||||
const cancelPvpQueue = () => {
|
||||
if (!queueActive.current) return;
|
||||
queueActive.current = false;
|
||||
clearQueueTimers();
|
||||
const ticketId = queueTicket.current;
|
||||
queueTicket.current = null;
|
||||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
||||
queueOperation.current?.cancel();
|
||||
queueOperation.current = null;
|
||||
setQueueing(false);
|
||||
setQueueElapsed(0);
|
||||
setMessage("Matchmaking cancelled.");
|
||||
@@ -1612,52 +1587,22 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
||||
setQueueing(true);
|
||||
setQueueElapsed(0);
|
||||
setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…");
|
||||
const startedAt = Date.now();
|
||||
queueClockTimer.current = window.setInterval(() => setQueueElapsed(Date.now() - startedAt), 100);
|
||||
queueCpuTimer.current = window.setTimeout(fallbackToCpu, HOCKEY_PVP_QUEUE_TIMEOUT_MS);
|
||||
if (!accountId || !networkAppearsOnline()) return;
|
||||
try {
|
||||
const joined = await onlineRepository.joinHockeyPvpQueue(hunter.slotId, hunter.hunterName);
|
||||
if (!queueActive.current) return;
|
||||
queueTicket.current = joined.ticketId;
|
||||
if (joined.match) {
|
||||
completePvpQueue({
|
||||
matchId: joined.match.id,
|
||||
seed: joined.match.seed,
|
||||
opponentName: joined.match.opponentName,
|
||||
role: joined.match.role,
|
||||
const operation = startHockeyPvpMatchmaking({
|
||||
slotId: hunter.slotId,
|
||||
hunterName: hunter.hunterName,
|
||||
online: Boolean(accountId && networkAppearsOnline()),
|
||||
onElapsed: setQueueElapsed,
|
||||
onOnlineUnavailable: () => setMessage("Online queue unavailable. CPU fallback still searching…"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const poll = async () => {
|
||||
if (!queueActive.current || !queueTicket.current) return;
|
||||
try {
|
||||
const result = await onlineRepository.pollHockeyPvpQueue(queueTicket.current);
|
||||
if (!queueActive.current) return;
|
||||
if (result.match) {
|
||||
completePvpQueue({
|
||||
matchId: result.match.id,
|
||||
seed: result.match.seed,
|
||||
opponentName: result.match.opponentName,
|
||||
role: result.match.role,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Five-second CPU fallback remains authoritative during transient outages.
|
||||
}
|
||||
if (queueActive.current) queuePollTimer.current = window.setTimeout(poll, 350);
|
||||
};
|
||||
queuePollTimer.current = window.setTimeout(poll, 350);
|
||||
} catch {
|
||||
setMessage("Online queue unavailable. CPU fallback still searching…");
|
||||
}
|
||||
queueOperation.current = operation;
|
||||
const match = await operation.result;
|
||||
if (!match || queueOperation.current !== operation) return;
|
||||
completePvpQueue(match);
|
||||
};
|
||||
useEffect(() => () => {
|
||||
queueActive.current = false;
|
||||
clearQueueTimers();
|
||||
const ticketId = queueTicket.current;
|
||||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
||||
queueOperation.current?.cancel();
|
||||
queueOperation.current = null;
|
||||
}, []);
|
||||
const leaveMode = () => {
|
||||
cancelPvpQueue();
|
||||
|
||||
@@ -7,10 +7,15 @@ import { tankAuraProtects } from "../game/partyCombat";
|
||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
||||
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
|
||||
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
||||
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
||||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||||
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
|
||||
import { isSingleScreenLayout } from "../platform/displayLayout";
|
||||
import { ABILITY_ORDER } from "../game/data";
|
||||
import { AbilityButton } from "./AbilityButton";
|
||||
|
||||
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
|
||||
GameScene.displayName = "MemoizedGameScene";
|
||||
@@ -165,6 +170,16 @@ function PhaseOverlay() {
|
||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(
|
||||
activityMode === "hockey-healing-pvp" && phase === "briefing",
|
||||
hockeyPvp.countdownEndsAtMs,
|
||||
);
|
||||
const pvpRequeueSeconds = useHockeyPvpCountdownSeconds(
|
||||
hockeyPvp.postMatchStatus === "requeueing",
|
||||
hockeyPvp.postMatchQueueEndsAtMs,
|
||||
);
|
||||
const setHockeyPvpPostMatchSelection = useGameStore((state) => state.setHockeyPvpPostMatchSelection);
|
||||
const singleScreen = isSingleScreenLayout();
|
||||
if (runMode === "rpg-roguelike") return null;
|
||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
@@ -219,13 +234,64 @@ function PhaseOverlay() {
|
||||
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
|
||||
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
|
||||
: 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";
|
||||
const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat");
|
||||
return (
|
||||
<div className={`phase-overlay phase-${phase}`}>
|
||||
<div className="phase-sigil">✦</div>
|
||||
<span>{eyebrow}</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{copy}</p>
|
||||
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
|
||||
{pvpMode && phase === "briefing" && <div className="pvp-match-countdown" role="timer" aria-live="polite" aria-label={`Match starts in ${pvpCountdownSeconds} seconds`}>
|
||||
<span>Match starts in</span>
|
||||
<strong>{pvpCountdownSeconds}</strong>
|
||||
<small>seconds</small>
|
||||
</div>}
|
||||
{pvpEnded && <>
|
||||
<div className="top-pvp-end-actions">
|
||||
<button
|
||||
className={hockeyPvp.postMatchSelection === "rematch" ? "is-controller-selected" : ""}
|
||||
disabled={hockeyPvp.postMatchStatus === "waiting-rematch"}
|
||||
onPointerEnter={() => setHockeyPvpPostMatchSelection("rematch")}
|
||||
onClick={() => requestHockeyPvpPostMatchAction("rematch")}
|
||||
><strong>{hockeyPvp.postMatchStatus === "waiting-rematch" ? "Rematch requested" : "Rematch"}</strong><small>Same opponent</small></button>
|
||||
<button
|
||||
className={hockeyPvp.postMatchSelection === "requeue" ? "is-controller-selected" : ""}
|
||||
disabled={hockeyPvp.postMatchStatus === "requeueing"}
|
||||
onPointerEnter={() => setHockeyPvpPostMatchSelection("requeue")}
|
||||
onClick={() => requestHockeyPvpPostMatchAction("requeue")}
|
||||
><strong>{hockeyPvp.postMatchStatus === "requeueing" ? `Queueing · ${pvpRequeueSeconds}s` : "Requeue"}</strong><small>Find another rival</small></button>
|
||||
</div>
|
||||
<div className="top-pvp-post-match-status" role="status" aria-live="polite">
|
||||
{hockeyPvp.postMatchStatus === "waiting-rematch"
|
||||
? `Waiting for ${hockeyPvp.opponentName}…`
|
||||
: hockeyPvp.postMatchStatus === "requeueing"
|
||||
? `Searching queue · CPU fallback in ${pvpRequeueSeconds}s`
|
||||
: "Choose next match"}
|
||||
</div>
|
||||
</>}
|
||||
<small>{phase === "briefing"
|
||||
? briefingPrompt
|
||||
: singleScreen
|
||||
? showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "D-pad chooses · Confirm selects · Menu exits" : "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>
|
||||
);
|
||||
}
|
||||
@@ -385,6 +451,7 @@ export function TopScreen({
|
||||
const blockbreakerMode = activityMode === "blockbreaker";
|
||||
const aetherAssaultMode = activityMode === "aether-assault";
|
||||
const pvpMode = activityMode === "hockey-healing-pvp";
|
||||
const pvpEnded = pvpMode && (phase === "victory" || phase === "defeat");
|
||||
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
|
||||
return (
|
||||
<section className="display top-display" aria-label="Main game viewport">
|
||||
@@ -400,8 +467,13 @@ export function TopScreen({
|
||||
<DampeningIndicator />
|
||||
<CastingBar />
|
||||
<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 ${pvpEnded && hockeyPvp.postMatchSelection === "menu" ? "is-controller-selected" : ""}`}
|
||||
onPointerEnter={() => { if (pvpEnded) useGameStore.getState().setHockeyPvpPostMatchSelection("menu"); }}
|
||||
onClick={() => phase === "combat" ? setPaused(true) : onExit()}
|
||||
><b>☰</b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
|
||||
</div>
|
||||
<SingleScreenAbilityBar />
|
||||
<GoalPopup />
|
||||
<BlockbreakerScorePopup />
|
||||
<AetherScorePopup />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRpgRoguelikeRun, reduceRpgRoguelikeRun } from "../../game/rpgRoguelike";
|
||||
import { PartyRoleBadge } from "./PartyRoleBadge";
|
||||
import { RpgRunOverlay } from "./RpgRunOverlay";
|
||||
import { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
|
||||
|
||||
describe("RPG roguelike party role UI", () => {
|
||||
it("renders clear visual and accessible Tank/DPS badges", () => {
|
||||
const tank = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Tank" }));
|
||||
const damage = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Damage" }));
|
||||
|
||||
expect(tank).toContain('class="rpg-role-badge is-tank"');
|
||||
expect(tank).toContain('aria-label="Role: Tank"');
|
||||
expect(damage).toContain('class="rpg-role-badge is-damage"');
|
||||
expect(damage).toContain('aria-label="Role: DPS"');
|
||||
});
|
||||
|
||||
it("shows role and composition in current-party lists on both displays", () => {
|
||||
let run = createRpgRoguelikeRun({ seed: 73 });
|
||||
const damage = run.partyDraft!.offers.find((candidate) => candidate.role === "Damage")!;
|
||||
run = reduceRpgRoguelikeRun(run, { type: "party-recruit", candidateId: damage.candidateId });
|
||||
run = reduceRpgRoguelikeRun(run, { type: "party-next-wave" });
|
||||
|
||||
const props = { run, onAction: () => undefined };
|
||||
const main = renderToStaticMarkup(createElement(RpgRunOverlay, props));
|
||||
const tactical = renderToStaticMarkup(createElement(RpgRunTacticalPanel, props));
|
||||
|
||||
expect(main).toContain('aria-label="Current party, 1 of 4: 0 Tanks · 1 DPS"');
|
||||
expect(main).toMatch(/class="rpg-picked-chip[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
|
||||
expect(main).toContain('aria-label="Role: DPS"');
|
||||
|
||||
expect(tactical).toContain('aria-label="Current party: 0 Tanks · 1 DPS"');
|
||||
expect(tactical).toMatch(/class="rpg-card-action[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
|
||||
expect(tactical).toContain('aria-label="Role: DPS"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PartyRole } from "../../game/rpgRoguelike";
|
||||
import { partyRolePresentation } from "../../game/rpgRoguelike";
|
||||
|
||||
export function PartyRoleBadge({ role }: { readonly role: PartyRole }) {
|
||||
const presentation = partyRolePresentation(role);
|
||||
return (
|
||||
<span
|
||||
className={`rpg-role-badge is-${presentation.className}`}
|
||||
aria-label={`Role: ${presentation.label}`}
|
||||
>
|
||||
<i aria-hidden="true">{presentation.icon}</i>
|
||||
<b>{presentation.label}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
PARTY_RECRUITS_PER_WAVE,
|
||||
SPELL_DRAFT_WAVE_COUNT,
|
||||
SPELL_PICKS_PER_WAVE,
|
||||
partyCompositionLabel,
|
||||
partyRolePresentation,
|
||||
rpgFocusId,
|
||||
} from "../../game/rpgRoguelike";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
|
||||
@@ -17,6 +19,8 @@ import {
|
||||
currentBoss,
|
||||
FocusButton,
|
||||
GearCard,
|
||||
GearStatComparison,
|
||||
gearComparisonLabel,
|
||||
PartyCard,
|
||||
rewardSummary,
|
||||
RoutePips,
|
||||
@@ -26,6 +30,7 @@ import {
|
||||
type RpgRunUiContext,
|
||||
type RpgRunUiProps,
|
||||
} from "./RpgRunUiShared";
|
||||
import { PartyRoleBadge } from "./PartyRoleBadge";
|
||||
import "./rpgRoguelike.css";
|
||||
|
||||
function DraftFooter({ context, focusId, action, disabled, label, hint }: {
|
||||
@@ -89,15 +94,15 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
className="rpg-card-action"
|
||||
disabled={recruited && !canRemove}
|
||||
pressed={recruited}
|
||||
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`}
|
||||
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
|
||||
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}>
|
||||
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong>
|
||||
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}: ${partyCompositionLabel(run.roster)}`}>
|
||||
<strong><span>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</span><small>{partyCompositionLabel(run.roster)}</small></strong>
|
||||
{run.roster.map((member) => (
|
||||
<FocusButton
|
||||
key={member.instanceId}
|
||||
@@ -106,8 +111,13 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
|
||||
className={`rpg-picked-chip rarity-${member.rarity}`}
|
||||
disabled={!canRemove}
|
||||
label={`Remove ${member.name}`}
|
||||
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton>
|
||||
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
|
||||
>
|
||||
<i style={{ background: member.color }} />
|
||||
<span className="rpg-picked-copy"><strong>{member.name}</strong><small>{member.className}</small></span>
|
||||
<PartyRoleBadge role={member.role} />
|
||||
<b className="rpg-picked-remove" aria-hidden="true">×</b>
|
||||
</FocusButton>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)}
|
||||
</div>
|
||||
@@ -268,8 +278,11 @@ function Rewards({ context }: { context: RpgRunUiContext }) {
|
||||
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
|
||||
className="rpg-reward-card"
|
||||
style={runAccentStyle(summary.accent)}
|
||||
label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}
|
||||
>
|
||||
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p><b>Claim</b>
|
||||
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p>
|
||||
{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}
|
||||
<b>Claim</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
@@ -304,7 +317,7 @@ function Shop({ context }: { context: RpgRunUiContext }) {
|
||||
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
|
||||
className="rpg-card-action"
|
||||
disabled={disabled}
|
||||
label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`}
|
||||
label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}
|
||||
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
|
||||
} />
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
PARTY_RECRUITS_PER_WAVE,
|
||||
SPELL_DRAFT_WAVE_COUNT,
|
||||
SPELL_PICKS_PER_WAVE,
|
||||
partyCompositionLabel,
|
||||
partyRolePresentation,
|
||||
rpgFocusId,
|
||||
} from "../../game/rpgRoguelike";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
|
||||
@@ -18,6 +20,8 @@ import {
|
||||
currentBoss,
|
||||
FocusButton,
|
||||
GearCard,
|
||||
GearStatComparison,
|
||||
gearComparisonLabel,
|
||||
PartyCard,
|
||||
rewardSummary,
|
||||
RoutePips,
|
||||
@@ -33,8 +37,11 @@ import "./rpgRoguelike.css";
|
||||
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
|
||||
const { run } = context;
|
||||
return (
|
||||
<section className="rpg-tactical-section">
|
||||
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header>
|
||||
<section className="rpg-tactical-section" aria-label={`Current party: ${partyCompositionLabel(run.roster)}`}>
|
||||
<header>
|
||||
<h3>Party</h3>
|
||||
<span className="rpg-party-composition"><b>{partyCompositionLabel(run.roster)}</b><small>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</small></span>
|
||||
</header>
|
||||
<div className="rpg-tactical-party-grid">
|
||||
{run.roster.map((member) => (
|
||||
<PartyCard key={member.instanceId} member={member} compact action={interactive ? (
|
||||
@@ -43,7 +50,7 @@ function TacticalParty({ context, interactive = false }: { context: RpgRunUiCont
|
||||
focusId={rpgFocusId.partyMember(member.instanceId)}
|
||||
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
|
||||
className="rpg-card-action"
|
||||
label={`Remove ${member.name}`}
|
||||
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
|
||||
><span>Remove</span></FocusButton>
|
||||
) : undefined} />
|
||||
))}
|
||||
@@ -116,6 +123,7 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
{draft.offers.map((candidate) => {
|
||||
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
|
||||
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
|
||||
const role = partyRolePresentation(candidate.role);
|
||||
return (
|
||||
<FocusButton
|
||||
key={candidate.candidateId}
|
||||
@@ -126,9 +134,10 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
style={runAccentStyle(candidate.color)}
|
||||
disabled={disabled}
|
||||
pressed={recruited}
|
||||
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
|
||||
>
|
||||
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i>
|
||||
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
|
||||
<i>{role.icon}</i>
|
||||
<span><small>{candidate.rarity} · {role.label}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
|
||||
<b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b>
|
||||
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
|
||||
</FocusButton>
|
||||
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
|
||||
{chest.choices.map((choice) => {
|
||||
const summary = rewardSummary(choice);
|
||||
return (
|
||||
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)}>
|
||||
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b>
|
||||
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)} label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}>
|
||||
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p>{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}</span><b>Claim</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
|
||||
<div className="rpg-tactical-shop-grid">
|
||||
{shop.offers.map((offer) => (
|
||||
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
|
||||
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`Buy ${offer.item.name}`}>
|
||||
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}>
|
||||
<span>{offer.sold ? "Sold" : "Buy"}</span>
|
||||
</FocusButton>
|
||||
} />
|
||||
|
||||
@@ -10,9 +10,10 @@ import type {
|
||||
RpgRoguelikeRunState,
|
||||
RunGearItem,
|
||||
} from "../../game/rpgRoguelike";
|
||||
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike";
|
||||
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
|
||||
import type { RpgUiCommand } from "../../game/rpgRoguelike";
|
||||
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
|
||||
import { PartyRoleBadge } from "./PartyRoleBadge";
|
||||
|
||||
export interface RpgRunUiProps {
|
||||
readonly run: RpgRoguelikeRunState;
|
||||
@@ -94,11 +95,14 @@ export function FocusButton({
|
||||
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
|
||||
const childRect = button.getBoundingClientRect();
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
if (parent.scrollHeight > parent.clientHeight) {
|
||||
const overflow = getComputedStyle(parent);
|
||||
const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
|
||||
const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
|
||||
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
|
||||
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
|
||||
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
|
||||
}
|
||||
if (parent.scrollWidth > parent.clientWidth) {
|
||||
if (canScrollX && parent.scrollWidth > parent.clientWidth) {
|
||||
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
|
||||
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
|
||||
}
|
||||
@@ -207,7 +211,7 @@ export function PartyCard({
|
||||
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
|
||||
style={runAccentStyle(entry.color)}
|
||||
>
|
||||
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div>
|
||||
<div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
|
||||
<h3>{entry.name}</h3>
|
||||
<p>{entry.className}</p>
|
||||
{!compact && (
|
||||
@@ -265,6 +269,39 @@ export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): str
|
||||
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
|
||||
}
|
||||
|
||||
export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string {
|
||||
const comparison = compareRunGear(run.equipment, item);
|
||||
const ownerName = gearOwnerName(run, item);
|
||||
const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none";
|
||||
const change = comparison.delta >= 0
|
||||
? `Gain ${comparison.delta} percentage points`
|
||||
: `Lose ${Math.abs(comparison.delta)} percentage points`;
|
||||
return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`;
|
||||
}
|
||||
|
||||
export function GearStatComparison({ run, item }: {
|
||||
readonly run: RpgRoguelikeRunState;
|
||||
readonly item: RunGearItem;
|
||||
}) {
|
||||
const ownerName = gearOwnerName(run, item);
|
||||
const comparison = compareRunGear(run.equipment, item);
|
||||
const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none";
|
||||
const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`;
|
||||
return (
|
||||
<span
|
||||
className="rpg-gear-comparison"
|
||||
aria-label={gearComparisonLabel(run, item)}
|
||||
>
|
||||
<strong>{ownerName} · {comparison.effectLabel}<em>{delta}</em></strong>
|
||||
<span>
|
||||
<span><small>Current · {currentRank}</small><b>+{comparison.currentValue}%</b></span>
|
||||
<i aria-hidden="true">→</i>
|
||||
<span><small>Replacement · +{item.enhancement}</small><b>+{comparison.replacementValue}%</b></span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearCard({ run, item, price, action }: {
|
||||
readonly run: RpgRoguelikeRunState;
|
||||
readonly item: RunGearItem;
|
||||
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
|
||||
<div>
|
||||
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
|
||||
<h3>{item.name}</h3>
|
||||
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p>
|
||||
{price !== undefined && <p className="rpg-gear-price">◆ {price}</p>}
|
||||
<GearStatComparison run={run} item={item} />
|
||||
</div>
|
||||
{action}
|
||||
</article>
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
|
||||
.rpg-card-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
color: var(--rarity-color, #e7e9ec);
|
||||
@@ -249,6 +250,41 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rpg-role-badge {
|
||||
min-width: 44px;
|
||||
padding: 2px 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rpg-role-badge.is-tank {
|
||||
color: #9bd9ff;
|
||||
background: rgba(65, 145, 199, 0.2);
|
||||
}
|
||||
|
||||
.rpg-role-badge.is-damage {
|
||||
color: #ffc27c;
|
||||
background: rgba(204, 111, 55, 0.19);
|
||||
}
|
||||
|
||||
.rpg-role-badge > i {
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rpg-role-badge > b {
|
||||
color: inherit;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.rpg-party-card h3,
|
||||
.rpg-spell-card h3,
|
||||
.rpg-gear-card h3 {
|
||||
@@ -389,6 +425,18 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rpg-picked-strip > strong {
|
||||
width: 108px;
|
||||
flex: 0 0 108px;
|
||||
}
|
||||
|
||||
.rpg-picked-strip > strong > small {
|
||||
color: var(--rpg-gold);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rpg-picked-chip,
|
||||
.rpg-empty-chip,
|
||||
.rpg-spellbook-chip {
|
||||
@@ -401,9 +449,12 @@
|
||||
|
||||
.rpg-picked-chip {
|
||||
position: relative;
|
||||
padding: 4px 18px 4px 10px;
|
||||
padding: 4px 6px 4px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -414,18 +465,24 @@
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.rpg-picked-chip > small {
|
||||
.rpg-picked-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.rpg-picked-copy > strong,
|
||||
.rpg-picked-copy > small {
|
||||
overflow: hidden;
|
||||
color: var(--rpg-muted);
|
||||
font-size: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rpg-picked-chip > b {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
.rpg-picked-copy > strong { color: var(--rpg-ink); font-size: 10px; }
|
||||
.rpg-picked-copy > small { color: var(--rpg-muted); font-size: 8px; }
|
||||
|
||||
.rpg-picked-chip > .rpg-picked-remove {
|
||||
color: #82958e;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rpg-empty-chip {
|
||||
@@ -870,7 +927,7 @@
|
||||
}
|
||||
|
||||
.rpg-gear-card {
|
||||
min-height: 72px;
|
||||
min-height: 112px;
|
||||
padding: 8px 8px 22px 43px;
|
||||
}
|
||||
|
||||
@@ -904,6 +961,12 @@
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.rpg-gear-card .rpg-gear-price {
|
||||
margin: 2px 0 0;
|
||||
color: var(--rpg-gold);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rpg-shop-side-list {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -1118,6 +1181,22 @@
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.rpg-party-composition {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.rpg-party-composition > b {
|
||||
color: var(--rpg-gold);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.rpg-party-composition > small {
|
||||
color: var(--rpg-muted);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.rpg-tactical-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -1400,7 +1479,7 @@ button.rpg-tactical-spell {
|
||||
}
|
||||
|
||||
.rpg-tactical-reward {
|
||||
min-height: 94px;
|
||||
min-height: 130px;
|
||||
padding: 9px 64px 9px 49px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -1457,12 +1536,88 @@ button.rpg-tactical-spell {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison {
|
||||
min-width: 0;
|
||||
margin-top: 7px;
|
||||
padding-top: 6px;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: #dce8e2;
|
||||
border-top: 1px solid var(--rpg-line);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > strong {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
color: #dce8e2;
|
||||
font-family: "Rajdhani", "Avenir Next Condensed", sans-serif;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > strong > em {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 4px;
|
||||
color: #9de1b8;
|
||||
border-radius: 2px;
|
||||
background: rgba(70, 167, 108, 0.15);
|
||||
font-size: 8px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > span > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison small,
|
||||
.rpg-tactical-reward .rpg-gear-comparison small {
|
||||
overflow: hidden;
|
||||
color: #82958e;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison b {
|
||||
color: #f1f7f4;
|
||||
font-size: 12px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > span > i {
|
||||
color: #75bceb;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rpg-run-tactical .rpg-tactical-shop-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.rpg-run-tactical .rpg-gear-card {
|
||||
min-height: 80px;
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.rpg-service-row {
|
||||
@@ -1724,6 +1879,15 @@ button.rpg-tactical-spell {
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.rpg-reward-grid {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rpg-reward-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.rpg-picked-strip,
|
||||
.rpg-spellbook-strip {
|
||||
overflow-x: auto;
|
||||
@@ -1735,6 +1899,10 @@ button.rpg-tactical-spell {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.rpg-picked-chip {
|
||||
min-width: 142px;
|
||||
}
|
||||
|
||||
.rpg-shop-layout {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
@@ -1751,6 +1919,12 @@ button.rpg-tactical-spell {
|
||||
.rpg-tactical-offer > b {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rpg-party-composition {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* Single-display browser fallback gets a usable full-height draft surface. */
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startHockeyPvpMatchmaking, startHockeyPvpRematch } from "./hockeyPvpMatchmaking";
|
||||
|
||||
describe("Hockey PVP matchmaking", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("falls back to a CPU match with a visible five-second start countdown", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const operation = startHockeyPvpMatchmaking({
|
||||
slotId: 1,
|
||||
hunterName: "Aelia",
|
||||
online: false,
|
||||
timeoutMs: 5_000,
|
||||
random: () => 0.5,
|
||||
cpuName: () => "CPU Sage",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await expect(operation.result).resolves.toMatchObject({
|
||||
role: "cpu",
|
||||
opponentName: "CPU Sage",
|
||||
generation: 1,
|
||||
countdownEndsAtMs: 11_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("polls until both players accept an online rematch", async () => {
|
||||
vi.useFakeTimers();
|
||||
const requestHockeyPvpRematch = vi.fn()
|
||||
.mockResolvedValueOnce({ status: "waiting" })
|
||||
.mockResolvedValueOnce({
|
||||
status: "matched",
|
||||
match: {
|
||||
id: "match-1",
|
||||
seed: 22,
|
||||
generation: 2,
|
||||
countdownEndsAtMs: 10_000,
|
||||
opponentName: "Rival",
|
||||
role: "host",
|
||||
},
|
||||
});
|
||||
const operation = startHockeyPvpRematch({
|
||||
matchId: "match-1",
|
||||
generation: 1,
|
||||
pollMs: 350,
|
||||
repository: {
|
||||
requestHockeyPvpRematch,
|
||||
cancelHockeyPvpRematch: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
await expect(operation.result).resolves.toMatchObject({
|
||||
matchId: "match-1",
|
||||
seed: 22,
|
||||
generation: 2,
|
||||
opponentName: "Rival",
|
||||
role: "host",
|
||||
});
|
||||
expect(requestHockeyPvpRematch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
HOCKEY_PVP_COUNTDOWN_MS,
|
||||
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
||||
randomHockeyPvpCpuName,
|
||||
type HockeyPvpMatchConfig,
|
||||
} from "../game/hockeyHealingPvp";
|
||||
import {
|
||||
onlineRepository,
|
||||
type HockeyPvpOnlineMatch,
|
||||
type OnlineRepository,
|
||||
} from "./onlineRepository";
|
||||
import type { SaveSlotId } from "./types";
|
||||
|
||||
type QueueRepository = Pick<OnlineRepository,
|
||||
"joinHockeyPvpQueue" | "pollHockeyPvpQueue" | "cancelHockeyPvpQueue">;
|
||||
type RematchRepository = Pick<OnlineRepository,
|
||||
"requestHockeyPvpRematch" | "cancelHockeyPvpRematch">;
|
||||
|
||||
export interface HockeyPvpMatchOperation {
|
||||
result: Promise<HockeyPvpMatchConfig | null>;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function onlineHockeyPvpMatchConfig(match: HockeyPvpOnlineMatch): HockeyPvpMatchConfig {
|
||||
return {
|
||||
matchId: match.id,
|
||||
seed: match.seed,
|
||||
generation: match.generation,
|
||||
countdownEndsAtMs: match.countdownEndsAtMs,
|
||||
opponentName: match.opponentName,
|
||||
role: match.role,
|
||||
};
|
||||
}
|
||||
|
||||
export function startHockeyPvpMatchmaking(options: {
|
||||
slotId: SaveSlotId;
|
||||
hunterName: string;
|
||||
online: boolean;
|
||||
repository?: QueueRepository;
|
||||
timeoutMs?: number;
|
||||
pollMs?: number;
|
||||
onElapsed?: (elapsedMs: number) => void;
|
||||
onOnlineUnavailable?: () => void;
|
||||
random?: () => number;
|
||||
cpuName?: () => string;
|
||||
}): HockeyPvpMatchOperation {
|
||||
const repository = options.repository ?? onlineRepository;
|
||||
const timeoutMs = options.timeoutMs ?? HOCKEY_PVP_QUEUE_TIMEOUT_MS;
|
||||
const pollMs = options.pollMs ?? 350;
|
||||
const random = options.random ?? Math.random;
|
||||
const cpuName = options.cpuName ?? randomHockeyPvpCpuName;
|
||||
const startedAt = Date.now();
|
||||
let active = true;
|
||||
let ticketId: string | null = null;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let settle: (match: HockeyPvpMatchConfig | null) => void = () => undefined;
|
||||
|
||||
const clearTimers = () => {
|
||||
if (pollTimer !== null) clearTimeout(pollTimer);
|
||||
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
||||
if (clockTimer !== null) clearInterval(clockTimer);
|
||||
pollTimer = null;
|
||||
fallbackTimer = null;
|
||||
clockTimer = null;
|
||||
};
|
||||
const finish = (match: HockeyPvpMatchConfig | null) => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
clearTimers();
|
||||
settle(match);
|
||||
};
|
||||
const cancelTicket = () => {
|
||||
const currentTicketId = ticketId;
|
||||
ticketId = null;
|
||||
if (currentTicketId) void repository.cancelHockeyPvpQueue(currentTicketId).catch(() => undefined);
|
||||
};
|
||||
const fallbackToCpu = () => {
|
||||
if (!active) return;
|
||||
cancelTicket();
|
||||
finish({
|
||||
matchId: null,
|
||||
seed: Math.max(1, Math.floor(random() * 0xffffffff)),
|
||||
generation: 1,
|
||||
opponentName: cpuName(),
|
||||
role: "cpu",
|
||||
countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS,
|
||||
});
|
||||
};
|
||||
const result = new Promise<HockeyPvpMatchConfig | null>((resolve) => {
|
||||
settle = resolve;
|
||||
fallbackTimer = setTimeout(fallbackToCpu, timeoutMs);
|
||||
if (options.onElapsed) {
|
||||
options.onElapsed(0);
|
||||
clockTimer = setInterval(() => options.onElapsed?.(Date.now() - startedAt), 100);
|
||||
}
|
||||
if (!options.online) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const joined = await repository.joinHockeyPvpQueue(options.slotId, options.hunterName);
|
||||
if (!active) {
|
||||
if (!joined.match) void repository.cancelHockeyPvpQueue(joined.ticketId).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
ticketId = joined.ticketId;
|
||||
if (joined.match) {
|
||||
finish(onlineHockeyPvpMatchConfig(joined.match));
|
||||
return;
|
||||
}
|
||||
const poll = async () => {
|
||||
if (!active || !ticketId) return;
|
||||
try {
|
||||
const queued = await repository.pollHockeyPvpQueue(ticketId);
|
||||
if (!active) return;
|
||||
if (queued.match) {
|
||||
finish(onlineHockeyPvpMatchConfig(queued.match));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
options.onOnlineUnavailable?.();
|
||||
}
|
||||
if (active) pollTimer = setTimeout(poll, pollMs);
|
||||
};
|
||||
pollTimer = setTimeout(poll, pollMs);
|
||||
} catch {
|
||||
options.onOnlineUnavailable?.();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
cancel: () => {
|
||||
if (!active) return;
|
||||
cancelTicket();
|
||||
finish(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function startHockeyPvpRematch(options: {
|
||||
matchId: string;
|
||||
generation: number;
|
||||
repository?: RematchRepository;
|
||||
pollMs?: number;
|
||||
onUnavailable?: () => void;
|
||||
}): HockeyPvpMatchOperation {
|
||||
const repository = options.repository ?? onlineRepository;
|
||||
const pollMs = options.pollMs ?? 350;
|
||||
let active = true;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let settle: (match: HockeyPvpMatchConfig | null) => void = () => undefined;
|
||||
const result = new Promise<HockeyPvpMatchConfig | null>((resolve) => {
|
||||
settle = resolve;
|
||||
const poll = async () => {
|
||||
if (!active) return;
|
||||
try {
|
||||
const rematch = await repository.requestHockeyPvpRematch(options.matchId, options.generation);
|
||||
if (!active) {
|
||||
void repository.cancelHockeyPvpRematch(options.matchId, options.generation).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (rematch.match) {
|
||||
active = false;
|
||||
settle(onlineHockeyPvpMatchConfig(rematch.match));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
options.onUnavailable?.();
|
||||
}
|
||||
if (active) pollTimer = setTimeout(poll, pollMs);
|
||||
};
|
||||
void poll();
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
cancel: () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
if (pollTimer !== null) clearTimeout(pollTimer);
|
||||
void repository.cancelHockeyPvpRematch(options.matchId, options.generation).catch(() => undefined);
|
||||
settle(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -30,15 +30,24 @@ export interface LeaderboardResult {
|
||||
current: LeaderboardEntry | null;
|
||||
}
|
||||
|
||||
export interface HockeyPvpOnlineMatch {
|
||||
id: string;
|
||||
seed: number;
|
||||
generation: number;
|
||||
countdownEndsAtMs: number;
|
||||
opponentName: string;
|
||||
role: Exclude<HockeyPvpRole, "cpu">;
|
||||
}
|
||||
|
||||
export interface HockeyPvpQueueResult {
|
||||
ticketId: string;
|
||||
status: "waiting" | "matched";
|
||||
match?: {
|
||||
id: string;
|
||||
seed: number;
|
||||
opponentName: string;
|
||||
role: Exclude<HockeyPvpRole, "cpu">;
|
||||
};
|
||||
match?: HockeyPvpOnlineMatch;
|
||||
}
|
||||
|
||||
export interface HockeyPvpRematchResult {
|
||||
status: "waiting" | "matched";
|
||||
match?: HockeyPvpOnlineMatch;
|
||||
}
|
||||
|
||||
export interface HockeyPvpExchangeResult {
|
||||
@@ -214,11 +223,27 @@ export class OnlineRepository {
|
||||
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
exchangeHockeyPvpState(matchId: string, snapshot: HockeyPvpRemoteSnapshot): Promise<HockeyPvpExchangeResult> {
|
||||
exchangeHockeyPvpState(matchId: string, generation: number, snapshot: HockeyPvpRemoteSnapshot): Promise<HockeyPvpExchangeResult> {
|
||||
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot }),
|
||||
body: JSON.stringify({ generation, snapshot }),
|
||||
});
|
||||
}
|
||||
|
||||
requestHockeyPvpRematch(matchId: string, generation: number): Promise<HockeyPvpRematchResult> {
|
||||
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation }),
|
||||
});
|
||||
}
|
||||
|
||||
cancelHockeyPvpRematch(matchId: string, generation: number): Promise<void> {
|
||||
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,36 @@ import {
|
||||
HOCKEY_PVP_GOAL_Z,
|
||||
advanceHockeyPvpPuck,
|
||||
createHockeyPvpState,
|
||||
cycleHockeyPvpPostMatchSelection,
|
||||
hockeyPvpBossAt,
|
||||
hockeyPvpCountdownSeconds,
|
||||
hockeyPvpDampeningPercent,
|
||||
hockeyPvpHealingEffectiveness,
|
||||
hockeyPvpPuckSpeed,
|
||||
mirrorHockeyPvpPuck,
|
||||
reconcileHockeyPvpPuck,
|
||||
} from "./hockeyHealingPvp";
|
||||
|
||||
describe("Healing Hockey PVP", () => {
|
||||
it("starts rallies at the faster default puck speed", () => {
|
||||
const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" });
|
||||
|
||||
expect(hockeyPvpPuckSpeed(0)).toBe(7.2);
|
||||
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.2);
|
||||
expect(hockeyPvpPuckSpeed(0)).toBe(7.8);
|
||||
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
|
||||
});
|
||||
|
||||
it("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("cycles rematch, requeue, and menu choices deterministically", () => {
|
||||
expect(cycleHockeyPvpPostMatchSelection("rematch", 1)).toBe("requeue");
|
||||
expect(cycleHockeyPvpPostMatchSelection("requeue", 1)).toBe("menu");
|
||||
expect(cycleHockeyPvpPostMatchSelection("menu", 1)).toBe("rematch");
|
||||
expect(cycleHockeyPvpPostMatchSelection("rematch", -1)).toBe("menu");
|
||||
});
|
||||
|
||||
it("adds five percent global dampening for every boss killed by either party", () => {
|
||||
@@ -76,4 +93,28 @@ describe("Healing Hockey PVP", () => {
|
||||
lastGoalSide: "local",
|
||||
});
|
||||
});
|
||||
|
||||
it("predicts guest movement between snapshots and soft-corrects small drift", () => {
|
||||
const guest = createHockeyPvpState({ matchId: "match", seed: 9, opponentName: "Rival", role: "guest" });
|
||||
guest.puckVelocity = [3, 6];
|
||||
const predicted = advanceHockeyPvpPuck(guest, {
|
||||
delta: 0.1,
|
||||
localPlayerPosition: [0, 8.5],
|
||||
localAimDirection: [0, -1],
|
||||
opponentPlayerPosition: [0, 8.5],
|
||||
opponentAimDirection: [0, -1],
|
||||
});
|
||||
expect(predicted.puckPosition[0]).toBeCloseTo(0.3);
|
||||
expect(predicted.puckPosition[1]).toBeCloseTo(0.6);
|
||||
expect(predicted.localGoalsConceded).toBe(0);
|
||||
|
||||
const authoritative = { ...predicted, puckPosition: [0.6, 0.6] as [number, number] };
|
||||
const reconciled = reconcileHockeyPvpPuck(predicted, authoritative);
|
||||
expect(reconciled.puckPosition[0]).toBeGreaterThan(predicted.puckPosition[0]);
|
||||
expect(reconciled.puckPosition[0]).toBeLessThan(authoritative.puckPosition[0]);
|
||||
|
||||
authoritative.goalSequence += 1;
|
||||
authoritative.puckPosition = [0, 0];
|
||||
expect(reconcileHockeyPvpPuck(predicted, authoritative).puckPosition).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,16 @@ import type { BossId, BossMotionMode, PartyMember, WorldPosition } from "./types
|
||||
|
||||
export type HockeyPvpRole = "cpu" | "host" | "guest";
|
||||
export type HockeyPvpGoalSide = "local" | "opponent";
|
||||
export type HockeyPvpPostMatchSelection = "rematch" | "requeue" | "menu";
|
||||
export type HockeyPvpPostMatchStatus = "idle" | "waiting-rematch" | "requeueing";
|
||||
|
||||
export interface HockeyPvpMatchConfig {
|
||||
matchId: string | null;
|
||||
seed: number;
|
||||
generation?: number;
|
||||
opponentName: string;
|
||||
role: HockeyPvpRole;
|
||||
countdownEndsAtMs?: number;
|
||||
}
|
||||
|
||||
export interface HockeyPvpPuckState {
|
||||
@@ -25,7 +29,12 @@ export interface HockeyPvpPuckState {
|
||||
}
|
||||
|
||||
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
|
||||
countdownEndsAtMs: number;
|
||||
generation: number;
|
||||
status: "inactive" | "live" | "won" | "lost";
|
||||
postMatchSelection: HockeyPvpPostMatchSelection;
|
||||
postMatchStatus: HockeyPvpPostMatchStatus;
|
||||
postMatchQueueEndsAtMs: number;
|
||||
aimDirection: WorldPosition;
|
||||
opponentBossKills: number;
|
||||
opponentPlayerPosition: WorldPosition;
|
||||
@@ -59,13 +68,18 @@ export const HOCKEY_PVP_GOAL_HALF_WIDTH = 8;
|
||||
export const HOCKEY_PVP_PUCK_RADIUS = 0.42;
|
||||
export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
|
||||
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
|
||||
export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
|
||||
export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5;
|
||||
export const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
|
||||
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
|
||||
export const HOCKEY_PVP_POST_MATCH_SELECTIONS = ["rematch", "requeue", "menu"] as const;
|
||||
|
||||
const STARTING_SPEED = 7.2;
|
||||
const MAX_SPEED = 11.5;
|
||||
const STARTING_SPEED = 7.8;
|
||||
const MAX_SPEED = 12.2;
|
||||
const MAX_SUBSTEPS = 10;
|
||||
const MAX_SUBSTEP_DISTANCE = 0.32;
|
||||
const GUEST_RECONCILIATION_BLEND = 0.35;
|
||||
const GUEST_RECONCILIATION_SNAP_DISTANCE = 3;
|
||||
const SERVE_LANES = [0, -0.46, 0.58, -0.25, 0.34, -0.7, 0.74] as const;
|
||||
|
||||
export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number {
|
||||
@@ -95,6 +109,21 @@ export function hockeyPvpPuckSpeed(totalReturns: number) {
|
||||
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));
|
||||
}
|
||||
|
||||
export function cycleHockeyPvpPostMatchSelection(
|
||||
selection: HockeyPvpPostMatchSelection,
|
||||
direction: 1 | -1,
|
||||
): HockeyPvpPostMatchSelection {
|
||||
const currentIndex = HOCKEY_PVP_POST_MATCH_SELECTIONS.indexOf(selection);
|
||||
return HOCKEY_PVP_POST_MATCH_SELECTIONS[
|
||||
(Math.max(0, currentIndex) + direction + HOCKEY_PVP_POST_MATCH_SELECTIONS.length)
|
||||
% HOCKEY_PVP_POST_MATCH_SELECTIONS.length
|
||||
];
|
||||
}
|
||||
|
||||
function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition {
|
||||
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;
|
||||
@@ -107,7 +136,12 @@ export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpSt
|
||||
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
|
||||
return {
|
||||
...match,
|
||||
countdownEndsAtMs: config?.countdownEndsAtMs ?? 0,
|
||||
generation: config?.generation ?? 1,
|
||||
status: config ? "live" : "inactive",
|
||||
postMatchSelection: "rematch",
|
||||
postMatchStatus: "idle",
|
||||
postMatchQueueEndsAtMs: 0,
|
||||
puckPosition: [0, 0],
|
||||
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
|
||||
localReturns: 0,
|
||||
@@ -161,6 +195,59 @@ function resetAfterGoal(state: HockeyPvpPuckState, side: HockeyPvpGoalSide) {
|
||||
state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns);
|
||||
}
|
||||
|
||||
function predictGuestPuck(source: HockeyPvpState, delta: number): HockeyPvpState {
|
||||
const state: HockeyPvpState = {
|
||||
...source,
|
||||
puckPosition: [...source.puckPosition],
|
||||
puckVelocity: [...source.puckVelocity],
|
||||
};
|
||||
const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]);
|
||||
const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * delta / MAX_SUBSTEP_DISTANCE)));
|
||||
const subDelta = delta / substeps;
|
||||
const minX = HOCKEY_PVP_ARENA_MIN_X + HOCKEY_PVP_PUCK_RADIUS;
|
||||
const maxX = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_PUCK_RADIUS;
|
||||
|
||||
for (let index = 0; index < substeps; index += 1) {
|
||||
const end: WorldPosition = [
|
||||
state.puckPosition[0] + state.puckVelocity[0] * subDelta,
|
||||
state.puckPosition[1] + state.puckVelocity[1] * subDelta,
|
||||
];
|
||||
if (end[0] < minX || end[0] > maxX) {
|
||||
end[0] = Math.max(minX, Math.min(maxX, end[0]));
|
||||
state.puckVelocity[0] *= -1;
|
||||
}
|
||||
if (end[1] >= HOCKEY_PVP_GOAL_Z) {
|
||||
end[1] = HOCKEY_PVP_GOAL_Z;
|
||||
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = -Math.abs(state.puckVelocity[1]);
|
||||
} else if (end[1] <= -HOCKEY_PVP_GOAL_Z) {
|
||||
end[1] = -HOCKEY_PVP_GOAL_Z;
|
||||
if (Math.abs(end[0]) > HOCKEY_PVP_GOAL_HALF_WIDTH) state.puckVelocity[1] = Math.abs(state.puckVelocity[1]);
|
||||
}
|
||||
state.puckPosition = end;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function reconcileHockeyPvpPuck(
|
||||
predicted: HockeyPvpPuckState,
|
||||
authoritative: HockeyPvpPuckState,
|
||||
): HockeyPvpPuckState {
|
||||
const errorX = authoritative.puckPosition[0] - predicted.puckPosition[0];
|
||||
const errorZ = authoritative.puckPosition[1] - predicted.puckPosition[1];
|
||||
const shouldSnap = authoritative.goalSequence !== predicted.goalSequence
|
||||
|| Math.hypot(errorX, errorZ) >= GUEST_RECONCILIATION_SNAP_DISTANCE;
|
||||
return {
|
||||
...authoritative,
|
||||
puckPosition: shouldSnap
|
||||
? [...authoritative.puckPosition]
|
||||
: [
|
||||
predicted.puckPosition[0] + errorX * GUEST_RECONCILIATION_BLEND,
|
||||
predicted.puckPosition[1] + errorZ * GUEST_RECONCILIATION_BLEND,
|
||||
],
|
||||
puckVelocity: [...authoritative.puckVelocity],
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceHockeyPvpPuck(
|
||||
source: HockeyPvpState,
|
||||
step: {
|
||||
@@ -171,7 +258,10 @@ export function advanceHockeyPvpPuck(
|
||||
opponentAimDirection: WorldPosition;
|
||||
},
|
||||
): HockeyPvpState {
|
||||
if (source.status !== "live" || source.role === "guest" || step.delta <= 0) return source;
|
||||
if (source.status !== "live" || step.delta <= 0) return source;
|
||||
// Guest predicts travel only. Host snapshots remain authoritative for contacts,
|
||||
// goals, damage, and rally counters.
|
||||
if (source.role === "guest") return predictGuestPuck(source, step.delta);
|
||||
const state: HockeyPvpState = {
|
||||
...source,
|
||||
puckPosition: [...source.puckPosition],
|
||||
|
||||
@@ -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 { HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt } from "./hockeyHealingPvp";
|
||||
import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
|
||||
import { upcomingEncounterMechanic, useGameStore } from "./store";
|
||||
import { freshParty } from "./data";
|
||||
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear";
|
||||
@@ -23,6 +23,8 @@ function createMaxedGear() {
|
||||
}
|
||||
|
||||
describe("Healing Hockey PVP encounter integration", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer(
|
||||
"priest",
|
||||
@@ -42,6 +44,28 @@ describe("Healing Hockey PVP encounter integration", () => {
|
||||
expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0));
|
||||
expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id);
|
||||
expect(state.hockeyPvp.opponentName).toBe("CPU Aster");
|
||||
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
|
||||
});
|
||||
|
||||
it("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", () => {
|
||||
@@ -170,13 +194,16 @@ describe("Healing Hockey PVP encounter integration", () => {
|
||||
expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id);
|
||||
});
|
||||
|
||||
it("wins when opponent party falls", () => {
|
||||
it("wins when opponent companions fall while rival healer remains alive", () => {
|
||||
useGameStore.getState().startEncounter();
|
||||
useGameStore.getState().setActiveTab("map");
|
||||
useGameStore.setState((state) => ({
|
||||
hockeyPvpOpponent: {
|
||||
...state.hockeyPvpOpponent,
|
||||
party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, hp: 0 })),
|
||||
party: state.hockeyPvpOpponent.party.map((member) => ({
|
||||
...member,
|
||||
hp: member.id === "aelia" ? member.hp : 0,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
useGameStore.getState().tick(0.01);
|
||||
@@ -185,6 +212,60 @@ describe("Healing Hockey PVP encounter integration", () => {
|
||||
expect(useGameStore.getState().activeTab).toBe("combat");
|
||||
});
|
||||
|
||||
it("loses when local companions fall while healer remains alive", () => {
|
||||
useGameStore.getState().startEncounter();
|
||||
useGameStore.setState((state) => ({
|
||||
party: state.party.map((member) => ({
|
||||
...member,
|
||||
hp: member.id === "aelia" ? member.hp : 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
useGameStore.getState().tick(0.01);
|
||||
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBeGreaterThan(0);
|
||||
expect(useGameStore.getState().phase).toBe("defeat");
|
||||
expect(useGameStore.getState().hockeyPvp.status).toBe("lost");
|
||||
});
|
||||
|
||||
it("wins an online match when remote companions fall while rival healer remains alive", () => {
|
||||
useGameStore.getState().configureHealer(
|
||||
"priest",
|
||||
"Aelia",
|
||||
createClassInventory("priest"),
|
||||
[hockeyPvpBossAt(MATCH.seed, 0)],
|
||||
"hockey-healing-pvp",
|
||||
undefined,
|
||||
"initiate",
|
||||
{ ...MATCH, matchId: "online-match", role: "guest" },
|
||||
);
|
||||
useGameStore.getState().startEncounter();
|
||||
const state = useGameStore.getState();
|
||||
const snapshot: HockeyPvpRemoteSnapshot = {
|
||||
sequence: 1,
|
||||
time: state.time,
|
||||
party: state.hockeyPvpOpponent.party.map((member) => ({
|
||||
...member,
|
||||
hp: member.id === "aelia" ? member.hp : 0,
|
||||
})),
|
||||
partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions),
|
||||
boss: {
|
||||
id: state.hockeyPvpOpponent.boss.id,
|
||||
name: state.hockeyPvpOpponent.boss.name,
|
||||
hp: state.hockeyPvpOpponent.boss.hp,
|
||||
maxHp: state.hockeyPvpOpponent.boss.maxHp,
|
||||
},
|
||||
bossPosition: [...state.hockeyPvpOpponent.bossMotion.position],
|
||||
bossMode: state.hockeyPvpOpponent.bossMotion.mode,
|
||||
bossKills: 0,
|
||||
playerPosition: [...state.hockeyPvp.opponentPlayerPosition],
|
||||
aimDirection: [...state.hockeyPvp.opponentAimDirection],
|
||||
};
|
||||
|
||||
useGameStore.getState().applyHockeyPvpRemoteSnapshot(snapshot);
|
||||
expect(useGameStore.getState().phase).toBe("victory");
|
||||
expect(useGameStore.getState().hockeyPvp.status).toBe("won");
|
||||
});
|
||||
|
||||
it("keeps HUD mechanic data defined during instant boss replacement", () => {
|
||||
useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } }));
|
||||
expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Shared healer resource tuning for every encounter and run mode. */
|
||||
export const BASE_MANA_POOL = 150;
|
||||
|
||||
/** Global in-combat regeneration, expressed as mana restored per second. */
|
||||
export const MANA_REGEN_PER_SECOND = 3.2 / 3;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "./healers";
|
||||
import { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
|
||||
import type { AbilitySlotId, HealerClassId } from "./types";
|
||||
|
||||
const LONG_DUAL_BOSS_SECONDS = 100;
|
||||
const MINIMUM_ENDING_RESERVE = 20;
|
||||
const MAXIMUM_ENDING_RESERVE = 50;
|
||||
|
||||
/**
|
||||
* High-pressure reference rotations for the longest intended dual-boss fight.
|
||||
* Counts include spot healing, maintenance effects, four cleanses, repeated
|
||||
* group recovery, and both available one-minute cooldown casts.
|
||||
*/
|
||||
const REFERENCE_ROTATIONS = {
|
||||
priest: { ability1: 8, ability2: 8, ability3: 5, ability4: 4, ability5: 5, ability6: 2 },
|
||||
druid: { ability1: 7, ability2: 9, ability3: 9, ability4: 4, ability5: 5, ability6: 2 },
|
||||
shaman: { ability1: 7, ability2: 9, ability3: 4, ability4: 4, ability5: 6, ability6: 2 },
|
||||
paladin: { ability1: 10, ability2: 20, ability3: 4, ability4: 4, ability5: 6, ability6: 2 },
|
||||
chronomancer: { ability1: 7, ability2: 8, ability3: 8, ability4: 4, ability5: 5, ability6: 2 },
|
||||
} as const satisfies Record<HealerClassId, Record<AbilitySlotId, number>>;
|
||||
|
||||
function rotationManaCost(classId: HealerClassId): number {
|
||||
const abilities = HEALER_CLASSES[classId].abilities;
|
||||
return Object.entries(REFERENCE_ROTATIONS[classId]).reduce(
|
||||
(total, [slotId, casts]) => total + abilities[slotId as AbilitySlotId].mana * casts,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
describe("dual-boss healer mana balance", () => {
|
||||
const availableMana = BASE_MANA_POOL + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS;
|
||||
|
||||
it.each(HEALER_CLASS_ORDER)("funds %s's high-pressure 100-second rotation with a useful reserve", (classId) => {
|
||||
const rotation = REFERENCE_ROTATIONS[classId];
|
||||
const abilities = HEALER_CLASSES[classId].abilities;
|
||||
const totalCasts = Object.values(rotation).reduce((total, casts) => total + casts, 0);
|
||||
const remainingMana = availableMana - rotationManaCost(classId);
|
||||
|
||||
expect(totalCasts).toBeLessThanOrEqual(LONG_DUAL_BOSS_SECONDS / 0.5);
|
||||
expect(Object.values(rotation).every((casts) => casts > 0)).toBe(true);
|
||||
for (const [slotId, casts] of Object.entries(rotation)) {
|
||||
const cooldown = abilities[slotId as AbilitySlotId].cooldown;
|
||||
if (cooldown <= 0) continue;
|
||||
const maximumCasts = Math.floor((LONG_DUAL_BOSS_SECONDS - Number.EPSILON) / cooldown) + 1;
|
||||
expect(casts).toBeLessThanOrEqual(maximumCasts);
|
||||
}
|
||||
expect(remainingMana).toBeGreaterThanOrEqual(MINIMUM_ENDING_RESERVE);
|
||||
expect(remainingMana).toBeLessThanOrEqual(MAXIMUM_ENDING_RESERVE);
|
||||
});
|
||||
|
||||
it("keeps reference rotation costs close enough that no class gets a dominant mana advantage", () => {
|
||||
const costs = HEALER_CLASS_ORDER.map(rotationManaCost);
|
||||
expect(Math.max(...costs) - Math.min(...costs)).toBeLessThanOrEqual(25);
|
||||
});
|
||||
|
||||
it("detects why the previous 100 mana pool was insufficient", () => {
|
||||
const previousBudget = 100 + MANA_REGEN_PER_SECOND * LONG_DUAL_BOSS_SECONDS;
|
||||
for (const classId of HEALER_CLASS_ORDER) {
|
||||
expect(rotationManaCost(classId)).toBeGreaterThan(previousBudget);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
|
||||
RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
|
||||
rpgEncounterDifficulty,
|
||||
} from "./difficulty";
|
||||
|
||||
describe("RPG Roguelike encounter difficulty", () => {
|
||||
it("gives a solo boss the normal two-boss encounter health budget", () => {
|
||||
expect(rpgEncounterDifficulty("boss-room", 0)).toEqual({
|
||||
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER,
|
||||
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER,
|
||||
});
|
||||
expect(RPG_SOLO_BOSS_HEALTH_MULTIPLIER).toBeGreaterThan(1.5);
|
||||
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeGreaterThan(1);
|
||||
expect(RPG_SOLO_BOSS_DAMAGE_MULTIPLIER).toBeLessThan(2);
|
||||
});
|
||||
|
||||
it("raises boss durability per room and pressure per act", () => {
|
||||
const first = rpgEncounterDifficulty("boss-room", 0);
|
||||
const lateActOne = rpgEncounterDifficulty("boss-room", 2);
|
||||
const firstActTwo = rpgEncounterDifficulty("boss-room", 3);
|
||||
|
||||
expect(lateActOne.healthMultiplier).toBeGreaterThan(first.healthMultiplier);
|
||||
expect(lateActOne.damageMultiplier).toBe(first.damageMultiplier);
|
||||
expect(firstActTwo.healthMultiplier).toBeGreaterThan(lateActOne.healthMultiplier);
|
||||
expect(firstActTwo.damageMultiplier).toBeGreaterThan(lateActOne.damageMultiplier);
|
||||
});
|
||||
|
||||
it("leaves existing two-boss hallway challenge damage tuning intact", () => {
|
||||
expect(rpgEncounterDifficulty("hallway-challenge", 0)).toEqual({
|
||||
healthMultiplier: 0.82,
|
||||
damageMultiplier: 1,
|
||||
});
|
||||
const actTwo = rpgEncounterDifficulty("hallway-challenge", 3);
|
||||
expect(actTwo.healthMultiplier).toBeCloseTo(0.9);
|
||||
expect(actTwo.damageMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("normalizes invalid route indexes to the first room", () => {
|
||||
expect(rpgEncounterDifficulty("boss-room", -4)).toEqual(rpgEncounterDifficulty("boss-room", 0));
|
||||
expect(rpgEncounterDifficulty("boss-room", 1.9)).toEqual(rpgEncounterDifficulty("boss-room", 1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BOSSES_PER_ACT } from "./types";
|
||||
|
||||
export type RpgEncounterKind = "hallway-challenge" | "boss-room";
|
||||
|
||||
export interface RpgEncounterDifficulty {
|
||||
readonly healthMultiplier: number;
|
||||
readonly damageMultiplier: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Party rotations are calibrated around two simultaneous bosses. A solo RPG
|
||||
* boss therefore carries the full shared health budget, while incoming
|
||||
* damage stays below a full 2x multiplier so one unavoidable hit cannot stand
|
||||
* in for two independently targeted mechanics.
|
||||
*/
|
||||
export const RPG_SOLO_BOSS_HEALTH_MULTIPLIER = 2;
|
||||
export const RPG_SOLO_BOSS_DAMAGE_MULTIPLIER = 1.5;
|
||||
|
||||
const RPG_BOSS_HEALTH_GROWTH_PER_ROOM = 0.14;
|
||||
const RPG_BOSS_DAMAGE_GROWTH_PER_ACT = 0.08;
|
||||
const RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER = 0.82;
|
||||
const RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT = 0.08;
|
||||
|
||||
/** Central mode-specific tuning hook used only when an RPG encounter begins. */
|
||||
export function rpgEncounterDifficulty(
|
||||
kind: RpgEncounterKind,
|
||||
requestedBossIndex: number,
|
||||
): RpgEncounterDifficulty {
|
||||
const bossIndex = Math.max(0, Math.floor(requestedBossIndex));
|
||||
const act = Math.floor(bossIndex / BOSSES_PER_ACT);
|
||||
|
||||
if (kind === "hallway-challenge") {
|
||||
return {
|
||||
healthMultiplier: RPG_CHALLENGE_BASE_HEALTH_MULTIPLIER + act * RPG_CHALLENGE_HEALTH_GROWTH_PER_ACT,
|
||||
damageMultiplier: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
healthMultiplier: RPG_SOLO_BOSS_HEALTH_MULTIPLIER * (1 + bossIndex * RPG_BOSS_HEALTH_GROWTH_PER_ROOM),
|
||||
damageMultiplier: RPG_SOLO_BOSS_DAMAGE_MULTIPLIER * (1 + act * RPG_BOSS_DAMAGE_GROWTH_PER_ACT),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AVAILABLE_BOSS_IDS } from "../bossCatalog";
|
||||
import { createClassInventory } from "../healers";
|
||||
import { useGameStore } from "../store";
|
||||
import type { BossId } from "../types";
|
||||
import { rpgEncounterDifficulty } from "./difficulty";
|
||||
|
||||
function simulateSoloBoss(bossId: BossId, maxSeconds = 120): number {
|
||||
useGameStore.getState().configureHealer(
|
||||
"priest",
|
||||
"Calibration",
|
||||
createClassInventory("priest"),
|
||||
bossId,
|
||||
"encounter",
|
||||
);
|
||||
useGameStore.getState().startEncounter();
|
||||
const profile = rpgEncounterDifficulty("boss-room", 0);
|
||||
useGameStore.setState((state) => {
|
||||
const maxHp = Math.round(state.boss.maxHp * profile.healthMultiplier);
|
||||
return { boss: { ...state.boss, maxHp, hp: maxHp } };
|
||||
});
|
||||
|
||||
while (useGameStore.getState().phase === "combat" && useGameStore.getState().time < maxSeconds) {
|
||||
useGameStore.setState((state) => ({
|
||||
party: state.party.map((member) => ({ ...member, hp: member.maxHp, absorb: 10_000 })),
|
||||
}));
|
||||
useGameStore.getState().tick(0.1);
|
||||
}
|
||||
|
||||
return useGameStore.getState().time;
|
||||
}
|
||||
|
||||
describe("RPG Roguelike solo boss duration calibration", () => {
|
||||
it.each(AVAILABLE_BOSS_IDS)("keeps %s near the two-boss rotation duration budget", (bossId) => {
|
||||
const duration = simulateSoloBoss(bossId);
|
||||
expect(useGameStore.getState().phase).toBe("victory");
|
||||
expect(duration).toBeGreaterThanOrEqual(50);
|
||||
// Shared baseline targets 50–90 seconds, with 100 seconds as the hard cap.
|
||||
expect(duration).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
@@ -7,3 +7,4 @@ export * from "./rewards";
|
||||
export * from "./run";
|
||||
export * from "./playSpace";
|
||||
export * from "./uiModel";
|
||||
export * from "./difficulty";
|
||||
|
||||
@@ -18,6 +18,14 @@ import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types";
|
||||
|
||||
export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"];
|
||||
|
||||
export interface RunGearComparison {
|
||||
readonly currentItem: RunGearItem | undefined;
|
||||
readonly effectLabel: string;
|
||||
readonly currentValue: number;
|
||||
readonly replacementValue: number;
|
||||
readonly delta: number;
|
||||
}
|
||||
|
||||
const GEAR_SLOT_DATA: Record<RunGearSlotId, { label: string; statId: RunGearStatId }> = {
|
||||
weapon: { label: "Weapon", statId: "damage" },
|
||||
armor: { label: "Armor", statId: "maxHealth" },
|
||||
@@ -36,6 +44,29 @@ export function equippedRunGear(
|
||||
return equipment[ownerId]?.[slotId];
|
||||
}
|
||||
|
||||
/** Player weapons convert their damage budget into healing power in combat. */
|
||||
export function runGearEffectLabel(ownerId: RunGearOwnerId, statId: RunGearStatId): string {
|
||||
if (statId === "damage") return ownerId === "player" ? "Healing power" : "Damage";
|
||||
if (statId === "maxHealth") return "Max health";
|
||||
return "Haste";
|
||||
}
|
||||
|
||||
/** Comparison data shared by chest and shop projections on either display. */
|
||||
export function compareRunGear(
|
||||
equipment: RunEquipment,
|
||||
item: RunGearItem,
|
||||
): RunGearComparison {
|
||||
const currentItem = equippedRunGear(equipment, item.ownerId, item.slotId);
|
||||
const currentValue = currentItem?.statId === item.statId ? currentItem.statValue : 0;
|
||||
return {
|
||||
currentItem,
|
||||
effectLabel: runGearEffectLabel(item.ownerId, item.statId),
|
||||
currentValue,
|
||||
replacementValue: item.statValue,
|
||||
delta: item.statValue - currentValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRunGearItem(
|
||||
id: string,
|
||||
ownerId: RunGearOwnerId,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
assignRosterToCombatSlots,
|
||||
autoEquipRunGear,
|
||||
challengeObjective,
|
||||
compareRunGear,
|
||||
createRandomState,
|
||||
createRpgRoguelikeRun,
|
||||
createRunGearItem,
|
||||
@@ -257,6 +258,32 @@ describe("RPG Roguelike deterministic domain", () => {
|
||||
expect(third.bag).toContain(weaker);
|
||||
});
|
||||
|
||||
it("describes current and replacement gear buffs using combat-facing stat names", () => {
|
||||
const playerWeapon = createRunGearItem("player-current", "player", "weapon", 2);
|
||||
const playerUpgrade = createRunGearItem("player-upgrade", "player", "weapon", 5);
|
||||
const companionUpgrade = createRunGearItem("companion-upgrade", "tank-instance", "weapon", 3);
|
||||
const armor = createRunGearItem("companion-armor", "tank-instance", "armor", 1);
|
||||
const trinket = createRunGearItem("companion-trinket", "tank-instance", "trinket", 4);
|
||||
const equipment = { player: { weapon: playerWeapon } };
|
||||
|
||||
expect(compareRunGear(equipment, playerUpgrade)).toEqual({
|
||||
currentItem: playerWeapon,
|
||||
effectLabel: "Healing power",
|
||||
currentValue: 12,
|
||||
replacementValue: 24,
|
||||
delta: 12,
|
||||
});
|
||||
expect(compareRunGear(equipment, companionUpgrade)).toMatchObject({
|
||||
currentItem: undefined,
|
||||
effectLabel: "Damage",
|
||||
currentValue: 0,
|
||||
replacementValue: 16,
|
||||
delta: 16,
|
||||
});
|
||||
expect(compareRunGear(equipment, armor).effectLabel).toBe("Max health");
|
||||
expect(compareRunGear(equipment, trinket).effectLabel).toBe("Haste");
|
||||
});
|
||||
|
||||
it("supports shop buy, sell, rest, revive, and leave", () => {
|
||||
let state = finishDrafts(501);
|
||||
const generated = generateRunShop(state.random, state, 1);
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
createRpgRoguelikeRun,
|
||||
createRunGearItem,
|
||||
moveRpgFocus,
|
||||
partyCompositionLabel,
|
||||
partyRolePresentation,
|
||||
reduceRpgRoguelikeRun,
|
||||
rpgFocusId,
|
||||
rpgFocusItems,
|
||||
@@ -27,6 +29,27 @@ function reachSpellDraft(seed = 810): RpgRoguelikeRunState {
|
||||
}
|
||||
|
||||
describe("RPG Roguelike semantic UI focus", () => {
|
||||
it("presents party roles as Tank/DPS and summarizes duplicate tanks", () => {
|
||||
expect(partyRolePresentation("Tank")).toEqual({ label: "Tank", icon: "⬡", className: "tank" });
|
||||
expect(partyRolePresentation("Damage")).toEqual({ label: "DPS", icon: "⚔", className: "damage" });
|
||||
expect(partyCompositionLabel([
|
||||
{ role: "Tank" },
|
||||
{ role: "Tank" },
|
||||
{ role: "Damage" },
|
||||
{ role: "Damage" },
|
||||
])).toBe("2 Tanks · 2 DPS");
|
||||
});
|
||||
|
||||
it("includes each party role in controller action labels", () => {
|
||||
const state = createRpgRoguelikeRun({ seed: 19 });
|
||||
const labels = new Map(rpgFocusItems(state).map((item) => [item.id, item.label]));
|
||||
for (const candidate of state.partyDraft!.offers) {
|
||||
expect(labels.get(rpgFocusId.partyOffer(candidate.candidateId))).toBe(
|
||||
`Recruit ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves horizontally within card rows and vertically between action groups", () => {
|
||||
let state = createRpgRoguelikeRun({ seed: 120 });
|
||||
const offers = state.partyDraft!.offers;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
|
||||
import type { PartyRole, RpgRoguelikeAction, RpgRoguelikeRunState } from "./types";
|
||||
import { canRemovePartyMember } from "./run";
|
||||
import {
|
||||
MAX_ACTIVE_ROSTER,
|
||||
@@ -22,6 +22,29 @@ export interface RpgFocusItem {
|
||||
|
||||
export type RpgFocusDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
export interface PartyRolePresentation {
|
||||
readonly label: "Tank" | "DPS";
|
||||
readonly icon: "⬡" | "⚔";
|
||||
readonly className: "tank" | "damage";
|
||||
}
|
||||
|
||||
const PARTY_ROLE_PRESENTATIONS: Record<PartyRole, PartyRolePresentation> = {
|
||||
Tank: { label: "Tank", icon: "⬡", className: "tank" },
|
||||
Damage: { label: "DPS", icon: "⚔", className: "damage" },
|
||||
};
|
||||
|
||||
/** Player-facing role copy used by party cards, selected-party lists, and controller labels. */
|
||||
export function partyRolePresentation(role: PartyRole): PartyRolePresentation {
|
||||
return PARTY_ROLE_PRESENTATIONS[role];
|
||||
}
|
||||
|
||||
/** Compact composition summary for draft surfaces where duplicate tanks must be obvious. */
|
||||
export function partyCompositionLabel(members: readonly { readonly role: PartyRole }[]): string {
|
||||
const tankCount = members.reduce((count, member) => count + (member.role === "Tank" ? 1 : 0), 0);
|
||||
const damageCount = members.length - tankCount;
|
||||
return `${tankCount} ${tankCount === 1 ? "Tank" : "Tanks"} · ${damageCount} DPS`;
|
||||
}
|
||||
|
||||
export const rpgFocusId = {
|
||||
partyOffer: (candidateId: string) => `party-offer:${candidateId}`,
|
||||
partyMember: (memberId: string) => `party-member:${memberId}`,
|
||||
@@ -73,7 +96,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
|
||||
if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return [];
|
||||
return [action(
|
||||
rpgFocusId.partyOffer(candidate.candidateId),
|
||||
`${recruited ? "Remove" : "Recruit"} ${candidate.name}`,
|
||||
`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
|
||||
recruited
|
||||
? { type: "party-remove", memberId: candidate.candidateId }
|
||||
: { type: "party-recruit", candidateId: candidate.candidateId },
|
||||
@@ -81,7 +104,7 @@ export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] {
|
||||
});
|
||||
const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action(
|
||||
rpgFocusId.partyMember(member.instanceId),
|
||||
`Remove ${member.name}`,
|
||||
`Remove ${member.name}, ${partyRolePresentation(member.role).label}`,
|
||||
{ type: "party-remove", memberId: member.instanceId },
|
||||
)) : [];
|
||||
return [
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena";
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
import { createClassInventory } from "./healers";
|
||||
import { BASE_MANA_POOL } from "./mana";
|
||||
import { createDefaultGearProgress } from "./progression/gear";
|
||||
import { equipPassiveInfusion } from "./progression/infusions";
|
||||
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE } from "./rpgRoguelike";
|
||||
import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE, rpgEncounterDifficulty } from "./rpgRoguelike";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
function finishRpgDrafts() {
|
||||
@@ -111,6 +113,32 @@ describe("RPG Roguelike store integration", () => {
|
||||
expect(state.party.slice(1).every((member) => member.runProfile)).toBe(true);
|
||||
expect(Object.values(state.abilityLoadout)).toEqual(drafted.selectedSpellIds);
|
||||
expect(state.endlessMode).toBe(true);
|
||||
expect(state.maxMana).toBe(BASE_MANA_POOL);
|
||||
expect(state.mana).toBe(BASE_MANA_POOL);
|
||||
});
|
||||
|
||||
it("keeps dual hallway tuning and applies the solo boss-room difficulty profile", () => {
|
||||
finishRpgDrafts();
|
||||
useGameStore.getState().dispatchRpgAction({ type: "challenge-start" });
|
||||
|
||||
const challenge = useGameStore.getState();
|
||||
const challengeDifficulty = rpgEncounterDifficulty("hallway-challenge", 0);
|
||||
expect(challenge.additionalBosses).toHaveLength(1);
|
||||
expect(challenge.boss.maxHp).toBe(Math.round(
|
||||
BOSS_DEFINITIONS[challenge.boss.id].maxHp * challengeDifficulty.healthMultiplier,
|
||||
));
|
||||
expect(challenge.difficultyDamageMultiplier).toBe(challengeDifficulty.damageMultiplier);
|
||||
|
||||
completeCurrentChallenge();
|
||||
useGameStore.getState().dispatchRpgAction({ type: "boss-start" });
|
||||
|
||||
const bossRoom = useGameStore.getState();
|
||||
const bossDifficulty = rpgEncounterDifficulty("boss-room", 0);
|
||||
expect(bossRoom.additionalBosses).toHaveLength(0);
|
||||
expect(bossRoom.boss.maxHp).toBe(Math.round(
|
||||
BOSS_DEFINITIONS[bossRoom.boss.id].maxHp * bossDifficulty.healthMultiplier,
|
||||
));
|
||||
expect(bossRoom.difficultyDamageMultiplier).toBe(bossDifficulty.damageMultiplier);
|
||||
});
|
||||
|
||||
it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => {
|
||||
|
||||
+23
-2
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BULL_CHARGE } from "./bossMechanics";
|
||||
import { distance, pointToSegmentDistance } from "./geometry";
|
||||
import { BARRIER_RADIUS, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
|
||||
import { BASE_MANA_POOL, BARRIER_RADIUS, MANA_REGEN_PER_SECOND, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
|
||||
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||
import { healingEffect } from "./healerEffects";
|
||||
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
|
||||
@@ -61,6 +61,27 @@ 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("starts every healer class with the shared 150 mana pool", () => {
|
||||
for (const classId of Object.keys(HEALER_CLASSES) as (keyof typeof HEALER_CLASSES)[]) {
|
||||
useGameStore.getState().configureHealer(classId, "Aelia", createClassInventory(classId));
|
||||
const state = useGameStore.getState();
|
||||
expect(state.maxMana).toBe(BASE_MANA_POOL);
|
||||
expect(state.mana).toBe(BASE_MANA_POOL);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a three-second Purify cooldown for every healer class", () => {
|
||||
expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3);
|
||||
expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3);
|
||||
@@ -549,7 +570,7 @@ describe("Roguelike ability buffs", () => {
|
||||
useGameStore.getState().selectMember("brann");
|
||||
|
||||
expect(useGameStore.getState().castAbility("ability1")).toBe(true);
|
||||
expect(useGameStore.getState().mana).toBe(97);
|
||||
expect(useGameStore.getState().mana).toBe(BASE_MANA_POOL - 3);
|
||||
expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3);
|
||||
useGameStore.getState().tick(0.22);
|
||||
|
||||
|
||||
+44
-15
@@ -40,6 +40,7 @@ import {
|
||||
resolveTimeLoop,
|
||||
startTimeLoop,
|
||||
} from "./healerMechanics";
|
||||
import { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
|
||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
||||
import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState";
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
type HockeyHealingState,
|
||||
} from "./hockeyHealing";
|
||||
import {
|
||||
HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER,
|
||||
HOCKEY_PVP_GOAL_DAMAGE,
|
||||
advanceHockeyPvpCpuGoalie,
|
||||
advanceHockeyPvpPuck,
|
||||
@@ -77,7 +79,10 @@ import {
|
||||
hockeyPvpHealingEffectiveness,
|
||||
hockeyPvpBossAt,
|
||||
mirrorHockeyPvpPuck,
|
||||
reconcileHockeyPvpPuck,
|
||||
type HockeyPvpMatchConfig,
|
||||
type HockeyPvpPostMatchSelection,
|
||||
type HockeyPvpPostMatchStatus,
|
||||
type HockeyPvpRemoteSnapshot,
|
||||
type HockeyPvpState,
|
||||
} from "./hockeyHealingPvp";
|
||||
@@ -120,7 +125,6 @@ import {
|
||||
} from "./aetherAssault";
|
||||
import {
|
||||
assignRosterToCombatSlots,
|
||||
BOSSES_PER_ACT,
|
||||
createRpgRoguelikeRun,
|
||||
reduceRpgRoguelikeRun,
|
||||
selectCurrentBossId,
|
||||
@@ -136,6 +140,7 @@ import {
|
||||
spellRankPowerMultiplier,
|
||||
type RpgPartyDamageProfiles,
|
||||
} from "./rpgRoguelike/combatAdapter";
|
||||
import { rpgEncounterDifficulty } from "./rpgRoguelike/difficulty";
|
||||
import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace";
|
||||
import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel";
|
||||
|
||||
@@ -239,6 +244,8 @@ export interface GameState {
|
||||
selectItem: (itemId: string) => void;
|
||||
setPlayerPosition: (position: [number, number]) => void;
|
||||
setHockeyAimDirection: (direction: [number, number]) => void;
|
||||
setHockeyPvpPostMatchSelection: (selection: HockeyPvpPostMatchSelection) => void;
|
||||
setHockeyPvpPostMatchStatus: (status: HockeyPvpPostMatchStatus, queueEndsAtMs?: number) => void;
|
||||
applyHockeyPvpRemoteSnapshot: (snapshot: HockeyPvpRemoteSnapshot, hostPuck?: HockeyPvpRemoteSnapshot["puck"]) => void;
|
||||
setPaused: (paused: boolean) => void;
|
||||
togglePause: () => void;
|
||||
@@ -266,6 +273,7 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
|
||||
|
||||
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
|
||||
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
|
||||
export { BASE_MANA_POOL, MANA_REGEN_PER_SECOND } from "./mana";
|
||||
|
||||
export const BARRIER_RADIUS = 4;
|
||||
export const BARRIER_DAMAGE_REDUCTION = 0.3;
|
||||
@@ -581,7 +589,7 @@ function initialState(
|
||||
0,
|
||||
"hockey",
|
||||
);
|
||||
const maxMana = 100;
|
||||
const maxMana = BASE_MANA_POOL;
|
||||
return {
|
||||
bossId: primary.boss.id,
|
||||
bossInstanceId: primary.instanceId,
|
||||
@@ -628,7 +636,8 @@ function initialState(
|
||||
runModifiers,
|
||||
healingMultiplier: gearModifiers.aelia.healingPower,
|
||||
difficultySlug,
|
||||
difficultyDamageMultiplier: difficulty.damageMultiplier,
|
||||
difficultyDamageMultiplier: difficulty.damageMultiplier
|
||||
* (runMode === "hockey-healing-pvp" ? HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER : 1),
|
||||
gearProgress,
|
||||
gearModifiers,
|
||||
time: 0,
|
||||
@@ -700,9 +709,12 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
|
||||
? [currentBossId, challengePartner]
|
||||
: [currentBossId];
|
||||
const layout: EncounterLayout = challenge ? "hockey" : "standard";
|
||||
const act = Math.floor(run.bossIndex / BOSSES_PER_ACT);
|
||||
const healthMultiplier = (challenge ? 0.82 + act * 0.08 : 1 + run.bossIndex * 0.14)
|
||||
* DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier;
|
||||
const modeDifficulty = rpgEncounterDifficulty(
|
||||
challenge ? "hallway-challenge" : "boss-room",
|
||||
run.bossIndex,
|
||||
);
|
||||
const baseDifficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
|
||||
const healthMultiplier = modeDifficulty.healthMultiplier * baseDifficulty.healthMultiplier;
|
||||
const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss(
|
||||
bossId,
|
||||
index,
|
||||
@@ -721,7 +733,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
|
||||
? { ...member, hp: member.maxHp * healerRatio }
|
||||
: member);
|
||||
const deterministicSeed = (run.random.state ^ ((run.bossIndex + 1) * 0x9e3779b9)) >>> 0;
|
||||
const maxMana = 100;
|
||||
const maxMana = BASE_MANA_POOL;
|
||||
|
||||
return {
|
||||
rpgRun: run,
|
||||
@@ -747,6 +759,7 @@ function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Part
|
||||
party,
|
||||
gearModifiers: projection.gearModifiers,
|
||||
healingMultiplier: projection.gearModifiers.aelia.healingPower,
|
||||
difficultyDamageMultiplier: modeDifficulty.damageMultiplier * baseDifficulty.damageMultiplier,
|
||||
partyCombat: createPartyCombatState(party),
|
||||
partyDamageEvents: [],
|
||||
partyPositions: freshPartyPositions(bossIds, layout),
|
||||
@@ -807,6 +820,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
|
||||
startEncounter: () => {
|
||||
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.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
|
||||
else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" });
|
||||
@@ -828,7 +844,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
difficultySlug,
|
||||
seenBossIds,
|
||||
runMode === "hockey-healing-pvp"
|
||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
|
||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
|
||||
: undefined,
|
||||
abilityLoadout,
|
||||
),
|
||||
@@ -881,7 +897,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
difficultySlug,
|
||||
[],
|
||||
runMode === "hockey-healing-pvp"
|
||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
|
||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, generation: hockeyPvp.generation, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
|
||||
: undefined,
|
||||
abilityLoadout,
|
||||
));
|
||||
@@ -1103,10 +1119,18 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
const hockey = setHockeyAim(state.hockey, direction);
|
||||
return hockey === state.hockey ? state : { hockey };
|
||||
}),
|
||||
|
||||
setHockeyPvpPostMatchSelection: (postMatchSelection) => set((state) => ({
|
||||
hockeyPvp: { ...state.hockeyPvp, postMatchSelection },
|
||||
})),
|
||||
|
||||
setHockeyPvpPostMatchStatus: (postMatchStatus, postMatchQueueEndsAtMs = 0) => set((state) => ({
|
||||
hockeyPvp: { ...state.hockeyPvp, postMatchStatus, postMatchQueueEndsAtMs },
|
||||
})),
|
||||
applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => {
|
||||
if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state;
|
||||
const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck
|
||||
? mirrorHockeyPvpPuck(hostPuck)
|
||||
? reconcileHockeyPvpPuck(state.hockeyPvp, mirrorHockeyPvpPuck(hostPuck))
|
||||
: undefined;
|
||||
const previousLocalGoals = state.hockeyPvp.localGoalsConceded;
|
||||
const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals;
|
||||
@@ -1121,8 +1145,8 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
})
|
||||
: state.party;
|
||||
const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] }));
|
||||
const opponentWiped = isPartyWiped(opponentParty);
|
||||
const localWiped = isPartyWiped(party);
|
||||
const opponentWiped = areAllNonHealerAlliesDefeated(opponentParty);
|
||||
const localWiped = areAllNonHealerAlliesDefeated(party);
|
||||
const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase;
|
||||
return {
|
||||
party,
|
||||
@@ -2104,7 +2128,8 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost";
|
||||
const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost";
|
||||
const pvpMode = state.activityMode === "hockey-healing-pvp";
|
||||
const opponentWiped = pvpMode && isPartyWiped(hockeyPvpOpponent.party);
|
||||
const localPvpTeamDefeated = pvpMode && allCompanionsDefeated;
|
||||
const opponentWiped = pvpMode && areAllNonHealerAlliesDefeated(hockeyPvpOpponent.party);
|
||||
const rpgChallengeActive = rpgRun?.phase === "challenge-active";
|
||||
const rpgBossActive = rpgRun?.phase === "boss-combat";
|
||||
if (rpgChallengeActive && rpgRun) {
|
||||
@@ -2186,7 +2211,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
phase = "combat";
|
||||
endlessMode = false;
|
||||
} else if (pvpMode) {
|
||||
if (partyWiped) {
|
||||
if (localPvpTeamDefeated) {
|
||||
phase = "defeat";
|
||||
hockeyPvp.status = "lost";
|
||||
combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger");
|
||||
@@ -2254,7 +2279,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
hockeyPvp,
|
||||
hockeyPvpOpponent,
|
||||
runBuffInputUnlockAt,
|
||||
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
|
||||
mana: Math.min(state.maxMana, state.mana + MANA_REGEN_PER_SECOND * (time - oldTime)),
|
||||
activeCast,
|
||||
combatLog,
|
||||
scenePulse: pulse,
|
||||
@@ -2277,6 +2302,8 @@ export type GameSnapshot = Omit<GameState,
|
||||
| "selectItem"
|
||||
| "setPlayerPosition"
|
||||
| "setHockeyAimDirection"
|
||||
| "setHockeyPvpPostMatchSelection"
|
||||
| "setHockeyPvpPostMatchStatus"
|
||||
| "applyHockeyPvpRemoteSnapshot"
|
||||
| "setPaused"
|
||||
| "togglePause"
|
||||
@@ -2306,6 +2333,8 @@ export function getGameSnapshot(): GameSnapshot {
|
||||
selectItem: _selectItem,
|
||||
setPlayerPosition: _setPlayerPosition,
|
||||
setHockeyAimDirection: _setHockeyAimDirection,
|
||||
setHockeyPvpPostMatchSelection: _setHockeyPvpPostMatchSelection,
|
||||
setHockeyPvpPostMatchStatus: _setHockeyPvpPostMatchStatus,
|
||||
applyHockeyPvpRemoteSnapshot: _applyHockeyPvpRemoteSnapshot,
|
||||
setPaused: _setPaused,
|
||||
togglePause: _togglePause,
|
||||
|
||||
@@ -5,6 +5,18 @@ import { isRunBuffInputLocked, useGameStore } from "./store";
|
||||
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
|
||||
import { cycleBottomTab } from "./bottomTabs";
|
||||
import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel";
|
||||
import { getDisplaySurface } from "../platform/displayRouting";
|
||||
import { isSingleScreenLayout } from "../platform/displayLayout";
|
||||
import { requestHockeyPvpPostMatchAction } from "../platform/dualScreenSync";
|
||||
import { cycleHockeyPvpPostMatchSelection } from "./hockeyHealingPvp";
|
||||
|
||||
function tacticalOverlayOwnsInput() {
|
||||
const store = useGameStore.getState();
|
||||
if (!isSingleScreenLayout() || getDisplaySurface() !== "bottom") return false;
|
||||
if (store.paused || store.phase === "intermission") return false;
|
||||
if (store.runMode === "rpg-roguelike" && rpgInputIsGated()) return false;
|
||||
return !(store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode);
|
||||
}
|
||||
|
||||
function cycleRunBuff(direction: 1 | -1) {
|
||||
const store = useGameStore.getState();
|
||||
@@ -29,15 +41,23 @@ function activateRpgFocus(onExit?: () => void) {
|
||||
else onExit?.();
|
||||
}
|
||||
|
||||
function activateHockeyPvpPostMatch(onExit?: () => void) {
|
||||
const store = useGameStore.getState();
|
||||
if (store.hockeyPvp.postMatchSelection === "menu") onExit?.();
|
||||
else requestHockeyPvpPostMatchAction(store.hockeyPvp.postMatchSelection);
|
||||
}
|
||||
|
||||
export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
const exitRef = useRef(onExit);
|
||||
exitRef.current = onExit;
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.repeat) return;
|
||||
const store = useGameStore.getState();
|
||||
const key = event.key.toLowerCase();
|
||||
if (tacticalOverlayOwnsInput()) return;
|
||||
if (store.paused) {
|
||||
if (["escape", "arrowup", "arrowdown", "enter"].includes(key)) event.preventDefault();
|
||||
if (key === "escape") store.setPaused(false);
|
||||
@@ -82,6 +102,18 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
if (key === "escape") exitRef.current?.();
|
||||
return;
|
||||
}
|
||||
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault();
|
||||
if (key === "arrowleft" || key === "arrowup") {
|
||||
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||
}
|
||||
if (key === "arrowright" || key === "arrowdown") {
|
||||
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||
}
|
||||
if (key === "enter") activateHockeyPvpPostMatch(exitRef.current);
|
||||
if (key === "escape") exitRef.current?.();
|
||||
return;
|
||||
}
|
||||
const numberIndex = Number(event.key) - 1;
|
||||
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
|
||||
store.castAbility(ABILITY_ORDER[numberIndex]);
|
||||
@@ -124,6 +156,8 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
|
||||
if (!enabled) return;
|
||||
const store = useGameStore.getState();
|
||||
if (isSingleScreenLayout() && token === "Button8") return;
|
||||
if (tacticalOverlayOwnsInput()) return;
|
||||
if (store.paused) {
|
||||
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
|
||||
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
|
||||
@@ -165,6 +199,17 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
if (!repeat && token === "Button1") exitRef.current?.();
|
||||
return;
|
||||
}
|
||||
if ((store.phase === "victory" || store.phase === "defeat") && store.runMode === "hockey-healing-pvp") {
|
||||
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) {
|
||||
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, -1));
|
||||
}
|
||||
if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) {
|
||||
store.setHockeyPvpPostMatchSelection(cycleHockeyPvpPostMatchSelection(store.hockeyPvp.postMatchSelection, 1));
|
||||
}
|
||||
if (!repeat && (token === "Button0" || token === "Button9")) activateHockeyPvpPostMatch(exitRef.current);
|
||||
if (!repeat && token === "Button1") exitRef.current?.();
|
||||
return;
|
||||
}
|
||||
if (repeat) return;
|
||||
if (token.startsWith("Button")) {
|
||||
const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))];
|
||||
|
||||
@@ -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 { BottomDisplayApp } from "./platform/BottomDisplayApp";
|
||||
import { startControllerInput } from "./input/controller";
|
||||
import { currentDisplayLayout } from "./platform/displayLayout";
|
||||
import "./styles.css";
|
||||
|
||||
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
|
||||
const displayMode = new URLSearchParams(window.location.search).get("display");
|
||||
const displayLayout = currentDisplayLayout();
|
||||
|
||||
if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
|
||||
document.documentElement.classList.add("native-platform");
|
||||
@@ -15,6 +17,7 @@ if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
|
||||
if (displayMode === "top" || displayMode === "bottom") {
|
||||
document.documentElement.dataset.displaySurface = displayMode;
|
||||
}
|
||||
document.documentElement.dataset.displayLayout = displayLayout;
|
||||
|
||||
startControllerInput();
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ export function BottomDisplayApp() {
|
||||
return false;
|
||||
},
|
||||
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
|
||||
setHockeyPvpPostMatchSelection: (selection) => postCommand({ name: "setHockeyPvpPostMatchSelection", selection }),
|
||||
dispatchRpgAction: (action) => {
|
||||
postCommand({ name: "dispatchRpgAction", action });
|
||||
return false;
|
||||
@@ -254,7 +255,7 @@ export function BottomDisplayApp() {
|
||||
return (
|
||||
<main className="bottom-display-root">
|
||||
{surface.screen === "game"
|
||||
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} /></Suspense>
|
||||
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} onHockeyPvpAction={(action) => postFrontendCommand({ name: "hockeyPvpPostMatch", action })} /></Suspense>
|
||||
: surface.notice === "Linking upper display…"
|
||||
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
|
||||
: <FrontEnd onLaunch={launchGame} />}
|
||||
|
||||
@@ -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";
|
||||
|
||||
const DISPLAY_SURFACE_EVENT = "thor:display-surface";
|
||||
let currentSurface: DisplaySurface = "top";
|
||||
|
||||
export function requestDisplaySurface(surface: DisplaySurface) {
|
||||
currentSurface = surface;
|
||||
window.dispatchEvent(new CustomEvent<DisplaySurface>(DISPLAY_SURFACE_EVENT, { detail: surface }));
|
||||
}
|
||||
|
||||
export function getDisplaySurface() {
|
||||
return currentSurface;
|
||||
}
|
||||
|
||||
export function subscribeDisplaySurface(listener: (surface: DisplaySurface) => void) {
|
||||
const onSurface = (event: Event) => listener((event as CustomEvent<DisplaySurface>).detail);
|
||||
const onSurface = (event: Event) => {
|
||||
currentSurface = (event as CustomEvent<DisplaySurface>).detail;
|
||||
listener(currentSurface);
|
||||
};
|
||||
window.addEventListener(DISPLAY_SURFACE_EVENT, onSurface);
|
||||
return () => window.removeEventListener(DISPLAY_SURFACE_EVENT, onSurface);
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ describe("dual-screen game snapshots", () => {
|
||||
it("routes maxed-run continuation and passive filter commands", () => {
|
||||
const originalContinue = useGameStore.getState().continueRoguelikeRound;
|
||||
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
|
||||
const originalSetHockeyPvpPostMatchSelection = useGameStore.getState().setHockeyPvpPostMatchSelection;
|
||||
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
|
||||
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
|
||||
const originalSelectProfileView = useFrontendStore.getState().selectProfileCollectionView;
|
||||
@@ -127,6 +128,7 @@ describe("dual-screen game snapshots", () => {
|
||||
useGameStore.setState({
|
||||
continueRoguelikeRound: () => { calls.push("continue"); return true; },
|
||||
startRogueTrialsEndless: () => { calls.push("endless"); return true; },
|
||||
setHockeyPvpPostMatchSelection: (selection) => { calls.push(`pvp:${selection}`); },
|
||||
});
|
||||
useFrontendStore.setState({
|
||||
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
|
||||
@@ -138,6 +140,7 @@ describe("dual-screen game snapshots", () => {
|
||||
|
||||
executeGameCommand({ name: "continueRoguelikeRound" });
|
||||
executeGameCommand({ name: "startRogueTrialsEndless" });
|
||||
executeGameCommand({ name: "setHockeyPvpPostMatchSelection", selection: "requeue" });
|
||||
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "ability3" });
|
||||
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
|
||||
executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" });
|
||||
@@ -146,6 +149,7 @@ describe("dual-screen game snapshots", () => {
|
||||
expect(calls).toEqual([
|
||||
"continue",
|
||||
"endless",
|
||||
"pvp:requeue",
|
||||
"ability:ability3",
|
||||
"passive:shield-guard",
|
||||
"profile-view:stats",
|
||||
@@ -153,7 +157,11 @@ describe("dual-screen game snapshots", () => {
|
||||
"profile-stat:broodfang-spider",
|
||||
]);
|
||||
|
||||
useGameStore.setState({ continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless });
|
||||
useGameStore.setState({
|
||||
continueRoguelikeRound: originalContinue,
|
||||
startRogueTrialsEndless: originalStartEndless,
|
||||
setHockeyPvpPostMatchSelection: originalSetHockeyPvpPostMatchSelection,
|
||||
});
|
||||
useFrontendStore.setState({
|
||||
selectPassiveAbility: originalSelectAbility,
|
||||
selectPassiveInfusion: originalSelectPassive,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types";
|
||||
import type { GearOwnerId, GearSlotId } from "../game/progression/gear";
|
||||
import type { DifficultySlug } from "../game/progression/loot";
|
||||
import type { BossGroupId } from "../game/bossCatalog";
|
||||
import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp";
|
||||
import type { HockeyPvpMatchConfig, HockeyPvpPostMatchSelection } from "../game/hockeyHealingPvp";
|
||||
import type { RpgFocusDirection, RpgRoguelikeAction } from "../game/rpgRoguelike";
|
||||
import type { CharacterAppearanceV1, CharacterModelMode } from "../game/characterAppearance";
|
||||
|
||||
@@ -30,6 +30,7 @@ export type GameCommand =
|
||||
| { name: "continueRoguelikeRound" }
|
||||
| { name: "startRogueTrialsEndless" }
|
||||
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" }
|
||||
| { name: "setHockeyPvpPostMatchSelection"; selection: HockeyPvpPostMatchSelection }
|
||||
| { name: "dispatchRpgAction"; action: RpgRoguelikeAction }
|
||||
| { name: "setRpgFocusId"; focusId: string }
|
||||
| { name: "cycleRpgFocus"; direction: 1 | -1 }
|
||||
@@ -76,11 +77,17 @@ export type FrontendCommand =
|
||||
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
||||
| { name: "selectHealerClass"; classId: HealerClassId }
|
||||
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
|
||||
| { name: "hockeyPvpPostMatch"; action: Exclude<HockeyPvpPostMatchSelection, "menu"> }
|
||||
| { name: "exitGame" }
|
||||
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig };
|
||||
|
||||
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
|
||||
export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game";
|
||||
export const HOCKEY_PVP_POST_MATCH_EVENT = "iwt:hockey-pvp-post-match";
|
||||
|
||||
export function requestHockeyPvpPostMatchAction(action: Exclude<HockeyPvpPostMatchSelection, "menu">) {
|
||||
window.dispatchEvent(new CustomEvent(HOCKEY_PVP_POST_MATCH_EVENT, { detail: action }));
|
||||
}
|
||||
|
||||
export type DualScreenMessage =
|
||||
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
|
||||
@@ -114,6 +121,7 @@ export function executeGameCommand(command: GameCommand) {
|
||||
case "continueRoguelikeRound": game.continueRoguelikeRound(); break;
|
||||
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
|
||||
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
|
||||
case "setHockeyPvpPostMatchSelection": game.setHockeyPvpPostMatchSelection(command.selection); break;
|
||||
case "dispatchRpgAction": game.dispatchRpgAction(command.action); break;
|
||||
case "setRpgFocusId": game.setRpgFocusId(command.focusId); break;
|
||||
case "cycleRpgFocus": game.cycleRpgFocus(command.direction); break;
|
||||
@@ -164,6 +172,7 @@ export function executeFrontendCommand(command: FrontendCommand) {
|
||||
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
|
||||
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
|
||||
case "updateSetting": frontend.updateSetting(command.key, command.value); break;
|
||||
case "hockeyPvpPostMatch": requestHockeyPvpPostMatchAction(command.action); break;
|
||||
case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break;
|
||||
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug, hockeyPvpMatch: command.hockeyPvpMatch } })); break;
|
||||
}
|
||||
|
||||
+319
@@ -118,6 +118,224 @@ button:focus-visible {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* PC and handheld browsers use the Thor top surface as the canonical game
|
||||
viewport. The lower surface becomes a disclosed tactical layer. */
|
||||
html[data-display-layout="single"],
|
||||
html[data-display-layout="single"] body,
|
||||
html[data-display-layout="single"] #root,
|
||||
html[data-display-layout="single"] .app-shell,
|
||||
.single-display-frame,
|
||||
.single-primary-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .app-shell {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .app-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.single-display-frame {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background: #030706;
|
||||
}
|
||||
|
||||
.single-primary-surface {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.single-primary-surface > .display,
|
||||
.single-primary-surface > .top-display,
|
||||
.single-primary-surface > .front-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.single-primary-surface > .top-display {
|
||||
container-name: single-game-screen;
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
.single-context-layer {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.single-context-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: linear-gradient(90deg, rgba(1, 5, 4, 0.6), rgba(1, 5, 4, 0.86));
|
||||
backdrop-filter: blur(5px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.single-context-surface {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, calc((100dvh - 28px) * 31 / 27));
|
||||
max-width: 760px;
|
||||
max-height: calc(100dvh - 28px);
|
||||
aspect-ratio: 31 / 27;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(232, 200, 114, 0.42);
|
||||
border-radius: 8px;
|
||||
background: #07110f;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.72), 0 0 32px rgba(94, 199, 176, 0.08);
|
||||
}
|
||||
|
||||
.single-context-surface > .bottom-display,
|
||||
.single-context-surface > .front-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.single-context-toggle {
|
||||
position: fixed;
|
||||
right: max(14px, env(safe-area-inset-right));
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
z-index: 100;
|
||||
min-width: 94px;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(232, 200, 114, 0.62);
|
||||
border-radius: 4px;
|
||||
color: var(--ink);
|
||||
background: rgba(3, 9, 8, 0.92);
|
||||
box-shadow: 0 7px 22px rgba(0, 0, 0, 0.45);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.single-context-toggle b {
|
||||
color: var(--gold-strong);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.single-context-toggle small {
|
||||
color: var(--muted);
|
||||
font-size: 7px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.single-display-frame.context-open .single-context-toggle {
|
||||
top: max(18px, env(safe-area-inset-top));
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .top-party {
|
||||
width: clamp(185px, 20cqw, 330px);
|
||||
gap: clamp(3px, .55cqh, 7px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .top-party-member {
|
||||
min-height: clamp(38px, 7.8cqh, 72px);
|
||||
grid-template-columns: clamp(27px, 3.1cqw, 50px) minmax(0, 1fr);
|
||||
gap: clamp(5px, .75cqw, 11px);
|
||||
padding: clamp(3px, .45cqw, 7px) clamp(5px, .7cqw, 11px) clamp(6px, .8cqw, 12px) clamp(3px, .45cqw, 7px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .portrait-dot {
|
||||
width: clamp(26px, 3cqw, 48px);
|
||||
height: clamp(26px, 3cqw, 48px);
|
||||
font-size: clamp(10px, 1.05cqw, 17px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .top-party-copy strong {
|
||||
font-size: clamp(10px, 1.05cqw, 17px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .top-party-copy small {
|
||||
font-size: clamp(7px, .68cqw, 11px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .microbar {
|
||||
right: clamp(5px, .7cqw, 11px);
|
||||
bottom: clamp(3px, .4cqh, 6px);
|
||||
left: clamp(37px, 4.25cqw, 68px);
|
||||
height: clamp(5px, .58cqh, 8px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .microbar.player-health-bar {
|
||||
bottom: clamp(9px, 1.05cqh, 15px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .player-mana-bar {
|
||||
right: clamp(5px, .7cqw, 11px);
|
||||
bottom: clamp(3px, .4cqh, 6px);
|
||||
left: clamp(37px, 4.25cqw, 68px);
|
||||
height: clamp(3px, .38cqh, 6px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .boss-bar-wrap {
|
||||
width: min(39%, 660px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .boss-name {
|
||||
font-size: clamp(7px, .62cqw, 11px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .boss-name strong {
|
||||
font-size: clamp(12px, 1.2cqw, 20px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .objective-chip span {
|
||||
font-size: clamp(6px, .58cqw, 10px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .objective-chip strong {
|
||||
font-size: clamp(8px, .82cqw, 14px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .casting-bar {
|
||||
bottom: clamp(76px, 11cqh, 126px);
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .control-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-display-layout="single"] .encounter-callout {
|
||||
bottom: clamp(74px, 10cqh, 118px);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) and (min-height: 700px) {
|
||||
.single-context-layer {
|
||||
justify-items: end;
|
||||
padding-right: max(24px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.single-context-surface {
|
||||
width: min(56vw, 720px);
|
||||
}
|
||||
}
|
||||
|
||||
.screen-label {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -683,6 +901,49 @@ button:focus-visible {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pvp-match-countdown {
|
||||
min-width: 150px;
|
||||
margin: 12px 0 8px;
|
||||
padding: 8px 24px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: end;
|
||||
border: 1px solid rgba(231, 198, 111, 0.7);
|
||||
background: rgba(4, 13, 11, 0.76);
|
||||
box-shadow: 0 0 30px rgba(231, 198, 111, 0.13), inset 0 0 18px rgba(231, 198, 111, 0.06);
|
||||
text-shadow: 0 2px 10px #000;
|
||||
}
|
||||
|
||||
.pvp-match-countdown span { grid-column: 1 / -1; color: var(--gold); font-size: 9px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||
.pvp-match-countdown strong { color: #fff7dc; font-family: "Cinzel", serif; font-size: clamp(44px, 7vw, 74px); line-height: 0.9; }
|
||||
.phase-overlay .pvp-match-countdown small { padding-bottom: 4px; color: #b7a878; font-size: 8px; }
|
||||
|
||||
.top-pvp-end-actions {
|
||||
width: min(390px, 72%);
|
||||
margin: 15px 0 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.top-pvp-end-actions button {
|
||||
min-height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 2px;
|
||||
border: 1px solid #566c63;
|
||||
color: #dce9e4;
|
||||
background: rgba(5, 17, 14, 0.88);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.top-pvp-end-actions button strong { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.top-pvp-end-actions button small { color: #788e85; font-size: 7px; }
|
||||
.top-pvp-end-actions button.is-controller-selected { border-color: var(--gold); outline: 2px solid #fff1b6; outline-offset: 2px; background: rgba(77, 65, 30, 0.78); }
|
||||
.top-pvp-end-actions button:disabled { cursor: default; opacity: 0.7; }
|
||||
.top-pvp-post-match-status { min-height: 16px; margin-bottom: 4px; color: #aebfb8; font-size: 9px; letter-spacing: 0.06em; }
|
||||
|
||||
.phase-victory { background: radial-gradient(circle at center, rgba(23, 71, 53, 0.42), rgba(3, 9, 8, 0.82)); }
|
||||
.phase-intermission { background: radial-gradient(circle at center, rgba(94, 76, 26, 0.38), rgba(3, 9, 8, 0.84)); }
|
||||
.phase-defeat { background: radial-gradient(circle at center, rgba(85, 31, 21, 0.45), rgba(6, 5, 4, 0.86)); }
|
||||
@@ -1066,6 +1327,19 @@ button:focus-visible {
|
||||
gap: 4%;
|
||||
}
|
||||
|
||||
.single-ability-bar {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
width: min(64vw, 760px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: clamp(3px, .45vw, 7px);
|
||||
transform: translateX(-50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ability {
|
||||
--ability-color: #ddd;
|
||||
position: relative;
|
||||
@@ -1086,6 +1360,40 @@ button:focus-visible {
|
||||
transition: filter 100ms ease, transform 100ms ease, border-color 100ms ease;
|
||||
}
|
||||
|
||||
.ability.is-compact {
|
||||
height: clamp(48px, 7.2dvh, 68px);
|
||||
grid-template-columns: clamp(27px, 3.1vw, 37px) minmax(0, 1fr);
|
||||
gap: clamp(3px, .45vw, 7px);
|
||||
padding: 5px;
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--ability-color), #0d1b18 92%), rgba(3, 10, 8, .94));
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, .32);
|
||||
}
|
||||
|
||||
.ability.is-compact .ability-icon {
|
||||
width: clamp(27px, 3.1vw, 37px);
|
||||
height: clamp(27px, 3.1vw, 37px);
|
||||
font-size: clamp(14px, 1.45vw, 20px);
|
||||
}
|
||||
|
||||
.ability.is-compact .ability-copy strong {
|
||||
font-size: clamp(7px, .72vw, 11px);
|
||||
}
|
||||
|
||||
.ability.is-compact .ability-copy small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ability.is-compact .ability-key,
|
||||
.ability.is-compact .ability-pad {
|
||||
font-size: clamp(5px, .46vw, 7px);
|
||||
}
|
||||
|
||||
.ability.is-compact .cooldown-mask b {
|
||||
width: clamp(28px, 3vw, 38px);
|
||||
height: clamp(28px, 3vw, 38px);
|
||||
font-size: clamp(11px, 1.1vw, 16px);
|
||||
}
|
||||
|
||||
.ability::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -1298,9 +1606,19 @@ button:focus-visible {
|
||||
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
|
||||
.end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
|
||||
.end-actions button.is-controller-selected { outline: 2px solid #fff1b6; outline-offset: 2px; }
|
||||
.end-actions button:disabled { cursor: default; opacity: 0.62; }
|
||||
.endless-choice-actions button:first-child { display: grid; gap: 3px; min-width: 155px; }
|
||||
.endless-choice-actions button:first-child small { font-size: 7px; font-weight: 600; letter-spacing: 0.06em; opacity: 0.72; }
|
||||
|
||||
.end-panel.is-pvp { padding-block: 4%; }
|
||||
.end-panel.is-pvp h2 { margin-bottom: 10px; }
|
||||
.pvp-post-match-status { min-height: 15px; margin-top: 12px; color: #9eb2aa; font-size: 9px; letter-spacing: 0.04em; }
|
||||
.pvp-end-actions { width: min(100%, 470px); grid-template-columns: 1fr 1fr auto; }
|
||||
.pvp-end-actions button { min-height: 46px; display: grid; place-items: center; gap: 2px; padding: 8px 14px; }
|
||||
.pvp-end-actions button span { font-size: 9px; }
|
||||
.pvp-end-actions button small { color: rgba(19, 23, 15, 0.62); font-size: 7px; letter-spacing: 0.04em; }
|
||||
.pvp-end-actions button.secondary small { color: #6f837b; }
|
||||
|
||||
.buff-draft {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
@@ -2596,6 +2914,7 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
.game-menu-button small { color: #71857e; font-size: 6px; }
|
||||
.game-menu-button.is-controller-selected { border-color: var(--gold); outline: 2px solid #fff1b6; outline-offset: 2px; }
|
||||
|
||||
.game-loading {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user