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

+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"} />;
}