Update Healer Man server

This commit is contained in:
phenom committed 2026-09-25 18:46:09 -04:00
1 parent a868401ebc
commit 48a678dcc5
295 files changed
+7644 -467

No files matched your search

+3
View File
@@ -34,6 +34,7 @@ import { KeybindsPanel } from "./ui/KeybindsPanel";
import { lootShelvedBoss, releaseShelvedGameRuntime, ShelvedGameBridges } from "@shelvedGameRuntime";
import { useDungeonSimulationStore } from "./game/dungeonSimulationStore";
import { DungeonSimulationHud } from "./ui/DungeonSimulationHud";
import { DungeonBossIntro } from "./ui/DungeonBossIntro";
import { useDungeonRouteStore } from "./game/dungeonRouteStore";
import { installCombatClock } from "./game/combatClock";
import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
@@ -45,6 +46,7 @@ function InputBridge() {
useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|| useGameStore.getState().dungeonIntroActive
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences;
@@ -137,6 +139,7 @@ export default function GameRuntime() {
<ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
{dungeonSimulation ? <DungeonSimulationHud /> : <Hud />}
{!dungeonSimulation && <DungeonBossIntro />}
{!dungeonSimulation && <MapPanel />}
{!dungeonSimulation && <GameplayPanels />}
{!dungeonSimulation && <PauseMenu />}
+1
View File
@@ -185,6 +185,7 @@ export function CombatBridge() {
return;
}
const game = useGameStore.getState();
if (game.dungeonIntroActive) return;
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
advanceOneTick(delta, Date.now(), false);
+3 -3
View File
@@ -35,7 +35,7 @@ export function DungeonRunBridge() {
const advance = () => {
const game = useGameStore.getState();
const shell = useShellStore.getState();
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode || game.dungeonIntroActive
|| !["ready", "proxy"].includes(game.assetStatus)) return;
const run = useDungeonRunStore.getState();
if (run.progress?.dungeonId !== game.activeDungeonId) return;
@@ -44,9 +44,9 @@ export function DungeonRunBridge() {
if (activity?.type === "dungeon" && activity.contentId === game.activeDungeonId
&& run.progress.finishedAt === null && run.progress.id !== activity.id) {
useDungeonRunStore.setState({ progress: { ...run.progress, id: activity.id,
startedAt: activity.startedAt, party } });
startedAt: game.dungeonIntroCompletedAt ?? activity.startedAt, party } });
}
run.start(Date.now(), party);
run.start(game.dungeonIntroCompletedAt ?? Date.now(), party);
run.advance(activeDungeonCompletedObjectiveIds(game.activeDungeonId),
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
Date.now(), party);
+39
View File
@@ -328,6 +328,45 @@ describe("combat audio output policy", () => {
audio.resetCombatAudio();
});
it("plays separate RuneWaker source cues together on one impact", async () => {
vi.resetModules();
const audio = await import("./combatAudio");
const sources: Array<{ start: ReturnType<typeof vi.fn>; loop: boolean }> = [];
const audioContext = {
currentTime: 0, state: "running", destination: {},
createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: {
setTargetAtTime: vi.fn(), value: 1,
} }),
createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }),
createBufferSource: vi.fn(() => {
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
buffer: null, loop: false, onended: null };
sources.push(source);
return source;
}),
createOscillator: vi.fn(),
decodeAudioData: vi.fn(async () => ({ duration: 0.2 })),
resume: vi.fn(async () => undefined),
};
vi.stubGlobal("window", { AudioContext: function () { return audioContext; } });
combatMedia.ready = true;
combatMedia.entry.mockReturnValue({
key: "runewaker:491168", source: "runewaker", spellId: 491168,
sounds: [
{ url: "/assets/game/combat-effects/audio/hit.mp3", phase: "impact" },
{ url: "/assets/game/combat-effects/audio/dot.mp3", phase: "impact" },
],
});
combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1, 2, 3])));
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
audio.playCombatAudio({ ...fixtureEvent, source: "runewaker", sourceSpellId: 491168 });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sources).toHaveLength(2);
expect(sources.every((source) => source.start.mock.calls.length === 1 && !source.loop)).toBe(true);
expect(combatMedia.fetch).toHaveBeenCalledTimes(2);
audio.resetCombatAudio();
});
it("does not start a loop after cancellation and caps a cast loop at its duration", async () => {
vi.resetModules();
const audio = await import("./combatAudio");
+20 -23
View File
@@ -202,18 +202,21 @@ export function selectCombatSoundAssets(
: [];
}
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
function soundsForEvent(event: CombatPresentationEvent): readonly CombatEffectSoundAsset[] {
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return [];
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
const playable = selectCombatSoundAssets(sounds, event);
if (!playable.length) return null;
if (!playable.length) return [];
// RuneWaker ImageObject and ROS callbacks are independent client sound
// layers. Choosing one at random drops cast fades or companion impact cues.
if (event.source === "runewaker") return playable;
const total = playable.reduce((sum, sound) => sum + Math.max(1, sound.weight ?? 1), 0);
let cursor = (event.id * 0.61803398875 % 1) * total;
for (const sound of playable) {
cursor -= Math.max(1, sound.weight ?? 1);
if (cursor <= 0) return sound;
if (cursor <= 0) return [sound];
}
return playable[playable.length - 1] ?? null;
return playable.length ? [playable[playable.length - 1]] : [];
}
async function decodeAudio(url: string): Promise<AudioBuffer | null> {
@@ -393,24 +396,18 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const sound = weightedSound(event);
if (sound) {
const castLoop = event.phase === "cast-start" && sound.loopDuringCast
? beginCastLoop(event)
: undefined;
void playBufferedCombatCue(event, sound, castLoop)
.then((played) => {
if (!played && !castLoop?.cancelled) {
if (castLoop) stopCastLoop(castLoopKey(event));
playGeneratedCombatCue(event);
}
})
.catch(() => {
if (!castLoop?.cancelled) {
if (castLoop) stopCastLoop(castLoopKey(event));
playGeneratedCombatCue(event);
}
});
const sounds = soundsForEvent(event);
if (sounds.length) {
const loopingSound = event.phase === "cast-start"
? sounds.find((sound) => sound.loopDuringCast) : undefined;
const castLoop = loopingSound ? beginCastLoop(event) : undefined;
void Promise.all(sounds.map((sound) => playBufferedCombatCue(
event, sound, sound === loopingSound ? castLoop : undefined,
).catch(() => false))).then((played) => {
if (played.some(Boolean) || castLoop?.cancelled) return;
if (castLoop) stopCastLoop(castLoopKey(event));
playGeneratedCombatCue(event);
});
} else {
playGeneratedCombatCue(event);
}
+6 -2
View File
@@ -154,7 +154,7 @@ describe("combat presentation", () => {
}
});
it("selects only the verified RuneWaker sprite phases for live combat", () => {
it("selects source-linked RuneWaker sprite phases for live combat", () => {
const manifest = JSON.parse(readFileSync(
path.resolve("public/assets/game/combat-effects/manifest.json"),
"utf8",
@@ -168,7 +168,11 @@ describe("combat presentation", () => {
.toEqual(["model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros"]);
expect(originalCombatPhaseAssets(entry(491168), "release").map((asset) => asset.model))
.toEqual(["model/fx/skill/wind/act_electric_ball_f_i.ros"]);
expect(originalCombatPhaseAssets(entry(491168), "impact")).toEqual([]);
expect(originalCombatPhaseAssets(entry(491168), "impact").map((asset) => asset.model))
.toEqual([
"model/fx/skill/wind/act_electric_ball_t_freeze_hit_e.ros",
"model/fx/skill/wind/act_electric_dot_t_down_e.ros",
]);
});
it("keeps every source-backed instant WoW rank on a renderable phase", () => {
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { dungeonBossCards } from "./dungeonBossCards";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { dungeonBossesForDifficulty } from "./dungeonRunStore";
describe("dungeon boss card preview", () => {
it("shows each available boss and the account's owned rarities", () => {
const dungeon = requireDungeonDefinition("wailing-caverns");
const cards = dungeonBossCards(dungeon, dungeon.defaultDifficultyId, {
counts: { "wow:lady anacondra": { normal: 2, rare: 1 } },
lastClaims: {},
});
expect(cards.map((card) => card.name)).toContain("Lady Anacondra");
expect(cards.length).toBe(dungeonBossesForDifficulty(dungeon).length);
expect(cards.find((card) => card.name === "Lady Anacondra")).toMatchObject({
copies: 3,
ownedRarities: ["normal", "rare"],
});
expect(cards.every((card) => Boolean(card.model?.url))).toBe(true);
expect(cards.some((card) => card.copies === 0)).toBe(true);
});
it("uses the rolled boss identity when a seeded run reuses a boss slot", () => {
const dungeon = requireDungeonDefinition("wailing-caverns");
const rolled = { ...dungeon, bosses: [{ ...dungeon.bosses[0], name: "Lord Cobrahn" }] };
const cards = dungeonBossCards(rolled, rolled.defaultDifficultyId, {
counts: { "wow:lord cobrahn": { normal: 1 } },
lastClaims: {},
});
expect(cards).toMatchObject([{ key: "wow:lord cobrahn", name: "Lord Cobrahn", copies: 1 }]);
expect(cards[0]?.model?.url).toContain("lord-cobrahn");
});
});
+43
View File
@@ -0,0 +1,43 @@
import { bossCardByKey, bossCardForSource } from "./bossCardCatalog";
import { BOSS_CARD_RARITIES, type BossCardCollection, type BossCardRarity } from "./bossCards";
import { dungeonBossesForDifficulty } from "./dungeonRunStore";
import type { DungeonDefinition } from "./dungeonTypes";
import type { MobModelStyle } from "./mobPopulation";
export interface DungeonBossCard {
readonly key: string;
readonly name: string;
readonly ownedRarities: readonly BossCardRarity[];
readonly copies: number;
readonly model?: MobModelStyle;
}
/** The preview uses the prepared run definition, so seeded bosses match the actual dungeon. */
export function dungeonBossCards(
dungeon: DungeonDefinition,
difficultyId: string,
collection: BossCardCollection,
): readonly DungeonBossCard[] {
const seen = new Set<string>();
return dungeonBossesForDifficulty(dungeon, difficultyId).flatMap((boss) => {
const category = dungeon.expansion === "runewaker" ? "rom" : "wow";
const normalizedName = boss.name.trim().toLowerCase().replace(/\s+/g, " ");
const fallbackKey = `${category}:${normalizedName}`;
const sourceCard = bossCardForSource(boss.id, dungeon.id, boss.name);
// Seeded variants can reuse an objective id while replacing its boss identity.
const card = sourceCard?.name.trim().toLowerCase().replace(/\s+/g, " ") === normalizedName
? sourceCard : bossCardByKey(fallbackKey);
const spawn = dungeon.staticSpawns.find((entry) => entry.id === boss.id)
?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((entry) => entry.id === boss.id);
const activeEntity = spawn ? dungeon.entities[spawn.entityId] : undefined;
const activeModel = activeEntity?.name.trim().toLowerCase().replace(/\s+/g, " ") === normalizedName
? activeEntity.visual.model : undefined;
const key = card?.key ?? fallbackKey;
if (seen.has(key)) return [];
seen.add(key);
const counts = collection.counts[key] ?? {};
const ownedRarities = BOSS_CARD_RARITIES.filter((rarity) => (counts[rarity] ?? 0) > 0);
const copies = BOSS_CARD_RARITIES.reduce((sum, rarity) => sum + (counts[rarity] ?? 0), 0);
return [{ key, name: boss.name, ownedRarities, copies, model: activeModel ?? card?.model }];
});
}
+3
View File
@@ -132,6 +132,9 @@ export function activateDungeonSession(
bossTest: options.bossTest,
gmReviewTarget: options.gmReviewTarget,
bossDifficulty,
showDungeonIntro: !options.preserveCharacter && !options.legacyResume
&& options.runProgress?.startedAt == null && (options.gameMode ?? "dungeon") === "dungeon"
&& !options.gmTestMode && !options.dungeonSimulation,
});
if (options.encounterSnapshots?.length) {
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots, { authoritative: true });
+14
View File
@@ -12,11 +12,25 @@ describe("game store", () => {
companionOpen: false,
assetStatus: "checking",
assetMessage: null,
dungeonIntroActive: false,
dungeonIntroCompletedAt: null,
distanceTravelled: 0,
hasMoved: false,
});
});
it("keeps a new dungeon gated until its own intro finishes", () => {
const game = useGameStore.getState();
game.activateDungeon("wailing-caverns", undefined, { showDungeonIntro: true });
const revision = useGameStore.getState().sessionRevision;
expect(useGameStore.getState().dungeonIntroActive).toBe(true);
useGameStore.getState().completeDungeonIntro(revision - 1);
expect(useGameStore.getState().dungeonIntroActive).toBe(true);
useGameStore.getState().completeDungeonIntro(revision);
expect(useGameStore.getState().dungeonIntroActive).toBe(false);
expect(useGameStore.getState().dungeonIntroCompletedAt).toBeGreaterThan(0);
});
it("opens the map without leaving camera control active", () => {
useGameStore.setState({ cameraLookActive: true });
useGameStore.getState().toggleMap();
+11
View File
@@ -36,6 +36,8 @@ export interface GameState {
activeGmReviewTarget: GmReviewTarget | null;
activeSpawn: WorldSpawn;
sessionRevision: number;
dungeonIntroActive: boolean;
dungeonIntroCompletedAt: number | null;
overlay: GameOverlay;
paused: boolean;
mapOpen: boolean;
@@ -67,8 +69,10 @@ export interface GameState {
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null;
readonly bossDifficulty?: number;
readonly showDungeonIntro?: boolean;
},
) => void;
completeDungeonIntro: (sessionRevision: number) => void;
openOverlay: (overlay: Exclude<GameOverlay, null>) => void;
toggleOverlay: (overlay: Exclude<GameOverlay, null>) => void;
closeOverlay: () => void;
@@ -102,6 +106,8 @@ export const useGameStore = create<GameState>((set) => ({
activeGmReviewTarget: null,
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
sessionRevision: 0,
dungeonIntroActive: false,
dungeonIntroCompletedAt: null,
overlay: null,
paused: false,
mapOpen: false,
@@ -140,6 +146,8 @@ export const useGameStore = create<GameState>((set) => ({
activeGmReviewTarget: options?.gmReviewTarget ?? null,
activeSpawn,
sessionRevision: state.sessionRevision + 1,
dungeonIntroActive: options?.showDungeonIntro ?? false,
dungeonIntroCompletedAt: null,
overlay: null,
paused: false,
mapOpen: false,
@@ -159,6 +167,9 @@ export const useGameStore = create<GameState>((set) => ({
relocationRevision: state.relocationRevision + 1,
};
}),
completeDungeonIntro: (sessionRevision) => set((state) => state.sessionRevision === sessionRevision && state.dungeonIntroActive
? { dungeonIntroActive: false, dungeonIntroCompletedAt: Date.now() }
: state),
openOverlay: (overlay) => set({
overlay,
paused: true,
+2
View File
@@ -171,6 +171,7 @@ export function GameScene() {
];
const roamingPacks = sessionPopulation.roamingPacks;
const paused = useGameStore((state) => state.paused);
const dungeonIntroActive = useGameStore((state) => state.dungeonIntroActive);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
@@ -191,6 +192,7 @@ export function GameScene() {
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
const simulationBlocked = graphicsRecovery !== null
|| !worldReady
|| dungeonIntroActive
|| (!documentVisible && !sharedOnline)
|| (!sharedOnline && (paused || mapOpen || companionOpen));
const frameloop = sceneFrameLoop({
+1 -1
View File
@@ -121,7 +121,7 @@ function PlayerController({
verticalMotionRef,
animationEventRef,
}: PlayerControllerProps) {
const paused = useGameStore((state) => state.paused);
const paused = useGameStore((state) => state.paused || state.dungeonIntroActive);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const resetRevision = useGameStore((state) => state.resetRevision);
+22 -2
View File
@@ -32,7 +32,7 @@ describe("RuneWaker sprite particle runtime", () => {
expect(runeSample([{ time: 0, value: 0 }, { time: 1, value: 20 }], 0.25, 0)).toBe(5);
});
it("accepts only the Priest holy casting audio trigger as a visual-only controller", () => {
it("accepts source audio callbacks and rejects callbacks that spawn other effects", () => {
const graph = parseRunewakerParticleGraph(graphFor("holy/act_casting_c_hand.ros"));
expect(runeMotion(graph, "cast-start")?.name).toBe("idle");
expect(runeLayers(graph, "cast-start")).toHaveLength(3);
@@ -44,7 +44,19 @@ describe("RuneWaker sprite particle runtime", () => {
const unreported = graphFor("holy/act_casting_c_hand.ros");
unreported.unsupported = [];
expect(() => parseRunewakerParticleGraph(unreported)).toThrow();
expect(() => parseRunewakerParticleGraph(graphFor("earth/act_casting_c_hand.ros"))).toThrow();
expect(runeLayers(parseRunewakerParticleGraph(graphFor("dark/act_casting_c_hand.ros")), "cast-start").length)
.toBeGreaterThan(0);
});
it("parses every packaged graph marked ready by the exporter", () => {
const failures: string[] = [];
for (const result of archive.results.filter((entry: { rendererReady: boolean }) => entry.rendererReady)) {
const graph = JSON.parse(fs.readFileSync(path.join(process.cwd(), "public", result.graphUrl.slice(1)), "utf8"));
try { parseRunewakerParticleGraph(graph); } catch (error) {
failures.push(`${result.model}: ${String(error)}`);
}
}
expect(failures).toEqual([]);
});
it("renders the source Rising Tide cast, projectile, and target hit layers", () => {
@@ -62,6 +74,14 @@ describe("RuneWaker sprite particle runtime", () => {
expect(() => parseRunewakerParticleGraph(altered)).toThrow();
});
it("applies a source alpha track that covers the whole sprite subtree", () => {
const graph = parseRunewakerParticleGraph(graphFor("physical/act_absolute_strike_c_top.ros"));
expect(runeLayers(graph, "cast-start")).toHaveLength(3);
expect(runeGlobalAlpha(graph, "cast-start", 0)).toBe(0);
expect(runeGlobalAlpha(graph, "cast-start", 0.1)).toBe(1);
expect(runeGlobalAlpha(graph, "cast-start", 3.2)).toBeCloseTo(0);
});
it("accepts Urgent Heal's original sprites, rune entity, and level-ground decal as partial", () => {
const graph = parseRunewakerParticleGraph(graphFor("holy/act_benedictory_heal_t_down.ros"));
expect(graph.rendererReady).toBe(false);
+43 -6
View File
@@ -257,7 +257,27 @@ function isUrgentGlobalColor(graph: Record<string, unknown>, controller: Record<
function normalizedEventString(controller: Record<string, unknown>) {
return typeof controller.eventString === "string"
? controller.eventString.replaceAll("\\", "/").toLowerCase() : null;
? controller.eventString.replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase() : null;
}
function isVisualOnlySourceTrigger(controller: Record<string, unknown>): boolean {
if (controller.sourceType !== "CRuController_EventTrigger"
|| typeof controller.anchor !== "string" || !finite(controller.startTime)
|| controller.startTime < 0 || !finite(controller.eventDuration)
|| controller.eventDuration < 0 || !Number.isInteger(controller.eventParameter0)
|| !Number.isInteger(controller.eventParameter1)) return false;
const eventString = normalizedEventString(controller);
if ([1, 50, 61].includes(Number(controller.eventID))) {
return controller.eventParameter0 === 0 && controller.eventParameter1 === 0 && eventString === "";
}
if ([41, 43].includes(Number(controller.eventID))) {
return [0, 1].includes(Number(controller.eventParameter0))
&& typeof eventString === "string"
&& /^sound\/fx\/[\w./ -]+\.(?:mp3|wav|ogg)$/.test(eventString)
&& !eventString.includes("..");
}
return controller.eventID === 44 && controller.eventParameter0 === 1
&& controller.eventParameter1 === 0 && eventString === "missile";
}
function isVisualOnlyRisingTideTrigger(
@@ -308,9 +328,17 @@ function isVerifiedGlobalAlpha(
&& track.length === 4 && track[0].time === 0 && track[0].value === 0
&& track[1].value === 1 && track[2].value === 1 && track[3].value === 0;
}
return graph.sourceModel === RIPTIDE_IMPACT_MODEL && motion.name === "explode"
&& controller.duration === 0 && track.length === 1
&& track[0].time === 0 && track[0].value === 1;
if (graph.sourceModel === RIPTIDE_IMPACT_MODEL && motion.name === "explode") {
return controller.duration === 0 && track.length === 1
&& track[0].time === 0 && track[0].value === 1;
}
const controllers = motion.controllers as Record<string, unknown>[];
const alphas = controllers.filter((candidate) => candidate.type === "global-alpha");
const sprites = controllers.filter((candidate) => candidate.type === "sprite");
return alphas.length === 1 && sprites.length > 0
&& typeof controller.anchor === "string"
&& sprites.every((sprite) => typeof sprite.anchor === "string"
&& (controller.anchor === "" || sprite.anchor.startsWith(`${controller.anchor}/`)));
}
/** Parse only the source controller slice implemented by the browser renderer. */
@@ -370,6 +398,8 @@ export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGr
visualOnlyTriggerCount++;
} else if (controller.type === "unsupported" && isUrgentPartialController(graph, motion, controller)) {
visualOnlyTriggerCount++;
} else if (controller.type === "unsupported" && isVisualOnlySourceTrigger(controller)) {
visualOnlyTriggerCount++;
} else if (controller.type === "entity-visualizer" && isUrgentEntityController(graph, controller)) {
if (!emitters.has(controller.anchor)) throw new TypeError("Urgent rune entity has no emitter.");
entityCount++;
@@ -384,11 +414,18 @@ export function parseRunewakerParticleGraph(value: unknown): RunewakerParticleGr
}
}
const [expectedTriggers, expectedAlpha] = expectedSpecialControllerCounts.get(graph.sourceModel) ?? [0, 0];
if (visualOnlyTriggerCount !== expectedTriggers || globalAlphaCount !== expectedAlpha
const sourceTriggerCount = graph.motions.flatMap((motion) => record(motion).controllers as unknown[])
.filter((controller) => record(controller).sourceType === "CRuController_EventTrigger").length;
const sourceAlphaCount = graph.motions.flatMap((motion) => record(motion).controllers as unknown[])
.filter((controller) => record(controller).sourceType === "CRuController_GlobalAlpha").length;
if (visualOnlyTriggerCount !== sourceTriggerCount
|| expectedTriggers > 0 && sourceTriggerCount !== expectedTriggers
|| globalAlphaCount !== sourceAlphaCount
|| expectedAlpha > 0 && sourceAlphaCount !== expectedAlpha
|| entityCount !== (urgentPartial ? 1 : 0)
|| decalCount !== (urgentPartial ? 1 : 0)
|| colorCount !== (urgentPartial ? 1 : 0)
|| graph.unsupported.length !== (urgentPartial ? urgentUnsupportedTypes.size : expectedTriggers > 0 ? 1 : 0)) {
|| graph.unsupported.length !== (urgentPartial ? urgentUnsupportedTypes.size : sourceTriggerCount > 0 ? 1 : 0)) {
throw new TypeError("RuneWaker graph has unverified event or alpha controllers.");
}
if (!layerCount) throw new TypeError("RuneWaker graph has no renderable sprites.");
+18 -12
View File
@@ -624,14 +624,15 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.pause-menu > p:not(.eyebrow) { color: var(--muted); line-height: 1.5; }
.pause-menu__actions { display: grid; gap: 8px; margin: 21px 0; }
.pause-menu--hub { width: min(860px, 100%); padding: clamp(16px, 2vw, 24px); }
.pause-menu__header { text-align: center; }
.pause-menu__header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; text-align: left; }
.pause-menu--hub h2 { font-family: inherit; font-size: 1.2rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; }
.pause-menu__character { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 10px; }
.pause-menu__character { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
.pause-menu__character img { width: 48px; height: 48px; flex: none; border: 2px solid var(--edge); border-radius: 10px; object-fit: cover; }
.pause-menu__character div { display: grid; gap: 3px; min-width: 0; text-align: left; }
.pause-menu__character strong { overflow: hidden; font-size: 1.05rem; text-overflow: ellipsis; white-space: nowrap; }
.pause-menu__character span, .pause-menu__context { color: var(--muted); font-size: 0.78rem; }
.pause-menu__context { margin: 7px 0 0; }
.pause-menu__character span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pause-menu__context { flex-basis: 100%; margin: 0; text-align: right; }
.pause-menu__resume { display: flex; justify-content: center; margin: 14px 0; }
.pause-menu__resume .button { width: min(320px, 100%); }
.pause-menu__groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
@@ -663,7 +664,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
@media (max-width: 620px) {
.pause-menu__groups { grid-template-columns: minmax(0, 1fr); gap: 8px; }
.pause-menu--hub .pause-menu__group { padding: 9px; }
.pause-menu--hub .pause-menu__character { margin-top: 8px; }
.pause-menu--hub .pause-menu__character { margin-top: 0; }
.pause-menu--hub .pause-menu__resume { margin: 10px 0; }
.objective-chip { width: calc(100vw - 116px); }
.objective-chip .eyebrow, .objective-chip > p:last-child { display: none; }
@@ -2336,9 +2337,9 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
gap: 17px;
border-bottom: 1px solid var(--front-line);
}
.main-menu-header > div:nth-child(2), .dungeon-select-header > div:nth-child(2), .manastorm-select-header > div:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; }
.main-menu-header > div:nth-child(2) span, .dungeon-select-header > div:nth-child(2) span, .manastorm-select-header > div:nth-child(2) span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
.main-menu-header > div:nth-child(2) strong, .dungeon-select-header > div:nth-child(2) strong, .manastorm-select-header > div:nth-child(2) strong { overflow: hidden; font: 18px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; }
.main-menu-header > div:nth-child(2), .manastorm-select-header > div:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; }
.main-menu-header > div:nth-child(2) span, .manastorm-select-header > div:nth-child(2) span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
.main-menu-header > div:nth-child(2) strong, .manastorm-select-header > div:nth-child(2) strong { overflow: hidden; font: 18px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; }
.main-menu-header > button {
margin-left: auto;
padding: 7px 10px;
@@ -2446,6 +2447,8 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
padding-bottom: max(8px, env(safe-area-inset-bottom));
}
.dungeon-select-header > p { display: flex; flex-direction: column; align-items: flex-end; margin: 0; }
.dungeon-select-header__breadcrumb { display: flex; min-width: 0; flex: 1; align-items: center; }
.dungeon-select-header__breadcrumb span { color: var(--front-dim); font-size: 10px; letter-spacing: .13em; text-transform: uppercase; }
.dungeon-select-header > p b { font: 13px Georgia, serif; font-weight: 400; }
.dungeon-select-header > p small { color: var(--front-dim); font-size: 7px; }
.dungeon-select-list {
@@ -2498,11 +2501,14 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: minmax(210px, .9fr) minmax(245px, 1.1fr);
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: 17px;
padding: 17px 0 12px;
}
.dungeon-select-detail--leaderboard-open { grid-template-columns: minmax(245px, 1.1fr) minmax(210px, .9fr); }
.dungeon-select-leaderboard-column { display: flex; min-width: 0; min-height: 0; }
.dungeon-select-leaderboard-column > .dungeon-leaderboard { flex: 1; }
.dungeon-select-map { min-width: 0; min-height: 0; overflow: hidden; border: 1px solid var(--front-line); border-radius: 13px 4px 13px 4px; background: rgba(4,16,11,.84); box-shadow: inset 0 0 35px rgba(79,119,66,.13); }
.dungeon-select-map svg { display: block; width: 100%; height: 100%; }
.dungeon-select-map__placeholder { display: grid; width: 100%; height: 100%; place-items: center; color: var(--front-green-bright); font: 48px Georgia, serif; }
@@ -2513,6 +2519,7 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.dungeon-select-copy dl div { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; border-bottom: 1px solid rgba(203,219,180,.09); }
.dungeon-select-copy dt { color: var(--front-dim); font-size: 7px; letter-spacing: .06em; text-transform: uppercase; }
.dungeon-select-copy dd { margin: 0; font-size: 8px; text-align: right; }
.dungeon-select-leaderboard-toggle { width: 100%; justify-content: center; }
.dungeon-select-enter { width: 100%; }
.dungeon-role-picker { display: grid; gap: 9px; }
.dungeon-role-picker > header h3 { margin: 3px 0 4px; font: 21px Georgia, serif; font-weight: 400; }
@@ -2981,7 +2988,7 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
.main-menu-screen { grid-template-columns: minmax(0, 1fr) 280px; column-gap: 16px; }
.main-menu-mode { min-height: 72px; }
.dungeon-select-screen { grid-template-columns: 255px minmax(0, 1fr); column-gap: 14px; }
.dungeon-select-detail { grid-template-columns: minmax(190px, .82fr) minmax(220px, 1.18fr); gap: 13px; }
.dungeon-select-detail--leaderboard-open { grid-template-columns: minmax(220px, 1.18fr) minmax(190px, .82fr); gap: 13px; }
}
@media (max-width: 760px) {
@@ -3026,7 +3033,6 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
.main-menu-mode > span small { display: none; }
.dungeon-select-screen { grid-template-columns: 40% minmax(0, 60%); grid-template-rows: 48px minmax(0,1fr); column-gap: 10px; }
.dungeon-select-header { grid-column: 1 / 3; }
.dungeon-select-header > div:nth-child(2) strong { font-size: 14px; }
.dungeon-select-header > p { display: none; }
.dungeon-select-list { padding: 11px 9px 7px 0; }
.dungeon-select-list > header h1 { margin-bottom: 6px; font-size: 21px; }
@@ -3034,6 +3040,7 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
.dungeon-select-row { min-height: 57px; grid-template-columns: 25px minmax(0,1fr); }
.dungeon-select-row > em { display: none; }
.dungeon-select-detail { grid-template-columns: 1fr; padding: 11px 0 7px; }
.dungeon-select-detail--leaderboard-open { grid-template-columns: 1fr; }
.dungeon-select-map { display: none; }
.dungeon-select-copy { padding: 4px 2px; }
.dungeon-select-copy h2 { font-size: 23px; }
@@ -3060,9 +3067,8 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
@media (max-width: 520px) {
.main-menu-header .front-brand small, .main-menu-header .front-brand strong,
.dungeon-select-header .front-brand small, .dungeon-select-header .front-brand strong,
.manastorm-select-header .front-brand small, .manastorm-select-header .front-brand strong { display: none; }
.main-menu-header .front-brand, .dungeon-select-header .front-brand, .manastorm-select-header .front-brand { gap: 0; }
.main-menu-header .front-brand, .manastorm-select-header .front-brand { gap: 0; }
.main-menu-mode-grid { grid-template-columns: 1fr; }
.main-menu-sidebar { max-height: 210px; grid-template-columns: 1fr; }
.main-menu-card__grid, .main-menu-gm-grid { grid-template-columns: 1fr; }
+1 -1
View File
@@ -82,7 +82,7 @@ function CompanionDungeonSelection({
return (
<FrontSurface bottom className="companion-panel companion-roster companion-dungeons" ariaLabel="Dungeon and party role selection companion screen">
<header><BrandMark compact /><b>CHOOSE DUNGEON / ROLE</b></header>
<header><b>CHOOSE DUNGEON / ROLE</b></header>
<main>
<div className="companion-dungeon-browser">
<div className="companion-category-tabs" role="group" aria-label="Dungeon categories">
+64
View File
@@ -0,0 +1,64 @@
import { lazy, Suspense, useEffect, useRef, useState, type CSSProperties } from "react";
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO } from "../game/bossCards";
import type { DungeonBossCard as BossCard } from "../game/dungeonBossCards";
import "./dungeonBossCards.css";
const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
function DungeonBossModel({ card }: { readonly card: BossCard }) {
const container = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const element = container.current;
if (!element || !card.model) return;
if (typeof IntersectionObserver === "undefined") {
setVisible(true);
return;
}
const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), {
rootMargin: "40px",
threshold: 0.01,
});
observer.observe(element);
return () => observer.disconnect();
}, [card.model]);
return <div ref={container} className="dungeon-boss-card__portrait">
{!card.model ? <span className="dungeon-boss-card__model-status">Model unavailable</span>
: visible ? <Suspense fallback={<span className="dungeon-boss-card__model-status">Loading model…</span>}>
<BossCardPortrait model={card.model} name={card.name} installed />
</Suspense> : <span className="dungeon-boss-card__model-status">Loading model…</span>}
</div>;
}
export function DungeonBossCards({ cards, compact = false }: {
readonly cards: readonly BossCard[];
readonly compact?: boolean;
}) {
if (!cards.length) return <p className="dungeon-boss-cards__empty">No boss cards are listed for this dungeon.</p>;
return <div className={`dungeon-boss-cards ${compact ? "dungeon-boss-cards--compact" : ""}`}>
{cards.map((card, index) => {
const highestRarity = card.ownedRarities.at(-1);
const accent = highestRarity ? BOSS_CARD_RARITY_INFO[highestRarity].color : "#9a947f";
return <article key={card.key} className={`dungeon-boss-card ${card.copies ? "is-owned" : ""}`}
style={{ "--boss-card-accent": accent } as CSSProperties}
aria-label={`${card.name}, ${card.copies ? `${card.copies} ${card.copies === 1 ? "card" : "cards"} owned` : "not collected"}`}>
<header><span>Boss {String(index + 1).padStart(2, "0")}</span><strong>{card.copies ? "Collected" : "Uncollected"}</strong></header>
<DungeonBossModel card={card} />
<div className="dungeon-boss-card__body">
<h3>{card.name}</h3>
<p>{card.copies ? `${card.copies} ${card.copies === 1 ? "copy" : "copies"} owned` : "Card not collected"}</p>
</div>
<footer aria-label="Card rarities">
{BOSS_CARD_RARITIES.map((rarity) => <span key={rarity}
className={card.ownedRarities.includes(rarity) ? "is-owned" : ""}
style={{ "--rarity-color": BOSS_CARD_RARITY_INFO[rarity].color } as CSSProperties}
title={`${BOSS_CARD_RARITY_INFO[rarity].label}: ${card.ownedRarities.includes(rarity) ? "owned" : "not owned"}`}>
{rarity.charAt(0).toUpperCase()}
</span>)}
</footer>
</article>;
})}
</div>;
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useMemo, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { dungeonBossCards } from "../game/dungeonBossCards";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { useGameStore } from "../game/store";
import { DungeonBossCards } from "./DungeonBossCards";
const INTRO_DURATION_MS = 5_000;
export function DungeonBossIntro() {
const active = useGameStore((state) => state.dungeonIntroActive);
const assetStatus = useGameStore((state) => state.assetStatus);
const dungeonId = useGameStore((state) => state.activeDungeonId);
const difficultyId = useGameStore((state) => state.activeDifficultyId);
const sessionRevision = useGameStore((state) => state.sessionRevision);
const collection = useShellStore((state) => state.bossCards);
const [seconds, setSeconds] = useState(5);
const ready = assetStatus === "ready" || assetStatus === "proxy";
const dungeon = useMemo(() => requireDungeonDefinition(dungeonId), [dungeonId, sessionRevision]);
const cards = useMemo(() => dungeonBossCards(dungeon, difficultyId, collection), [dungeon, difficultyId, collection]);
const collected = cards.filter((card) => card.copies > 0).length;
useEffect(() => {
if (!active || !ready) return;
let remaining = INTRO_DURATION_MS;
let last = performance.now();
setSeconds(5);
const timer = window.setInterval(() => {
const now = performance.now();
const elapsed = Math.min(250, Math.max(0, now - last));
last = now;
const game = useGameStore.getState();
if (document.hidden || game.overlay || game.companionOpen) return;
remaining = Math.max(0, remaining - elapsed);
setSeconds(Math.max(1, Math.ceil(remaining / 1_000)));
if (remaining === 0) game.completeDungeonIntro(sessionRevision);
}, 50);
return () => window.clearInterval(timer);
}, [active, ready, sessionRevision]);
if (!active || !ready) return null;
return <div className="dungeon-boss-intro" role="dialog" aria-modal="true" aria-labelledby="dungeon-boss-intro-title"
onPointerDown={(event) => event.stopPropagation()}>
<section className="dungeon-boss-intro__panel">
<header className="dungeon-boss-intro__header">
<div><h2 id="dungeon-boss-intro-title">{dungeon.title}</h2>
<span>{cards.length} {cards.length === 1 ? "boss" : "bosses"} ahead · {collected} collected</span></div>
<div className="dungeon-boss-intro__timer" role="status" aria-live="polite">
<span>Entering in</span><strong>{String(seconds).padStart(2, "0")}</strong>
</div>
</header>
<div className="dungeon-boss-intro__grid" aria-label="Boss cards in this dungeon">
<DungeonBossCards cards={cards} />
</div>
</section>
</div>;
}
+42 -27
View File
@@ -25,7 +25,7 @@ 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 { ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { specializationById } from "../game/specializationCatalog";
@@ -91,6 +91,7 @@ export function DungeonSelectScreen() {
const [bossDifficulty, setBossDifficulty] = useState(1);
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set());
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
const [readinessOpen, setReadinessOpen] = useState(false);
const leaderboardDifficulty = selectedDefinition?.difficultyVariants.find((variant) => variant.id === difficultyId)
?? selectedDefinition?.difficultyVariants.find((variant) => variant.id === selectedDefinition.defaultDifficultyId);
@@ -205,19 +206,24 @@ export function DungeonSelectScreen() {
id: `dungeon-${dungeon.id}`,
run: () => selectDungeon(dungeon.id),
enabled: true,
neighbors: { ...(index === 0 ? { up: `dungeon-category-${selectedCategory}` } : {}), right: "leaderboard-final" },
neighbors: { ...(index === 0 ? { up: `dungeon-category-${selectedCategory}` } : {}), right: "leaderboard-toggle" },
})),
{ 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 },
{ id: "leaderboard-toggle", run: () => setLeaderboardOpen((open) => !open) },
...(leaderboardOpen ? [
{ 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 },
] : []),
{ 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 },
{ id: "leaderboard-next", run: () => leaderboard.setOffset(leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE),
enabled: !leaderboard.loading && leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE < leaderboard.data.total },
...(leaderboardOpen ? [
...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}`,
@@ -233,7 +239,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, bossDifficulty]);
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty]);
const controller = useMenuController(actions, {
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
onBack: pendingDownload
@@ -250,10 +256,8 @@ export function DungeonSelectScreen() {
top={
<FrontSurface className="dungeon-select-screen" ariaLabel="Dungeon selection">
<header className="dungeon-select-header">
<BrandMark compact />
<div>
<div className="dungeon-select-header__breadcrumb">
<span>Main menu / Dungeons</span>
<strong>Choose an expedition</strong>
</div>
{character && race && characterClass && (
<p>
@@ -327,20 +331,26 @@ export function DungeonSelectScreen() {
</ControllerButton>
</aside>
<main className="dungeon-select-detail" aria-live="polite">
<main className={`dungeon-select-detail ${leaderboardOpen ? "dungeon-select-detail--leaderboard-open" : ""}`} aria-live="polite">
{selectedDungeon ? (
<>
<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={`${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">
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
<h2>{selectedDungeon.title}</h2>
<span className="dungeon-release-stage">{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}</span>
<p>{selectedDungeon.summary}</p>
<ControllerButton
controlId="leaderboard-toggle"
selectedId={controller.selectedId}
select={controller.select}
className="front-button dungeon-select-leaderboard-toggle"
type="button"
aria-expanded={leaderboardOpen}
aria-controls={leaderboardOpen ? "dungeon-select-leaderboard" : undefined}
onClick={() => setLeaderboardOpen((open) => !open)}
>
{leaderboardOpen ? "Hide leaderboard" : "View leaderboard"}
</ControllerButton>
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty}
selectedId={controller.selectedId} select={controller.select} />
{!canStart && selectedDungeon.availabilityReason && (
@@ -391,11 +401,6 @@ export function DungeonSelectScreen() {
) : (
<>
<dl>
<div><dt>Status</dt><dd>{canStart ? "Available offline" : selectedDungeon.available ? "Download required" : "Coming soon"}</dd></div>
<div><dt>Category</dt><dd>{DUNGEON_CATEGORIES.find((category) => category.id === selectedCategory)?.fullName}</dd></div>
<div><dt>Location</dt><dd>{selectedDungeon.location}</dd></div>
<div><dt>Theme</dt><dd>{selectedDungeon.theme}</dd></div>
<div><dt>Map</dt><dd>{selectedDungeon.mapId}</dd></div>
<div><dt>Starting point</dt><dd>{selectedDungeon.entranceName}</dd></div>
</dl>
<ControllerButton
@@ -414,6 +419,16 @@ export function DungeonSelectScreen() {
)}
{notice && <p className="front-notice" role="status">{notice}</p>}
</section>
{leaderboardOpen && (
<div id="dungeon-select-leaderboard" className="dungeon-select-leaderboard-column">
<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={`${bossDifficultyLabel(bossDifficulty)} · ${leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}`}
onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined}
selectedId={controller.selectedId} select={controller.select} />
</div>
)}
</>
) : (
<section className="dungeon-select-empty">
+15
View File
@@ -8,13 +8,20 @@ import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
import { BossTestingReviewPanel } from "./BossTestingReviewPanel";
import { classById } from "../app/characterCatalog";
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
import { dungeonBossCards } from "../game/dungeonBossCards";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { DungeonBossCards } from "./DungeonBossCards";
function PauseDialog() {
const closeOverlay = useGameStore((state) => state.closeOverlay);
const openOverlay = useGameStore((state) => state.openOverlay);
const reset = useGameStore((state) => state.resetAtEntrance);
const gameMode = useGameStore((state) => state.gameMode);
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const activeDifficultyId = useGameStore((state) => state.activeDifficultyId);
const sessionRevision = useGameStore((state) => state.sessionRevision);
const bossTest = useGameStore((state) => state.bossTest);
const bossCards = useShellStore((state) => state.bossCards);
const activeCharacter = useShellStore((state) => state.activeCharacter);
const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
@@ -41,6 +48,9 @@ function PauseDialog() {
const classDetail = activeCharacter?.secondaryClassId
? classById(activeCharacter.secondaryClassId).name
: specialization && specialization.classId === activeCharacter?.classId ? specialization.displayName : null;
const dungeonCards = useMemo(() => gameMode === "dungeon"
? dungeonBossCards(requireDungeonDefinition(activeDungeonId), activeDifficultyId, bossCards)
: [], [activeDungeonId, activeDifficultyId, bossCards, gameMode, sessionRevision]);
const actions = useMemo<MenuAction[]>(() => [
{ id: "pause-resume", run: resume, neighbors: { down: "pause-party", right: "pause-party", up: "pause-main-menu", left: "pause-main-menu" } },
@@ -95,6 +105,11 @@ function PauseDialog() {
</div>
{bossTest && <p className="pause-menu__context">Boss Testing · {bossTest.name}</p>}
</header>
{gameMode === "dungeon" && <section className="pause-menu__bosses" aria-label="Boss cards in this dungeon">
<div className="pause-menu__bosses-heading"><strong>Boss cards in this dungeon</strong>
<span>{dungeonCards.filter((card) => card.copies > 0).length} / {dungeonCards.length} collected</span></div>
<DungeonBossCards cards={dungeonCards} compact />
</section>}
<div className="pause-menu__resume">{menuButton("pause-resume", "Resume game", resume, true)}</div>
<div className="pause-menu__groups">
<section className="pause-menu__group" aria-labelledby="pause-adventure-title">
+97
View File
@@ -0,0 +1,97 @@
.dungeon-boss-intro, .pause-menu__bosses {
--boss-intro-ink: #f3ead1;
--boss-intro-muted: #b8b5a7;
--boss-intro-gold: #d5b875;
--boss-intro-line: rgba(214, 192, 132, .36);
}
.dungeon-boss-intro {
position: absolute;
inset: 0;
z-index: 86;
display: grid;
place-items: center;
padding: clamp(12px, 3vw, 32px);
background: rgba(2, 7, 7, .82);
backdrop-filter: blur(9px);
color: var(--boss-intro-ink);
}
.dungeon-boss-intro__panel {
display: grid;
width: min(1080px, 100%);
max-height: min(94dvh, 820px);
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
gap: 14px;
padding: clamp(16px, 2.5vw, 28px);
border: 1px solid var(--boss-intro-line);
border-radius: 13px;
background: linear-gradient(145deg, #202523, #101716 70%);
box-shadow: 0 24px 80px #000b, inset 0 0 45px rgba(142, 156, 110, .07);
}
.dungeon-boss-intro__header { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.dungeon-boss-intro__header h2 { margin: 0 0 4px; color: var(--boss-intro-ink); font: clamp(22px, 3vw, 34px) Georgia, serif; }
.dungeon-boss-intro__header span { color: var(--boss-intro-muted); font-size: 12px; }
.dungeon-boss-intro__timer { display: grid; min-width: 100px; justify-items: center; padding: 6px 14px; border-left: 1px solid var(--boss-intro-line); }
.dungeon-boss-intro__timer span { font-size: 10px; letter-spacing: .12em; text-transform: uppercase; white-space: nowrap; }
.dungeon-boss-intro__timer strong { color: var(--boss-intro-gold); font: 48px Georgia, serif; font-variant-numeric: tabular-nums; line-height: 1; }
.dungeon-boss-intro__grid { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 2px 4px 4px 2px; }
.dungeon-boss-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.dungeon-boss-cards__empty { margin: 0; padding: 20px; color: var(--boss-intro-muted); }
.dungeon-boss-card {
display: flex;
min-width: 0;
flex-direction: column;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--boss-card-accent) 50%, #373d39);
border-radius: 8px;
background: radial-gradient(ellipse at 50% 35%, #37413d, #161d1b 75%);
box-shadow: inset 0 0 0 3px #111a18, 0 7px 16px #0005;
color: var(--boss-intro-ink);
}
.dungeon-boss-card.is-owned { border-color: var(--boss-card-accent); }
.dungeon-boss-card > header { display: flex; justify-content: space-between; gap: 5px; padding: 8px 10px; border-bottom: 1px solid #ffffff18; background: #07100dbb; font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
.dungeon-boss-card > header strong { color: var(--boss-card-accent); font-size: 9px; }
.dungeon-boss-card__portrait { position: relative; height: 120px; overflow: hidden; background: radial-gradient(circle, color-mix(in srgb, var(--boss-card-accent) 15%, transparent), transparent 70%); }
.dungeon-boss-card__portrait .boss-card__portrait { width: 100%; height: 100%; }
.dungeon-boss-card__portrait canvas { display: block; width: 100%; height: 100%; }
.dungeon-boss-card__model-status, .dungeon-boss-card__portrait .boss-card__model-status { display: grid; width: 100%; height: 100%; place-items: center; padding: 10px; color: var(--boss-intro-muted); font-size: 10px; text-align: center; }
.dungeon-boss-card__body { flex: 1; padding: 9px 10px; }
.dungeon-boss-card__body h3 { margin: 0 0 4px; color: var(--boss-intro-ink); font: 16px Georgia, serif; line-height: 1.15; }
.dungeon-boss-card__body p { margin: 0; color: var(--boss-intro-muted); font-size: 10px; }
.dungeon-boss-card > footer { display: flex; gap: 5px; padding: 8px 10px; border-top: 1px solid #ffffff18; background: #07100dbb; }
.dungeon-boss-card > footer span { display: grid; width: 20px; height: 20px; place-items: center; border: 1px solid #56605a; border-radius: 3px; color: #8c968e; font-size: 10px; font-weight: 700; }
.dungeon-boss-card > footer span.is-owned { border-color: var(--rarity-color); background: color-mix(in srgb, var(--rarity-color) 20%, #101819); color: var(--rarity-color); }
.pause-menu__bosses { min-width: 0; margin-top: 12px; padding: 10px 0; border-block: 1px solid var(--boss-intro-line); }
.pause-menu__bosses-heading { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 8px; color: var(--boss-intro-gold); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
.pause-menu__bosses-heading span { color: var(--boss-intro-muted); white-space: nowrap; }
.dungeon-boss-cards--compact { display: flex; gap: 8px; overflow-x: auto; padding: 2px 2px 5px; scroll-snap-type: x proximity; }
.dungeon-boss-cards--compact .dungeon-boss-card { flex: 0 0 150px; scroll-snap-align: start; }
.dungeon-boss-cards--compact .dungeon-boss-card > header { padding: 5px 7px; font-size: 8px; }
.dungeon-boss-cards--compact .dungeon-boss-card > header strong { font-size: 8px; }
.dungeon-boss-cards--compact .dungeon-boss-card__portrait { height: 86px; }
.dungeon-boss-cards--compact .dungeon-boss-card__body { min-height: 48px; padding: 7px; }
.dungeon-boss-cards--compact .dungeon-boss-card__body h3 { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.dungeon-boss-cards--compact .dungeon-boss-card > footer { padding: 5px 7px; }
.dungeon-boss-cards--compact .dungeon-boss-card > footer span { width: 16px; height: 16px; font-size: 8px; }
@media (max-width: 620px) {
.dungeon-boss-intro__panel { gap: 9px; padding: 13px; }
.dungeon-boss-intro__header h2 { font-size: 22px; }
.dungeon-boss-intro__timer { min-width: 66px; padding: 3px 5px; }
.dungeon-boss-intro__timer strong { font-size: 36px; }
.dungeon-boss-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.dungeon-boss-card__portrait { height: 80px; }
.dungeon-boss-card__body h3 { font-size: 13px; }
.dungeon-boss-cards--compact { display: flex; }
}
@media (max-height: 620px) {
.dungeon-boss-intro__panel { gap: 9px; padding: 14px; }
.dungeon-boss-intro__header h2 { font-size: 24px; }
.dungeon-boss-intro__timer strong { font-size: 36px; }
.dungeon-boss-card__portrait { height: 72px; }
.dungeon-boss-card__body { padding: 7px 9px; }
.dungeon-boss-card__body h3 { font-size: 14px; }
.dungeon-boss-card > header, .dungeon-boss-card > footer { padding: 5px 9px; }
}
@media (prefers-reduced-motion: reduce) {
.dungeon-boss-cards--compact { scroll-behavior: auto; }
}
+3 -1
View File
@@ -100,13 +100,15 @@
.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-width: 760px) {
.dungeon-select-detail--leaderboard-open { grid-template-rows: minmax(120px, .8fr) minmax(145px, 1fr); gap: 9px; }
}
@media (max-height: 520px) {
.dungeon-results { padding: 13px; }
.dungeon-results h2 { font-size: 23px; }
+1
View File
@@ -146,6 +146,7 @@
.creator-footer { gap: 8px; }
.dungeon-select-screen { grid-template-columns: 260px minmax(0, 1fr); gap: 10px; }
.dungeon-select-detail { grid-template-columns: 1fr; gap: 10px; }
.dungeon-select-detail--leaderboard-open { grid-template-columns: 1fr; grid-template-rows: minmax(120px, .8fr) minmax(145px, 1fr); }
.dungeon-select-map { display: none; }
.dungeon-select-copy { padding: 8px; }
.dungeon-role-option { min-height: 48px; }