fixed # 优化部分已知问题

This commit is contained in:
2026-10-08 15:17:13 +08:00
parent cba7b5fbcc
commit 63dc8c66f9
10 changed files with 197 additions and 62 deletions
-4
View File
@@ -17,26 +17,22 @@ tools/*.mjs
*.mjs
*.cjs
# Local updater downloads and runtime logs
/.update_tmp/
/.planning/
/mengsan/logs/
/logs/
*.log
# Local credentials; keep shareable environment templates
.env
.env.*
!.env.example
!.env.sample
!.env.template
# Dependency and interpreter caches
node_modules/
__pycache__/
*.pyc
# Editor temporary files and OS metadata
*.tmp
*.bak
*.swp
+4 -1
View File
@@ -43,6 +43,9 @@ occurrences 表示被捕获的次数,不表示奖励发放次数。最近报
系统自己生成的旧文件,保留总量约 5 MiB、最多 64 个文件。报告包含截断和
丢弃计数;“完整诊断”指受限的错误链与现场记录,不是无限保留整个游戏过程。
待写队列默认最多 12 项、合计 2 MiB,优先保存错误报告;另有最多一项正在写入。
错误报告复用已脱敏的记录文本,按字节预算一次裁剪,保留首次和最近异常。
PC 使用异步文件操作,安卓使用 Cordova 目录和 FileWriter 回调,写入前截断旧
内容,使用 UTF-8 Blob。单次保存操作设置 5 秒观察超时;超时后停止使用该
文件通道,避免迟到写入与后续写入重叠。写入异常不会中断游戏业务。
@@ -56,4 +59,4 @@ PC 使用异步文件操作,安卓使用 Cordova 目录和 FileWriter 回调
和剧情等待不会被当作超时错误;观察器不会结束业务事件。
字段过滤会隐藏凭据字段、常见 URL 凭据参数和 Bearer 内容。报告不会复制完整
玩家对象、DOM、引擎状态或整个存档。日志文件不进入发布清单和 Git。
玩家对象、DOM、引擎状态或整个存档。日志文件不进入发布清单和 Git。
+35 -14
View File
@@ -27,6 +27,7 @@ export function createDiagnostics(options = {}) {
const maxDiskBytes = options.maxDiskBytes ?? 5 * 1024 * 1024;
const maxFiles = options.maxFiles ?? 64;
const maxPending = options.maxPending ?? 12;
const maxPendingBytes = options.maxPendingBytes ?? 2 * 1024 * 1024;
const flushMs = options.flushMs ?? 1000;
const recent = [], contexts = new Map(), pending = new Map();
const errors = new WeakMap(), fingerprints = new Map(), listeners = [];
@@ -35,6 +36,7 @@ export function createDiagnostics(options = {}) {
let flushTimer = null, worker = null, stopped = false, unavailable = false;
let latestReport = null, droppedRecords = 0, droppedWrites = 0;
let inventory = null, inventoryKind = null;
let pendingBytes = 0;
const metadata = snapshot({
extension: "术樱包", extensionVersion: options.version || "unknown",
engineVersion: env.lib?.version || "unknown", launchId,
@@ -86,17 +88,19 @@ export function createDiagnostics(options = {}) {
context: contextFor(extra),
};
if (error !== undefined) entry.error = serializeError(error);
const item = { entry, bytes: 0 };
const item = { entry, bytes: 0, text: "" };
recent.push(item);
if (level === "ERROR") createReport(entry, error);
const text = JSON.stringify(entry) + "\n";
const bytes = byteLength(text);
const text = JSON.stringify(entry);
const bytes = byteLength(text) + 1;
item.text = text;
delete item.entry;
item.bytes = bytes; memoryBytes += bytes;
if (journalBytes + bytes > maxJournalBytes && journal.length) {
enqueue(runtimeName(), journal.join(""));
segment++; journal = []; journalBytes = 0; queuedRevision = -1;
}
journal.push(text); journalBytes += bytes; revision++;
journal.push(text + "\n"); journalBytes += bytes; revision++;
while (recent.length > 400 || memoryBytes > maxMemoryBytes) {
memoryBytes -= recent.shift().bytes; droppedRecords++;
}
@@ -128,14 +132,22 @@ export function createDiagnostics(options = {}) {
schemaVersion: 1, reportId: known.id, firstSeen: known.first,
lastSeen: entry.time, occurrences: known.count, environment: metadata,
storageAtCapture: snapshot(storageStatus()), firstFailure: known.firstFailure,
failure: entry, records: recent.map(item => item.entry),
failure: entry,
droppedRecords, droppedWrites,
};
let text = JSON.stringify(report, null, 2);
while (byteLength(text) > maxReportBytes && report.records.length > 1) {
report.records.shift(); report.droppedRecords++;
text = JSON.stringify(report, null, 2);
const records = recent.map(item => {
const text = item.text || JSON.stringify(item.entry);
return { text, bytes: item.text ? item.bytes - 1 : byteLength(text) };
});
let start = 0;
let bytes = byteLength(JSON.stringify(report)) + 14
+ records.reduce((total, item) => total + item.bytes + 1, 0);
while (bytes + 32 > maxReportBytes && start < records.length - 1) {
bytes -= records[start++].bytes + 1;
}
report.droppedRecords += start;
const text = JSON.stringify(report).slice(0, -1) + ',"records":['
+ records.slice(start).map(item => item.text).join(",") + "]}";
latestReport = { id: known.id, file, revision: known.count,
persisted: false, event: entry.event };
enqueue(file, text, true, known.count);
@@ -143,12 +155,19 @@ export function createDiagnostics(options = {}) {
function enqueue(name, text, priority = false, reportRevision = null) {
if (unavailable || stopped) return;
if (!pending.has(name) && pending.size >= maxPending) {
const victim = [...pending].find(([, item]) => !item.priority)?.[0];
const bytes = byteLength(text);
const previous = pending.get(name);
if (previous) { pendingBytes -= previous.bytes; pending.delete(name); }
if (bytes > maxPendingBytes) { droppedWrites++; return; }
while (pending.size >= maxPending || pendingBytes + bytes > maxPendingBytes) {
const victim = [...pending].find(([, item]) => !item.priority)?.[0]
|| (priority ? pending.keys().next().value : null);
if (!victim) { droppedWrites++; return; }
pendingBytes -= pending.get(victim).bytes;
pending.delete(victim); droppedWrites++;
}
pending.set(name, { text, priority, reportRevision });
pending.set(name, { text, bytes, priority, reportRevision });
pendingBytes += bytes;
startWorker();
}
@@ -182,6 +201,7 @@ export function createDiagnostics(options = {}) {
|| pending.keys().next().value;
const item = pending.get(name);
pending.delete(name);
pendingBytes -= item.bytes;
try {
await storage.write(name, item.text);
if (latestReport?.file === name && latestReport.revision === item.reportRevision) {
@@ -190,10 +210,10 @@ export function createDiagnostics(options = {}) {
}
try { await prune(name, item.text); } catch { inventory = null; }
} catch {
unavailable = true; pending.clear();
unavailable = true; pending.clear(); pendingBytes = 0;
}
}
}).catch(() => { unavailable = true; pending.clear(); }).finally(() => {
}).catch(() => { unavailable = true; pending.clear(); pendingBytes = 0; }).finally(() => {
worker = null;
if (pending.size && !unavailable) startWorker();
});
@@ -305,6 +325,7 @@ export function createDiagnostics(options = {}) {
flush,
status: () => snapshot({ enabled: true, ...storageStatus(),
unavailable, latestReport, droppedRecords, droppedWrites,
pendingFiles: pending.size, pendingBytes,
runtimeFile: runtimeName(), launchId }),
dispose() {
void flush(); stopped = true; clearTimeout(flushTimer);
+8 -2
View File
@@ -62,7 +62,7 @@ import { STRANGLER_CHARACTER, isStrangler, rollStranglerHp, recordStranglerActio
import { JAXFRUIT_CHARACTER, isJaxfruit, rollJaxfruitHp, recordJaxfruitAction } from "./monsters/jaxfruit-intent.js";
import { CRAWLER_CHARACTER, isCrawler, rollCrawlerHp, recordCrawlerAction } from "./monsters/crawler-intent.js";
import { INKLET_CHARACTER, isInklet, rollInkletHp, recordInkletAction, capInkletHpLoss } from "./monsters/inklet-intent.js";
import { mountEnemyIntent } from "./ui/intent-display.js";
import { mountEnemyIntent, refreshEnemyIntents } from "./ui/intent-display.js";
import { createFogmogBattle } from "./monsters/fogmog-battle.js";
import { createPhrogBattle } from "./monsters/phrog-battle.js";
import { PHROG_CHARACTER, WRIGGLER_CHARACTER, INFESTED_COUNT, isPhrogActor, isWriggler, rollPhrogHp, rollWrigglerHp, recordPhrogAction, initializePhrog, skipSpawnedWriggler } from "./monsters/phrog-intent.js";
@@ -648,7 +648,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
});
for (const skill of [
"mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_ic_bridge_shuying", "mengsan_shared_bridge_shuying",
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying",
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_intent_refresh_shuying",
"mengsan_card_payment_shuying", "mengsan_monster_draw_shuying",
"mengsan_flyconid_action_shuying", "mengsan_raider_action_shuying",
"mengsan_fogmog_action_shuying", "mengsan_mawler_action_shuying",
@@ -1742,6 +1742,12 @@ const createMode = () => {
else player.removeSkill("mengsan_vulnerable_shuying");
},
},
mengsan_intent_refresh_shuying: {
trigger: { global: ["useCardAfter", "useSkillAfter", "respondAfter", "changeHpAfter", "dieAfter", "phaseAfter"] },
forced: true, silent: true, popup: false, priority: -1000,
filter() { return Boolean(activeBattle?.session.active); },
content() { refreshEnemyIntents(activeBattle.session); },
},
mengsan_scenario_shuying: {
trigger: { global: ["changeHpAfter", "gainAfter", "loseAfter", "dieAfter", "turnOverAfter", "linkAfter", "phaseAfter"] },
forced: true, silent: true, popup: false, priority: -100,
+43 -30
View File
@@ -132,15 +132,26 @@ export function openCardLibrary(run, options = {}) {
if (typeof window !== "undefined") window.removeEventListener?.("resize", fitCards);
if (dialog.open) dialog.close();
dialog.remove();
list.replaceChildren(); detailPanel.replaceChildren(); body.replaceChildren();
dialog.replaceChildren();
catalogRecords = null; descriptionCache = new WeakMap();
selectedButton = null; current = [];
virtualGrid = null; sizeObserver = null;
if (previousFocus?.isConnected) previousFocus.focus();
const onClose = options.onClose;
options = {}; run = null;
onClose?.(close);
};
button("关闭", header, close);
let view = !options.sections && options.view === "catalog" ? "catalog" : "deck";
const navigation = element("nav", "ms-deck-tabs", null, dialog);
navigation.setAttribute("aria-label", "卡牌列表视图");
navigation.hidden = Boolean(options.sections);
const deckTab = button("持有牌", navigation, () => { view = "deck"; render(); });
const catalogTab = button("图鉴", navigation, () => { view = "catalog"; render(); });
deckTab.hidden = catalogTab.hidden = Boolean(options.sections);
let sectionIndex = 0;
const sectionTabs = options.sections ? options.sections().map((section, index) =>
button(section.title, navigation, () => { sectionIndex = index; render(); })) : [];
const filters = element("div", "ms-catalog-filters", null, dialog);
function field(label) {
const wrapper = element("label", "ms-catalog-field", null, filters);
@@ -179,13 +190,13 @@ export function openCardLibrary(run, options = {}) {
summary.setAttribute("aria-live", "polite");
const body = element("div", "ms-deck-body", null, dialog);
const footer = element("p", "ms-deck-footer", "点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序", dialog);
const current = Array.isArray(run?.player?.deck) ? run.player.deck : [];
let detail = false, selectedButton = null, scrollTop = 0;
const list = element("div", options.sections ? "ms-deck-grid" : "ms-deck-grid ms-library-grid", null, body);
let current = Array.isArray(run?.player?.deck) ? run.player.deck : [];
let detail = false, selectedButton = null, scrollTop = 0, orderedView = false;
const list = element("div", "ms-deck-grid ms-library-grid", null, body);
const detailPanel = element("section", "ms-deck-detail", null, body);
detailPanel.hidden = true;
let catalogRecords = null, descriptionCache = new WeakMap();
if (!options.sections) virtualGrid = createVirtualCardGrid(body, list, createTile);
virtualGrid = createVirtualCardGrid(body, list, createTile);
function back() {
detail = false; detailPanel.hidden = true; list.hidden = false;
detailPanel.replaceChildren(); body.scrollTop = scrollTop; fitCards();
@@ -214,12 +225,13 @@ export function openCardLibrary(run, options = {}) {
body.scrollTop = 0; backButton.focus();
}
function fitCards() {
if (options.sections || closed || !body.clientHeight) return;
if (closed || !body.clientHeight) return;
const padding = getComputedStyle(body);
const available = body.clientHeight - parseFloat(padding.paddingTop) - parseFloat(padding.paddingBottom);
const height = Math.max(64, Math.min(218, Math.floor((available - 8) / 2)));
const rows = available >= 264 ? 2 : 1;
const height = Math.max(128, Math.min(218, Math.floor((available - (rows - 1) * 8) / rows)));
dialog.style.setProperty("--ms-card-height", String(height) + "px");
dialog.style.setProperty("--ms-card-art-width", String(Math.max(8, Math.min(98, Math.floor((height - 80) * 5 / 7)))) + "px");
dialog.style.setProperty("--ms-card-art-width", String(Math.max(8, Math.min(98, Math.floor((height - (orderedView ? 104 : 80)) * 5 / 7)))) + "px");
virtualGrid?.layout(height);
}
function createTile(record, index) {
@@ -240,7 +252,6 @@ export function openCardLibrary(run, options = {}) {
clearTimeout(searchTimer);
detail = false; list.hidden = false; detailPanel.hidden = true;
detailPanel.replaceChildren();
if (!virtualGrid) list.replaceChildren();
body.scrollTop = 0;
selectedButton = null;
const catalog = !options.sections && view === "catalog";
@@ -248,8 +259,22 @@ export function openCardLibrary(run, options = {}) {
deckTab.setAttribute("aria-pressed", String(!catalog));
catalogTab.setAttribute("aria-pressed", String(catalog));
list.classList.toggle("ms-catalog-grid", catalog);
let records = [];
if (catalog) {
let records = [], emptyMessage = "此处暂无卡牌。";
orderedView = false;
if (options.sections) {
const sections = options.sections();
const section = sections[sectionIndex];
orderedView = Boolean(section?.ordered);
if (section?.ordered) emptyMessage = "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。";
sectionTabs.forEach((tab, index) => {
tab.textContent = `${sections[index].title} · ${sections[index].cards.length}`;
tab.setAttribute("aria-pressed", String(index === sectionIndex));
});
records = (section?.cards || []).map(card => ({ card, ordered: section.ordered }));
summary.textContent = section?.ordered
? "剩余牌堆按从左到右、从上到下的顺序摸取;第 1 张为下一张。"
: `${section?.title || "牌堆"} · 共 ${records.length} 张`;
} else if (catalog) {
const query = search.value.trim().toLocaleLowerCase();
catalogRecords ||= getCardCatalog().map(card => {
const details = describeCatalogCard(card);
@@ -273,24 +298,11 @@ export function openCardLibrary(run, options = {}) {
}
footer.textContent = catalog ? "图鉴仅供查阅,不会获得卡牌或改变征程牌组 · 点击卡牌查看完整介绍" :
"点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序";
if (virtualGrid) {
virtualGrid.setRecords(catalog ? records : current.map(card => ({ card })));
if (!(catalog ? records : current).length) {
element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : "此处暂无卡牌。", list);
}
fitCards();
return;
}
const sections = catalog ? [{ title: "", cards: records.map(record => record.card) }] :
options.sections ? options.sections() : [{ title: "", cards: current }];
for (const section of sections) {
const cards = section.cards;
if (section.title) element("h3", "ms-deck-section", `${section.title} · ${cards.length} 张`, list);
if (!cards.length) element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : section.ordered ? "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。" : "此处暂无卡牌。", list);
cards.forEach((card, index) => {
list.appendChild(createTile({ card, ordered: section.ordered }, index));
});
virtualGrid.setRecords(catalog || options.sections ? records : current.map(card => ({ card })));
if (!(catalog || options.sections ? records : current).length) {
element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : emptyMessage, list);
}
fitCards();
}
dialog.addEventListener("cancel", event => { event.preventDefault(); if (detail) back(); else close(); });
dialog.addEventListener("close", close);
@@ -300,10 +312,10 @@ export function openCardLibrary(run, options = {}) {
document.body.appendChild(dialog);
try { dialog.showModal(); } catch (error) { close(); throw error; }
fitCards();
if (!options.sections && typeof ResizeObserver !== "undefined") {
if (typeof ResizeObserver !== "undefined") {
sizeObserver = new ResizeObserver(fitCards);
sizeObserver.observe(body);
} else if (!options.sections && typeof window !== "undefined") {
} else if (typeof window !== "undefined") {
window.addEventListener?.("resize", fitCards);
}
header.querySelector("button").focus();
@@ -327,6 +339,7 @@ export function mountBattlePiles(session, battle) {
closeDialog?.();
closeDialog = openCardLibrary(null, {
title: "战斗牌堆", eyebrow: "梦三 · 个人牌堆",
onClose(handle) { if (closeDialog === handle) closeDialog = null; },
sections: () => [
{ title: "剩余牌堆", cards: battle.drawPile, ordered: true },
{ title: "弃牌堆", cards: battle.discardPile, ordered: false },
+23 -2
View File
@@ -20,6 +20,7 @@ export async function mountHandUI(player, session, env) {
let disposed = false;
let observer, resizeObserver, pointer = null, nativeInput = false;
let finishStyleLoad = null, refreshTimer = null;
let refreshFrame = null;
const touchScrollClasses = new Map();
let layoutEnabled = false, warned = false;
const log = (message, error) => {
@@ -72,6 +73,15 @@ export async function mountHandUI(player, session, env) {
}
overlays.sync(adapter.cards());
}
function scheduleRefresh() {
if (disposed || document.hidden || refreshFrame !== null) return;
const draw = () => {
refreshFrame = null;
if (!document.hidden) refresh();
};
refreshFrame = typeof window.requestAnimationFrame === "function"
? window.requestAnimationFrame(draw) : window.setTimeout(draw, 16);
}
function listen(target, type, listener, options) {
target.addEventListener(type, listener, options);
restores.push(() => target.removeEventListener(type, listener, options));
@@ -120,6 +130,11 @@ export async function mountHandUI(player, session, env) {
if (disposed || !nativeInput) return;
nativeInput = false;
window.clearTimeout(refreshTimer);
if (refreshFrame !== null) {
if (typeof window.requestAnimationFrame === "function") window.cancelAnimationFrame(refreshFrame);
else window.clearTimeout(refreshFrame);
refreshFrame = null;
}
refreshTimer = window.setTimeout(refresh, 0);
}
function restoreTouchScroll() {
@@ -154,6 +169,11 @@ export async function mountHandUI(player, session, env) {
disposed = true;
finishStyleLoad?.();
window.clearTimeout(refreshTimer);
if (refreshFrame !== null) {
if (typeof window.requestAnimationFrame === "function") window.cancelAnimationFrame(refreshFrame);
else window.clearTimeout(refreshFrame);
refreshFrame = null;
}
restoreTouchScroll();
observer?.disconnect();
resizeObserver?.disconnect();
@@ -201,7 +221,7 @@ export async function mountHandUI(player, session, env) {
listen(container, "touchend", () => restoreTouchScroll(), true);
listen(container, "touchcancel", () => restoreTouchScroll(), true);
}
observer = new window.MutationObserver(refresh);
observer = new window.MutationObserver(scheduleRefresh);
for (const zone of adapter.zones) {
observer.observe(zone, { childList: true, subtree: true,
attributes: true, attributeFilter: ["class"] });
@@ -211,7 +231,7 @@ export async function mountHandUI(player, session, env) {
observer.observe(adapter.root.parentNode, { childList: true });
}
if (window.ResizeObserver && adapter.root) {
resizeObserver = new window.ResizeObserver(refresh);
resizeObserver = new window.ResizeObserver(scheduleRefresh);
resizeObserver.observe(adapter.root);
}
if (window.PointerEvent && adapter.root) {
@@ -221,6 +241,7 @@ export async function mountHandUI(player, session, env) {
listen(window, "pointercancel", endInput, true);
listen(window, "blur", () => endInput());
}
listen(document, "visibilitychange", scheduleRefresh);
refresh();
diagnose(`已挂载手牌 UI,独立排列=${layoutEnabled}`);
return { refresh, dispose };
+68 -4
View File
@@ -2,6 +2,56 @@
import { attackHitCount, previewAttackDamage } from "../battle/intent-damage.js";
import { isDeathBlowIntent } from "../battle/death-blow.js";
import { isStunIntent } from "../battle/stun-intent.js";
import { getDiagnostics } from "../../diagnostics/index.js";
const intentPollers = new WeakMap();
const intentFields = ["damage", "hits", "intentType", "summon", "slimed", "infection",
"wound", "ringing", "plow", "dazed", "debuff", "stacks", "block", "strength", "sleep", "shrink"];
export function refreshEnemyIntents(session) {
intentPollers.get(session)?.schedule();
}
function observeIntents(session, document, update) {
let group = intentPollers.get(session);
if (!group) {
const callbacks = new Set();
const host = document.defaultView || globalThis;
let frame = null;
const tick = () => {
if (document.hidden || session.active === false) return;
for (const callback of callbacks) {
try { callback(); }
catch (error) {
getDiagnostics().scope("mengsan.intent").error("refresh.failed", error);
}
}
};
const schedule = () => {
if (frame !== null || document.hidden) return;
const draw = () => { frame = null; tick(); };
frame = host.requestAnimationFrame ? host.requestAnimationFrame(draw) : host.setTimeout(draw, 16);
};
const timer = host.setInterval(tick, 500);
timer?.unref?.();
document.addEventListener?.("visibilitychange", schedule);
group = { callbacks, schedule, dispose() {
host.clearInterval(timer);
if (frame !== null) {
if (host.requestAnimationFrame) host.cancelAnimationFrame(frame);
else host.clearTimeout(frame);
}
document.removeEventListener?.("visibilitychange", schedule);
intentPollers.delete(session);
} };
intentPollers.set(session, group);
}
group.callbacks.add(update);
return () => {
group.callbacks.delete(update);
if (!group.callbacks.size) group.dispose();
};
}
export function attackIconForDamage(damage) {
if (!Number.isInteger(damage) || damage < 0) {
@@ -60,9 +110,9 @@ export function mountEnemyIntent(player, session, assetBase, document,
badge.append(damageLine, debuffLine, blockLine, buffLine);
player.appendChild(badge);
let currentIntent = null;
let timer;
let previous = null, unsubscribe = () => {};
session.ownResource(badge, () => {
clearInterval(timer);
unsubscribe(); previous = null; currentIntent = null;
badge.remove();
});
const refresh = () => {
@@ -180,9 +230,23 @@ export function mountEnemyIntent(player, session, assetBase, document,
if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label);
badge.hidden = false;
};
timer = setInterval(refresh, 100);
const poll = () => {
const values = intentFields.map(key => currentIntent?.[key]);
values.push(player.maxHp);
if (currentIntent?.damage != null) {
const target = getTarget();
const targets = Array.isArray(target) ? target : [target];
for (const current of targets.length ? targets : [null]) {
values.push(previewAttackDamage(currentIntent, player, current));
}
}
if (previous && previous.length === values.length && values.every((value, index) => Object.is(value, previous[index]))) return;
refresh(); previous = values;
};
unsubscribe = observeIntents(session, document, poll);
return intent => {
currentIntent = intent;
refresh();
previous = null;
poll();
};
}
+7 -2
View File
@@ -260,7 +260,9 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
createText("mengsan-map-menu-action-name-shuying", "卡牌库", copy);
createText("mengsan-map-menu-action-description-shuying", `共 ${run.player.deck.length} 张 · 查看花色、点数、属性和卡牌详情`, copy);
library.appendChild(copy);
library.addEventListener("click", () => { closeCardLibrary = openCardLibrary(run); });
library.addEventListener("click", () => { closeCardLibrary = openCardLibrary(run, {
onClose(handle) { if (closeCardLibrary === handle) closeCardLibrary = null; },
}); });
parent.appendChild(library);
const appendNames = (title, values, emptyText) => {
createText("mengsan-map-inventory-section-title-shuying", title, parent);
@@ -309,7 +311,10 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
if (back) back.disabled = busy;
}), ".mengsan-bonds-shuying button") });
createMenuAction({ icon: "book", name: "图鉴", description: "卡牌图鉴 · 查看全部梦三卡牌、效果与费用",
onClick: () => { closeCardLibrary = openCardLibrary(run, { view: "catalog", title: "卡牌图鉴", eyebrow: "梦三 · 图鉴" }); }
onClick: () => { closeCardLibrary = openCardLibrary(run, {
view: "catalog", title: "卡牌图鉴", eyebrow: "梦三 · 图鉴",
onClose(handle) { if (closeCardLibrary === handle) closeCardLibrary = null; },
}); }
}).setAttribute("aria-haspopup", "dialog");
createMenuAction({ icon: "record", name: "征程记录", description: `已通过 ${run.statistics?.completedNodes || 0} 个节点`, onClick: () => showMenuPage("征程记录", "本次征程的阶段统计", parent => {
const list = ui.create.div(".mengsan-map-record-list-shuying", parent);
+9 -3
View File
@@ -1,6 +1,8 @@
// 保留完整滚动高度,只挂载可见行与上下各两行;滚动时不重建仍可见的卡牌。
export function createVirtualCardGrid(viewport, grid, createCard) {
const mounted = new Map();
const cached = new Map();
const maxCached = 24;
let records = [], columns = 1, height = 218, gap = 8, padding = 5, viewportHeight = 0;
let frame = 0, disposed = false;
function draw() {
@@ -14,11 +16,14 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
for (const [index, node] of mounted) {
if ((index < first || index >= end) && node !== document.activeElement) {
node.remove(); mounted.delete(index);
cached.set(index, node);
while (cached.size > maxCached) cached.delete(cached.keys().next().value);
}
}
for (let index = first; index < end; index++) {
if (mounted.has(index)) continue;
const node = createCard(records[index], index);
const node = cached.get(index) || createCard(records[index], index);
cached.delete(index);
node.dataset.cardIndex = String(index);
node.style.gridRow = String(Math.floor(index / columns) + 1);
node.style.gridColumn = String(index % columns + 1);
@@ -75,7 +80,7 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
return {
setRecords(value) {
// 调用方更新筛选栏后再统一量尺寸,避免用旧高度先生成一批预览。
records = value; mounted.clear(); grid.replaceChildren();
records = value; mounted.clear(); cached.clear(); grid.replaceChildren();
},
layout,
focus,
@@ -84,7 +89,8 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
disposed = true; cancelAnimationFrame(frame);
viewport.removeEventListener("scroll", schedule);
grid.removeEventListener("keydown", keydown);
mounted.clear(); records = [];
mounted.clear(); cached.clear(); records = [];
grid.replaceChildren();
},
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 MiB

After

Width:  |  Height:  |  Size: 191 KiB