fixed # 重构梦三项目结构

This commit is contained in:
2026-09-30 10:23:58 +08:00
parent 3fdaf6d218
commit f2da9bce65
53 changed files with 267 additions and 175 deletions
+1
View File
@@ -0,0 +1 @@
export { showMap as default, showMap } from "./renderer.js";
+472
View File
@@ -0,0 +1,472 @@
import { openCardLibrary } from "../card-library.js";
import { askMenu, openModeSelection } from "../navigation.js";
import { ui, get } from "../../../../../noname.js";
import config from "../../config.js";
import { getSelectableNodes } from "../../progression/state.js";
// Original vector pictograms; no external image/font requests.
const iconPaths = {
battle: "M8 5l19 22M24 4l4 4L9 27M5 23l5 5M22 7l5 5M6 4l5 2-3 4z",
elite: "M16 3l10 5v9c0 6-10 12-10 12S6 23 6 17V8zM10 13l6 4 6-4M16 17v7M11 5l5-3 5 3",
event: "M10 10c0-9 15-8 15 0 0 5-9 5-9 10M16 25v1",
story: "M8 5h18v20H10c-5 0-5-6 0-6h16M8 5c-5 0-5 6 0 6h3V5M12 10h10M12 14h8",
chest: "M4 13h24v15H4zM2 8h28v6H2zM16 8v20" +
"M9 8c-4-3-1-7 3-5l4 5M23 8c4-3 1-7-3-5l-4 5",
rest: "M3 26L16 5l13 21zM10 26l6-11 6 11M16 5V2M7 28h19",
shop: "M4 12l3-7h18l3 7M4 12v4h24v-4M7 16v12h18V16M12 28v-8h8v8M3 28h26M11 6v6M21 6v6",
boss: "M6 28V5M6 6c8-8 12 6 22-1v16c-10 7-14-7-22 1M11 10l11 7M22 10l-11 7M3 28h9",
};
const controlIconPaths = {
menu: "M5 8h22M5 16h22M5 24h22",
map: "M4 7l7-3 10 4 7-3v20l-7 3-10-4-7 3zM11 4v20M21 8v20",
bag: "M9 10V8a7 7 0 0114 0v2M6 10h20l2 18H4zM12 16v3M20 16v3",
book: "M5 5h9a5 5 0 015 5v17a5 5 0 00-5-5H5zM27 5h-9a5 5 0 00-5 5v17a5 5 0 015-5h9z",
record: "M8 4h16v24H8zM12 10h8M12 16h8M12 22h5",
settings: "M16 11a5 5 0 100 10 5 5 0 000-10zM16 3v4M16 25v4M3 16h4M25 16h4M7 7l3 3M22 22l3 3M25 7l-3 3M10 22l-3 3",
exit: "M13 5H6v22h7M20 10l6 6-6 6M26 16H11",
back: "M20 7l-9 9 9 9M11 16h16",
close: "M8 8l16 16M24 8L8 24",
};
const makeIcon = type => {
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 32 32");
icon.setAttribute("aria-hidden", "true");
icon.classList.add("mengsan-ink-icon-shuying");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", iconPaths[type] || iconPaths.event);
icon.appendChild(path);
return icon;
};
const makeControlIcon = type => {
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 32 32");
icon.setAttribute("aria-hidden", "true");
icon.classList.add("mengsan-control-icon-shuying");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", controlIconPaths[type]);
icon.appendChild(path);
return icon;
};
const createText = (className, text, parent) => {
const node = document.createElement("div");
node.className = className;
node.textContent = text;
parent.appendChild(node);
return node;
};
const nodeColor = {
battle: "#9d4937",
elite: "#7e3f79",
event: "#477aa6",
story: "#8c6a34",
chest: "#a27b35",
rest: "#3f8560",
shop: "#a27b35",
boss: "#a62727",
};
const createMapOverlay = title => {
const overlay = ui.create.div(".mengsan-overlay-shuying.mengsan-map-overlay-shuying", ui.window);
const panel = ui.create.div(".mengsan-panel-shuying.mengsan-map-panel-shuying", overlay);
const header = ui.create.div(".mengsan-map-header-shuying", panel);
const titleGroup = ui.create.div(".mengsan-map-title-group-shuying", header);
createText("mengsan-map-eyebrow-shuying", "MENGSAN EXPEDITION", titleGroup);
const heading = ui.create.div(".mengsan-title-shuying", titleGroup);
heading.textContent = `${title} · 行军图`;
return { overlay, panel, header };
};
const getTraversedEdgeIds = map => {
const traversed = new Set();
const completed = map.completedNodeIds || [];
for (let index = 1; index < completed.length; index++) {
traversed.add(`${completed[index - 1]}\u0000${completed[index]}`);
}
return traversed;
};
export const showMap = run => new Promise(resolve => {
const act = config.acts[run.actIndex];
const selectable = getSelectableNodes(run);
const selectableIds = new Set(selectable.map(node => node.id));
const traversedEdgeIds = getTraversedEdgeIds(run.map);
const { overlay, panel, header } = createMapOverlay(act.name);
let closeCardLibrary = null;
const finish = result => {
if (overlay.dataset.resolved) return;
overlay.dataset.resolved = "true";
document.removeEventListener("keydown", handleEscape);
closeCardLibrary?.();
overlay.remove();
resolve(result);
};
const menuTrigger = document.createElement("button");
menuTrigger.type = "button";
menuTrigger.className = "mengsan-map-menu-trigger-shuying";
menuTrigger.appendChild(makeControlIcon("menu"));
const menuTriggerLabel = document.createElement("span");
menuTriggerLabel.textContent = "菜单";
menuTrigger.appendChild(menuTriggerLabel);
menuTrigger.setAttribute("aria-haspopup", "dialog");
menuTrigger.setAttribute("aria-expanded", "false");
menuTrigger.setAttribute("aria-controls", "mengsan-map-menu-shuying");
header.insertBefore(menuTrigger, header.firstChild);
const status = ui.create.div(".mengsan-map-status-shuying", header);
const statusItems = [
["生命", run.player.hp == null ? "未初始化" : `${run.player.hp}/${run.player.maxHp}`],
["金币", String(run.player.gold)],
["牌组", String(run.player.deck.length)],
];
statusItems.forEach(([label, value]) => {
const item = ui.create.div(".mengsan-map-stat-shuying", status);
createText("mengsan-map-stat-label-shuying", label, item);
createText("mengsan-map-stat-value-shuying", value, item);
});
const menuLayer = ui.create.div(".mengsan-map-menu-layer-shuying", panel);
menuLayer.id = "mengsan-map-menu-shuying";
menuLayer.hidden = true;
const menuScrim = ui.create.div(".mengsan-map-menu-scrim-shuying", menuLayer);
const menuSheet = document.createElement("div");
menuSheet.className = "mengsan-map-menu-sheet-shuying";
menuSheet.setAttribute("role", "dialog");
menuSheet.setAttribute("aria-modal", "true");
menuSheet.setAttribute("aria-labelledby", "mengsan-map-menu-title-shuying");
menuLayer.appendChild(menuSheet);
const menuHeader = document.createElement("div");
menuHeader.className = "mengsan-map-menu-header-shuying";
menuSheet.appendChild(menuHeader);
const menuHeadingGroup = document.createElement("div");
menuHeadingGroup.className = "mengsan-map-menu-heading-group-shuying";
menuHeader.appendChild(menuHeadingGroup);
createText("mengsan-map-menu-kicker-shuying", "征程管理", menuHeadingGroup);
const menuTitle = createText("mengsan-map-menu-title-shuying", "行军菜单", menuHeadingGroup);
menuTitle.id = "mengsan-map-menu-title-shuying";
const closeMenu = document.createElement("button");
closeMenu.type = "button";
closeMenu.className = "mengsan-map-icon-button-shuying";
closeMenu.appendChild(makeControlIcon("close"));
closeMenu.setAttribute("aria-label", "关闭菜单");
menuHeader.appendChild(closeMenu);
const menuContent = document.createElement("div");
menuContent.className = "mengsan-map-menu-content-shuying";
menuSheet.appendChild(menuContent);
const menuFooter = document.createElement("div");
menuFooter.className = "mengsan-map-menu-footer-shuying";
menuSheet.appendChild(menuFooter);
const exitButton = document.createElement("button");
exitButton.type = "button";
exitButton.className = "mengsan-map-exit-fixed-shuying";
exitButton.appendChild(makeControlIcon("exit"));
const exitCopy = document.createElement("span");
exitCopy.className = "mengsan-map-exit-copy-shuying";
const exitName = document.createElement("strong");
exitName.textContent = "返回模式选择";
const exitDescription = document.createElement("small");
exitDescription.textContent = "保留已保存征程,选择其他模式";
exitCopy.append(exitName, exitDescription);
exitButton.appendChild(exitCopy);
exitButton.addEventListener("click", async () => {
if (exitButton.disabled) return;
exitButton.disabled = true;
try {
const result = await askMenu("返回模式选择?", "保留上次完成节点的存档,未完成内容不会提交。", [
{ id: "cancel", name: "留在行军图" },
{ id: "exit", name: "确认返回模式选择", danger: true },
]);
if (result === "exit") await openModeSelection();
} catch (error) {
console.error(error);
await askMenu("暂时无法返回", "征程未被清除,请重试。", [{ id: "close", name: "关闭提示" }]);
} finally { exitButton.disabled = false; }
});
menuFooter.appendChild(exitButton);
const setMenuOpen = open => {
if (open) renderMainMenu();
menuLayer.hidden = !open;
menuTrigger.setAttribute("aria-expanded", String(open));
panel.classList.toggle("menu-open", open);
if (open) requestAnimationFrame(() => menuSheet.querySelector("button")?.focus());
else menuTrigger.focus();
};
const createMenuAction = ({ icon, name, description, danger = false, disabled = false, onClick }) => {
const button = document.createElement("button");
button.type = "button";
button.className = "mengsan-map-menu-action-shuying";
if (danger) button.classList.add("danger");
button.disabled = disabled;
button.appendChild(makeControlIcon(icon));
const copy = document.createElement("div");
copy.className = "mengsan-map-menu-action-copy-shuying";
button.appendChild(copy);
createText("mengsan-map-menu-action-name-shuying", name, copy);
createText("mengsan-map-menu-action-description-shuying", description, copy);
button.addEventListener("click", onClick);
menuContent.appendChild(button);
return button;
};
const showMenuPage = (title, subtitle, render) => {
menuTitle.textContent = title;
menuContent.replaceChildren();
const intro = ui.create.div(".mengsan-map-menu-page-intro-shuying", menuContent);
intro.textContent = subtitle;
render(menuContent);
const back = document.createElement("button");
back.type = "button";
back.className = "mengsan-map-menu-back-shuying";
back.appendChild(makeControlIcon("back"));
const label = document.createElement("span");
label.textContent = "返回菜单";
back.appendChild(label);
back.addEventListener("click", renderMainMenu);
menuContent.appendChild(back);
requestAnimationFrame(() => back.focus());
};
const renderBackpack = parent => {
const summary = ui.create.div(".mengsan-map-inventory-summary-shuying", parent);
[["卡牌", run.player.deck.length], ["道具", run.player.items?.length || 0], ["技能", run.player.permanentSkills?.length || 0]].forEach(([label, value]) => {
const item = ui.create.div(".mengsan-map-inventory-count-shuying", summary);
createText("", String(value), item);
createText("", label, item);
});
const library = document.createElement("button");
library.type = "button";
library.className = "mengsan-map-menu-action-shuying";
library.setAttribute("aria-haspopup", "dialog");
library.appendChild(makeControlIcon("book"));
const copy = document.createElement("div");
copy.className = "mengsan-map-menu-action-copy-shuying";
createText("mengsan-map-menu-action-name-shuying", "卡牌库", copy);
createText("mengsan-map-menu-action-description-shuying", `共 ${run.player.deck.length} 张 · 查看花色、点数、属性和卡牌详情`, copy);
library.appendChild(copy);
library.addEventListener("click", () => { closeCardLibrary = openCardLibrary(run); });
parent.appendChild(library);
const appendNames = (title, values, emptyText) => {
createText("mengsan-map-inventory-section-title-shuying", title, parent);
const list = ui.create.div(".mengsan-map-inventory-list-shuying", parent);
if (!values?.length) {
createText("mengsan-map-empty-shuying", emptyText, list);
return;
}
values.forEach(value => {
const row = ui.create.div(".mengsan-map-inventory-row-shuying", list);
createText("", get.translation(value) || value, row);
createText("", "已获得", row);
});
};
const supports = run.player.supports || [];
createText("mengsan-map-inventory-section-title-shuying", "战斗支援", parent);
const supportList = ui.create.div(".mengsan-map-inventory-list-shuying", parent);
if (!supports.length) createText("mengsan-map-empty-shuying", "暂无战斗支援。", supportList);
for (const support of supports) {
const row = ui.create.div(".mengsan-map-inventory-row-shuying", supportList);
createText("", config.rewards[support.rewardId]?.name || get.translation(support.unit.character), row);
createText("", support.battles === -1 ? "本次征程持续生效" : `剩余 ${support.battles} 场`, row);
}
appendNames("永久道具", run.player.items, "尚未获得永久道具。");
appendNames("永久技能", run.player.permanentSkills, "尚未获得永久技能。");
};
const renderPlaceholder = (parent, message) => {
const empty = ui.create.div(".mengsan-map-placeholder-shuying", parent);
empty.appendChild(makeControlIcon("record"));
createText("mengsan-map-placeholder-title-shuying", "功能开发中", empty);
createText("mengsan-map-placeholder-text-shuying", message, empty);
};
function renderMainMenu() {
menuTitle.textContent = "行军菜单";
menuContent.replaceChildren();
createMenuAction({ icon: "map", name: "继续行军", description: "关闭菜单并返回当前路线", onClick: () => setMenuOpen(false) });
createMenuAction({ icon: "bag", name: "行囊", description: `查看牌组、道具与永久技能 · ${run.player.deck.length}张牌`, onClick: () => showMenuPage("行囊", "本次征程携带的资源", renderBackpack) });
createMenuAction({ icon: "book", name: "图鉴", description: "查看已发现的敌人、事件与奖励 · 开发中", onClick: () => showMenuPage("图鉴", "征程见闻与收集记录", parent => renderPlaceholder(parent, "后续将收录已发现的节点、敌人和奖励。")) });
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]) => {
const row = ui.create.div(".mengsan-map-inventory-row-shuying", list);
createText("", label, row);
createText("", String(value), row);
});
}) });
createMenuAction({ icon: "settings", name: "设置与说明", description: "显示、音效与玩法说明 · 开发中", onClick: () => showMenuPage("设置与说明", "个性化与帮助", parent => renderPlaceholder(parent, "后续将在这里提供显示、音效和操作设置。")) });
}
function handleEscape(event) {
if (window.inSplash) return;
if (document.querySelector("dialog.mengsan-deck-dialog-shuying[open]")) return;
if (document.querySelector(".mengsan-menu-dialog-shuying[open]")) return;
if (event.key == "Escape" && !menuLayer.hidden) {
event.preventDefault();
setMenuOpen(false);
}
else if (event.key == "Tab" && !menuLayer.hidden) {
const focusable = Array.from(menuSheet.querySelectorAll("button:not(:disabled)"));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
}
else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
renderMainMenu();
menuTrigger.addEventListener("click", () => setMenuOpen(true));
closeMenu.addEventListener("click", () => setMenuOpen(false));
menuScrim.addEventListener("click", () => setMenuOpen(false));
document.addEventListener("keydown", handleEscape);
const legend = document.createElement("details");
legend.className = "mengsan-ink-legend-shuying";
legend.open = window.matchMedia("(min-width: 900px)").matches;
const summary = document.createElement("summary");
summary.textContent = "行军图例";
legend.appendChild(summary);
Object.keys(iconPaths).forEach(type => {
const item = document.createElement("div");
item.appendChild(makeIcon(type));
const label = document.createElement("span");
label.textContent = type === "boss" ? "关底主将" : config.nodeNames[type];
item.appendChild(label);
legend.appendChild(item);
});
const hint = document.createElement("p");
hint.textContent = "朱圈可行 · 墨线已行";
legend.appendChild(hint);
panel.appendChild(legend);
const viewport = ui.create.div(".mengsan-map-viewport-shuying", panel);
let drag = null;
let suppressClick = false;
viewport.addEventListener("pointerdown", event => {
if (!event.isPrimary || event.button !== 0) return;
suppressClick = false;
if (event.pointerType !== "mouse") return;
drag = { id: event.pointerId, y: event.clientY, top: viewport.scrollTop, moved: false };
});
viewport.addEventListener("pointermove", event => {
if (!drag || drag.id !== event.pointerId) return;
const distance = event.clientY - drag.y;
if (!drag.moved && Math.abs(distance) < 6) return;
drag.moved = true;
suppressClick = true;
if (!viewport.hasPointerCapture(event.pointerId)) viewport.setPointerCapture(event.pointerId);
viewport.classList.add("dragging");
viewport.scrollTop = drag.top - distance;
event.preventDefault();
});
const endDrag = event => {
if (!drag || drag.id !== event.pointerId) return;
drag = null;
viewport.classList.remove("dragging");
if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
};
viewport.addEventListener("pointerup", endDrag);
viewport.addEventListener("pointercancel", endDrag);
viewport.addEventListener("lostpointercapture", endDrag);
viewport.addEventListener("pointerleave", event => {
if (drag && !drag.moved) endDrag(event);
});
viewport.addEventListener("click", event => {
if (suppressClick && event.detail !== 0) {
event.preventDefault();
event.stopImmediatePropagation();
suppressClick = false;
}
}, true);
const map = ui.create.div(".mengsan-map-shuying", viewport);
const floorCount = Math.max(...run.map.nodes.map(node =>
Math.floor(node.floor))) + 1;
map.style.setProperty("--mengsan-map-min-height",
`${floorCount * 150}px`);
const landscape = document.createElementNS("http://www.w3.org/2000/svg", "svg");
landscape.classList.add("mengsan-ink-landscape-shuying");
landscape.setAttribute("viewBox", "0 0 800 1200");
landscape.setAttribute("preserveAspectRatio", "none");
landscape.setAttribute("aria-hidden", "true");
["M0 580L45 380 80 420 145 210 204 340 242 310 295 590Z", "M510 950l80-240 30 40 60-280 42 110 32-70 46 330v110Z", "M0 1140q150-75 320-5t480-30v95H0Z"].forEach(d => {
const mountain = document.createElementNS("http://www.w3.org/2000/svg", "path");
mountain.setAttribute("d", d);
landscape.appendChild(mountain);
});
map.appendChild(landscape);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("mengsan-lines-shuying");
svg.setAttribute("viewBox", "0 0 100 100");
svg.setAttribute("preserveAspectRatio", "none");
svg.setAttribute("aria-hidden", "true");
map.appendChild(svg);
const nodeMap = new Map(run.map.nodes.map(node => [node.id, node]));
run.map.edges.forEach(edge => {
const source = nodeMap.get(edge[0]);
const target = nodeMap.get(edge[1]);
if (!source || !target) return;
const line = document.createElementNS("http://www.w3.org/2000/svg", "path");
const midY = (source.y + target.y) / 2;
line.setAttribute("d", `M ${source.x} ${source.y} C ${source.x} ${midY}, ${target.x} ${midY}, ${target.x} ${target.y}`);
if (source.id === run.map.currentNodeId && selectableIds.has(target.id)) line.classList.add("available");
if (traversedEdgeIds.has(`${source.id}\u0000${target.id}`)) line.classList.add("completed");
if (source.dynamic || target.dynamic) line.classList.add("dynamic");
svg.appendChild(line);
});
run.map.nodes.forEach(node => {
const button = document.createElement("button");
button.type = "button";
button.className = "mengsan-node-shuying";
map.appendChild(button);
button.style.left = `${node.x}%`;
button.style.top = `${node.y}%`;
button.style.setProperty("--node-color", nodeColor[node.type] || "#666");
const content = node.contentId ? config.nodeContents?.[node.contentId] : null;
const label = content?.mapName || (node.type === "boss" ? "关底主将" : config.nodeNames[node.type]) || node.type;
button.appendChild(makeIcon(node.type));
const caption = document.createElement("span");
caption.textContent = label;
button.appendChild(caption);
button.title = label;
button.setAttribute("aria-label", `${label},${node.completed ? "已完成" : selectableIds.has(node.id) ? "可前往" : "未开放"}`);
button.setAttribute("aria-disabled", String(!selectableIds.has(node.id)));
button.tabIndex = selectableIds.has(node.id) ? 0 : -1;
button.dataset.type = node.type;
if (node.id === run.map.currentNodeId) {
button.classList.add("current");
button.setAttribute("aria-current", "step");
}
button.dataset.nodeId = node.id;
if (node.completed) button.classList.add("completed");
else if (selectableIds.has(node.id)) button.classList.add("selectable");
else button.classList.add("locked");
button.addEventListener("click", event => {
event.preventDefault();
if (!selectableIds.has(node.id) || overlay.dataset.resolved) return;
finish(node);
});
});
requestAnimationFrame(() => {
if (!overlay.isConnected) return;
const current = nodeMap.get(run.map.currentNodeId) || selectable[0];
viewport.scrollTop = current
? Math.max(0, map.offsetHeight * current.y / 100 - viewport.clientHeight * 0.76)
: viewport.scrollHeight;
});
});
export default showMap;