Release Healer Man 0.1.40
This commit is contained in:
1 parent
ed0d2fea3d
commit
197a64117c
215 files changed
+5131
-181
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BossCardCollection } from "./BossCardCollection";
|
||||
import { BOSS_CARD_CATALOG } from "../game/bossCardCatalog";
|
||||
|
||||
describe("boss card display", () => {
|
||||
it("shows per-rarity copy totals and every dungeon for the visible boss", () => {
|
||||
const boss = BOSS_CARD_CATALOG[0];
|
||||
const markup = renderToStaticMarkup(createElement(BossCardCollection, {
|
||||
onClose: () => {}, characterName: "Collector",
|
||||
collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} },
|
||||
}));
|
||||
expect(markup).toContain("7 copies");
|
||||
expect(markup).toContain("1 copy");
|
||||
expect(markup).toContain("8 total copies");
|
||||
expect(markup).toContain("Group by dungeon");
|
||||
expect(markup).toContain("Group by rarity");
|
||||
for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog";
|
||||
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, emptyBossCardCollection, type BossCardCollection as Collection } from "../game/bossCards";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import "./bossCards.css";
|
||||
|
||||
const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
|
||||
const EMPTY_COLLECTION = emptyBossCardCollection();
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
readonly onClose: () => void;
|
||||
readonly collection: Collection;
|
||||
readonly characterName: string;
|
||||
}) {
|
||||
useContentManagerSnapshot();
|
||||
const [earnedOnly, setEarnedOnly] = useState(false);
|
||||
const [grouping, setGrouping] = useState<BossCardGrouping>("all");
|
||||
const [category, setCategory] = useState<"all" | "wow" | "rom">("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const groups = useMemo(() => groupBossCards(collection, { earnedOnly, grouping, search, category }), [collection, earnedOnly, grouping, search, category]);
|
||||
const rows = useMemo(() => groups.flatMap((group) => group.rows.map((row) => ({ ...row, group }))), [groups]);
|
||||
const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, pageCount - 1);
|
||||
const visibleRows = rows.slice(currentPage * PAGE_SIZE, (currentPage + 1) * PAGE_SIZE);
|
||||
const summary = useMemo(() => {
|
||||
const counts = BOSS_CARD_CATALOG.flatMap((boss) => BOSS_CARD_RARITIES.map((rarity) => collection.counts[boss.key]?.[rarity] ?? 0));
|
||||
return { earned: counts.filter((copies) => copies > 0).length, copies: counts.reduce((sum, copies) => sum + copies, 0), total: counts.length };
|
||||
}, [collection]);
|
||||
const filter = (earned: boolean) => { setEarnedOnly(earned); setPage(0); };
|
||||
const groupBy = (value: BossCardGrouping) => { setGrouping(value); setPage(0); };
|
||||
const actions: MenuAction[] = [
|
||||
{ id: "cards-close", run: onClose },
|
||||
{ id: "cards-all", run: () => filter(false) },
|
||||
{ id: "cards-earned", run: () => filter(true) },
|
||||
{ id: "cards-boss", run: () => groupBy("all") },
|
||||
{ id: "cards-dungeon", run: () => groupBy("dungeon") },
|
||||
{ id: "cards-rarity", run: () => groupBy("rarity") },
|
||||
{ id: "cards-prev", enabled: currentPage > 0, run: () => setPage(currentPage - 1) },
|
||||
{ id: "cards-next", enabled: currentPage + 1 < pageCount, run: () => setPage(currentPage + 1) },
|
||||
];
|
||||
const controller = useMenuController(actions, { initialId: "cards-all", onBack: onClose });
|
||||
const controlProps = { selectedId: controller.selectedId, select: controller.select };
|
||||
|
||||
return <div className="modal-backdrop boss-collection-backdrop" role="presentation" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title">
|
||||
<header className="boss-collection__header">
|
||||
<div><p className="eyebrow">{characterName}’s collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
|
||||
<ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton>
|
||||
</header>
|
||||
<div className="boss-collection__summary">
|
||||
<strong>{summary.earned} / {summary.total} cards earned</strong><span>{summary.copies.toLocaleString()} total copies</span>
|
||||
<p>Every boss awards Normal. Rare, Epic, and Legendary roll independently as bonus cards.</p>
|
||||
</div>
|
||||
<div className="boss-collection__tools">
|
||||
<div role="group" aria-label="Collection filter">
|
||||
<ControllerButton {...controlProps} controlId="cards-all" aria-pressed={!earnedOnly} onClick={() => filter(false)}>All</ControllerButton>
|
||||
<ControllerButton {...controlProps} controlId="cards-earned" aria-pressed={earnedOnly} onClick={() => filter(true)}>Earned</ControllerButton>
|
||||
</div>
|
||||
<div role="group" aria-label="Group cards">
|
||||
<ControllerButton {...controlProps} controlId="cards-boss" aria-pressed={grouping === "all"} onClick={() => groupBy("all")}>By boss</ControllerButton>
|
||||
<ControllerButton {...controlProps} controlId="cards-dungeon" aria-pressed={grouping === "dungeon"} onClick={() => groupBy("dungeon")}>Group by dungeon</ControllerButton>
|
||||
<ControllerButton {...controlProps} controlId="cards-rarity" aria-pressed={grouping === "rarity"} onClick={() => groupBy("rarity")}>Group by rarity</ControllerButton>
|
||||
</div>
|
||||
<label className="boss-collection__search">Search<input type="search" placeholder="Boss or dungeon name" value={search} onChange={(event) => { setSearch(event.target.value); setPage(0); }} /></label>
|
||||
<label>World<select value={category} onChange={(event) => { setCategory(event.target.value as typeof category); setPage(0); }}><option value="all">All worlds</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
|
||||
</div>
|
||||
<div className="boss-collection__pages" aria-label="Collection pages">
|
||||
<ControllerButton {...controlProps} controlId="cards-prev" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Previous</ControllerButton>
|
||||
<span role="status">Page {currentPage + 1} / {pageCount} · {rows.length} card{rows.length === 1 ? "" : "s"}{grouping === "dungeon" ? " across dungeons" : ""}</span>
|
||||
<ControllerButton {...controlProps} controlId="cards-next" disabled={currentPage + 1 >= pageCount} onClick={() => setPage(currentPage + 1)}>Next</ControllerButton>
|
||||
</div>
|
||||
<div className="boss-collection__scroll">
|
||||
{visibleRows.length ? <div className="boss-collection__grid">
|
||||
{visibleRows.map((row, index) => {
|
||||
const rarity = BOSS_CARD_RARITY_INFO[row.rarity];
|
||||
return <div className="boss-collection__entry" key={`${row.group.key}:${row.boss.key}:${row.rarity}`}>
|
||||
{grouping !== "all" && (index === 0 || visibleRows[index - 1].group.key !== row.group.key) && <h3 className="boss-collection__group-title">{row.group.title}</h3>}
|
||||
<article className={`boss-card ${row.copies ? "boss-card--earned" : "boss-card--unearned"}`} style={{ "--card-color": rarity.color } as CSSProperties}>
|
||||
<header><span>{rarity.label}</span><small>{rarity.odds}</small></header>
|
||||
<Suspense fallback={<span className="boss-card__model-status">Loading boss model…</span>}><BossCardPortrait model={row.boss.model} name={row.boss.name} installed={row.boss.dungeons.some((entry) => dungeonContentIsUsable(entry.id))} /></Suspense>
|
||||
<div className="boss-card__details"><h3>{row.boss.name}</h3><small>{row.boss.category === "wow" ? "World of Warcraft" : "Runes of Magic"}</small>
|
||||
<p className="boss-card__dungeons">{row.boss.dungeons.map((entry) => entry.title).join(" · ")}</p>
|
||||
</div>
|
||||
<footer><strong>{row.copies.toLocaleString()} {row.copies === 1 ? "copy" : "copies"}</strong><span>{row.copies ? "Earned" : "Not earned"}</span></footer>
|
||||
</article>
|
||||
</div>;
|
||||
})}
|
||||
</div> : <p className="boss-collection__empty">{earnedOnly ? "No earned cards match these filters. Defeat a boss to begin your collection." : "No boss cards match your search."}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) {
|
||||
const savedCharacter = useShellStore(selectedCharacter);
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const liveCollection = useCombatStore((state) => state.bossCards);
|
||||
const character = activeCharacter ?? savedCharacter;
|
||||
return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())}
|
||||
collection={phase === "game" ? liveCollection : character?.bossCards ?? EMPTY_COLLECTION} characterName={character?.name ?? "Adventurer"} />;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, Suspense, useMemo, type ReactNode } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Bounds } from "@react-three/drei";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { useGameGLTF } from "../scene/useGameGLTF";
|
||||
import type { MobModelStyle } from "../game/mobPopulation";
|
||||
|
||||
class PortraitBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() { return { failed: true }; }
|
||||
render() {
|
||||
return this.state.failed ? <span className="boss-card__model-status">Install this dungeon in Downloads to view its boss model.</span> : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function BossModel({ model }: { readonly model: MobModelStyle }) {
|
||||
const gltf = useGameGLTF(model.url);
|
||||
const scene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||
return <group rotation={[0, model.rotationY, 0]} dispose={null}><primitive object={scene} /></group>;
|
||||
}
|
||||
|
||||
/** Static previews render only when loaded/resized; pagination limits simultaneous WebGL contexts. */
|
||||
export default function BossCardPortrait({ model, name, installed }: { readonly model?: MobModelStyle; readonly name: string; readonly installed: boolean }) {
|
||||
if (!installed) return <span className="boss-card__model-status">Install this dungeon in Downloads to view its boss model.</span>;
|
||||
if (!model) return <span className="boss-card__model-status">Boss model not yet available</span>;
|
||||
return <PortraitBoundary key={model.url}>
|
||||
<div className="boss-card__portrait" role="img" aria-label={`${name} boss model`}>
|
||||
<Canvas frameloop="demand" dpr={1} camera={{ fov: 35, position: [3, 2, 5] }}
|
||||
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }} fallback={<span>3D preview unavailable</span>}>
|
||||
<ambientLight intensity={1.5} />
|
||||
<hemisphereLight args={["#ede3cf", "#303040", 2]} />
|
||||
<directionalLight position={[3, 5, 6]} intensity={2.5} />
|
||||
<Suspense fallback={null}>
|
||||
<Bounds fit clip observe margin={1.25} maxDuration={0}>
|
||||
<BossModel model={model} />
|
||||
</Bounds>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
</PortraitBoundary>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, type CSSProperties } from "react";
|
||||
import { BOSS_CARD_RARITY_INFO } from "../game/bossCards";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { classCanEquipItem, PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
|
||||
@@ -83,6 +84,9 @@ export function BossLootWindow() {
|
||||
<div className="boss-loot-window__body">
|
||||
<p className="boss-loot-window__eyebrow">Loot acquired automatically</p>
|
||||
<div className="boss-loot-window__items">
|
||||
{reward.cards?.rarities.map((rarity) => <article key={rarity} className="boss-loot-window__item" style={{ "--item-quality": BOSS_CARD_RARITY_INFO[rarity].color } as CSSProperties}>
|
||||
<b aria-hidden="true">◆</b><p><strong>{BOSS_CARD_RARITY_INFO[rarity].label} Boss Card</strong><small>{reward.bossName} · Collection log</small></p><em>+1</em>
|
||||
</article>)}
|
||||
{reward.items.map((item) => {
|
||||
const comparison = classCanEquipItem(classId, item)
|
||||
? compareEquipmentUpgrade(item, loadout, profile, heirloomContext)
|
||||
@@ -100,15 +104,15 @@ export function BossLootWindow() {
|
||||
{item.quantity > 1 ? <em>×{item.quantity}</em> : null}
|
||||
</article>;
|
||||
})}
|
||||
{!reward.items.length ? (
|
||||
{!reward.items.length && !reward.cards?.rarities.length ? (
|
||||
<p className="boss-loot-window__empty">No items dropped this time.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<span>{reward.items.length} item{reward.items.length === 1 ? "" : "s"} added to shared inventory</span>
|
||||
<button type="button" onClick={viewInventory}>View inventory</button>
|
||||
<span>{reward.cards?.rarities.length ? `${reward.cards.rarities.length} card${reward.cards.rarities.length === 1 ? "" : "s"} added to collection${reward.items.length ? ` · ${reward.items.length} items acquired` : ""}` : `${reward.items.length} items added to shared inventory`}</span>
|
||||
{reward.cards ? <button type="button" onClick={() => { dismiss(); useGameStore.getState().openOverlay("boss-cards"); }}>View collection</button> : <button type="button" onClick={viewInventory}>View inventory</button>}
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ function safePercent(current: number, maximum: number): number {
|
||||
function overlayName(overlay: GameOverlay, romSkills = false): string {
|
||||
if (overlay === "party") return "Party management";
|
||||
if (overlay === "inventory") return "Inventory & equipment";
|
||||
if (overlay === "boss-cards") return "Boss card collection";
|
||||
if (overlay === "spellbook") return "Spellbook";
|
||||
if (overlay === "talents") return romSkills ? "Skills" : "Talents";
|
||||
if (overlay === "bindings") return "Ability controls";
|
||||
@@ -462,7 +463,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
</section>
|
||||
<div className="companion-tactical__overview">
|
||||
<section className="companion-map-card">
|
||||
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
|
||||
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} dungeonId={snapshot.game.dungeonId} compact />
|
||||
<div>
|
||||
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
|
||||
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
|
||||
@@ -495,6 +496,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
<button type="button" className={snapshot.game.overlay === "spellbook" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "spellbook"} onClick={() => toggleDestination("spellbook")}>Spells</button>
|
||||
<button type="button" className={snapshot.game.overlay === "talents" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "talents"} onClick={() => toggleDestination("talents")}>{skillMenuLabel}</button>
|
||||
<button type="button" className={snapshot.game.overlay === "inventory" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "inventory"} onClick={() => toggleDestination("inventory")}>Gear</button>
|
||||
<button type="button" className={snapshot.game.overlay === "boss-cards" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "boss-cards"} onClick={() => toggleDestination("boss-cards")}>Cards</button>
|
||||
<button type="button" className={snapshot.game.overlay === "bindings" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "bindings"} onClick={() => toggleDestination("bindings")}>Controls</button>
|
||||
<button type="button" className={snapshot.game.overlay === "options" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "options"} onClick={() => toggleDestination("options")}>Options</button>
|
||||
<button type="button" className={snapshot.game.overlay === "pause" ? "is-active" : ""} aria-pressed={snapshot.game.overlay === "pause"} onClick={() => toggleDestination("pause")}>{snapshot.game.overlay === "pause" ? "Resume" : "Pause"}</button>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { bossDifficultyLabel, bossDifficultyMultipliers } from "../game/dungeonDifficulty";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import "./dungeonRuns.css";
|
||||
|
||||
export function DungeonDifficultySlider({ value, onChange, selectedId, select }: {
|
||||
readonly value: number;
|
||||
readonly onChange: (value: number) => void;
|
||||
readonly selectedId: string;
|
||||
readonly select: (id: string) => void;
|
||||
}) {
|
||||
const multipliers = bossDifficultyMultipliers(value);
|
||||
const controls = { selectedId, select };
|
||||
return <section className="dungeon-difficulty" aria-label="Boss difficulty">
|
||||
<label htmlFor="boss-difficulty"><strong>Boss difficulty</strong><span>{bossDifficultyLabel(value)}</span></label>
|
||||
<div className="dungeon-difficulty__control">
|
||||
<ControllerButton {...controls} controlId="boss-difficulty-down" className="front-button" aria-label="Decrease boss difficulty"
|
||||
disabled={value === 1} onClick={() => onChange(value - 1)}>−</ControllerButton>
|
||||
<input id="boss-difficulty" aria-label="Boss difficulty" type="range" min="1" max="10" step="1" value={value}
|
||||
aria-valuetext={bossDifficultyLabel(value)} onChange={(event) => onChange(Number(event.target.value))} />
|
||||
<ControllerButton {...controls} controlId="boss-difficulty-up" className="front-button" aria-label="Increase boss difficulty"
|
||||
disabled={value === 10} onClick={() => onChange(value + 1)}>+</ControllerButton>
|
||||
</div>
|
||||
<p>Boss health ×{multipliers.health.toFixed(2)} · Damage ×{multipliers.damage.toFixed(2)}</p>
|
||||
<small>Easy targets 30-second bosses and 45-second final bosses. Difficulty is fixed for the expedition. Each level has its own leaderboard.</small>
|
||||
</section>;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter,
|
||||
<time>{formatDungeonDuration(entry.durationMs)}</time><em aria-hidden="true">{expanded.has(entry.id) ? "−" : "+"}</em>
|
||||
</ControllerButton>
|
||||
{expanded.has(entry.id) ? <div id={`run-details-${entry.id}`}><DungeonRunDetails dungeonId={entry.dungeonId}
|
||||
difficultyId={entry.difficultyId} party={entry.party} killedBossIds={entry.killedBossIds} /></div> : null}
|
||||
difficultyId={entry.difficultyId} bossDifficulty={entry.bossDifficulty} party={entry.party} killedBossIds={entry.killedBossIds} /></div> : null}
|
||||
</li>)}
|
||||
</ol>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||
|
||||
function render(id: string, options: { compact?: boolean; showPatrols?: boolean; floorLevel?: number } = {}) {
|
||||
const dungeon = requireDungeonDefinition(id);
|
||||
return renderToStaticMarkup(createElement(DungeonMapGraphic, { dungeonId: id, position: dungeon.entrance.footPosition, yaw: 0, ...options }));
|
||||
}
|
||||
|
||||
describe("dungeon map artwork", () => {
|
||||
it("renders authentic Wailing Caverns artwork and player/entrance/numbered encounters", () => {
|
||||
const html = render("wailing-caverns");
|
||||
expect(html).toContain("wailing-caverns/maps/floor-1.png");
|
||||
expect(html).toContain('viewBox="0 0 1002 668"');
|
||||
expect(html).toContain("map-player");
|
||||
expect(html).toContain("Dungeon entrance");
|
||||
expect(html).toContain("Lady Anacondra");
|
||||
expect(html).toContain("map-marker-label");
|
||||
expect(html).not.toContain("map-contour");
|
||||
expect(html).not.toContain("map-patrol");
|
||||
});
|
||||
it("keeps the compact map clear of labels and patrols", () => {
|
||||
const html = render("wailing-caverns", { compact: true, showPatrols: true });
|
||||
expect(html).toContain("original game map");
|
||||
expect(html).not.toContain("map-marker-label");
|
||||
expect(html).not.toContain("map-patrol");
|
||||
});
|
||||
it("shows explicitly selected floor artwork", () => {
|
||||
expect(render("deadmines", { floorLevel: 2 })).toContain("deadmines/maps/floor-2.png");
|
||||
});
|
||||
it("preserves the survey fallback for RuneWaker dungeons", () => {
|
||||
const html = render("forsaken-abbey");
|
||||
expect(html).toContain('viewBox="0 0 430 300"');
|
||||
expect(html).not.toContain("<image");
|
||||
expect(html).toContain('data-artwork="unavailable"');
|
||||
});
|
||||
it("only draws patrol overlays on request", () => {
|
||||
expect(render("wailing-caverns", { showPatrols: true })).toContain("map-patrol");
|
||||
});
|
||||
});
|
||||
@@ -1,49 +1,78 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { resolveContentUrl } from "../content/contentManager";
|
||||
import { wowDungeonMapFor, wowMapContains, wowMapFloorFor, wowMapHeading, wowMapPoint } from "../game/wowDungeonMap";
|
||||
|
||||
export function DungeonMapGraphic({ position, yaw, compact = false, dungeonId }: {
|
||||
export type MapArtworkStatus = "loading" | "ready" | "unavailable";
|
||||
|
||||
export function DungeonMapGraphic({ position, yaw, compact = false, dungeonId, floorLevel, showPatrols = false, onArtworkStatus }: {
|
||||
position: readonly [number, number, number];
|
||||
yaw: number;
|
||||
compact?: boolean;
|
||||
dungeonId?: string;
|
||||
floorLevel?: number | null;
|
||||
showPatrols?: boolean;
|
||||
onArtworkStatus?: (status: MapArtworkStatus) => void;
|
||||
}) {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const dungeon = requireDungeonDefinition(dungeonId ?? activeDungeonId);
|
||||
const { width: viewWidth, height: viewHeight, padding, contours } = dungeon.presentation.map;
|
||||
const map = wowDungeonMapFor(dungeon);
|
||||
const floor = map ? wowMapFloorFor(map, position, floorLevel) : null;
|
||||
const [failedImage, setFailedImage] = useState<string | null>(null);
|
||||
const [loadedImage, setLoadedImage] = useState<string | null>(null);
|
||||
const artwork = map && floor && failedImage !== floor.image ? floor : null;
|
||||
const status = !artwork ? "unavailable" : loadedImage === artwork.image ? "ready" : "loading";
|
||||
useEffect(() => { onArtworkStatus?.(status); }, [onArtworkStatus, status]);
|
||||
const presentation = dungeon.presentation.map;
|
||||
const viewWidth = artwork ? 1002 : presentation.width;
|
||||
const viewHeight = artwork ? 668 : presentation.height;
|
||||
const { padding, contours } = presentation;
|
||||
const width = Math.max(1, dungeon.bounds.max[0] - dungeon.bounds.min[0]);
|
||||
const depth = Math.max(1, dungeon.bounds.max[2] - dungeon.bounds.min[2]);
|
||||
const project = (point: readonly [number, number, number]): readonly [number, number] => [
|
||||
padding + ((point[0] - dungeon.bounds.min[0]) / width) * (viewWidth - padding * 2),
|
||||
padding + ((point[2] - dungeon.bounds.min[2]) / depth) * (viewHeight - padding * 2),
|
||||
];
|
||||
const project = (point: readonly [number, number, number]): readonly [number, number] => artwork && map
|
||||
? wowMapPoint(map, artwork, point)
|
||||
: [padding + ((point[0] - dungeon.bounds.min[0]) / width) * (viewWidth - padding * 2),
|
||||
padding + ((point[2] - dungeon.bounds.min[2]) / depth) * (viewHeight - padding * 2)];
|
||||
const visible = (point: readonly [number, number, number]) => !artwork || !map || wowMapContains(map, artwork, point);
|
||||
const [playerX, playerY] = project(position);
|
||||
const headingX = Math.sin(yaw) * 12;
|
||||
const headingY = Math.cos(yaw) * 12;
|
||||
const id = `${dungeon.id}-${compact ? "companion" : "dialog"}-map`;
|
||||
const [headingX, headingY] = artwork && map ? wowMapHeading(map, artwork, position, yaw) : [Math.sin(yaw) * 12, Math.cos(yaw) * 12];
|
||||
const markerScale = artwork ? 2 : 1;
|
||||
const id = `dungeon-map-${useId().replace(/:/g, "")}`;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${viewWidth} ${viewHeight}`} role="img" aria-label={`${dungeon.title} patrol and encounter map`}>
|
||||
<svg viewBox={`0 0 ${viewWidth} ${viewHeight}`} role="img" aria-label={`${dungeon.title}${artwork ? ` — ${artwork.name}, original game map` : " survey map"}`} data-artwork={status} data-floor={artwork?.level}>
|
||||
<defs>
|
||||
<filter id={`${id}-glow`}><feGaussianBlur stdDeviation="2.5" result="blur" /></filter>
|
||||
<pattern id={`${id}-grid`} width="28" height="28" patternUnits="userSpaceOnUse">
|
||||
<path d="M 28 0 L 0 0 0 28" fill="none" stroke="rgba(154,190,139,.09)" strokeWidth="1" />
|
||||
</pattern>
|
||||
<clipPath id={`${id}-clip`}><rect width={viewWidth} height={viewHeight} /></clipPath>
|
||||
</defs>
|
||||
<rect width={viewWidth} height={viewHeight} fill={`url(#${id}-grid)`} />
|
||||
{contours.map((path, index) => (
|
||||
<path key={path} className={index === 0 ? "map-contour" : "map-contour map-contour--thin"} d={path} />
|
||||
))}
|
||||
{!compact && dungeon.roamingPacks.map((pack) => {
|
||||
const points = pack.waypoints.map((waypoint) => project(waypoint).join(",")).join(" ");
|
||||
const [startX, startY] = project(pack.waypoints[0]);
|
||||
return <g key={pack.id} className="map-patrol"><polyline points={points} /><circle cx={startX} cy={startY} r="3" /><title>{pack.name}</title></g>;
|
||||
})}
|
||||
{dungeon.areas.map((checkpoint, index) => {
|
||||
const [x, y] = project(checkpoint.center);
|
||||
return <g key={checkpoint.id} className={index === 0 ? "map-anchor map-anchor--entrance" : "map-anchor"}><circle cx={x} cy={y} r={compact ? 4 : 5} /><title>{checkpoint.name}</title></g>;
|
||||
})}
|
||||
<circle className="map-player-glow" cx={playerX} cy={playerY} r="10" filter={`url(#${id}-glow)`} />
|
||||
<circle className="map-player" cx={playerX} cy={playerY} r="5.5" />
|
||||
<line className="map-heading" x1={playerX} y1={playerY} x2={playerX + headingX} y2={playerY + headingY} />
|
||||
{artwork ? <image key={artwork.image} href={resolveContentUrl(artwork.image)} width="1002" height="668" preserveAspectRatio="none"
|
||||
onLoad={() => setLoadedImage(artwork.image)} onError={() => setFailedImage(artwork.image)} />
|
||||
: contours.map((path, index) => <path key={path} className={index === 0 ? "map-contour" : "map-contour map-contour--thin"} d={path} />)}
|
||||
<g clipPath={`url(#${id}-clip)`}>
|
||||
{!compact && showPatrols && dungeon.roamingPacks.filter((pack) => pack.waypoints.length > 0).map((pack) => {
|
||||
const points = pack.waypoints.map((waypoint) => project(waypoint).join(",")).join(" ");
|
||||
return <g key={pack.id} className="map-patrol"><polyline points={points} style={{ strokeWidth: markerScale * 1.5 }} /><title>{pack.name}</title></g>;
|
||||
})}
|
||||
{visible(dungeon.entrance.footPosition) && (() => {
|
||||
const [x, y] = project(dungeon.entrance.footPosition);
|
||||
return <g className="map-anchor map-anchor--entrance"><circle cx={x} cy={y} r={markerScale * 5} style={{ strokeWidth: markerScale * 1.5 }} /><title>Dungeon entrance</title></g>;
|
||||
})()}
|
||||
{dungeon.objectiveOrder.filter((objective) => visible(objective.position)).map((objective) => {
|
||||
const index = dungeon.objectiveOrder.indexOf(objective);
|
||||
const [x, y] = project(objective.position);
|
||||
return <g key={objective.id} className="map-anchor"><circle cx={x} cy={y} r={markerScale * (compact ? 4 : 8)} style={{ strokeWidth: markerScale * 1.5 }} />
|
||||
{!compact && <text className="map-marker-label" x={x} y={y} fontSize={markerScale * 9}>{index + 1}</text>}<title>{`${index + 1}. ${objective.name}`}</title></g>;
|
||||
})}
|
||||
{visible(position) && <g><title>You are here — camera direction</title>
|
||||
<circle className="map-player-glow" cx={playerX} cy={playerY} r={markerScale * 10} filter={`url(#${id}-glow)`} />
|
||||
<circle className="map-player" cx={playerX} cy={playerY} r={markerScale * 5.5} style={{ strokeWidth: markerScale * 2 }} />
|
||||
<line className="map-heading" x1={playerX} y1={playerY} x2={playerX + headingX} y2={playerY + headingY} style={{ strokeWidth: markerScale * 2.5 }} />
|
||||
</g>}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
import type { DungeonRunMember } from "../game/dungeonRunTypes";
|
||||
import { bossDifficultyLabel } from "../game/dungeonDifficulty";
|
||||
|
||||
const className = (id: string) => catalog.classes.find((entry) => entry.id === id)?.name ?? id;
|
||||
|
||||
export function DungeonRunDetails({ dungeonId, difficultyId, party, killedBossIds }: {
|
||||
export function DungeonRunDetails({ dungeonId, difficultyId, bossDifficulty, party, killedBossIds }: {
|
||||
readonly bossDifficulty?: number;
|
||||
readonly dungeonId: string;
|
||||
readonly difficultyId: string;
|
||||
readonly party: readonly DungeonRunMember[];
|
||||
@@ -13,6 +15,7 @@ export function DungeonRunDetails({ dungeonId, difficultyId, party, killedBossId
|
||||
const ids = dungeon?.difficulties.find((entry) => entry.id === difficultyId)?.bossIds ?? [];
|
||||
const bosses = dungeon?.bosses.filter((boss) => ids.includes(boss.id)) ?? [];
|
||||
return <div className="dungeon-run-details">
|
||||
<p>{bossDifficultyLabel(bossDifficulty)} · {difficultyId === "heroic" ? "Heroic" : "Normal"}</p>
|
||||
<h4>Party</h4>
|
||||
<ul className="dungeon-run-party">
|
||||
{party.map((member, index) => <li key={`${member.name}-${index}`}>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
|
||||
{!progress.timed ? <p className="dungeon-results__note">This resumed run has no saved timer. Start a new expedition to record a leaderboard time.</p> : null}
|
||||
{ranking.scope === "device" ? <p className="dungeon-results__note">Your time is saved on this device. Reconnect to submit it to the world leaderboard.</p> : null}
|
||||
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
|
||||
difficultyId={progress.difficultyId} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
|
||||
difficultyId={progress.difficultyId} bossDifficulty={progress.bossDifficulty} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
|
||||
<footer>
|
||||
<ControllerButton {...controls} controlId="run-loot" className="button" onClick={onLoot}>Stay and loot</ControllerButton>
|
||||
<ControllerButton {...controls} controlId="run-finish" className="button button--primary" onClick={onFinish}>Return to main menu</ControllerButton>
|
||||
|
||||
@@ -31,6 +31,8 @@ import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { DUNGEON_RELEASE_STAGE_LABELS } from "../game/dungeonReleaseStage";
|
||||
import { useDungeonReports } from "./useDungeonReports";
|
||||
import { DungeonDifficultySlider } from "./DungeonDifficultySlider";
|
||||
import { bossDifficultyLabel } from "../game/dungeonDifficulty";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -82,10 +84,11 @@ export function DungeonSelectScreen() {
|
||||
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null;
|
||||
const [clearFilter, setClearFilter] = useState<DungeonClearFilter>("final");
|
||||
const [difficultyId, setDifficultyId] = useState("");
|
||||
const [bossDifficulty, setBossDifficulty] = useState(1);
|
||||
const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set());
|
||||
const leaderboardDifficulty = selectedDefinition?.difficultyVariants.find((variant) => variant.id === difficultyId)
|
||||
?? selectedDefinition?.difficultyVariants.find((variant) => variant.id === selectedDefinition.defaultDifficultyId);
|
||||
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal", clearFilter);
|
||||
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal", clearFilter, bossDifficulty);
|
||||
const toggleRun = (id: string) => setExpandedRuns((current) => {
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
@@ -121,7 +124,7 @@ export function DungeonSelectScreen() {
|
||||
const continueAsRole = async (role: PartyRole) => {
|
||||
if (!selectedDungeon?.available || !character) return;
|
||||
if (!onlineGroup || !session || session.kind !== "account") {
|
||||
enterDungeon(selectedDungeon.id, role);
|
||||
enterDungeon(selectedDungeon.id, role, { difficultyId: leaderboardDifficulty?.id, bossDifficulty });
|
||||
return;
|
||||
}
|
||||
if (!await updateOnlineMember(session, character, role)) return;
|
||||
@@ -132,6 +135,8 @@ export function DungeonSelectScreen() {
|
||||
await startOnlineActivity(session, {
|
||||
type: "dungeon",
|
||||
contentId: selectedDungeon.id,
|
||||
difficultyId: leaderboardDifficulty?.id,
|
||||
bossDifficulty,
|
||||
partySize: 5,
|
||||
});
|
||||
};
|
||||
@@ -199,6 +204,8 @@ export function DungeonSelectScreen() {
|
||||
{ id: "leaderboard-full", run: () => setClearFilter("full") },
|
||||
{ id: "leaderboard-difficulty", run: cycleDifficulty, enabled: (selectedDefinition?.difficultyVariants.length ?? 0) > 1 },
|
||||
{ id: "leaderboard-refresh", run: leaderboard.refresh },
|
||||
{ id: "boss-difficulty-down", run: () => setBossDifficulty((value) => Math.max(1, value - 1)), enabled: bossDifficulty > 1 },
|
||||
{ id: "boss-difficulty-up", run: () => setBossDifficulty((value) => Math.min(10, value + 1)), enabled: bossDifficulty < 10 },
|
||||
...leaderboard.data.entries.map((entry) => ({ id: `leaderboard-run-${entry.id}`, run: () => toggleRun(entry.id) })),
|
||||
{ id: "leaderboard-prev", run: () => leaderboard.setOffset(Math.max(0, leaderboard.offset - DUNGEON_LEADERBOARD_PAGE_SIZE)),
|
||||
enabled: !leaderboard.loading && leaderboard.offset > 0 },
|
||||
@@ -219,7 +226,7 @@ export function DungeonSelectScreen() {
|
||||
run: () => setRolePickerOpen(false),
|
||||
}] : []),
|
||||
{ id: "dungeon-back", run: returnToMainMenu },
|
||||
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, expandedRuns, leaderboardDifficulty]);
|
||||
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, expandedRuns, leaderboardDifficulty, bossDifficulty]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
|
||||
onBack: pendingDownload
|
||||
@@ -318,7 +325,7 @@ export function DungeonSelectScreen() {
|
||||
<DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
|
||||
loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
|
||||
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
|
||||
onRefresh={leaderboard.refresh} difficultyLabel={leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}
|
||||
onRefresh={leaderboard.refresh} difficultyLabel={`${bossDifficultyLabel(bossDifficulty)} · ${leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}`}
|
||||
onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined}
|
||||
selectedId={controller.selectedId} select={controller.select} />
|
||||
<section className="dungeon-select-copy">
|
||||
@@ -326,6 +333,8 @@ export function DungeonSelectScreen() {
|
||||
<h2>{selectedDungeon.title}</h2>
|
||||
<span className="dungeon-release-stage">{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}</span>
|
||||
<p>{selectedDungeon.summary}</p>
|
||||
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty}
|
||||
selectedId={controller.selectedId} select={controller.select} />
|
||||
{!canStart && selectedDungeon.availabilityReason && (
|
||||
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ const PartyManagementPanel = lazy(() => loadPartyManagementPanel().then((module)
|
||||
const BossGuidePanel = lazy(() => loadBossGuidePanel().then((module) => ({ default: module.BossGuidePanel })));
|
||||
|
||||
const TalentGameplayPanel = lazy(loadTalentGameplayPanel);
|
||||
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
|
||||
|
||||
function TalentsPanelLoading() {
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
@@ -101,6 +102,7 @@ export function GameplayPanels() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{overlay === "boss-cards" ? <Suspense fallback={null}><BossCardCollectionScreen /></Suspense> : null}
|
||||
{overlay === "party"
|
||||
? PreloadedPartyManagementPanel
|
||||
? <PreloadedPartyManagementPanel />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { lazy, Suspense, useMemo, useState } from "react";
|
||||
import { classById, FACTIONS, raceById } from "../app/characterCatalog";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
@@ -11,6 +11,7 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||
import { DungeonReportsScreen } from "./DungeonReportsScreen";
|
||||
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
|
||||
|
||||
const GAME_MODES = [
|
||||
{
|
||||
@@ -66,14 +67,17 @@ const GAME_MODES = [
|
||||
export function MainMenuScreen() {
|
||||
const [contentManagerOpen, setContentManagerOpen] = useState(false);
|
||||
const [reportsOpen, setReportsOpen] = useState(false);
|
||||
const [cardsOpen, setCardsOpen] = useState(false);
|
||||
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} />;
|
||||
}
|
||||
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports }: {
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
|
||||
readonly onOpenDownloads: () => void;
|
||||
readonly onOpenReports: () => void;
|
||||
readonly onOpenCards: () => void;
|
||||
}) {
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
@@ -117,7 +121,8 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports }: {
|
||||
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||
{ id: "mode-reports", run: onOpenReports },
|
||||
], [character, isGm, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
{ id: "mode-cards", run: onOpenCards },
|
||||
], [character, isGm, onOpenCards, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -173,6 +178,10 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports }: {
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
||||
<ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}>
|
||||
<b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-reports" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReports}>
|
||||
<b aria-hidden="true">!</b>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { getInputPreferences, publishInputPreferences, setCapturingInput } from "../input/keybindRuntime";
|
||||
import { closeMapForKey, MapPanel } from "./MapPanel";
|
||||
|
||||
// Render the live client state rather than Zustand's immutable initial SSR snapshot.
|
||||
vi.mock("../game/store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/store")>();
|
||||
return { ...actual, useGameStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.useGameStore.getState>) => unknown) => select(actual.useGameStore.getState()), actual.useGameStore,
|
||||
) };
|
||||
});
|
||||
|
||||
const initialPreferences = getInputPreferences();
|
||||
const initialState = useGameStore.getState();
|
||||
|
||||
function key(code: string, extra = {}) {
|
||||
return { code, ctrlKey: false, altKey: false, shiftKey: false, metaKey: false, repeat: false, defaultPrevented: false,
|
||||
preventDefault: vi.fn(), stopPropagation: vi.fn(), ...extra };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
publishInputPreferences(initialPreferences);
|
||||
setCapturingInput(false);
|
||||
useGameStore.setState(initialState);
|
||||
});
|
||||
|
||||
describe("map dialog controls", () => {
|
||||
it("closes with M even when a map form control owns focus", () => {
|
||||
publishInputPreferences({ ...initialPreferences, pc: { ...initialPreferences.pc, map: "KeyM" } });
|
||||
const event = key("KeyM");
|
||||
const close = vi.fn();
|
||||
closeMapForKey(event, close);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(event.stopPropagation).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("honors the rebound map shortcut", () => {
|
||||
publishInputPreferences({ ...initialPreferences, pc: { ...initialPreferences.pc, map: "Ctrl+KeyG" } });
|
||||
const close = vi.fn();
|
||||
closeMapForKey(key("KeyM"), close);
|
||||
closeMapForKey(key("KeyG", { ctrlKey: true }), close);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("ignores repeats, consumed events, other keys and keybind capture", () => {
|
||||
const close = vi.fn();
|
||||
closeMapForKey(key("KeyM", { repeat: true }), close);
|
||||
closeMapForKey(key("KeyM", { defaultPrevented: true }), close);
|
||||
closeMapForKey(key("Space"), close);
|
||||
setCapturingInput(true);
|
||||
closeMapForKey(key("KeyM"), close);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
it("renders available floors and the optional patrol control", () => {
|
||||
useGameStore.setState({ activeDungeonId: "deadmines", overlay: "map" });
|
||||
const html = renderToStaticMarkup(createElement(MapPanel));
|
||||
expect(html).toContain("Map floor");
|
||||
expect(html).toContain("Auto — best match");
|
||||
expect(html).toContain('value="2"');
|
||||
expect(html).toContain("Patrol routes");
|
||||
expect(html).toContain("Choose a floor when rooms overlap");
|
||||
});
|
||||
it("unmounts when the map is closed", () => {
|
||||
useGameStore.setState({ overlay: null });
|
||||
expect(renderToStaticMarkup(createElement(MapPanel))).toBe("");
|
||||
});
|
||||
});
|
||||
+43
-7
@@ -1,10 +1,24 @@
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import { inputKeyForEvent } from "../input/keybinds";
|
||||
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController } from "../input/useMenuController";
|
||||
import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||
import { DungeonMapGraphic, type MapArtworkStatus } from "./DungeonMapGraphic";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { wowDungeonMapFor, wowMapContains, wowMapFloorFor, wowMapPoint } from "../game/wowDungeonMap";
|
||||
|
||||
export function closeMapForKey(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey" | "repeat" | "defaultPrevented" | "preventDefault" | "stopPropagation">, close: () => void) {
|
||||
if (event.repeat || event.defaultPrevented || isCapturingInput()) return;
|
||||
const key = inputKeyForEvent(event);
|
||||
if (!key || key !== getInputPreferences().pc.map) return;
|
||||
// The gameplay listener intentionally ignores focused form controls.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
}
|
||||
|
||||
function MapDialog() {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
@@ -17,6 +31,13 @@ function MapDialog() {
|
||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||
const map = wowDungeonMapFor(dungeon);
|
||||
const [floorLevel, setFloorLevel] = useState<number | null>(null);
|
||||
const [showPatrols, setShowPatrols] = useState(false);
|
||||
const [artworkStatus, setArtworkStatus] = useState<MapArtworkStatus>(map ? "loading" : "unavailable");
|
||||
const floor = map ? wowMapFloorFor(map, position, floorLevel) : null;
|
||||
const mapPosition = map && floor ? wowMapPoint(map, floor, position) : null;
|
||||
const positionOnMap = Boolean(map && floor && wowMapContains(map, floor, position));
|
||||
const stagePresentation = resolveStagePresentation(gameMode, encounter, {
|
||||
title: dungeon.title,
|
||||
mapId: dungeon.mapId,
|
||||
@@ -25,27 +46,41 @@ function MapDialog() {
|
||||
useMenuController([], { onBack: () => openOverlay("pause") });
|
||||
|
||||
return (
|
||||
<aside className="map-panel" role="dialog" aria-modal="true" aria-labelledby="map-title">
|
||||
<aside className="map-panel" role="dialog" aria-modal="true" aria-labelledby="map-title" onKeyDown={(event) => closeMapForKey(event, close)}>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Expedition survey</p>
|
||||
<p className="eyebrow">{artworkStatus === "ready" ? "Dungeon atlas" : "Expedition survey"}</p>
|
||||
<h2 id="map-title">{stagePresentation.title}</h2>
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={close} aria-label="Close map">X</button>
|
||||
</header>
|
||||
|
||||
<div className="map-panel__tools">
|
||||
{map && map.floors.length > 1 && <label>Map floor
|
||||
<select value={floorLevel ?? "auto"} onChange={(event) => setFloorLevel(event.target.value === "auto" ? null : Number(event.target.value))}>
|
||||
<option value="auto">Auto — best match</option>
|
||||
{map.floors.map((entry) => <option key={entry.level} value={entry.level}>{entry.name}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
<label className="map-panel__patrol-toggle"><input type="checkbox" checked={showPatrols} onChange={(event) => setShowPatrols(event.target.checked)} />Patrol routes</label>
|
||||
</div>
|
||||
<div className="map-panel__canvas">
|
||||
<DungeonMapGraphic position={position} yaw={yaw} />
|
||||
<DungeonMapGraphic position={position} yaw={yaw} floorLevel={floorLevel} showPatrols={showPatrols} onArtworkStatus={setArtworkStatus} />
|
||||
</div>
|
||||
|
||||
<div className="map-panel__readout">
|
||||
<div><span>Current area</span><strong>{stagePresentation.areaName}</strong></div>
|
||||
<div><span>Position</span><strong>{position.map((value) => Math.round(value)).join(" / ")}</strong></div>
|
||||
<div><span>{artworkStatus === "ready" ? floor?.name : "Position"}</span><strong>{artworkStatus === "ready" && mapPosition
|
||||
? positionOnMap ? `${(mapPosition[0] / 1002 * 100).toFixed(1)}%, ${(mapPosition[1] / 668 * 100).toFixed(1)}%` : "Outside selected floor"
|
||||
: position.map((value) => Math.round(value)).join(" / ")}</strong></div>
|
||||
</div>
|
||||
<p className="map-panel__note">
|
||||
{stagePresentation.usingFallbackEnvironment
|
||||
? "Stage location is authoritative; the survey artwork is temporary fallback geometry. "
|
||||
: "Gold markers show encounters. Dashed lines show database patrol routes. "}
|
||||
: artworkStatus === "ready"
|
||||
? "Original game artwork. White: you and camera direction. Green: entrance. Gold: numbered encounters. "
|
||||
: artworkStatus === "loading" ? "Loading original game artwork. " : "Original artwork unavailable; showing the survey fallback. "}
|
||||
{map && map.floors.length > 1 && "Choose a floor when rooms overlap. "}
|
||||
Press {bindingLabel("pc", "map")} to close or {bindingLabel("controller", "menu-back")} to return to Pause.
|
||||
</p>
|
||||
</aside>
|
||||
@@ -54,5 +89,6 @@ function MapDialog() {
|
||||
|
||||
export function MapPanel() {
|
||||
const open = useGameStore((state) => state.overlay === "map");
|
||||
return open ? <MapDialog /> : null;
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
return open ? <MapDialog key={activeDungeonId} /> : null;
|
||||
}
|
||||
+8
-2
@@ -3,6 +3,8 @@ import type { Vector3Tuple } from "../game/dungeonManifest";
|
||||
import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { wowDungeonMapFor, wowMapFloorFor, wowMapHeading } from "../game/wowDungeonMap";
|
||||
|
||||
export interface MinimapProps {
|
||||
position: Vector3Tuple;
|
||||
@@ -23,15 +25,19 @@ export function Minimap({
|
||||
}: MinimapProps) {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
const inputMode = useGameStore((state) => state.inputMode);
|
||||
const dungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const map = wowDungeonMapFor(requireDungeonDefinition(dungeonId));
|
||||
const direction = map ? wowMapHeading(map, wowMapFloorFor(map, position), position, yaw) : null;
|
||||
const compassYaw = direction ? Math.atan2(direction[0], -direction[1]) : yaw;
|
||||
const mapKey = bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "map");
|
||||
const heading = ((Math.round((yaw * 180) / Math.PI) % 360) + 360) % 360;
|
||||
const heading = ((Math.round((compassYaw * 180) / Math.PI) % 360) + 360) % 360;
|
||||
return (
|
||||
<section className="hud-minimap" aria-label="Dungeon minimap">
|
||||
<button type="button" className="hud-minimap__map" onClick={onOpenMap} aria-label="Open full dungeon map">
|
||||
<span className="hud-minimap__north" aria-hidden="true">N</span>
|
||||
<span
|
||||
className="hud-minimap__viewport"
|
||||
style={{ "--minimap-turn": rotationMode === "player-up" ? `${-yaw}rad` : "0rad" } as CSSProperties}
|
||||
style={{ "--minimap-turn": rotationMode === "player-up" ? `${-compassYaw}rad` : "0rad" } as CSSProperties}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<DungeonMapGraphic position={position} yaw={yaw} compact />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import { classById, isRomClassId, raceById } from "../app/characterCatalog";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import {
|
||||
@@ -115,6 +115,8 @@ function MemberFrame({ member, selected, onSelect }: {
|
||||
}) {
|
||||
const characterClass = classById(member.classId);
|
||||
const race = raceById(member.raceId);
|
||||
const isRom = isRomClassId(member.classId);
|
||||
const classLabel = `${characterClass.name}${member.secondaryClassId ? `/${classById(member.secondaryClassId).name}` : ""}`;
|
||||
const percent = healthPercent(member.health, member.maxHealth);
|
||||
const absorb = useCombatStore((state) => state.auras.reduce((total, aura) => (
|
||||
aura.targetId === member.id
|
||||
@@ -138,11 +140,11 @@ function MemberFrame({ member, selected, onSelect }: {
|
||||
className={`party-frame ${selected ? "is-selected" : ""} ${dispellableCount ? "is-dispellable" : ""} ${member.health <= 0 ? "is-down" : ""}`}
|
||||
onClick={onSelect}
|
||||
aria-pressed={selected}
|
||||
aria-label={`Target ${member.name}, ${member.gender} ${race.name}, ${member.specialization} ${characterClass.name}`}
|
||||
aria-label={isRom ? `Target ${member.name}, ${classLabel}` : `Target ${member.name}, ${member.gender} ${race.name}, ${member.specialization} ${characterClass.name}`}
|
||||
>
|
||||
<span className="party-frame__sigil" style={{ "--party-class-color": characterClass.color } as CSSProperties}>{characterClass.sigil}</span>
|
||||
<span className="party-frame__body">
|
||||
<span className="party-frame__identity"><strong>{member.name}</strong><small>{PARTY_ROLE_LABELS[member.role]} · {race.name} · {member.specialization}</small></span>
|
||||
<span className="party-frame__identity"><strong>{member.name}</strong><small style={isRom ? { textTransform: "none" } : undefined}>{isRom ? classLabel : `${PARTY_ROLE_LABELS[member.role]} · ${race.name} · ${member.specialization}`}</small></span>
|
||||
{dispellableCount ? <span className="party-frame__dispel" title={dispellableNames}>DISPEL {dispellableCount}</span> : null}
|
||||
<span className="party-frame__health" role="progressbar" aria-label={`${member.name} health`} aria-valuemin={0} aria-valuemax={member.maxHealth} aria-valuenow={Math.max(0, member.health)}>
|
||||
<i style={{ width: `${percent}%` }} />
|
||||
|
||||
@@ -40,6 +40,7 @@ function PauseDialog() {
|
||||
{ id: "pause-resume", run: resume },
|
||||
{ id: "pause-party", run: () => openOverlay("party") },
|
||||
{ id: "pause-inventory", run: () => openOverlay("inventory") },
|
||||
{ id: "pause-boss-cards", run: () => openOverlay("boss-cards") },
|
||||
{ id: "pause-map", run: () => openOverlay("map") },
|
||||
{ id: "pause-boss-guide", run: () => openOverlay("boss-guide") },
|
||||
{ id: "pause-spellbook", run: () => openOverlay("spellbook") },
|
||||
@@ -78,6 +79,7 @@ function PauseDialog() {
|
||||
{menuButton("pause-resume", "Resume expedition", resume, true)}
|
||||
{menuButton("pause-party", "Party management", () => openOverlay("party"))}
|
||||
{menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))}
|
||||
{menuButton("pause-boss-cards", "Boss card collection", () => openOverlay("boss-cards"))}
|
||||
{menuButton("pause-map", "Dungeon map", () => openOverlay("map"))}
|
||||
{menuButton("pause-boss-guide", "Boss guide", () => openOverlay("boss-guide"))}
|
||||
{menuButton("pause-spellbook", "Spellbook", () => openOverlay("spellbook"))}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
.boss-collection-backdrop { z-index: 90; padding: 20px; }
|
||||
.boss-collection {
|
||||
--card-gold: #cbb37b;
|
||||
display: flex; flex-direction: column; width: min(1080px, 100%); height: min(900px, 95vh);
|
||||
min-height: 0; padding: 24px; color: #eee8d8; background: linear-gradient(145deg, #24252c, #11161e);
|
||||
border: 1px solid #716044; border-radius: 12px; box-shadow: 0 24px 90px #000b; pointer-events: auto;
|
||||
}
|
||||
.boss-collection__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.boss-collection__header h2 { margin: 3px 0 14px; font-family: Georgia, serif; font-size: 32px; color: #ead8af; }
|
||||
.boss-collection__header .eyebrow { margin: 0; color: var(--card-gold); }
|
||||
.boss-collection__summary { display: flex; flex-wrap: wrap; gap: 8px 24px; font-size: 14px; }
|
||||
.boss-collection__summary p { flex-basis: 100%; color: #bcbcbf; margin: 2px 0 14px; font-size: 12px; }
|
||||
.boss-collection__tools { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; margin-bottom: 12px; }
|
||||
.boss-collection__tools [role="group"] { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.boss-collection button { padding: 9px 12px; border-radius: 5px; border: 1px solid #5e594e; background: #292e38; color: #eee8d8; cursor: pointer; }
|
||||
.boss-collection button[aria-pressed="true"] { color: #fff1cb; background: #55472d; border-color: var(--card-gold); }
|
||||
.boss-collection button:disabled { opacity: .4; cursor: default; }
|
||||
.boss-collection button:focus-visible, .boss-collection .is-controller-selected { outline: 2px solid var(--card-gold); outline-offset: 2px; }
|
||||
.boss-collection__tools label { display: grid; gap: 4px; color: #bcbcbf; font-size: 11px; }
|
||||
.boss-collection__tools input, .boss-collection__tools select { padding: 9px 10px; border: 1px solid #5e594e; border-radius: 5px; background: #171c25; color: #eee8d8; min-width: 0; }
|
||||
.boss-collection__search { flex: 1 1 180px; }
|
||||
.boss-collection__pages { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-bottom: 12px; font-size: 12px; color: #c7c6c3; }
|
||||
.boss-collection__scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 3px 5px 10px 3px; }
|
||||
.boss-collection__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||
.boss-collection__entry { display: contents; }
|
||||
.boss-collection__group-title { grid-column: 1 / -1; margin: 5px 0 0; font-family: Georgia, serif; color: #ead8af; font-size: 20px; }
|
||||
.boss-card { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--card-color); border-radius: 8px; background: radial-gradient(ellipse at 50% 30%, #3c404d, #191c25 75%); overflow: hidden; }
|
||||
.boss-card--unearned { border-color: color-mix(in srgb, var(--card-color) 50%, #333); }
|
||||
.boss-card header, .boss-card footer { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 10px 12px; background: #10141b99; }
|
||||
.boss-card header { color: var(--card-color); font-size: 13px; }
|
||||
.boss-card header small { font-size: 11px; }
|
||||
.boss-card__portrait, .boss-card__model-status { height: 140px; flex-shrink: 0; }
|
||||
.boss-card__model-status { display: grid; place-items: center; padding: 20px; text-align: center; font-size: 12px; color: #c4c4c9; }
|
||||
.boss-card__details { padding: 10px 12px; flex: 1; }
|
||||
.boss-card__details h3 { margin: 0 0 5px; color: #f3eedd; font-family: Georgia, serif; font-size: 17px; line-height: 1.25; }
|
||||
.boss-card__details > small { color: #939dac; font-size: 10px; }
|
||||
.boss-card__dungeons { color: #c8c4b8; font-size: 11px; line-height: 1.4; margin: 7px 0 0; }
|
||||
.boss-card footer { font-size: 11px; }
|
||||
.boss-card footer strong { color: var(--card-color); font-size: 13px; }
|
||||
.boss-card footer span { color: #9daba4; }
|
||||
.boss-collection__empty { padding: 70px 20px; text-align: center; color: #c8c4b8; }
|
||||
.boss-loot-window__card { --item-quality: var(--card-color); }
|
||||
@media (max-width: 720px) {
|
||||
.boss-collection-backdrop { padding: 8px; }
|
||||
.boss-collection { padding: 14px; height: 95dvh; }
|
||||
.boss-collection__header h2 { font-size: 26px; }
|
||||
.boss-collection__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.boss-collection button { min-height: 40px; padding: 8px 10px; font-size: 12px; }
|
||||
.boss-card__portrait, .boss-card__model-status { height: 120px; }
|
||||
.boss-card header, .boss-card footer, .boss-card__details { padding: 8px; }
|
||||
.boss-card__details h3 { font-size: 15px; }
|
||||
.boss-collection__pages span { text-align: center; font-size: 10px; }
|
||||
}
|
||||
@@ -1,3 +1,18 @@
|
||||
.dungeon-difficulty {
|
||||
padding: 14px;
|
||||
margin: 16px 0;
|
||||
border: 1px solid var(--front-line, #61543e);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-solid, #171c20);
|
||||
}
|
||||
.dungeon-difficulty label { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.dungeon-difficulty label span { color: var(--front-gold, #e6c680); }
|
||||
.dungeon-difficulty__control { display: flex; align-items: center; gap: 12px; margin: 12px 0; }
|
||||
.dungeon-difficulty__control input { min-width: 0; width: 100%; accent-color: var(--front-gold, #e6c680); }
|
||||
.dungeon-difficulty__control button { min-width: 40px; min-height: 40px; font-size: 20px; }
|
||||
.dungeon-difficulty p { margin: 0 0 6px; }
|
||||
.dungeon-difficulty small { display: block; line-height: 1.5; color: var(--front-dim, #c0bbae); }
|
||||
|
||||
.dungeon-leaderboard, .dungeon-results {
|
||||
--run-gold: var(--front-gold, #d8ae57);
|
||||
--run-green: var(--front-green-bright, #c8e28d);
|
||||
|
||||
@@ -3,13 +3,13 @@ import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeade
|
||||
type LeaderboardScope } from "../app/dungeonLeaderboardClient";
|
||||
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
|
||||
|
||||
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter) {
|
||||
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter, bossDifficulty = 1) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({
|
||||
data: { entries: [], total: 0 }, scope: "world", loading: true,
|
||||
});
|
||||
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter]);
|
||||
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter, bossDifficulty]);
|
||||
useEffect(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []);
|
||||
useEffect(() => {
|
||||
const refresh = () => setRevision((value) => value + 1);
|
||||
@@ -21,12 +21,12 @@ export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, f
|
||||
let live = true;
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
setState({ data: { entries: [], total: 0 }, scope: "world", loading: true });
|
||||
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal)
|
||||
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal, bossDifficulty)
|
||||
.then((data) => { if (live) setState({ data, scope: "world", loading: false }); })
|
||||
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset),
|
||||
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset, bossDifficulty),
|
||||
scope: "device", loading: false }); });
|
||||
else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false });
|
||||
return () => { live = false; controller.abort(); clearTimeout(timeout); };
|
||||
}, [dungeonId, difficultyId, filter, offset, revision]);
|
||||
}, [dungeonId, difficultyId, filter, offset, revision, bossDifficulty]);
|
||||
return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) };
|
||||
}
|
||||
Reference in new issue
Block a user