This commit is contained in:
2026-09-30 16:59:10 +08:00
parent 12e9ac181f
commit b005a7ff15
14 changed files with 525 additions and 135 deletions
-38
View File
@@ -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();
},
};
}
+20
View File
@@ -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 };
}
+17
View File
@@ -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));
};
}
+200
View File
@@ -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;
}
}
+53
View File
@@ -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);
},
};
}
+111
View File
@@ -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;
}
}
-41
View File
@@ -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; }