fixed # 补全梦三怪物、卡牌

fixed # 修复天书乱斗中,过关技能不会重置次数的问题
This commit is contained in:
2026-10-03 04:42:19 +08:00
parent b005a7ff15
commit c448da39c2
131 changed files with 5021 additions and 246 deletions
+101 -19
View File
@@ -1,9 +1,13 @@
import { lib, get, ui } from "../../../../noname.js";
import { cardDefinitions } from "../cards/card-definitions.js";
import { DAZED_NAME } from "../cards/status-cards.js";
import { SLIMED_NAME } from "../cards/slimed-card.js";
import { cardCost } from "../battle/combat-rules.js";
import { AFFIX_INFO } from "../cards/affixes.js";
import { cardUpgradeLevel, cardUpgradeRule } from "../cards/upgrades.js";
const suits = { spade: "♠ 黑桃", heart: "♥ 红桃", club: "♣ 梅花", diamond: "♦ 方块" };
const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" };
const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌", status: "状态牌" };
const affixes = Object.fromEntries(Object.entries(AFFIX_INFO).map(([key, info]) => [key, `${info.name}:${info.description}`]));
const text = value => {
const template = document.createElement("template");
@@ -31,6 +35,28 @@ export function describeLibraryCard(card) {
};
}
// 只读图鉴数据,不创建实体牌、伪造花色点数或写入永久牌组。
export function getCardCatalog() {
const states = Object.entries(lib.card).filter(([name, info]) =>
/^mengsan_[a-z0-9_]+$/.test(name) && info?.type === "status" &&
/^ext:术樱包\/mengsan\/assets\/cards\/[a-z0-9_]+\.png$/.test(info.image)).map(([name]) => name);
return [...new Set([...Object.keys(cardDefinitions), DAZED_NAME, SLIMED_NAME, ...states])]
.map(name => ({ name, nature: null, affixes: [], upgrade: 0 }));
}
const categories = { damage: "攻击", utility: "辅助", recovery: "恢复", status: "状态" };
function describeCatalogCard(card) {
const details = describeLibraryCard(card), info = lib.card[card.name];
const definition = cardDefinitions[card.name];
const category = info?.type === "status" || card.name === DAZED_NAME || card.name === SLIMED_NAME
? "status" : definition?.category || "utility";
const unplayable = info?.enable === false;
const cost = info && !unplayable ? cardCost(card) : null;
return { ...details, category, categoryName: categories[category], cost, unplayable,
costLabel: !info ? "未加载" : unplayable ? "不可打出" : `${cost}费`,
exclusive: definition?.owner ? translate(definition.owner) : "" };
}
function element(tag, className, value, parent) {
const node = document.createElement(tag);
node.className = className;
@@ -47,9 +73,9 @@ function button(label, parent, action, className = "ms-deck-button") {
}
// Read-only DOM previews, never game.createCard or Card.init: no IDs, triggers or pile writes.
function preview(card, details, parent) {
function preview(card, details, parent, catalog = false) {
const face = element("div", "ms-deck-face", null, parent);
element("span", `ms-deck-corner${details.red ? " ms-deck-red" : ""}`, `${details.suit} ${details.number}`, face);
element("span", `ms-deck-corner${details.red ? " ms-deck-red" : ""}`, catalog ? details.costLabel : `${details.suit} ${details.number}`, face);
const art = element("div", "ms-deck-art", null, face);
element("span", "ms-deck-glyph", details.name, art);
const imageName = lib.card[card.name]?.cardimage || card.name;
@@ -66,9 +92,9 @@ function preview(card, details, parent) {
image.src = `${lib.assetURL || ""}${localArt || `image/card/${imageName}.png`}`;
}
element("strong", "ms-deck-name", details.name, face);
element("span", "ms-deck-meta", `${details.type} · ${details.nature}`, face);
element("span", "ms-deck-meta", catalog ? `${details.categoryName} · ${details.exclusive ? "专属:" + details.exclusive : details.category === "status" ? "战斗生成" : "通用"}` : `${details.type} · ${details.nature}`, face);
if (details.upgrade || details.affixes.length) {
element("span", "ms-deck-tags", [details.upgrade ? `强化 +${details.upgrade}` : "", ...details.affixes.map(value => value.split(":")[0])].filter(Boolean).join(" · "), face);
element("span", "ms-deck-tags", [details.upgrade ? `+${details.upgrade}` : "", ...details.affixes.map(value => value.split(":")[0])].filter(Boolean).join(" · "), face);
}
return face;
}
@@ -92,7 +118,39 @@ export function openCardLibrary(run, options = {}) {
if (previousFocus?.isConnected) previousFocus.focus();
};
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(); });
const filters = element("div", "ms-catalog-filters", null, dialog);
function field(label) {
const wrapper = element("label", "ms-catalog-field", null, filters);
element("span", "", label, wrapper); return wrapper;
}
const search = element("input", "ms-catalog-input", null, field("名称 / 效果"));
search.type = "search"; search.maxLength = 120; search.placeholder = "搜索卡牌";
search.setAttribute("aria-label", "搜索图鉴卡牌");
const categoryFilter = element("select", "ms-catalog-select", null, field("类型"));
categoryFilter.setAttribute("aria-label", "图鉴类型");
for (const [key, label] of Object.entries({ "": "全部类型", ...categories })) {
const option = element("option", "", label, categoryFilter); option.value = key;
}
const costFilter = element("select", "ms-catalog-select", null, field("费用"));
costFilter.setAttribute("aria-label", "图鉴费用");
for (const [key, label] of [["", "全部费用"], ["0", "0费"], ["1", "1费"], ["2", "2费"], ["3+", "3费及以上"], ["unplayable", "不可打出"]]) {
const option = element("option", "", label, costFilter); option.value = key;
}
button("清空筛选", filters, () => {
search.value = ""; categoryFilter.value = ""; costFilter.value = "";
render(); search.focus();
});
search.addEventListener("input", render);
categoryFilter.addEventListener("change", render);
costFilter.addEventListener("change", render);
const summary = element("p", "ms-deck-summary", null, dialog);
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 : [];
@@ -105,40 +163,64 @@ export function openCardLibrary(run, options = {}) {
detailPanel.replaceChildren(); body.scrollTop = scrollTop;
selectedButton?.focus({ preventScroll: true });
}
function showDetail(card, details, owner) {
function showDetail(card, details, owner, catalog = false) {
selectedButton = owner; scrollTop = body.scrollTop;
detail = true; list.hidden = true; detailPanel.hidden = false;
detailPanel.replaceChildren();
const backButton = button("返回牌库", detailPanel, back);
const backButton = button(catalog ? "返回图鉴" : "返回牌库", detailPanel, back);
const content = element("div", "ms-deck-detail-content", null, detailPanel);
preview(card, details, content);
preview(card, details, content, catalog);
const copy = element("div", "ms-deck-detail-copy", null, content);
element("h3", "", details.name, copy);
element("p", "", `${details.suit} ${details.number} · ${details.type} · ${details.nature}`, copy);
element("p", "", catalog ? `${details.costLabel} · ${details.categoryName} · ${details.type}` :
`${details.suit} ${details.number} · ${details.type} · ${details.nature}`, copy);
if (catalog && details.exclusive) element("p", "ms-deck-rules", `专属角色:${details.exclusive}`, copy);
element("h4", "", "卡牌介绍", copy);
element("p", "ms-deck-rules", details.description, copy);
if (!details.available) element("p", "ms-deck-warning", "该卡牌定义未加载;保留存档记录,不删除此牌。", copy);
element("p", "", details.upgradeLimit
? `强化:${details.upgrade}/${details.upgradeLimit}`
: "此牌不可强化", copy);
for (const affix of details.affixes) element("p", "ms-deck-rules", affix, copy);
body.scrollTop = 0; backButton.focus();
}
function render() {
detail = false; list.hidden = false; detailPanel.hidden = true;
detailPanel.replaceChildren(); list.replaceChildren(); body.scrollTop = 0;
const sections = options.sections ? options.sections() : [{ title: "", cards: current }];
summary.textContent = options.sections ? "剩余牌堆按从左到右、从上到下的顺序摸取;第 1 张为下一张。" : `本次征程实际携带 · 共 ${current.length} 张 · 同名不同花色、点数分别展示`;
selectedButton = null;
const catalog = !options.sections && view === "catalog";
filters.hidden = !catalog;
deckTab.setAttribute("aria-pressed", String(!catalog));
catalogTab.setAttribute("aria-pressed", String(catalog));
list.classList.toggle("ms-catalog-grid", catalog);
let records = [];
if (catalog) {
const query = search.value.trim().toLocaleLowerCase();
records = getCardCatalog().map(card => ({ card, details: describeCatalogCard(card) }));
const total = records.length;
records = records.filter(({ card, details }) =>
(!query || `${details.name} ${card.name} ${details.description}`.toLocaleLowerCase().includes(query)) &&
(!categoryFilter.value || details.category === categoryFilter.value) &&
(!costFilter.value || (costFilter.value === "unplayable" ? details.unplayable :
costFilter.value === "3+" ? details.cost !== null && details.cost >= 3 :
details.cost !== null && details.cost === Number(costFilter.value))));
summary.textContent = `梦三卡牌图鉴 · 共 ${total} 种 · 当前显示 ${records.length} 种`;
} else {
summary.textContent = options.sections ? "剩余牌堆按从左到右、从上到下的顺序摸取;第 1 张为下一张。" :
`本次征程实际携带 · 共 ${current.length} 张 · 同名不同花色、点数分别展示`;
}
footer.textContent = catalog ? "图鉴仅供查阅,不会获得卡牌或改变征程牌组 · 点击卡牌查看完整介绍" :
"点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序";
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", section.ordered ? "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。" : "此处暂无卡牌。", list);
if (!cards.length) element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : section.ordered ? "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。" : "此处暂无卡牌。", list);
cards.forEach((card, index) => {
const details = describeLibraryCard(card);
const tile = button("", list, () => showDetail(card, details, tile), "ms-deck-card");
tile.setAttribute("aria-label", `${section.ordered ? `第 ${index + 1} 张,` : ""}${details.name},${details.suit}${details.number},${details.nature},查看详情`);
const details = catalog ? records[index].details : describeLibraryCard(card);
const tile = button("", list, () => showDetail(card, details, tile, catalog), "ms-deck-card");
tile.setAttribute("aria-label", catalog ? `${details.name},${details.categoryName},${details.costLabel},查看详情` :
`${section.ordered ? `第 ${index + 1} 张,` : ""}${details.name},${details.suit}${details.number},${details.nature},查看详情`);
if (section.ordered) element("span", "ms-deck-order", index === 0 ? "1 · 下一张" : `${index + 1}`, tile);
preview(card, details, tile);
preview(card, details, tile, catalog);
element("span", "ms-deck-excerpt", details.description, tile);
});
}
+89
View File
@@ -0,0 +1,89 @@
// 只覆盖命名空间内的 CSS 变量,不移动或复制真实卡牌。
export function fanSlots(count, width, cardWidth = 112, cardHeight = 156) {
if (!Number.isSafeInteger(count) || count < 0 || !Number.isFinite(width) || width <= 0) return [];
const step = count > 1 ? Math.min(cardWidth * 0.68,
Math.max(0, width - cardWidth - 32) / (count - 1)) : 0;
const half = (count - 1) / 2;
const angleStep = count > 1 ? Math.min(4, 13 / half) : 0;
return Array.from({ length: count }, (_, index) => {
const offset = index - half;
return { x: width / 2 + offset * step - cardWidth / 2,
y: -cardHeight + 28 + (half ? Math.pow(offset / half, 2) * 20 : 0),
angle: offset * angleStep, z: index + 1 };
});
}
export function createHandFan(adapter, env) {
const { window } = env;
const owned = new Map();
let enabled = false, hover = null, dragging = null;
function variable(card, key, value) {
let record = owned.get(card);
if (!record) { record = new Map(); owned.set(card, record); }
if (!record.has(key)) record.set(key, {
before: card.style.getPropertyValue(key), priority: card.style.getPropertyPriority(key), last: null,
});
const entry = record.get(key);
entry.last = value;
if (card.style.getPropertyValue(key) !== value) card.style.setProperty(key, value);
}
function release(card) {
for (const name of ["mengsan-hand-hover-shuying", "mengsan-hand-drag-shuying", "mengsan-hand-free-drag-shuying"]) card.classList.remove(name);
for (const [key, record] of owned.get(card) || []) {
if (card.style.getPropertyValue(key) !== record.last) continue;
if (record.before) card.style.setProperty(key, record.before, record.priority);
else card.style.removeProperty(key);
}
owned.delete(card);
}
function setHover(card) {
if (hover === card) return;
hover?.classList.remove("mengsan-hand-hover-shuying");
hover = enabled && !dragging ? card : null;
hover?.classList.add("mengsan-hand-hover-shuying");
}
function sync(active) {
enabled = active;
const cards = adapter.cards().filter(card => !card.classList.contains("display-none"));
const current = new Set(cards);
for (const card of owned.keys()) if (!active || !current.has(card)) release(card);
if (!active) { setHover(null); return; }
const small = window.innerWidth <= 640 || window.innerHeight <= 500;
const width = adapter.containers[0]?.clientWidth || adapter.root.clientWidth;
const slots = fanSlots(cards.length, width, small ? 82 : 112, small ? 114 : 156);
cards.forEach((card, index) => {
const slot = slots[index];
if (!slot) return;
variable(card, "--mengsan-fan-x", `${slot.x}px`);
variable(card, "--mengsan-fan-y", `${slot.y}px`);
variable(card, "--mengsan-fan-angle", `${slot.angle}deg`);
variable(card, "--mengsan-fan-z", `${slot.z}`);
});
if (hover && !current.has(hover)) setHover(null);
}
function drag(card, point, targeted) {
if (dragging && dragging !== card) stopDrag();
setHover(null);
dragging = card;
card.classList.add("mengsan-hand-drag-shuying");
card.classList.toggle("mengsan-hand-free-drag-shuying", !targeted);
const zone = card.parentNode;
const rect = zone.getBoundingClientRect();
// CSS 像素相对于真实父容器:兼容 documentZoom/缩放,不能直接拿屏幕坐标当布局坐标。
const scaleX = rect.width / zone.offsetWidth || 1;
const scaleY = rect.height / zone.offsetHeight || scaleX;
const rootRect = adapter.root.getBoundingClientRect();
const small = window.innerWidth <= 640 || window.innerHeight <= 500;
const width = small ? 82 : 112, height = small ? 114 : 156;
const x = targeted ? rootRect.left + rootRect.width / 2 : point.x;
const y = targeted ? rootRect.bottom - height * scaleY / 2 - 6 : point.y;
variable(card, "--mengsan-drag-x", `${(x - rect.left) / scaleX - width / 2}px`);
variable(card, "--mengsan-drag-y", `${(y - rect.top) / scaleY - height / 2}px`);
}
function stopDrag() {
dragging?.classList.remove("mengsan-hand-drag-shuying", "mengsan-hand-free-drag-shuying");
dragging = null;
}
return { sync, setHover, drag, stopDrag,
dispose() { stopDrag(); setHover(null); for (const card of owned.keys()) release(card); } };
}
+36 -6
View File
@@ -1,11 +1,15 @@
import { createHandAdapter } from "./adapter.js";
import { createHandOverlays } from "./overlays.js";
import { createHandDiagnostics } from "./diagnostics.js";
import { createHandFan } from "./fan.js";
import { createHandInput } from "./input.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 overlays = createHandOverlays(document, card => cardCost(card, player));
const fan = createHandFan(adapter, env);
let input = null;
const restores = [];
const diagnose = createHandDiagnostics(env.game, session.id);
const style = document.createElement("link");
@@ -58,6 +62,8 @@ export async function mountHandUI(player, session, env) {
}
const supported = adapter.supported();
setLayout(supported && !nativeInput);
fan.sync(supported && !nativeInput);
input?.reconcile();
if (!supported && !warned) {
warned = true;
log("当前容器不支持独立排列,已保留原排列与费用/强化标记");
@@ -68,17 +74,35 @@ export async function mountHandUI(player, session, env) {
target.addEventListener(type, listener, options);
restores.push(() => target.removeEventListener(type, listener, options));
}
// 不可使用的真实手牌不能触发旧手势的横排回退;选中牌保留原生交互。
function canBeginNativeInput(card) {
if (!adapter.zones.includes(card?.parentNode) ||
card.classList.contains("noclick") ||
(!card.classList.contains("selectable") &&
!card.classList.contains("selected"))) return false;
const current = env._status?.event;
return !(current?.name === "chooseToUse" && current.player === player &&
env.isActiveCardUse?.(current, player) &&
env.canPayCard?.(player, card) === false);
}
function beginInput(event) {
if (input?.owns()) return;
const card = event.target.closest?.(".card");
if (event.button !== 0 || pointer ||
!adapter.zones.includes(card?.parentNode)) {
!canBeginNativeInput(card)) {
return;
}
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY,
pointer = { card, 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 (input?.owns()) return;
if (!pointer || pointer.id !== event.pointerId) return;
if (!canBeginNativeInput(pointer.card)) { endInput(event); return; }
if (nativeInput) return;
// 未接管的上拖(包括响应牌或已选牌)不属于手牌排序,不退回横排。
const dx = event.clientX - pointer.x, dy = event.clientY - pointer.y;
if (dy < 0 && Math.abs(dy) >= Math.abs(dx)) return;
if (Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) < 8) {
return;
}
@@ -131,6 +155,8 @@ export async function mountHandUI(player, session, env) {
restoreTouchScroll();
observer?.disconnect();
resizeObserver?.disconnect();
input?.dispose();
fan.dispose();
overlays.dispose();
setLayout(false);
for (const restore of restores.reverse()) restore();
@@ -157,6 +183,8 @@ export async function mountHandUI(player, session, env) {
document.head.appendChild(style);
});
if (!session.active || disposed) { dispose(); return { refresh, dispose }; }
ownClass(adapter.root, "mengsan-hand-root-shuying");
ownClass(document.body, "mengsan-hand-ui-shuying");
for (const zone of adapter.zones) {
ownClass(zone, "mengsan-hand-zone-shuying");
}
@@ -164,7 +192,7 @@ export async function mountHandUI(player, session, env) {
if (!container.hasAttribute("tabindex")) {
ownAttribute(container, "tabindex", "0");
}
ownAttribute(container, "aria-label", "梦三手牌,可横向滚动");
ownAttribute(container, "aria-label", "梦三扇形手牌,悬停查看;上拖出牌,拖向目标后松手确认");
listen(container, "wheel", scrollWheel,
{ capture: true, passive: false });
listen(container, "touchmove", allowTouchScroll, true);
@@ -173,8 +201,10 @@ export async function mountHandUI(player, session, env) {
}
observer = new window.MutationObserver(refresh);
for (const zone of adapter.zones) {
observer.observe(zone, { childList: true });
observer.observe(zone, { childList: true, subtree: true,
attributes: true, attributeFilter: ["class"] });
}
input = createHandInput(player, session, adapter, fan, env, refresh, log);
if (adapter.root?.parentNode) {
observer.observe(adapter.root.parentNode, { childList: true });
}
+255
View File
@@ -0,0 +1,255 @@
// 梦三局部输入适配:使用真实 ui.click/card/target 和 game.check 提交,不自行 useCard。
export function createHandInput(player, session, adapter, fan, env, refresh, log) {
const { ui, game, get, _status: status, document, window } = env;
const listeners = [];
const maskedControls = new Set(), submittedControls = new Set();
const maskClass = "mengsan-hand-controls-hidden-shuying";
function maskControls(hidden) {
const wanted = hidden ? new Set([ui.control, ui.confirm, gesture?.event.endButton]) : new Set();
for (const node of maskedControls) {
if (wanted.has(node)) continue;
node.classList.remove(maskClass); maskedControls.delete(node);
}
// ui.control 是不同 UI 共同的控件入口;独立确认/结束按钮也可能挂在外部。
for (const node of wanted) {
if (!node?.classList || node.classList.contains(maskClass)) continue;
node.classList.add(maskClass); maskedControls.add(node);
}
}
function closeSubmittedControls(state) {
for (const node of new Set([ui.confirm, state.event.endButton])) {
if (typeof node?.close !== "function") continue;
if (node.classList && !node.classList.contains("mengsan-hand-submitted-control-shuying")) {
node.classList.add("mengsan-hand-submitted-control-shuying"); submittedControls.add(node);
}
// 与本体 windowmouseup 的提交路径一致:关闭旧确认后再调用 ui.click.ok。
node.close();
}
}
let gesture = null, suppressed = null, aim = null, frame = null, lastPoint = null;
const listen = (node, type, handler, options = true) => {
const safeHandler = event => {
try { handler(event); }
catch (error) {
log("手牌局部输入异常,取消本次拖动", error);
try { cleanup(true); }
catch (cause) { log("取消输入时引擎异常", cause); }
}
};
node.addEventListener(type, safeHandler, options);
listeners.push(() => node.removeEventListener(type, safeHandler, options));
};
const swallow = event => { event.preventDefault(); event.stopImmediatePropagation(); };
const live = state => session.active && adapter.supported() && state?.card.isConnected &&
adapter.zones.includes(state.card.parentNode) && status.event === state.event &&
!state.event.skill && state.event.isMine?.() && player.isAlive?.() !== false;
// 引擎 checkEnd 的自动确认必须在选牌/试选目标期间禁用;仅在本次同步调用内改标志。
function engineCall(callback) {
const previous = {};
for (const key of ["touchnocheck", "clicked", "dragged"]) previous[key] = { own: Object.hasOwn(status, key), value: status[key] };
status.touchnocheck = true; status.clicked = false; status.dragged = false;
try { return callback(); }
finally {
for (const [key, old] of Object.entries(previous)) {
if (old.own) status[key] = old.value;
else delete status[key];
}
}
}
function range(event) {
const value = get.select(event.selectTarget);
if (!Array.isArray(value) || value.length < 2 || value.some(n => !Number.isFinite(n))) return null;
return value;
}
function eligible(card) {
const event = status?.event;
if (!event || typeof ui.click?.card !== "function" || typeof ui.click?.ok !== "function" ||
typeof get?.select !== "function" || typeof env.isActiveCardUse !== "function" ||
!session.active || !adapter.supported() || ui.intro || game.online ||
event.name !== "chooseToUse" || event.player !== player || !event.isMine?.() ||
event.skill || event.complexSelect || event.custom?.replace?.card || event.custom?.replace?.target || event.custom?.replace?.confirm ||
!env.isActiveCardUse(event, player) || !env.canPayCard?.(player, card) ||
ui.selected.cards.length || ui.selected.targets.length || ui.selected.buttons.length ||
!card.classList.contains("selectable") || card.classList.contains("noclick") ||
status.mousedragging || status.dragged || status.paused2) return false;
const cards = get.select(event.selectCard);
return cards?.[0] === 1 && cards?.[1] === 1;
}
function removeAim() { aim?.remove(); aim = null; }
function showAim(card, point) {
if (!aim) {
aim = document.createElementNS("http://www.w3.org/2000/svg", "svg");
aim.classList.add("mengsan-hand-aim-shuying");
aim.setAttribute("aria-hidden", "true");
const path = document.createElementNS(aim.namespaceURI, "path");
aim.appendChild(path); document.body.appendChild(aim);
}
const rect = card.getBoundingClientRect();
const x = rect.left + rect.width / 2, y = rect.top + 12;
aim.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
// clientX/Y 与卡牌矩形都是视口坐标,SVG 本身却可能受到 body zoom/transform 影响。
// 用 SVG 实际屏幕矩阵反变换,不能重复除 documentZoom 或假定 SVG 没有偏移。
const matrix = aim.getScreenCTM();
if (!matrix) { removeAim(); return; }
const inverse = matrix.inverse();
const start = new window.DOMPoint(x, y).matrixTransform(inverse);
const end = new window.DOMPoint(point.x, point.y).matrixTransform(inverse);
if (![start.x, start.y, end.x, end.y].every(Number.isFinite)) { removeAim(); return; }
// 十周年金色细线(本地 CONFIG.LINE_COLOR/LINE_WIDTH),不采用尖塔箭头/骨节造型。
aim.firstElementChild.setAttribute("d", `M ${start.x} ${start.y} L ${end.x} ${end.y}`);
aim.firstElementChild.setAttribute("vector-effect", "non-scaling-stroke");
}
function clearSelection(state) {
if (!live(state) || !state.selected) return;
engineCall(() => { game.uncheck(); game.check(); });
}
function cleanup(cancel = false) {
const state = gesture;
gesture = null;
if (frame != null) window.cancelAnimationFrame(frame);
frame = null; lastPoint = null;
removeAim(); fan.stopDrag();
maskControls(false);
if (cancel && state) clearSelection(state);
if (state) suppressed = { card: state.card, until: Date.now() + 450,
x: state.endX ?? state.x, y: state.endY ?? state.y };
refresh();
}
function start(event) {
if (event.button !== 0 || !event.isPrimary || gesture) return;
const card = event.target.closest?.(".card");
if (!card || !adapter.zones.includes(card.parentNode) || !eligible(card)) return;
// 在 window 捕获,早于十周年的手牌排序和本体的 windowmousedown;不改全局处理器。
gesture = { card, event: status.event, id: event.pointerId,
x: event.clientX, y: event.clientY, moved: false, selected: false, targeted: false, manual: false };
swallow(event);
}
function targetAt(point) {
const node = document.elementFromPoint(point.x, point.y)?.closest?.(".player");
return game.players?.includes(node) ? node : null;
}
function chooseTarget(state, point) {
if (!state.targeted || state.manual) return;
const target = targetAt(point);
for (const previous of ui.selected.targets.slice()) {
if (previous !== target) engineCall(() => ui.click.target.call(previous));
}
if (target?.classList.contains("selectable") && !ui.selected.targets.includes(target)) {
engineCall(() => ui.click.target.call(target));
}
}
function paint() {
frame = null;
const state = gesture, point = lastPoint;
if (!state || !point || !state.moved) return;
if (!live(state) || !env.canPayCard(player, state.card)) { cleanup(true); return; }
fan.drag(state.card, point, state.targeted);
if (state.targeted) showAim(state.card, point);
chooseTarget(state, point);
maskControls(!state.manual);
}
function move(event) {
const state = gesture;
if (!state || event.pointerId !== state.id) return;
swallow(event);
if (!live(state)) { cleanup(true); return; }
const point = { x: event.clientX, y: event.clientY };
if (!state.moved && Math.hypot(point.x - state.x, point.y - state.y) < 9) return;
if (!state.moved) {
state.moved = true;
engineCall(() => ui.click.card.call(state.card));
state.selected = ui.selected.cards.includes(state.card);
const targets = range(state.event);
if (!state.selected || !live(state) || !targets) { cleanup(true); return; }
state.targeted = targets[1] > 0;
// 多目标保留选牌和原生确认,不擅自把一条线当作已选全部目标。
state.manual = state.targeted && (targets[0] !== 1 || targets[1] !== 1);
}
lastPoint = point;
if (frame == null) frame = window.requestAnimationFrame(() => {
try { paint(); }
catch (error) { log("手牌拖动绘制失败", error); cleanup(true); }
});
}
function end(event) {
const state = gesture;
if (!state || state.id !== event.pointerId) return;
swallow(event);
state.endX = event.clientX; state.endY = event.clientY;
if (!live(state) || event.type === "pointercancel") { cleanup(true); return; }
if (!state.moved) {
cleanup();
// 短点击保持原生选牌/自动确认行为,不强制进入拖动。
status.clicked = false;
ui.click.card.call(state.card);
return;
}
lastPoint = { x: event.clientX, y: event.clientY };
paint();
if (!gesture) return;
const targets = range(state.event);
if (targets && targets[1] > 0 && (targets[0] !== 1 || targets[1] !== 1)) state.manual = true;
const ready = env.canPayCard(player, state.card) && ui.selected.cards.length === 1 &&
ui.selected.cards[0] === state.card && targets &&
(!state.targeted ? state.y - event.clientY >= 60 &&
event.clientY < adapter.root.getBoundingClientRect().top :
!state.manual && targets[0] === 1 && targets[1] === 1 &&
targetAt(lastPoint) === ui.selected.targets[0] && ui.selected.targets.length === 1);
if (ready && engineCall(() => game.check()) && live(state)) {
closeSubmittedControls(state);
cleanup();
ui.click.ok();
} else {
cleanup(!state.manual);
}
}
function compatibility(event) {
if (gesture || (event.type === "click" && suppressed && Date.now() < suppressed.until &&
(suppressed.card.contains(event.target) || Math.hypot(event.clientX - suppressed.x, event.clientY - suppressed.y) < 3))) swallow(event);
}
function hover(event) {
if (gesture || event.pointerType === "touch") return;
const card = event.target.closest?.(".card");
fan.setHover(adapter.zones.includes(card?.parentNode) ? card : null);
}
function cancel(event) {
if (!gesture) return;
if (event?.type === "keydown" && event.key !== "Escape") return;
if (event?.cancelable) swallow(event);
cleanup(true);
}
if (window.PointerEvent && adapter.root && status && get) {
listen(window, "pointerdown", start);
listen(window, "pointermove", move);
listen(window, "pointerup", end);
listen(window, "pointercancel", cancel);
listen(window, "blur", cancel);
listen(window, "keydown", cancel);
listen(window, "contextmenu", cancel);
for (const type of ["mousedown", "mousemove", "mouseup", "click", "touchstart", "touchmove", "touchend"]) {
listen(window, type, compatibility, { capture: true, passive: false });
}
}
listen(adapter.root, "pointerover", hover);
listen(adapter.root, "pointerleave", () => { if (!gesture) fan.setHover(null); });
return {
owns: () => Boolean(gesture),
reconcile() {
if (gesture && (!live(gesture) || !gesture.card.isConnected)) cleanup(true);
else if (gesture?.moved) maskControls(!gesture.manual);
for (const node of submittedControls) {
if (node.isConnected) continue;
node.classList.remove("mengsan-hand-submitted-control-shuying"); submittedControls.delete(node);
}
},
dispose() {
try { cleanup(true); }
catch (error) { log("手牌拖动取消失败", error); }
for (const release of listeners.reverse()) release();
maskControls(false);
for (const node of submittedControls) node.classList.remove("mengsan-hand-submitted-control-shuying");
submittedControls.clear();
suppressed = null;
},
};
}
+2 -1
View File
@@ -21,7 +21,8 @@ export function createHandOverlays(document, cardCost) {
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);
if (card.name === "mengsan_dazed_shuying") entry.cost.remove();
else if (entry.cost.parentNode !== card) card.appendChild(entry.cost);
const level = cardUpgradeLevel(card);
if (level) {
setData(entry.upgrade, "mengsanUpgrade", String(level));
+184 -22
View File
@@ -4,12 +4,21 @@
--mengsan-hand-ink: #fff1cb;
}
.mengsan-hand-scroll-shuying {
overflow-x: auto !important;
overflow-y: hidden !important;
overflow: visible !important;
left: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100% !important;
padding: 0 !important;
pointer-events: none;
scrollbar-width: thin;
scrollbar-color: #d8ba83 #234844;
overscroll-behavior-x: contain;
touch-action: pan-x;
touch-action: none;
}
.mengsan-hand-root-shuying {
overflow: visible !important;
}
.mengsan-hand-scroll-shuying:focus-visible {
outline: 2px solid #d8ba83;
@@ -17,37 +26,88 @@
}
.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;
display: block !important;
width: 100% !important;
height: 100% !important;
left: 0 !important;
top: auto !important;
bottom: 0 !important;
margin: 0 !important;
padding: 24px 8px 6px !important;
padding: 0 !important;
box-sizing: border-box;
pointer-events: none;
overflow: visible !important;
}
.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;
position: absolute !important;
width: 112px !important;
height: 156px !important;
left: 0 !important;
right: auto !important;
top: auto !important;
top: 100% !important;
bottom: auto !important;
margin: 0 !important;
transform: none !important;
transition: transform 120ms ease-out !important;
transform-origin: 50% 100% !important;
transform: translate(var(--mengsan-fan-x, 0px), var(--mengsan-fan-y, -128px)) rotate(var(--mengsan-fan-angle, 0deg)) !important;
z-index: var(--mengsan-fan-z, 1) !important;
transition: transform 160ms cubic-bezier(.2,.7,.3,1), box-shadow 160ms ease-out !important;
pointer-events: auto;
touch-action: none;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.selected {
transform: translateY(-18px) !important;
transform: translate(var(--mengsan-fan-x, 0px), calc(var(--mengsan-fan-y, -128px) - 20px)) rotate(var(--mengsan-fan-angle, 0deg)) !important;
outline: 2px solid var(--mengsan-hand-gold);
outline-offset: -2px;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.mengsan-hand-hover-shuying {
transform: translate(var(--mengsan-fan-x, 0px), calc(var(--mengsan-fan-y, -128px) - 62px)) rotate(0deg) scale(1.35) !important;
z-index: 220 !important;
box-shadow: 0 0 0 2px #b6e8de, 0 0 14px #68b6a58c, 0 12px 22px #0009;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.mengsan-hand-drag-shuying {
top: 0 !important;
transform: translate(var(--mengsan-drag-x), var(--mengsan-drag-y)) rotate(0deg) !important;
z-index: 240 !important;
pointer-events: none !important;
transition: none !important;
box-shadow: 0 0 0 2px #b6e8de, 0 0 14px #68b6a58c, 0 12px 22px #0009;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.mengsan-hand-free-drag-shuying {
transform: translate(var(--mengsan-drag-x), var(--mengsan-drag-y)) rotate(0deg) scale(1.22) !important;
transform-origin: 50% 50% !important;
}
.mengsan-hand-aim-shuying {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
pointer-events: none;
overflow: visible;
}
.mengsan-hand-aim-shuying > path {
fill: none;
stroke: rgb(250, 220, 140);
stroke-width: 2.6;
stroke-linecap: round;
filter: drop-shadow(0 0 2px #f5d25b);
}
/* 只在梦三手牌挂载期间隐藏十周年阶段、等待响应和对方思考图片。 */
body.mengsan-hand-ui-shuying [style*="extension/十周年UI/ui/assets/lbtn/tips/"]:is(
[style*="/tips/hhks."], [style*="/tips/zbjd."], [style*="/tips/pdjd."],
[style*="/tips/mpjd."], [style*="/tips/cpjd."], [style*="/tips/qpjd."],
[style*="/tips/jsjd."], [style*="/tips/hhjs."],
[style*="/tips/ddxy."], [style*="/tips/dfsk."]
) {
display: none !important;
}
body.mengsan-hand-ui-shuying :is(
.mengsan-hand-controls-hidden-shuying,
.mengsan-hand-submitted-control-shuying
) {
visibility: hidden !important;
pointer-events: none !important;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.removing,
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.display-none {
display: none !important;
@@ -86,8 +146,11 @@
content: attr(data-mengsan-cost);
}
.mengsan-hand-zone-shuying > .card > .mengsan-hand-upgrade-shuying {
top: 4px !important;
right: 4px !important;
/* 扇形叠牌露出左侧:角标排在花色下、费用上,不抬高整张牌的层级。 */
top: calc(max(48%, 64px) - 25px) !important;
left: 3px !important;
right: auto !important;
white-space: nowrap;
width: 26px;
height: 20px;
border: 1px solid #86bd85;
@@ -95,13 +158,27 @@
background: #214f36;
color: #f5ffea;
}
/* 只为带强化角标的真实手牌预留左侧信息栏,避免窄屏挤压。 */
.mengsan-hand-zone-shuying > .card:has(> .mengsan-hand-upgrade-shuying) > .mengsan-hand-cost-shuying {
top: max(48%, 64px) !important;
}
.mengsan-hand-zone-shuying > .card:has(> .mengsan-hand-upgrade-shuying) > .suit-num {
top: 6px !important;
}
@media (max-width: 640px), (max-height: 500px) {
.mengsan-hand-zone-shuying > .card:has(> .mengsan-hand-upgrade-shuying) > .suit-num {
top: 4px !important;
font-size: 20px !important;
line-height: 16px !important;
}
}
.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;
width: 82px !important;
height: 114px !important;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -109,3 +186,88 @@
transition: none !important;
}
}
/* 梦三本地竖幅立绘使用完整卡面;只协调梦三手牌里的原生显示节点。 */
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip) {
background-size: cover !important;
background-position: center !important;
}
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
display: block !important;
top: auto !important;
bottom: 8px !important;
left: auto !important;
right: 8px !important;
width: auto !important;
height: auto !important;
padding: 3px 6px !important;
margin: 0 !important;
writing-mode: horizontal-tb !important;
-webkit-writing-mode: horizontal-tb !important;
font-size: 16px !important;
line-height: 20px !important;
letter-spacing: 1px !important;
color: #fff1cb !important;
text-shadow: 0 1px 2px #000 !important;
background: #231b17e6 !important;
border-radius: 3px;
z-index: 7 !important;
pointer-events: none;
}
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) br {
display: none;
}
/* 十周年同时保留竖排名称与 top-name 时,只显示后者。 */
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip):has(> .top-name) > .name {
display: none !important;
}
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip) > :is(.info:not(.gaintag), .suit-num) {
top: 6px !important;
left: 6px !important;
right: auto !important;
bottom: auto !important;
width: auto !important;
height: auto !important;
margin: 0 !important;
padding: 2px 4px !important;
font-size: 18px !important;
line-height: 18px !important;
letter-spacing: 0 !important;
color: #231b17 !important;
text-shadow: none !important;
background: #fff1cbe6 !important;
border-radius: 3px;
z-index: 7 !important;
pointer-events: none;
}
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]) > .image,
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]) > .info:empty {
display: none !important;
}
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):is([data-suit="heart"], [data-suit="diamond"]):not(.infohidden):not(.infoflip) > :is(.info:not(.gaintag), .suit-num) {
color: #a52219 !important;
}
/* 强化卡牌的完整卡面样式优先级更高,窄屏同样为角标留出空隙。 */
@media (max-width: 640px), (max-height: 500px) {
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip):has(> .mengsan-hand-upgrade-shuying) > .suit-num {
top: 4px !important;
padding: 0 2px !important;
font-size: 18px !important;
line-height: 16px !important;
}
}
/* 长牌名完整显示,不挤出卡面;只作用于梦三手牌。 */
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
font-size: 12px !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
}
@media (max-width: 640px), (max-height: 500px) {
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
font-size: 10px !important;
padding: 2px 4px !important;
}
}
+24 -6
View File
@@ -91,9 +91,18 @@ export function mountEnemyIntent(player, session, assetBase, document,
throw new TypeError("濒死一击必须有伤害数值");
}
damageLine.hidden = !hasDamage;
const hasSummon = typeof intent.summon === "string";
const hasSlimed = Number.isInteger(intent.slimed) && intent.slimed > 0;
const hasInfection = Number.isInteger(intent.infection) && intent.infection > 0;
const hasDazed = Number.isInteger(intent.dazed) && intent.dazed > 0;
const hasDebuff = typeof intent.debuff === "string" &&
Number.isInteger(intent.stacks) && intent.stacks > 0;
debuffLine.hidden = !hasDebuff;
debuffLine.hidden = !(hasDebuff || hasDazed || hasSlimed || hasInfection);
const debuffSrc = `${assetBase}/assets/intent/${hasDazed || hasSlimed || hasInfection ? "Intent_status_card.png" : "Intent_debuff.png"}`;
if (debuffIcon.getAttribute("src") !== debuffSrc) debuffIcon.src = debuffSrc;
if (hasInfection) debuffAmount.textContent = `×${intent.infection}`;
if (hasSlimed) debuffAmount.textContent = `×${intent.slimed}`;
if (hasDazed) debuffAmount.textContent = `×${intent.dazed}`;
if (hasDebuff) {
const debuffText = `×${intent.stacks}`;
if (debuffAmount.textContent !== debuffText) {
@@ -102,8 +111,11 @@ export function mountEnemyIntent(player, session, assetBase, document,
}
const hasBlock = Number.isInteger(intent.block) && intent.block > 0;
const hasStrength = Number.isInteger(intent.strength) && intent.strength > 0;
const buffSrc = `${assetBase}/assets/intent/${hasSummon ? "Intent_summon.png" : "Intent_buff.png"}`;
if (buffIcon.getAttribute("src") !== buffSrc) buffIcon.src = buffSrc;
blockLine.hidden = !hasBlock;
buffLine.hidden = !hasStrength;
buffLine.hidden = !(hasStrength || hasSummon);
if (hasSummon) buffAmount.textContent = "×1";
if (hasBlock) blockAmount.textContent = String(intent.block);
if (hasStrength) {
buffAmount.textContent = `+${intent.strength}`;
@@ -136,20 +148,26 @@ export function mountEnemyIntent(player, session, assetBase, document,
}
if (hasDebuff) {
const label = intent.debuff === "脆弱" ? "Frail" :
intent.debuff === "易伤" ? "Vulnerable" : "Debuff";
intent.debuff === "易伤" ? "Vulnerable" : intent.debuff === "缩小" ? "Shrink" : intent.debuff === "缠结" ? "Tangled" : "Debuff";
effects.push(`${label} ×${intent.stacks}`);
}
if (hasSummon) effects.push("Summon: 利齿之眼 ×1");
if (hasDazed) effects.push(`Dazed ×${intent.dazed}`);
if (hasInfection) effects.push(`Infection ×${intent.infection}`);
if (hasSlimed) effects.push(`Slimed ×${intent.slimed}`);
if (hasBlock) effects.push(`Block ${intent.block}`);
if (hasStrength) effects.push(`Strength +${intent.strength}`);
if (!effects.length) {
damageLine.hidden = false;
amount.textContent = "";
const src = `${assetBase}/assets/intent/Intent_unknown.png`;
const src = `${assetBase}/assets/intent/${intent.sleep ? "Intent_sleep.png" : "Intent_unknown.png"}`;
if (icon.getAttribute("src") !== src) icon.src = src;
effects.push("Unknown");
effects.push(intent.sleep ? "Sleep" : "Unknown");
}
const label = effects.join(", ");
badge.title = "攻击与负面效果作用于所有存活敌对角色;伤害按各目标状态计算,护甲另行抵扣。";
badge.title = hasInfection ? `向敌对角色的个人弃牌堆加入${intent.infection}张感染;不能被打出,回合结束在手牌中每张造成3点非攻击伤害。` : intent.sleep ? "沉睡:本回合不行动,不消耗费用。" : intent.debuff === "缠结" ? "紧绕藤蔓:攻击后施加1回合缠结,攻击牌费用增加1;自身回合结束减少1回合。" : intent.shrink ? "缩小:攻击伤害减少30%,不重复叠加;来源死亡解除。" : hasSlimed ? `黏液:向敌对角色的个人弃牌堆加入${intent.slimed}张黏液;耗1费用、抽1张牌、消耗。` : hasSummon ? "虚幻孢子:召唤1只6生命的利齿之眼。" :
hasDazed ? "牵制:向敌对角色的个人弃牌堆加入3张晕眩;晕眩不能被打出,具有虚无。" :
"攻击与负面效果作用于所有存活敌对角色;伤害按各目标状态计算,护甲另行抵扣。";
if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label);
badge.hidden = false;
};
+3 -1
View File
@@ -305,7 +305,9 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
const back = menuContent.querySelector(".mengsan-map-menu-back-shuying");
if (back) back.disabled = busy;
}), ".mengsan-bonds-shuying button") });
createMenuAction({ icon: "book", name: "图鉴", description: "查看已发现的敌人、事件与奖励 · 开发中", onClick: () => showMenuPage("图鉴", "征程见闻与收集记录", parent => renderPlaceholder(parent, "后续将收录已发现的节点、敌人和奖励。")) });
createMenuAction({ icon: "book", name: "图鉴", description: "卡牌图鉴 · 查看全部梦三卡牌、效果与费用",
onClick: () => { closeCardLibrary = openCardLibrary(run, { view: "catalog", title: "卡牌图鉴", eyebrow: "梦三 · 图鉴" }); }
}).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);
[["已通过节点", run.statistics?.completedNodes || 0], ["击败敌人", run.statistics?.defeatedEnemies || 0], ["累计金币", run.statistics?.goldEarned || 0]].forEach(([label, value]) => {
+30 -20
View File
@@ -1,5 +1,4 @@
import { get } from "../../../../noname.js";
import { cardUpgradeRule } from "../cards/upgrades.js";
import { lib, get } from "../../../../noname.js";
import { getRelic } from "../relics/definitions.js";
import {
chooseCardDialog, chooseRelicDialog, chooseVictoryOptions,
@@ -8,12 +7,12 @@ import {
const upgradeDescription = choice => {
const card = choice.card;
const rule = cardUpgradeRule(card.name);
const damage = card.name === "sha" ? 6 : 8;
const info = lib.card[card.name];
const suit = get.translation(card.suit);
return `${suit}${card.number} · 伤害 ${damage} → ${rule.damage}` +
(rule.vulnerable ? `;易伤 2 → ${rule.vulnerable} 层` : "") +
"。费用不变,最多强化一次。";
// Display this card's current rules, not eligibility or upgrade comparisons.
const description = typeof info?.cardPrompt === "function"
? info.cardPrompt(card) : lib.translate[`${card.name}_info`] || "暂无卡牌介绍。";
return `${suit}${card.number} · ${description}`;
};
export async function chooseRewardPackage(choices, {
@@ -23,59 +22,70 @@ export async function chooseRewardPackage(choices, {
const upgradeChoices = pack.upgradeChoices.map(card => ({
id: card.id, card,
}));
const ready = () => (!upgradeChoices.length || cardId !== null) &&
(!pack.relicChoices.length || relicId !== null);
const upgrade = {
id: "upgrade", label: "强化一张牌",
description: upgradeChoices.length
? "从个人牌库中择一磨砺,查看强化前后的变化。"
? "从个人牌库中选择一张牌进行磨砺。"
: "当前没有可强化卡牌,此项无需选择。",
isDisabled: () => !upgradeChoices.length,
async choose() {
const selected = await chooseCardDialog(upgradeChoices, {
title: "磨砺 · 强化一张牌", skipLabel: "返回奖励",
title: "磨砺 · 强化一张牌", skipLabel: "跳过强化",
description: "择一磨砺,锋芒更盛。只强化你选中的一张牌。",
describeChoice: upgradeDescription, actionLabel: "强化此牌",
});
cardId = selected;
if (selected !== null) {
cardId = selected;
const choice = upgradeChoices.find(card => card.id === selected);
upgrade.label = `已选强化 · ${get.translation(choice.card.name)}`;
upgrade.description = upgradeDescription(choice);
} else {
upgrade.label = "已跳过强化";
upgrade.description = "不强化卡牌;点击可重新选择。";
}
return RETURN_TO_VICTORY;
},
};
const relic = {
id: "relic", label: "遗物 · 三选一",
id: "relic", label: pack.relicChoices.length
? `遗物奖励 · ${pack.relicChoices.length}选1` : "遗物奖励 · 暂无候选",
description: pack.relicChoices.length
? "检视本次随机珍藏,择一伴你踏上下一段征途。"
: "已持有奖励池中的全部遗物,此项无需选择。",
isDisabled: () => !pack.relicChoices.length,
async choose() {
const selected = await chooseRelicDialog(pack.relicChoices, {
skipLabel: "返回奖励", actionLabel: "携此遗物",
skipLabel: "跳过遗物", actionLabel: "携此遗物",
});
relicId = selected;
if (selected !== null) {
relicId = selected;
const choice = pack.relicChoices.find(item => item.id === selected);
const definition = getRelic(choice.relic);
relic.label = `已选遗物 · ${definition.name}`;
relic.description = definition.description;
} else {
relic.label = "已跳过遗物";
relic.description = "不领取遗物;点击可重新选择。";
}
return RETURN_TO_VICTORY;
},
};
return chooseVictoryOptions([upgrade, relic, {
id: "claim-package", label: "领取全部奖励",
description: "完成以上选择后,金币、强化、遗物与固定卡牌一并入囊。",
isDisabled: () => !ready(),
id: "claim-package", label: "领取奖励并继续",
description: "领取必得奖励与已选奖励,未选择的项目视为跳过。",
choose: () => ({ cardId, relicId }),
}, {
id: "skip-package", label: "跳过",
description: "跳过强化与遗物,金币与固定卡牌仍会获得。",
choose: () => ({ cardId: null, relicId: null }),
}], {
title: "初识的卢", allowSkip: false,
rewardLabel: "固定收获 · 完成两项选择后领取",
rewardLabel: "搜刮 · 战利品列表",
rewardItems: [{ name: `金币 · ${pack.gold}(必得)`,
description: "无论选择还是跳过,金币都会结算。" },
...fixedRewards.map(reward => ({ ...reward, name: `${reward.name}(必得)` }))],
description: `固定获得 ${pack.gold} 金币与${fixedRewards.map(
reward => `【${reward.name}】`).join("、")}。` +
"另可强化一张牌,并从随机遗物中择一件。",
"强化与遗物点击后选择,也可跳过。",
});
}
+44 -43
View File
@@ -2,7 +2,6 @@ import { lib, get } from "../../../../noname.js";
import { cardCost } from "../battle/combat-rules.js";
import { cardUpgradeRule } from "../cards/upgrades.js";
import { getRelic } from "../relics/definitions.js";
import { chooseButtons } from "./flow-ui.js";
const element = (tag, className, text, parent) => {
const node = document.createElement(tag);
@@ -34,7 +33,7 @@ const viewCard = choice => {
? `${lib.assetURL || ""}image/card/${imageName}.png` : null;
const type = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" }[info?.type] || "卡牌";
return {
name: get.translation(name), type, image, cost: cardCost({ name }),
name: get.translation(name), type, image, cost: cardCost(choice.card || { name }),
description: plainText(cardUpgradeRule(name) && info?.cardPrompt
? info.cardPrompt(choice.card || { name })
: lib.translate[`${name}_info`] || choice.description ||
@@ -71,6 +70,22 @@ export function chooseRelicDialog(choices, options = {}) {
});
}
// Mixed reward pools use the same selector, with exactly one candidate awarded.
function chooseRewardDialog(choices) {
if (choices.every(isCardReward)) return chooseCardDialog(choices);
if (choices.every(choice => getRelic(choice.relic))) return chooseRelicDialog(choices);
return chooseTilesDialog(choices, {
title: "选择一项奖励", actionLabel: "领取此奖励",
description: "从本次候选中选择一项,也可以跳过。",
}, choice => {
if (isCardReward(choice)) return viewCard(choice);
const relic = getRelic(choice.relic);
return { name: relic?.name || choice.name, type: relic ? "遗物" : "奖励",
image: null, cost: null,
description: plainText(relic?.description || choice.description) };
});
}
function chooseTilesDialog(choices, {
title = "选择一张牌", allowSkip = true, skipLabel = "跳过",
description = "择一入阵,余者留于身后", describeChoice,
@@ -137,7 +152,7 @@ function chooseTilesDialog(choices, {
export function chooseVictoryOptions(options, {
title = "战后抉择", allowSkip = true,
description = "此役已定,选择下一段征途的收获。",
rewardLabel = "可选战利品",
rewardLabel = "可选战利品", rewardItems = [],
} = {}) {
if (!Array.isArray(options) || !options.length || options.some(option => !option?.id || !option?.label)) {
return Promise.reject(new TypeError("chooseVictoryOptions requires nonempty options with IDs and labels"));
@@ -154,6 +169,12 @@ export function chooseVictoryOptions(options, {
element("p", "", description, intro);
const board = element("div", "mengsan-victory-options-shuying", null, stage);
element("span", "mengsan-victory-eyebrow-shuying", rewardLabel, board);
// Read-only loot list: inspecting a reward must not select or award it.
for (const reward of rewardItems) {
const item = element("p", "mengsan-victory-option-copy-shuying", null, board);
element("strong", "", reward.name || reward.id, item);
if (reward.description) element("small", "", plainText(reward.description), item);
}
let settled = false, choosing = false;
const entries = [];
const refresh = () => {
@@ -218,54 +239,34 @@ export function chooseVictoryOptions(options, {
});
}
// Gold is a persisted candidate, not an automatic reward. The card pool remains
// a separate Dialog; skipping that Dialog returns to the victory choices.
// Gold and fixed rewards are guaranteed; random loot opens a separate one-of-N selector.
export function chooseBattleReward(choices, { fixedRewards = [] } = {}) {
if (!Array.isArray(choices)) return Promise.resolve(null);
const fixedDescription = fixedRewards.map(reward =>
`${reward.name || reward.id}${reward.description
? `:${plainText(reward.description)}` : ""}`
).join(";");
if (!choices.length) {
if (!fixedRewards.length) return Promise.resolve(null);
return chooseVictoryOptions([{
id: "claim-fixed-rewards",
label: "领取固定奖励",
description: fixedDescription,
choose: () => null,
}], {
title: "过关奖励",
description: "此役已定,以下固定奖励将全部获得。",
rewardLabel: "固定战利品",
allowSkip: false,
});
}
const gold = choices.find(choice => choice.kind === "gold");
const gold = choices.filter(choice => choice.kind === "gold")
.reduce((amount, choice) => amount + choice.amount, 0);
const rewards = choices.filter(choice => choice.kind !== "gold");
const options = [];
if (gold) options.push({
id: gold.id,
label: `金币 · ${gold.amount}`,
description: "将此关金币收入征程钱袋。",
});
if (rewards.length) {
const cardOnly = rewards.every(isCardReward);
const kind = rewards.every(isCardReward) ? "卡牌"
: rewards.every(choice => getRelic(choice.relic)) ? "遗物" : "战利品";
options.push({
id: "choose-reward",
label: cardOnly ? "择一张牌" : "挑选一项战利品",
description: cardOnly ? "从本次候选卡牌中选择一张,加入牌组。" : "检视本次战利品,再决定带走哪一项。",
async choose() {
const selected = cardOnly
? await chooseCardDialog(rewards, {skipLabel:"跳过选卡"})
: await chooseButtons("选择一项奖励", rewards);
return selected ?? RETURN_TO_VICTORY;
},
id: "choose-reward", label: `${kind}奖励 · ${rewards.length}选1`,
description: `点击查看候选${kind},选择一项领取,或点击跳过。`,
choose: () => chooseRewardDialog(rewards),
});
}
options.push({
id: "skip-loot", label: "跳过",
description: rewards.length ? "跳过本次可选奖励,必得奖励照常领取。"
: "本次没有可选奖励,领取必得奖励后继续。",
choose: () => null,
});
return chooseVictoryOptions(options, {
title: "过关奖励",
description: fixedRewards.length
? `固定奖励:${fixedDescription}。另可选择一项战利品。`
: "此役已定,可选择一项战利品,也可放弃本次选择。",
title: "过关奖励", allowSkip: false,
description: `本关必得 ${gold} 金币。点击奖励选项后择一领取,也可跳过。`,
rewardLabel: "搜刮 · 战利品列表",
rewardItems: [{name: `金币 · ${gold}(必得)`,
description: "无论选择还是跳过,金币都会结算。"},
...fixedRewards.map(reward => ({ ...reward, name: `${reward.name}(必得)` }))],
});
}
+18
View File
@@ -1427,3 +1427,21 @@ body.mengsan-battle-ui-shuying :is(
outline: 2px solid #8c6a34;
outline-offset: 3px;
}
/* Read-only encyclopedia uses the existing wood, aged paper and gold palette. */
.mengsan-deck-dialog-shuying .ms-deck-tabs { flex-wrap: wrap; }
.mengsan-deck-dialog-shuying .ms-catalog-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; flex-shrink: 0; }
.mengsan-deck-dialog-shuying .ms-catalog-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; color: var(--ms-muted); font-size: 13px; }
.mengsan-deck-dialog-shuying .ms-catalog-field:first-child { flex: 1 1 220px; }
.mengsan-deck-dialog-shuying .ms-catalog-input,
.mengsan-deck-dialog-shuying .ms-catalog-select { box-sizing: border-box; position: static; min-width: 0; min-height: 44px; width: 100%; margin: 0; padding: 8px 10px; border: 1px solid #96754e; border-radius: 6px; background: #392c23; color: var(--ms-ink); font: 15px/1.5 "Microsoft YaHei", sans-serif; }
.mengsan-deck-dialog-shuying .ms-catalog-input::placeholder { color: var(--ms-muted); opacity: 1; }
.mengsan-deck-dialog-shuying .ms-catalog-input:focus-visible,
.mengsan-deck-dialog-shuying .ms-catalog-select:focus-visible { outline: 3px solid #ffe0a1; outline-offset: 3px; }
.mengsan-deck-dialog-shuying .ms-catalog-grid .ms-deck-art { height: auto; aspect-ratio: 5 / 7; }
@media (max-width: 600px) {
.mengsan-deck-dialog-shuying .ms-catalog-filters { gap: 8px; }
.mengsan-deck-dialog-shuying .ms-catalog-field:first-child { flex-basis: 100%; }
.mengsan-deck-dialog-shuying .ms-catalog-field:not(:first-child) { flex: 1 1 90px; }
.mengsan-deck-dialog-shuying .ms-catalog-filters > button { padding: 8px 10px; }
}