diff --git a/.gitignore b/.gitignore index 4618c0e..4087208 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ tools/*.mjs /Slay the Spire 2 -*.mjs \ No newline at end of file +*.mjs +*.cjs \ No newline at end of file diff --git a/mengsan/README.md b/mengsan/README.md index cb12acf..e00a9ea 100644 --- a/mengsan/README.md +++ b/mengsan/README.md @@ -12,6 +12,7 @@ | `progression/` | 征程状态、存档、剧情选择和奖励逻辑 | | `content/` | 章节、遭遇、剧本、角色与内容注册表 | | `ui/` | 地图、对话、意图、奖励、图鉴及样式 | +| `ui/hand/` | 梦三必需手牌 UI 子扩展:真实卡牌原位排列、覆盖层及生命周期 | | `assets/intent/` | 意图图标 | | `assets/cards/` | 梦三专属卡面 | | `assets/portraits/` | 角色立绘 | @@ -19,6 +20,9 @@ 新增功能优先放入所属领域;界面代码放在 `ui/`,怪物行动逻辑放在 `monsters/`。跨领域引用使用相对路径,避免新增一个涵盖所有功能的扁平 `runtime/` 目录。模块资源文件随 `tools/generate_manifest.py` 自动收录,发布入口仍为 `mengsan/register.js`。 +手牌 UI 子扩展没有单独关闭开关;实现边界、日志位置和验证结果见 +[手牌 UI 子扩展说明](docs/ui-hand-subextension.md)。 + 梦三【杀】消耗 1 费用,基础伤害为 6;其他锦囊的基础伤害仍为 4。 【重杀】(`mengsan_zhongsha`)是普通战斗奖励池中的通用基本牌: 消耗 2 费用,对攻击范围内的一名其他角色直接造成 8 点伤害, diff --git a/mengsan/cards/personal-piles.js b/mengsan/cards/personal-piles.js index 2644eff..379e51e 100644 --- a/mengsan/cards/personal-piles.js +++ b/mengsan/cards/personal-piles.js @@ -1,8 +1,6 @@ // Mode-owned piles use real Card nodes so native searches/gain/insert operations agree. // DIY must access ui piles inside the acting player's engine event, never cache global nodes. -import { cardCost } from "../battle/combat-rules.js"; import { AFFIX_INFO } from "./affixes.js"; -import { createHandUpgradeBadges } from "../ui/hand-upgrade.js"; export function installPersonalPiles(session, owner, battle, run, resources, env) { @@ -14,8 +12,7 @@ export function installPersonalPiles(session, owner, battle, run, resources, env const draw = document.createElement("div"), discard = document.createElement("div"); // Detached nodes: engine get.position recognises these IDs; no duplicate document IDs. draw.id = "cardPile"; discard.id = "discardPile"; - const owned = new Set(), restores = [], costBadges = new Map(); - const upgradeBadges = createHandUpgradeBadges(document); + const owned = new Set(), restores = []; const strict = env.strict !== false; let released = false, forcedOwner = false; const personal = () => { @@ -29,30 +26,6 @@ export function installPersonalPiles(session, owner, battle, run, resources, env return false; // Never infer ownership from whose turn it is. }; const refresh = () => { if (!released) env.refresh(); }; - function showCost(card) { - if (!env.showCosts || !card?.name) return; - const cost = String(cardCost(card)); - card.dataset.mengsanCost = cost; - let badge = costBadges.get(card); - if (!badge) { - badge = Array.from(card.children || []).find(child => child.classList?.contains("mengsan-hand-cost-shuying")); - if (!badge) { - badge = document.createElement("span"); - badge.className = "mengsan-hand-cost-shuying"; - badge.setAttribute("aria-hidden", "true"); - card.appendChild(badge); - } - costBadges.set(card, badge); - } - // 十周年 UI scans thrown-card children via innerText when adding “刘备使用”. - // Keep this child text-free so its numeric cost cannot match that scan. - badge.textContent = ""; - badge.dataset.mengsanCost = cost; - } - function showHandBadges(card) { - showCost(card); - if (env.showCosts) upgradeBadges.refresh(card); - } function adopt(card, data) { if (owned.has(card)) return; owned.add(card); resources.card(card); @@ -63,7 +36,6 @@ export function installPersonalPiles(session, owner, battle, run, resources, env // Retired affixes from old saves must not acquire new battle behaviour. card.storage.mengsanCard_shuying.affixes = (card.storage.mengsanCard_shuying.affixes || []).filter(key => key !== "annihilate"); } - if (data) showHandBadges(card); // Existing foreign/generated cards retain their original destruction semantics. if (!data) return; card.destroyLog = false; @@ -90,30 +62,6 @@ export function installPersonalPiles(session, owner, battle, run, resources, env const card = game.createCard(data.name, data.suit, data.number, data.nature); adopt(card, data); draw.appendChild(card); } - // Native and 十周年 UI both move real card nodes into these hand containers. - // Decorate newly gained cards without patching either UI's card factory or layout. - const handZones = [owner.node?.handcards1, owner.node?.handcards2].filter(Boolean); - const handObserver = new MutationObserver(mutations => { - if (released) return; - for (const mutation of mutations) for (const card of mutation.addedNodes) { - if (card.classList?.contains("card")) showHandBadges(card); - } - }); - for (const zone of handZones) { - for (const card of zone.childNodes) { - if (card.classList?.contains("card")) showHandBadges(card); - } - handObserver.observe(zone, { childList: true }); - } - session.ownResource(handObserver, () => { - handObserver.disconnect(); - for (const [card, badge] of costBadges) { - if (badge.parentNode === card) badge.remove(); - delete card.dataset.mengsanCost; - } - costBadges.clear(); - upgradeBadges.dispose(); - }); const publicDiscard = ui.discardPile; for (const [key, node] of [["cardPile", draw], ["discardPile", discard]]) { const descriptor = Object.getOwnPropertyDescriptor(ui, key); diff --git a/mengsan/docs/ui-hand-subextension.md b/mengsan/docs/ui-hand-subextension.md new file mode 100644 index 0000000..9e92079 --- /dev/null +++ b/mengsan/docs/ui-hand-subextension.md @@ -0,0 +1,98 @@ +# 梦三手牌 UI 子扩展 + +## 模块边界 + +这是术樱包内部的必需子模块,由梦三战斗加载,不是另一个可安装扩展。 +没有单独关闭开关。JS 或样式加载失败时进入既有战斗异常处理流程, +保留存档并提供返回模式选择入口;不会静默略过必需模块。 + +| 位置 | 职责 | +| --- | --- | +| `ui/hand/index.js` | 挂载、同步、局部输入适配、回退和幂等卸载 | +| `ui/hand/adapter.js` | 校验引擎容器并读取真实卡牌,识别第二容器离线结构 | +| `ui/hand/overlays.js` | 费用与强化覆盖层,仅维护当前手牌的显示节点 | +| `ui/hand/style.css` | 独立排列、尺寸、间距、选中标记及隔离样式 | +| `ui/hand/diagnostics.js` | 生命周期、回退和加载失败诊断日志 | +| `tools/test-hand-ui.cjs` | 浏览器隔离验证,非发布资源 | +| `verification/hand-ui/` | 本地验证报告、截图和清单,非发布资源 | +| `logs/log.txt` | 游戏内生成的最近 100 条诊断,非发布资源 | + +`cards/personal-piles.js` 只负责个人牌堆业务,原先的手牌 DOM 显示逻辑 +已移入本模块。旧的 `ui/hand-upgrade.js` 和通用样式文件中的对应规则 +已移除,避免两套覆盖层并行维护。发布入口仍为 `register.js`。 + +## 交互方案与证据 + +本体 `noname/ui/click/index.js` 的 `card()` 使用真实 `this` 操作 +`ui.selected.cards` 并调用 `game.check()`。本体 `card.updateTransform()` +检查卡牌祖先是否为 `ui.me`。十周年 `core/layout.js`、`cardDragSort.js` +也依赖真实节点的父容器和变换。因此首期选用真实节点原位复用, +不复制卡面,不移动手牌容器,不替换全局原型或布局函数。 + +排列使用独立命名空间的 flex 样式;默认卡牌 90×110、间距 8, +小屏卡牌 80×100,牌多时横向滚动。具体尺寸集中在本模块 CSS。 +卡面继续采用当前引擎/主题生成的内容。选择、取消、出牌、响应和 +选目标继续交给引擎,模块不另行计算卡牌可用性或提交出牌事件。 + +引擎仍维护节点上的 `selected`、`selectable` 等状态。本模块通过这些 +原有类显示选择状态,保留 DOM 顺序作为引擎排序的依据。 +检测到移动手势时临时退回既有排列,释放或取消后恢复独立排列; +触屏水平滚动保留独立排列。首期没有另做手动拖动排序系统。 + +费用调用既有 `cardCost`,强化读取既有 `cardUpgradeLevel`。 +覆盖层只在自己的子节点 dataset 上保存显示值,使用伪元素显示文字, +避免十周年对出牌子节点 innerText 的扫描误识别费用数字。 +覆盖层不接收指针事件;离开手牌区即回收,返回手牌区重新生成。 +若业务直接修改卡牌 storage,修改者应调用返回控制器的 `refresh()`。 + +十周年 `uiCreateMe()` 的第二手牌容器默认离线。 +适配器允许“主容器连接、第二容器离线且为空”的结构。 +未知或被替换的容器退回原排列,保留覆盖层,并通过游戏日志和 +诊断文件报告,不依赖 `decadeUI` 的内部坐标接口。 + +## 生命周期与日志 + +`mode.js` 在玩家初始化后、开局摸牌前等待 `mountHandUI()`。 +模块资源登记到 battle-session,逆序释放早于个人牌堆和玩家资源。 +正常战斗结束和流程异常另有显式卸载入口;DOM 根节点被移除时 +观察器会触发卸载。加载中取消会结束等待并取消计时器。 +卸载移除覆盖层、观察器、局部监听、样式链接、自己的类名,并恢复 +自己改过的属性;多次调用安全。它不销毁真实卡牌或重置选择状态。 + +支持引擎文件接口时,日志写入安装目录下 +`extension/术樱包/mengsan/logs/log.txt`,包含时间、战斗 session ID、 +挂载、回退、卸载与加载错误。最近 100 条覆盖写入,串行提交。 +没有文件接口时保留控制台日志;布局回退也写入游戏日志。 + +## 验证结果及边界 + +本次参考安装源码:本体 `game/update.js` 为 1.11.5.2; +十周年 `info.json` 为 1.3.1。 +十周年 `STYLE_TO_SKIN` 将 off/on/othersOff 映射到 +shousha/shizhounian/xinsha,配置菜单显示名与规划文档略有不同。 + +浏览器验证加载安装目录中的本体 CSS、十周年实际基础样式组合及 +手杀/十周年/新杀的按钮和技能样式,以匹配 `uiCreateMe()` 的 DOM 结构 +构造测试场景;点击测试使用安装源码中的真实 `ui.click.card` 函数体, +其 `game.check` 等引擎服务为测试替身,不代表完整游戏事件链验证。 + +| 场景 | 桌面 1280×720 | 小屏 540×360 | 完整游戏实测 | +| --- | --- | --- | --- | +| 原生 UI | 隔离验证通过 | 隔离验证通过 | 待验证 | +| 手杀 off/shousha | 隔离验证通过 | 隔离验证通过 | 待验证 | +| 十周年 on/shizhounian | 隔离验证通过 | 隔离验证通过 | 待验证 | +| 新杀 othersOff/xinsha | 隔离验证通过 | 隔离验证通过 | 待验证 | + +已验证真实节点父级及点击身份、选择/取消、费用与强化移除、 +手牌离开/返回、横向滚动处理、手势布局退让、会话结束、重复卸载、 +根节点移除、加载中取消、CSS 加载失败、回退覆盖层及属性恢复。 +已有梦三逻辑回归 44 项通过。发布清单包含本模块全部 JS/CSS, +不包含 tools、verification、.planning 和 log.txt。 + +尚需真实游戏验证出牌/响应、技能虚拟牌、选目标/取消、引擎自动排序、 +十周年拖拽排序插件及真实手机触屏。本次没有改动已安装的游戏文件, +也没有把隔离场景结果标记为游戏内实测通过。 + +运行浏览器测试:`node mengsan/tools/test-hand-ui.cjs`。 +可用 NONAME_APP 指向本体目录、CODEX_NODE_MODULES 指向提供 Playwright +的依赖目录;脚本使用本机 Chrome 的无界面独立实例。 diff --git a/mengsan/docs/ui-subsystem-outline.md b/mengsan/docs/ui-subsystem-outline.md index 0e99c49..7c78adb 100644 --- a/mengsan/docs/ui-subsystem-outline.md +++ b/mengsan/docs/ui-subsystem-outline.md @@ -1,5 +1,10 @@ # 梦三手牌 UI 子系统规划大纲 +首期实现已归入 `ui/hand/`,是梦三必须依赖的包内子模块, +没有单独关闭开关。实现及验证状态见 +[手牌 UI 子扩展说明](ui-hand-subextension.md)。 +下文保留规划与完整游戏验收项;隔离测试不等同于游戏内实测。 + ## 1. 背景与目标 - 当前手牌 UI 与无名杀真实卡牌节点的关系 diff --git a/mengsan/mode.js b/mengsan/mode.js index 27f66ba..d623ddc 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -22,6 +22,7 @@ import { } from "./monsters/actions.js"; import { mountEnemyIntent } from "./ui/intent-display.js"; import { mountEnergy } from "./ui/energy-display.js"; +import { mountHandUI } from "./ui/hand/index.js"; import { finishDeathBlow, hasPendingDeathBlow, @@ -36,7 +37,10 @@ import { } from "./battle/stun-intent.js"; import { recordFlyconidAction } from "./monsters/flyconid-intent.js"; import { recordRaiderAction } from "./monsters/raider-intent.js"; -import { PLAYER_ENERGY, PLAYER_HAND_LIMIT, canPayCard, payCard, isActiveCardUse } from "./battle/combat-rules.js"; +import { + PLAYER_ENERGY, PLAYER_HAND_LIMIT, cardCost, + canPayCard, payCard, isActiveCardUse, +} from "./battle/combat-rules.js"; import { mountBattlePiles } from "./ui/card-library.js"; import { mountGMManager } from "./ui/gm-manager.js"; import { createPlayerTeardown } from "./battle/skill-teardown.js"; @@ -431,7 +435,6 @@ const prepareBattle = async (run, node, encounter, session, resources) => { activeBattle.personalPiles = installPersonalPiles(session, me, _status.mengsanBattle_shuying, run, resources, { game, ui, get, _status, document, MutationObserver, shuffle: shuffleBattlePile, - showCosts: true, refresh: () => activeBattle?.session === session && activeBattle.pilesUI?.refresh(), }); activeBattle.personalPiles.withOwner(() => me.init(run.player.character)); @@ -458,6 +461,12 @@ const prepareBattle = async (run, node, encounter, session, resources) => { me.storage.mengsanMaxEnergy_shuying = PLAYER_ENERGY; me.storage.mengsanEnergy_shuying = PLAYER_ENERGY; currentBattle.energyUI.set(me, mountEnergy(me, session, document)); + currentBattle.handUI = await mountHandUI(me, session, { + game, ui, document, window, cardCost, + styleURL: `${STYLE_PATH}/ui/hand/style.css`, + log: message => game.log(message), + }); + if (!session.active) return; game.zhu = me; if (run.player.maxHp == null) { run.player.maxHp = me.maxHp; @@ -607,6 +616,7 @@ const requestBattleFinish = outcome => { current.pilesUI?.dispose(); current.gmUI?.dispose(); current.relicUI?.dispose(); + current.handUI?.dispose(); stopBattleTurn(current.session, _status.eventManager); if (_status.mengsanBattle_shuying) _status.mengsanBattle_shuying.resolving = true; current.finished(); @@ -653,6 +663,7 @@ const setupBattle = async (run, node, encounter = getNodeEncounter(run, node)) = await signal; // External controller, never an engine event content await. if (current.engineError) { session.requestStop(); + current.handUI?.dispose(); document.body.classList.remove("mengsan-battle-ui-shuying"); await chooseButtons("战斗流程异常", [{id:"exit",name:"保留存档并返回模式选择"}], "无法确认旧事件已退出,已停止继续开战。上次成功存档仍保留。"); await openModeSelection(); return; diff --git a/mengsan/ui/hand-upgrade.js b/mengsan/ui/hand-upgrade.js deleted file mode 100644 index 82361fa..0000000 --- a/mengsan/ui/hand-upgrade.js +++ /dev/null @@ -1,38 +0,0 @@ -import { cardUpgradeLevel } from "../cards/upgrades.js"; - -export function createHandUpgradeBadges(document) { - const badges = new Map(); - return { - refresh(card) { - if (!card?.name) return; - const level = cardUpgradeLevel(card); - let badge = badges.get(card) || Array.from(card.children || []) - .find(child => child.classList?.contains( - "mengsan-hand-upgrade-shuying")); - if (!level) { - badge?.remove(); - badges.delete(card); - delete card.dataset.mengsanUpgrade; - return; - } - if (!badge) { - badge = document.createElement("span"); - badge.className = "mengsan-hand-upgrade-shuying"; - badge.setAttribute("role", "img"); - } - if (badge.parentNode !== card) card.appendChild(badge); - badge.textContent = ""; - badge.dataset.mengsanUpgrade = String(level); - badge.setAttribute("aria-label", `已强化${level}次`); - card.dataset.mengsanUpgrade = String(level); - badges.set(card, badge); - }, - dispose() { - for (const [card, badge] of badges) { - badge.remove(); - delete card.dataset.mengsanUpgrade; - } - badges.clear(); - }, - }; -} diff --git a/mengsan/ui/hand/adapter.js b/mengsan/ui/hand/adapter.js new file mode 100644 index 0000000..c148f32 --- /dev/null +++ b/mengsan/ui/hand/adapter.js @@ -0,0 +1,20 @@ +export function createHandAdapter(player, ui) { + const zones = [player.node?.handcards1, player.node?.handcards2] + .filter(Boolean); + const containers = [ui.handcards1Container, ui.handcards2Container]; + const root = ui.me; + const cards = () => zones.flatMap(zone => Array.from(zone.children) + .filter(card => card.classList.contains("card") && + !card.classList.contains("removing"))); + const supported = () => Boolean(root?.isConnected && ui.me === root && + zones.length === 2 && + zones.every((zone, index) => + containers[index] === ui[index === 0 ? + "handcards1Container" : "handcards2Container"] && + zone.parentNode === containers[index] && + (zone.isConnected ? root.contains(zone) : + index === 1 && zone.childElementCount === 0) && + Array.from(zone.children).every(node => + node.classList.contains("card")))); + return { root, zones, containers, cards, supported }; +} diff --git a/mengsan/ui/hand/diagnostics.js b/mengsan/ui/hand/diagnostics.js new file mode 100644 index 0000000..085fbd2 --- /dev/null +++ b/mengsan/ui/hand/diagnostics.js @@ -0,0 +1,17 @@ +const lines = []; +let pendingWrite = Promise.resolve(); + +export function createHandDiagnostics(game, sessionId) { + return (message, error) => { + const line = `${new Date().toISOString()} [${sessionId}] ${message}` + + (error ? ` ${error.message || error}` : ""); + console[error ? "warn" : "info"](`梦三手牌 UI:${line}`); + lines.push(line); + if (lines.length > 100) lines.shift(); + if (typeof game?.writeFile !== "function") return; + const contents = lines.join("\n") + "\n"; + pendingWrite = pendingWrite.then(() => game.promises.writeFile( + contents, "extension/术樱包/mengsan/logs", "log.txt" + )).catch(cause => console.warn("梦三手牌 UI 日志写入失败", cause)); + }; +} diff --git a/mengsan/ui/hand/index.js b/mengsan/ui/hand/index.js new file mode 100644 index 0000000..0d3e59d --- /dev/null +++ b/mengsan/ui/hand/index.js @@ -0,0 +1,200 @@ +import { createHandAdapter } from "./adapter.js"; +import { createHandOverlays } from "./overlays.js"; +import { createHandDiagnostics } from "./diagnostics.js"; + +export async function mountHandUI(player, session, env) { + const { ui, document, window, cardCost, styleURL } = env; + const adapter = createHandAdapter(player, ui); + const overlays = createHandOverlays(document, cardCost); + const restores = []; + const diagnose = createHandDiagnostics(env.game, session.id); + const style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = styleURL; + let disposed = false; + let observer, resizeObserver, pointer = null, nativeInput = false; + let finishStyleLoad = null, refreshTimer = null; + const touchScrollClasses = new Map(); + let layoutEnabled = false, warned = false; + const log = (message, error) => { + diagnose(message, error); + env.log?.(`梦三手牌 UI:${message}`); + }; + function ownClass(node, name) { + if (!node || node.classList.contains(name)) return; + node.classList.add(name); + restores.push(() => node.classList.remove(name)); + } + function ownAttribute(node, name, value) { + const previous = node.getAttribute(name); + node.setAttribute(name, value); + restores.push(() => { + if (node.getAttribute(name) !== value) return; + if (previous === null) node.removeAttribute(name); + else node.setAttribute(name, previous); + }); + } + function nativeLayout() { + if (ui.me !== adapter.root) return; + try { ui.updateh?.(true); ui.updatehl?.(); } + catch (error) { log("原手牌布局刷新失败", error); } + } + function setLayout(enabled) { + if (layoutEnabled === enabled) return; + layoutEnabled = enabled; + for (const zone of adapter.zones) { + zone.classList.toggle("mengsan-hand-layout-shuying", enabled); + } + for (const container of adapter.containers.filter(Boolean)) { + container.classList.toggle("mengsan-hand-scroll-shuying", enabled); + } + if (!enabled) nativeLayout(); + } + function refresh() { + if (disposed) return; + if (!session.active || !adapter.zones.some(zone => zone.isConnected)) { + dispose(); + return; + } + const supported = adapter.supported(); + setLayout(supported && !nativeInput); + if (!supported && !warned) { + warned = true; + log("当前容器不支持独立排列,已保留原排列与费用/强化标记"); + } + overlays.sync(adapter.cards()); + } + function listen(target, type, listener, options) { + target.addEventListener(type, listener, options); + restores.push(() => target.removeEventListener(type, listener, options)); + } + function beginInput(event) { + const card = event.target.closest?.(".card"); + if (event.button !== 0 || pointer || + !adapter.zones.includes(card?.parentNode)) { + return; + } + pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, + touch: event.pointerType === "touch" }; + } + function moveInput(event) { + if (!pointer || pointer.id !== event.pointerId || nativeInput) return; + if (Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) < 8) { + return; + } + if (pointer.touch && Math.abs(event.clientX - pointer.x) > + Math.abs(event.clientY - pointer.y)) return; + nativeInput = true; + setLayout(false); + } + function endInput(event) { + if (event?.pointerId != null && event.pointerId !== pointer?.id) return; + restoreTouchScroll(); + pointer = null; + if (disposed || !nativeInput) return; + nativeInput = false; + window.clearTimeout(refreshTimer); + refreshTimer = window.setTimeout(refresh, 0); + } + function restoreTouchScroll() { + for (const [container, hadScroll] of touchScrollClasses) { + if (!hadScroll) container.classList.remove("scrollh"); + } + touchScrollClasses.clear(); + } + function scrollWheel(event) { + if (!layoutEnabled || event.ctrlKey || event.metaKey) return; + const container = event.currentTarget; + if (container.scrollWidth <= container.clientWidth) return; + const scale = event.deltaMode === 1 ? 16 : + event.deltaMode === 2 ? container.clientWidth : 1; + container.scrollLeft += (event.deltaX || event.deltaY) * scale; + event.preventDefault(); + event.stopImmediatePropagation(); + } + function allowTouchScroll(event) { + const container = event.currentTarget; + if (!layoutEnabled || container.scrollWidth <= container.clientWidth) { + return; + } + if (!touchScrollClasses.has(container)) { + touchScrollClasses.set(container, + container.classList.contains("scrollh")); + } + container.classList.add("scrollh"); + } + function dispose() { + if (disposed) return; + disposed = true; + finishStyleLoad?.(); + window.clearTimeout(refreshTimer); + restoreTouchScroll(); + observer?.disconnect(); + resizeObserver?.disconnect(); + overlays.dispose(); + setLayout(false); + for (const restore of restores.reverse()) restore(); + style.remove(); + pointer = null; + diagnose("已卸载手牌 UI"); + } + session.ownResource({}, dispose); + try { + await new Promise((resolve, reject) => { + const timer = window.setTimeout(() => finish( + new Error("梦三手牌样式加载超时") + ), 10000); + const finish = error => { + window.clearTimeout(timer); + finishStyleLoad = null; + style.onload = style.onerror = null; + if (error) reject(error); + else resolve(); + }; + finishStyleLoad = finish; + style.onload = () => finish(); + style.onerror = () => finish(new Error("梦三手牌样式加载失败")); + document.head.appendChild(style); + }); + if (!session.active || disposed) { dispose(); return { refresh, dispose }; } + for (const zone of adapter.zones) { + ownClass(zone, "mengsan-hand-zone-shuying"); + } + for (const container of adapter.containers.filter(Boolean)) { + if (!container.hasAttribute("tabindex")) { + ownAttribute(container, "tabindex", "0"); + } + ownAttribute(container, "aria-label", "梦三手牌,可横向滚动"); + listen(container, "wheel", scrollWheel, + { capture: true, passive: false }); + listen(container, "touchmove", allowTouchScroll, true); + listen(container, "touchend", () => restoreTouchScroll(), true); + listen(container, "touchcancel", () => restoreTouchScroll(), true); + } + observer = new window.MutationObserver(refresh); + for (const zone of adapter.zones) { + observer.observe(zone, { childList: true }); + } + if (adapter.root?.parentNode) { + observer.observe(adapter.root.parentNode, { childList: true }); + } + if (window.ResizeObserver && adapter.root) { + resizeObserver = new window.ResizeObserver(refresh); + resizeObserver.observe(adapter.root); + } + if (window.PointerEvent && adapter.root) { + listen(adapter.root, "pointerdown", beginInput, true); + listen(window, "pointermove", moveInput, true); + listen(window, "pointerup", endInput, true); + listen(window, "pointercancel", endInput, true); + listen(window, "blur", () => endInput()); + } + refresh(); + diagnose(`已挂载手牌 UI,独立排列=${layoutEnabled}`); + return { refresh, dispose }; + } catch (error) { + log("必需模块挂载失败,停止进入战斗", error); + dispose(); + throw error; + } +} diff --git a/mengsan/ui/hand/overlays.js b/mengsan/ui/hand/overlays.js new file mode 100644 index 0000000..f23cea8 --- /dev/null +++ b/mengsan/ui/hand/overlays.js @@ -0,0 +1,53 @@ +import { cardUpgradeLevel } from "../../cards/upgrades.js"; + +export function createHandOverlays(document, cardCost) { + const entries = new Map(); + const setData = (node, key, value) => { + if (node.dataset[key] !== value) node.dataset[key] = value; + }; + function refresh(card) { + if (!card.name) return; + let entry = entries.get(card); + if (!entry) { + const cost = document.createElement("span"); + cost.className = "mengsan-hand-cost-shuying"; + cost.setAttribute("role", "img"); + const upgrade = document.createElement("span"); + upgrade.className = "mengsan-hand-upgrade-shuying"; + upgrade.setAttribute("role", "img"); + entry = { cost, upgrade }; + entries.set(card, entry); + } + const cost = String(cardCost(card)); + setData(entry.cost, "mengsanCost", cost); + entry.cost.setAttribute("aria-label", `费用 ${cost}`); + if (entry.cost.parentNode !== card) card.appendChild(entry.cost); + const level = cardUpgradeLevel(card); + if (level) { + setData(entry.upgrade, "mengsanUpgrade", String(level)); + entry.upgrade.setAttribute("aria-label", `已强化${level}次`); + if (entry.upgrade.parentNode !== card) card.appendChild(entry.upgrade); + } else { + entry.upgrade.remove(); + } + } + function release(card) { + const entry = entries.get(card); + if (!entry) return; + entry.cost.remove(); + entry.upgrade.remove(); + entries.delete(card); + } + return { + sync(cards) { + const current = new Set(cards); + for (const card of entries.keys()) { + if (!current.has(card)) release(card); + } + for (const card of cards) refresh(card); + }, + dispose() { + for (const card of entries.keys()) release(card); + }, + }; +} diff --git a/mengsan/ui/hand/style.css b/mengsan/ui/hand/style.css new file mode 100644 index 0000000..c7291aa --- /dev/null +++ b/mengsan/ui/hand/style.css @@ -0,0 +1,111 @@ +.mengsan-hand-zone-shuying { + --mengsan-hand-gold: #d8ba83; + --mengsan-hand-jade: #234844; + --mengsan-hand-ink: #fff1cb; +} +.mengsan-hand-scroll-shuying { + overflow-x: auto !important; + overflow-y: hidden !important; + scrollbar-width: thin; + scrollbar-color: #d8ba83 #234844; + overscroll-behavior-x: contain; + touch-action: pan-x; +} +.mengsan-hand-scroll-shuying:focus-visible { + outline: 2px solid #d8ba83; + outline-offset: -2px; +} +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying { + position: relative !important; + display: flex !important; + align-items: flex-end; + gap: 8px; + width: max-content !important; + min-width: 100% !important; + height: 100% !important; + left: 0 !important; + top: auto !important; + bottom: 0 !important; + margin: 0 !important; + padding: 24px 8px 6px !important; + box-sizing: border-box; +} +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card { + position: relative !important; + flex: 0 0 auto; + width: 90px !important; + height: 110px !important; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; + margin: 0 !important; + transform: none !important; + transition: transform 120ms ease-out !important; +} +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.selected { + transform: translateY(-18px) !important; + outline: 2px solid var(--mengsan-hand-gold); + outline-offset: -2px; +} +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.removing, +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.display-none { + display: none !important; +} +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card > .info, +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card > .name { + transform: none !important; +} +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card > .info > span { + display: inline !important; +} +.mengsan-hand-zone-shuying > .card > .mengsan-hand-cost-shuying, +.mengsan-hand-zone-shuying > .card > .mengsan-hand-upgrade-shuying { + position: absolute !important; + display: grid !important; + place-items: center; + z-index: 8 !important; + margin: 0 !important; + box-sizing: border-box; + pointer-events: none; + writing-mode: horizontal-tb !important; + font: 700 13px/1 "Microsoft YaHei", sans-serif; + box-shadow: 0 2px 4px #0008; +} +.mengsan-hand-zone-shuying > .card > .mengsan-hand-cost-shuying { + top: 48% !important; + left: 3px !important; + width: 24px; + height: 24px; + border: 1px solid var(--mengsan-hand-gold); + border-radius: 50%; + background: var(--mengsan-hand-jade); + color: var(--mengsan-hand-ink); +} +.mengsan-hand-zone-shuying > .card > .mengsan-hand-cost-shuying::before { + content: attr(data-mengsan-cost); +} +.mengsan-hand-zone-shuying > .card > .mengsan-hand-upgrade-shuying { + top: 4px !important; + right: 4px !important; + width: 26px; + height: 20px; + border: 1px solid #86bd85; + border-radius: 4px; + background: #214f36; + color: #f5ffea; +} +.mengsan-hand-zone-shuying > .card > .mengsan-hand-upgrade-shuying::before { + content: "+" attr(data-mengsan-upgrade); +} +@media (max-width: 640px), (max-height: 500px) { + .mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card { + width: 80px !important; + height: 100px !important; + } +} +@media (prefers-reduced-motion: reduce) { + .mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card { + transition: none !important; + } +} diff --git a/mengsan/ui/style.css b/mengsan/ui/style.css index f31adda..33572d9 100644 --- a/mengsan/ui/style.css +++ b/mengsan/ui/style.css @@ -1371,47 +1371,6 @@ body.mengsan-battle-ui-shuying :is( height: 22px; object-fit: contain; } -/* Own a child badge, not card::after (which 十周年 UI reserves and hides). */ -.handcards > .card[data-mengsan-cost] > .mengsan-hand-cost-shuying { - position: absolute !important; - top: 55px !important; - left: 3px !important; - z-index: 7 !important; - display: grid !important; - place-items: center; - width: 24px; - height: 24px; - border: 1px solid #d8ba83; - border-radius: 50%; - background: #234844f2; - box-shadow: 0 2px 4px #0008; - color: #fff1cb; - font: 700 14px/1 "Microsoft YaHei", sans-serif; - pointer-events: none; -} -.handcards > .card[data-mengsan-cost] > .mengsan-hand-cost-shuying::before { - content: attr(data-mengsan-cost); -} -.handcards > .card[data-mengsan-upgrade] > .mengsan-hand-upgrade-shuying { - position: absolute !important; - top: 4px !important; - right: 4px !important; - z-index: 8 !important; - display: grid !important; - place-items: center; - width: 26px; - height: 20px; - border: 1px solid #86bd85; - border-radius: 4px; - background: #214f36; - box-shadow: 0 1px 3px #0008; - color: #f5ffea; - font: 700 13px/1 "Microsoft YaHei", sans-serif; - pointer-events: none; -} -.handcards > .card[data-mengsan-upgrade] > .mengsan-hand-upgrade-shuying::before { - content: "+" attr(data-mengsan-upgrade); -} .mengsan-battle-relics-button-shuying { top: 162px; } .mengsan-relic-dialog-shuying { max-width: 620px; } .mengsan-relic-list-shuying { overflow-y: auto; padding: 12px 0; } diff --git a/tools/generate_manifest.py b/tools/generate_manifest.py index 3691de0..9af93c3 100644 --- a/tools/generate_manifest.py +++ b/tools/generate_manifest.py @@ -16,6 +16,7 @@ EXCLUDE_DIRS = { "dist", "tools", "__pycache__", + "verification", } EXCLUDE_FILES = {".gitignore", "log.txt"} EXCLUDE_EXTENSIONS = {".md", ".mjs"}