mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-17 12:40:06 +08:00
fixed # 重构梦三项目结构
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { lib, get, ui } from "../../../../noname.js";
|
||||
import { AFFIX_INFO } from "../cards/affixes.js";
|
||||
|
||||
const suits = { spade: "♠ 黑桃", heart: "♥ 红桃", club: "♣ 梅花", diamond: "♦ 方块" };
|
||||
const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" };
|
||||
const affixes = Object.fromEntries(Object.entries(AFFIX_INFO).map(([key, info]) => [key, `${info.name}:${info.description}`]));
|
||||
const text = value => {
|
||||
const template = document.createElement("template");
|
||||
// Translation markup is parsed inertly; only plain text is inserted into the UI.
|
||||
template.innerHTML = String(value ?? "").replace(/<br\s*\/?\s*>/gi, "\n");
|
||||
return template.content.textContent.trim();
|
||||
};
|
||||
const translate = value => text(lib.translate[value] || get.translation(value) || value);
|
||||
|
||||
export function describeLibraryCard(card) {
|
||||
const info = lib.card[card.name];
|
||||
const nature = typeof card.nature === "string" ? card.nature.split("|").filter(Boolean).map(translate).join("、") : "";
|
||||
return {
|
||||
name: translate(card.name), suit: suits[card.suit] || "无花色",
|
||||
number: ({ 1: "A", 11: "J", 12: "Q", 13: "K" })[card.number] || String(card.number ?? "—"),
|
||||
nature: nature || "无属性", type: types[info?.type] || "未知类别",
|
||||
red: card.suit === "heart" || card.suit === "diamond",
|
||||
description: text(lib.translate[`${card.name}_info`] || "暂无卡牌介绍。"),
|
||||
affixes: (Array.isArray(card.affixes) ? card.affixes : []).filter(key => key !== "annihilate").map(key => affixes[key] || `词缀:${translate(key)}`),
|
||||
upgrade: Number.isFinite(card.upgrade) ? card.upgrade : 0,
|
||||
available: Boolean(info),
|
||||
};
|
||||
}
|
||||
|
||||
function element(tag, className, value, parent) {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (value != null) node.textContent = value;
|
||||
parent?.appendChild(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function button(label, parent, action, className = "ms-deck-button") {
|
||||
const node = element("button", className, label, parent);
|
||||
node.type = "button";
|
||||
node.addEventListener("click", action);
|
||||
return node;
|
||||
}
|
||||
|
||||
// Read-only DOM previews, never game.createCard or Card.init: no IDs, triggers or pile writes.
|
||||
function preview(card, details, parent) {
|
||||
const face = element("div", "ms-deck-face", null, parent);
|
||||
element("span", `ms-deck-corner${details.red ? " ms-deck-red" : ""}`, `${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;
|
||||
// Only mode-standard local art paths; unknown or missing art keeps the text fallback.
|
||||
if (/^[a-zA-Z0-9_]+$/.test(imageName)) {
|
||||
const image = element("img", "ms-deck-image", null, art);
|
||||
image.alt = "";
|
||||
image.loading = "lazy";
|
||||
image.addEventListener("error", () => image.remove(), { once: true });
|
||||
image.src = `${lib.assetURL || ""}image/card/${imageName}.png`;
|
||||
}
|
||||
element("strong", "ms-deck-name", details.name, face);
|
||||
element("span", "ms-deck-meta", `${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);
|
||||
}
|
||||
return face;
|
||||
}
|
||||
|
||||
export function openCardLibrary(run, options = {}) {
|
||||
const existing = document.querySelector("dialog.mengsan-deck-dialog-shuying[open]");
|
||||
if (existing) { existing.focus(); return () => {}; }
|
||||
const previousFocus = document.activeElement;
|
||||
const dialog = element("dialog", "mengsan-deck-dialog-shuying", null);
|
||||
dialog.setAttribute("aria-label", options.title || "卡牌库");
|
||||
const header = element("header", "ms-deck-header", null, dialog);
|
||||
const title = element("div", "ms-deck-title-group", null, header);
|
||||
element("span", "ms-deck-eyebrow", options.eyebrow || "梦三 · 行囊", title);
|
||||
element("h2", "ms-deck-title", options.title || "卡牌库", title);
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
};
|
||||
button("关闭", header, close);
|
||||
const summary = element("p", "ms-deck-summary", null, dialog);
|
||||
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 : [];
|
||||
let detail = false, selectedButton = null, scrollTop = 0;
|
||||
const list = element("div", "ms-deck-grid", null, body);
|
||||
const detailPanel = element("section", "ms-deck-detail", null, body);
|
||||
detailPanel.hidden = true;
|
||||
function back() {
|
||||
detail = false; detailPanel.hidden = true; list.hidden = false;
|
||||
detailPanel.replaceChildren(); body.scrollTop = scrollTop;
|
||||
selectedButton?.focus({ preventScroll: true });
|
||||
}
|
||||
function showDetail(card, details, owner) {
|
||||
selectedButton = owner; scrollTop = body.scrollTop;
|
||||
detail = true; list.hidden = true; detailPanel.hidden = false;
|
||||
detailPanel.replaceChildren();
|
||||
const backButton = button("返回牌库", detailPanel, back);
|
||||
const content = element("div", "ms-deck-detail-content", null, detailPanel);
|
||||
preview(card, details, content);
|
||||
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("h4", "", "卡牌介绍", copy);
|
||||
element("p", "ms-deck-rules", details.description, copy);
|
||||
if (!details.available) element("p", "ms-deck-warning", "该卡牌定义未加载;保留存档记录,不删除此牌。", copy);
|
||||
if (details.upgrade) element("p", "", `强化等级:${details.upgrade}(实际效果以玩法规则为准)`, 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} 张 · 同名不同花色、点数分别展示`;
|
||||
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);
|
||||
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},查看详情`);
|
||||
if (section.ordered) element("span", "ms-deck-order", index === 0 ? "1 · 下一张" : `${index + 1}`, tile);
|
||||
preview(card, details, tile);
|
||||
element("span", "ms-deck-excerpt", details.description, tile);
|
||||
});
|
||||
}
|
||||
}
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); if (detail) back(); else close(); });
|
||||
dialog.addEventListener("close", close);
|
||||
// Prevent the map's Escape/Tab handler from acting behind this modal.
|
||||
dialog.addEventListener("keydown", event => event.stopPropagation());
|
||||
render();
|
||||
document.body.appendChild(dialog);
|
||||
try { dialog.showModal(); } catch (error) { close(); throw error; }
|
||||
header.querySelector("button").focus();
|
||||
close.refresh = () => {
|
||||
if (closed) return;
|
||||
// Rebuild from live arrays after a pile mutation, including while viewing details.
|
||||
const wasDetail = detail, offset = body.scrollTop;
|
||||
render(); body.scrollTop = offset;
|
||||
if (wasDetail) header.querySelector("button").focus();
|
||||
};
|
||||
return close;
|
||||
}
|
||||
|
||||
// The battle view exposes only this player's own piles, including consumed cards.
|
||||
export function mountBattlePiles(session, battle) {
|
||||
let closeDialog = null, disposed = false;
|
||||
const control = button("牌堆", ui.window, () => {
|
||||
if (disposed || !session.active || battle.resolving) return;
|
||||
closeDialog?.();
|
||||
closeDialog = openCardLibrary(null, {
|
||||
title: "战斗牌堆", eyebrow: "梦三 · 个人牌堆",
|
||||
sections: () => [
|
||||
{ title: "剩余牌堆", cards: battle.drawPile, ordered: true },
|
||||
{ title: "弃牌堆", cards: battle.discardPile, ordered: false },
|
||||
{ title: "消耗牌堆", cards: battle.exhaustPile, ordered: false },
|
||||
],
|
||||
});
|
||||
}, "mengsan-battle-piles-button-shuying");
|
||||
control.setAttribute("aria-haspopup", "dialog");
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true; closeDialog?.(); closeDialog = null; control.remove();
|
||||
};
|
||||
session.ownResource(control, dispose);
|
||||
return { dispose, refresh() { if (!disposed) closeDialog?.refresh?.(); } };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { mountMenu } from "./navigation.js";
|
||||
import { ui, get } from "../../../../noname.js";
|
||||
import { isDialogueLineVisible, applyDialogueChoice, applySkippedDialogueChoices } from "../progression/dialogue-choice.js";
|
||||
|
||||
const TYPE_NAMES = {
|
||||
character: "角色",
|
||||
narrator: "旁白",
|
||||
sound: "音效",
|
||||
choice: "选择发言",
|
||||
};
|
||||
|
||||
|
||||
const resolveCharacter = (character, run) => character == "$player" ? run?.player?.character : character;
|
||||
|
||||
const resolveSpeaker = (line, character) => {
|
||||
if (line.speaker == "$player") return character ? get.translation(character) : "你";
|
||||
if (line.speaker) return line.speaker;
|
||||
if (line.type == "character" && character) return get.translation(character);
|
||||
return TYPE_NAMES[line.type] || "旁白";
|
||||
};
|
||||
|
||||
export const splitDialogueText = text => Array.from(String(text || ""));
|
||||
|
||||
export const playDialogue = (lines, options = {}) => new Promise(resolve => {
|
||||
if (!Array.isArray(lines) || !lines.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = ui.create.div(".mengsan-dialogue-overlay-shuying", ui.window);
|
||||
const stage = ui.create.div(".mengsan-dialogue-stage-shuying", overlay);
|
||||
ui.create.div(".mengsan-dialogue-chapter-shuying", options.title || "剧情", stage);
|
||||
const textBox = ui.create.div(".mengsan-dialogue-box-shuying", stage);
|
||||
const portraitFrame = ui.create.div(
|
||||
".mengsan-dialogue-portrait-frame-shuying", textBox);
|
||||
const speaker = ui.create.div(".mengsan-dialogue-speaker-shuying", textBox);
|
||||
const text = ui.create.div(".mengsan-dialogue-text-shuying", textBox);
|
||||
const progress = ui.create.div(".mengsan-dialogue-progress-shuying", textBox);
|
||||
const indicator = ui.create.div(".mengsan-dialogue-indicator-shuying", "◆", textBox);
|
||||
const choices = ui.create.div(".mengsan-dialogue-choices-shuying", stage);
|
||||
const skipButton = document.createElement("button");
|
||||
skipButton.type = "button";
|
||||
skipButton.className = "mengsan-dialogue-skip-shuying";
|
||||
skipButton.textContent = "跳过对话";
|
||||
skipButton.setAttribute("aria-label", "跳过对话,未选择的发言使用默认选项");
|
||||
stage.appendChild(skipButton);
|
||||
|
||||
let lineIndex = -1;
|
||||
let timer = null;
|
||||
let typing = false;
|
||||
let paused = false;
|
||||
let currentSpeed = 28;
|
||||
let characters = [];
|
||||
let characterIndex = 0;
|
||||
let currentLine = null;
|
||||
let awaitingChoice = false;
|
||||
const portrait = ui.create.div(".mengsan-dialogue-portrait-shuying", portraitFrame);
|
||||
const reduceMotion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer != null) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const finishTyping = () => {
|
||||
stopTimer();
|
||||
text.textContent = characters.join("");
|
||||
characterIndex = characters.length;
|
||||
typing = false;
|
||||
if (currentLine?.type === "choice" && awaitingChoice) showChoices(currentLine);
|
||||
else indicator.classList.add("visible");
|
||||
};
|
||||
|
||||
function showChoices(line) {
|
||||
choices.replaceChildren();
|
||||
for (const item of line.choices) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "mengsan-dialogue-choice-shuying";
|
||||
button.textContent = item.text;
|
||||
button.addEventListener("click", event => {
|
||||
event.stopPropagation();
|
||||
if (!awaitingChoice) return;
|
||||
text.textContent = applyDialogueChoice(options.run, line, item.id);
|
||||
awaitingChoice = false;
|
||||
choices.replaceChildren();
|
||||
speaker.textContent = get.translation(options.run.player.character);
|
||||
stage.dataset.lineType = "character";
|
||||
const character = options.run.player.character;
|
||||
portraitFrame.classList.add("visible");
|
||||
portrait.setBackground(character, "character");
|
||||
indicator.classList.add("visible");
|
||||
textBox.focus();
|
||||
});
|
||||
choices.appendChild(button);
|
||||
}
|
||||
choices.querySelector("button")?.focus();
|
||||
}
|
||||
|
||||
const scheduleCharacter = speed => {
|
||||
if (!typing || paused) return;
|
||||
text.textContent += characters[characterIndex++];
|
||||
if (characterIndex >= characters.length) {
|
||||
finishTyping();
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => scheduleCharacter(speed), speed);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
stopTimer();
|
||||
skipButton.removeEventListener("click", handleSkip);
|
||||
textBox.removeEventListener("click", handleClick);
|
||||
textBox.removeEventListener("keydown", handleKeydown);
|
||||
choices.replaceChildren();
|
||||
overlay.remove();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const showNextLine = () => {
|
||||
do { lineIndex++; } while (lineIndex < lines.length && !isDialogueLineVisible(lines[lineIndex], options.run));
|
||||
if (lineIndex >= lines.length) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
const line = lines[lineIndex];
|
||||
currentLine = line;
|
||||
awaitingChoice = line.type === "choice";
|
||||
choices.replaceChildren();
|
||||
const lineType = TYPE_NAMES[line.type] ? line.type : "narrator";
|
||||
const character = resolveCharacter(line.character, options.run);
|
||||
stage.dataset.lineType = lineType;
|
||||
speaker.textContent = resolveSpeaker(line, character);
|
||||
progress.textContent = `${lineIndex + 1} / ${lines.length}`;
|
||||
indicator.classList.remove("visible");
|
||||
|
||||
if (lineType == "character" && character) {
|
||||
portraitFrame.classList.add("visible");
|
||||
portrait.setBackground(character, "character");
|
||||
portrait.setAttribute("aria-label", `${speaker.textContent}立绘`);
|
||||
}
|
||||
else {
|
||||
portraitFrame.classList.remove("visible");
|
||||
portrait.removeAttribute("aria-label");
|
||||
}
|
||||
|
||||
characters = splitDialogueText(line.text);
|
||||
characterIndex = 0;
|
||||
text.textContent = "";
|
||||
typing = characters.length > 0;
|
||||
const speed = Number.isFinite(line.speed) ? Math.max(0, line.speed) : 28;
|
||||
currentSpeed = speed;
|
||||
if (!typing || reduceMotion || speed == 0) finishTyping();
|
||||
else scheduleCharacter(speed);
|
||||
};
|
||||
|
||||
function handleClick(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (paused) return;
|
||||
if (typing) finishTyping();
|
||||
else if (awaitingChoice) return;
|
||||
else showNextLine();
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key == "Enter" || event.key == " ") handleClick(event);
|
||||
}
|
||||
|
||||
function handleSkip(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (paused) return;
|
||||
applySkippedDialogueChoices(lines, lineIndex + (awaitingChoice ? 0 : 1), options.run);
|
||||
close();
|
||||
}
|
||||
|
||||
textBox.setAttribute("role", "button");
|
||||
textBox.setAttribute("tabindex", "0");
|
||||
textBox.setAttribute("aria-label", "剧情文本:点击补全文字或继续");
|
||||
textBox.addEventListener("click", handleClick);
|
||||
textBox.addEventListener("keydown", handleKeydown);
|
||||
skipButton.addEventListener("click", handleSkip);
|
||||
mountMenu(overlay, {
|
||||
onOpen() { paused = true; stopTimer(); },
|
||||
onClose() { paused = false; if (typing) scheduleCharacter(currentSpeed); },
|
||||
});
|
||||
const help = ui.create.div(".mengsan-dialogue-help-shuying", stage);
|
||||
help.textContent = "点击补全文字 · 再次点击继续 · Enter / 空格";
|
||||
showNextLine();
|
||||
textBox.focus();
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export function mountEnergy(player, session, document) {
|
||||
const badge = document.createElement("div");
|
||||
badge.className = "mengsan-energy-shuying";
|
||||
const refresh = () => {
|
||||
const current = player.storage.mengsanEnergy_shuying;
|
||||
const maximum = player.storage.mengsanMaxEnergy_shuying;
|
||||
badge.textContent = `费用 ${current}/${maximum}`;
|
||||
};
|
||||
player.appendChild(badge);
|
||||
session.ownResource(badge, () => badge.remove());
|
||||
refresh();
|
||||
return refresh;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ui } from "../../../../noname.js";
|
||||
import { mountMenu } from "./navigation.js";
|
||||
|
||||
export const chooseButtons = (title, choices, description = "", options = {}) => new Promise(resolve => {
|
||||
const previousFocus = document.activeElement;
|
||||
const overlay = ui.create.div(".mengsan-overlay-shuying.mengsan-flow-overlay-shuying", ui.window);
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-label", title);
|
||||
const panel = ui.create.div(".mengsan-panel-shuying.mengsan-flow-panel-shuying", overlay);
|
||||
const eyebrow = document.createElement("p");
|
||||
eyebrow.className = "mengsan-flow-eyebrow-shuying";
|
||||
eyebrow.textContent = options.eyebrow || "梦三 · 征程";
|
||||
panel.appendChild(eyebrow);
|
||||
const heading = ui.create.div(".mengsan-title-shuying", panel);
|
||||
heading.textContent = title;
|
||||
heading.setAttribute("role", "heading");
|
||||
heading.setAttribute("aria-level", "1");
|
||||
if (description) {
|
||||
const summary = ui.create.div(".mengsan-description-shuying", panel);
|
||||
summary.textContent = description;
|
||||
}
|
||||
const buttons = ui.create.div(".mengsan-choice-list-shuying", panel);
|
||||
const finish = id => {
|
||||
if (overlay.dataset.resolved) return;
|
||||
overlay.dataset.resolved = "true";
|
||||
overlay.remove();
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
resolve(id);
|
||||
};
|
||||
choices.forEach(choice => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "mengsan-choice-shuying";
|
||||
button.dataset.choice = choice.id;
|
||||
if (choice.danger) button.classList.add("danger");
|
||||
const name = document.createElement("span");
|
||||
name.className = "mengsan-choice-name-shuying";
|
||||
name.textContent = choice.name;
|
||||
button.appendChild(name);
|
||||
if (choice.description) {
|
||||
const detail = document.createElement("span");
|
||||
detail.className = "mengsan-choice-description-shuying";
|
||||
detail.textContent = choice.description;
|
||||
button.appendChild(detail);
|
||||
}
|
||||
button.disabled = Boolean(choice.disabled);
|
||||
button.addEventListener("click", () => finish(choice.id));
|
||||
buttons.appendChild(button);
|
||||
});
|
||||
mountMenu(overlay);
|
||||
overlay.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && options.back) {
|
||||
event.preventDefault(); finish(options.back); return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = [...overlay.querySelectorAll("button:not(:disabled)")];
|
||||
const first = focusable[0], 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(); }
|
||||
});
|
||||
buttons.querySelector("button:not(:disabled)")?.focus();
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { AFFIX_INFO } from "../cards/affixes.js";
|
||||
import { describeLibraryCard } from "./card-library.js";
|
||||
|
||||
const node = (tag, className, label, parent) => {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
if (label != null) element.textContent = label;
|
||||
parent?.appendChild(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
const button = (label, parent, action) => {
|
||||
const element = node("button", "ms-deck-button", label, parent);
|
||||
element.type = "button";
|
||||
element.addEventListener("click", action);
|
||||
return element;
|
||||
};
|
||||
|
||||
// Keep the command battle-scoped; further GM actions can be added as panels here.
|
||||
export function mountGMManager(session, owner, piles, battle, resources, game) {
|
||||
let dialog = null, selected = null, previousFocus = null, disposed = false;
|
||||
const available = () => !disposed && session.active && !battle.resolving;
|
||||
const close = () => {
|
||||
if (!dialog) return;
|
||||
const current = dialog;
|
||||
dialog = null;
|
||||
if (current.open) current.close();
|
||||
current.remove();
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
previousFocus = null;
|
||||
};
|
||||
const render = () => {
|
||||
if (!dialog) return;
|
||||
const list = dialog.querySelector(".ms-gm-hand");
|
||||
const editor = dialog.querySelector(".ms-gm-editor");
|
||||
list.replaceChildren(); editor.replaceChildren();
|
||||
const hand = owner.getCards("h").filter(card => card.storage?.mengsanCard_shuying);
|
||||
if (!hand.includes(selected)) selected = null;
|
||||
if (!hand.length) node("p", "ms-deck-empty", "当前没有可编辑的梦三手牌。", list);
|
||||
for (const [index, card] of hand.entries()) {
|
||||
const data = card.storage.mengsanCard_shuying;
|
||||
const details = describeLibraryCard({ ...data, name: card.name, suit: card.suit, number: card.number });
|
||||
const label = `${index + 1}. ${details.name} ${details.suit}${details.number}${details.affixes.length ? ` · ${details.affixes.map(value => value.split(":")[0]).join("、")}` : ""}`;
|
||||
const item = button(label, list, () => { selected = card; render(); });
|
||||
item.classList.add("ms-gm-card");
|
||||
item.setAttribute("aria-pressed", String(selected === card));
|
||||
}
|
||||
if (!selected) { node("p", "ms-deck-empty", "选择一张手牌,然后添加词缀。", editor); return; }
|
||||
node("h3", "", `编辑:${describeLibraryCard(selected.storage.mengsanCard_shuying).name}`, editor);
|
||||
const choices = node("div", "ms-gm-affixes", null, editor);
|
||||
for (const [key, info] of Object.entries(AFFIX_INFO)) {
|
||||
const applied = selected.storage.mengsanCard_shuying.affixes.includes(key);
|
||||
const item = button(`${info.name}${applied ? " · 已有" : ""}`, choices, () => {
|
||||
if (!available() || !owner.getCards("h").includes(selected)) { render(); return; }
|
||||
piles.addAffix(selected, key);
|
||||
render();
|
||||
});
|
||||
item.disabled = applied;
|
||||
item.title = info.description;
|
||||
}
|
||||
};
|
||||
const open = () => {
|
||||
if (!available()) return false;
|
||||
if (dialog?.open) { render(); dialog.focus(); return true; }
|
||||
previousFocus = document.activeElement;
|
||||
dialog = node("dialog", "mengsan-deck-dialog-shuying mengsan-gm-dialog-shuying", null);
|
||||
dialog.setAttribute("aria-label", "梦三 GM 管理器");
|
||||
dialog.tabIndex = -1;
|
||||
const header = node("header", "ms-deck-header", null, dialog);
|
||||
node("h2", "ms-deck-title", "GM 管理器", header);
|
||||
button("关闭", header, close);
|
||||
node("p", "ms-deck-summary", "手牌词缀 · 仅本场战斗生效,不改永久牌组或存档。", dialog);
|
||||
button("刷新手牌", dialog, render);
|
||||
node("div", "ms-gm-hand", null, dialog);
|
||||
node("section", "ms-gm-editor", null, dialog);
|
||||
const created = dialog;
|
||||
dialog.addEventListener("close", () => { if (dialog === created) close(); });
|
||||
dialog.addEventListener("keydown", event => event.stopPropagation());
|
||||
document.body.appendChild(dialog);
|
||||
try { dialog.showModal(); } catch (error) { close(); throw error; }
|
||||
render();
|
||||
dialog.querySelector(".ms-deck-header button").focus();
|
||||
return true;
|
||||
};
|
||||
resources.field(globalThis, "mengsanGM_shuying", open);
|
||||
resources.field(game, "mengsanGM_shuying", open);
|
||||
const handle = { dispose: close };
|
||||
session.ownResource(handle, () => { disposed = true; close(); });
|
||||
return handle;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// 只呈现已经选定的预测技能;不从手牌或原生技能猜测意图。
|
||||
import { attackHitCount, previewAttackDamage } from "../battle/intent-damage.js";
|
||||
import { isDeathBlowIntent } from "../battle/death-blow.js";
|
||||
import { isStunIntent } from "../battle/stun-intent.js";
|
||||
|
||||
export function attackIconForDamage(damage) {
|
||||
if (!Number.isInteger(damage) || damage < 0) {
|
||||
throw new RangeError("预测伤害必须是非负整数");
|
||||
}
|
||||
if (damage < 5) return 1;
|
||||
if (damage < 10) return 2;
|
||||
if (damage < 20) return 3;
|
||||
if (damage < 40) return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
export function mountEnemyIntent(player, session, assetBase, document,
|
||||
getTarget = () => null) {
|
||||
const badge = document.createElement("div");
|
||||
badge.className = "mengsan-intent-shuying";
|
||||
badge.hidden = true;
|
||||
badge.setAttribute("aria-live", "polite");
|
||||
badge.setAttribute("aria-atomic", "true");
|
||||
const damageLine = document.createElement("span");
|
||||
damageLine.className = "mengsan-intent-damage-shuying";
|
||||
const amount = document.createElement("span");
|
||||
amount.className = "mengsan-intent-amount-shuying";
|
||||
const icon = document.createElement("img");
|
||||
icon.className = "mengsan-intent-icon-shuying";
|
||||
icon.alt = "";
|
||||
icon.decoding = "async";
|
||||
damageLine.append(icon, amount);
|
||||
const debuffLine = document.createElement("span");
|
||||
debuffLine.className = "mengsan-intent-debuff-shuying";
|
||||
const debuffIcon = document.createElement("img");
|
||||
debuffIcon.className = "mengsan-intent-icon-shuying";
|
||||
debuffIcon.alt = "";
|
||||
debuffIcon.decoding = "async";
|
||||
debuffIcon.src = `${assetBase}/assets/intent/Intent_debuff.png`;
|
||||
const debuffAmount = document.createElement("span");
|
||||
debuffLine.append(debuffIcon, debuffAmount);
|
||||
const blockLine = document.createElement("span");
|
||||
blockLine.className = "mengsan-intent-block-shuying";
|
||||
const blockIcon = document.createElement("img");
|
||||
blockIcon.className = "mengsan-intent-icon-shuying";
|
||||
blockIcon.alt = "";
|
||||
blockIcon.decoding = "async";
|
||||
blockIcon.src = `${assetBase}/assets/intent/Intent_defend.png`;
|
||||
const blockAmount = document.createElement("span");
|
||||
blockLine.append(blockIcon, blockAmount);
|
||||
const buffLine = document.createElement("span");
|
||||
buffLine.className = "mengsan-intent-buff-shuying";
|
||||
const buffIcon = document.createElement("img");
|
||||
buffIcon.className = "mengsan-intent-icon-shuying";
|
||||
buffIcon.alt = "";
|
||||
buffIcon.decoding = "async";
|
||||
buffIcon.src = `${assetBase}/assets/intent/Intent_buff.png`;
|
||||
const buffAmount = document.createElement("span");
|
||||
buffLine.append(buffIcon, buffAmount);
|
||||
badge.append(damageLine, debuffLine, blockLine, buffLine);
|
||||
player.appendChild(badge);
|
||||
let currentIntent = null;
|
||||
let timer;
|
||||
session.ownResource(badge, () => {
|
||||
clearInterval(timer);
|
||||
badge.remove();
|
||||
});
|
||||
const refresh = () => {
|
||||
const intent = currentIntent;
|
||||
if (!intent) {
|
||||
badge.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (isStunIntent(intent)) {
|
||||
damageLine.hidden = false;
|
||||
debuffLine.hidden = true;
|
||||
blockLine.hidden = true;
|
||||
buffLine.hidden = true;
|
||||
amount.textContent = "";
|
||||
const src = `${assetBase}/assets/intent/Intent_stun.png`;
|
||||
if (icon.getAttribute("src") !== src) icon.src = src;
|
||||
if (badge.getAttribute("aria-label") !== "Stunned") {
|
||||
badge.setAttribute("aria-label", "Stunned");
|
||||
}
|
||||
badge.hidden = false;
|
||||
return;
|
||||
}
|
||||
const hasDamage = intent.damage != null;
|
||||
const deathBlow = isDeathBlowIntent(intent);
|
||||
if (deathBlow && !hasDamage) {
|
||||
throw new TypeError("濒死一击必须有伤害数值");
|
||||
}
|
||||
damageLine.hidden = !hasDamage;
|
||||
const hasDebuff = typeof intent.debuff === "string" &&
|
||||
Number.isInteger(intent.stacks) && intent.stacks > 0;
|
||||
debuffLine.hidden = !hasDebuff;
|
||||
if (hasDebuff) {
|
||||
const debuffText = `×${intent.stacks}`;
|
||||
if (debuffAmount.textContent !== debuffText) {
|
||||
debuffAmount.textContent = debuffText;
|
||||
}
|
||||
}
|
||||
const hasBlock = Number.isInteger(intent.block) && intent.block > 0;
|
||||
const hasStrength = Number.isInteger(intent.strength) && intent.strength > 0;
|
||||
blockLine.hidden = !hasBlock;
|
||||
buffLine.hidden = !hasStrength;
|
||||
if (hasBlock) blockAmount.textContent = String(intent.block);
|
||||
if (hasStrength) {
|
||||
buffAmount.textContent = `+${intent.strength}`;
|
||||
}
|
||||
const effects = [];
|
||||
if (hasDamage) {
|
||||
const hits = attackHitCount(intent);
|
||||
if (deathBlow && hits !== 1) {
|
||||
throw new RangeError("濒死一击只能造成一次伤害");
|
||||
}
|
||||
const damage = previewAttackDamage(intent, player, getTarget());
|
||||
const tier = deathBlow ? null :
|
||||
attackIconForDamage(damage * hits);
|
||||
const damageText = hits > 1 ? `${damage}×${hits}` :
|
||||
String(damage);
|
||||
if (amount.textContent !== damageText) {
|
||||
amount.textContent = damageText;
|
||||
}
|
||||
const src = deathBlow ?
|
||||
`${assetBase}/assets/intent/Intent_death_blow.png` :
|
||||
`${assetBase}/assets/intent/Intent_attack_${tier}.png`;
|
||||
if (icon.getAttribute("src") !== src) icon.src = src;
|
||||
effects.push(`${deathBlow ? "Death Blow" : "Attack"}: ${damage} damage${hits > 1 ? ` × ${hits} hits` : ""}`);
|
||||
}
|
||||
if (hasDebuff) {
|
||||
const label = intent.debuff === "脆弱" ? "Frail" :
|
||||
intent.debuff === "易伤" ? "Vulnerable" : "Debuff";
|
||||
effects.push(`${label} ×${intent.stacks}`);
|
||||
}
|
||||
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`;
|
||||
if (icon.getAttribute("src") !== src) icon.src = src;
|
||||
effects.push("Unknown");
|
||||
}
|
||||
const label = effects.join(", ");
|
||||
if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label);
|
||||
badge.hidden = false;
|
||||
};
|
||||
timer = setInterval(refresh, 100);
|
||||
return intent => {
|
||||
currentIntent = intent;
|
||||
refresh();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { showMap as default, showMap } from "./renderer.js";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,120 @@
|
||||
import { lib, game, ui } from "../../../../noname.js";
|
||||
import { save } from "../../../../noname/util/config.js";
|
||||
|
||||
let selectingMode = false;
|
||||
|
||||
// Reuse the registered engine splash, not reload-to-the-current-mode or a fake mode list.
|
||||
export const openModeSelection = async () => {
|
||||
if (selectingMode) return;
|
||||
const splash = lib.onloadSplashes.find(item => item.id === lib.config.splash_style) || lib.onloadSplashes[0];
|
||||
if (!splash) throw new Error("模式选择界面未加载,请稍后重试。");
|
||||
selectingMode = true;
|
||||
const previousSplash = window.inSplash;
|
||||
const previousFocus = document.activeElement;
|
||||
const previousInert = ui.window.inert;
|
||||
const node = ui.create.div("#splash", document.body);
|
||||
node.style.zIndex = "1000";
|
||||
window.inSplash = true;
|
||||
ui.window.inert = true;
|
||||
try {
|
||||
let choose;
|
||||
const selected = new Promise(resolve => { choose = resolve; });
|
||||
await splash.init(node, choose);
|
||||
const mode = await selected;
|
||||
if (!lib.config.all.mode.includes(mode)) throw new Error("所选模式不可用。");
|
||||
await save("mode", "config", mode);
|
||||
lib.config.mode = mode;
|
||||
localStorage.setItem(`${lib.configprefix}directstart`, "true");
|
||||
// Reload only AFTER a mode is chosen; retain engine pending-write handling.
|
||||
node.inert = true;
|
||||
game.reload();
|
||||
// Keep the abandoned flow inert while the engine waits for pending DB writes.
|
||||
await new Promise(() => {});
|
||||
} catch (error) {
|
||||
// DefaultSplash owns a Vue app. Clean it up if opening/saving failed.
|
||||
splash.app?.unmount?.();
|
||||
throw error;
|
||||
} finally {
|
||||
node.remove();
|
||||
window.inSplash = previousSplash;
|
||||
ui.window.inert = previousInert;
|
||||
selectingMode = false;
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const askMenu = (title, description, choices) => new Promise(resolve => {
|
||||
const previousFocus = document.activeElement;
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "mengsan-menu-dialog-shuying";
|
||||
dialog.setAttribute("aria-label", title);
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = title;
|
||||
const body = document.createElement("p");
|
||||
body.textContent = description;
|
||||
dialog.append(heading, body);
|
||||
let closed = false;
|
||||
const finish = value => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
resolve(value);
|
||||
};
|
||||
for (const choice of choices) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = choice.name;
|
||||
if (choice.danger) button.className = "danger";
|
||||
button.addEventListener("click", () => finish(choice.id));
|
||||
dialog.appendChild(button);
|
||||
}
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); finish(null); });
|
||||
document.body.appendChild(dialog);
|
||||
dialog.showModal();
|
||||
dialog.querySelector("button")?.focus();
|
||||
});
|
||||
|
||||
export const mountMenu = (overlay, options = {}) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "mengsan-flow-menu-shuying";
|
||||
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
icon.setAttribute("viewBox", "0 0 24 24");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", "M4 6h16M4 12h16M4 18h16");
|
||||
icon.appendChild(path);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "菜单";
|
||||
button.append(icon, label);
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
let opened = false;
|
||||
button.addEventListener("click", async event => {
|
||||
event.stopPropagation();
|
||||
if (opened) return;
|
||||
opened = true;
|
||||
options.onOpen?.();
|
||||
try {
|
||||
const action = await askMenu("征程菜单", "退出不删除存档。未完成的节点与对话将在下次进入时重新开始。", [
|
||||
{ id: "resume", name: "继续当前界面" },
|
||||
{ id: "exit", name: "返回模式选择" },
|
||||
]);
|
||||
if (action !== "exit") return;
|
||||
const confirmed = await askMenu("返回模式选择?", "保留上次已完成节点的存档。本次尚未完成的内容不会提交。", [
|
||||
{ id: "cancel", name: "留在梦三" },
|
||||
{ id: "confirm", name: "确认返回模式选择", danger: true },
|
||||
]);
|
||||
if (confirmed === "confirm") await openModeSelection();
|
||||
} catch (error) {
|
||||
console.error("梦三返回模式选择失败:", error);
|
||||
await askMenu("暂时无法返回", "没有清除征程。请关闭提示后重试。", [{ id: "close", name: "回到当前界面" }]);
|
||||
} finally {
|
||||
opened = false;
|
||||
options.onClose?.();
|
||||
}
|
||||
});
|
||||
overlay.appendChild(button);
|
||||
return button;
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { lib, get } from "../../../../noname.js";
|
||||
import { cardCost } from "../battle/combat-rules.js";
|
||||
import { chooseButtons } from "./flow-ui.js";
|
||||
|
||||
const element = (tag, className, text, parent) => {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
parent?.appendChild(node);
|
||||
return node;
|
||||
};
|
||||
|
||||
const plainText = value => {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = String(value ?? "").replace(/<br\s*\/?\s*>/gi, "\n");
|
||||
return template.content.textContent.trim();
|
||||
};
|
||||
|
||||
const cardNameOf = choice => choice?.card?.name || ({ card_sha: "sha", card_tao: "tao" })[choice?.effectId] || null;
|
||||
export const isCardReward = choice => Boolean(cardNameOf(choice));
|
||||
|
||||
const viewCard = choice => {
|
||||
const name = cardNameOf(choice);
|
||||
const info = lib.card[name];
|
||||
const imageName = info?.cardimage || name;
|
||||
const image = /^[a-zA-Z0-9_]+$/.test(imageName) ? `${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 }),
|
||||
description: plainText(lib.translate[`${name}_info`] || choice.description || "暂无卡牌介绍。"),
|
||||
};
|
||||
};
|
||||
|
||||
const restoreFocus = previous => { if (previous?.isConnected) previous.focus(); };
|
||||
const RETURN_TO_VICTORY = Symbol("return to victory choice");
|
||||
|
||||
// Reusable card-only selector. It accepts three cards today and can lay out four or five later.
|
||||
// Callers supply stable choice IDs; this UI never creates or awards cards itself.
|
||||
export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip = true, skipLabel = "跳过" } = {}) {
|
||||
if (!Array.isArray(choices) || !choices.length || choices.some(choice => !choice?.id || !isCardReward(choice))) {
|
||||
return Promise.reject(new TypeError("chooseCardDialog requires nonempty card choices with IDs"));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const previousFocus = document.activeElement;
|
||||
const dialog = element("dialog", "mengsan-card-choice-dialog-shuying", null);
|
||||
dialog.setAttribute("aria-label", title);
|
||||
const stage = element("div", "mengsan-card-choice-stage-shuying", null, dialog);
|
||||
const banner = element("header", "mengsan-card-choice-heading-shuying", null, stage);
|
||||
element("span", "mengsan-reward-overline-shuying", "梦三 · 战利品", banner);
|
||||
element("h2", "", title, banner);
|
||||
element("p", "", "择一入阵,余者留于身后", banner);
|
||||
const cards = element("div", "mengsan-card-choice-grid-shuying", null, stage);
|
||||
cards.style.setProperty("--choice-count", String(Math.min(choices.length, 5)));
|
||||
let settled = false;
|
||||
const finish = id => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
restoreFocus(previousFocus);
|
||||
resolve(id);
|
||||
};
|
||||
for (const choice of choices) {
|
||||
const view = viewCard(choice);
|
||||
const card = element("button", "mengsan-card-choice-tile-shuying", null, cards);
|
||||
card.type = "button";
|
||||
card.setAttribute("aria-label", `${view.name},${view.type},${view.description}。选择此牌`);
|
||||
card.addEventListener("click", () => finish(choice.id));
|
||||
element("span", "mengsan-card-choice-cost-shuying", String(view.cost), card);
|
||||
element("span", "mengsan-card-choice-name-shuying", view.name, card);
|
||||
const artwork = element("span", "mengsan-card-choice-art-shuying", null, card);
|
||||
if (view.image) {
|
||||
const image = element("img", "", null, artwork);
|
||||
image.src = view.image; image.alt = ""; image.loading = "lazy";
|
||||
image.addEventListener("error", () => image.remove(), { once: true });
|
||||
}
|
||||
element("span", "mengsan-card-choice-glyph-shuying", view.name.slice(0, 1), artwork);
|
||||
element("span", "mengsan-card-choice-kind-shuying", view.type, card);
|
||||
element("span", "mengsan-card-choice-description-shuying", view.description, card);
|
||||
}
|
||||
if (allowSkip) {
|
||||
const skip = element("button", "mengsan-card-choice-skip-shuying", skipLabel, stage);
|
||||
skip.type = "button";
|
||||
skip.addEventListener("click", () => finish(null));
|
||||
}
|
||||
dialog.addEventListener("cancel", event => event.preventDefault());
|
||||
dialog.addEventListener("keydown", event => event.stopPropagation());
|
||||
// The engine scales <body>; attach to <html> so viewport-sized modals stay full-screen.
|
||||
document.documentElement.appendChild(dialog);
|
||||
try { dialog.showModal(); cards.querySelector("button")?.focus(); }
|
||||
catch (error) { dialog.remove(); restoreFocus(previousFocus); reject(error); }
|
||||
});
|
||||
}
|
||||
|
||||
// Reusable post-battle choice surface. Each option owns its follow-up; the surface
|
||||
// only resolves a stable candidate ID (or null) and never awards a reward itself.
|
||||
export function chooseVictoryOptions(options, { title = "战后抉择", allowSkip = true } = {}) {
|
||||
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"));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const previousFocus = document.activeElement;
|
||||
const dialog = element("dialog", "mengsan-victory-dialog-shuying", null);
|
||||
dialog.setAttribute("aria-label", title);
|
||||
const stage = element("div", "mengsan-victory-stage-shuying", null, dialog);
|
||||
const intro = element("header", "mengsan-victory-intro-shuying", null, stage);
|
||||
element("span", "mengsan-reward-overline-shuying", "梦三 · 战后", intro);
|
||||
element("span", "mengsan-victory-seal-shuying", "胜", intro);
|
||||
element("h2", "", title, intro);
|
||||
element("p", "", "此役已定,选择下一段征途的收获。", intro);
|
||||
const board = element("div", "mengsan-victory-options-shuying", null, stage);
|
||||
element("span", "mengsan-victory-eyebrow-shuying", "可选战利品", board);
|
||||
let settled = false, choosing = false;
|
||||
const finish = id => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
restoreFocus(previousFocus);
|
||||
resolve(id);
|
||||
};
|
||||
for (const [index, option] of options.entries()) {
|
||||
const entry = element("button", "mengsan-victory-option-shuying", null, board);
|
||||
entry.type = "button";
|
||||
entry.setAttribute("aria-label", `${option.label}。${option.description || ""}`);
|
||||
element("span", "mengsan-victory-option-index-shuying", String(index + 1).padStart(2, "0"), entry);
|
||||
const copy = element("span", "mengsan-victory-option-copy-shuying", null, entry);
|
||||
element("strong", "", option.label, copy);
|
||||
if (option.description) element("small", "", option.description, copy);
|
||||
element("span", "mengsan-victory-option-arrow-shuying", "›", entry).setAttribute("aria-hidden", "true");
|
||||
entry.addEventListener("click", async () => {
|
||||
if (settled || choosing) return;
|
||||
choosing = true;
|
||||
entry.disabled = true;
|
||||
if (dialog.open) dialog.close();
|
||||
try {
|
||||
const selected = option.choose ? await option.choose() : option.id;
|
||||
if (selected === RETURN_TO_VICTORY) {
|
||||
dialog.showModal();
|
||||
entry.disabled = false;
|
||||
choosing = false;
|
||||
entry.focus();
|
||||
} else finish(selected);
|
||||
}
|
||||
catch (error) {
|
||||
if (!settled) { settled = true; dialog.remove(); restoreFocus(previousFocus); reject(error); }
|
||||
}
|
||||
});
|
||||
}
|
||||
if (allowSkip) {
|
||||
const skip = element("button", "mengsan-victory-skip-shuying", "放弃本次选择", stage);
|
||||
skip.type = "button";
|
||||
skip.addEventListener("click", () => { if (!choosing) finish(null); });
|
||||
}
|
||||
dialog.addEventListener("cancel", event => event.preventDefault());
|
||||
dialog.addEventListener("keydown", event => event.stopPropagation());
|
||||
document.documentElement.appendChild(dialog);
|
||||
try { dialog.showModal(); board.querySelector("button")?.focus(); }
|
||||
catch (error) { dialog.remove(); restoreFocus(previousFocus); reject(error); }
|
||||
});
|
||||
}
|
||||
|
||||
// Gold is a persisted candidate, not an automatic reward. The card pool remains
|
||||
// a separate Dialog; skipping that Dialog returns to the victory choices.
|
||||
export function chooseBattleReward(choices) {
|
||||
if (!Array.isArray(choices) || !choices.length) return Promise.resolve(null);
|
||||
const gold = choices.find(choice => choice.kind === "gold");
|
||||
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);
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
return chooseVictoryOptions(options);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user