Files
noname-shuying/mengsan/ui/map/renderer.js
T
Diuse 82d80397b6 fixed # 新增羁绊系统
fixed # 新增遗物系统
fixed # 梦三模式下移除护甲上限限制
2026-09-30 14:33:17 +08:00

489 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { openCardLibrary } from "../card-library.js";
import { askMenu, openModeSelection } from "../navigation.js";
import { ui, get } from "../../../../../noname.js";
import config from "../../config.js";
import { getRelic } from "../../relics/definitions.js";
import { renderBonds } from "../bonds.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, { saveRun } = {}) => 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;
let savingBonds = false;
const finish = result => {
if (overlay.dataset.resolved || savingBonds) 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 || savingBonds) 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 (savingBonds) return;
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, focusSelector = null) => {
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(() => {
const target = focusSelector ?
menuContent.querySelector(focusSelector) : back;
target?.focus({ preventScroll: Boolean(focusSelector) });
});
};
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);
const relic = getRelic(value);
createText("", relic?.name || get.translation(value) || value, row);
createText("", relic?.description || "已获得", 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 => renderBonds(parent, run, saveRun, busy => {
savingBonds = busy;
closeMenu.disabled = busy;
exitButton.disabled = busy;
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: "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;