Update Healer Man server
This commit is contained in:
1 parent
a868401ebc
commit
48a678dcc5
295 files changed
+7644
-467
No files matched your search
@@ -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 />}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }];
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user