diff --git a/.gitignore b/.gitignore index 8887391..c11998e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/diagnostics/README.md b/diagnostics/README.md index ac8067c..ed90185 100644 --- a/diagnostics/README.md +++ b/diagnostics/README.md @@ -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。 \ No newline at end of file +玩家对象、DOM、引擎状态或整个存档。日志文件不进入发布清单和 Git。 diff --git a/diagnostics/index.js b/diagnostics/index.js index 1666a90..06d5190 100644 --- a/diagnostics/index.js +++ b/diagnostics/index.js @@ -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); diff --git a/mengsan/mode.js b/mengsan/mode.js index e9679d5..245e3dc 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -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, diff --git a/mengsan/ui/card-library.js b/mengsan/ui/card-library.js index 006a15c..2a164b2 100644 --- a/mengsan/ui/card-library.js +++ b/mengsan/ui/card-library.js @@ -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 }, diff --git a/mengsan/ui/hand/index.js b/mengsan/ui/hand/index.js index 8d56066..27c050f 100644 --- a/mengsan/ui/hand/index.js +++ b/mengsan/ui/hand/index.js @@ -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 }; diff --git a/mengsan/ui/intent-display.js b/mengsan/ui/intent-display.js index 22fbc20..03d10dc 100644 --- a/mengsan/ui/intent-display.js +++ b/mengsan/ui/intent-display.js @@ -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(); }; } diff --git a/mengsan/ui/map/renderer.js b/mengsan/ui/map/renderer.js index 67c2488..a936003 100644 --- a/mengsan/ui/map/renderer.js +++ b/mengsan/ui/map/renderer.js @@ -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); diff --git a/mengsan/ui/virtual-card-grid.js b/mengsan/ui/virtual-card-grid.js index db56e28..268e95c 100644 --- a/mengsan/ui/virtual-card-grid.js +++ b/mengsan/ui/virtual-card-grid.js @@ -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(); }, }; } diff --git a/pve/images/青青子衿/simayi_boss_shuying.jpg b/pve/images/青青子衿/simayi_boss_shuying.jpg index 23da0e8..785b6de 100644 Binary files a/pve/images/青青子衿/simayi_boss_shuying.jpg and b/pve/images/青青子衿/simayi_boss_shuying.jpg differ