mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-13 04:20:03 +08:00
改动整体底层系统
This commit is contained in:
@@ -16,8 +16,8 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding,
|
||||
if (job.pending.state !== "chosen") {
|
||||
state = "choosing";
|
||||
if (!job.hasChoice) {
|
||||
job.choiceId = job.pending.outcome === "defeat" || !job.pending.choices.length ? null : await chooseReward(copy(job.pending.choices));
|
||||
if (job.pending.outcome !== "defeat" && job.pending.choices.length && !job.pending.choices.some(c => c.id === job.choiceId)) throw new Error("Invalid reward selection");
|
||||
job.choiceId = job.pending.outcome === "defeat" || !job.pending.choices.length ? null : await chooseReward(copy(job.pending.choices), { boss: job.pending.boss });
|
||||
if (job.pending.outcome !== "defeat" && job.pending.choices.length && job.choiceId !== null && !job.pending.choices.some(c => c.id === job.choiceId)) throw new Error("Invalid reward selection");
|
||||
job.hasChoice = true;
|
||||
}
|
||||
job.pending = await settlement.choose(job.input.run.runId, job.pending.id, job.choiceId);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a
|
||||
// Persist fixed candidates before opening choice UI. No mutation of the live run.
|
||||
async prepare({run: snapshot, node, encounter, hp, outcome = "victory"}) {
|
||||
if (!["victory", "defeat"].includes(outcome)) throw new Error("Unknown battle outcome");
|
||||
if (outcome === "victory" && (!Number.isFinite(hp) || !Number.isFinite(encounter.gold) || encounter.gold < 0)) throw new Error("Invalid battle result");
|
||||
if (outcome === "victory" && (!Number.isFinite(hp) || (!encounter.skipRandomReward && (!Number.isSafeInteger(encounter.gold) || encounter.gold < 0)))) throw new Error("Invalid battle result");
|
||||
const id = idFor(snapshot, node);
|
||||
return store.update(storage => {
|
||||
const run = current(storage, snapshot.runId);
|
||||
@@ -36,12 +36,13 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a
|
||||
const base = copy(snapshot); delete base.battleFlow;
|
||||
if (outcome === "victory") {
|
||||
base.player.hp = Math.max(1, Math.min(hp, base.player.maxHp ?? hp));
|
||||
base.player.gold += encounter.gold;
|
||||
base.statistics.goldEarned += encounter.gold;
|
||||
base.statistics.defeatedEnemies += encounter.defeatedEnemies ?? 1;
|
||||
}
|
||||
const choices = outcome === "victory" && !encounter.skipRandomReward ? getRandomRewardChoices(base, encounter.rewardPool).filter(c => c.name) : [];
|
||||
if (outcome === "victory" && !encounter.skipRandomReward && !choices.length) throw new Error("Empty battle reward pool");
|
||||
const rewards = outcome === "victory" && !encounter.skipRandomReward ? getRandomRewardChoices(base, encounter.rewardPool).filter(c => c.name) : [];
|
||||
if (outcome === "victory" && !encounter.skipRandomReward && !rewards.length) throw new Error("Empty battle reward pool");
|
||||
// Persist the configured amount as a candidate, never as automatic victory income.
|
||||
const choices = outcome === "victory" && !encounter.skipRandomReward
|
||||
? [{id:"mengsan.reward.gold.shuying", kind:"gold", name:"金币", amount:encounter.gold}, ...rewards] : [];
|
||||
run.battleFlow.pending = {id, state:"awaitingChoice", nodeId:node.id, base, choices:copy(choices), fixedRewards:copy(encounter.fixedRewards || []), victoryDialogue:copy(encounter.victoryDialogue || []), boss:!!encounter.boss, outcome};
|
||||
return run.battleFlow.pending;
|
||||
});
|
||||
@@ -57,9 +58,13 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a
|
||||
let route = "defeat";
|
||||
if (item.outcome === "victory") {
|
||||
const choice = item.choices.find(c => c.id === choiceId);
|
||||
if (item.choices.length) {
|
||||
if (item.choices.length && choiceId !== null) {
|
||||
if (!choice) throw new Error("Reward is not a fixed candidate");
|
||||
applyReward(result, choice.effectId || choice.id);
|
||||
if (choice.kind === "gold") {
|
||||
if (!Number.isSafeInteger(choice.amount) || choice.amount < 0) throw new Error("Invalid pending gold reward");
|
||||
result.player.gold += choice.amount;
|
||||
result.statistics.goldEarned += choice.amount;
|
||||
} else applyReward(result, choice.effectId || choice.id);
|
||||
} else if (choiceId !== null) throw new Error("Unexpected reward selection");
|
||||
for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId);
|
||||
if (!completeNode(result, item.nodeId)) throw new Error("Node completion failed");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cardDefinitions, canAcquireCard } from "../content/card-definitions.js";
|
||||
|
||||
const suits = ["spade", "heart", "club", "diamond"];
|
||||
|
||||
// 仅创建可序列化的牌组数据;战斗中的实体牌仍由 game.createCard 创建。
|
||||
export function createCardData(spec, { character, enemy = false, registry = cardDefinitions } = {}) {
|
||||
if (!spec || typeof spec !== "object" || !Object.hasOwn(registry, spec.name)) {
|
||||
throw new Error(`梦三牌未注册:${spec?.name}`);
|
||||
}
|
||||
if (!enemy && !canAcquireCard(character, spec.name, registry)) {
|
||||
throw new Error(`武将 ${character || "未知"} 无法获得牌:${spec.name}`);
|
||||
}
|
||||
if (typeof spec.id !== "string" || !spec.id || !suits.includes(spec.suit) ||
|
||||
!Number.isInteger(spec.number) || spec.number < 1 || spec.number > 13 ||
|
||||
(spec.nature != null && typeof spec.nature !== "string") ||
|
||||
!Number.isInteger(spec.upgrade ?? 0) || (spec.upgrade ?? 0) < 0 ||
|
||||
!Array.isArray(spec.affixes ?? []) || !(spec.affixes ?? []).every(value => typeof value === "string")) {
|
||||
throw new Error(`梦三牌数据无效:${spec.name}`);
|
||||
}
|
||||
return {
|
||||
id: spec.id, suit: spec.suit, number: spec.number, name: spec.name,
|
||||
nature: spec.nature ?? null, affixes: (spec.affixes ?? []).slice(), upgrade: spec.upgrade ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRandomCardData(run, name, { random, now = Date.now, upgrade = 0, affixes = [] } = {}) {
|
||||
if (!run?.player || !Array.isArray(run.player.deck) || typeof random !== "function") throw new Error("梦三随机牌参数无效");
|
||||
// 先检查资格,再消耗随机数;失败的获得操作不改变后续奖励序列。
|
||||
if (!canAcquireCard(run.player.character, name)) throw new Error(`武将 ${run.player.character} 无法获得牌:${name}`);
|
||||
const stamp = now();
|
||||
let id;
|
||||
do { id = `mengsan_card_${stamp}_${Math.floor(random() * 1e6)}`; }
|
||||
while (run.player.deck.some(card => card.id === id));
|
||||
return createCardData({
|
||||
id, suit: suits[Math.floor(random() * suits.length)], number: 1 + Math.floor(random() * 13),
|
||||
name, nature: null, upgrade, affixes,
|
||||
}, { character: run.player.character });
|
||||
}
|
||||
|
||||
export function addCardToDeck(run, spec) {
|
||||
const card = createCardData(spec, { character: run?.player?.character });
|
||||
if (!Array.isArray(run?.player?.deck)) throw new Error("梦三牌组无效");
|
||||
if (run.player.deck.some(existing => existing.id === card.id)) throw new Error(`梦三牌 ID 重复:${card.id}`);
|
||||
run.player.deck.push(card);
|
||||
return card;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 冷却按飞蝇菌子自己的行动次数计算,不受玩家回合或增援数量影响。
|
||||
export const FLYCONID_MOVES = Object.freeze({
|
||||
smash: Object.freeze({ id: "smash", name: "猛砸", damage: 11 }),
|
||||
frail: Object.freeze({ id: "frail", name: "脆弱孢子", damage: 8, debuff: "脆弱", stacks: 2 }),
|
||||
vulnerable: Object.freeze({ id: "vulnerable", name: "易伤孢子", debuff: "易伤", stacks: 2 }),
|
||||
});
|
||||
|
||||
export function eligibleFlyconidMoves(state = {}) {
|
||||
const upcoming = (state.turnsTaken || 0) + 1;
|
||||
const lastUsed = state.lastUsed || {};
|
||||
const moves = [];
|
||||
if (state.lastMove !== "smash") moves.push(FLYCONID_MOVES.smash);
|
||||
if (lastUsed.frail == null || upcoming - lastUsed.frail > 2) moves.push(FLYCONID_MOVES.frail);
|
||||
if (upcoming >= 2 && (lastUsed.vulnerable == null || upcoming - lastUsed.vulnerable > 3)) moves.push(FLYCONID_MOVES.vulnerable);
|
||||
return moves;
|
||||
}
|
||||
|
||||
export function selectFlyconidMove(state, random) {
|
||||
const moves = eligibleFlyconidMoves(state);
|
||||
if (!moves.length) return null;
|
||||
const roll = random();
|
||||
if (!Number.isFinite(roll) || roll < 0 || roll >= 1) throw new RangeError("怪物随机数必须在 [0,1) 内");
|
||||
return moves[Math.floor(roll * moves.length)];
|
||||
}
|
||||
|
||||
export function recordFlyconidAction(state, move, paid) {
|
||||
const next = {
|
||||
turnsTaken: (state.turnsTaken || 0) + 1,
|
||||
lastMove: state.lastMove || null,
|
||||
lastUsed: { ...(state.lastUsed || {}) },
|
||||
};
|
||||
if (paid && move) {
|
||||
next.lastMove = move.id;
|
||||
next.lastUsed[move.id] = next.turnsTaken;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 只呈现已经选定的预测技能;不从手牌或原生技能猜测意图。
|
||||
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) {
|
||||
const badge = document.createElement("div");
|
||||
badge.className = "mengsan-intent-shuying";
|
||||
badge.hidden = true;
|
||||
badge.setAttribute("aria-live", "polite");
|
||||
const name = document.createElement("span");
|
||||
name.className = "mengsan-intent-name-shuying";
|
||||
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(amount, icon);
|
||||
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.webp`;
|
||||
const debuffAmount = document.createElement("span");
|
||||
debuffLine.append(debuffIcon, debuffAmount);
|
||||
badge.append(name, damageLine, debuffLine);
|
||||
player.appendChild(badge);
|
||||
session.ownResource(badge, () => badge.remove());
|
||||
return intent => {
|
||||
if (!intent) {
|
||||
badge.hidden = true;
|
||||
badge.removeAttribute("title");
|
||||
return;
|
||||
}
|
||||
if (typeof intent.name !== "string" || !intent.name.trim()) throw new TypeError("预测技能必须有名称");
|
||||
name.textContent = intent.name;
|
||||
const hasDamage = intent.damage != null;
|
||||
damageLine.hidden = !hasDamage;
|
||||
const hasDebuff = typeof intent.debuff === "string" && Number.isInteger(intent.stacks) && intent.stacks > 0;
|
||||
debuffLine.hidden = !hasDebuff;
|
||||
if (hasDebuff) debuffAmount.textContent = `${intent.debuff}×${intent.stacks}`;
|
||||
if (hasDamage) {
|
||||
const tier = attackIconForDamage(intent.damage);
|
||||
amount.textContent = String(intent.damage);
|
||||
const src = `${assetBase}/assets/intent/Intent_attack_${tier}.webp`;
|
||||
if (icon.getAttribute("src") !== src) icon.src = src;
|
||||
badge.title = `${intent.name}:预计造成 ${intent.damage} 点伤害(非最终伤害)${hasDebuff ? `,施加${intent.stacks}层${intent.debuff}` : ""}`;
|
||||
} else {
|
||||
badge.title = hasDebuff ? `${intent.name}:施加${intent.stacks}层${intent.debuff}` : intent.name;
|
||||
}
|
||||
badge.hidden = false;
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createCardData } from "./card-data.js";
|
||||
|
||||
// 梦三怪物规则只作用于敌方单位;子类可覆盖构造参数或 createDeck()。
|
||||
const TIER_RULES = Object.freeze({
|
||||
normal: Object.freeze({ draw: 1, handLimit: 3, energy: 2 }),
|
||||
@@ -13,6 +15,7 @@ const BASE_DECKS = Object.freeze({
|
||||
|
||||
const HEALTH = Object.freeze({
|
||||
mengsan_soldier_shuying: 24,
|
||||
mengsan_flyconid_shuying: 48,
|
||||
re_xiahoudun: 30, re_zhangliao: 28, re_xuzhu: 34, re_lvbu: 120,
|
||||
re_ganning: 39, re_zhangfei: 42, re_huangzhong: 37, re_weiyan: 40,
|
||||
re_guanyu: 65, dianwei: 62, shen_guanyu: 165,
|
||||
@@ -43,12 +46,12 @@ export class Monster {
|
||||
createDeck(prefix) {
|
||||
return this.deck.map((entry, index) => {
|
||||
const card = typeof entry === "string" ? { name: entry } : entry;
|
||||
return {
|
||||
return createCardData({
|
||||
id: `${prefix}_${index}`, name: card.name,
|
||||
suit: card.suit || ["spade", "heart", "club", "diamond"][index % 4],
|
||||
number: card.number || index % 13 + 1,
|
||||
nature: card.nature || null, affixes: Array.isArray(card.affixes) ? card.affixes.slice() : [], upgrade: card.upgrade || 0,
|
||||
};
|
||||
}, { enemy: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -57,9 +60,12 @@ export class Monster {
|
||||
export class OpeningSoldier extends Monster {
|
||||
constructor(options = {}) { super({ character: "mengsan_soldier_shuying", tier: "normal", hp: 24, ...options }); }
|
||||
}
|
||||
export class Flyconid extends Monster {
|
||||
constructor(options = {}) { super({ ...options, character: "mengsan_flyconid_shuying", tier: "normal", hp: 48 }); }
|
||||
}
|
||||
|
||||
// 新怪物子类在这里注册,未注册的角色使用 Monster 的阶级默认值。
|
||||
export const MONSTER_TYPES = Object.freeze({ mengsan_soldier_shuying: OpeningSoldier });
|
||||
export const MONSTER_TYPES = Object.freeze({ mengsan_soldier_shuying: OpeningSoldier, mengsan_flyconid_shuying: Flyconid });
|
||||
|
||||
export function createMonster(spec) {
|
||||
const Type = Object.hasOwn(MONSTER_TYPES, spec.character) ? MONSTER_TYPES[spec.character] : Monster;
|
||||
|
||||
@@ -13,7 +13,7 @@ export function installPersonalPiles(session, owner, battle, run, resources, env
|
||||
const draw = document.createElement("div"), discard = document.createElement("div");
|
||||
// Detached nodes: engine get.position recognises these IDs; no duplicate document IDs.
|
||||
draw.id = "cardPile"; discard.id = "discardPile";
|
||||
const owned = new Set(), restores = [];
|
||||
const owned = new Set(), restores = [], costBadges = new Map();
|
||||
const strict = env.strict !== false;
|
||||
let released = false, forcedOwner = false;
|
||||
const personal = () => {
|
||||
@@ -27,6 +27,26 @@ export function installPersonalPiles(session, owner, battle, run, resources, env
|
||||
return false; // Never infer ownership from whose turn it is.
|
||||
};
|
||||
const refresh = () => { if (!released) env.refresh(); };
|
||||
function showCost(card) {
|
||||
if (!env.showCosts || !card?.name) return;
|
||||
const cost = String(cardCost(card));
|
||||
card.dataset.mengsanCost = cost;
|
||||
let badge = costBadges.get(card);
|
||||
if (!badge) {
|
||||
badge = Array.from(card.children || []).find(child => child.classList?.contains("mengsan-hand-cost-shuying"));
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "mengsan-hand-cost-shuying";
|
||||
badge.setAttribute("aria-hidden", "true");
|
||||
card.appendChild(badge);
|
||||
}
|
||||
costBadges.set(card, badge);
|
||||
}
|
||||
// 十周年 UI scans thrown-card children via innerText when adding “刘备使用”.
|
||||
// Keep this child text-free so its numeric cost cannot match that scan.
|
||||
badge.textContent = "";
|
||||
badge.dataset.mengsanCost = cost;
|
||||
}
|
||||
function adopt(card, data) {
|
||||
if (owned.has(card)) return;
|
||||
owned.add(card); resources.card(card);
|
||||
@@ -37,9 +57,7 @@ export function installPersonalPiles(session, owner, battle, run, resources, env
|
||||
// Retired affixes from old saves must not acquire new battle behaviour.
|
||||
card.storage.mengsanCard_shuying.affixes = (card.storage.mengsanCard_shuying.affixes || []).filter(key => key !== "annihilate");
|
||||
}
|
||||
if (data && env.showCosts) {
|
||||
card.dataset.mengsanCost = String(cardCost(data));
|
||||
}
|
||||
if (data) showCost(card);
|
||||
// Existing foreign/generated cards retain their original destruction semantics.
|
||||
if (!data) return;
|
||||
card.destroyLog = false;
|
||||
@@ -66,6 +84,27 @@ export function installPersonalPiles(session, owner, battle, run, resources, env
|
||||
const card = game.createCard(data.name, data.suit, data.number, data.nature);
|
||||
adopt(card, data); draw.appendChild(card);
|
||||
}
|
||||
// Native and 十周年 UI both move real card nodes into these hand containers.
|
||||
// Decorate newly gained cards without patching either UI's card factory or layout.
|
||||
const handZones = [owner.node?.handcards1, owner.node?.handcards2].filter(Boolean);
|
||||
const handObserver = new MutationObserver(mutations => {
|
||||
if (released) return;
|
||||
for (const mutation of mutations) for (const card of mutation.addedNodes) {
|
||||
if (card.classList?.contains("card")) showCost(card);
|
||||
}
|
||||
});
|
||||
for (const zone of handZones) {
|
||||
for (const card of zone.childNodes) if (card.classList?.contains("card")) showCost(card);
|
||||
handObserver.observe(zone, { childList: true });
|
||||
}
|
||||
session.ownResource(handObserver, () => {
|
||||
handObserver.disconnect();
|
||||
for (const [card, badge] of costBadges) {
|
||||
if (badge.parentNode === card) badge.remove();
|
||||
delete card.dataset.mengsanCost;
|
||||
}
|
||||
costBadges.clear();
|
||||
});
|
||||
const publicDiscard = ui.discardPile;
|
||||
for (const [key, node] of [["cardPile", draw], ["discardPile", discard]]) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(ui, key);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { lib, get } from "../../../../noname.js";
|
||||
import { cardCost } from "./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);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import config from "../config.js";
|
||||
import { nextRandom } from "../state.js";
|
||||
import { canAcquireCard } from "../content/card-definitions.js";
|
||||
|
||||
export const rewardCardName = reward => reward?.card?.name || ({ card_sha: "sha", card_tao: "tao" })[reward?.effectId] || null;
|
||||
export const canAcquireReward = (run, reward) => {
|
||||
const name = rewardCardName(reward);
|
||||
return !name || canAcquireCard(run?.player?.character, name);
|
||||
};
|
||||
|
||||
export const getRandomRewardChoices = (run, poolId, count = 3) => {
|
||||
const fallbackId = "shared.pool.battle.normal";
|
||||
const pool = (config.rewardPools[poolId] || config.rewardPools[fallbackId]).slice();
|
||||
const pool = (config.rewardPools[poolId] || config.rewardPools[fallbackId])
|
||||
.filter(id => canAcquireReward(run, config.rewards[id])).slice();
|
||||
for (let index = pool.length - 1; index > 0; index--) {
|
||||
const target = Math.floor(nextRandom(run) * (index + 1));
|
||||
[pool[index], pool[target]] = [pool[target], pool[index]];
|
||||
|
||||
Reference in New Issue
Block a user