Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fd434226 |
@@ -131,10 +131,10 @@ outside the repository.
|
|||||||
- `WASD` / left stick: move
|
- `WASD` / left stick: move
|
||||||
- `Q` and `E` / D-pad: cycle party target
|
- `Q` and `E` / D-pad: cycle party target
|
||||||
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
- `1`–`6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
|
||||||
- Gamepad: `X`, `Y`, `B`, `A`, `LB`, `RB` map to those abilities
|
- Gamepad: PlayStation `□`, `△`, `○`, `✕`, `L1`, `R1` map to those abilities
|
||||||
- `M`: tactical map
|
- `M`: tactical map
|
||||||
- `I`: inventory and item tooltip
|
- `I`: inventory and item tooltip
|
||||||
- `Enter` / Start: begin or reset encounter
|
- `Enter` / `START`: begin or reset encounter
|
||||||
|
|
||||||
Touch controls on lower display support party targeting, ability casting, map, and inventory.
|
Touch controls on lower display support party targeting, ability casting, map, and inventory.
|
||||||
|
|
||||||
|
|||||||
@@ -54,3 +54,15 @@ CREATE TABLE IF NOT EXISTS roguelike_records (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS roguelike_rank_idx
|
CREATE INDEX IF NOT EXISTS roguelike_rank_idx
|
||||||
ON roguelike_records (highest_round DESC, updated_at ASC);
|
ON roguelike_records (highest_round DESC, updated_at ASC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rogue_trials_endless_records (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
|
||||||
|
highest_boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (highest_boss_kills >= 0),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (account_id, slot_id),
|
||||||
|
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS rogue_trials_endless_rank_idx
|
||||||
|
ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -217,6 +217,14 @@ function syncLeaderboardStats(database, accountId, slotId, save) {
|
|||||||
highest_round = excluded.highest_round,
|
highest_round = excluded.highest_round,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
`).run(accountId, slotId, highestRound);
|
`).run(accountId, slotId, highestRound);
|
||||||
|
const highestEndlessKills = normalizeNonNegativeInteger(save.stats?.highestRogueTrialsEndlessKills);
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO rogue_trials_endless_records (account_id, slot_id, highest_boss_kills, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(account_id, slot_id) DO UPDATE SET
|
||||||
|
highest_boss_kills = excluded.highest_boss_kills,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`).run(accountId, slotId, highestEndlessKills);
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeSave(database, accountId, slotId, rawSave) {
|
function writeSave(database, accountId, slotId, rawSave) {
|
||||||
@@ -324,6 +332,32 @@ function roguelikeLeaderboard(database, accountId, slotId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rogueTrialsEndlessLeaderboard(database, accountId, slotId) {
|
||||||
|
const rows = database.prepare(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
RANK() OVER (ORDER BY records.highest_boss_kills DESC) AS rank,
|
||||||
|
records.account_id AS accountId,
|
||||||
|
records.slot_id AS slotId,
|
||||||
|
records.highest_boss_kills AS highestBossKills,
|
||||||
|
accounts.username,
|
||||||
|
saves.hunter_name AS hunterName,
|
||||||
|
records.updated_at AS updatedAt
|
||||||
|
FROM rogue_trials_endless_records records
|
||||||
|
JOIN accounts ON accounts.id = records.account_id
|
||||||
|
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
|
||||||
|
WHERE records.highest_boss_kills > 0
|
||||||
|
)
|
||||||
|
SELECT * FROM ranked ORDER BY highestBossKills DESC, updatedAt ASC, accountId ASC, slotId ASC
|
||||||
|
`).all();
|
||||||
|
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
|
||||||
|
return {
|
||||||
|
kind: "rogue-trials-endless",
|
||||||
|
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestBossKills")),
|
||||||
|
current: current ? leaderboardEntry(current, "highestBossKills") : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createGameApiHandler(options = {}) {
|
export function createGameApiHandler(options = {}) {
|
||||||
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
|
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
|
||||||
mkdirSync(dataDirectory, { recursive: true });
|
mkdirSync(dataDirectory, { recursive: true });
|
||||||
@@ -387,6 +421,10 @@ export function createGameApiHandler(options = {}) {
|
|||||||
const slotId = validateSlotId(url.searchParams.get("slot"));
|
const slotId = validateSlotId(url.searchParams.get("slot"));
|
||||||
return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId));
|
return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId));
|
||||||
}
|
}
|
||||||
|
if (path === "/api/leaderboards/rogue-trials-endless" && request.method === "GET") {
|
||||||
|
const slotId = validateSlotId(url.searchParams.get("slot"));
|
||||||
|
return sendJson(response, 200, rogueTrialsEndlessLeaderboard(database, session.accountId, slotId));
|
||||||
|
}
|
||||||
return sendJson(response, 404, { error: "API route not found." });
|
return sendJson(response, 404, { error: "API route not found." });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = Number(error?.status) || 500;
|
const status = Number(error?.status) || 500;
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ async function json(path, init = {}) {
|
|||||||
return { response, body };
|
return { response, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound) {
|
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills) {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 5,
|
schemaVersion: 5,
|
||||||
slotId,
|
slotId,
|
||||||
hunterName,
|
hunterName,
|
||||||
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound },
|
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +62,14 @@ test("accounts, server saves, and top-five plus current rankings work end to end
|
|||||||
const token = registration.body.token;
|
const token = registration.body.token;
|
||||||
const kills = 60 - index * 10;
|
const kills = 60 - index * 10;
|
||||||
const highestRound = 30 - index * 4;
|
const highestRound = 30 - index * 4;
|
||||||
|
const highestEndlessKills = 24 - index * 3;
|
||||||
const upload = await json("/api/saves/1", {
|
const upload = await json("/api/saves/1", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound) }),
|
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills) }),
|
||||||
});
|
});
|
||||||
assert.equal(upload.response.status, 200);
|
assert.equal(upload.response.status, 200);
|
||||||
players.push({ token, kills, highestRound });
|
players.push({ token, kills, highestRound, highestEndlessKills });
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = players[5];
|
const current = players[5];
|
||||||
@@ -87,6 +88,15 @@ test("accounts, server saves, and top-five plus current rankings work end to end
|
|||||||
assert.equal(rogueBoard.body.current.rank, 6);
|
assert.equal(rogueBoard.body.current.rank, 6);
|
||||||
assert.equal(rogueBoard.body.current.value, current.highestRound);
|
assert.equal(rogueBoard.body.current.value, current.highestRound);
|
||||||
|
|
||||||
|
const endlessBoard = await json("/api/leaderboards/rogue-trials-endless?slot=1", {
|
||||||
|
headers: { Authorization: `Bearer ${current.token}` },
|
||||||
|
});
|
||||||
|
assert.equal(endlessBoard.body.kind, "rogue-trials-endless");
|
||||||
|
assert.equal(endlessBoard.body.top.length, 5);
|
||||||
|
assert.equal(endlessBoard.body.top[0].value, 24);
|
||||||
|
assert.equal(endlessBoard.body.current.rank, 6);
|
||||||
|
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
|
||||||
|
|
||||||
const download = await json("/api/saves/1", {
|
const download = await json("/api/saves/1", {
|
||||||
headers: { Authorization: `Bearer ${current.token}` },
|
headers: { Authorization: `Bearer ${current.token}` },
|
||||||
});
|
});
|
||||||
|
|||||||
+21
-9
@@ -8,7 +8,7 @@ import type { BossId } from "./game/types";
|
|||||||
import type { DifficultySlug } from "./game/progression/loot";
|
import type { DifficultySlug } from "./game/progression/loot";
|
||||||
import { useActionBindings } from "./game/useGameLoop";
|
import { useActionBindings } from "./game/useGameLoop";
|
||||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||||
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
||||||
|
|
||||||
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
|
||||||
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
||||||
@@ -33,6 +33,7 @@ export default function App() {
|
|||||||
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
|
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
|
||||||
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
|
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
|
||||||
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
|
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
|
||||||
|
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
|
||||||
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
|
||||||
const rewardedBossInstances = useRef(new Set<string>());
|
const rewardedBossInstances = useRef(new Set<string>());
|
||||||
const screenRef = useRef(screen);
|
const screenRef = useRef(screen);
|
||||||
@@ -69,6 +70,11 @@ export default function App() {
|
|||||||
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
|
||||||
}, [launchGame]);
|
}, [launchGame]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
||||||
|
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
|
||||||
|
}, [leaveGame]);
|
||||||
|
|
||||||
useActionBindings(screen === "game", leaveGame);
|
useActionBindings(screen === "game", leaveGame);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,8 +85,11 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useGameStore.subscribe((state, previousState) => {
|
return useGameStore.subscribe((state, previousState) => {
|
||||||
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|
||||||
|| previousState.phase === "intermission" && state.phase === "combat";
|
|| previousState.phase === "intermission" && state.phase === "combat"
|
||||||
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") {
|
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode;
|
||||||
|
if (state.phase === "briefing"
|
||||||
|
|| previousState.phase === "intermission" && state.phase === "combat"
|
||||||
|
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode) {
|
||||||
rewardedBossInstances.current.clear();
|
rewardedBossInstances.current.clear();
|
||||||
}
|
}
|
||||||
if (startedFreshEncounter) clearRecentRewards();
|
if (startedFreshEncounter) clearRecentRewards();
|
||||||
@@ -88,11 +97,14 @@ export default function App() {
|
|||||||
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
|
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
|
||||||
recordRoguelikeDefeat(state.round);
|
recordRoguelikeDefeat(state.round);
|
||||||
}
|
}
|
||||||
|
if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
|
||||||
|
recordRogueTrialsEndlessDefeat(state.endlessBossKills);
|
||||||
|
}
|
||||||
const bossCount = 1 + state.additionalBosses.length;
|
const bossCount = 1 + state.additionalBosses.length;
|
||||||
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
|
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
|
||||||
const primaryInstanceId = `boss-0-${state.boss.id}`;
|
const primaryInstanceId = state.bossInstanceId;
|
||||||
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
|
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
|
||||||
rewardedBossInstances.current.add(primaryInstanceId);
|
if (!state.endlessMode) rewardedBossInstances.current.add(primaryInstanceId);
|
||||||
const defeatedBefore = (state.round - 1) * bossCount;
|
const defeatedBefore = (state.round - 1) * bossCount;
|
||||||
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
||||||
recordBossVictory(state.boss.id, rewardDifficulty);
|
recordBossVictory(state.boss.id, rewardDifficulty);
|
||||||
@@ -102,14 +114,14 @@ export default function App() {
|
|||||||
const entry = state.additionalBosses[index];
|
const entry = state.additionalBosses[index];
|
||||||
const previous = previousState.additionalBosses[index];
|
const previous = previousState.additionalBosses[index];
|
||||||
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
|
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
|
||||||
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
|
if (!justDefeated || !state.endlessMode && rewardedBossInstances.current.has(entry.instanceId)) continue;
|
||||||
rewardedBossInstances.current.add(entry.instanceId);
|
if (!state.endlessMode) rewardedBossInstances.current.add(entry.instanceId);
|
||||||
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
|
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
|
||||||
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
|
||||||
recordBossVictory(entry.boss.id, rewardDifficulty);
|
recordBossVictory(entry.boss.id, rewardDifficulty);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat]);
|
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
@@ -118,7 +130,7 @@ export default function App() {
|
|||||||
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
|
||||||
</header>
|
</header>
|
||||||
{screen === "game"
|
{screen === "game"
|
||||||
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense>
|
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
|
||||||
: <FrontEnd onLaunch={launchGame} />}
|
: <FrontEnd onLaunch={launchGame} />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../g
|
|||||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||||
import type { BottomTab, PartyMember } from "../game/types";
|
import type { BottomTab, PartyMember } from "../game/types";
|
||||||
import { useFrontendStore } from "../frontend/store";
|
import { useFrontendStore } from "../frontend/store";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
function RewardSummary() {
|
function RewardSummary() {
|
||||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||||
@@ -170,7 +171,7 @@ function BriefingPanel() {
|
|||||||
<span>Chosen discipline</span>
|
<span>Chosen discipline</span>
|
||||||
<h2>{healer.specialization}</h2>
|
<h2>{healer.specialization}</h2>
|
||||||
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
|
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
|
||||||
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>START / ENTER</small></button>
|
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="briefing-kit">
|
<div className="briefing-kit">
|
||||||
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
||||||
@@ -189,29 +190,51 @@ function BriefingPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EndPanel() {
|
function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
|
const round = useGameStore((state) => state.round);
|
||||||
|
const endlessMode = useGameStore((state) => state.endlessMode);
|
||||||
|
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||||||
|
const endlessChoiceSelection = useGameStore((state) => state.endlessChoiceSelection);
|
||||||
|
const setEndlessChoiceSelection = useGameStore((state) => state.setEndlessChoiceSelection);
|
||||||
|
const startRogueTrialsEndless = useGameStore((state) => state.startRogueTrialsEndless);
|
||||||
const time = useGameStore((state) => state.time);
|
const time = useGameStore((state) => state.time);
|
||||||
const party = useGameStore((state) => state.party);
|
const party = useGameStore((state) => state.party);
|
||||||
const restart = useGameStore((state) => state.restart);
|
const restart = useGameStore((state) => state.restart);
|
||||||
const startEncounter = useGameStore((state) => state.startEncounter);
|
const startEncounter = useGameStore((state) => state.startEncounter);
|
||||||
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
|
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
|
||||||
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||||
|
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||||
|
const endlessDefeat = phase === "defeat" && endlessMode;
|
||||||
return (
|
return (
|
||||||
<div className={`end-panel end-${phase}`}>
|
<div className={`end-panel end-${phase}`}>
|
||||||
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
||||||
<small>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
|
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
|
||||||
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
|
<h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
|
||||||
<div className="result-stats">
|
<div className="result-stats">
|
||||||
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
|
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
|
||||||
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
|
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
|
||||||
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
|
<span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
{phase === "victory" && <RewardSummary />}
|
{phase === "victory" && <RewardSummary />}
|
||||||
<div className="end-actions">
|
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
|
||||||
|
<button
|
||||||
|
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
|
||||||
|
onFocus={() => setEndlessChoiceSelection("continue")}
|
||||||
|
onPointerEnter={() => setEndlessChoiceSelection("continue")}
|
||||||
|
onClick={startRogueTrialsEndless}
|
||||||
|
>Continue Endless</button>
|
||||||
|
<button
|
||||||
|
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
|
||||||
|
onFocus={() => setEndlessChoiceSelection("quit")}
|
||||||
|
onPointerEnter={() => setEndlessChoiceSelection("quit")}
|
||||||
|
onClick={onExit}
|
||||||
|
>Quit to Main Menu</button>
|
||||||
|
</div> : <div className="end-actions">
|
||||||
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
|
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
|
||||||
<button className="secondary" onClick={restart}>Return to briefing</button>
|
<button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,16 +248,16 @@ function IntermissionStatusPanel() {
|
|||||||
<h2>Choose on top display</h2>
|
<h2>Choose on top display</h2>
|
||||||
<p>Next encounter stays locked until one blessing is claimed.</p>
|
<p>Next encounter stays locked until one blessing is claimed.</p>
|
||||||
<RewardSummary />
|
<RewardSummary />
|
||||||
<small>Use D-pad to choose · A to claim</small>
|
<small>Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CombatPanel() {
|
function CombatPanel({ onExit }: { onExit?: () => void }) {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
if (phase === "briefing") return <BriefingPanel />;
|
if (phase === "briefing") return <BriefingPanel />;
|
||||||
if (phase === "intermission") return <IntermissionStatusPanel />;
|
if (phase === "intermission") return <IntermissionStatusPanel />;
|
||||||
if (phase === "victory" || phase === "defeat") return <EndPanel />;
|
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
|
||||||
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +363,7 @@ const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
|
|||||||
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
|
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BottomScreen() {
|
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
||||||
const activeTab = useGameStore((state) => state.activeTab);
|
const activeTab = useGameStore((state) => state.activeTab);
|
||||||
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
@@ -358,13 +381,13 @@ export function BottomScreen() {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main className="lower-content">
|
<main className="lower-content">
|
||||||
{activeTab === "combat" && <CombatPanel />}
|
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
|
||||||
{activeTab === "map" && <MapPanel />}
|
{activeTab === "map" && <MapPanel />}
|
||||||
{activeTab === "pack" && <PackPanel />}
|
{activeTab === "pack" && <PackPanel />}
|
||||||
</main>
|
</main>
|
||||||
{paused && (
|
{paused && (
|
||||||
<div className="lower-pause-overlay" aria-hidden="true">
|
<div className="lower-pause-overlay" aria-hidden="true">
|
||||||
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · ↑↓ selects menu action</small>
|
<span>PAUSED</span><strong>Encounter suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · ↑↓ selects menu action</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
|
import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
|
||||||
import { HEALER_CLASSES } from "../game/healers";
|
import { HEALER_CLASSES } from "../game/healers";
|
||||||
import { isRunBuffInputLocked, useGameStore } from "../game/store";
|
import { isRunBuffInputLocked, useGameStore } from "../game/store";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
||||||
const round = useGameStore((state) => state.round);
|
const round = useGameStore((state) => state.round);
|
||||||
@@ -67,7 +68,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<footer>{inputLocked ? <b>Choices ready in a moment…</b> : <>{choices.length > 0 && <><b>← / →</b> Choose <i /></>} <b>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
|
<footer>{inputLocked ? <b>Choices ready in a moment…</b> : <>{choices.length > 0 && <><b>← / →</b> Choose <i /></>} <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||||
import { subscribeControllerToken } from "../input/controller";
|
import { subscribeControllerToken } from "../input/controller";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
|
||||||
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
|
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
|
||||||
@@ -45,7 +46,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
|
|||||||
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
|
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
|
||||||
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
|
||||||
>
|
>
|
||||||
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small>
|
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+37
-20
@@ -37,6 +37,7 @@ import {
|
|||||||
import { requestDisplaySurface } from "../platform/displayRouting";
|
import { requestDisplaySurface } from "../platform/displayRouting";
|
||||||
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
|
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
|
||||||
import { DualDisplayFrame } from "./DualDisplayFrame";
|
import { DualDisplayFrame } from "./DualDisplayFrame";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
|
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ function BrandMark({ compact = false }: { compact?: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ControllerLegend({ back = false }: { back?: boolean }) {
|
function ControllerLegend({ back = false }: { back?: boolean }) {
|
||||||
return <div className="controller-legend"><span><b>A</b> Select</span>{back && <span><b>B</b> Back</span>}<span><b>+</b> Navigate</span></div>;
|
return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b>+</b> Navigate</span></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoginScreen() {
|
function LoginScreen() {
|
||||||
@@ -335,7 +336,7 @@ function SaveScreen() {
|
|||||||
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
|
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
|
||||||
<div className="save-actions">
|
<div className="save-actions">
|
||||||
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
|
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
|
||||||
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>A</small>
|
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small>
|
||||||
</FocusButton>
|
</FocusButton>
|
||||||
<div className="sync-actions">
|
<div className="sync-actions">
|
||||||
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}>↑ Sync offline to server</FocusButton>
|
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}>↑ Sync offline to server</FocusButton>
|
||||||
@@ -399,7 +400,6 @@ function HomeScreen() {
|
|||||||
top={
|
top={
|
||||||
<FrontSurface className="home-surface" ariaLabel="Main menu">
|
<FrontSurface className="home-surface" ariaLabel="Main menu">
|
||||||
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
|
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
|
||||||
<div className="home-title"><span>Choose your hunt</span><h1>Where are you needed?</h1></div>
|
|
||||||
<div className="mode-grid">
|
<div className="mode-grid">
|
||||||
{HOME_MODES.map((mode) => (
|
{HOME_MODES.map((mode) => (
|
||||||
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
|
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
|
||||||
@@ -441,6 +441,8 @@ function HomeScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless";
|
||||||
|
|
||||||
function ProfileScreen() {
|
function ProfileScreen() {
|
||||||
const hunter = useActiveHunter();
|
const hunter = useActiveHunter();
|
||||||
const accountId = useFrontendStore((state) => state.accountId);
|
const accountId = useFrontendStore((state) => state.accountId);
|
||||||
@@ -449,11 +451,11 @@ function ProfileScreen() {
|
|||||||
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
|
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
|
||||||
const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies");
|
const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies");
|
||||||
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
|
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
|
||||||
const [selectedStat, setSelectedStat] = useState<BossId | "roguelike">("roguelike");
|
const [selectedStat, setSelectedStat] = useState<ProfileStatId>("roguelike");
|
||||||
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
|
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
|
||||||
const [leaderboardStatus, setLeaderboardStatus] = useState("");
|
const [leaderboardStatus, setLeaderboardStatus] = useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedStat === "roguelike" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
|
if (selectedStat === "roguelike" || selectedStat === "rogue-trials-endless" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
|
||||||
setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike");
|
setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike");
|
||||||
}, [collection, selectedStat]);
|
}, [collection, selectedStat]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -467,7 +469,9 @@ function ProfileScreen() {
|
|||||||
setLeaderboardStatus("Loading overall rankings…");
|
setLeaderboardStatus("Loading overall rankings…");
|
||||||
const request = selectedStat === "roguelike"
|
const request = selectedStat === "roguelike"
|
||||||
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
|
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
|
||||||
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
|
: selectedStat === "rogue-trials-endless"
|
||||||
|
? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId)
|
||||||
|
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
|
||||||
void request.then((result) => {
|
void request.then((result) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setLeaderboard(result);
|
setLeaderboard(result);
|
||||||
@@ -484,12 +488,13 @@ function ProfileScreen() {
|
|||||||
{ id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } },
|
{ id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } },
|
||||||
{ id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } },
|
{ id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } },
|
||||||
...(collectionView === "stats" ? [
|
...(collectionView === "stats" ? [
|
||||||
{ id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: `stat-${collection.bosses[0].bossId}` } },
|
{ id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: "stat-rogue-trials-endless" } },
|
||||||
|
{ id: "stat-rogue-trials-endless", run: () => setSelectedStat("rogue-trials-endless"), neighbors: { up: "stat-roguelike", down: `stat-${collection.bosses[0].bossId}` } },
|
||||||
...collection.bosses.map((boss, index) => ({
|
...collection.bosses.map((boss, index) => ({
|
||||||
id: `stat-${boss.bossId}`,
|
id: `stat-${boss.bossId}`,
|
||||||
run: () => setSelectedStat(boss.bossId),
|
run: () => setSelectedStat(boss.bossId),
|
||||||
neighbors: {
|
neighbors: {
|
||||||
up: index === 0 ? "stat-roguelike" : `stat-${collection.bosses[index - 1].bossId}`,
|
up: index === 0 ? "stat-rogue-trials-endless" : `stat-${collection.bosses[index - 1].bossId}`,
|
||||||
down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`,
|
down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
@@ -503,6 +508,16 @@ function ProfileScreen() {
|
|||||||
const activeProgress = hunter.healers[hunter.activeClassId];
|
const activeProgress = hunter.healers[hunter.activeClassId];
|
||||||
const earned = collection.drops.filter((drop) => drop.count > 0).length;
|
const earned = collection.drops.filter((drop) => drop.count > 0).length;
|
||||||
const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length;
|
const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length;
|
||||||
|
const selectedStatValue = selectedStat === "roguelike"
|
||||||
|
? hunter.stats.highestRoguelikeRound
|
||||||
|
: selectedStat === "rogue-trials-endless"
|
||||||
|
? hunter.stats.highestRogueTrialsEndlessKills
|
||||||
|
: hunter.stats.bossKills[selectedStat] ?? 0;
|
||||||
|
const selectedStatLabel = selectedStat === "roguelike"
|
||||||
|
? "Roguelike rounds"
|
||||||
|
: selectedStat === "rogue-trials-endless"
|
||||||
|
? "Rogue Trials endless kills"
|
||||||
|
: BOSS_DEFINITIONS[selectedStat].name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
@@ -512,7 +527,7 @@ function ProfileScreen() {
|
|||||||
<FocusButton id="view-trophies" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "trophies" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "trophies"} onClick={() => setCollectionView("trophies")}>Trophy Case</FocusButton>
|
<FocusButton id="view-trophies" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "trophies" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "trophies"} onClick={() => setCollectionView("trophies")}>Trophy Case</FocusButton>
|
||||||
<FocusButton id="view-stats" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "stats" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "stats"} onClick={() => setCollectionView("stats")}>Boss Stats</FocusButton>
|
<FocusButton id="view-stats" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "stats" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "stats"} onClick={() => setCollectionView("stats")}>Boss Stats</FocusButton>
|
||||||
<FocusButton id="view-loot" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "loot" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</FocusButton>
|
<FocusButton id="view-loot" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "loot" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</FocusButton>
|
||||||
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
|
||||||
{collectionView === "loot" ? <>
|
{collectionView === "loot" ? <>
|
||||||
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
|
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
|
||||||
<div className="collection-grid">
|
<div className="collection-grid">
|
||||||
@@ -540,18 +555,19 @@ function ProfileScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="collection-note trophy-note"><i>♛</i><span><strong>Each guardian keeps its own trophy.</strong><small>Defeat that boss for a 1 in 500 pet roll.</small></span></div>
|
<div className="collection-note trophy-note"><i>♛</i><span><strong>Each guardian keeps its own trophy.</strong><small>Defeat that boss for a 1 in 500 pet roll.</small></span></div>
|
||||||
</> : <>
|
</> : <>
|
||||||
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Highest roguelike round {hunter.stats.highestRoguelikeRound}</b></div>
|
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills</b></div>
|
||||||
<div className="boss-stats-layout">
|
<div className="boss-stats-layout">
|
||||||
<section className="boss-stat-selector" aria-label="Boss statistic selection">
|
<section className="boss-stat-selector" aria-label="Boss statistic selection">
|
||||||
<FocusButton id="stat-roguelike" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "roguelike" ? "is-selected" : ""} onClick={() => setSelectedStat("roguelike")}><i>∞</i><span><strong>Roguelike</strong><small>Highest round before defeat</small></span><b>{hunter.stats.highestRoguelikeRound}</b></FocusButton>
|
<FocusButton id="stat-roguelike" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "roguelike" ? "is-selected" : ""} onClick={() => setSelectedStat("roguelike")}><i>∞</i><span><strong>Roguelike</strong><small>Highest round before defeat</small></span><b>{hunter.stats.highestRoguelikeRound}</b></FocusButton>
|
||||||
|
<FocusButton id="stat-rogue-trials-endless" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "rogue-trials-endless" ? "is-selected" : ""} onClick={() => setSelectedStat("rogue-trials-endless")}><i>Ⅲ</i><span><strong>Trials Endless</strong><small>Most bosses in one run</small></span><b>{hunter.stats.highestRogueTrialsEndlessKills}</b></FocusButton>
|
||||||
{collection.bosses.map((boss) => <FocusButton key={boss.bossId} id={`stat-${boss.bossId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === boss.bossId ? "is-selected" : ""} onClick={() => setSelectedStat(boss.bossId)}><i>{BOSS_DEFINITIONS[boss.bossId].icon}</i><span><strong>{boss.bossName}</strong><small>Lifetime boss kills</small></span><b>{boss.kills}</b></FocusButton>)}
|
{collection.bosses.map((boss) => <FocusButton key={boss.bossId} id={`stat-${boss.bossId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === boss.bossId ? "is-selected" : ""} onClick={() => setSelectedStat(boss.bossId)}><i>{BOSS_DEFINITIONS[boss.bossId].icon}</i><span><strong>{boss.bossName}</strong><small>Lifetime boss kills</small></span><b>{boss.kills}</b></FocusButton>)}
|
||||||
</section>
|
</section>
|
||||||
<section className="leaderboard-panel" aria-label="Overall leaderboard">
|
<section className="leaderboard-panel" aria-label="Overall leaderboard">
|
||||||
<header><span><small>Overall Top 5</small><strong>{selectedStat === "roguelike" ? "Roguelike rounds" : BOSS_DEFINITIONS[selectedStat].name}</strong></span><b>{selectedStat === "roguelike" ? `${hunter.stats.highestRoguelikeRound} best` : `${hunter.stats.bossKills[selectedStat] ?? 0} kills`}</b></header>
|
<header><span><small>Overall Top 5</small><strong>{selectedStatLabel}</strong></span><b>{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}</b></header>
|
||||||
{leaderboardStatus ? <div className="leaderboard-status">{leaderboardStatus}</div> : <div className="leaderboard-rows">
|
{leaderboardStatus ? <div className="leaderboard-status">{leaderboardStatus}</div> : <div className="leaderboard-rows">
|
||||||
{leaderboard?.top.length ? leaderboard.top.map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === accountId && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{entry.value}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
|
{leaderboard?.top.length ? leaderboard.top.map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === accountId && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{entry.value}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
|
||||||
</div>}
|
</div>}
|
||||||
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStat === "roguelike" ? hunter.stats.highestRoguelikeRound : hunter.stats.bossKills[selectedStat] ?? 0}</em></div>
|
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStatValue}</em></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div className="collection-note trophy-note"><i>◆</i><span><strong>Rankings update with server saves.</strong><small>Top five always shown; your row stays visible at any rank.</small></span></div>
|
<div className="collection-note trophy-note"><i>◆</i><span><strong>Rankings update with server saves.</strong><small>Top five always shown; your row stays visible at any rank.</small></span></div>
|
||||||
@@ -567,6 +583,7 @@ function ProfileScreen() {
|
|||||||
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
|
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
|
||||||
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
|
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
|
||||||
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
|
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
|
||||||
|
<span><small>Endless best</small><strong>{hunter.stats.highestRogueTrialsEndlessKills}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
|
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
|
||||||
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
|
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
|
||||||
@@ -701,7 +718,7 @@ function GearScreen() {
|
|||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
top={
|
top={
|
||||||
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
|
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
|
||||||
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
|
||||||
<div className="gear-workshop-layout">
|
<div className="gear-workshop-layout">
|
||||||
<section className="gear-owner-list" aria-label="Party gear owners">
|
<section className="gear-owner-list" aria-label="Party gear owners">
|
||||||
{GEAR_OWNER_ORDER.map((ownerId) => {
|
{GEAR_OWNER_ORDER.map((ownerId) => {
|
||||||
@@ -764,7 +781,7 @@ function GearScreen() {
|
|||||||
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
|
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
|
||||||
}) : <article className="is-met"><i>✓</i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
|
}) : <article className="is-met"><i>✓</i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
|
||||||
</div>
|
</div>
|
||||||
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : "A · Equip selected passive"}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
|
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
|
||||||
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
|
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
|
||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
@@ -795,7 +812,7 @@ function SettingsScreen() {
|
|||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
top={
|
top={
|
||||||
<FrontSurface className="settings-surface" ariaLabel="Settings">
|
<FrontSurface className="settings-surface" ariaLabel="Settings">
|
||||||
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
|
||||||
<div className="settings-layout">
|
<div className="settings-layout">
|
||||||
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−</FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+</FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
|
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−</FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+</FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
|
||||||
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
|
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
|
||||||
@@ -808,9 +825,9 @@ function SettingsScreen() {
|
|||||||
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
|
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
|
||||||
<div className="controller-map">
|
<div className="controller-map">
|
||||||
<div className="pad-diagram"><i>↑</i><span>←<b>●</b>→</span><i>↓</i></div>
|
<div className="pad-diagram"><i>↑</i><span>←<b>●</b>→</span><i>↓</i></div>
|
||||||
<div className="face-diagram"><i className="y">Y</i><span><i className="x">X</i><b>●</b><i className="b">B</i></span><i className="a">A</i></div>
|
<div className="face-diagram"><i className="triangle">{DEFAULT_CONTROLLER_GLYPHS.faceTop}</i><span><i className="square">{DEFAULT_CONTROLLER_GLYPHS.faceLeft}</i><b>●</b><i className="circle">{DEFAULT_CONTROLLER_GLYPHS.faceRight}</i></span><i className="cross">{DEFAULT_CONTROLLER_GLYPHS.faceBottom}</i></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mapping-list"><span><b>A</b> Confirm / cast Purify</span><span><b>B</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>Start</b> Pause / menu</span></div>
|
<div className="mapping-list"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Confirm / cast Purify</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.start}</b> Pause / menu</span></div>
|
||||||
<div className="control-assurance"><i>✓</i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
|
<div className="control-assurance"><i>✓</i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
|
||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
@@ -908,7 +925,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
? [
|
? [
|
||||||
["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."],
|
["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."],
|
||||||
["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."],
|
["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."],
|
||||||
["Trial victory", "Defeat all three final bosses together to complete Rogue Trials."],
|
["Endless choice", "After the trio falls, quit with the clear or continue while every dead boss is replaced."],
|
||||||
]
|
]
|
||||||
: isPve
|
: isPve
|
||||||
? [
|
? [
|
||||||
@@ -925,7 +942,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
top={
|
top={
|
||||||
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
|
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
|
||||||
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
|
||||||
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
|
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
|
||||||
{isDungeon && (
|
{isDungeon && (
|
||||||
<div className="boss-picker" aria-label="Choose boss encounter">
|
<div className="boss-picker" aria-label="Choose boss encounter">
|
||||||
@@ -973,7 +990,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
|
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton>
|
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm}</small></FocusButton>
|
||||||
{message && <div className="front-notice">{message}</div>}
|
{message && <div className="front-notice">{message}</div>}
|
||||||
</FrontSurface>
|
</FrontSurface>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
|||||||
import { bossRoomFor } from "../game/bossRooms";
|
import { bossRoomFor } from "../game/bossRooms";
|
||||||
import { tankAuraProtects } from "../game/partyCombat";
|
import { tankAuraProtects } from "../game/partyCombat";
|
||||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
|
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
|
||||||
|
|
||||||
@@ -109,6 +110,9 @@ function EncounterCallout() {
|
|||||||
function PhaseOverlay() {
|
function PhaseOverlay() {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const runMode = useGameStore((state) => state.runMode);
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
|
const round = useGameStore((state) => state.round);
|
||||||
|
const endlessMode = useGameStore((state) => state.endlessMode);
|
||||||
|
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||||||
const primaryBoss = useGameStore((state) => state.boss);
|
const primaryBoss = useGameStore((state) => state.boss);
|
||||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||||
@@ -116,6 +120,8 @@ function PhaseOverlay() {
|
|||||||
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
||||||
const room = bossRoomFor(primaryBoss.id);
|
const room = bossRoomFor(primaryBoss.id);
|
||||||
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
||||||
|
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||||
|
const endlessDefeat = phase === "defeat" && endlessMode;
|
||||||
const briefingMode = runMode === "rogue-trials"
|
const briefingMode = runMode === "rogue-trials"
|
||||||
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
|
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
|
||||||
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
|
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
|
||||||
@@ -123,25 +129,25 @@ function PhaseOverlay() {
|
|||||||
const title = phase === "briefing"
|
const title = phase === "briefing"
|
||||||
? room.name
|
? room.name
|
||||||
: phase === "victory"
|
: phase === "victory"
|
||||||
? `${bossNames} Broken`
|
? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
|
||||||
: "Party Broken";
|
: endlessDefeat ? "Endless Run Ended" : "Party Broken";
|
||||||
const eyebrow = phase === "briefing"
|
const eyebrow = phase === "briefing"
|
||||||
? `${briefingMode} · ${room.biome}`
|
? `${briefingMode} · ${room.biome}`
|
||||||
: phase === "victory"
|
: phase === "victory"
|
||||||
? "Encounter Complete"
|
? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
|
||||||
: "Encounter Failed";
|
: endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
|
||||||
const copy = phase === "briefing"
|
const copy = phase === "briefing"
|
||||||
? definitions.map((boss) => boss.briefing).join(" ")
|
? definitions.map((boss) => boss.briefing).join(" ")
|
||||||
: phase === "victory"
|
: phase === "victory"
|
||||||
? "Five entered. Five endured."
|
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
|
||||||
: definitions.map((boss) => boss.failure).join(" ");
|
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
|
||||||
return (
|
return (
|
||||||
<div className={`phase-overlay phase-${phase}`}>
|
<div className={`phase-overlay phase-${phase}`}>
|
||||||
<div className="phase-sigil">✦</div>
|
<div className="phase-sigil">✦</div>
|
||||||
<span>{eyebrow}</span>
|
<span>{eyebrow}</span>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{copy}</p>
|
<p>{copy}</p>
|
||||||
<small>{phase === "briefing" ? "Begin from lower display" : "Restart from lower display"}</small>
|
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,15 +174,15 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
|
|||||||
onFocus={() => setPauseSelection("resume")}
|
onFocus={() => setPauseSelection("resume")}
|
||||||
onPointerEnter={() => setPauseSelection("resume")}
|
onPointerEnter={() => setPauseSelection("resume")}
|
||||||
onClick={() => setPaused(false)}
|
onClick={() => setPaused(false)}
|
||||||
>Resume <small>START / ESC</small></button>
|
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
|
||||||
<button
|
<button
|
||||||
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
|
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
|
||||||
onFocus={() => setPauseSelection("exit")}
|
onFocus={() => setPauseSelection("exit")}
|
||||||
onPointerEnter={() => setPauseSelection("exit")}
|
onPointerEnter={() => setPauseSelection("exit")}
|
||||||
onClick={exit}
|
onClick={exit}
|
||||||
>Return to main menu <small>A</small></button>
|
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
|
||||||
</div>
|
</div>
|
||||||
<footer><b>↑ / ↓</b> Choose <i /> <b>A / ENTER</b> Confirm</footer>
|
<footer><b>↑ / ↓</b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> Confirm</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -187,6 +193,8 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
|
|||||||
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
||||||
const round = useGameStore((state) => state.round);
|
const round = useGameStore((state) => state.round);
|
||||||
const runMode = useGameStore((state) => state.runMode);
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
|
const endlessMode = useGameStore((state) => state.endlessMode);
|
||||||
|
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||||||
const setPaused = useGameStore((state) => state.setPaused);
|
const setPaused = useGameStore((state) => state.setPaused);
|
||||||
return (
|
return (
|
||||||
<section className="display top-display" aria-label="Main game viewport">
|
<section className="display top-display" aria-label="Main game viewport">
|
||||||
@@ -197,11 +205,11 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
|
|||||||
<div className="top-hud">
|
<div className="top-hud">
|
||||||
<CompactParty />
|
<CompactParty />
|
||||||
<BossBar />
|
<BossBar />
|
||||||
<div className="objective-chip"><span>{runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
|
<div className="objective-chip"><span>{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
|
||||||
<EncounterCallout />
|
<EncounterCallout />
|
||||||
<CastingBar />
|
<CastingBar />
|
||||||
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
<div className="control-hint"><b>WASD</b> Move <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>START / ESC</small></button>}
|
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b>☰</b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
|
||||||
</div>
|
</div>
|
||||||
<PhaseOverlay />
|
<PhaseOverlay />
|
||||||
<PauseOverlay onExit={onExit} />
|
<PauseOverlay onExit={onExit} />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
|
|||||||
describe("game mode configuration", () => {
|
describe("game mode configuration", () => {
|
||||||
it("separates randomized PVE from selectable Dungeons", () => {
|
it("separates randomized PVE from selectable Dungeons", () => {
|
||||||
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
|
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
|
||||||
expect(MODE_COPY["rogue-trials"].detail).toContain("unseen");
|
expect(MODE_COPY["rogue-trials"].detail).toContain("Endless");
|
||||||
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
|
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
|
|||||||
"rogue-trials": {
|
"rogue-trials": {
|
||||||
eyebrow: "1–4 hunters · five-round PVE trial",
|
eyebrow: "1–4 hunters · five-round PVE trial",
|
||||||
title: "Rogue Trials",
|
title: "Rogue Trials",
|
||||||
description: "Build through four randomized dual-boss rounds, then face three bosses together in a final trial.",
|
description: "Build through four randomized dual-boss rounds, defeat an unseen trio, then leave with the clear or continue into endless combat.",
|
||||||
detail: "Round 5 trio always uses bosses unseen during that run",
|
detail: "Endless mode replaces every fallen boss and tracks your best kill count",
|
||||||
status: "Playable now",
|
status: "Playable now",
|
||||||
},
|
},
|
||||||
dungeons: {
|
dungeons: {
|
||||||
@@ -139,6 +139,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
|||||||
healingDone: 0,
|
healingDone: 0,
|
||||||
bossKills: {},
|
bossKills: {},
|
||||||
highestRoguelikeRound: 0,
|
highestRoguelikeRound: 0,
|
||||||
|
highestRogueTrialsEndlessKills: 0,
|
||||||
},
|
},
|
||||||
materials: [] as MaterialStack[],
|
materials: [] as MaterialStack[],
|
||||||
collectionLog: createEmptyCollectionLog(),
|
collectionLog: createEmptyCollectionLog(),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface LeaderboardEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaderboardResult {
|
export interface LeaderboardResult {
|
||||||
kind: "boss" | "roguelike";
|
kind: "boss" | "roguelike" | "rogue-trials-endless";
|
||||||
bossId?: BossId;
|
bossId?: BossId;
|
||||||
top: LeaderboardEntry[];
|
top: LeaderboardEntry[];
|
||||||
current: LeaderboardEntry | null;
|
current: LeaderboardEntry | null;
|
||||||
@@ -147,6 +147,10 @@ export class OnlineRepository {
|
|||||||
roguelikeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
roguelikeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||||
return this.request(`/api/leaderboards/roguelike?slot=${slotId}`);
|
return this.request(`/api/leaderboards/roguelike?slot=${slotId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||||
|
return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const onlineRepository = new OnlineRepository();
|
export const onlineRepository = new OnlineRepository();
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe("SaveRepository", () => {
|
|||||||
expect(migrated.activeClassId).toBe("priest");
|
expect(migrated.activeClassId).toBe("priest");
|
||||||
expect(migrated.playSeconds).toBe(0);
|
expect(migrated.playSeconds).toBe(0);
|
||||||
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
|
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
|
||||||
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0 });
|
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0 });
|
||||||
expect(migrated.materials).toEqual([]);
|
expect(migrated.materials).toEqual([]);
|
||||||
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
|
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
|
||||||
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
|
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
|
||||||
@@ -122,6 +122,7 @@ describe("SaveRepository", () => {
|
|||||||
const drop = groupDrop("charge", "veteran");
|
const drop = groupDrop("charge", "veteran");
|
||||||
created.healers.priest.level = 8;
|
created.healers.priest.level = 8;
|
||||||
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
|
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
|
||||||
|
created.stats.highestRogueTrialsEndlessKills = 14;
|
||||||
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
|
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
|
||||||
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
|
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
|
||||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||||
@@ -130,6 +131,7 @@ describe("SaveRepository", () => {
|
|||||||
expect(migrated.schemaVersion).toBe(5);
|
expect(migrated.schemaVersion).toBe(5);
|
||||||
expect(migrated.healers.priest.level).toBe(8);
|
expect(migrated.healers.priest.level).toBe(8);
|
||||||
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
|
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
|
||||||
|
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14);
|
||||||
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
|
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
|
||||||
expect(migrated.collectionLog).toEqual(created.collectionLog);
|
expect(migrated.collectionLog).toEqual(created.collectionLog);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ function normalizeSave(value: unknown): HunterSave | null {
|
|||||||
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
|
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
|
||||||
bossKills,
|
bossKills,
|
||||||
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
|
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
|
||||||
|
highestRogueTrialsEndlessKills: Math.max(0, Math.floor(candidate.stats?.highestRogueTrialsEndlessKills ?? 0)),
|
||||||
},
|
},
|
||||||
materials: normalizeMaterials(candidate.materials, collectionLog),
|
materials: normalizeMaterials(candidate.materials, collectionLog),
|
||||||
collectionLog,
|
collectionLog,
|
||||||
|
|||||||
+18
-2
@@ -13,7 +13,7 @@ import {
|
|||||||
infusionsForOwner,
|
infusionsForOwner,
|
||||||
} from "../game/progression/infusions";
|
} from "../game/progression/infusions";
|
||||||
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
||||||
import { highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
|
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
|
||||||
|
|
||||||
const repository = new SaveRepository();
|
const repository = new SaveRepository();
|
||||||
const accounts = new AccountRepository();
|
const accounts = new AccountRepository();
|
||||||
@@ -125,6 +125,7 @@ export interface FrontendState {
|
|||||||
touchActiveSave: () => void;
|
touchActiveSave: () => void;
|
||||||
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
||||||
recordRoguelikeDefeat: (round: number) => void;
|
recordRoguelikeDefeat: (round: number) => void;
|
||||||
|
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
|
||||||
clearRecentRewards: () => void;
|
clearRecentRewards: () => void;
|
||||||
clearNotice: () => void;
|
clearNotice: () => void;
|
||||||
}
|
}
|
||||||
@@ -388,7 +389,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
slots: refreshLocalSlots(state.slots),
|
slots: refreshLocalSlots(state.slots),
|
||||||
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
|
recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards,
|
||||||
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
|
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
|
||||||
}));
|
}));
|
||||||
return awarded;
|
return awarded;
|
||||||
@@ -406,6 +407,19 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
if (!updated) return;
|
if (!updated) return;
|
||||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||||
},
|
},
|
||||||
|
recordRogueTrialsEndlessDefeat: (bossKills) => {
|
||||||
|
const { activeSlotId } = get();
|
||||||
|
if (!activeSlotId) return;
|
||||||
|
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
||||||
|
...save,
|
||||||
|
stats: {
|
||||||
|
...save.stats,
|
||||||
|
highestRogueTrialsEndlessKills: highestEndlessBossKillsAfterDefeat(save.stats.highestRogueTrialsEndlessKills, bossKills),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (!updated) return;
|
||||||
|
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||||
|
},
|
||||||
clearRecentRewards: () => set({ recentRewards: [] }),
|
clearRecentRewards: () => set({ recentRewards: [] }),
|
||||||
clearNotice: () => set({ notice: "" }),
|
clearNotice: () => set({ notice: "" }),
|
||||||
}));
|
}));
|
||||||
@@ -442,6 +456,7 @@ export type FrontendSnapshot = Omit<FrontendState,
|
|||||||
| "touchActiveSave"
|
| "touchActiveSave"
|
||||||
| "recordBossVictory"
|
| "recordBossVictory"
|
||||||
| "recordRoguelikeDefeat"
|
| "recordRoguelikeDefeat"
|
||||||
|
| "recordRogueTrialsEndlessDefeat"
|
||||||
| "clearRecentRewards"
|
| "clearRecentRewards"
|
||||||
| "clearNotice"
|
| "clearNotice"
|
||||||
>;
|
>;
|
||||||
@@ -479,6 +494,7 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
|||||||
touchActiveSave: _touchActiveSave,
|
touchActiveSave: _touchActiveSave,
|
||||||
recordBossVictory: _recordBossVictory,
|
recordBossVictory: _recordBossVictory,
|
||||||
recordRoguelikeDefeat: _recordRoguelikeDefeat,
|
recordRoguelikeDefeat: _recordRoguelikeDefeat,
|
||||||
|
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
|
||||||
clearRecentRewards: _clearRecentRewards,
|
clearRecentRewards: _clearRecentRewards,
|
||||||
clearNotice: _clearNotice,
|
clearNotice: _clearNotice,
|
||||||
...snapshot
|
...snapshot
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface HunterStats {
|
|||||||
healingDone: number;
|
healingDone: number;
|
||||||
bossKills: Record<string, number>;
|
bossKills: Record<string, number>;
|
||||||
highestRoguelikeRound: number;
|
highestRoguelikeRound: number;
|
||||||
|
highestRogueTrialsEndlessKills: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealerProgress {
|
export interface HealerProgress {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ABILITY_BY_CONTROLLER_BUTTON, ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings";
|
||||||
|
|
||||||
|
describe("PlayStation controller ability bindings", () => {
|
||||||
|
it("keeps prompts aligned with standard gamepad button indices", () => {
|
||||||
|
expect(ABILITY_BY_CONTROLLER_BUTTON).toEqual({
|
||||||
|
0: "purify",
|
||||||
|
1: "shield",
|
||||||
|
2: "mend",
|
||||||
|
3: "renew",
|
||||||
|
4: "radiance",
|
||||||
|
5: "barrier",
|
||||||
|
});
|
||||||
|
expect(Object.values(ABILITY_CONTROLLER_BINDINGS).map(({ glyph }) => glyph)).toEqual([
|
||||||
|
"□",
|
||||||
|
"△",
|
||||||
|
"○",
|
||||||
|
"✕",
|
||||||
|
"L1",
|
||||||
|
"R1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
import type { AbilityId } from "./types";
|
||||||
|
|
||||||
|
interface AbilityControllerBinding {
|
||||||
|
buttonIndex: number;
|
||||||
|
glyph: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ABILITY_CONTROLLER_BINDINGS: Record<AbilityId, AbilityControllerBinding> = {
|
||||||
|
mend: { buttonIndex: 2, glyph: DEFAULT_CONTROLLER_GLYPHS.faceLeft },
|
||||||
|
renew: { buttonIndex: 3, glyph: DEFAULT_CONTROLLER_GLYPHS.faceTop },
|
||||||
|
shield: { buttonIndex: 1, glyph: DEFAULT_CONTROLLER_GLYPHS.faceRight },
|
||||||
|
purify: { buttonIndex: 0, glyph: DEFAULT_CONTROLLER_GLYPHS.faceBottom },
|
||||||
|
radiance: { buttonIndex: 4, glyph: DEFAULT_CONTROLLER_GLYPHS.leftShoulder },
|
||||||
|
barrier: { buttonIndex: 5, glyph: DEFAULT_CONTROLLER_GLYPHS.rightShoulder },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ABILITY_BY_CONTROLLER_BUTTON = Object.fromEntries(
|
||||||
|
Object.entries(ABILITY_CONTROLLER_BINDINGS).map(([abilityId, binding]) => [binding.buttonIndex, abilityId]),
|
||||||
|
) as Partial<Record<number, AbilityId>>;
|
||||||
+7
-6
@@ -1,12 +1,13 @@
|
|||||||
import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types";
|
import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types";
|
||||||
|
import { ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings";
|
||||||
|
|
||||||
const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = {
|
const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = {
|
||||||
mend: { id: "mend", key: "1", gamepad: "X", targeting: "ally" },
|
mend: { id: "mend", key: "1", gamepad: ABILITY_CONTROLLER_BINDINGS.mend.glyph, targeting: "ally" },
|
||||||
renew: { id: "renew", key: "2", gamepad: "Y", targeting: "ally" },
|
renew: { id: "renew", key: "2", gamepad: ABILITY_CONTROLLER_BINDINGS.renew.glyph, targeting: "ally" },
|
||||||
shield: { id: "shield", key: "3", gamepad: "B", targeting: "ally" },
|
shield: { id: "shield", key: "3", gamepad: ABILITY_CONTROLLER_BINDINGS.shield.glyph, targeting: "ally" },
|
||||||
purify: { id: "purify", key: "4", gamepad: "A", targeting: "ally" },
|
purify: { id: "purify", key: "4", gamepad: ABILITY_CONTROLLER_BINDINGS.purify.glyph, targeting: "ally" },
|
||||||
radiance: { id: "radiance", key: "5", gamepad: "LB", targeting: "party" },
|
radiance: { id: "radiance", key: "5", gamepad: ABILITY_CONTROLLER_BINDINGS.radiance.glyph, targeting: "party" },
|
||||||
barrier: { id: "barrier", key: "6", gamepad: "RB", targeting: "party" },
|
barrier: { id: "barrier", key: "6", gamepad: ABILITY_CONTROLLER_BINDINGS.barrier.glyph, targeting: "party" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition {
|
function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { highestRoguelikeRoundAfterDefeat } from "./hunterStats";
|
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "./hunterStats";
|
||||||
|
|
||||||
describe("roguelike hunter records", () => {
|
describe("roguelike hunter records", () => {
|
||||||
it("records the reached defeat round without lowering a previous best", () => {
|
it("records the reached defeat round without lowering a previous best", () => {
|
||||||
@@ -13,3 +13,16 @@ describe("roguelike hunter records", () => {
|
|||||||
expect(highestRoguelikeRoundAfterDefeat(4.9, 8.9)).toBe(8);
|
expect(highestRoguelikeRoundAfterDefeat(4.9, 8.9)).toBe(8);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Rogue Trials endless hunter records", () => {
|
||||||
|
it("keeps the highest boss count from one endless run", () => {
|
||||||
|
expect(highestEndlessBossKillsAfterDefeat(0, 17)).toBe(17);
|
||||||
|
expect(highestEndlessBossKillsAfterDefeat(17, 9)).toBe(17);
|
||||||
|
expect(highestEndlessBossKillsAfterDefeat(17, 23)).toBe(23);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes invalid and fractional kill counts", () => {
|
||||||
|
expect(highestEndlessBossKillsAfterDefeat(Number.NaN, Number.NaN)).toBe(0);
|
||||||
|
expect(highestEndlessBossKillsAfterDefeat(4.9, 8.9)).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,3 +3,9 @@ export function highestRoguelikeRoundAfterDefeat(currentRecord: number, reachedR
|
|||||||
const normalizedRound = Math.max(1, Math.floor(Number(reachedRound) || 1));
|
const normalizedRound = Math.max(1, Math.floor(Number(reachedRound) || 1));
|
||||||
return Math.max(normalizedRecord, normalizedRound);
|
return Math.max(normalizedRecord, normalizedRound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function highestEndlessBossKillsAfterDefeat(currentRecord: number, bossKills: number): number {
|
||||||
|
const normalizedRecord = Math.max(0, Math.floor(Number(currentRecord) || 0));
|
||||||
|
const normalizedKills = Math.max(0, Math.floor(Number(bossKills) || 0));
|
||||||
|
return Math.max(normalizedRecord, normalizedKills);
|
||||||
|
}
|
||||||
|
|||||||
@@ -707,6 +707,67 @@ describe("Rogue Trials", () => {
|
|||||||
expect(useGameStore.getState().phase).toBe("victory");
|
expect(useGameStore.getState().phase).toBe("victory");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers endless mode, counts each kill, and refills the dead boss slot", () => {
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
"priest",
|
||||||
|
"Aelia",
|
||||||
|
createClassInventory("priest"),
|
||||||
|
["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"],
|
||||||
|
"rogue-trials",
|
||||||
|
);
|
||||||
|
useGameStore.setState({ round: 5, phase: "victory" });
|
||||||
|
|
||||||
|
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
|
||||||
|
const started = useGameStore.getState();
|
||||||
|
expect(started.phase).toBe("combat");
|
||||||
|
expect(started.endlessMode).toBe(true);
|
||||||
|
expect(started.endlessBossKills).toBe(0);
|
||||||
|
expect(started.additionalBosses).toHaveLength(2);
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
boss: { ...state.boss, hp: 1, nextMeleeAt: 999 },
|
||||||
|
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
|
||||||
|
additionalBosses: state.additionalBosses.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
boss: { ...entry.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
|
||||||
|
motion: { ...entry.motion, nextMechanicAt: 999 },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (let step = 0; step < 50 && useGameStore.getState().endlessBossKills === 0; step += 1) {
|
||||||
|
useGameStore.getState().tick(0.1);
|
||||||
|
}
|
||||||
|
const defeated = useGameStore.getState();
|
||||||
|
expect(defeated.endlessBossKills).toBe(1);
|
||||||
|
expect(defeated.boss.hp).toBe(0);
|
||||||
|
const defeatedInstanceId = defeated.bossInstanceId;
|
||||||
|
|
||||||
|
useGameStore.getState().tick(0.05);
|
||||||
|
const replaced = useGameStore.getState();
|
||||||
|
expect(replaced.phase).toBe("combat");
|
||||||
|
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
|
||||||
|
expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId);
|
||||||
|
expect(replaced.endlessBossKills).toBe(1);
|
||||||
|
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends endless mode when the party falls without losing its kill count", () => {
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
round: 5,
|
||||||
|
phase: "victory",
|
||||||
|
boss: { ...state.boss, hp: 0 },
|
||||||
|
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
|
||||||
|
}));
|
||||||
|
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
endlessBossKills: 7,
|
||||||
|
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
|
||||||
|
}));
|
||||||
|
|
||||||
|
useGameStore.getState().tick(0.05);
|
||||||
|
expect(useGameStore.getState().phase).toBe("defeat");
|
||||||
|
expect(useGameStore.getState().endlessBossKills).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
it("restarts a completed trial with a fresh two-boss first round", () => {
|
it("restarts a completed trial with a fresh two-boss first round", () => {
|
||||||
useGameStore.getState().configureHealer(
|
useGameStore.getState().configureHealer(
|
||||||
"priest",
|
"priest",
|
||||||
|
|||||||
+113
-2
@@ -25,6 +25,7 @@ import {
|
|||||||
selectRunBuffDraft,
|
selectRunBuffDraft,
|
||||||
selectRandomBossPair,
|
selectRandomBossPair,
|
||||||
selectRogueTrialsBosses,
|
selectRogueTrialsBosses,
|
||||||
|
selectUnseenBosses,
|
||||||
ROGUE_TRIALS_TRIO_ROUND,
|
ROGUE_TRIALS_TRIO_ROUND,
|
||||||
type CompiledRunModifiers,
|
type CompiledRunModifiers,
|
||||||
} from "./roguelike";
|
} from "./roguelike";
|
||||||
@@ -67,6 +68,7 @@ export interface AdditionalBossState {
|
|||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
bossId: BossId;
|
bossId: BossId;
|
||||||
|
bossInstanceId: string;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
pauseSelection: "resume" | "exit";
|
pauseSelection: "resume" | "exit";
|
||||||
healerClassId: HealerClassId;
|
healerClassId: HealerClassId;
|
||||||
@@ -75,6 +77,10 @@ export interface GameState {
|
|||||||
runMode: RunMode;
|
runMode: RunMode;
|
||||||
round: number;
|
round: number;
|
||||||
seenBossIds: BossId[];
|
seenBossIds: BossId[];
|
||||||
|
endlessMode: boolean;
|
||||||
|
endlessBossKills: number;
|
||||||
|
endlessSpawnSequence: number;
|
||||||
|
endlessChoiceSelection: "continue" | "quit";
|
||||||
runBuffRanks: RunBuffRanks;
|
runBuffRanks: RunBuffRanks;
|
||||||
draftBuffIds: RunBuffId[];
|
draftBuffIds: RunBuffId[];
|
||||||
selectedRunBuffId: RunBuffId | null;
|
selectedRunBuffId: RunBuffId | null;
|
||||||
@@ -123,6 +129,8 @@ export interface GameState {
|
|||||||
setSelectedRunBuff: (buffId: RunBuffId) => void;
|
setSelectedRunBuff: (buffId: RunBuffId) => void;
|
||||||
chooseRunBuff: (buffId: RunBuffId) => boolean;
|
chooseRunBuff: (buffId: RunBuffId) => boolean;
|
||||||
continueRoguelikeRound: () => boolean;
|
continueRoguelikeRound: () => boolean;
|
||||||
|
startRogueTrialsEndless: () => boolean;
|
||||||
|
setEndlessChoiceSelection: (selection: "continue" | "quit") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyCooldowns = (): Record<AbilityId, number> => ({
|
const emptyCooldowns = (): Record<AbilityId, number> => ({
|
||||||
@@ -305,6 +313,7 @@ function initialState(
|
|||||||
const maxMana = 100;
|
const maxMana = 100;
|
||||||
return {
|
return {
|
||||||
bossId: primary.boss.id,
|
bossId: primary.boss.id,
|
||||||
|
bossInstanceId: primary.instanceId,
|
||||||
paused: false,
|
paused: false,
|
||||||
pauseSelection: "resume" as const,
|
pauseSelection: "resume" as const,
|
||||||
healerClassId,
|
healerClassId,
|
||||||
@@ -313,6 +322,10 @@ function initialState(
|
|||||||
runMode,
|
runMode,
|
||||||
round,
|
round,
|
||||||
seenBossIds: [...new Set([...seenBossIds, ...bossIds])],
|
seenBossIds: [...new Set([...seenBossIds, ...bossIds])],
|
||||||
|
endlessMode: false,
|
||||||
|
endlessBossKills: 0,
|
||||||
|
endlessSpawnSequence: 0,
|
||||||
|
endlessChoiceSelection: "continue" as const,
|
||||||
runBuffRanks: { ...runBuffRanks },
|
runBuffRanks: { ...runBuffRanks },
|
||||||
draftBuffIds,
|
draftBuffIds,
|
||||||
selectedRunBuffId: draftBuffIds[0] ?? null,
|
selectedRunBuffId: draftBuffIds[0] ?? null,
|
||||||
@@ -441,6 +454,46 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
startRogueTrialsEndless: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.runMode !== "rogue-trials"
|
||||||
|
|| state.round !== ROGUE_TRIALS_TRIO_ROUND
|
||||||
|
|| state.phase !== "victory"
|
||||||
|
|| state.endlessMode) return false;
|
||||||
|
const bossIds = selectRogueTrialsBosses(ROGUE_TRIALS_TRIO_ROUND, []);
|
||||||
|
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
|
||||||
|
const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
|
||||||
|
const encounterBosses = bossIds.map((bossId, index) => {
|
||||||
|
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier);
|
||||||
|
return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
|
||||||
|
});
|
||||||
|
const primary = encounterBosses[0];
|
||||||
|
set({
|
||||||
|
bossId: primary.boss.id,
|
||||||
|
bossInstanceId: primary.instanceId,
|
||||||
|
boss: primary.boss,
|
||||||
|
bossMotion: primary.motion,
|
||||||
|
additionalBosses: encounterBosses.slice(1),
|
||||||
|
phase: "combat",
|
||||||
|
endlessMode: true,
|
||||||
|
endlessBossKills: 0,
|
||||||
|
endlessSpawnSequence: encounterBosses.length,
|
||||||
|
partyCombat: createPartyCombatState(state.party),
|
||||||
|
partyDamageEvents: [],
|
||||||
|
partyPositions: freshPartyPositions(bossIds),
|
||||||
|
playerPosition: [0, 4.5],
|
||||||
|
activeCast: null,
|
||||||
|
activeTab: "combat",
|
||||||
|
combatLog: [{
|
||||||
|
id: Date.now(),
|
||||||
|
time: state.time,
|
||||||
|
message: `${encounterBosses.map((entry) => entry.boss.name).join(", ")} enter the endless trial.`,
|
||||||
|
tone: "danger",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
setEndlessChoiceSelection: (endlessChoiceSelection) => set({ endlessChoiceSelection }),
|
||||||
setPlayerPosition: (playerPosition) => set((state) => {
|
setPlayerPosition: (playerPosition) => set((state) => {
|
||||||
playerPosition = clampToArena(playerPosition);
|
playerPosition = clampToArena(playerPosition);
|
||||||
const current = state.playerPosition;
|
const current = state.playerPosition;
|
||||||
@@ -601,6 +654,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
// as well doubled short-lived allocations for every simulation step.
|
// as well doubled short-lived allocations for every simulation step.
|
||||||
let party = state.party.map((member) => ({ ...member }));
|
let party = state.party.map((member) => ({ ...member }));
|
||||||
let boss = { ...state.boss };
|
let boss = { ...state.boss };
|
||||||
|
let bossInstanceId = state.bossInstanceId;
|
||||||
let bossMotion = { ...state.bossMotion };
|
let bossMotion = { ...state.bossMotion };
|
||||||
let additionalBosses = state.additionalBosses.map((entry) => ({
|
let additionalBosses = state.additionalBosses.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
@@ -614,6 +668,36 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
let partyCombat = state.partyCombat;
|
let partyCombat = state.partyCombat;
|
||||||
let partyDamageEvents = state.partyDamageEvents;
|
let partyDamageEvents = state.partyDamageEvents;
|
||||||
let barrier = { ...state.barrier };
|
let barrier = { ...state.barrier };
|
||||||
|
let endlessBossKills = state.endlessBossKills;
|
||||||
|
let endlessSpawnSequence = state.endlessSpawnSequence;
|
||||||
|
|
||||||
|
if (state.endlessMode) {
|
||||||
|
const slots: AdditionalBossState[] = [
|
||||||
|
{ instanceId: bossInstanceId, boss, motion: bossMotion },
|
||||||
|
...additionalBosses,
|
||||||
|
];
|
||||||
|
for (let index = 0; index < slots.length; index += 1) {
|
||||||
|
if (slots[index].boss.hp > 0) continue;
|
||||||
|
const activeBossIds = slots
|
||||||
|
.filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
|
||||||
|
.map((entry) => entry.boss.id);
|
||||||
|
const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds])[0];
|
||||||
|
endlessSpawnSequence += 1;
|
||||||
|
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
|
||||||
|
const replacement = createEncounterBoss(
|
||||||
|
replacementId,
|
||||||
|
index,
|
||||||
|
slots.length,
|
||||||
|
bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
|
||||||
|
);
|
||||||
|
slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
|
||||||
|
combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
|
||||||
|
}
|
||||||
|
boss = slots[0].boss;
|
||||||
|
bossInstanceId = slots[0].instanceId;
|
||||||
|
bossMotion = slots[0].motion;
|
||||||
|
additionalBosses = slots.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (activeCast && activeCast.completesAt <= time) {
|
if (activeCast && activeCast.completesAt <= time) {
|
||||||
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
|
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
|
||||||
@@ -683,7 +767,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const encounterBosses: AdditionalBossState[] = [
|
const encounterBosses: AdditionalBossState[] = [
|
||||||
{ instanceId: `boss-0-${boss.id}`, boss, motion: bossMotion },
|
{ instanceId: bossInstanceId, boss, motion: bossMotion },
|
||||||
...additionalBosses,
|
...additionalBosses,
|
||||||
];
|
];
|
||||||
for (let index = 0; index < encounterBosses.length; index += 1) {
|
for (let index = 0; index < encounterBosses.length; index += 1) {
|
||||||
@@ -733,13 +817,32 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
|
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
|
||||||
}
|
}
|
||||||
boss = encounterBosses[0].boss;
|
boss = encounterBosses[0].boss;
|
||||||
|
bossInstanceId = encounterBosses[0].instanceId;
|
||||||
bossMotion = encounterBosses[0].motion;
|
bossMotion = encounterBosses[0].motion;
|
||||||
additionalBosses = encounterBosses.slice(1);
|
additionalBosses = encounterBosses.slice(1);
|
||||||
const tank = party.find((member) => member.id === "brann")!;
|
const tank = party.find((member) => member.id === "brann")!;
|
||||||
const healer = party.find((member) => member.id === "aelia")!;
|
const healer = party.find((member) => member.id === "aelia")!;
|
||||||
let phase: GamePhase = state.phase;
|
let phase: GamePhase = state.phase;
|
||||||
let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
|
let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
|
||||||
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
|
let newlyDefeatedBossCount = 0;
|
||||||
|
if (state.endlessMode) {
|
||||||
|
for (let index = 0; index < encounterBosses.length; index += 1) {
|
||||||
|
const current = encounterBosses[index];
|
||||||
|
const previous = index === 0
|
||||||
|
? { instanceId: state.bossInstanceId, boss: state.boss }
|
||||||
|
: state.additionalBosses[index - 1];
|
||||||
|
if (current.boss.hp > 0 || previous?.instanceId !== current.instanceId || previous.boss.hp <= 0) continue;
|
||||||
|
newlyDefeatedBossCount += 1;
|
||||||
|
combatLog = addLog(combatLog, time, `${current.boss.name} falls. Endless kill ${endlessBossKills + newlyDefeatedBossCount}.`, "good");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endlessBossKills += newlyDefeatedBossCount;
|
||||||
|
if (state.endlessMode && (tank.hp <= 0 || healer.hp <= 0)) {
|
||||||
|
phase = "defeat";
|
||||||
|
combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the formation fell.`, "danger");
|
||||||
|
} else if (state.endlessMode) {
|
||||||
|
phase = "combat";
|
||||||
|
} else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
|
||||||
const rogueTrialsComplete = state.runMode === "rogue-trials" && state.round === ROGUE_TRIALS_TRIO_ROUND;
|
const rogueTrialsComplete = state.runMode === "rogue-trials" && state.round === ROGUE_TRIALS_TRIO_ROUND;
|
||||||
phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
|
phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
|
||||||
if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
|
if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
|
||||||
@@ -752,6 +855,8 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
time,
|
time,
|
||||||
party,
|
party,
|
||||||
|
bossId: boss.id,
|
||||||
|
bossInstanceId,
|
||||||
boss,
|
boss,
|
||||||
additionalBosses,
|
additionalBosses,
|
||||||
partyCombat,
|
partyCombat,
|
||||||
@@ -759,6 +864,8 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
partyPositions,
|
partyPositions,
|
||||||
bossMotion,
|
bossMotion,
|
||||||
phase,
|
phase,
|
||||||
|
endlessBossKills,
|
||||||
|
endlessSpawnSequence,
|
||||||
runBuffInputUnlockAt,
|
runBuffInputUnlockAt,
|
||||||
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
|
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
|
||||||
activeCast,
|
activeCast,
|
||||||
@@ -786,6 +893,8 @@ export type GameSnapshot = Omit<GameState,
|
|||||||
| "setSelectedRunBuff"
|
| "setSelectedRunBuff"
|
||||||
| "chooseRunBuff"
|
| "chooseRunBuff"
|
||||||
| "continueRoguelikeRound"
|
| "continueRoguelikeRound"
|
||||||
|
| "startRogueTrialsEndless"
|
||||||
|
| "setEndlessChoiceSelection"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function getGameSnapshot(): GameSnapshot {
|
export function getGameSnapshot(): GameSnapshot {
|
||||||
@@ -806,6 +915,8 @@ export function getGameSnapshot(): GameSnapshot {
|
|||||||
setSelectedRunBuff: _setSelectedRunBuff,
|
setSelectedRunBuff: _setSelectedRunBuff,
|
||||||
chooseRunBuff: _chooseRunBuff,
|
chooseRunBuff: _chooseRunBuff,
|
||||||
continueRoguelikeRound: _continueRoguelikeRound,
|
continueRoguelikeRound: _continueRoguelikeRound,
|
||||||
|
startRogueTrialsEndless: _startRogueTrialsEndless,
|
||||||
|
setEndlessChoiceSelection: _setEndlessChoiceSelection,
|
||||||
...snapshot
|
...snapshot
|
||||||
} = useGameStore.getState();
|
} = useGameStore.getState();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
|
|||||||
+23
-11
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { subscribeControllerToken } from "../input/controller";
|
import { subscribeControllerToken } from "../input/controller";
|
||||||
import { ABILITY_ORDER } from "./data";
|
import { ABILITY_ORDER } from "./data";
|
||||||
import { isRunBuffInputLocked, useGameStore } from "./store";
|
import { isRunBuffInputLocked, useGameStore } from "./store";
|
||||||
import type { AbilityId } from "./types";
|
import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
|
||||||
|
|
||||||
function cycleRunBuff(direction: 1 | -1) {
|
function cycleRunBuff(direction: 1 | -1) {
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
@@ -12,15 +12,6 @@ function cycleRunBuff(direction: 1 | -1) {
|
|||||||
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]);
|
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const gamepadAbilityMap: Record<number, AbilityId> = {
|
|
||||||
0: "purify",
|
|
||||||
1: "shield",
|
|
||||||
2: "mend",
|
|
||||||
3: "renew",
|
|
||||||
4: "radiance",
|
|
||||||
5: "barrier",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useActionBindings(enabled = true, onExit?: () => void) {
|
export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||||
const exitRef = useRef(onExit);
|
const exitRef = useRef(onExit);
|
||||||
exitRef.current = onExit;
|
exitRef.current = onExit;
|
||||||
@@ -53,6 +44,17 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
|||||||
if (key === "escape") exitRef.current?.();
|
if (key === "escape") exitRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) {
|
||||||
|
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault();
|
||||||
|
if (key === "arrowleft" || key === "arrowup") store.setEndlessChoiceSelection("continue");
|
||||||
|
if (key === "arrowright" || key === "arrowdown") store.setEndlessChoiceSelection("quit");
|
||||||
|
if (key === "enter") {
|
||||||
|
if (store.endlessChoiceSelection === "continue") store.startRogueTrialsEndless();
|
||||||
|
else exitRef.current?.();
|
||||||
|
}
|
||||||
|
if (key === "escape") exitRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const numberIndex = Number(event.key) - 1;
|
const numberIndex = Number(event.key) - 1;
|
||||||
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
|
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
|
||||||
store.castAbility(ABILITY_ORDER[numberIndex]);
|
store.castAbility(ABILITY_ORDER[numberIndex]);
|
||||||
@@ -110,9 +112,19 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
|||||||
if (!repeat && token === "Button1") exitRef.current?.();
|
if (!repeat && token === "Button1") exitRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) {
|
||||||
|
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) store.setEndlessChoiceSelection("continue");
|
||||||
|
if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) store.setEndlessChoiceSelection("quit");
|
||||||
|
if (!repeat && token === "Button0") {
|
||||||
|
if (store.endlessChoiceSelection === "continue") store.startRogueTrialsEndless();
|
||||||
|
else exitRef.current?.();
|
||||||
|
}
|
||||||
|
if (!repeat && token === "Button1") exitRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (repeat) return;
|
if (repeat) return;
|
||||||
if (token.startsWith("Button")) {
|
if (token.startsWith("Button")) {
|
||||||
const ability = gamepadAbilityMap[Number(token.slice("Button".length))];
|
const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))];
|
||||||
if (ability && store.phase === "combat") store.castAbility(ability);
|
if (ability && store.phase === "combat") store.castAbility(ability);
|
||||||
}
|
}
|
||||||
if (token === "Button12") store.cycleMember(-1);
|
if (token === "Button12") store.cycleMember(-1);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const DEFAULT_CONTROLLER_GLYPHS = {
|
||||||
|
confirm: "✕",
|
||||||
|
back: "○",
|
||||||
|
faceBottom: "✕",
|
||||||
|
faceRight: "○",
|
||||||
|
faceLeft: "□",
|
||||||
|
faceTop: "△",
|
||||||
|
leftShoulder: "L1",
|
||||||
|
rightShoulder: "R1",
|
||||||
|
select: "SELECT",
|
||||||
|
start: "START",
|
||||||
|
} as const;
|
||||||
@@ -9,6 +9,7 @@ import type { BossId } from "../game/types";
|
|||||||
import type { DifficultySlug } from "../game/progression/loot";
|
import type { DifficultySlug } from "../game/progression/loot";
|
||||||
import { useForcedThorDisplays } from "./useThorDualScreen";
|
import { useForcedThorDisplays } from "./useThorDualScreen";
|
||||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||||
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
||||||
const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33;
|
const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33;
|
||||||
@@ -45,8 +46,8 @@ function CompanionStandby({ screen, hunterName, notice }: {
|
|||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<span><b>+</b> Navigate</span>
|
<span><b>+</b> Navigate</span>
|
||||||
<span><b>A</b> Select</span>
|
<span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>
|
||||||
<span><b>B</b> Back</span>
|
<span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -168,6 +169,11 @@ export function BottomDisplayApp() {
|
|||||||
postCommand({ name: "continueRoguelikeRound" });
|
postCommand({ name: "continueRoguelikeRound" });
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
startRogueTrialsEndless: () => {
|
||||||
|
postCommand({ name: "startRogueTrialsEndless" });
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
|
||||||
});
|
});
|
||||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
if (event.data.type === "authoritative-ready") {
|
if (event.data.type === "authoritative-ready") {
|
||||||
@@ -221,7 +227,7 @@ export function BottomDisplayApp() {
|
|||||||
return (
|
return (
|
||||||
<main className="bottom-display-root">
|
<main className="bottom-display-root">
|
||||||
{surface.screen === "game"
|
{surface.screen === "game"
|
||||||
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen /></Suspense>
|
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} /></Suspense>
|
||||||
: surface.notice === "Linking upper display…"
|
: surface.notice === "Linking upper display…"
|
||||||
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
|
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
|
||||||
: <FrontEnd onLaunch={launchGame} />}
|
: <FrontEnd onLaunch={launchGame} />}
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import { useFrontendStore } from "../frontend/store";
|
|||||||
function snapshot(): BottomGameSnapshot {
|
function snapshot(): BottomGameSnapshot {
|
||||||
return {
|
return {
|
||||||
bossId: "bulldrome",
|
bossId: "bulldrome",
|
||||||
|
bossInstanceId: "boss-0-bulldrome",
|
||||||
paused: false,
|
paused: false,
|
||||||
healerClassId: "priest",
|
healerClassId: "priest",
|
||||||
phase: "combat",
|
phase: "combat",
|
||||||
round: 1,
|
round: 1,
|
||||||
|
endlessMode: false,
|
||||||
|
endlessBossKills: 0,
|
||||||
|
endlessChoiceSelection: "continue",
|
||||||
runModifiers: {
|
runModifiers: {
|
||||||
mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1,
|
mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1,
|
||||||
renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1,
|
renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1,
|
||||||
@@ -67,21 +71,26 @@ describe("dual-screen game snapshots", () => {
|
|||||||
|
|
||||||
it("routes maxed-run continuation and passive filter commands", () => {
|
it("routes maxed-run continuation and passive filter commands", () => {
|
||||||
const originalContinue = useGameStore.getState().continueRoguelikeRound;
|
const originalContinue = useGameStore.getState().continueRoguelikeRound;
|
||||||
|
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
|
||||||
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
|
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
|
||||||
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
|
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
useGameStore.setState({ continueRoguelikeRound: () => { calls.push("continue"); return true; } });
|
useGameStore.setState({
|
||||||
|
continueRoguelikeRound: () => { calls.push("continue"); return true; },
|
||||||
|
startRogueTrialsEndless: () => { calls.push("endless"); return true; },
|
||||||
|
});
|
||||||
useFrontendStore.setState({
|
useFrontendStore.setState({
|
||||||
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
|
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
|
||||||
selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); },
|
selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); },
|
||||||
});
|
});
|
||||||
|
|
||||||
executeGameCommand({ name: "continueRoguelikeRound" });
|
executeGameCommand({ name: "continueRoguelikeRound" });
|
||||||
|
executeGameCommand({ name: "startRogueTrialsEndless" });
|
||||||
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" });
|
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" });
|
||||||
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
|
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
|
||||||
expect(calls).toEqual(["continue", "ability:shield", "passive:shield-guard"]);
|
expect(calls).toEqual(["continue", "endless", "ability:shield", "passive:shield-guard"]);
|
||||||
|
|
||||||
useGameStore.setState({ continueRoguelikeRound: originalContinue });
|
useGameStore.setState({ continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless });
|
||||||
useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive });
|
useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export type GameCommand =
|
|||||||
| { name: "setPauseSelection"; selection: "resume" | "exit" }
|
| { name: "setPauseSelection"; selection: "resume" | "exit" }
|
||||||
| { name: "setSelectedRunBuff"; buffId: RunBuffId }
|
| { name: "setSelectedRunBuff"; buffId: RunBuffId }
|
||||||
| { name: "chooseRunBuff"; buffId: RunBuffId }
|
| { name: "chooseRunBuff"; buffId: RunBuffId }
|
||||||
| { name: "continueRoguelikeRound" };
|
| { name: "continueRoguelikeRound" }
|
||||||
|
| { name: "startRogueTrialsEndless" }
|
||||||
|
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" };
|
||||||
|
|
||||||
export type FrontendCommand =
|
export type FrontendCommand =
|
||||||
| { name: "signIn"; username: string; password: string }
|
| { name: "signIn"; username: string; password: string }
|
||||||
@@ -52,9 +54,11 @@ export type FrontendCommand =
|
|||||||
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
||||||
| { name: "selectHealerClass"; classId: HealerClassId }
|
| { name: "selectHealerClass"; classId: HealerClassId }
|
||||||
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
|
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
|
||||||
|
| { name: "exitGame" }
|
||||||
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug };
|
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug };
|
||||||
|
|
||||||
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
|
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
|
||||||
|
export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game";
|
||||||
|
|
||||||
export type DualScreenMessage =
|
export type DualScreenMessage =
|
||||||
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
|
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
|
||||||
@@ -86,6 +90,8 @@ export function executeGameCommand(command: GameCommand) {
|
|||||||
case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break;
|
case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break;
|
||||||
case "chooseRunBuff": game.chooseRunBuff(command.buffId); break;
|
case "chooseRunBuff": game.chooseRunBuff(command.buffId); break;
|
||||||
case "continueRoguelikeRound": game.continueRoguelikeRound(); break;
|
case "continueRoguelikeRound": game.continueRoguelikeRound(); break;
|
||||||
|
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
|
||||||
|
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +124,7 @@ export function executeFrontendCommand(command: FrontendCommand) {
|
|||||||
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
|
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
|
||||||
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
|
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
|
||||||
case "updateSetting": frontend.updateSetting(command.key, command.value); break;
|
case "updateSetting": frontend.updateSetting(command.key, command.value); 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 } })); break;
|
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,10 +139,14 @@ export function receiveAuthoritativeMessage(message: DualScreenMessage) {
|
|||||||
/** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */
|
/** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */
|
||||||
export type BottomGameSnapshot = Pick<GameState,
|
export type BottomGameSnapshot = Pick<GameState,
|
||||||
| "bossId"
|
| "bossId"
|
||||||
|
| "bossInstanceId"
|
||||||
| "paused"
|
| "paused"
|
||||||
| "healerClassId"
|
| "healerClassId"
|
||||||
| "phase"
|
| "phase"
|
||||||
| "round"
|
| "round"
|
||||||
|
| "endlessMode"
|
||||||
|
| "endlessBossKills"
|
||||||
|
| "endlessChoiceSelection"
|
||||||
| "runModifiers"
|
| "runModifiers"
|
||||||
| "time"
|
| "time"
|
||||||
| "party"
|
| "party"
|
||||||
@@ -158,7 +169,7 @@ export type BottomGameSnapshot = Pick<GameState,
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [
|
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [
|
||||||
"bossId", "paused", "healerClassId", "phase", "round", "runModifiers", "time", "party", "boss", "additionalBosses",
|
"bossId", "bossInstanceId", "paused", "healerClassId", "phase", "round", "endlessMode", "endlessBossKills", "endlessChoiceSelection", "runModifiers", "time", "party", "boss", "additionalBosses",
|
||||||
"partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns",
|
"partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns",
|
||||||
"globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier",
|
"globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier",
|
||||||
];
|
];
|
||||||
@@ -184,10 +195,14 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot {
|
|||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
return {
|
return {
|
||||||
bossId: state.bossId,
|
bossId: state.bossId,
|
||||||
|
bossInstanceId: state.bossInstanceId,
|
||||||
paused: state.paused,
|
paused: state.paused,
|
||||||
healerClassId: state.healerClassId,
|
healerClassId: state.healerClassId,
|
||||||
phase: state.phase,
|
phase: state.phase,
|
||||||
round: state.round,
|
round: state.round,
|
||||||
|
endlessMode: state.endlessMode,
|
||||||
|
endlessBossKills: state.endlessBossKills,
|
||||||
|
endlessChoiceSelection: state.endlessChoiceSelection,
|
||||||
runModifiers: state.runModifiers,
|
runModifiers: state.runModifiers,
|
||||||
time: state.time,
|
time: state.time,
|
||||||
party: state.party,
|
party: state.party,
|
||||||
|
|||||||
+10
-14
@@ -1075,6 +1075,7 @@ button:focus-visible {
|
|||||||
.end-actions { display: flex; gap: 9px; margin-top: 18px; }
|
.end-actions { display: flex; gap: 9px; margin-top: 18px; }
|
||||||
.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 { 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.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
|
||||||
|
.end-actions button.is-controller-focused { outline: 2px solid #fff1b6; outline-offset: 2px; }
|
||||||
|
|
||||||
.buff-draft {
|
.buff-draft {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1755,10 +1756,7 @@ button:focus-visible {
|
|||||||
.home-header > span { margin-left: auto; color: #8fa39b; font-size: 10px; }
|
.home-header > span { margin-left: auto; color: #8fa39b; font-size: 10px; }
|
||||||
.home-header > span b { color: #dce9e4; }
|
.home-header > span b { color: #dce9e4; }
|
||||||
.home-header > i { color: #6ecaa7; font-size: 8px; font-style: normal; font-weight: 700; letter-spacing: 0.08em; }
|
.home-header > i { color: #6ecaa7; font-size: 8px; font-style: normal; font-weight: 700; letter-spacing: 0.08em; }
|
||||||
.home-title { padding: 17px 2px 12px; }
|
.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; margin-top: 18px; }
|
||||||
.home-title span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; }
|
|
||||||
.home-title h1 { margin: 2px 0 0; font-family: "Cinzel", serif; font-size: 25px; font-weight: 500; }
|
|
||||||
.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; }
|
|
||||||
.mode-card { position: relative; display: grid; grid-template-columns: 54px 1fr 17px; align-items: center; gap: 12px; padding: 13px; overflow: hidden; text-align: left; }
|
.mode-card { position: relative; display: grid; grid-template-columns: 54px 1fr 17px; align-items: center; gap: 12px; padding: 13px; overflow: hidden; text-align: left; }
|
||||||
.mode-card::after { position: absolute; inset: 0; content: ""; background: linear-gradient(110deg, rgba(69,153,131,0.13), transparent 60%); pointer-events: none; }
|
.mode-card::after { position: absolute; inset: 0; content: ""; background: linear-gradient(110deg, rgba(69,153,131,0.13), transparent 60%); pointer-events: none; }
|
||||||
.mode-card.is-wide { grid-row: 1 / 3; }
|
.mode-card.is-wide { grid-row: 1 / 3; }
|
||||||
@@ -1869,7 +1867,7 @@ button:focus-visible {
|
|||||||
.boss-stats-heading { padding-top: 10px; }
|
.boss-stats-heading { padding-top: 10px; }
|
||||||
.boss-stats-layout { height: 331px; display: grid; grid-template-columns: minmax(235px, .78fr) minmax(0, 1.22fr); gap: 11px; }
|
.boss-stats-layout { height: 331px; display: grid; grid-template-columns: minmax(235px, .78fr) minmax(0, 1.22fr); gap: 11px; }
|
||||||
.boss-stat-selector { min-width: 0; display: grid; align-content: start; gap: 5px; }
|
.boss-stat-selector { min-width: 0; display: grid; align-content: start; gap: 5px; }
|
||||||
.boss-stat-selector button { width: 100%; min-height: 52px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 32px; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; }
|
.boss-stat-selector button { width: 100%; min-height: 51px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 32px; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; }
|
||||||
.boss-stat-selector button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: linear-gradient(90deg, rgba(93,73,23,.27), rgba(8,18,15,.88)); }
|
.boss-stat-selector button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: linear-gradient(90deg, rgba(93,73,23,.27), rgba(8,18,15,.88)); }
|
||||||
.boss-stat-selector button > i { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid #496159; color: var(--gold); font-style: normal; }
|
.boss-stat-selector button > i { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid #496159; color: var(--gold); font-style: normal; }
|
||||||
.boss-stat-selector button > span { min-width: 0; display: grid; }
|
.boss-stat-selector button > span { min-width: 0; display: grid; }
|
||||||
@@ -1895,7 +1893,7 @@ button:focus-visible {
|
|||||||
.leaderboard-empty { min-height: 200px !important; border: 0 !important; }
|
.leaderboard-empty { min-height: 200px !important; border: 0 !important; }
|
||||||
.profile-context { padding: 0 5.5% 18px; }
|
.profile-context { padding: 0 5.5% 18px; }
|
||||||
.profile-context .context-header { margin: 0 -5.8%; }
|
.profile-context .context-header { margin: 0 -5.8%; }
|
||||||
.profile-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: 1px solid var(--line); }
|
.profile-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); border-bottom: 1px solid var(--line); }
|
||||||
.profile-stats span { min-width: 0; display: grid; padding: 8px 6px; border-right: 1px solid var(--line); }
|
.profile-stats span { min-width: 0; display: grid; padding: 8px 6px; border-right: 1px solid var(--line); }
|
||||||
.profile-stats span:last-child { border-right: 0; }
|
.profile-stats span:last-child { border-right: 0; }
|
||||||
.profile-stats small { color: #6d8179; font-size: clamp(7px, 1.5cqw, 9px); text-transform: uppercase; }
|
.profile-stats small { color: #6d8179; font-size: clamp(7px, 1.5cqw, 9px); text-transform: uppercase; }
|
||||||
@@ -1942,10 +1940,10 @@ button:focus-visible {
|
|||||||
.pad-diagram b,
|
.pad-diagram b,
|
||||||
.face-diagram b { color: #40534c; }
|
.face-diagram b { color: #40534c; }
|
||||||
.face-diagram i { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-size: 10px; font-style: normal; }
|
.face-diagram i { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-size: 10px; font-style: normal; }
|
||||||
.face-diagram .a { color: #67c394; }
|
.face-diagram .triangle { color: #70c18b; }
|
||||||
.face-diagram .b { color: #d66b61; }
|
.face-diagram .circle { color: #dc6f78; }
|
||||||
.face-diagram .x { color: #5db1d0; }
|
.face-diagram .cross { color: #78a9dd; }
|
||||||
.face-diagram .y { color: #d6ba67; }
|
.face-diagram .square { color: #cf8fc5; }
|
||||||
.mapping-list { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 15px; }
|
.mapping-list { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 15px; }
|
||||||
.mapping-list span { padding: 8px; border: 1px solid var(--line); color: #83978f; font-size: clamp(8px, 1.7cqw, 10px); }
|
.mapping-list span { padding: 8px; border: 1px solid var(--line); color: #83978f; font-size: clamp(8px, 1.7cqw, 10px); }
|
||||||
.mapping-list b { color: #d6e3de; }
|
.mapping-list b { color: #d6e3de; }
|
||||||
@@ -2083,9 +2081,7 @@ button:focus-visible {
|
|||||||
.save-footer .controller-legend { display: none; }
|
.save-footer .controller-legend { display: none; }
|
||||||
.home-header { height: 35px; }
|
.home-header { height: 35px; }
|
||||||
.home-header > span, .home-header > i { font-size: 5px; }
|
.home-header > span, .home-header > i { font-size: 5px; }
|
||||||
.home-title { padding: 6px 0; }
|
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; margin-top: 6px; }
|
||||||
.home-title h1 { font-size: 12px; }
|
|
||||||
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; }
|
|
||||||
.mode-card { grid-template-columns: 25px 1fr 8px; gap: 4px; padding: 4px; }
|
.mode-card { grid-template-columns: 25px 1fr 8px; gap: 4px; padding: 4px; }
|
||||||
.mode-card > i, .mode-card.is-wide > i { width: 23px; height: 23px; font-size: 10px; }
|
.mode-card > i, .mode-card.is-wide > i { width: 23px; height: 23px; font-size: 10px; }
|
||||||
.mode-card strong, .mode-card.is-wide strong { font-size: 8px; }
|
.mode-card strong, .mode-card.is-wide strong { font-size: 8px; }
|
||||||
@@ -2134,7 +2130,7 @@ button:focus-visible {
|
|||||||
.boss-stats-heading { padding-top: 3px; }
|
.boss-stats-heading { padding-top: 3px; }
|
||||||
.boss-stats-layout { height: 213px; grid-template-columns: minmax(138px, .82fr) minmax(0, 1.18fr); gap: 4px; }
|
.boss-stats-layout { height: 213px; grid-template-columns: minmax(138px, .82fr) minmax(0, 1.18fr); gap: 4px; }
|
||||||
.boss-stat-selector { gap: 2px; }
|
.boss-stat-selector { gap: 2px; }
|
||||||
.boss-stat-selector button { min-height: 34px; grid-template-columns: 18px minmax(0, 1fr) 18px; gap: 3px; padding: 2px 4px; }
|
.boss-stat-selector button { min-height: 33px; grid-template-columns: 18px minmax(0, 1fr) 18px; gap: 3px; padding: 2px 4px; }
|
||||||
.boss-stat-selector button > i { width: 16px; height: 16px; font-size: 7px; }
|
.boss-stat-selector button > i { width: 16px; height: 16px; font-size: 7px; }
|
||||||
.boss-stat-selector strong { font-size: 6px; }
|
.boss-stat-selector strong { font-size: 6px; }
|
||||||
.boss-stat-selector small { font-size: 4px; }
|
.boss-stat-selector small { font-size: 4px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user