Release Healer Man 0.1.40

This commit is contained in:
phenom committed 2026-09-13 13:21:27 -04:00
1 parent ed0d2fea3d
commit 197a64117c
215 files changed
+5131 -181

No files matched your search

+21
View File
@@ -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);
});
});
+110
View File
@@ -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"} />;
}
+41
View File
@@ -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>;
}
+8 -4
View File
@@ -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>
);
+3 -1
View File
@@ -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>
+26
View File
@@ -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>;
}
+1 -1
View File
@@ -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>
+42
View File
@@ -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");
});
});
+54 -25
View File
@@ -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>
);
}
+4 -1
View File
@@ -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}`}>
+1 -1
View File
@@ -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>
+13 -4
View File
@@ -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>
)}
+2
View File
@@ -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 />
+13 -4
View File
@@ -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>
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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 />
+5 -3
View File
@@ -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}%` }} />
+2
View File
@@ -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"))}
+53
View File
@@ -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; }
}
+15
View File
@@ -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);
+5 -5
View File
@@ -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) };
}