mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-01 09:19:58 +08:00
同上
This commit is contained in:
+2
-1
@@ -14,4 +14,5 @@ tools/*.mjs
|
||||
|
||||
/Slay the Spire 2
|
||||
|
||||
*.mjs
|
||||
*.mjs
|
||||
*.cjs
|
||||
@@ -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 点伤害,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 的无界面独立实例。
|
||||
@@ -1,5 +1,10 @@
|
||||
# 梦三手牌 UI 子系统规划大纲
|
||||
|
||||
首期实现已归入 `ui/hand/`,是梦三必须依赖的包内子模块,
|
||||
没有单独关闭开关。实现及验证状态见
|
||||
[手牌 UI 子扩展说明](ui-hand-subextension.md)。
|
||||
下文保留规划与完整游戏验收项;隔离测试不等同于游戏内实测。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
- 当前手牌 UI 与无名杀真实卡牌节点的关系
|
||||
|
||||
+13
-2
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -16,6 +16,7 @@ EXCLUDE_DIRS = {
|
||||
"dist",
|
||||
"tools",
|
||||
"__pycache__",
|
||||
"verification",
|
||||
}
|
||||
EXCLUDE_FILES = {".gitignore", "log.txt"}
|
||||
EXCLUDE_EXTENSIONS = {".md", ".mjs"}
|
||||
|
||||
Reference in New Issue
Block a user