改动整体底层系统

This commit is contained in:
2026-09-29 00:29:00 +08:00
parent ced1b28cc2
commit 2e552cf17d
24 changed files with 809 additions and 53 deletions
+1
View File
@@ -2,3 +2,4 @@ log.txt
dist/manifest.json
tools/__pycache__/
/mengsan/.planning/
/mengsan/verification
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+2 -1
View File
@@ -12,7 +12,8 @@ const act1Map = {
contentId: "act1.battle.liubeiOpening",
},
],
enemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"],
enemies: ["mengsan_flyconid_shuying"],
eliteEnemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"],
boss: "re_lvbu",
baseGold: 20,
};
+35
View File
@@ -0,0 +1,35 @@
// 梦三永久牌组的可获得牌注册表。这里登记牌名与归属,不复制引擎中的效果定义。
// 新增武将专属牌时填写 owner 为该武将 ID;通用牌填写 owner: null。
const definitions = [
{ name: "sha", category: "damage", owner: null },
{ name: "shan", category: "utility", owner: null },
{ name: "tao", category: "recovery", owner: null },
{ name: "jiu", category: "recovery", owner: null },
{ name: "guohe", category: "utility", owner: null },
{ name: "wuzhong", category: "utility", owner: null },
{ name: "juedou", category: "damage", owner: null },
{ name: "bagua", category: "utility", owner: null },
// 当前仅刘备专属首关的固定奖励会永久获得【的卢】。
{ name: "dilu", category: "utility", owner: "mengsan_liubei_shuying" },
];
export function createCardRegistry(entries) {
const registry = Object.create(null);
for (const entry of entries) {
if (!entry || typeof entry.name !== "string" || !/^[a-z][a-z0-9_]*$/.test(entry.name) ||
!["damage", "recovery", "utility"].includes(entry.category) ||
!(entry.owner === null || typeof entry.owner === "string" && entry.owner.length > 0) ||
Object.hasOwn(registry, entry.name)) {
throw new Error(`梦三牌定义无效或重复:${entry?.name}`);
}
registry[entry.name] = Object.freeze({ name: entry.name, category: entry.category, owner: entry.owner });
}
return Object.freeze(registry);
}
export const cardDefinitions = createCardRegistry(definitions);
export function canAcquireCard(character, name, registry = cardDefinitions) {
const definition = Object.hasOwn(registry, name) ? registry[name] : null;
return !!definition && (definition.owner === null || definition.owner === character);
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { createCardData } from "../runtime/card-data.js";
// Mode-owned starting decks. Existing saves always retain their actual player.deck.
export const defaultDeck = Object.freeze([
["spade", 7, "sha"], ["heart", 10, "sha"],
@@ -24,8 +26,8 @@ export function getStartingDeck(character) {
}
export function createStartingDeck(character, prefix = `mengsan_card_${Date.now()}`) {
return getStartingDeck(character).map((entry, index) => ({
return getStartingDeck(character).map((entry, index) => createCardData({
id: `${prefix}_${index}`, suit: entry[0], number: entry[1], name: entry[2],
nature: entry[3] || null, affixes: entry[4] ? entry[4].slice() : [], upgrade: 0,
}));
}, { character }));
}
+3
View File
@@ -6,6 +6,8 @@ export function createScenarioCharacters(Character) {
// 国战男性普通小兵使用原生 shibing1 立绘;实际战斗生命由怪物类或关卡配置决定。
mengsan_soldier_shuying: new Character({ sex: "male", group: "qun", hp: 4, maxHp: 4,
skills: [], img: "image/character/shibing1.jpg" }),
mengsan_flyconid_shuying: new Character({ sex: "none", group: "qun", hp: 48, maxHp: 48,
skills: [], img: "extension/术樱包/mengsan/assets/flyconid-portrait.png" }),
mengsan_sushuang_zhangshiping_shuying: new Character({ sex: "male", group: "shu", hp: 4, maxHp: 4,
skills: [], img: "extension/术樱包/mengsan/assets/sushuang-zhangshiping.png", names: "苏|双-张|世平" }),
};
@@ -13,5 +15,6 @@ export function createScenarioCharacters(Character) {
export const scenarioTranslations = {
mengsan_liubei_shuying: "界刘备", mengsan_liubei_shuying_title: "50/50生命 · 无初始技能",
mengsan_soldier_shuying: "士兵",
mengsan_flyconid_shuying: "飞蝇菌子",
mengsan_sushuang_zhangshiping_shuying: "苏双&张世平",
};
+2 -1
View File
@@ -5,6 +5,7 @@ export const rewards = {
},
"shared.reward.card.sha": { effectId: "card_sha", name: "获得一张【杀】", description: "加入独立永久牌组" },
"shared.reward.card.tao": { effectId: "card_tao", name: "获得一张【桃】", description: "加入独立永久牌组" },
"shared.reward.card.wuzhong": { card: { name: "wuzhong" }, name: "获得一张【无中生有】", description: "加入独立永久牌组" },
"shared.reward.card.upgrade": { effectId: "upgrade", name: "随机强化", description: "随机一张牌强化一级" },
"shared.reward.heal": { effectId: "heal", name: "整顿伤势", description: "回复 8 点生命" },
"shared.reward.item.handCharm": { effectId: "item_hand", name: "道具·束带", description: "基础手牌上限额外 +1" },
@@ -16,7 +17,7 @@ export const rewardPools = {
"shared.pool.battle.normal": [
"shared.reward.card.sha",
"shared.reward.card.tao",
"shared.reward.card.upgrade",
"shared.reward.card.wuzhong",
],
"shared.pool.boss.premium": [
"shared.reward.skill.heroic",
+123 -19
View File
@@ -3,6 +3,8 @@ import { createMengsanCards } from "./content/mode-cards.js";
import { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport } from "./runtime/battle-director.js";
import { installPersonalPiles } from "./runtime/personal-piles.js";
import { createMonster } from "./runtime/monster.js";
import { mountEnemyIntent } from "./runtime/intent-display.js";
import { selectFlyconidMove, recordFlyconidAction } from "./runtime/flyconid-intent.js";
import { PLAYER_ENERGY, PLAYER_HAND_LIMIT, canPayCard, payCard, isActiveCardUse } from "./runtime/combat-rules.js";
import { mountBattlePiles } from "./runtime/card-library.js";
import { mountGMManager } from "./runtime/gm-manager.js";
@@ -18,6 +20,9 @@ import { lib, game, ui, get, _status } from "../../../noname.js";
import config from "./config.js";
import { showMap } from "./map/index.js";
import { getRandomRewardChoices } from "./runtime/reward.js";
import { canAcquireCard } from "./content/card-definitions.js";
import { addCardToDeck, createRandomCardData } from "./runtime/card-data.js";
import { chooseBattleReward } from "./runtime/reward-ui.js";
import { applyStoryOutcome, getAvailableStoryChoices } from "./runtime/story.js";
import { playDialogue } from "./runtime/dialogue.js";
import { chooseButtons } from "./runtime/flow-ui.js";
@@ -71,31 +76,25 @@ const chooseCharacter = async () => {
return chooseButtons("选择出征武将", [...(choices.length ? choices : [{ id: "mengsan_liubei_shuying", name: "界刘备" }]), { id: "back", name: "返回存档选择", description: "不会修改现有征程" }], "此武将将陪伴你完成本次征程。", { back: "back", eyebrow: "出征准备" });
};
const createCardInstance = (run, name = "sha", upgrade = 0, affixes = []) => ({
id: `mengsan_card_${Date.now()}_${Math.floor(nextRandom(run) * 1e6)}`,
suit: ["spade", "heart", "club", "diamond"][Math.floor(nextRandom(run) * 4)],
number: 1 + Math.floor(nextRandom(run) * 13),
name,
nature: null,
upgrade,
affixes: affixes.slice(),
});
const createCardInstance = (run, name = "sha", upgrade = 0, affixes = []) =>
createRandomCardData(run, name, { random: () => nextRandom(run), upgrade, affixes });
const applyReward = (run, rewardId) => {
const player = run.player;
const cardReward = config.rewards[rewardId]?.card;
if (cardReward) {
player.deck.push({ ...createCardInstance(run, cardReward.name), ...copy(cardReward) });
if (!canAcquireCard(player.character, cardReward.name)) throw new Error(`武将 ${player.character} 无法获得牌:${cardReward.name}`);
addCardToDeck(run, { ...createCardInstance(run, cardReward.name), ...copy(cardReward) });
return;
}
const support = config.rewards[rewardId]?.support;
if (support) { grantSupport(run, rewardId, support); return; }
switch (rewardId) {
case "card_sha":
player.deck.push(createCardInstance(run, "sha"));
addCardToDeck(run, createCardInstance(run, "sha"));
break;
case "card_tao":
player.deck.push(createCardInstance(run, "tao"));
addCardToDeck(run, createCardInstance(run, "tao"));
break;
case "upgrade": {
const eligible = player.deck.filter(card => !card.affixes?.includes("eternal"));
@@ -176,13 +175,14 @@ const finishUtilityNode = async (run, node) => {
else if (node.type == "shop") {
const price = 20;
const choice = await chooseButtons("商店 Demo", [
{ id: "card_sha", name: `购买【杀】(${price}金币)`, description: "加入个人牌组", disabled: run.player.gold < price },
{ id: "card_sha", name: `购买【杀】(${price}金币)`, description: "加入个人牌组", disabled: run.player.gold < price || !canAcquireCard(run.player.character, "sha") },
{ id: "heal", name: `恢复生命(${price}金币)`, description: "回复 8 点生命", disabled: run.player.gold < price },
{ id: "leave", name: "离开", description: "暂不购买" },
]);
if (choice != "leave") {
run.player.gold -= price;
if (choice != "leave" && choice != null) {
if (run.player.gold < price || (choice === "card_sha" && !canAcquireCard(run.player.character, "sha"))) throw new Error("梦三商店购买资格无效");
applyReward(run, choice);
run.player.gold -= price;
}
}
else {
@@ -258,6 +258,41 @@ const mountEnergy = (player, session) => {
refresh();
return refresh;
};
const isFlyconid = player => player?.name === "mengsan_flyconid_shuying" && player.storage?.mengsanCamp_shuying === "enemy";
const planFlyconidIntent = (player, run) => {
if (!isFlyconid(player) || !player.isAlive() || player.storage.mengsanFlyconidIntent_shuying) return;
const move = selectFlyconidMove(player.storage.mengsanFlyconidState_shuying || {}, () => nextRandom(run));
player.storage.mengsanFlyconidIntent_shuying = move;
game.mengsanSetEnemyIntent_shuying(player, move);
};
const applyFlyconidDebuff = (target, move) => {
if (!move.debuff) return;
const frail = move.id === "frail";
const key = frail ? "mengsanFrail_shuying" : "mengsanVulnerable_shuying";
target.storage[key] = (target.storage[key] || 0) + move.stacks;
target.addSkill(frail ? "mengsan_frail_shuying" : "mengsan_vulnerable_shuying");
target.markSkill(frail ? "mengsan_frail_shuying" : "mengsan_vulnerable_shuying");
};
const executeFlyconidIntent = async player => {
const move = player.storage.mengsanFlyconidIntent_shuying;
if (!move) return;
player.storage.mengsanFlyconidIntent_shuying = null;
game.mengsanSetEnemyIntent_shuying(player, null);
const paid = player.storage.mengsanEnergy_shuying >= 1;
player.storage.mengsanFlyconidState_shuying = recordFlyconidAction(player.storage.mengsanFlyconidState_shuying || {}, move, paid);
if (!paid) { game.log(player, "费用不足,未发动", move.name); return; }
player.storage.mengsanEnergy_shuying--;
activeBattle?.energyUI.get(player)?.();
game.log(player, "消耗1费用发动", move.name);
const target = game.me;
if (!target?.isAlive() || !player.isAlive()) return;
if (move.damage != null) {
const hit = target.damage(move.damage, player);
hit.mengsanAttack_shuying = true;
await hit;
}
if (target.isAlive() && player.isAlive()) applyFlyconidDebuff(target, move);
};
const equipBattleUnit = async (player, spec, resources) => {
for (const info of spec.equipment || []) {
const card = resources.card(game.createCard(info.name, info.suit, info.number, info.nature));
@@ -279,11 +314,15 @@ const createScenario = (plan, current) => {
join.setContent(async () => {
const monster = initBattleUnit(player, spec);
installMonsterPile(player, monster, current);
if (monster) current.energyUI.set(player, mountEnergy(player, current.session));
if (monster) {
current.energyUI.set(player, mountEnergy(player, current.session));
current.intentUI.set(player, mountEnemyIntent(player, current.session, STYLE_PATH, document));
}
for (const participant of [...game.players, ...game.dead]) participant.setSeatNum(Number(participant.dataset.position) + 1);
await equipBattleUnit(player, spec, current.resources);
await player.draw(spec.hand ?? 4);
await game.triggerEnter(player);
if (monster && current.session.active) planFlyconidIntent(player, _status.mengsanRun_shuying);
});
await join;
game.log(player, "作为", spec.camp === "ally" ? "友方支援" : "敌方援军", "加入战斗");
@@ -358,6 +397,9 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
const participants = game.players.filter(player => player !== me);
const monsters = plan.units.map((spec, index) => initBattleUnit(participants[index], spec));
plan.units.forEach((spec, index) => installMonsterPile(participants[index], monsters[index], currentBattle));
participants.forEach((player, index) => {
if (monsters[index]) currentBattle.intentUI.set(player, mountEnemyIntent(player, session, STYLE_PATH, document));
});
activeBattle.director = createScenario(plan, currentBattle);
activeBattle.director.bind("player", me);
plan.units.forEach((spec, index) => activeBattle.director.bind(spec.id, participants[index]));
@@ -376,7 +418,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
run.player.permanentSkills.forEach(skill => {
if (lib.skill[skill] && !me.hasSkill(skill)) me.addSkill(skill);
});
for (const skill of ["mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_card_payment_shuying", "mengsan_monster_draw_shuying"]) {
for (const skill of ["mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_card_payment_shuying", "mengsan_monster_draw_shuying", "mengsan_flyconid_action_shuying"]) {
if (!lib.skill.global.includes(skill)) {
game.addGlobalSkill(skill);
session.ownResource({}, () => game.removeGlobalSkill(skill));
@@ -413,6 +455,9 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
currentBattle.energyUI.get(player)?.();
}
await currentBattle.director.beforeTurn(player);
if (player === me && session.active) {
for (const enemy of game.players) planFlyconidIntent(enemy, run);
}
game.checkResult();
}}));
};
@@ -437,7 +482,7 @@ const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({
releaseSkills,
presentVictory: pending => playDialogue(pending.victoryDialogue, { run: pending.base, title: "战后剧情" }),
settlement:createBattleSettlement({store:modeStorage, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct}),
chooseReward:choices => chooseButtons("战斗奖励(三选一)", choices),
chooseReward:chooseBattleReward,
showMap,
showEnding:async route => { game.over(route === "victory"); },
quiesce:() => quiesceEngine(_status),
@@ -480,7 +525,7 @@ const setupBattle = async (run, node, encounter = getNodeEncounter(run, node)) =
const flow = makeFlow(() => current.releaseSkills?.());
let finished;
const signal = new Promise(resolve => { finished = resolve; });
current = {session, flow, finished, resources, players: new Set(), monsterPiles: new Map(), energyUI: new Map()};
current = {session, flow, finished, resources, players: new Set(), monsterPiles: new Map(), energyUI: new Map(), intentUI: new Map()};
activeBattle = current;
const root = new lib.element.GameEvent("mengsanBattle", false, _status.eventManager);
const previousRoot = _status.eventManager.rootEvent;
@@ -551,6 +596,7 @@ const startJourney = async () => {
const createMode = identityMode => {
const identityElement = identityMode.element || {};
const identityPlayer = identityElement.player || {};
const nativeChangeHujia = identityPlayer.changeHujia || lib.element.Player.prototype.changeHujia;
const modeCards = createMengsanCards();
return {
...identityMode,
@@ -574,6 +620,13 @@ const createMode = identityMode => {
isEnemyOf(player) { return Boolean(player && this.storage.mengsanCamp_shuying !== player.storage?.mengsanCamp_shuying); },
getEnemies(filter, includeDie) { return game[includeDie ? "filterPlayer2" : "filterPlayer"](p => this.isEnemyOf(p) && (!filter || filter(p))); },
getFriends(filter, includeDie) { const self = filter === true; return game[includeDie ? "filterPlayer2" : "filterPlayer"](p => (p !== this || self) && this.isFriendOf(p) && (typeof filter !== "function" || filter(p))); },
changeHujia(num, type, limit) {
// 脆弱只削减正向获得的护甲,不改变受伤时消耗护甲的数值。
if (this.storage?.mengsanFrail_shuying > 0 && (num == null || num > 0) && type !== "damage") {
num = Math.floor((num ?? 1) * 0.75);
}
return nativeChangeHujia.call(this, num, type, limit);
},
getHandcardLimit() {
if (this.storage?.mengsanPlayer_shuying) {
const run = _status.mengsanRun_shuying;
@@ -591,6 +644,12 @@ const createMode = identityMode => {
},
game: {
...(identityMode.game || {}),
mengsanSetEnemyIntent_shuying(player, intent) {
const update = activeBattle?.intentUI.get(player);
if (!update) return false;
update(intent);
return true;
},
async mengsanPersistRun_shuying(run) {
await saveRun(run);
},
@@ -663,6 +722,46 @@ const createMode = identityMode => {
filter(event, player) { return Boolean(player.storage?.mengsanMonster_shuying); },
async content(event, trigger, player) { trigger.num = player.storage.mengsanMonster_shuying.draw; },
},
mengsan_flyconid_action_shuying: {
trigger: { global: "phaseUseBefore" },
forced: true, silent: true, popup: false, priority: 100,
filter(event) { return Boolean(activeBattle?.session.active && isFlyconid(event.player) && event.player.storage.mengsanFlyconidIntent_shuying); },
async content(event, trigger) { await executeFlyconidIntent(trigger.player); },
},
mengsan_frail_shuying: {
mark: true, marktext: "脆",
onremove(player) { delete player.storage.mengsanFrail_shuying; },
intro: { content(storage, player) { return `剩余${player.storage.mengsanFrail_shuying || 0}回合:获得护甲减少25%(向下取整)`; } },
trigger: { player: "phaseAfter" },
forced: true, silent: true, popup: false,
async content(event, trigger, player) {
const remaining = Math.max(0, (player.storage.mengsanFrail_shuying || 0) - 1);
player.storage.mengsanFrail_shuying = remaining;
if (remaining) player.markSkill("mengsan_frail_shuying");
else player.removeSkill("mengsan_frail_shuying");
},
},
mengsan_vulnerable_shuying: {
mark: true, marktext: "易",
onremove(player) { delete player.storage.mengsanVulnerable_shuying; },
intro: { content(storage, player) { return `剩余${player.storage.mengsanVulnerable_shuying || 0}回合:受到攻击伤害增加50%(向上取整)`; } },
trigger: { player: ["damageBegin1", "phaseAfter"] },
forced: true, silent: true, popup: false,
filter(event, player) {
if (!player.storage?.mengsanVulnerable_shuying) return false;
return event.name !== "damage" || Boolean(event.card || event.mengsanAttack_shuying);
},
async content(event, trigger, player) {
if (event.triggername === "damageBegin1") {
trigger.num = Math.ceil(trigger.num * 1.5);
return;
}
const remaining = Math.max(0, (player.storage.mengsanVulnerable_shuying || 0) - 1);
player.storage.mengsanVulnerable_shuying = remaining;
if (remaining) player.markSkill("mengsan_vulnerable_shuying");
else player.removeSkill("mengsan_vulnerable_shuying");
},
},
mengsan_scenario_shuying: {
trigger: { global: ["changeHpAfter", "gainAfter", "loseAfter", "dieAfter", "turnOverAfter", "linkAfter", "phaseAfter"] },
forced: true, silent: true, popup: false, priority: -100,
@@ -757,6 +856,11 @@ const createMode = identityMode => {
mengsan_card_use_shuying: "梦三词缀",
mengsan_yingyong_shuying: "英勇",
mengsan_yingyong_shuying_info: "锁定技,每回合限一次,你于自己的回合内使用牌造成的伤害+1。",
mengsan_flyconid_action_shuying: "孢子行动",
mengsan_frail_shuying: "脆弱",
mengsan_frail_shuying_info: "接下来相应回合获得护甲减少25%(向下取整)。",
mengsan_vulnerable_shuying: "易伤",
mengsan_vulnerable_shuying_info: "接下来相应回合受到的攻击伤害增加50%(向上取整)。",
},
config: {},
help: {
+2 -2
View File
@@ -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);
+12 -7
View File
@@ -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");
+46
View File
@@ -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;
}
+37
View File
@@ -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;
}
+63
View File
@@ -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;
};
}
+9 -3
View File
@@ -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;
+43 -4
View File
@@ -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);
+190
View File
@@ -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);
}
+9 -1
View File
@@ -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]];
+228 -13
View File
@@ -979,6 +979,158 @@
.mengsan-flow-panel-shuying .mengsan-choice-description-shuying { display: block; margin-top: 6px; color: var(--flow-muted); font-size: 14px; line-height: 1.6; }
.mengsan-flow-panel-shuying .mengsan-choice-shuying:disabled { opacity: .5; cursor: not-allowed; }
.mengsan-flow-panel-shuying .mengsan-choice-shuying.danger { border-color: #d89586; background: #853d3440; }
/* Victory choices and card choices are independent, reusable ink-and-jade surfaces. */
dialog.mengsan-victory-dialog-shuying, dialog.mengsan-card-choice-dialog-shuying {
--reward-ink: #101c20; --reward-jade: #32736c; --reward-celadon: #8db4a5;
--reward-bronze: #c7a477; --reward-paper: #eee2c9; --reward-muted: #cabda5;
position: fixed !important; inset: 0 !important; width: 100vw !important; height: 100dvh !important;
max-width: none !important; max-height: none !important; margin: 0 !important; padding: 0 !important;
border: 0 !important; overflow: auto !important; transform: none !important; background: transparent !important;
color: var(--reward-paper); font: 16px/1.5 "Microsoft YaHei", sans-serif; text-align: left;
}
dialog.mengsan-victory-dialog-shuying::backdrop, dialog.mengsan-card-choice-dialog-shuying::backdrop {
background: rgba(4, 10, 13, .88);
}
.mengsan-victory-dialog-shuying *, .mengsan-card-choice-dialog-shuying *,
.mengsan-victory-dialog-shuying *::before, .mengsan-card-choice-dialog-shuying *::before,
.mengsan-victory-dialog-shuying *::after, .mengsan-card-choice-dialog-shuying *::after { box-sizing: border-box; }
.mengsan-reward-overline-shuying {
display: block; color: var(--reward-celadon); font: 700 12px/1.4 "Microsoft YaHei",sans-serif;
letter-spacing: .22em;
}
.mengsan-victory-stage-shuying {
position: relative !important; display: grid !important; grid-template-columns: minmax(210px,260px) minmax(320px,520px);
grid-template-rows: auto auto; align-content: center; justify-content: center; gap: 0 32px;
min-height: 100%; width: 100%; padding: 48px 28px;
}
.mengsan-victory-stage-shuying::before {
content: ""; position: absolute !important; z-index: -1; inset: 0;
background: radial-gradient(ellipse at 48% 48%,rgba(46,88,80,.25),transparent 62%);
pointer-events: none;
}
.mengsan-victory-intro-shuying {
position: relative !important; grid-column: 1; grid-row: 1 / 3; align-self: center;
padding: 26px 26px 28px 0; border-right: 1px solid rgba(199,164,119,.45);
}
.mengsan-victory-intro-shuying::after {
content: ""; display: block; width: 66px; height: 3px; margin-top: 28px; background: var(--reward-bronze);
}
.mengsan-victory-seal-shuying {
display: grid !important; place-items: center; width: 88px; height: 88px; margin: 29px 0 24px;
border: 3px double var(--reward-bronze); border-radius: 8px; transform: rotate(-7deg);
color: var(--reward-paper); background: #6b302b; box-shadow: 0 8px 24px #0007;
font: 700 52px/1 "KaiTi","STKaiti",serif;
}
.mengsan-victory-intro-shuying h2 {
margin: 0 0 8px; color: var(--reward-paper); font: 700 clamp(32px,3.5vw,46px)/1.15 "KaiTi","STKaiti",serif;
letter-spacing: .09em;
}
.mengsan-victory-intro-shuying p { max-width: 17em; margin: 0; color: var(--reward-muted); font-size: 15px; line-height: 1.8; }
.mengsan-victory-options-shuying {
position: relative !important; grid-column: 2; grid-row: 1; display: flex !important; flex-direction: column !important;
gap: 12px; max-height: min(66dvh,560px); padding: 24px; overflow-y: auto;
border: 1px solid rgba(199,164,119,.65); border-radius: 7px;
background: linear-gradient(145deg,#1e3435,#111f24 68%);
box-shadow: inset 0 0 0 5px rgba(9,19,22,.8),0 22px 50px #0008;
}
.mengsan-victory-eyebrow-shuying { color: var(--reward-bronze); font: 700 13px/1.4 "Microsoft YaHei",sans-serif; letter-spacing: .16em; margin: 0 0 5px; }
button.mengsan-victory-option-shuying {
position: relative !important; display: flex !important; align-items: center; gap: 18px;
width: 100%; min-height: 94px; padding: 17px 18px; border: 1px solid #60857a;
border-left: 4px solid var(--reward-bronze); border-radius: 3px; color: var(--reward-paper);
background: linear-gradient(110deg,#254b49,#1a3238); text-align: left; cursor: pointer;
transition: background .18s ease, border-color .18s ease, box-shadow .18s ease;
}
button.mengsan-victory-option-shuying:hover, button.mengsan-victory-option-shuying:focus-visible {
border-color: var(--reward-paper); background: linear-gradient(110deg,#346a60,#24434a);
box-shadow: inset 0 0 0 1px #9dbca9,0 6px 20px #0005;
}
button.mengsan-victory-option-shuying:focus-visible, button.mengsan-victory-skip-shuying:focus-visible,
button.mengsan-card-choice-tile-shuying:focus-visible, button.mengsan-card-choice-skip-shuying:focus-visible {
outline: 3px solid #d9e9bb; outline-offset: 4px;
}
button.mengsan-victory-option-shuying:disabled { opacity: .6; cursor: wait; }
.mengsan-victory-option-index-shuying { flex: 0 0 44px; color: var(--reward-bronze); font: 700 24px/1 "KaiTi",serif; }
.mengsan-victory-option-copy-shuying { display: flex !important; flex: 1; min-width: 0; flex-direction: column; gap: 4px; }
.mengsan-victory-option-copy-shuying strong { color: #fff1d5; font: 700 23px/1.2 "KaiTi",serif; }
.mengsan-victory-option-copy-shuying small { color: #d3e0d4; font: 14px/1.5 "Microsoft YaHei",sans-serif; }
.mengsan-victory-option-arrow-shuying { flex: 0 0 28px; color: var(--reward-bronze); font: 34px/1 "KaiTi",serif; text-align: center; }
button.mengsan-victory-skip-shuying {
grid-column: 2; grid-row: 2; justify-self: end; min-height: 48px; margin-top: 16px; padding: 8px 4px;
border: 0; border-bottom: 1px solid var(--reward-muted); color: var(--reward-muted);
background: transparent; font: 16px/1.4 "Microsoft YaHei",sans-serif; cursor: pointer;
}
button.mengsan-victory-skip-shuying:hover { color: #fff1d5; border-color: #fff1d5; }
.mengsan-card-choice-stage-shuying {
position: relative !important; display: flex !important; flex-direction: column !important; align-items: center;
justify-content: center; gap: clamp(20px,4vh,38px); min-height: 100%; width: 100%; padding: 36px 22px 48px;
background: radial-gradient(ellipse at 50% 48%,rgba(47,80,73,.21),transparent 68%);
}
.mengsan-card-choice-heading-shuying { width: min(620px,100%); text-align: center; }
.mengsan-card-choice-heading-shuying::after { content: ""; display: block; width: 132px; height: 2px; margin: 15px auto 0; background: linear-gradient(90deg,transparent,var(--reward-bronze),transparent); }
.mengsan-card-choice-heading-shuying h2 { margin: 7px 0 4px; color: var(--reward-paper); font: 700 clamp(28px,3vw,38px)/1.2 "KaiTi","STKaiti",serif; letter-spacing: .13em; }
.mengsan-card-choice-heading-shuying p { margin: 0; color: var(--reward-muted); font-size: 14px; }
.mengsan-card-choice-grid-shuying {
position: relative !important; display: grid !important; grid-template-columns: repeat(var(--choice-count),minmax(0,210px));
justify-content: center; align-items: stretch; gap: 18px; width: max-content; max-width: 100%;
}
button.mengsan-card-choice-tile-shuying {
position: relative !important; display: flex !important; flex-direction: column !important; align-items: stretch;
min-width: 0; min-height: 322px; padding: 11px; border: 1px solid #9e8261; border-radius: 6px;
color: var(--reward-paper); background: linear-gradient(155deg,#355550,#1b3034 48%,#182429);
box-shadow: inset 0 0 0 4px #1a282a,inset 0 0 0 5px #57746c,0 12px 25px #0009;
font: inherit; text-align: center; appearance: none; cursor: pointer;
transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease;
}
button.mengsan-card-choice-tile-shuying:hover, button.mengsan-card-choice-tile-shuying:focus-visible {
border-color: #f4d7a0; box-shadow: inset 0 0 0 4px #1a282a,inset 0 0 0 5px #b59667,0 16px 28px #000a;
transform: translateY(-4px);
}
.mengsan-card-choice-cost-shuying {
position: absolute !important; z-index: 2; top: -13px; left: -12px; display: grid !important; place-items: center;
width: 38px; height: 38px; border: 2px solid #e2c38c; border-radius: 50%; color: #fff1d5;
background: #7a3b31; font: 700 19px/1 "KaiTi",serif; box-shadow: 0 3px 9px #0009;
}
.mengsan-card-choice-name-shuying { display: block !important; min-height: 32px; padding: 2px 6px 5px; color: #f4e6c5; border-bottom: 1px solid #b69a6b; font: 700 20px/1.3 "KaiTi",serif; }
.mengsan-card-choice-art-shuying {
position: relative !important; display: grid !important; place-items: center; height: 128px; margin: 10px 0 7px;
overflow: hidden; border: 1px solid #a98d68; border-radius: 3px;
background: radial-gradient(circle,#4f5f55,#192629 70%);
}
.mengsan-card-choice-art-shuying img { position: absolute !important; z-index: 1; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #263333; }
.mengsan-card-choice-glyph-shuying { color: #e9d4ae; font: 700 52px/1 "KaiTi",serif; }
.mengsan-card-choice-kind-shuying { align-self: center; margin-bottom: 6px; padding: 1px 12px; border: 1px solid #b99a6b; color: #f0dfba; font-size: 12px; }
.mengsan-card-choice-description-shuying { display: block !important; flex: 1; overflow: hidden; padding: 2px 3px 0; color: #e8e5d6; font-size: 13px; line-height: 1.55; }
button.mengsan-card-choice-skip-shuying {
min-height: 48px; padding: 9px 28px; border: 1px solid #82978b; border-radius: 3px;
color: var(--reward-muted); background: #1a2b2f; font: 16px/1.4 "Microsoft YaHei",sans-serif; cursor: pointer;
}
button.mengsan-card-choice-skip-shuying:hover { color: #fff1d5; border-color: #d4bc91; background: #27433f; }
@media (max-width: 1100px) { .mengsan-card-choice-grid-shuying { grid-template-columns: repeat(var(--choice-count),minmax(0,180px)); gap: 14px; } }
@media (max-width: 700px) {
.mengsan-victory-stage-shuying { grid-template-columns: minmax(0,520px); gap: 18px; align-content: start; padding: 30px 18px; }
.mengsan-victory-intro-shuying { grid-column: 1; grid-row: 1; padding: 0 0 15px; border-right: 0; border-bottom: 1px solid rgba(199,164,119,.45); }
.mengsan-victory-seal-shuying { display: none !important; }
.mengsan-victory-intro-shuying::after { display: none; }
.mengsan-victory-intro-shuying h2 { margin-top: 8px; }
.mengsan-victory-options-shuying { grid-column: 1; grid-row: 2; max-height: none; padding: 18px; }
button.mengsan-victory-skip-shuying { grid-column: 1; grid-row: 3; }
.mengsan-card-choice-stage-shuying { justify-content: flex-start; }
.mengsan-card-choice-grid-shuying { grid-template-columns: repeat(3,minmax(0,1fr)); width: min(620px,100%); }
}
@media (max-width: 560px) {
.mengsan-card-choice-grid-shuying { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 15px; }
button.mengsan-card-choice-tile-shuying { min-height: 292px; }
.mengsan-card-choice-art-shuying { height: 105px; }
.mengsan-victory-option-index-shuying { flex-basis: 30px; font-size: 20px; }
button.mengsan-victory-option-shuying { gap: 10px; padding: 13px; }
}
@media (max-width: 350px) { .mengsan-card-choice-grid-shuying { grid-template-columns: minmax(0,210px); } }
@media (prefers-reduced-motion: reduce) {
button.mengsan-victory-option-shuying, button.mengsan-card-choice-tile-shuying { transition: none; }
button.mengsan-card-choice-tile-shuying:hover, button.mengsan-card-choice-tile-shuying:focus-visible { transform: none; }
}
.mengsan-flow-menu-shuying {
position: absolute !important; top: max(16px, env(safe-area-inset-top)); right: max(20px, env(safe-area-inset-right));
z-index: 4; min-width: 96px; min-height: 48px; padding: 8px 16px;
@@ -1123,11 +1275,14 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; }
.mengsan-gm-dialog-shuying .ms-gm-affixes button:disabled { opacity: .55; cursor: not-allowed; }
@media (max-height: 500px) { .mengsan-battle-piles-button-shuying { top: 70px; right: max(8px, env(safe-area-inset-right)); } }
/* Only session-created Mengsan players; retain internal identity and side. */
/* Only session-created Mengsan players; retain internal identity and seat order. */
.player.mengsan-hide-identity-shuying > .identity {
display: none !important;
pointer-events: none !important;
}
.player.mengsan-hide-identity-shuying > .seat {
display: none !important;
}
.player > .mengsan-energy-shuying {
position: absolute !important;
top: -22px !important;
@@ -1146,17 +1301,77 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; }
text-align: center;
pointer-events: none;
}
.card[data-mengsan-cost]::after {
content: attr(data-mengsan-cost) "费";
position: absolute;
top: 1px;
left: 1px;
z-index: 5;
padding: 1px 3px;
border-radius: 3px;
background: #173b43e8;
color: #fff1c5;
font-size: 11px;
line-height: 1.2;
/* The selected skill appears below an enemy's frame; number precedes the weapon icon. */
.player > .mengsan-intent-shuying {
position: absolute !important;
bottom: -31px !important;
left: 50% !important;
transform: translateX(-50%) !important;
z-index: 9;
display: flex !important;
align-items: center;
gap: 5px;
width: max-content !important;
max-width: 145px;
min-height: 25px;
padding: 2px 5px;
border: 1px solid #a87967;
border-radius: 6px;
background: #1b1719ef;
box-shadow: 0 2px 7px #0009;
color: #fff0e4;
font: 700 12px/1.2 "Microsoft YaHei", sans-serif;
white-space: nowrap;
pointer-events: none;
}
.player > .mengsan-intent-shuying[hidden],
.mengsan-intent-damage-shuying[hidden],
.mengsan-intent-debuff-shuying[hidden] { display: none !important; }
.mengsan-intent-name-shuying {
overflow: hidden;
text-overflow: ellipsis;
}
.mengsan-intent-damage-shuying {
display: inline-flex;
flex: none;
align-items: center;
gap: 1px;
color: #ffe4d1;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.mengsan-intent-debuff-shuying {
display: inline-flex;
flex: none;
align-items: center;
gap: 1px;
color: #d8aee5;
font-size: 11px;
}
.mengsan-intent-icon-shuying {
display: block;
width: 22px;
height: 22px;
object-fit: contain;
}
/* Own a child badge, not card::after (which 十周年 UI reserves and hides). */
.handcards > .card[data-mengsan-cost] > .mengsan-hand-cost-shuying {
position: absolute !important;
top: 55px !important;
left: 3px !important;
z-index: 7 !important;
display: grid !important;
place-items: center;
width: 24px;
height: 24px;
border: 1px solid #d8ba83;
border-radius: 50%;
background: #234844f2;
box-shadow: 0 2px 4px #0008;
color: #fff1cb;
font: 700 14px/1 "Microsoft YaHei", sans-serif;
pointer-events: none;
}
.handcards > .card[data-mengsan-cost] > .mengsan-hand-cost-shuying::before {
content: attr(data-mengsan-cost);
}