Release Healer Man 0.1.39
This commit is contained in:
1 parent
b417b7c3c3
commit
ed0d2fea3d
51 files changed
+17811
-49
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import { DUNGEON_LEADERBOARD_PAGE_SIZE, type LeaderboardScope } from "../app/dungeonLeaderboardClient";
|
||||
import { formatDungeonDuration, type DungeonClearFilter, type DungeonLeaderboard as LeaderboardData } from "../game/dungeonRunTypes";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { DungeonRunDetails } from "./DungeonRunDetails";
|
||||
import "./dungeonRuns.css";
|
||||
|
||||
export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter, onFilter,
|
||||
expanded, onExpand, offset, onPage, onRefresh, difficultyLabel, onDifficulty,
|
||||
selectedId, select }: {
|
||||
readonly dungeonTitle: string;
|
||||
readonly data: LeaderboardData;
|
||||
readonly scope: LeaderboardScope;
|
||||
readonly loading: boolean;
|
||||
readonly filter: DungeonClearFilter;
|
||||
readonly onFilter: (filter: DungeonClearFilter) => void;
|
||||
readonly expanded: ReadonlySet<string>;
|
||||
readonly onExpand: (id: string) => void;
|
||||
readonly offset: number;
|
||||
readonly onPage: (offset: number) => void;
|
||||
readonly onRefresh: () => void;
|
||||
readonly difficultyLabel: string;
|
||||
readonly onDifficulty?: () => void;
|
||||
readonly selectedId: string;
|
||||
readonly select: (id: string) => void;
|
||||
}) {
|
||||
const controls = { selectedId, select };
|
||||
return <section className="dungeon-leaderboard" aria-label={`${dungeonTitle} leaderboard`} aria-busy={loading}>
|
||||
<header>
|
||||
<p className="front-kicker">{scope === "world" ? "World leaderboard" : "This device · offline leaderboard"}</p>
|
||||
<h3>Fastest expeditions</h3>
|
||||
<div className="dungeon-leaderboard__filters" aria-label="Clear type">
|
||||
<ControllerButton {...controls} controlId="leaderboard-final" aria-pressed={filter === "final"}
|
||||
onClick={() => onFilter("final")}>Final Boss clears <small>All entries</small></ControllerButton>
|
||||
<ControllerButton {...controls} controlId="leaderboard-full" aria-pressed={filter === "full"}
|
||||
onClick={() => onFilter("full")}>Full dungeon clears <small>All bosses</small></ControllerButton>
|
||||
</div>
|
||||
<div className="dungeon-leaderboard__meta">
|
||||
{onDifficulty ? <ControllerButton {...controls} controlId="leaderboard-difficulty" onClick={onDifficulty}
|
||||
aria-label={`Leaderboard difficulty: ${difficultyLabel}. Change difficulty`}>{difficultyLabel} ↻</ControllerButton>
|
||||
: <span>{difficultyLabel}</span>}
|
||||
<span>{data.total} {data.total === 1 ? "run" : "runs"}</span>
|
||||
<ControllerButton {...controls} controlId="leaderboard-refresh" onClick={onRefresh}>Refresh</ControllerButton>
|
||||
</div>
|
||||
</header>
|
||||
<div className="dungeon-leaderboard__entries">
|
||||
{loading ? <p className="dungeon-leaderboard__empty" role="status">Loading run times…</p>
|
||||
: data.entries.length === 0 ? <p className="dungeon-leaderboard__empty">No {filter === "full" ? "full dungeon" : "final boss"} clears yet. Complete a run to set the first time.</p>
|
||||
: <ol>
|
||||
{data.entries.map((entry) => <li key={entry.id}>
|
||||
<ControllerButton {...controls} controlId={`leaderboard-run-${entry.id}`}
|
||||
className="dungeon-leaderboard__run" aria-expanded={expanded.has(entry.id)}
|
||||
aria-controls={`run-details-${entry.id}`} onClick={() => onExpand(entry.id)}>
|
||||
<b>#{entry.rank}</b><span><strong>{entry.party.find((member) => !member.npc)?.name ?? entry.party[0]?.name ?? "Party"}</strong>
|
||||
<small>{entry.fullClear ? "All bosses" : `${entry.killedBossIds.length} bosses`} · {new Date(entry.finishedAt).toLocaleDateString()}</small></span>
|
||||
<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}
|
||||
</li>)}
|
||||
</ol>}
|
||||
</div>
|
||||
{data.total > DUNGEON_LEADERBOARD_PAGE_SIZE ? <nav className="dungeon-leaderboard__pages" aria-label="Leaderboard pages">
|
||||
<ControllerButton {...controls} controlId="leaderboard-prev" disabled={offset === 0 || loading}
|
||||
onClick={() => onPage(Math.max(0, offset - DUNGEON_LEADERBOARD_PAGE_SIZE))}>Previous</ControllerButton>
|
||||
<span>{offset + 1}–{Math.min(offset + DUNGEON_LEADERBOARD_PAGE_SIZE, data.total)} of {data.total}</span>
|
||||
<ControllerButton {...controls} controlId="leaderboard-next" disabled={offset + DUNGEON_LEADERBOARD_PAGE_SIZE >= data.total || loading}
|
||||
onClick={() => onPage(offset + DUNGEON_LEADERBOARD_PAGE_SIZE)}>Next</ControllerButton>
|
||||
</nav> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
import type { DungeonRunMember } from "../game/dungeonRunTypes";
|
||||
|
||||
const className = (id: string) => catalog.classes.find((entry) => entry.id === id)?.name ?? id;
|
||||
|
||||
export function DungeonRunDetails({ dungeonId, difficultyId, party, killedBossIds }: {
|
||||
readonly dungeonId: string;
|
||||
readonly difficultyId: string;
|
||||
readonly party: readonly DungeonRunMember[];
|
||||
readonly killedBossIds: readonly string[];
|
||||
}) {
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
|
||||
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">
|
||||
<h4>Party</h4>
|
||||
<ul className="dungeon-run-party">
|
||||
{party.map((member, index) => <li key={`${member.name}-${index}`}>
|
||||
<strong>{member.name}{member.npc ? <small> NPC</small> : null}</strong>
|
||||
<span>Level {member.level} · {className(member.classId)}{member.secondaryClassId ? ` / ${className(member.secondaryClassId)}` : ""}</span>
|
||||
<small>{member.role === "damage" ? "Damage" : member.role === "healer" ? "Healer" : "Tank"}</small>
|
||||
</li>)}
|
||||
</ul>
|
||||
<h4>Bosses defeated · {killedBossIds.length}/{bosses.length}</h4>
|
||||
<ul className="dungeon-run-bosses">
|
||||
{bosses.map((boss) => <li key={boss.id} className={killedBossIds.includes(boss.id) ? "is-defeated" : ""}>
|
||||
<span aria-hidden="true">{killedBossIds.includes(boss.id) ? "✓" : "—"}</span>
|
||||
{boss.name}<small>{killedBossIds.includes(boss.id) ? "Defeated" : "Not defeated"}</small>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { awaitSharedDungeonRunPlacement, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun, type LeaderboardScope } from "../app/dungeonLeaderboardClient";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { completedDungeonRun, useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { formatDungeonDuration, type DungeonRunPlacement, type DungeonRunProgress } from "../game/dungeonRunTypes";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { DungeonRunDetails } from "./DungeonRunDetails";
|
||||
import "./dungeonRuns.css";
|
||||
|
||||
export interface RunPlacementState {
|
||||
readonly scope: LeaderboardScope;
|
||||
readonly placement: DungeonRunPlacement | null;
|
||||
readonly loading: boolean;
|
||||
}
|
||||
|
||||
export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry }: {
|
||||
readonly progress: DungeonRunProgress;
|
||||
readonly ranking: RunPlacementState;
|
||||
readonly onFinish: () => void;
|
||||
readonly onLoot: () => void;
|
||||
readonly onRetry: () => void;
|
||||
}) {
|
||||
const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId);
|
||||
const fullClear = definition?.difficulties.find((entry) => entry.id === progress.difficultyId)?.bossIds
|
||||
.every((id) => progress.killedBossIds.includes(id)) ?? false;
|
||||
const controller = useMenuController([
|
||||
{ id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish },
|
||||
...(ranking.scope === "device" ? [{ id: "run-retry", run: onRetry }] : []),
|
||||
], { initialId: "run-loot", onBack: onLoot });
|
||||
const controls = { selectedId: controller.selectedId, select: controller.select };
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement;
|
||||
panel.current?.focus();
|
||||
return () => { if (previous instanceof HTMLElement && previous.isConnected) previous.focus(); };
|
||||
}, []);
|
||||
return <div className="modal-backdrop dungeon-results-backdrop">
|
||||
<section ref={panel} tabIndex={-1} className="dungeon-results" role="dialog" aria-modal="true"
|
||||
aria-labelledby="dungeon-results-title" onKeyDown={(event) => {
|
||||
if (event.key !== "Tab") return;
|
||||
const buttons = Array.from(panel.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? []);
|
||||
const first = buttons[0]; const last = buttons.at(-1);
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) {
|
||||
event.preventDefault(); last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||
}}>
|
||||
<header><p className="front-kicker">{fullClear ? "Full dungeon clear" : "Final boss clear"}</p>
|
||||
<h2 id="dungeon-results-title">{definition?.title ?? progress.dungeonId} complete</h2>
|
||||
<p>The final boss is defeated. Your expedition is complete.</p>
|
||||
</header>
|
||||
<div className="dungeon-results__score">
|
||||
<div><small>Time to final boss</small><strong>{progress.timed && progress.durationMs !== null
|
||||
? formatDungeonDuration(progress.durationMs) : "Untimed"}</strong></div>
|
||||
<div aria-live="polite"><small>{ranking.scope === "world" ? "World leaderboard" : "This device leaderboard"}</small>
|
||||
<strong>{ranking.loading ? "Recording…" : ranking.placement ? `#${ranking.placement.finalRank}` : "Unranked"}</strong>
|
||||
{ranking.placement ? <span>of {ranking.placement.finalTotal} final boss clears</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{ranking.placement?.fullRank ? <p className="dungeon-results__full-rank">Full dungeon clears: <strong>#{ranking.placement.fullRank}</strong> of {ranking.placement.fullTotal}</p> : null}
|
||||
{!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>
|
||||
<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>
|
||||
{ranking.scope === "device" ? <ControllerButton {...controls} controlId="run-retry" className="button" onClick={onRetry}>Retry submission</ControllerButton> : null}
|
||||
</footer>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function DungeonRunResults() {
|
||||
const progress = useDungeonRunStore((state) => state.progress);
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const record = useMemo(() => progress ? completedDungeonRun(progress) : null, [progress]);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [ranking, setRanking] = useState<RunPlacementState>({ scope: "world", placement: null, loading: false });
|
||||
useEffect(() => {
|
||||
if (!record) { setRanking({ scope: "world", placement: null, loading: false }); return; }
|
||||
let live = true;
|
||||
saveLocalDungeonRun(record);
|
||||
setRanking({ scope: "world", placement: null, loading: true });
|
||||
const online = useOnlineSessionStore.getState().snapshot;
|
||||
const submission = online?.activity.id === record.id && !online.authority
|
||||
? awaitSharedDungeonRunPlacement(record.id) : submitDungeonRun(record, useShellStore.getState().session);
|
||||
void submission
|
||||
.then((placement) => { if (live) setRanking({ scope: "world", placement, loading: false }); })
|
||||
.catch(() => { if (live) setRanking({ scope: "device", placement: localDungeonRunPlacement(record), loading: false }); });
|
||||
return () => { live = false; };
|
||||
}, [record, retry]);
|
||||
if (overlay !== "dungeon-results" || !progress || progress.finishedAt === null) return null;
|
||||
return <DungeonRunResultsDialog progress={progress} ranking={ranking} onRetry={() => setRetry((value) => value + 1)}
|
||||
onLoot={() => useGameStore.getState().closeOverlay()} onFinish={() => {
|
||||
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
|
||||
useGameStore.getState().closeOverlay();
|
||||
useShellStore.getState().returnToMainMenu();
|
||||
}} />;
|
||||
}
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||
import { DungeonLeaderboard } from "./DungeonLeaderboard";
|
||||
import { useDungeonLeaderboard } from "./useDungeonLeaderboard";
|
||||
import type { DungeonClearFilter } from "../game/dungeonRunTypes";
|
||||
import { DUNGEON_LEADERBOARD_PAGE_SIZE } from "../app/dungeonLeaderboardClient";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
@@ -77,6 +80,20 @@ export function DungeonSelectScreen() {
|
||||
?? firstCategoryDungeon
|
||||
?? null;
|
||||
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null;
|
||||
const [clearFilter, setClearFilter] = useState<DungeonClearFilter>("final");
|
||||
const [difficultyId, setDifficultyId] = useState("");
|
||||
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 toggleRun = (id: string) => setExpandedRuns((current) => {
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
const cycleDifficulty = () => {
|
||||
const variants = selectedDefinition?.difficultyVariants ?? [];
|
||||
const index = variants.findIndex((variant) => variant.id === leaderboardDifficulty?.id);
|
||||
if (variants.length) setDifficultyId(variants[(index + 1) % variants.length].id);
|
||||
};
|
||||
const selectedPackInstalled = selectedDungeon ? dungeonContentIsUsable(selectedDungeon.id) : false;
|
||||
const canStart = Boolean(selectedDungeon?.available && selectedPackInstalled);
|
||||
|
||||
@@ -176,8 +193,17 @@ export function DungeonSelectScreen() {
|
||||
id: `dungeon-${dungeon.id}`,
|
||||
run: () => selectDungeon(dungeon.id),
|
||||
enabled: true,
|
||||
neighbors: index === 0 ? { up: `dungeon-category-${selectedCategory}` } : undefined,
|
||||
neighbors: { ...(index === 0 ? { up: `dungeon-category-${selectedCategory}` } : {}), right: "leaderboard-final" },
|
||||
})),
|
||||
{ id: "leaderboard-final", run: () => setClearFilter("final") },
|
||||
{ id: "leaderboard-full", run: () => setClearFilter("full") },
|
||||
{ id: "leaderboard-difficulty", run: cycleDifficulty, enabled: (selectedDefinition?.difficultyVariants.length ?? 0) > 1 },
|
||||
{ id: "leaderboard-refresh", run: leaderboard.refresh },
|
||||
...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 },
|
||||
{ id: "leaderboard-next", run: () => leaderboard.setOffset(leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE),
|
||||
enabled: !leaderboard.loading && leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE < leaderboard.data.total },
|
||||
...(rolePickerOpen
|
||||
? availableRoles.map((role) => ({
|
||||
id: `dungeon-role-${role}`,
|
||||
@@ -193,7 +219,7 @@ export function DungeonSelectScreen() {
|
||||
run: () => setRolePickerOpen(false),
|
||||
}] : []),
|
||||
{ id: "dungeon-back", run: returnToMainMenu },
|
||||
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon]);
|
||||
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, expandedRuns, leaderboardDifficulty]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
|
||||
onBack: pendingDownload
|
||||
@@ -289,17 +315,12 @@ export function DungeonSelectScreen() {
|
||||
<main className="dungeon-select-detail" aria-live="polite">
|
||||
{selectedDungeon ? (
|
||||
<>
|
||||
<div className="dungeon-select-map">
|
||||
{selectedDefinition ? (
|
||||
<DungeonMapGraphic
|
||||
dungeonId={selectedDefinition.id}
|
||||
position={selectedDefinition.entrance.footPosition}
|
||||
yaw={selectedDefinition.entrance.yaw}
|
||||
/>
|
||||
) : (
|
||||
<div className="dungeon-select-map__placeholder" aria-hidden="true">HM</div>
|
||||
)}
|
||||
</div>
|
||||
<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"}
|
||||
onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined}
|
||||
selectedId={controller.selectedId} select={controller.select} />
|
||||
<section className="dungeon-select-copy">
|
||||
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
|
||||
<h2>{selectedDungeon.title}</h2>
|
||||
|
||||
+18
-5
@@ -37,6 +37,8 @@ import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||
import { BossLootWindow } from "./BossLootWindow";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeter } from "./DpsMeter";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { formatDungeonDuration } from "../game/dungeonRunTypes";
|
||||
import { DesktopActionBars } from "./DesktopActionBars";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { SummonFrames } from "./SummonFrames";
|
||||
@@ -140,20 +142,28 @@ export function DefeatedPrompt({
|
||||
export function DungeonCompletePrompt({
|
||||
dungeonTitle,
|
||||
onFinish,
|
||||
onResults,
|
||||
durationMs,
|
||||
finalBossOnly = false,
|
||||
}: {
|
||||
readonly dungeonTitle: string;
|
||||
readonly onFinish: () => void;
|
||||
readonly onResults?: () => void;
|
||||
readonly durationMs?: number | null;
|
||||
readonly finalBossOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="dungeon-complete-banner" role="status" aria-live="polite">
|
||||
<span aria-hidden="true">◆</span>
|
||||
<p>
|
||||
<strong>{dungeonTitle} complete</strong>
|
||||
<small>Every required encounter is defeated. Loot the room or finish the run.</small>
|
||||
<small>{finalBossOnly ? "Final boss defeated." : "Every required encounter is defeated."} Loot the room or finish the run.</small>
|
||||
{durationMs != null ? <small>Time to final boss: {formatDungeonDuration(durationMs)}</small> : null}
|
||||
</p>
|
||||
<button type="button" className="button button--primary" onClick={onFinish}>
|
||||
Finish dungeon
|
||||
</button>
|
||||
{onResults ? <button type="button" className="button" onClick={onResults}>Run results</button> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -218,8 +228,9 @@ export function Hud() {
|
||||
activeDungeonEncounterRevision,
|
||||
activeDungeonEncounterRevision,
|
||||
);
|
||||
const dungeonComplete = gameMode === "dungeon"
|
||||
&& activeDungeonRunIsComplete(activeDungeonId);
|
||||
const runProgress = useDungeonRunStore((state) => state.progress);
|
||||
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
|
||||
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId));
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setClock(Date.now()), 100);
|
||||
@@ -384,8 +395,10 @@ export function Hud() {
|
||||
<ShelvedHud />
|
||||
<DpsMeter />
|
||||
<BossLootWindow />
|
||||
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
|
||||
<EditableHudElement id="completion">{dungeonComplete ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon}
|
||||
finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
|
||||
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
|
||||
) : null}</EditableHudElement>
|
||||
<EditableHudElement id="loot">{feedback?.kind === "loot" ? (
|
||||
<section className="loot-reward" role="status" aria-live="polite">
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
.dungeon-leaderboard, .dungeon-results {
|
||||
--run-gold: var(--front-gold, #d8ae57);
|
||||
--run-green: var(--front-green-bright, #c8e28d);
|
||||
--run-line: var(--front-line, rgba(204, 224, 180, .18));
|
||||
--run-muted: #b1b7a3;
|
||||
color: #f0ecdc;
|
||||
}
|
||||
.dungeon-leaderboard {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--run-line);
|
||||
border-radius: 13px 4px 13px 4px;
|
||||
background: rgba(4, 16, 11, .9);
|
||||
}
|
||||
.dungeon-leaderboard > header { padding: 15px 12px 10px; border-bottom: 1px solid var(--run-line); }
|
||||
.dungeon-leaderboard h3 { margin: 6px 0 14px; color: var(--run-gold); font: 24px Georgia, serif; }
|
||||
.dungeon-leaderboard__filters { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.dungeon-leaderboard button { color: inherit; cursor: pointer; }
|
||||
.dungeon-leaderboard__filters button {
|
||||
min-height: 48px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--run-line);
|
||||
border-radius: 5px;
|
||||
background: #0c1a12;
|
||||
font-size: 11px;
|
||||
}
|
||||
.dungeon-leaderboard__filters button[aria-pressed="true"] { border-color: var(--run-gold); background: #27301a; color: #f2dfaa; }
|
||||
.dungeon-leaderboard__filters small { display: block; margin-top: 3px; color: var(--run-muted); font-size: 9px; }
|
||||
.dungeon-leaderboard__meta { display: flex; margin-top: 10px; align-items: center; justify-content: space-between; gap: 7px; color: var(--run-muted); font-size: 10px; }
|
||||
.dungeon-leaderboard__meta button { padding: 5px 2px; border: 0; background: transparent; color: var(--run-green); font-size: 10px; }
|
||||
.dungeon-leaderboard__entries { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
|
||||
.dungeon-leaderboard__entries ol { margin: 0; padding: 0; list-style: none; }
|
||||
.dungeon-leaderboard__entries > ol > li { border-bottom: 1px solid var(--run-line); }
|
||||
.dungeon-leaderboard__run { display: grid; width: 100%; min-height: 63px; grid-template-columns: 31px minmax(0, 1fr) auto 10px; gap: 7px; padding: 10px; align-items: center; border: 0; background: transparent; text-align: left; }
|
||||
.dungeon-leaderboard__run:hover, .dungeon-leaderboard__run[aria-expanded="true"] { background: rgba(143, 181, 92, .09); }
|
||||
.dungeon-leaderboard__run > b { color: var(--run-gold); font: 16px Georgia, serif; }
|
||||
.dungeon-leaderboard__run > span { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.dungeon-leaderboard__run strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dungeon-leaderboard__run small { color: var(--run-muted); font-size: 9px; }
|
||||
.dungeon-leaderboard__run time { color: var(--run-green); font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||
.dungeon-leaderboard__run em { color: var(--run-gold); font-size: 16px; font-style: normal; }
|
||||
.dungeon-leaderboard__empty { padding: 22px 15px; color: var(--run-muted); font-size: 12px; line-height: 1.7; }
|
||||
.dungeon-leaderboard__pages { display: flex; padding: 8px; align-items: center; justify-content: space-between; gap: 5px; border-top: 1px solid var(--run-line); font-size: 9px; }
|
||||
.dungeon-leaderboard__pages button { padding: 7px; border: 1px solid var(--run-line); border-radius: 4px; background: #122019; font-size: 10px; }
|
||||
.dungeon-leaderboard button:disabled { opacity: .4; cursor: default; }
|
||||
.dungeon-run-details { padding: 8px 12px 12px; }
|
||||
.dungeon-run-details h4 { margin: 9px 0; color: var(--run-gold); font-size: 11px; font-weight: 500; }
|
||||
.dungeon-run-details ul { margin: 0; padding: 0; list-style: none; }
|
||||
.dungeon-run-party li { display: flex; padding: 6px 0; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 9px; border-bottom: 1px solid rgba(204, 224, 180, .07); }
|
||||
.dungeon-run-party strong { font-size: 11px; }
|
||||
.dungeon-run-party strong small { color: var(--run-muted); font-size: 8px; font-weight: 400; }
|
||||
.dungeon-run-party span { width: 100%; order: 3; color: #d6dcc9; font-size: 10px; }
|
||||
.dungeon-run-party li > small { color: var(--run-muted); font-size: 9px; }
|
||||
.dungeon-run-bosses li { display: flex; padding: 5px 0; align-items: center; gap: 7px; color: var(--run-muted); font-size: 10px; }
|
||||
.dungeon-run-bosses li.is-defeated { color: var(--run-green); }
|
||||
.dungeon-run-bosses small { margin-left: auto; color: var(--run-muted); font-size: 8px; }
|
||||
.dungeon-results-backdrop { z-index: 110; padding: 16px; pointer-events: auto; }
|
||||
.dungeon-results {
|
||||
display: flex;
|
||||
width: min(580px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
flex-direction: column;
|
||||
padding: 23px;
|
||||
border: 1px solid var(--run-gold);
|
||||
border-radius: 12px;
|
||||
outline: 0;
|
||||
background: linear-gradient(145deg, #172319, #07100d);
|
||||
box-shadow: 0 20px 65px #0009;
|
||||
}
|
||||
.dungeon-results > header { flex: 0 0 auto; }
|
||||
.dungeon-results h2 { margin: 7px 0; color: #f3deb0; font: 29px Georgia, serif; }
|
||||
.dungeon-results header > p:last-child { margin: 9px 0 16px; color: var(--run-muted); font-size: 12px; }
|
||||
.dungeon-results__score { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; flex: 0 0 auto; }
|
||||
.dungeon-results__score > div { display: flex; padding: 14px; flex-direction: column; gap: 6px; border: 1px solid var(--run-line); border-radius: 6px; background: #ffffff04; }
|
||||
.dungeon-results__score small { color: var(--run-muted); font-size: 11px; }
|
||||
.dungeon-results__score strong { color: var(--run-green); font: 31px Georgia, serif; font-variant-numeric: tabular-nums; }
|
||||
.dungeon-results__score span { color: var(--run-muted); font-size: 10px; }
|
||||
.dungeon-results__full-rank { margin: 12px 0 0; color: var(--run-gold); font-size: 12px; }
|
||||
.dungeon-results__note { margin: 12px 0 0; color: var(--run-muted); font-size: 11px; line-height: 1.5; }
|
||||
.dungeon-results__details { min-height: 40px; margin-top: 13px; overflow: auto; overscroll-behavior: contain; border-block: 1px solid var(--run-line); }
|
||||
.dungeon-results__details .dungeon-run-details { padding-inline: 0; }
|
||||
.dungeon-results > footer { display: flex; margin-top: 16px; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
|
||||
.dungeon-results > footer button { min-height: 38px; flex: 1; white-space: nowrap; }
|
||||
@media (max-width: 960px) {
|
||||
.dungeon-select-detail { grid-template-rows: minmax(145px, 1fr) minmax(120px, .8fr); gap: 9px; }
|
||||
.dungeon-leaderboard > header { padding: 9px; }
|
||||
.dungeon-leaderboard h3 { margin: 4px 0 8px; font-size: 19px; }
|
||||
.dungeon-leaderboard__filters button { min-height: 39px; font-size: 10px; }
|
||||
.dungeon-leaderboard__meta { margin-top: 5px; }
|
||||
.dungeon-leaderboard__run { min-height: 52px; }
|
||||
}
|
||||
@media (max-height: 520px) {
|
||||
.dungeon-results { padding: 13px; }
|
||||
.dungeon-results h2 { font-size: 23px; }
|
||||
.dungeon-results header > p:last-child { margin-bottom: 9px; }
|
||||
.dungeon-results__score > div { padding: 8px 12px; }
|
||||
.dungeon-results__score strong { font-size: 25px; }
|
||||
.dungeon-results > footer { margin-top: 9px; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DungeonLeaderboard } from "./DungeonLeaderboard";
|
||||
import { DungeonRunResultsDialog } from "./DungeonRunResults";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
|
||||
|
||||
const caves = catalog.dungeons.find((entry) => entry.id === "barren-caves")!;
|
||||
const progress: DungeonRunProgress = { id: "ui-caves-test", dungeonId: caves.id, difficultyId: "normal", timed: true,
|
||||
startedAt: 1000, finishedAt: 13345, durationMs: 12345, killedBossIds: [caves.finalBossId],
|
||||
party: [{ name: "Tester", classId: "rom-priest", level: 3, role: "healer", npc: false },
|
||||
{ name: "Garrick", classId: "warrior", level: 3, role: "tank", npc: true }] };
|
||||
const noop = () => undefined;
|
||||
describe("dungeon results and leaderboard", () => {
|
||||
it("announces the final boss, frozen time, both rankings, party levels, and explicit loot/menu choices", () => {
|
||||
const html = renderToStaticMarkup(createElement(DungeonRunResultsDialog, { progress,
|
||||
ranking: { scope: "world", loading: false, placement: { finalRank: 2, finalTotal: 10, fullRank: 1, fullTotal: 4 } },
|
||||
onFinish: noop, onLoot: noop, onRetry: noop }));
|
||||
for (const text of ["Barren Caves complete", "00:12.3", "#2", "#1", "Anglo", "Level 3", "Garrick", "Stay and loot", "Return to main menu"])
|
||||
expect(html).toContain(text);
|
||||
expect(html).toContain('aria-modal="true"');
|
||||
});
|
||||
it("labels disconnected rankings as device-only and offers submission retry", () => {
|
||||
const html = renderToStaticMarkup(createElement(DungeonRunResultsDialog, { progress,
|
||||
ranking: { scope: "device", loading: false, placement: { finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 } },
|
||||
onFinish: noop, onLoot: noop, onRetry: noop }));
|
||||
expect(html).toContain("This device leaderboard"); expect(html).toContain("Retry submission");
|
||||
});
|
||||
it("offers both clear filters and expandable party/boss records in the dungeon picker", () => {
|
||||
const html = renderToStaticMarkup(createElement(DungeonLeaderboard, { dungeonTitle: caves.title,
|
||||
data: { entries: [{ ...progress, durationMs: progress.durationMs!, finishedAt: progress.finishedAt!, fullClear: true, rank: 1 }], total: 1 },
|
||||
scope: "world", loading: false, filter: "full", onFilter: noop, expanded: new Set([progress.id]),
|
||||
onExpand: noop, offset: 0, onPage: noop, onRefresh: noop, difficultyLabel: "Normal", selectedId: "", select: noop }));
|
||||
for (const text of ["Final Boss clears", "All entries", "Full dungeon clears", "All bosses", "Anglo", "Level 3", "00:12.3"])
|
||||
expect(html).toContain(text);
|
||||
expect(html).toContain('aria-expanded="true"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeaderboard,
|
||||
type LeaderboardScope } from "../app/dungeonLeaderboardClient";
|
||||
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
|
||||
|
||||
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter) {
|
||||
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(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []);
|
||||
useEffect(() => {
|
||||
const refresh = () => setRevision((value) => value + 1);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => window.removeEventListener("focus", refresh);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
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)
|
||||
.then((data) => { if (live) setState({ data, scope: "world", loading: false }); })
|
||||
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset),
|
||||
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]);
|
||||
return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) };
|
||||
}
|
||||
Reference in new issue
Block a user