4 Commits
Author SHA1 Message Date
Diuse b005a7ff15 同上 2026-09-30 16:59:10 +08:00
Diuse 12e9ac181f fixed # 新增UI子模块,服务梦三 2026-09-30 16:58:13 +08:00
Diuse 0ccb14cecd fixed # 更新刘备配置和奖励 2026-09-30 15:43:07 +08:00
Diuse 82d80397b6 fixed # 新增羁绊系统
fixed # 新增遗物系统
fixed # 梦三模式下移除护甲上限限制
2026-09-30 14:33:17 +08:00
55 changed files with 2438 additions and 214 deletions
+5 -3
View File
@@ -6,11 +6,13 @@ task_plan.md
dist/manifest.json
tools/__pycache__/
tools/test_update.mjs
tools/test_intent.mjs
tools/*.mjs
/mengsan/.planning/
/mengsan/verification
/mengsan/assets/sts2/
/Slay the Spire 2
/Slay the Spire 2
*.mjs
*.cjs
+21
View File
@@ -7,11 +7,32 @@
| `battle/` | 战斗会话、回合、结算、费用与通用战斗效果 |
| `monsters/` | 怪物阶级、牌堆默认值和各怪物意图选择 |
| `cards/` | 卡牌定义、词缀、生成和个人牌堆 |
| `relics/` | 遗物(道具)定义、持有规则、战斗触发及资源待补清单 |
| `bonds/` | NPC 羁绊、助战选择、成长与意图配置、默认牌库及濒死状态 |
| `progression/` | 征程状态、存档、剧情选择和奖励逻辑 |
| `content/` | 章节、遭遇、剧本、角色与内容注册表 |
| `ui/` | 地图、对话、意图、奖励、图鉴及样式 |
| `ui/hand/` | 梦三必需手牌 UI 子扩展:真实卡牌原位排列、覆盖层及生命周期 |
| `assets/intent/` | 意图图标 |
| `assets/cards/` | 梦三专属卡面 |
| `assets/portraits/` | 角色立绘 |
| `docs/` | 战斗和关卡配置说明 |
新增功能优先放入所属领域;界面代码放在 `ui/`,怪物行动逻辑放在 `monsters/`。跨领域引用使用相对路径,避免新增一个涵盖所有功能的扁平 `runtime/` 目录。模块资源文件随 `tools/generate_manifest.py` 自动收录,发布入口仍为 `mengsan/register.js`。
手牌 UI 子扩展没有单独关闭开关;实现边界、日志位置和验证结果见
[手牌 UI 子扩展说明](docs/ui-hand-subextension.md)。
梦三【杀】消耗 1 费用,基础伤害为 6;其他锦囊的基础伤害仍为 4。
【重杀】(`mengsan_zhongsha`)是普通战斗奖励池中的通用基本牌:
消耗 2 费用,对攻击范围内的一名其他角色直接造成 8 点伤害,
然后给予存活目标 2 层易伤,每回合使用次数不限。
易伤沿用既有规则:受到攻击伤害增加 50%(向上取整),
目标每个自身回合结束减少 1 层;本次新施加的易伤不放大本次伤害。
强化规则集中在 `cards/upgrades.js`,未配置的牌不可强化。
【杀】最多强化一次,伤害由 6 变为 9;【重杀】最多强化一次,
伤害由 8 变为 10,易伤由 2 层变为 3 层,费用均不变。
随机强化只选尚未满级、可强化且没有“永恒”词缀的牌。
没有候选牌时禁用休息节点的磨砺,并从随机奖励中排除强化。
旧存档不重写:历史超限等级按当前上限结算,未配置牌的历史等级不生效。
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+2 -1
View File
@@ -87,7 +87,7 @@ export function grantSupport(run, rewardId, spec) {
supports.push({ id: `support_${number}`, rewardId, category: spec.category || "item", unit: copy(spec.unit), battles });
}
export function buildBattlePlan(encounter, run) {
export function buildBattlePlan(encounter, run, bondUnit = null) {
const plan = copy(encounter.battlePlan || {
units: [{ id: "enemy_1", character: encounter.enemy, camp: "enemy", tier: encounter.tier || "normal", hand: 4 }], rules: [],
});
@@ -98,6 +98,7 @@ export function buildBattlePlan(encounter, run) {
assert(support.battles === -1 || (Number.isInteger(support.battles) && support.battles > 0), "支援持续场数无效");
plan.units.push({ ...copy(support.unit), id: support.id });
}
if (bondUnit) plan.units.unshift(copy(bondUnit));
validateBattlePlan(plan);
return plan;
}
+11 -2
View File
@@ -16,7 +16,12 @@ 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), { boss: job.pending.boss });
job.choiceId = job.pending.outcome === "defeat" ? null
: await chooseReward(copy(job.pending.choices), {
boss: job.pending.boss,
fixedRewards: copy(job.pending.fixedRewards || []),
rewardPackage: copy(job.pending.rewardPackage || null),
});
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;
}
@@ -37,7 +42,11 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding,
await quiesce();
if (!job.delivered) {
state = "presenting";
if (job.committed.route === "map") job.nextNode = await showMap(copy(job.committed.run));
if (job.committed.route === "map") {
const mapRun = copy(job.committed.run);
job.nextNode = await showMap(mapRun);
job.committed.run = mapRun;
}
else await showEnding(job.committed.route, copy(job.committed.run));
job.delivered = true;
}
+31 -5
View File
@@ -1,7 +1,15 @@
// Candidate settlement adapter. Dependencies are the current mode/state reward functions.
import { applyBattleEndRelics } from "../relics/battle.js";
import { settleBondBattle } from "../bonds/state.js";
import { prepareRewardPackage, applyRewardPackage }
from "../progression/reward-package.js";
const copy = value => JSON.parse(JSON.stringify(value));
const idFor = (run, node) => JSON.stringify([run.runId, run.actIndex, node.id]);
export function createBattleSettlement({store, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct, now = Date.now}) {
export function createBattleSettlement({
store, config, getRandomRewardChoices, applyReward, completeNode,
enterNextAct, now = Date.now, logRewardPackage = () => {},
}) {
const readRun = () => store.read()[config.saveKey];
function current(storage, runId) {
const run = storage[config.saveKey];
@@ -34,16 +42,23 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a
}
// Battle may have changed RNG/card state since the last checkpoint.
const base = copy(snapshot); delete base.battleFlow;
const bondGrowth = settleBondBattle(base);
if (outcome === "victory") {
base.player.hp = Math.max(1, Math.min(hp, base.player.maxHp ?? hp));
base.statistics.defeatedEnemies += encounter.defeatedEnemies ?? 1;
}
const relicRecovery = hp > 0
? applyBattleEndRelics(base, outcome) : 0;
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};
const rewardPackage = outcome === "victory" && encounter.rewardPackage
? prepareRewardPackage(base, encounter.rewardPackage,
getRandomRewardChoices) : null;
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, relicRecovery, bondGrowth};
run.battleFlow.pending.rewardPackage = copy(rewardPackage);
return run.battleFlow.pending;
});
},
@@ -51,21 +66,29 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a
return store.update(storage => {
const {item} = pending(storage, runId, id);
if (item.state === "chosen") {
if (item.choiceId !== choiceId) throw new Error("Choice already locked");
const sameChoice = item.rewardPackage
? item.choiceId?.cardId === choiceId?.cardId &&
item.choiceId?.relicId === choiceId?.relicId
: item.choiceId === choiceId;
if (!sameChoice) throw new Error("Choice already locked");
return item;
}
const result = copy(item.base);
let route = "defeat";
if (item.outcome === "victory") {
if (item.rewardPackage) {
applyRewardPackage(result, item.rewardPackage, choiceId,
applyReward);
}
const choice = item.choices.find(c => c.id === choiceId);
if (item.choices.length && choiceId !== null) {
if (!item.rewardPackage && item.choices.length && choiceId !== null) {
if (!choice) throw new Error("Reward is not a fixed candidate");
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");
} else if (!item.rewardPackage && 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");
route = item.boss && !enterNextAct(result) ? "victory" : "map";
@@ -73,6 +96,9 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a
// Save the exact result (including random card ID/target) before final commit.
item.state = "chosen"; item.choiceId = choiceId; item.result = result;
item.route = route; item.completedAt = now();
if (item.rewardPackage && item.outcome === "victory") {
logRewardPackage(item.rewardPackage, choiceId);
}
return item;
});
},
+4 -2
View File
@@ -2,9 +2,11 @@
import { _status } from "../../../../noname.js";
export const PLAYER_ENERGY = 3;
export const PLAYER_HAND_LIMIT = 3;
export const SHA_DAMAGE = 4;
export const SHA_DAMAGE = 6;
export const TRICK_DAMAGE = 4;
const COSTS = Object.freeze({ sha: 1, tao: 1, jiu: 1, shan: 0, wuxie: 0,
const COSTS = Object.freeze({ sha: 1, mengsan_zhongsha: 2,
tao: 1, jiu: 1, shan: 0, wuxie: 0,
juedou: 2, nanman: 2, wanjian: 2, taoyuan: 2 });
export const cardCost = card => COSTS[card?.name] ?? 1;
+53
View File
@@ -0,0 +1,53 @@
import { attackHitCount, outgoingAttackDamage } from "./intent-damage.js";
import { beginDeathBlow, isDeathBlowIntent } from "./death-blow.js";
export const getIntentHostiles = (game, source) =>
game.players.filter(target => target !== source && target.isAlive() &&
target.storage?.mengsanCamp_shuying !==
source.storage?.mengsanCamp_shuying);
export function createIntentExecutor(game, getActiveBattle) {
const active = source => source.isAlive() &&
Boolean(getActiveBattle()?.session.active);
const applyDebuff = (target, move) => {
if (!move.debuff) return;
const frail = move.debuff === "脆弱";
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");
};
return async (source, move) => {
for (const target of getIntentHostiles(game, source)) {
if (!active(source)) break;
if (!target.isAlive()) continue;
if (isDeathBlowIntent(move)) {
await beginDeathBlow(source, target, move);
continue;
}
if (move.damage != null) {
for (let hitIndex = 0; hitIndex < attackHitCount(move); hitIndex++) {
if (!target.isAlive() || !active(source)) break;
const hit = target.damage(
outgoingAttackDamage(move, source), source);
hit.mengsanAttack_shuying = true;
hit.mengsanScriptedSkill_shuying = true;
await hit;
}
}
if (target.isAlive() && active(source)) applyDebuff(target, move);
}
if (!active(source)) return;
if (move.block) await source.changeHujia(move.block);
if (!active(source)) return;
if (move.strength) {
source.storage.mengsanStrength_shuying =
(source.storage.mengsanStrength_shuying || 0) + move.strength;
source.addSkill("mengsan_raider_strength_shuying");
source.markSkill("mengsan_raider_strength_shuying");
}
};
}
+42
View File
@@ -0,0 +1,42 @@
import { createCardData } from "../cards/card-data.js";
import { bondDefinitions } from "./definitions.js";
import { ensureBonds, markBondDown } from "./state.js";
import { getBondIntents } from "./intents.js";
export function initializeBondUnit(player, spec, run) {
if (!spec.bondId) return null;
const portrait = bondDefinitions[spec.bondId].portrait;
if (player.node?.avatar) {
player.node.avatar.style.backgroundImage = portrait ?
`url("${portrait}")` : "none";
}
player.storage.mengsanBond_shuying = {
id: spec.bondId, draw: spec.draw, handLimit: spec.handLimit,
intents: getBondIntents(spec).map(move => ({ ...move })),
turnsTaken: 0, intent: null,
};
player.storage.mengsanMaxEnergy_shuying = spec.energy;
player.storage.mengsanEnergy_shuying = spec.energy;
if (run.bondBattle?.id === spec.bondId) run.bondBattle.arrived = true;
return {
createDeck(prefix) {
return spec.deck.map((entry, index) => {
const card = typeof entry === "string" ? { name: entry } : entry;
return createCardData({
...card, id: `${prefix}_${index}`,
suit: card.suit ||
["spade", "heart", "club", "diamond"][index % 4],
number: card.number || index % 13 + 1,
}, { enemy: true });
});
},
};
}
export function recordBondDeath(player, run, log) {
const id = player.storage?.mengsanBond_shuying?.id;
if (!id || !run) return;
if (ensureBonds(run).npcs[id]?.down) return;
markBondDown(run, id);
log(`${bondDefinitions[id].name}已退出战斗,羁绊状态:濒死`);
}
+59
View File
@@ -0,0 +1,59 @@
import { getBondIntents } from "./intents.js";
export const DEFAULT_BOND_DECK = Object.freeze([
"sha", "sha", "sha", "shan", "shan", "tao", "wuzhong",
]);
export function getBondDeck(definition) {
const deck = definition.deck;
return deck == null || Array.isArray(deck) && !deck.length ?
[...DEFAULT_BOND_DECK] : deck;
}
const emptyGrowth = () => Array.from({ length: 10 }, () => ({
maxHp: null,
hand: null,
draw: null,
handLimit: null,
energy: null,
skills: null,
}));
export const bondDefinitions = {
guanyu: {
name: "关羽", character: "guanyu", portrait: null,
deck: null, intents: null, growth: emptyGrowth(),
},
zhangfei: {
name: "张飞", character: "zhangfei", portrait: null,
deck: null, intents: null, growth: emptyGrowth(),
},
};
export function getBondCombat(id, level) {
const definition = bondDefinitions[id];
if (!definition || !Number.isInteger(level) || level < 1 || level > 10) {
return null;
}
const stats = definition.growth[level - 1];
if (!stats || !["maxHp", "hand", "draw", "handLimit", "energy"]
.every(key => Number.isInteger(stats[key]) && stats[key] >= 0)
|| stats.maxHp < 1 || stats.energy < 1
|| !Array.isArray(stats.skills)
|| !stats.skills.every(skill => typeof skill === "string" && skill)) {
return null;
}
const skills = new Set();
for (const growth of definition.growth.slice(0, level)) {
for (const skill of growth.skills || []) skills.add(skill);
}
return {
id: `bond_${id}`, bondId: id, character: definition.character,
camp: "ally", after: "player", inheritSkills: false,
hp: stats.maxHp, maxHp: stats.maxHp, hand: stats.hand,
draw: stats.draw, handLimit: stats.handLimit, energy: stats.energy,
skills: [...skills],
deck: getBondDeck(definition),
intents: getBondIntents(definition).map(move => ({ ...move })),
};
}
+64
View File
@@ -0,0 +1,64 @@
import { createIntentExecutor } from "../battle/intent-effects.js";
import { isStunned } from "../battle/stun-intent.js";
export const DEFAULT_BOND_INTENTS = Object.freeze([
Object.freeze({ id: "assist_strike", name: "援击", damage: 6 }),
Object.freeze({ id: "assist_combo", name: "连击", damage: 3, hits: 2 }),
Object.freeze({ id: "assist_guard", name: "整备", block: 5 }),
]);
export function getBondIntents(definition) {
const moves = definition.intents;
if (moves == null || Array.isArray(moves) && !moves.length) {
return DEFAULT_BOND_INTENTS;
}
if (!Array.isArray(moves) || moves.some(move =>
!move || typeof move.id !== "string" || !move.id ||
typeof move.name !== "string" || !move.name ||
!["damage", "block", "strength"].every(key => move[key] == null ||
Number.isInteger(move[key]) && move[key] >= 0) ||
move.hits != null && (!Number.isInteger(move.hits) || move.hits < 1) ||
move.debuff != null && (!["脆弱", "易伤"].includes(move.debuff) ||
!Number.isInteger(move.stacks) || move.stacks < 1) ||
move.intentType != null && move.intentType !== "death_blow" ||
move.intentType === "death_blow" &&
(move.damage == null || (move.hits ?? 1) !== 1) ||
move.damage == null && !move.block && !move.strength && !move.debuff)) {
throw new Error("羁绊 NPC 意图组配置无效");
}
return moves;
}
export function selectBondIntent(combat) {
return combat.intents[combat.turnsTaken % combat.intents.length];
}
export function createBondIntentActions(game, getActiveBattle) {
const executeEffects = createIntentExecutor(game, getActiveBattle);
const planBondIntent = player => {
const combat = player.storage?.mengsanBond_shuying;
if (!combat || !player.isAlive() || isStunned(player) || combat.intent) {
return;
}
combat.intent = selectBondIntent(combat);
game.mengsanSetEnemyIntent_shuying(player, combat.intent);
};
const executeBondIntent = async player => {
const combat = player.storage?.mengsanBond_shuying;
if (!combat?.intent || !player.isAlive() || isStunned(player) ||
!getActiveBattle()?.session.active) return;
const move = combat.intent;
combat.intent = null;
combat.turnsTaken++;
game.mengsanSetEnemyIntent_shuying(player, null);
if (player.storage.mengsanEnergy_shuying < 1) {
game.log(player, "费用不足,未发动羁绊意图", move.name);
return;
}
player.storage.mengsanEnergy_shuying--;
getActiveBattle()?.energyUI.get(player)?.();
game.log(player, "消耗1费用发动羁绊意图", move.name);
await executeEffects(player, move);
};
return { planBondIntent, executeBondIntent };
}
+73
View File
@@ -0,0 +1,73 @@
import { bondDefinitions, getBondCombat } from "./definitions.js";
export const bondProbability = level => (level - 1) / 9;
export function ensureBonds(run) {
return run.player.bonds ||= { selected: null, npcs: {} };
}
export function meetBond(run, id, level = 1) {
if (!Object.hasOwn(bondDefinitions, id) || !Number.isInteger(level)
|| level < 1 || level > 10) throw new Error("羁绊 NPC 或等级无效");
const bonds = ensureBonds(run);
if (bonds.npcs[id]) return false;
bonds.npcs[id] = { level, progress: 0, down: false };
return true;
}
export function initializeInnateBonds(run) {
ensureBonds(run);
if (run.player.character === "mengsan_liubei_shuying") {
meetBond(run, "guanyu", 8);
meetBond(run, "zhangfei", 8);
}
}
export function selectBond(run, id) {
const bonds = ensureBonds(run);
if (id !== null && !Object.hasOwn(bonds.npcs, id)) {
throw new Error("尚未结识该 NPC");
}
bonds.selected = id;
}
export function rescueBond(run, id) {
const npc = ensureBonds(run).npcs[id];
if (!npc?.down) return false;
npc.down = false;
return true;
}
export function markBondDown(run, id) {
const npc = ensureBonds(run).npcs[id];
if (npc) npc.down = true;
}
export function prepareBondBattle(run, random) {
const bonds = ensureBonds(run);
const id = bonds.selected;
const npc = bonds.npcs[id];
run.bondBattle = npc ? { id, arrived: false } : null;
if (!npc || npc.down) return null;
const combat = getBondCombat(id, npc.level);
if (!combat || random() >= bondProbability(npc.level)) return null;
return combat;
}
export function settleBondBattle(run) {
const battle = run.bondBattle;
if (!battle) return null;
const npc = ensureBonds(run).npcs[battle.id];
if (!npc) throw new Error("助战羁绊记录丢失");
const gain = battle.arrived ? 50 : 25;
if (npc.level < 10) {
npc.progress += gain;
while (npc.progress >= 100 && npc.level < 10) {
npc.progress -= 100;
npc.level++;
}
}
if (npc.level === 10) npc.progress = 0;
run.bondBattle = null;
return { id: battle.id, gain, level: npc.level, progress: npc.progress };
}
+2
View File
@@ -1,4 +1,5 @@
import { cardDefinitions, canAcquireCard } from "./card-definitions.js";
import { cardUpgradeRule } from "./upgrades.js";
const suits = ["spade", "heart", "club", "diamond"];
@@ -14,6 +15,7 @@ export function createCardData(spec, { character, enemy = false, registry = card
!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 ||
(spec.upgrade ?? 0) > (cardUpgradeRule(spec.name)?.maxLevel || 0) ||
!Array.isArray(spec.affixes ?? []) || !(spec.affixes ?? []).every(value => typeof value === "string")) {
throw new Error(`梦三牌数据无效:${spec.name}`);
}
+1
View File
@@ -2,6 +2,7 @@
// 新增武将专属牌时填写 owner 为该武将 ID;通用牌填写 owner: null。
const definitions = [
{ name: "sha", category: "damage", owner: null },
{ name: "mengsan_zhongsha", category: "damage", owner: null },
{ name: "shan", category: "utility", owner: null },
{ name: "tao", category: "recovery", owner: null },
{ name: "jiu", category: "recovery", owner: null },
+69 -7
View File
@@ -2,8 +2,10 @@
// 保留原牌名,以兼容原有牌的响应、装备技能及转化牌引用。
import standard from "../../../../card/standard.js";
import extra from "../../../../card/extra.js";
import { _status } from "../../../../noname.js";
import { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE } from "../battle/combat-rules.js";
import { _status, game } from "../../../../noname.js";
import { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE,
TRICK_DAMAGE } from "../battle/combat-rules.js";
import { cardUpgradeLevel, cardUpgradeRule } from "./upgrades.js";
const packs = [standard, extra];
const names = [...new Set(packs.flatMap(pack => pack.list.map(entry => entry[2])))];
@@ -40,11 +42,71 @@ export function createMengsanCards() {
card.sha.baseDamage = SHA_DAMAGE;
card.sha.content = async function (event, trigger, player) {
if (typeof event.baseDamage !== "number") event.baseDamage = SHA_DAMAGE;
if (cardUpgradeLevel(event.card)) {
event.baseDamage += cardUpgradeRule("sha").damage - SHA_DAMAGE;
}
return nativeSha.content.call(this, event, trigger, player);
};
card.sha.usable = Infinity;
card.sha.cardPrompt = () => `梦三:主动使用消耗1费用;目标须使用【闪】,否则受到${SHA_DAMAGE}点伤害。`;
card.sha.cardPrompt = used => {
const damage = cardUpgradeLevel(used)
? cardUpgradeRule("sha").damage : SHA_DAMAGE;
return `梦三:主动使用消耗1费用;目标须使用【闪】,` +
`否则受到${damage}点伤害。每回合使用次数不限。` +
(cardUpgradeLevel(used) ? "已强化(上限1次)。" :
"可强化1次:伤害变为9点。");
};
translate.sha_info = `梦三:主动使用消耗1费用。出牌阶段,对攻击范围内的一名角色使用;其须使用【闪】,否则受到${SHA_DAMAGE}点伤害。每回合使用次数不限。`;
card.mengsan_zhongsha = {
type: "basic",
fullskin: true,
image: "ext:术樱包/mengsan/assets/cards/mengsan_zhongsha.png",
mengsanCost_shuying: cardCost({ name: "mengsan_zhongsha" }),
enable(used, player, event) {
return !isActiveCardUse(event || _status.event, player) ||
canPayCard(player, used);
},
usable: Infinity,
selectTarget: 1,
range: nativeSha.range,
filterTarget: nativeSha.filterTarget,
async content(event, trigger, player) {
const target = event.target;
const effect = cardUpgradeLevel(event.card)
? cardUpgradeRule("mengsan_zhongsha")
: { damage: 8, vulnerable: 2 };
const damage = target.damage(effect.damage, player);
damage.mengsanAttack_shuying = true;
await damage;
if (!target.isAlive()) return;
target.storage.mengsanVulnerable_shuying =
(target.storage.mengsanVulnerable_shuying || 0) +
effect.vulnerable;
target.addSkill("mengsan_vulnerable_shuying");
target.markSkill("mengsan_vulnerable_shuying");
game.log(target, `获得${effect.vulnerable}层易伤`);
},
ai: { order: 4, result: { target: -1.5 },
tag: { damage: 1 } },
cardPrompt(used) {
const effect = cardUpgradeLevel(used)
? cardUpgradeRule("mengsan_zhongsha")
: { damage: 8, vulnerable: 2 };
return "梦三:主动使用消耗2费用。对攻击范围内的" +
`一名其他角色造成${effect.damage}点伤害,然后给予其` +
`${effect.vulnerable}层易伤。受到攻击伤害增加50%` +
"(向上取整),每个自身回合结束减少1层。" +
"每回合使用次数不限。" + (cardUpgradeLevel(used)
? "已强化(上限1次)。" :
"可强化1次:10点伤害、3层易伤。");
},
};
translate.mengsan_zhongsha = "重杀";
translate.mengsan_zhongsha_info =
"梦三:主动使用消耗2费用。出牌阶段,对攻击范围内的" +
"一名其他角色造成8点伤害,然后给予其2层易伤。" +
"易伤使受到的攻击伤害增加50%(向上取整)," +
"每个自身回合结束减少1层。每回合使用次数不限。";
// 【桃】的回复量也由梦三牌本体提供;救援仍不消耗费用。
card.tao.content = async function (event) { await event.target.recover(8); };
translate.tao_info = "梦三:主动使用消耗1费用,回复8点生命;濒死救援不消耗费用,同样回复8点生命。";
@@ -53,9 +115,9 @@ export function createMengsanCards() {
card.taoyuan.cardPrompt = () => translate.taoyuan_info;
for (const name of ["nanman", "wanjian", "juedou"]) {
const nativeContent = sourceCard(name).content;
card[name].baseDamage = SHA_DAMAGE;
card[name].baseDamage = TRICK_DAMAGE;
card[name].content = async function (event, trigger, player) {
if (typeof event.baseDamage !== "number") event.baseDamage = SHA_DAMAGE;
if (typeof event.baseDamage !== "number") event.baseDamage = TRICK_DAMAGE;
return nativeContent.call(this, event, trigger, player);
};
const detail = {
@@ -67,7 +129,7 @@ export function createMengsanCards() {
card[name].cardPrompt = () => translate[`${name}_info`];
}
// 火攻通过当前牌事件的 baseDamage 传入 damage("fire")。
card.huogong.baseDamage = SHA_DAMAGE;
card.huogong.baseDamage = TRICK_DAMAGE;
translate.huogong_info = "梦三:主动使用消耗1费用。令一名有手牌的角色展示一张手牌;若你弃置同花色牌,对其造成4点火焰伤害。";
card.huogong.cardPrompt = () => translate.huogong_info;
// 闪电属于延时锦囊,其命中伤害在 effect 中显式写死为3,需要独立覆写。
@@ -77,5 +139,5 @@ export function createMengsanCards() {
};
translate.shandian_info = "梦三:主动使用消耗1费用。若判定为黑桃2~9,目标受到12点雷电伤害;否则移至下家的判定区。";
card.shandian.cardPrompt = () => translate.shandian_info;
return { card, translate, names };
return { card, translate, names: [...names, "mengsan_zhongsha"] };
}
+1 -44
View File
@@ -1,6 +1,5 @@
// Mode-owned piles use real Card nodes so native searches/gain/insert operations agree.
// DIY must access ui piles inside the acting player's engine event, never cache global nodes.
import { cardCost } from "../battle/combat-rules.js";
import { AFFIX_INFO } from "./affixes.js";
@@ -13,7 +12,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 = [], costBadges = new Map();
const owned = new Set(), restores = [];
const strict = env.strict !== false;
let released = false, forcedOwner = false;
const personal = () => {
@@ -27,26 +26,6 @@ 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);
@@ -57,7 +36,6 @@ 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) showCost(card);
// Existing foreign/generated cards retain their original destruction semantics.
if (!data) return;
card.destroyLog = false;
@@ -84,27 +62,6 @@ 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);
+38
View File
@@ -0,0 +1,38 @@
const upgrades = Object.freeze({
sha: Object.freeze({ maxLevel: 1, damage: 9 }),
mengsan_zhongsha: Object.freeze({ maxLevel: 1, damage: 10,
vulnerable: 3 }),
});
export const cardUpgradeRule = name =>
Object.hasOwn(upgrades, name) ? upgrades[name] : null;
export function cardUpgradeLevel(card) {
const data = card?.storage?.mengsanCard_shuying || card;
if (!data || data.name !== card?.name) return 0;
const limit = cardUpgradeRule(card.name)?.maxLevel || 0;
return Number.isInteger(data.upgrade) && data.upgrade > 0
? Math.min(data.upgrade, limit) : 0;
}
export function canUpgradeCard(card) {
const rule = cardUpgradeRule(card?.name);
const level = card?.upgrade ?? 0;
return Boolean(rule && Number.isInteger(level) && level >= 0 &&
level < rule.maxLevel && !card.affixes?.includes("eternal"));
}
export const hasUpgradeableCard = deck =>
Array.isArray(deck) && deck.some(canUpgradeCard);
export function upgradeRandomCard(deck, random) {
const eligible = Array.isArray(deck) ? deck.filter(canUpgradeCard) : [];
if (!eligible.length) return null;
const value = random();
if (!Number.isFinite(value) || value < 0 || value >= 1) {
throw new RangeError("梦三强化随机数必须位于[0, 1)");
}
const card = eligible[Math.floor(value * eligible.length)];
card.upgrade = (card.upgrade || 0) + 1;
return card;
}
+4
View File
@@ -1,5 +1,9 @@
import { relicRewardIds } from "../../../relics/definitions.js";
export const rewardPools = {
"act1.pool.liubeiOpening.relics": [...relicRewardIds],
"act1.pool.chest": [
...relicRewardIds,
"shared.reward.item.handCharm",
"shared.reward.maxHp",
"shared.reward.support.scout",
@@ -46,8 +46,9 @@ export const openingRewards = {
export default {
id: "act1.battle.liubeiOpening", kind: "battle", name: "刘备初始剧情", mapName: "初识的卢",
requiredCharacter: "mengsan_liubei_shuying",
enemies: ["mengsan_soldier_shuying"], gold: 20,
enemies: ["mengsan_soldier_shuying"], gold: 400,
skipRandomReward: true, fixedRewards: ["act1.reward.dilu"], openingDialogue, victoryDialogue,
rewardPackage: { gold: 400, relicPool: "act1.pool.liubeiOpening.relics" },
battlePlan: {
units: [{
id: "soldier", character: "mengsan_soldier_shuying", camp: "enemy",
+7
View File
@@ -66,6 +66,13 @@ Object.entries(nodeContents).forEach(([contentId, content]) => {
validateDialogue(content.victoryDialogue, contentId + " 战后");
validateDialogue(content.openingDialogue, contentId + " 开场");
for (const id of content.fixedRewards || []) if (!rewards[id]) throw new Error("固定奖励不存在:" + id);
if (content.rewardPackage) {
const pack = content.rewardPackage;
if (!Number.isSafeInteger(pack.gold) || pack.gold < 0 ||
!rewardPools[pack.relicPool]?.every(id => rewards[id].relic)) {
throw new Error(`梦三奖励包配置无效:${contentId}`);
}
}
if (content.battlePlan) validateBattlePlan(content.battlePlan);
(content.choices || []).forEach(choice => {
if (!choice.id) throw new Error(`梦三剧情选项缺少ID:${contentId}`);
+6 -2
View File
@@ -2,7 +2,8 @@
export function createScenarioCharacters(Character) {
return {
mengsan_liubei_shuying: new Character({ sex: "male", group: "han", hp: 50, maxHp: 50,
skills: [], img: "image/character/re_liubei.jpg" }),
skills: ["mengsan_taoyuan_bond_shuying"],
img: "image/character/re_liubei.jpg" }),
// 国战男性普通小兵使用原生 shibing1 立绘;实际战斗生命由怪物类或关卡配置决定。
mengsan_soldier_shuying: new Character({ sex: "male", group: "qun", hp: 4, maxHp: 4,
skills: [], img: "image/character/shibing1.jpg" }),
@@ -23,7 +24,10 @@ export function createScenarioCharacters(Character) {
};
}
export const scenarioTranslations = {
mengsan_liubei_shuying: "界刘备", mengsan_liubei_shuying_title: "50/50生命 · 无初始技能",
mengsan_liubei_shuying: "界刘备",
mengsan_liubei_shuying_title: "50/50生命 · 先天桃园羁绊",
mengsan_taoyuan_bond_shuying: "桃园羁绊",
mengsan_taoyuan_bond_shuying_info: "先天:本征程初始结识关羽、张飞,二人羁绊均为8级。可在行军菜单中指定一名助战角色;战斗结束,到场增加50%升级进度,未到场增加25%。",
mengsan_soldier_shuying: "士兵",
mengsan_flyconid_shuying: "飞蝇菌子",
mengsan_raider_brute_shuying: "劫掠者暴徒",
+13 -2
View File
@@ -1,14 +1,23 @@
import { relicRewards, relicRewardIds } from "../../relics/definitions.js";
export const rewards = {
...relicRewards,
"shared.reward.support.scout": {
name: "道具·斥候援令", description: "本次征程后续每场战斗,友方张辽携 4 张手牌参战;重复获得不叠加人数。",
support: { category: "item", battles: -1, unit: { character: "re_zhangliao", camp: "ally", hand: 4 } },
},
"shared.reward.card.sha": { effectId: "card_sha", name: "获得一张【杀】", description: "加入独立永久牌组" },
"shared.reward.card.zhongsha": {
card: { name: "mengsan_zhongsha" }, name: "获得一张【重杀】",
description: "2费:造成8点伤害,然后给予目标2层易伤。",
},
"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.card.upgrade": { effectId: "upgrade", name: "随机强化", description: "随机强化一张尚未满级的可强化牌" },
"shared.reward.heal": { effectId: "heal", name: "整顿伤势", description: "回复 8 点生命" },
"shared.reward.item.handCharm": { effectId: "item_hand", name: "道具·束带", description: "基础手牌上限额外 +1" },
"shared.reward.item.handCharm": { effectId: "item_hand",
relic: "mengsan_hand_charm_shuying", name: "道具·束带",
description: "基础手牌上限额外 +1" },
"shared.reward.skill.heroic": { effectId: "skill_yingyong", name: "极品技能·英勇", description: "本次征程永久获得【英勇】" },
"shared.reward.maxHp": { effectId: "max_hp", name: "极品强化·生机", description: "生命上限与当前生命各 +5" },
};
@@ -16,10 +25,12 @@ export const rewards = {
export const rewardPools = {
"shared.pool.battle.normal": [
"shared.reward.card.sha",
"shared.reward.card.zhongsha",
"shared.reward.card.tao",
"shared.reward.card.wuzhong",
],
"shared.pool.boss.premium": [
...relicRewardIds,
"shared.reward.skill.heroic",
"shared.reward.item.handCharm",
"shared.reward.maxHp",
+3 -2
View File
@@ -4,7 +4,7 @@
- 本模块是梦三玩法规则,不批量修改武将技能源码。
- 主角固定单位 ID:`player`;`ally` 为友方,`enemy` 为敌方。
- `neutral` 仅保留预设,目前配置为中立会报错,不启用中立行为。
- 主角使用个人牌堆;友方支援和敌人仍使用公共牌堆。
- 主角、敌人、羁绊助战使用各自牌堆;其他友方支援使用公共牌堆。
- 没有 `battlePlan` 的旧关卡继续使用原来随机的一名敌人。
- 本版单场累计最多 8 个席位,包含主角、初始单位、奖励支援、以后出现的援军。
@@ -82,7 +82,8 @@ battlePlan: {
再将奖励 ID 加入指定 rewardPools。相同奖励来源不叠加人数;临时次数可累加,永久来源保持永久。
数据保存在 `run.player.supports`,旧存档没有该字段时按空列表处理。
有限次数只随结算事务提交;重载未完成战斗不会额外扣次,重试结算不会重复发放。
行囊的“战斗支援”显示来源名称和持续场数。此版本未实现羁绊系统本身。
行囊的“战斗支援”显示来源名称和持续场数。羁绊助战单独在行军菜单
“羁绊”中管理,规则和配置见 [bonds.md](bonds.md)。
## 已接入的示例
- 林中伏兵“主动追击”:两名初始敌人,第 3 轮必定出现一名敌军援军。
+115
View File
@@ -0,0 +1,115 @@
# 梦三羁绊与助战
## 当前规则
- 行军菜单 → 羁绊:可结识多名 NPC,但只能指定一名助战;允许不指定。
- 羁绊仅随本次征程保存,新征程重置。刘备先天技能“桃园羁绊”
初始结识 `guanyu`、`zhangfei`,均为 8 级、升级进度 0%,默认未指定助战。
- 普通事件结识默认 1 级、进度 0%。每级助战概率为 `(level - 1) / 9`,
1 级为 0%,10 级为 100%。
- 战前在地图切换助战;开战只判定一次。成功后占玩家下家,AI 自动行动。
原有剧情与道具支援保留,原有 8 席校验和座次代码保留。
- 完成战斗结算:指定 NPC 到场 +50%,未到场 +25%。
濒死、战斗配置未补齐也属于未到场。不要求 NPC 存活,不限定胜利。
退出或重载未完成战斗不增长;重复结算不重复增长。
- 100% 进度升一级并保留溢出,最高 10 级,满级不再积累进度。
升级数值与新增技能从下一场生效。
- 存活 NPC 下一场恢复满血、重建自己的抽牌堆/弃牌堆/消耗牌堆。
抽牌堆耗尽后洗回自身弃牌堆,无可用牌时不使玩家征程失败。
- NPC 在自身出牌阶段前消耗 1 费用执行当前意图,然后照常使用手牌。
意图在开场及玩家新一轮行动时预告,按 NPC 自身已执行次数循环。
NPC 获得的护甲在其下次主循环回合开始时清除,与劫掠者一致。
- 原生死亡结算后离场,存档标记 `down: true`,界面显示“濒死”。
未救助前不能参战,但仍可指定助战并获得未到场的 25% 进度。
- 怪物意图攻击、负面效果作用于全部存活敌对单位,含剧情与道具友军。
每个目标承受完整段数,各自结算易伤、护甲与技能;怪物自身增益只执行一次。
意图伤害数字在目标状态不同时显示伤害范围,护甲仍由伤害流程抵扣。
## 手动补充 NPC
配置位置:`bonds/definitions.js` 的 `bondDefinitions`。
关羽、张飞的 `portrait` 以及 10 个 `growth` 行目前均预留。
成长数值与技能未填写完整前可以结识、选择与成长,但不能参战;
不继承原版武将技能。`deck` 和 `intents` 无需预先填写,已有默认兜底。
`portrait` 为空时不显示原版立绘。
每个成长行的字段:
| 字段 | 含义 |
| --- | --- |
| `maxHp` | 该等级的最大生命,参战时满血,正整数 |
| `hand` | 初始手牌数量,非负整数 |
| `draw` | 自身摸牌阶段摸牌数,非负整数 |
| `handLimit` | 手牌上限,非负整数 |
| `energy` | 每回合费用,正整数 |
| `skills` | 本等级新解锁技能 ID 数组;无新增时填写 `[]`,低等级已解锁技能累计保留 |
`growth[0]` 对应 1 级,`growth[9]` 对应 10 级。数值逐行填写,不自动插值。
当前关卡校验要求生命及初始手牌不超过 99。
`deck` 为该 NPC 独立的基础牌组,填写牌名数组,或 `{name,suit,number}` 数组。
牌名必须已在游戏注册。每场创建新的卡牌实例,不与其他 NPC 或玩家共享实例。
## 默认牌库和意图
`deck` 未填写、为 `null` 或 `[]` 时,使用 `DEFAULT_BOND_DECK`:
3 张【杀】、2 张【闪】、1 张【桃】、1 张【无中生有】,每场独立生成并洗牌。
NPC 的 `intents` 未填写、为 `null` 或 `[]` 时,使用
`bonds/intents.js` 的 `DEFAULT_BOND_INTENTS`,按下列顺序循环:
| 意图 | 效果 |
| --- | --- |
| 援击 | 对每名存活敌对单位造成 6 点攻击伤害 |
| 连击 | 对每名存活敌对单位造成 2 段攻击伤害,每段 3 点 |
| 整备 | 自身获得 5 点护甲 |
默认意图数值不随等级自动插值。NPC 可以在 `bondDefinitions` 中
填写独立的 `intents` 数组来覆盖默认组,例如:
```js
intents: [
{ id: "strike", name: "重击", damage: 8 },
{ id: "break", name: "破阵", damage: 3, hits: 2,
debuff: "易伤", stacks: 1 },
{ id: "guard", name: "坚守", block: 6 },
],
```
每项需要 `id`、`name`,可配置 `damage`、`hits`、`debuff`、`stacks`、
`block`、`strength`。伤害和增益为非负整数,攻击段数为正整数;
负面效果目前支持“脆弱”“易伤”。`intentType: "death_blow"`
可选,但只允许单段攻击,并沿用出牌阶段结束后自身死亡的规则。
显式填写的无效意图会报错,避免将错误配置静默替换成默认值。
所有攻击目标各自结算护甲、易伤及技能;自身护甲、力量增益只执行一次。
费用不足时该项不产生效果,轮次仍向后推进;每场重新从第一项开始。
## 事件结识接口
剧情选项的 `outcome` 可填写:
```js
outcome: {
bonds: ["guanyu", { id: "zhangfei", level: 3 }],
}
```
已结识 NPC 再次结识不会重置等级、进度或濒死状态。
也可从 `bonds/state.js` 导入 `meetBond(run, id, level)`,
在现有事件结算中调用并使用现有征程存档流程保存。
## 救助接口预留
`bonds/state.js` 导出 `rescueBond(run, id)`:
仅解除该 NPC 的濒死标记,返回是否发生状态变化。
当前没有绑定道具或取得途径,菜单仅显示禁用的“救助道具待补充”按钮。
以后接入地图道具时,应在同一次存档中完成道具扣除与状态解除;
不能把此接口作为免费救助按钮。救助后下一场按当前等级满血参战。
## 验证
`tools/test_bonds.mjs` 覆盖初始化、事件重复结识、单选、概率边界、
成长溢出/上限、濒死/救助、独立牌组、结算重复提交和多目标意图。
另覆盖默认牌库与意图兜底、独立循环、费用不足、自定义意图和击晕期间禁止执行。
这些检查不等同于游戏原生战斗运行验证;空缺战斗配置待补充后再实机验收。
+7 -2
View File
@@ -1,10 +1,15 @@
# 梦三战斗规则(测试阶段)
只影响梦三模式,不修改 noname 的标准/军争原始牌包。`cards/mode-cards.js` 从标准、军争实体牌列表逐一复制模式专属定义,沿用原牌名以保留响应、装备、转化兼容性;图鉴说明在梦三模式覆盖。新征程的界刘备为 50/50 生命,默认每回合摸 2 张、手牌上限 3、回合费用恢复至 3。主动使用牌消耗费用,转化牌按转化后的牌名计费;响应【闪】、救援【桃】等不消耗费用。默认主动牌 1 费,`battle/combat-rules.js` 中列出例外。【杀】1 费、可多次使用,牌定义的基础伤害为 4;【桃】及【桃园结义】回复 8 点生命;【南蛮入侵】、【万箭齐发】、【决斗】、【火攻】基础伤害 4,【闪电】命中伤害 12。其余卡牌保留原有效果,并加上梦三费用与图鉴说明。
只影响梦三模式,不修改 noname 的标准/军争原始牌包。`cards/mode-cards.js` 从标准、军争实体牌列表逐一复制模式专属定义,沿用原牌名以保留响应、装备、转化兼容性;图鉴说明在梦三模式覆盖。新征程的界刘备为 50/50 生命,默认每回合摸 2 张、手牌上限 3、回合费用恢复至 3。主动使用牌消耗费用,转化牌按转化后的牌名计费;响应【闪】、救援【桃】等不消耗费用。默认主动牌 1 费,`battle/combat-rules.js` 中列出例外。【杀】1 费、可多次使用,牌定义的基础伤害为 6;【桃】及【桃园结义】回复 8 点生命;【南蛮入侵】、【万箭齐发】、【决斗】、【火攻】基础伤害 4,【闪电】命中伤害 12。其余卡牌保留原有效果,并加上梦三费用与图鉴说明。
## 怪物
`monsters/monster.js` 的 `Monster` 类统一提供阶级默认值。普通/精英/Boss 分别每回合摸 1/2/3 张、手牌上限 3/5/7、回合费用 2/3/4。每名敌人拥有自己的抽牌堆、弃牌堆与手牌;怪物默认使用阶级牌堆,不再从公共牌堆摸牌。友方支援仍用公共牌堆。
梦三模式护甲不设数值上限,玩家、敌人和友方单位均适用;
调用 `changeHujia` 时传入的上限参数不生效。
护甲增减、伤害抵扣与日志仍沿用引擎流程;脆弱仍使获得护甲
减少 25%(向下取整),劫掠者仍在自身回合开始前清空剩余护甲。
`monsters/monster.js` 的 `Monster` 类统一提供阶级默认值。普通/精英/Boss 分别每回合摸 1/2/3 张、手牌上限 3/5/7、回合费用 2/3/4。每名敌人拥有自己的抽牌堆、弃牌堆与手牌;怪物默认使用阶级牌堆,不再从公共牌堆摸牌。羁绊助战使用各自专属牌堆;其他友方支援仍用公共牌堆。
`HEALTH` 为目前各敌人的独立生命值;新怪物未配置时使用阶级兜底生命值。`OpeningSoldier extends Monster` 是继承示例;新子类在 `MONSTER_TYPES` 注册。关卡单位可以写 `tier`、`hp/maxHp`、`draw`、`handLimit`、`energy`、`deck` 覆盖默认值;`deck` 可为牌名数组(如 `deck:["sha","shan","tao"]`),也可用 `{name,suit,number}` 指定单张牌。牌名必须在 noname 中存在。
+2
View File
@@ -16,6 +16,8 @@
- 苏双&张世平为一名男性蜀势力武将,暂设无技能、4/4、4张初始手牌,可自行调整。
- `discardEquipment` 弃置指定单位装备区的指定牌;已不在装备区则不重复弃置,不影响其他装备。
- `fixedRewards` 固定获得梅花5的卢一张,加入征程个人牌库;此关关闭随机三选一。奖励与节点完成一起事务提交,重试不会重复发牌。
- `rewardPackage` 额外固定获得400金币、手动强化1张牌、随机遗物三选一。遗物候选复用当前新遗物奖励池,排除已持有项,随机结果在打开界面前保存;不足三件时显示剩余候选。强化只允许选择尚未满级且没有永恒词缀的可强化牌;没有候选时明确提示并跳过该项。两项选择完成后,与金币、的卢及节点完成一起提交,重试或恢复存档不会重复领取。遗物池耗尽时同样提示并跳过遗物选择。
- 固定奖励与选择奖励共用过关奖励界面,以“固定战利品”/“可选战利品”等文字区分;固定奖励显示名称和说明,点击“领取固定奖励”后继续结算,不能放弃。若同时配置两类奖励,固定奖励全部获得,候选奖励仍择一领取或放弃。
- 初始士兵/支援手牌仍来自公共牌堆;弃掉的敌方的卢走原生弃牌流程,胜利所得的卢是持久牌库新卡,并非再从弃牌区拾取。
- 提前击败敌人允许正常获胜,此时未到第四次回合的支援不会触发。不强行等待、不添加免死。
+98
View File
@@ -0,0 +1,98 @@
# 梦三手牌 UI 子扩展
## 模块边界
这是术樱包内部的必需子模块,由梦三战斗加载,不是另一个可安装扩展。
没有单独关闭开关。JS 或样式加载失败时进入既有战斗异常处理流程,
保留存档并提供返回模式选择入口;不会静默略过必需模块。
| 位置 | 职责 |
| --- | --- |
| `ui/hand/index.js` | 挂载、同步、局部输入适配、回退和幂等卸载 |
| `ui/hand/adapter.js` | 校验引擎容器并读取真实卡牌,识别第二容器离线结构 |
| `ui/hand/overlays.js` | 费用与强化覆盖层,仅维护当前手牌的显示节点 |
| `ui/hand/style.css` | 独立排列、尺寸、间距、选中标记及隔离样式 |
| `ui/hand/diagnostics.js` | 生命周期、回退和加载失败诊断日志 |
| `tools/test-hand-ui.cjs` | 浏览器隔离验证,非发布资源 |
| `verification/hand-ui/` | 本地验证报告、截图和清单,非发布资源 |
| `logs/log.txt` | 游戏内生成的最近 100 条诊断,非发布资源 |
`cards/personal-piles.js` 只负责个人牌堆业务,原先的手牌 DOM 显示逻辑
已移入本模块。旧的 `ui/hand-upgrade.js` 和通用样式文件中的对应规则
已移除,避免两套覆盖层并行维护。发布入口仍为 `register.js`。
## 交互方案与证据
本体 `noname/ui/click/index.js` 的 `card()` 使用真实 `this` 操作
`ui.selected.cards` 并调用 `game.check()`。本体 `card.updateTransform()`
检查卡牌祖先是否为 `ui.me`。十周年 `core/layout.js`、`cardDragSort.js`
也依赖真实节点的父容器和变换。因此首期选用真实节点原位复用,
不复制卡面,不移动手牌容器,不替换全局原型或布局函数。
排列使用独立命名空间的 flex 样式;默认卡牌 90×110、间距 8,
小屏卡牌 80×100,牌多时横向滚动。具体尺寸集中在本模块 CSS。
卡面继续采用当前引擎/主题生成的内容。选择、取消、出牌、响应和
选目标继续交给引擎,模块不另行计算卡牌可用性或提交出牌事件。
引擎仍维护节点上的 `selected`、`selectable` 等状态。本模块通过这些
原有类显示选择状态,保留 DOM 顺序作为引擎排序的依据。
检测到移动手势时临时退回既有排列,释放或取消后恢复独立排列;
触屏水平滚动保留独立排列。首期没有另做手动拖动排序系统。
费用调用既有 `cardCost`,强化读取既有 `cardUpgradeLevel`。
覆盖层只在自己的子节点 dataset 上保存显示值,使用伪元素显示文字,
避免十周年对出牌子节点 innerText 的扫描误识别费用数字。
覆盖层不接收指针事件;离开手牌区即回收,返回手牌区重新生成。
若业务直接修改卡牌 storage,修改者应调用返回控制器的 `refresh()`。
十周年 `uiCreateMe()` 的第二手牌容器默认离线。
适配器允许“主容器连接、第二容器离线且为空”的结构。
未知或被替换的容器退回原排列,保留覆盖层,并通过游戏日志和
诊断文件报告,不依赖 `decadeUI` 的内部坐标接口。
## 生命周期与日志
`mode.js` 在玩家初始化后、开局摸牌前等待 `mountHandUI()`。
模块资源登记到 battle-session,逆序释放早于个人牌堆和玩家资源。
正常战斗结束和流程异常另有显式卸载入口;DOM 根节点被移除时
观察器会触发卸载。加载中取消会结束等待并取消计时器。
卸载移除覆盖层、观察器、局部监听、样式链接、自己的类名,并恢复
自己改过的属性;多次调用安全。它不销毁真实卡牌或重置选择状态。
支持引擎文件接口时,日志写入安装目录下
`extension/术樱包/mengsan/logs/log.txt`,包含时间、战斗 session ID、
挂载、回退、卸载与加载错误。最近 100 条覆盖写入,串行提交。
没有文件接口时保留控制台日志;布局回退也写入游戏日志。
## 验证结果及边界
本次参考安装源码:本体 `game/update.js` 为 1.11.5.2;
十周年 `info.json` 为 1.3.1。
十周年 `STYLE_TO_SKIN` 将 off/on/othersOff 映射到
shousha/shizhounian/xinsha,配置菜单显示名与规划文档略有不同。
浏览器验证加载安装目录中的本体 CSS、十周年实际基础样式组合及
手杀/十周年/新杀的按钮和技能样式,以匹配 `uiCreateMe()` 的 DOM 结构
构造测试场景;点击测试使用安装源码中的真实 `ui.click.card` 函数体,
其 `game.check` 等引擎服务为测试替身,不代表完整游戏事件链验证。
| 场景 | 桌面 1280×720 | 小屏 540×360 | 完整游戏实测 |
| --- | --- | --- | --- |
| 原生 UI | 隔离验证通过 | 隔离验证通过 | 待验证 |
| 手杀 off/shousha | 隔离验证通过 | 隔离验证通过 | 待验证 |
| 十周年 on/shizhounian | 隔离验证通过 | 隔离验证通过 | 待验证 |
| 新杀 othersOff/xinsha | 隔离验证通过 | 隔离验证通过 | 待验证 |
已验证真实节点父级及点击身份、选择/取消、费用与强化移除、
手牌离开/返回、横向滚动处理、手势布局退让、会话结束、重复卸载、
根节点移除、加载中取消、CSS 加载失败、回退覆盖层及属性恢复。
已有梦三逻辑回归 44 项通过。发布清单包含本模块全部 JS/CSS,
不包含 tools、verification、.planning 和 log.txt。
尚需真实游戏验证出牌/响应、技能虚拟牌、选目标/取消、引擎自动排序、
十周年拖拽排序插件及真实手机触屏。本次没有改动已安装的游戏文件,
也没有把隔离场景结果标记为游戏内实测通过。
运行浏览器测试:`node mengsan/tools/test-hand-ui.cjs`。
可用 NONAME_APP 指向本体目录、CODEX_NODE_MODULES 指向提供 Playwright
的依赖目录;脚本使用本机 Chrome 的无界面独立实例。
+145
View File
@@ -0,0 +1,145 @@
# 梦三手牌 UI 子系统规划大纲
首期实现已归入 `ui/hand/`,是梦三必须依赖的包内子模块,
没有单独关闭开关。实现及验证状态见
[手牌 UI 子扩展说明](ui-hand-subextension.md)。
下文保留规划与完整游戏验收项;隔离测试不等同于游戏内实测。
## 1. 背景与目标
- 当前手牌 UI 与无名杀真实卡牌节点的关系
- 十周年 UI 不同风格可能造成的布局差异
- 建立梦三独立控制的手牌显示层
- 兼容原生 UI 与目标 UI 风格
## 2. 范围与非目标
### 2.1 本次范围
- 手牌布局、卡牌尺寸与间距
- 梦三费用、强化等卡牌覆盖层
- 卡牌显示与无名杀游戏状态同步
- 战斗期间挂载、退出时清理
### 2.2 非目标
- 不改写无名杀全局卡牌系统
- 不改变其他模式或扩展的全局样式
- 不承诺兼容所有未知 UI 扩展
- 不在验证交互方案前替换真实卡牌对象
## 3. 设计原则
- 无名杀真实卡牌对象是游戏状态的唯一来源
- 梦三 UI 只负责显示与输入转接
- 梦三样式使用独立根节点和命名空间
- 不依赖十周年 UI 未公开的内部结构作为硬性前提
- 适配失败时可回退到现有手牌显示
## 4. 分层设计
### 4.1 引擎卡牌与游戏状态层
- 真实卡牌对象的所有权
- 摸牌、出牌、响应、选目标、排序的现有流程
### 4.2 梦三手牌视图层
- 卡牌视觉表示
- 手牌排列与响应式布局
- 费用、强化等梦三标记
- 选中、可用、禁用等状态呈现
### 4.3 适配与生命周期层
- 真实卡牌到梦三视图的对象映射
- 卡牌进入、离开、更新时的同步
- 战斗开始时挂载、退出时完整清理
- 未知或不支持状态的回退行为
## 5. 关键技术决策
### 5.1 真实卡牌节点与自绘视图的关系
- 评估复用真实节点与代理视图两种方案
- 确认节点移动是否影响引擎及其他 UI 扩展
- 验证代理视图如何调用引擎既有选择和出牌流程
- 明确决策门槛与回退方案
### 5.2 输入与交互
- 点击与拖动
- 选中、取消选中
- 目标选择与确认
- 出牌与响应
- 自动排序与手动排序
- 卡牌移出手牌区后的视图回收
### 5.3 样式隔离
- 梦三根节点和 CSS 类命名规范
- 避免全局选择器、全局原型覆盖和全局事件劫持
- 层级、缩放、窗口尺寸和移动端布局策略
## 6. 分阶段实施
### 阶段一:现有交互链路调查与可行性验证
- 记录引擎对真实卡牌节点的依赖
- 验证核心操作和 UI 扩展的节点行为
- 产出方案比较与 go/no-go 决策
### 阶段二:状态映射与适配接口
- 定义卡牌身份映射
- 定义视图更新与交互回调
- 明确缺少能力时的降级路径
### 阶段三:梦三手牌视图原型
- 创建隔离的 UI 根节点
- 实现布局、卡牌外观和梦三覆盖层
- 验证视图随真实卡牌状态更新
### 阶段四:完整交互接入
- 接入选择、出牌、响应、取消与排序
- 保证卡牌身份和事件目标始终对应
### 阶段五:生命周期、回退与兼容验证
- 验证进入战斗、正常结束、异常退出时的清理
- 验证原生 UI 与十周年主要风格
- 记录未支持的扩展和限制
## 7. 兼容性验证矩阵
| 环境 | 布局显示 | 点击/拖动 | 出牌/响应 | 选目标/取消 | 排序 | 退出清理 |
|---|---|---|---|---|---|---|
| 无名杀原生 UI | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 |
| 十周年 UI:手杀风格 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 |
| 十周年 UI:十周年风格 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 |
| 十周年 UI:新杀风格 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 |
## 8. 风险与应对
- 真实卡牌节点移动导致引擎或其他扩展状态不同步
- 代理卡牌视图未继承引擎交互语义
- 不同 UI 修改同一容器或卡牌样式
- 监听器、观察器或 DOM 节点在退出后残留
- 引擎升级改变内部行为
## 9. 验收标准
- 不修改梦三以外的全局 UI 样式或原型
- 核心卡牌交互在目标环境中完整可用
- 梦三覆盖层与布局不依赖特定主题的坐标假设
- 战斗结束或异常退出后无梦三 UI 残留
- 不支持的环境能安全回退并有明确诊断信息
## 10. 待确认事项
- 首期支持哪些无名杀版本与 UI 风格
- 是否要求手动拖动排序
- 小屏幕和触屏设备的交互要求
- 不支持某种 UI 时是否提示玩家切回原生手牌显示
+180 -31
View File
@@ -1,15 +1,28 @@
import { createScenarioCharacters, scenarioTranslations } from "./content/scenario-characters.js";
import { createMengsanCards } from "./cards/mode-cards.js";
import { createRelicBattle, createRelicSkills } from "./relics/battle.js";
import { grantRelic, getRelic, initializeRelicMaxHp }
from "./relics/definitions.js";
import { mountRelics } from "./ui/relics.js";
import { createOpeningEffects, createRelicCombatSkills }
from "./relics/combat.js";
import { applyRoomRelics, restRecovery } from "./relics/rooms.js";
import { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport } from "./battle/battle-director.js";
import { installPersonalPiles } from "./cards/personal-piles.js";
import { createMonster } from "./monsters/monster.js";
import { prepareBondBattle } from "./bonds/state.js";
import { initializeBondUnit, recordBondDeath } from "./bonds/battle.js";
import { bondDefinitions } from "./bonds/definitions.js";
import { createBondIntentActions } from "./bonds/intents.js";
import {
createMonsterIntentActions,
getIntentHostiles,
isFlyconid,
isRaider,
} from "./monsters/actions.js";
import { mountEnemyIntent } from "./ui/intent-display.js";
import { mountEnergy } from "./ui/energy-display.js";
import { mountHandUI } from "./ui/hand/index.js";
import {
finishDeathBlow,
hasPendingDeathBlow,
@@ -24,7 +37,10 @@ import {
} from "./battle/stun-intent.js";
import { recordFlyconidAction } from "./monsters/flyconid-intent.js";
import { recordRaiderAction } from "./monsters/raider-intent.js";
import { PLAYER_ENERGY, PLAYER_HAND_LIMIT, canPayCard, payCard, isActiveCardUse } from "./battle/combat-rules.js";
import {
PLAYER_ENERGY, PLAYER_HAND_LIMIT, cardCost,
canPayCard, payCard, isActiveCardUse,
} from "./battle/combat-rules.js";
import { mountBattlePiles } from "./ui/card-library.js";
import { mountGMManager } from "./ui/gm-manager.js";
import { createPlayerTeardown } from "./battle/skill-teardown.js";
@@ -37,11 +53,13 @@ import { createBattleSettlement } from "./battle/battle-settlement.js";
import { createBattleFlow, quiesceEngine } from "./battle/battle-flow.js";
import { lib, game, ui, get, _status } from "../../../noname.js";
import config from "./config.js";
import { showMap } from "./ui/map/index.js";
import { showMap as renderMap } from "./ui/map/index.js";
import { getRandomRewardChoices } from "./progression/reward.js";
import { canAcquireCard } from "./cards/card-definitions.js";
import { addCardToDeck, createRandomCardData } from "./cards/card-data.js";
import { hasUpgradeableCard, upgradeRandomCard } from "./cards/upgrades.js";
import { chooseBattleReward } from "./ui/reward-ui.js";
import { chooseRewardPackage } from "./ui/reward-package.js";
import { applyStoryOutcome, getAvailableStoryChoices } from "./progression/story.js";
import { playDialogue } from "./ui/dialogue.js";
import { chooseButtons } from "./ui/flow-ui.js";
@@ -72,6 +90,7 @@ const saveRun = async run => {
}
};
const clearRun = modeStorage.clearRun;
const showMap = run => renderMap(run, { saveRun });
let battleSequence = 0;
let activeBattle = null;
const reportFlowError = error => { console.error("梦三流程暂停,未自动清档:", error); };
@@ -100,6 +119,13 @@ const createCardInstance = (run, name = "sha", upgrade = 0, affixes = []) =>
const applyReward = (run, rewardId) => {
const player = run.player;
const relicId = config.rewards[rewardId]?.relic;
if (relicId) {
if (grantRelic(run, relicId)) {
game.log(`获得遗物【${getRelic(relicId).name}】`);
}
return;
}
const cardReward = config.rewards[rewardId]?.card;
if (cardReward) {
if (!canAcquireCard(player.character, cardReward.name)) throw new Error(`武将 ${player.character} 无法获得牌:${cardReward.name}`);
@@ -116,9 +142,10 @@ const applyReward = (run, rewardId) => {
addCardToDeck(run, createCardInstance(run, "tao"));
break;
case "upgrade": {
const eligible = player.deck.filter(card => !card.affixes?.includes("eternal"));
const card = eligible[Math.floor(nextRandom(run) * eligible.length)];
if (card) card.upgrade = Number(card.upgrade || 0) + 1;
const card = upgradeRandomCard(player.deck, () => nextRandom(run));
if (card) game.log("梦三:", get.translation(card.name),
"强化至", card.upgrade, "级");
else game.log("梦三:没有可强化卡牌");
break;
}
case "heal":
@@ -129,10 +156,7 @@ const applyReward = (run, rewardId) => {
player.hp += 5;
break;
case "item_hand":
if (!player.items.includes("mengsan_hand_charm_shuying")) {
player.items.push("mengsan_hand_charm_shuying");
player.handLimitBonus++;
}
grantRelic(run, "mengsan_hand_charm_shuying");
break;
case "skill_yingyong":
if (!player.permanentSkills.includes("mengsan_yingyong_shuying")) {
@@ -186,10 +210,19 @@ const finishStoryNode = async (run, node) => {
const finishUtilityNode = async (run, node) => {
if (node.type == "rest") {
const choice = await chooseButtons("休息节点", [
{ id: "heal", name: "休息", description: "回复 8 点生命" },
{ id: "upgrade", name: "磨砺", description: "随机强化一张卡牌" },
{ id: "heal", name: "休息",
description: `回复 ${restRecovery(run)} 点生命` },
{ id: "upgrade", name: "磨砺",
description: hasUpgradeableCard(run.player.deck)
? "随机强化一张尚未满级的可强化牌"
: "没有可强化卡牌",
disabled: !hasUpgradeableCard(run.player.deck) },
]);
applyReward(run, choice);
if (choice === "heal") {
const result = applyRoomRelics(run, node, choice);
if (result) game.log(`休息回复${result.recovered}点生命`,
...result.relics.map(relic => relic.name));
} else applyReward(run, choice);
}
else if (node.type == "chest") {
await chooseReward(run, {
@@ -199,6 +232,11 @@ const finishUtilityNode = async (run, node) => {
});
}
else if (node.type == "shop") {
const result = applyRoomRelics(run, node, "enter");
if (result) {
game.log(`遗物【餐券】进入商店回复${result.recovered}点生命`);
await saveRun(run);
}
const price = 20;
const choice = await chooseButtons("商店 Demo", [
{ id: "card_sha", name: `购买【杀】(${price}金币)`, description: "加入个人牌组", disabled: run.player.gold < price || !canAcquireCard(run.player.character, "sha") },
@@ -266,7 +304,8 @@ const initBattleUnit = (player, spec) => {
player.storage.mengsanInitialHand_shuying = spec.hand ?? 4;
for (const skill of spec.skills || []) player.addSkill(skill);
player.update();
return monster;
return monster || initializeBondUnit(player, spec,
_status.mengsanRun_shuying);
};
const installMonsterPile = (player, monster, current) => {
@@ -286,6 +325,8 @@ const {
executeFlyconidIntent,
executeRaiderIntent,
} = createMonsterIntentActions(game, () => activeBattle);
const { planBondIntent, executeBondIntent } =
createBondIntentActions(game, () => activeBattle);
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));
@@ -311,13 +352,17 @@ const createScenario = (plan, current) => {
current.energyUI.set(player,
mountEnergy(player, current.session, document));
current.intentUI.set(player, mountEnemyIntent(player,
current.session, STYLE_PATH, document, () => game.me));
current.session, STYLE_PATH, document,
() => getIntentHostiles(game, player)));
}
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) planEnemyIntent(player, _status.mengsanRun_shuying);
if (monster && current.session.active) {
planEnemyIntent(player, _status.mengsanRun_shuying);
planBondIntent(player);
}
});
await join;
game.log(player, "作为", spec.camp === "ally" ? "友方支援" : "敌方援军", "加入战斗");
@@ -344,7 +389,12 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
// Old saves keep their deck instances, but the retired affix no longer exists.
for (const card of run.player.deck) if (Array.isArray(card.affixes)) card.affixes = card.affixes.filter(key => key !== "annihilate");
if (encounter.requiredCharacter && run.player.character !== encounter.requiredCharacter) throw new Error("该关卡仅限指定主角,请开始刘备的新征程");
const plan = buildBattlePlan(encounter, run);
const bondUnit = prepareBondBattle(run, () => nextRandom(run));
const plan = buildBattlePlan(encounter, run, bondUnit);
if (run.bondBattle) {
game.log(`羁绊助战:${bondDefinitions[run.bondBattle.id].name},${
bondUnit ? "本场将到场" : "本场未到场"}`);
}
for (const spec of [...plan.units, ...plan.rules.flatMap(r => r.effects.filter(e => e.type === "spawn").map(e => e.unit))]) {
if (!lib.character[spec.character]) throw new Error("关卡武将未加载:" + spec.character);
for (const skill of spec.skills || []) if (!lib.skill[skill]) throw new Error("关卡技能未加载:" + skill);
@@ -385,7 +435,6 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
activeBattle.personalPiles = installPersonalPiles(session, me, _status.mengsanBattle_shuying, run, resources, {
game, ui, get, _status, document, MutationObserver, shuffle: shuffleBattlePile,
showCosts: true,
refresh: () => activeBattle?.session === session && activeBattle.pilesUI?.refresh(),
});
activeBattle.personalPiles.withOwner(() => me.init(run.player.character));
@@ -397,7 +446,13 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
participants.forEach((player, index) => {
if (monsters[index]) currentBattle.intentUI.set(player,
mountEnemyIntent(player, session, STYLE_PATH, document,
() => game.me));
() => getIntentHostiles(game, player)));
});
participants.forEach(player => {
if (player.storage.mengsanBond_shuying) {
currentBattle.energyUI.set(player,
mountEnergy(player, session, document));
}
});
activeBattle.director = createScenario(plan, currentBattle);
activeBattle.director.bind("player", me);
@@ -406,14 +461,31 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
me.storage.mengsanMaxEnergy_shuying = PLAYER_ENERGY;
me.storage.mengsanEnergy_shuying = PLAYER_ENERGY;
currentBattle.energyUI.set(me, mountEnergy(me, session, document));
currentBattle.handUI = await mountHandUI(me, session, {
game, ui, document, window, cardCost,
styleURL: `${STYLE_PATH}/ui/hand/style.css`,
log: message => game.log(message),
});
if (!session.active) return;
game.zhu = me;
if (run.player.maxHp == null) {
run.player.maxHp = me.maxHp;
run.player.hp = me.hp;
}
initializeRelicMaxHp(run);
me.maxHp = run.player.maxHp;
me.hp = Math.max(1, Math.min(run.player.hp, me.maxHp));
me.update();
currentBattle.relics = createRelicBattle(run, {
active: () => session.active && me.hp > 0,
draw: number => game.mengsanDraw_shuying(me, number),
effect: createOpeningEffects(game, me,
() => currentBattle.energyUI.get(me)?.()),
log: (relic, effect) => {
game.log(me, `遗物【${relic.name}】发动:${effect}`);
currentBattle.relicUI?.refresh();
},
});
run.player.permanentSkills.forEach(skill => {
if (lib.skill[skill] && !me.hasSkill(skill)) me.addSkill(skill);
});
@@ -422,10 +494,13 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying",
"mengsan_card_payment_shuying", "mengsan_monster_draw_shuying",
"mengsan_flyconid_action_shuying", "mengsan_raider_action_shuying",
"mengsan_bond_action_shuying",
"mengsan_raider_card_strength_shuying",
"mengsan_death_blow_finish_shuying",
"mengsan_stun_skip_shuying", "mengsan_stun_recover_shuying",
"mengsan_stun_clear_shuying",
"mengsan_relics_shuying",
"mengsan_relic_attack_shuying",
]) {
if (!lib.skill.global.includes(skill)) {
game.addGlobalSkill(skill);
@@ -433,6 +508,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
}
}
activeBattle.pilesUI = mountBattlePiles(session, _status.mengsanBattle_shuying);
currentBattle.relicUI = mountRelics(session, run, currentBattle.relics);
activeBattle.gmUI = mountGMManager(session, me, activeBattle.personalPiles, _status.mengsanBattle_shuying, resources, game);
for (let index = 0; index < plan.units.length; index++) await equipBattleUnit(participants[index], plan.units[index], resources);
game.syncState();
@@ -444,6 +520,8 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
await game.mengsanDraw_shuying(me, 4);
if (!session.active) return;
activeBattle.personalPiles.drawInnate();
await currentBattle.relics.start();
if (!session.active) return;
await playDialogue(encounter.openingDialogue, { run, title: encounter.name || "开场剧情" });
if (!session.active) return;
await currentBattle.director.start();
@@ -456,16 +534,21 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
loop.setContent(async event => runPhaseLoop(event, me, session, {game, lib, _status, get, beforeTurn: async player => {
if (player === me && !openingHandChecked) {
openingHandChecked = true;
currentBattle.personalPiles.trimOpeningHand(4);
currentBattle.personalPiles.trimOpeningHand(
4 + currentBattle.relics.openingHandBonus);
}
if (isRaider(player) && player.hujia > 0) await player.changeHujia(-player.hujia);
if ((isRaider(player) || player.storage.mengsanBond_shuying) &&
player.hujia > 0) await player.changeHujia(-player.hujia);
if (player.storage.mengsanMaxEnergy_shuying != null) {
player.storage.mengsanEnergy_shuying = player.storage.mengsanMaxEnergy_shuying;
currentBattle.energyUI.get(player)?.();
}
await currentBattle.director.beforeTurn(player);
if (player === me && session.active) {
for (const enemy of game.players) planEnemyIntent(enemy, run);
for (const participant of game.players) {
planEnemyIntent(participant, run);
planBondIntent(participant);
}
}
game.checkResult();
}}));
@@ -489,9 +572,34 @@ const registerPlayers = (session, players) => {
};
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:chooseBattleReward,
presentVictory: pending => {
if (pending.bondGrowth) {
const growth = pending.bondGrowth;
game.log(`羁绊【${bondDefinitions[growth.id].name}】进度增加${
growth.gain}%,当前${growth.level}级(${growth.progress}%)`);
}
if (pending.relicRecovery > 0) {
game.log(`遗物【燃烧之血】战后回复${pending.relicRecovery}点生命`);
}
return playDialogue(pending.victoryDialogue,
{ run: pending.base, title: "战后剧情" });
},
settlement:createBattleSettlement({store:modeStorage, config,
getRandomRewardChoices, applyReward, completeNode, enterNextAct,
logRewardPackage: (pack, selection) => {
const card = pack.upgradeChoices.find(card =>
card.id === selection.cardId);
game.log(`梦三:过关奖励 ${pack.gold} 金币,` +
(card ? `强化【${get.translation(card.name)}】` : "无可强化牌"));
},
}),
chooseReward: (choices, options) => (options.rewardPackage
? chooseRewardPackage : chooseBattleReward)(choices, {
...options,
fixedRewards: options.fixedRewards.map(id => ({
...config.rewards[id], id,
})),
}),
showMap,
showEnding:async route => { game.over(route === "victory"); },
quiesce:() => quiesceEngine(_status),
@@ -499,10 +607,16 @@ const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({
const requestBattleFinish = outcome => {
const current = activeBattle;
if (!current) return false;
for (const player of game.dead) {
recordBondDeath(player, _status.mengsanRun_shuying,
message => game.log(message));
}
const accepted = current.flow.requestFinish({session:current.session, hp:game.me?.hp || 0, outcome, defeatedEnemies:current.director?.defeatedEnemies ?? 1});
if (accepted) {
current.pilesUI?.dispose();
current.gmUI?.dispose();
current.relicUI?.dispose();
current.handUI?.dispose();
stopBattleTurn(current.session, _status.eventManager);
if (_status.mengsanBattle_shuying) _status.mengsanBattle_shuying.resolving = true;
current.finished();
@@ -549,6 +663,7 @@ const setupBattle = async (run, node, encounter = getNodeEncounter(run, node)) =
await signal; // External controller, never an engine event content await.
if (current.engineError) {
session.requestStop();
current.handUI?.dispose();
document.body.classList.remove("mengsan-battle-ui-shuying");
await chooseButtons("战斗流程异常", [{id:"exit",name:"保留存档并返回模式选择"}], "无法确认旧事件已退出,已停止继续开战。上次成功存档仍保留。");
await openModeSelection(); return;
@@ -627,18 +742,22 @@ const createMode = identityMode => {
...identityElement,
player: {
...identityPlayer,
dieAfter() { game.checkResult(); },
dieAfter() {
recordBondDeath(this, _status.mengsanRun_shuying,
message => game.log(message));
game.checkResult();
},
dieAfter2() {}, // Identity-mode kill rewards/loyalist penalties do not apply here.
isFriendOf(player) { return this === player || (this.storage.mengsanCamp_shuying === player?.storage?.mengsanCamp_shuying); },
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) {
changeHujia(num, type) {
// 脆弱只削减正向获得的护甲,不改变受伤时消耗护甲的数值。
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);
return nativeChangeHujia.call(this, num, type, false);
},
getHandcardLimit() {
if (this.storage?.mengsanPlayer_shuying) {
@@ -647,6 +766,9 @@ const createMode = identityMode => {
return PLAYER_HAND_LIMIT + bonus;
}
if (this.storage?.mengsanMonster_shuying) return this.storage.mengsanMonster_shuying.handLimit;
if (this.storage?.mengsanBond_shuying) {
return this.storage.mengsanBond_shuying.handLimit;
}
let number = Math.max(this.hp, 0);
number = game.checkMod(this, number, "maxHandcardBase", this);
number = game.checkMod(this, number, "maxHandcard", this);
@@ -755,6 +877,12 @@ const createMode = identityMode => {
},
skill: {
...(identityMode.skill || {}),
...createRelicSkills(() => activeBattle, () => game.me),
...createRelicCombatSkills(() => activeBattle, () => game.me),
mengsan_taoyuan_bond_shuying: {
locked: true, mark: true, marktext: "义",
intro: { content: "先天结识关羽、张飞,初始羁绊均为8级。助战角色在行军菜单中选择。" },
},
mengsan_card_payment_shuying: {
forced: true, silent: true, popup: false,
firstDo: true, priority: 10000,
@@ -775,10 +903,14 @@ const createMode = identityMode => {
mengsan_monster_draw_shuying: {
trigger: { player: "phaseDrawBegin2" },
forced: true, silent: true, popup: false,
filter(event, player) { return Boolean(player.storage?.mengsanMonster_shuying); },
filter(event, player) {
return Boolean(player.storage?.mengsanMonster_shuying ||
player.storage?.mengsanBond_shuying);
},
async content(event, trigger, player) {
trigger.num = isStunned(player) ? 0 :
player.storage.mengsanMonster_shuying.draw;
(player.storage.mengsanMonster_shuying ||
player.storage.mengsanBond_shuying).draw;
},
},
mengsan_flyconid_action_shuying: {
@@ -797,11 +929,24 @@ const createMode = identityMode => {
event.player.storage.mengsanRaiderIntent_shuying); },
async content(event, trigger) { await executeRaiderIntent(trigger.player); },
},
mengsan_bond_action_shuying: {
trigger: { global: "phaseUseBefore" },
forced: true, silent: true, popup: false, priority: 100,
filter(event) {
return Boolean(activeBattle?.session.active &&
!isStunned(event.player) &&
event.player.storage?.mengsanBond_shuying?.intent);
},
async content(event, trigger) {
await executeBondIntent(trigger.player);
},
},
mengsan_raider_card_strength_shuying: {
trigger: { source: "damageBegin1" },
forced: true, silent: true, popup: false, priority: 90,
filter(event, player) { return Boolean(activeBattle?.session.active &&
isRaider(player) && player.storage.mengsanStrength_shuying > 0 &&
(isRaider(player) || player.storage.mengsanBond_shuying) &&
player.storage.mengsanStrength_shuying > 0 &&
event.card && !event.mengsanScriptedSkill_shuying); },
async content(event, trigger, player) {
trigger.num += player.storage.mengsanStrength_shuying;
@@ -983,6 +1128,10 @@ const createMode = identityMode => {
mengsan_yingyong_shuying_info: "锁定技,每回合限一次,你于自己的回合内使用牌造成的伤害+1。",
mengsan_flyconid_action_shuying: "孢子行动",
mengsan_raider_action_shuying: "劫掠行动",
mengsan_weak_shuying: "虚弱",
mengsan_weak_shuying_info:
"攻击伤害减少25%(向下取整),自身回合结束减少1层。",
mengsan_relic_attack_shuying: "遗物攻击修正",
mengsan_raider_card_strength_shuying: "力量加成",
mengsan_raider_strength_shuying: "力量",
mengsan_raider_strength_shuying_info: "攻击造成的伤害按力量层数增加。",
@@ -993,7 +1142,7 @@ const createMode = identityMode => {
},
config: {},
help: {
梦三: "梦三:玩家和敌方怪物各有独立牌堆与弃牌堆,友方支援仍使用公共牌堆。主动出牌消耗费用,转化牌按转化后牌名计费;响应不耗费。DIY须在技能所属角色事件内访问牌堆,不可缓存公共牌堆节点。",
梦三: "梦三:玩家、敌方怪物和羁绊助战各有独立牌堆与弃牌堆,其他友方支援使用公共牌堆。主动出牌消耗费用,转化牌按转化后牌名计费;响应不耗费。DIY须在技能所属角色事件内访问牌堆,不可缓存公共牌堆节点。",
},
};
};
+5 -54
View File
@@ -1,8 +1,5 @@
import {
attackHitCount,
outgoingAttackDamage,
} from "../battle/intent-damage.js";
import { beginDeathBlow, isDeathBlowIntent } from "../battle/death-blow.js";
import { createIntentExecutor } from "../battle/intent-effects.js";
export { getIntentHostiles } from "../battle/intent-effects.js";
import { isStunned } from "../battle/stun-intent.js";
import { nextRandom } from "../progression/state.js";
import { selectFlyconidMove, recordFlyconidAction } from "./flyconid-intent.js";
@@ -21,6 +18,7 @@ export const isRaider = player =>
isRaiderCharacter(player.name);
export function createMonsterIntentActions(game, getActiveBattle) {
const executeEffects = createIntentExecutor(game, getActiveBattle);
const planEnemyIntent = (player, run) => {
if (!player?.isAlive() || isStunned(player)) return;
if (isFlyconid(player)) {
@@ -39,30 +37,6 @@ export function createMonsterIntentActions(game, getActiveBattle) {
}
};
const applyIntentDebuff = (target, move) => {
if (!move.debuff) return;
const frail = move.debuff === "脆弱";
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 dealIntentDamage = async (source, target, move) => {
if (move.damage == null) return;
for (let index = 0; index < attackHitCount(move); index++) {
if (!target.isAlive() || !source.isAlive()) break;
const hit = target.damage(
outgoingAttackDamage(move, source), source);
hit.mengsanAttack_shuying = true;
hit.mengsanScriptedSkill_shuying = true;
await hit;
}
};
const executeFlyconidIntent = async player => {
const move = player.storage.mengsanFlyconidIntent_shuying;
if (!move) return;
@@ -80,16 +54,7 @@ export function createMonsterIntentActions(game, getActiveBattle) {
player.storage.mengsanEnergy_shuying--;
getActiveBattle()?.energyUI.get(player)?.();
game.log(player, "消耗1费用发动", move.name);
const target = game.me;
if (!target?.isAlive() || !player.isAlive()) return;
if (isDeathBlowIntent(move)) {
await beginDeathBlow(player, target, move);
return;
}
await dealIntentDamage(player, target, move);
if (target.isAlive() && player.isAlive()) {
applyIntentDebuff(target, move);
}
await executeEffects(player, move);
};
const executeRaiderIntent = async player => {
@@ -106,21 +71,7 @@ export function createMonsterIntentActions(game, getActiveBattle) {
player.storage.mengsanEnergy_shuying--;
getActiveBattle()?.energyUI.get(player)?.();
game.log(player, "消耗1费用发动", move.name);
const target = game.me;
if (!target?.isAlive() || !player.isAlive()) return;
await dealIntentDamage(player, target, move);
if (target.isAlive() && player.isAlive()) {
applyIntentDebuff(target, move);
}
if (!player.isAlive() || !getActiveBattle()?.session.active) return;
if (move.block) await player.changeHujia(move.block);
if (move.strength) {
player.storage.mengsanStrength_shuying =
(player.storage.mengsanStrength_shuying || 0) +
move.strength;
player.addSkill("mengsan_raider_strength_shuying");
player.markSkill("mengsan_raider_strength_shuying");
}
await executeEffects(player, move);
};
return {
+2 -2
View File
@@ -8,9 +8,9 @@ const TIER_RULES = Object.freeze({
});
const BASE_DECKS = Object.freeze({
normal: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "tao", "jiu", "guohe", "sha"]),
normal: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "tao", "jiu", "sha"]),
elite: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "jiu", "juedou", "wuzhong", "guohe"]),
boss: Object.freeze(["sha", "sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "tao", "jiu", "jiu", "juedou", "wuzhong", "guohe"]),
boss: Object.freeze(["sha", "sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "jiu", "juedou", "wuzhong", "guohe"]),
});
const HEALTH = Object.freeze({
+39
View File
@@ -0,0 +1,39 @@
import { canUpgradeCard } from "../cards/upgrades.js";
import { canAcquireRelic } from "../relics/definitions.js";
export function prepareRewardPackage(run, spec, randomChoices) {
if (!Number.isSafeInteger(spec.gold) || spec.gold < 0) {
throw new Error("奖励包金币无效");
}
const relicChoices = randomChoices(run, spec.relicPool, 3);
if (relicChoices.some(reward => !reward.relic)) {
throw new Error("奖励包候选必须是遗物");
}
return {
gold: spec.gold,
upgradeChoices: run.player.deck.filter(canUpgradeCard),
relicChoices,
};
}
export function applyRewardPackage(run, pack, selection, applyReward) {
if (!selection || typeof selection !== "object") {
throw new Error("奖励包选择无效");
}
const card = run.player.deck.find(card => card.id === selection.cardId);
const relic = pack.relicChoices.find(choice =>
choice.id === selection.relicId);
if (pack.upgradeChoices.length) {
if (!pack.upgradeChoices.some(card => card.id === selection.cardId) ||
!canUpgradeCard(card)) throw new Error("强化目标无效");
} else if (selection.cardId !== null) throw new Error("无可强化卡牌");
if (pack.relicChoices.length) {
if (!relic || !canAcquireRelic(run, relic.relic)) {
throw new Error("遗物选择无效");
}
} else if (selection.relicId !== null) throw new Error("无可选遗物");
run.player.gold += pack.gold;
run.statistics.goldEarned += pack.gold;
if (pack.upgradeChoices.length) card.upgrade = (card.upgrade || 0) + 1;
if (relic) applyReward(run, relic.id);
}
+9
View File
@@ -1,9 +1,18 @@
import config from "../config.js";
import { nextRandom } from "./state.js";
import { canAcquireCard } from "../cards/card-definitions.js";
import { hasUpgradeableCard } from "../cards/upgrades.js";
import { canAcquireRelic } from "../relics/definitions.js";
export const rewardCardName = reward => reward?.card?.name || ({ card_sha: "sha", card_tao: "tao" })[reward?.effectId] || null;
export const canAcquireReward = (run, reward) => {
if (reward?.relic) return canAcquireRelic(run, reward.relic);
if (reward?.effectId === "item_hand") {
return canAcquireRelic(run, "mengsan_hand_charm_shuying");
}
if (reward?.effectId === "upgrade") {
return hasUpgradeableCard(run?.player?.deck);
}
const name = rewardCardName(reward);
return !name || canAcquireCard(run?.player?.character, name);
};
+3
View File
@@ -1,5 +1,6 @@
import { createStartingDeck } from "../cards/starting-deck.js";
import config from "../config.js";
import { initializeInnateBonds } from "../bonds/state.js";
import { RAIDER_TRIO_ENCOUNTER, createRaiderTrioBattlePlan } from "../content/acts/act1/raider-trio.js";
const hashText = text => {
@@ -177,6 +178,7 @@ export const createRun = character => {
},
storyFlags: {},
};
initializeInnateBonds(run);
run.map = generateActMap(run, 0);
return run;
};
@@ -252,6 +254,7 @@ export const getNodeEncounter = (run, node, override = null) => {
requiredCharacter: encounter?.requiredCharacter || null,
openingDialogue: encounter?.openingDialogue || [],
fixedRewards: encounter?.fixedRewards || [],
rewardPackage: encounter?.rewardPackage || null,
skipRandomReward: encounter?.skipRandomReward === true,
victoryDialogue: encounter?.victoryDialogue || [],
battlePlan: encounter?.battlePlan || (enemy === RAIDER_TRIO_ENCOUNTER ?
+6
View File
@@ -1,3 +1,5 @@
import { meetBond } from "../bonds/state.js";
export const getAvailableStoryChoices = (run, content) => {
const flags = run.storyFlags || {};
return (content?.choices || []).filter(choice => {
@@ -9,5 +11,9 @@ export const getAvailableStoryChoices = (run, content) => {
export const applyStoryOutcome = (run, outcome = {}) => {
if (!run.storyFlags) run.storyFlags = {};
Object.assign(run.storyFlags, outcome.flags || {});
for (const bond of outcome.bonds || []) {
meetBond(run, typeof bond === "string" ? bond : bond.id,
typeof bond === "string" ? 1 : bond.level ?? 1);
}
return outcome;
};
+25
View File
@@ -0,0 +1,25 @@
# 遗物资源待补清单
当前所有遗物的 `image` 均为 `null`,使用文字名称和效果说明,
不会加载不存在的图片。后续图片统一放入 `mengsan/assets/relics/`,
补齐时更新 `definitions.js` 并接入展示;本次不生成占位图片。
| 遗物 | 稳定 ID | 建议图片文件名 | 状态 |
| --- | --- | --- | --- |
| 燃烧之血 | `mengsan_burning_blood_shuying` | `burning_blood.png` | 待提供 |
| 蛇之戒指 | `mengsan_snake_ring_shuying` | `snake_ring.png` | 待提供 |
| 长蛇戒指 | `mengsan_long_snake_ring_shuying` | `long_snake_ring.png` | 待提供 |
| 千年积木 | `mengsan_millennium_puzzle_shuying` | `millennium_puzzle.png` | 待提供 |
| 摆动球 | `mengsan_pendulum_shuying` | `pendulum.png` | 待提供 |
| 束带(既有道具) | `mengsan_hand_charm_shuying` | `hand_charm.png` | 待提供 |
| 餐券 | `mengsan_meal_ticket_shuying` | `meal_ticket.png` | 待提供 |
| 草莓 | `mengsan_strawberry_shuying` | `strawberry.png` | 待提供 |
| 弹珠袋 | `mengsan_bag_of_marbles_shuying` | `bag_of_marbles.png` | 待提供 |
| 灯笼 | `mengsan_lantern_shuying` | `lantern.png` | 待提供 |
| 红面具 | `mengsan_red_mask_shuying` | `red_mask.png` | 待提供 |
| 皇家枕头 | `mengsan_regal_pillow_shuying` | `regal_pillow.png` | 待提供 |
| 节日拉炮 | `mengsan_festive_poppers_shuying` | `festive_poppers.png` | 待提供 |
| 金刚杵 | `mengsan_vajra_shuying` | `vajra.png` | 待提供 |
建议使用统一尺寸的透明背景 PNG,实际尺寸和视觉风格在资源制作时确定。
图片仅负责外观,不承载名称、效果或触发计数。
+105
View File
@@ -0,0 +1,105 @@
# 梦三遗物(道具)
本目录负责遗物定义、持有规则与战斗效果管理。
已实现十三件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。
## 已实现的遗物
| 遗物 | 效果 |
| --- | --- |
| 燃烧之血 | 胜利结算后回复 6 点生命,不超过上限;失败不回血 |
| 蛇之戒指 | 每场战斗开始额外摸 2 张,开场手牌整理不会移除这两张 |
| 长蛇戒指 | 每场战斗第 1~3 个自身回合开始,各额外摸 2 张 |
| 千年积木 | 每场战斗首次实际生命减少且仍存活时摸 3 张;纯护甲损失不触发 |
| 摆动球 | 每场战斗第 3、6、9……个自身回合开始额外摸 1 张 |
| 束带 | 原有手牌上限 +1,重复获得不叠加 |
| 餐券 | 进入商店时回复 15 点生命,每个商店节点一次 |
| 草莓 | 获得时生命上限 +7,当前生命不变,重复获得不叠加 |
| 弹珠袋 | 开战时给予当时所有存活敌人 1 层易伤 |
| 灯笼 | 首个自身回合可用费用 +1,费用上限不变,下回合恢复正常 |
| 红面具 | 开战时给予当时所有存活敌人 1 层虚弱 |
| 皇家枕头 | 选择休息回血时额外恢复 15;现有基础 8,总计 23 |
| 节日拉炮 | 开战时对当时所有存活敌人造成 9 点伤害 |
| 金刚杵 | 开战时获得 1 点力量,只在当前战斗有效 |
虚弱使攻击伤害减少 25%(向下取整),自身回合结束减少 1 层;
力量使玩家攻击牌伤害增加对应点数,意图攻击沿用原有力量/虚弱计算。
节日拉炮是非卡牌伤害,正常消耗目标护甲,不享受力量、易伤、虚弱的攻击修正。
开场效果先摸牌和施加状态,再结算节日拉炮;仅影响开战时的敌人,不影响后续援军。
餐券在打开商店前保存回血和节点记录,读档不会重复触发;
皇家枕头不影响磨砺、商店治疗或其他回复效果。所有回复均不超过生命上限。
草莓若在角色生命尚未初始化时获得,会保存待应用加成,初始化后只应用一次。
遗物互相独立:同时持有两种戒指时,开场和前三回合效果分别触发。
所有回合计数按玩家自身实际开始的回合计算,不计敌方和支援回合,
不使用界面显示的全局轮数。每场战斗重置计数及“首次损失生命”标记。
额外摸牌沿用个人牌堆规则;牌堆与弃牌堆不足时仍按原规则挑战失败。
### 获得、保存和查看
- 新遗物通过自动生成的遗物奖励列表加入共享 Boss 和第一章宝箱奖励池;
后续章节使用共享 Boss 奖励池的宝箱也可出现它们。
- 角色默认不携带新遗物;未新增商店价格或精英掉落规则。
- 持有记录沿用 `run.player.items`,已持有遗物会从随机奖励中排除。
- `definitions.js` 提供注册、资格检查和去重获得;旧“束带”ID 与效果保留。
- `battle.js` 管理战斗计数、摸牌效果及战后回血;战斗运行状态不写入征程。
- 战后回血写入结算候选快照,之后才领取奖励,重试、重复提交和读档
复用快照,不再次回血;刚从本场奖励获得的燃烧之血从下一场生效。
- 战斗中的“遗物”按钮显示名称、效果与触发状态;地图行囊显示持有详情。
- 获得和效果触发使用游戏日志反馈;没有图片时只显示文字。
## 参考来源
- [杀戮尖塔 2 中文百科:遗物](https://sts2.huijiwiki.com/wiki/%E9%81%97%E7%89%A9)
- 查阅日期:2026-09-30。
- 页面直连返回 403;以下概述依据搜索索引返回的该页正文整理。
本文概述机制,不复制完整遗物列表、具体效果或图片。
## 网页中的遗物概念
遗物是单局游戏中获得并持续持有的物品,会影响牌组构筑、
手牌管理和其他玩法。这里的“永久”指当前这一局,
不代表跨新征程永久保留。
- 每个角色拥有自己的初始遗物,此外也有通用和角色专属遗物。
- 获得途径包括击败精英或 Boss、宝箱、商店、部分事件和先古之民。
- 已经持有的遗物不会再次出现。
- 通常不能主动移除;部分事件可以交换或移除遗物。
- 部分特殊遗物可以失效,但失效后仍保留在持有列表中。
- 页面按稀有度或获取类别区分初始、普通、罕见、稀有、
商店、先古之民、事件及其他遗物。
## 接入前的项目背景
接入前,项目已有以下零散道具逻辑:
| 位置 | 现有职责 |
| --- | --- |
| `../progression/state.js` | 新征程创建 `player.items` 和手牌上限加成数据 |
| `../mode.js` | 获得“束带”时记录道具 ID,手牌上限加成增加 1,重复获得不叠加 |
| `../content/shared/rewards.js` | 配置“束带”和“斥候援令”等奖励 |
| `../battle/battle-director.js` | 管理援令带来的支援单位与重复来源处理 |
| `../ui/map/renderer.js` | 行囊展示永久道具,数量读取 `player.items` |
“斥候援令”走支援管理流程,不能仅凭奖励名中的“道具”
认定它已经进入 `player.items` 或统一遗物系统。
## 设计约定与后续扩展
1. 将“遗物”作为本征程持续持有道具的统一概念,
与手牌、装备牌、消耗品及临时战斗状态分别管理。
2. 每个遗物定义稳定 ID、名称、说明、图标、角色限制、
获取类别及效果规则;具体三国题材名称与数值另行配置。
3. 根据效果需要提供获得时、战斗开始、回合开始、出牌后、
战斗结束等触发时机;不能假定所有遗物只提供固定属性加成。
4. 获得前统一检查角色资格和是否已持有,
默认不重复获取;失效、交换和移除规则由具体遗物明确声明。
5. 将持有记录和需要跨战斗保存的计数写入征程存档,
战斗内计数由战斗会话管理,读档不重复执行“获得时”效果。
6. 先以“束带”作为接入现有机制的候选,保留它当前的效果与去重行为;
“斥候援令”需要明确与支援系统的关系,再决定如何接入。
7. 获得提示、持有列表、说明与触发反馈放在 `../ui/`,
图片建议放在 `../assets/relics/`;本目录负责遗物自身规则。
资源、稀有度、精英掉落、商店价格、角色初始遗物以及特殊交换或移除
规则仍待后续配置;本次保留原道具与存档字段,不引入独立遗物存档副本。
+122
View File
@@ -0,0 +1,122 @@
import { heldRelics } from "./definitions.js";
export function createRelicBattle(run, { active, draw, log, effect }) {
const relics = heldRelics(run);
let started = false, turns = 0, firstLoss = false;
let openingHandBonus = 0, pendingHpDraw = null;
const seenTurns = new WeakSet();
const usable = () => started && active();
const drawEffect = async relic => {
if (!usable()) return;
if (turns === 0) openingHandBonus += relic.amount;
log(relic, `额外摸${relic.amount}张牌`);
await draw(relic.amount);
};
return {
get openingHandBonus() { return openingHandBonus; },
get openingDraw() {
return relics.filter(relic => relic.effect === "openingDraw")
.reduce((sum, relic) => sum + relic.amount, 0);
},
async start() {
if (started || !active()) return;
started = true;
for (const relic of relics) {
if (relic.effect === "openingDraw") await drawEffect(relic);
}
for (const relic of relics.filter(item =>
["openingVulnerable", "openingWeak", "openingStrength",
"openingDamage"].includes(item.effect))
.sort((a, b) => Number(a.effect === "openingDamage") -
Number(b.effect === "openingDamage"))) {
if (!usable()) break;
log(relic, relic.description);
await effect(relic);
}
if (pendingHpDraw) {
const relic = pendingHpDraw;
pendingHpDraw = null;
await drawEffect(relic);
}
},
async turnStart(event) {
if (!usable() || seenTurns.has(event)) return;
seenTurns.add(event);
turns++;
for (const relic of relics) {
if (relic.effect === "firstTurnEnergy" && turns === 1 &&
usable()) {
log(relic, `获得${relic.amount}点临时费用`);
await effect(relic);
}
if ((relic.effect === "earlyTurnDraw" &&
turns <= relic.turns) ||
(relic.effect === "periodicTurnDraw" &&
turns % relic.turns === 0)) await drawEffect(relic);
}
},
async loseHp(event) {
if (!active() || firstLoss || !(event.num < 0)) return;
const relic = relics.find(item =>
item.effect === "firstHpLossDraw");
if (!relic) return;
firstLoss = true;
if (!started) {
pendingHpDraw = relic;
return;
}
await drawEffect(relic);
},
status(relic) {
if (relic.effect === "firstHpLossDraw") {
return firstLoss ? "本场已触发" : "本场尚未触发";
}
if (relic.effect === "earlyTurnDraw") {
return `本场已开始${turns}个自身回合`;
}
if (relic.effect === "periodicTurnDraw") {
return `回合计数:${turns % relic.turns}/${relic.turns}`;
}
if (relic.effect.startsWith("opening")) {
return started ? "本场已触发" : "等待战斗开始";
}
if (relic.effect === "firstTurnEnergy") {
return turns ? "本场已触发" : "等待首个自身回合";
}
return "持续持有";
},
};
}
export function applyBattleEndRelics(run, outcome) {
if (outcome !== "victory" || !(run.player.hp > 0)) return 0;
const amount = heldRelics(run).filter(relic =>
relic.effect === "battleHeal").reduce((sum, relic) =>
sum + relic.amount, 0);
const before = run.player.hp;
run.player.hp = Math.min(run.player.maxHp ?? before,
before + amount);
return run.player.hp - before;
}
export function createRelicSkills(getBattle, getOwner) {
return {
mengsan_relics_shuying: {
trigger: { global: ["phaseBegin", "changeHp"] },
forced: true, silent: true, popup: false, priority: 100,
filter(event) {
const battle = getBattle(), owner = getOwner();
return Boolean(battle?.session.active && battle.relics &&
owner?.hp > 0 && event.player === owner &&
(event.name === "phase" || event.num < 0));
},
async content(event, trigger) {
const battle = getBattle();
if (!battle?.session.active) return;
if (event.triggername === "phaseBegin") {
await battle.relics.turnStart(trigger);
} else await battle.relics.loseHp(trigger);
},
},
};
}
+78
View File
@@ -0,0 +1,78 @@
export function createOpeningEffects(game, owner, refreshEnergy) {
const enemies = () => game.players.filter(player =>
player !== owner && player.isAlive() &&
player.storage?.mengsanCamp_shuying === "enemy");
const addStatus = (player, key, skill, amount) => {
player.storage[key] = (player.storage[key] || 0) + amount;
player.addSkill(skill);
player.markSkill(skill);
};
return async relic => {
if (relic.effect === "firstTurnEnergy") {
owner.storage.mengsanEnergy_shuying += relic.amount;
refreshEnergy();
} else if (relic.effect === "openingStrength") {
addStatus(owner, "mengsanStrength_shuying",
"mengsan_raider_strength_shuying", relic.amount);
} else {
for (const target of enemies()) {
if (!owner.isAlive()) break;
if (!target.isAlive()) continue;
if (relic.effect === "openingVulnerable") {
addStatus(target, "mengsanVulnerable_shuying",
"mengsan_vulnerable_shuying", relic.amount);
} else if (relic.effect === "openingWeak") {
addStatus(target, "mengsanWeak_shuying",
"mengsan_weak_shuying", relic.amount);
} else if (relic.effect === "openingDamage") {
const damage = target.damage(relic.amount, owner, "nocard");
damage.mengsanScriptedSkill_shuying = true;
await damage;
}
}
}
};
}
export function createRelicCombatSkills(getBattle, getOwner) {
return {
mengsan_relic_attack_shuying: {
trigger: { global: "damageBegin1" },
forced: true, silent: true, popup: false, priority: 80,
filter(event) {
const source = event.source;
return Boolean(getBattle()?.session.active && event.card &&
!event.mengsanScriptedSkill_shuying &&
((source === getOwner() &&
source?.storage?.mengsanStrength_shuying > 0) ||
source?.storage?.mengsanWeak_shuying > 0));
},
async content(event, trigger) {
const source = trigger.source;
if (source === getOwner()) {
trigger.num += source.storage.mengsanStrength_shuying || 0;
}
if (source.storage.mengsanWeak_shuying > 0) {
trigger.num = Math.floor(trigger.num * 0.75);
}
},
},
mengsan_weak_shuying: {
mark: true, marktext: "弱",
onremove(player) { delete player.storage.mengsanWeak_shuying; },
intro: { content(storage, player) {
return `剩余${player.storage.mengsanWeak_shuying || 0}回合:` +
"攻击伤害减少25%(向下取整)";
} },
trigger: { player: "phaseAfter" },
forced: true, silent: true, popup: false,
async content(event, trigger, player) {
player.storage.mengsanWeak_shuying = Math.max(0,
(player.storage.mengsanWeak_shuying || 0) - 1);
if (player.storage.mengsanWeak_shuying) {
player.markSkill("mengsan_weak_shuying");
} else player.removeSkill("mengsan_weak_shuying");
},
},
};
}
+99
View File
@@ -0,0 +1,99 @@
const entries = [
{ id: "mengsan_burning_blood_shuying", name: "燃烧之血",
description: "战斗结束后回复6点生命,不能超过生命上限。",
effect: "battleHeal", amount: 6 },
{ id: "mengsan_snake_ring_shuying", name: "蛇之戒指",
description: "每场战斗开始时,额外摸2张牌。",
effect: "openingDraw", amount: 2 },
{ id: "mengsan_long_snake_ring_shuying", name: "长蛇戒指",
description: "每场战斗的前3个自身回合开始时,额外摸2张牌。",
effect: "earlyTurnDraw", amount: 2, turns: 3 },
{ id: "mengsan_millennium_puzzle_shuying", name: "千年积木",
description: "每场战斗首次实际损失生命且仍存活时,摸3张牌。",
effect: "firstHpLossDraw", amount: 3 },
{ id: "mengsan_pendulum_shuying", name: "摆动球",
description: "每场战斗每到第3个自身回合开始时,额外摸1张牌。",
effect: "periodicTurnDraw", amount: 1, turns: 3 },
{ id: "mengsan_hand_charm_shuying", name: "束带",
description: "基础手牌上限额外+1;重复获得不叠加。",
effect: "handLimit", amount: 1 },
{ id: "mengsan_meal_ticket_shuying", name: "餐券",
description: "进入商店房间时回复15点生命。",
effect: "shopHeal", amount: 15 },
{ id: "mengsan_strawberry_shuying", name: "草莓",
description: "获得时最大生命值提升7;不额外回复生命。",
effect: "gainMaxHp", amount: 7 },
{ id: "mengsan_bag_of_marbles_shuying", name: "弹珠袋",
description: "每场战斗开始时给予所有敌人1层易伤。",
effect: "openingVulnerable", amount: 1 },
{ id: "mengsan_lantern_shuying", name: "灯笼",
description: "每场战斗首个自身回合额外获得1点临时费用。",
effect: "firstTurnEnergy", amount: 1 },
{ id: "mengsan_red_mask_shuying", name: "红面具",
description: "每场战斗开始时给予所有敌人1层虚弱。",
effect: "openingWeak", amount: 1 },
{ id: "mengsan_regal_pillow_shuying", name: "皇家枕头",
description: "选择休息回血时额外回复15点生命。",
effect: "restHeal", amount: 15 },
{ id: "mengsan_festive_poppers_shuying", name: "节日拉炮",
description: "每场战斗开始时对所有敌人造成9点伤害。",
effect: "openingDamage", amount: 9 },
{ id: "mengsan_vajra_shuying", name: "金刚杵",
description: "每场战斗开始时获得1点力量。",
effect: "openingStrength", amount: 1 },
];
export const relicDefinitions = Object.freeze(Object.fromEntries(
entries.map(entry => [entry.id, Object.freeze({ ...entry,
owner: null, image: null })])));
export const getRelic = id => Object.hasOwn(relicDefinitions, id)
? relicDefinitions[id] : null;
export const heldRelics = run => [...new Set(run?.player?.items || [])]
.map(getRelic).filter(Boolean);
export function canAcquireRelic(run, id) {
const relic = getRelic(id);
return Boolean(run?.player && relic &&
(relic.owner === null || relic.owner === run.player.character) &&
!run.player.items?.includes(id));
}
export function grantRelic(run, id) {
if (!getRelic(id)) throw new Error(`梦三遗物未注册:${id}`);
if (!canAcquireRelic(run, id)) return false;
const relic = getRelic(id);
run.player.items ||= [];
run.player.items.push(id);
if (relic.effect === "handLimit") {
run.player.handLimitBonus = (run.player.handLimitBonus || 0) +
relic.amount;
}
if (relic.effect === "gainMaxHp") {
if (Number.isFinite(run.player.maxHp)) {
run.player.maxHp += relic.amount;
} else {
run.player.pendingRelicMaxHp =
(run.player.pendingRelicMaxHp || 0) + relic.amount;
}
}
return true;
}
export function initializeRelicMaxHp(run) {
const amount = run.player.pendingRelicMaxHp || 0;
if (!amount || !Number.isFinite(run.player.maxHp)) return;
run.player.maxHp += amount;
delete run.player.pendingRelicMaxHp;
}
export const relicRewardIds = entries.filter(entry =>
entry.effect !== "handLimit").map(entry => `shared.reward.relic.${entry.id}`);
export const relicRewards = Object.fromEntries(relicRewardIds.map(id => {
const relicId = id.slice("shared.reward.relic.".length);
const relic = getRelic(relicId);
return [id, { relic: relicId, name: `遗物·${relic.name}`,
description: relic.description }];
}));
+22
View File
@@ -0,0 +1,22 @@
import { heldRelics } from "./definitions.js";
export const restRecovery = run => 8 + heldRelics(run)
.filter(relic => relic.effect === "restHeal")
.reduce((sum, relic) => sum + relic.amount, 0);
export function applyRoomRelics(run, node, action) {
const resting = node.type === "rest" && action === "heal";
if (node.type !== "shop" && !resting) return null;
const receipt = `${node.type}:${node.id}`;
const receipts = run.player.relicRoomReceipts || [];
if (receipts.includes(receipt)) return null;
const effect = resting ? "restHeal" : "shopHeal";
const relics = heldRelics(run).filter(relic => relic.effect === effect);
const amount = (resting ? 8 : 0) + relics.reduce((sum, relic) =>
sum + relic.amount, 0);
if (!amount) return null;
const before = run.player.hp;
run.player.hp = Math.min(run.player.maxHp, before + amount);
run.player.relicRoomReceipts = [...receipts, receipt];
return { recovered: run.player.hp - before, relics };
}
+91
View File
@@ -0,0 +1,91 @@
import { bondDefinitions, getBondCombat, getBondDeck }
from "../bonds/definitions.js";
import { getBondIntents } from "../bonds/intents.js";
import { bondProbability, ensureBonds, selectBond } from "../bonds/state.js";
export function renderBonds(parent, run, saveRun, onBusy = () => {}) {
const bonds = ensureBonds(run);
const root = document.createElement("div");
root.className = "mengsan-bonds-shuying";
parent.appendChild(root);
const text = (tag, content, target = root) => {
const node = document.createElement(tag);
node.textContent = content;
target.appendChild(node);
return node;
};
const status = text("p", "");
status.setAttribute("role", "status");
const buttons = [];
let saving = false;
const choose = async id => {
if (saving) return;
saving = true;
onBusy(true);
const previous = bonds.selected;
for (const button of buttons) button.disabled = true;
status.textContent = "正在保存助战选择…";
try {
selectBond(run, id);
await saveRun(run);
const updated = renderBonds(parent, run, saveRun, onBusy);
root.replaceWith(updated);
updated.querySelector('button[aria-pressed="true"]')
?.focus({ preventScroll: true });
} catch (error) {
selectBond(run, previous);
status.textContent = "保存失败,请重试。";
console.error("梦三助战选择保存失败", error);
for (const button of buttons) button.disabled = false;
} finally {
saving = false;
onBusy(false);
}
};
const clear = text("button", "不指定助战");
clear.type = "button";
clear.setAttribute("aria-pressed", String(bonds.selected === null));
clear.addEventListener("click", () => choose(null));
buttons.push(clear);
if (!Object.keys(bonds.npcs).length) text("p", "尚未结识羁绊 NPC。");
for (const [id, npc] of Object.entries(bonds.npcs)) {
const definition = bondDefinitions[id];
if (!definition) continue;
const card = document.createElement("section");
card.className = "mengsan-bond-card-shuying";
root.appendChild(card);
text("h3", definition.name, card);
const ready = Boolean(getBondCombat(id, npc.level));
const state = npc.down ? "濒死 · 等待救助" :
ready ? "可助战" : "战斗配置待补";
text("p", `${state}${npc.down && !ready ?
" · 战斗配置待补" : ""}`, card);
text("p", `羁绊 ${npc.level} 级 · 助战概率 ${
(bondProbability(npc.level) * 100).toFixed(2)}%`, card);
const progress = text("progress", "", card);
progress.max = 100;
progress.value = npc.level === 10 ? 100 : npc.progress;
progress.setAttribute("aria-label", `${definition.name}升级进度`);
text("p", npc.level === 10 ? "羁绊已满级" :
`升级进度 ${npc.progress}% / 100%`, card);
const button = text("button", bonds.selected === id ?
"已指定助战" : "指定助战", card);
button.type = "button";
button.setAttribute("aria-pressed", String(bonds.selected === id));
button.addEventListener("click", () => choose(id));
buttons.push(button);
const combat = getBondCombat(id, npc.level);
text("p", combat ? `生命 ${combat.maxHp} · 初始手牌 ${combat.hand}` :
"生命、初始手牌及技能等待配置。", card);
text("p", `独立牌库 ${getBondDeck(definition).length} 张 · 意图 ${
getBondIntents(definition).map(move => move.name).join(" → ")}`,
card);
if (npc.down) {
const rescue = text("button", "救助道具待补充", card);
rescue.type = "button";
rescue.disabled = true;
}
}
text("p", "指定 NPC 后,每场战斗结束:到场 +50%,未到场 +25%。");
return root;
}
+14 -4
View File
@@ -1,5 +1,6 @@
import { lib, get, ui } from "../../../../noname.js";
import { AFFIX_INFO } from "../cards/affixes.js";
import { cardUpgradeLevel, cardUpgradeRule } from "../cards/upgrades.js";
const suits = { spade: "♠ 黑桃", heart: "♥ 红桃", club: "♣ 梅花", diamond: "♦ 方块" };
const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" };
@@ -20,9 +21,12 @@ export function describeLibraryCard(card) {
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`] || "暂无卡牌介绍。"),
description: text(cardUpgradeRule(card.name) && info?.cardPrompt
? info.cardPrompt(card)
: 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,
upgrade: cardUpgradeLevel(card),
upgradeLimit: cardUpgradeRule(card.name)?.maxLevel || 0,
available: Boolean(info),
};
}
@@ -49,13 +53,17 @@ function preview(card, details, parent) {
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;
const modeImage = lib.card[card.name]?.image;
const localArt = typeof modeImage === "string" &&
/^ext:术樱包\/mengsan\/assets\/cards\/[a-z0-9_]+\.png$/.test(modeImage)
? modeImage.replace(/^ext:/, "extension/") : null;
// 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`;
image.src = `${lib.assetURL || ""}${localArt || `image/card/${imageName}.png`}`;
}
element("strong", "ms-deck-name", details.name, face);
element("span", "ms-deck-meta", `${details.type} · ${details.nature}`, face);
@@ -110,7 +118,9 @@ export function openCardLibrary(run, options = {}) {
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);
element("p", "", details.upgradeLimit
? `强化:${details.upgrade}/${details.upgradeLimit}`
: "此牌不可强化", copy);
for (const affix of details.affixes) element("p", "ms-deck-rules", affix, copy);
body.scrollTop = 0; backButton.focus();
}
+20
View File
@@ -0,0 +1,20 @@
export function createHandAdapter(player, ui) {
const zones = [player.node?.handcards1, player.node?.handcards2]
.filter(Boolean);
const containers = [ui.handcards1Container, ui.handcards2Container];
const root = ui.me;
const cards = () => zones.flatMap(zone => Array.from(zone.children)
.filter(card => card.classList.contains("card") &&
!card.classList.contains("removing")));
const supported = () => Boolean(root?.isConnected && ui.me === root &&
zones.length === 2 &&
zones.every((zone, index) =>
containers[index] === ui[index === 0 ?
"handcards1Container" : "handcards2Container"] &&
zone.parentNode === containers[index] &&
(zone.isConnected ? root.contains(zone) :
index === 1 && zone.childElementCount === 0) &&
Array.from(zone.children).every(node =>
node.classList.contains("card"))));
return { root, zones, containers, cards, supported };
}
+17
View File
@@ -0,0 +1,17 @@
const lines = [];
let pendingWrite = Promise.resolve();
export function createHandDiagnostics(game, sessionId) {
return (message, error) => {
const line = `${new Date().toISOString()} [${sessionId}] ${message}` +
(error ? ` ${error.message || error}` : "");
console[error ? "warn" : "info"](`梦三手牌 UI:${line}`);
lines.push(line);
if (lines.length > 100) lines.shift();
if (typeof game?.writeFile !== "function") return;
const contents = lines.join("\n") + "\n";
pendingWrite = pendingWrite.then(() => game.promises.writeFile(
contents, "extension/术樱包/mengsan/logs", "log.txt"
)).catch(cause => console.warn("梦三手牌 UI 日志写入失败", cause));
};
}
+200
View File
@@ -0,0 +1,200 @@
import { createHandAdapter } from "./adapter.js";
import { createHandOverlays } from "./overlays.js";
import { createHandDiagnostics } from "./diagnostics.js";
export async function mountHandUI(player, session, env) {
const { ui, document, window, cardCost, styleURL } = env;
const adapter = createHandAdapter(player, ui);
const overlays = createHandOverlays(document, cardCost);
const restores = [];
const diagnose = createHandDiagnostics(env.game, session.id);
const style = document.createElement("link");
style.rel = "stylesheet";
style.href = styleURL;
let disposed = false;
let observer, resizeObserver, pointer = null, nativeInput = false;
let finishStyleLoad = null, refreshTimer = null;
const touchScrollClasses = new Map();
let layoutEnabled = false, warned = false;
const log = (message, error) => {
diagnose(message, error);
env.log?.(`梦三手牌 UI:${message}`);
};
function ownClass(node, name) {
if (!node || node.classList.contains(name)) return;
node.classList.add(name);
restores.push(() => node.classList.remove(name));
}
function ownAttribute(node, name, value) {
const previous = node.getAttribute(name);
node.setAttribute(name, value);
restores.push(() => {
if (node.getAttribute(name) !== value) return;
if (previous === null) node.removeAttribute(name);
else node.setAttribute(name, previous);
});
}
function nativeLayout() {
if (ui.me !== adapter.root) return;
try { ui.updateh?.(true); ui.updatehl?.(); }
catch (error) { log("原手牌布局刷新失败", error); }
}
function setLayout(enabled) {
if (layoutEnabled === enabled) return;
layoutEnabled = enabled;
for (const zone of adapter.zones) {
zone.classList.toggle("mengsan-hand-layout-shuying", enabled);
}
for (const container of adapter.containers.filter(Boolean)) {
container.classList.toggle("mengsan-hand-scroll-shuying", enabled);
}
if (!enabled) nativeLayout();
}
function refresh() {
if (disposed) return;
if (!session.active || !adapter.zones.some(zone => zone.isConnected)) {
dispose();
return;
}
const supported = adapter.supported();
setLayout(supported && !nativeInput);
if (!supported && !warned) {
warned = true;
log("当前容器不支持独立排列,已保留原排列与费用/强化标记");
}
overlays.sync(adapter.cards());
}
function listen(target, type, listener, options) {
target.addEventListener(type, listener, options);
restores.push(() => target.removeEventListener(type, listener, options));
}
function beginInput(event) {
const card = event.target.closest?.(".card");
if (event.button !== 0 || pointer ||
!adapter.zones.includes(card?.parentNode)) {
return;
}
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY,
touch: event.pointerType === "touch" };
}
function moveInput(event) {
if (!pointer || pointer.id !== event.pointerId || nativeInput) return;
if (Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) < 8) {
return;
}
if (pointer.touch && Math.abs(event.clientX - pointer.x) >
Math.abs(event.clientY - pointer.y)) return;
nativeInput = true;
setLayout(false);
}
function endInput(event) {
if (event?.pointerId != null && event.pointerId !== pointer?.id) return;
restoreTouchScroll();
pointer = null;
if (disposed || !nativeInput) return;
nativeInput = false;
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(refresh, 0);
}
function restoreTouchScroll() {
for (const [container, hadScroll] of touchScrollClasses) {
if (!hadScroll) container.classList.remove("scrollh");
}
touchScrollClasses.clear();
}
function scrollWheel(event) {
if (!layoutEnabled || event.ctrlKey || event.metaKey) return;
const container = event.currentTarget;
if (container.scrollWidth <= container.clientWidth) return;
const scale = event.deltaMode === 1 ? 16 :
event.deltaMode === 2 ? container.clientWidth : 1;
container.scrollLeft += (event.deltaX || event.deltaY) * scale;
event.preventDefault();
event.stopImmediatePropagation();
}
function allowTouchScroll(event) {
const container = event.currentTarget;
if (!layoutEnabled || container.scrollWidth <= container.clientWidth) {
return;
}
if (!touchScrollClasses.has(container)) {
touchScrollClasses.set(container,
container.classList.contains("scrollh"));
}
container.classList.add("scrollh");
}
function dispose() {
if (disposed) return;
disposed = true;
finishStyleLoad?.();
window.clearTimeout(refreshTimer);
restoreTouchScroll();
observer?.disconnect();
resizeObserver?.disconnect();
overlays.dispose();
setLayout(false);
for (const restore of restores.reverse()) restore();
style.remove();
pointer = null;
diagnose("已卸载手牌 UI");
}
session.ownResource({}, dispose);
try {
await new Promise((resolve, reject) => {
const timer = window.setTimeout(() => finish(
new Error("梦三手牌样式加载超时")
), 10000);
const finish = error => {
window.clearTimeout(timer);
finishStyleLoad = null;
style.onload = style.onerror = null;
if (error) reject(error);
else resolve();
};
finishStyleLoad = finish;
style.onload = () => finish();
style.onerror = () => finish(new Error("梦三手牌样式加载失败"));
document.head.appendChild(style);
});
if (!session.active || disposed) { dispose(); return { refresh, dispose }; }
for (const zone of adapter.zones) {
ownClass(zone, "mengsan-hand-zone-shuying");
}
for (const container of adapter.containers.filter(Boolean)) {
if (!container.hasAttribute("tabindex")) {
ownAttribute(container, "tabindex", "0");
}
ownAttribute(container, "aria-label", "梦三手牌,可横向滚动");
listen(container, "wheel", scrollWheel,
{ capture: true, passive: false });
listen(container, "touchmove", allowTouchScroll, true);
listen(container, "touchend", () => restoreTouchScroll(), true);
listen(container, "touchcancel", () => restoreTouchScroll(), true);
}
observer = new window.MutationObserver(refresh);
for (const zone of adapter.zones) {
observer.observe(zone, { childList: true });
}
if (adapter.root?.parentNode) {
observer.observe(adapter.root.parentNode, { childList: true });
}
if (window.ResizeObserver && adapter.root) {
resizeObserver = new window.ResizeObserver(refresh);
resizeObserver.observe(adapter.root);
}
if (window.PointerEvent && adapter.root) {
listen(adapter.root, "pointerdown", beginInput, true);
listen(window, "pointermove", moveInput, true);
listen(window, "pointerup", endInput, true);
listen(window, "pointercancel", endInput, true);
listen(window, "blur", () => endInput());
}
refresh();
diagnose(`已挂载手牌 UI,独立排列=${layoutEnabled}`);
return { refresh, dispose };
} catch (error) {
log("必需模块挂载失败,停止进入战斗", error);
dispose();
throw error;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { cardUpgradeLevel } from "../../cards/upgrades.js";
export function createHandOverlays(document, cardCost) {
const entries = new Map();
const setData = (node, key, value) => {
if (node.dataset[key] !== value) node.dataset[key] = value;
};
function refresh(card) {
if (!card.name) return;
let entry = entries.get(card);
if (!entry) {
const cost = document.createElement("span");
cost.className = "mengsan-hand-cost-shuying";
cost.setAttribute("role", "img");
const upgrade = document.createElement("span");
upgrade.className = "mengsan-hand-upgrade-shuying";
upgrade.setAttribute("role", "img");
entry = { cost, upgrade };
entries.set(card, entry);
}
const cost = String(cardCost(card));
setData(entry.cost, "mengsanCost", cost);
entry.cost.setAttribute("aria-label", `费用 ${cost}`);
if (entry.cost.parentNode !== card) card.appendChild(entry.cost);
const level = cardUpgradeLevel(card);
if (level) {
setData(entry.upgrade, "mengsanUpgrade", String(level));
entry.upgrade.setAttribute("aria-label", `已强化${level}次`);
if (entry.upgrade.parentNode !== card) card.appendChild(entry.upgrade);
} else {
entry.upgrade.remove();
}
}
function release(card) {
const entry = entries.get(card);
if (!entry) return;
entry.cost.remove();
entry.upgrade.remove();
entries.delete(card);
}
return {
sync(cards) {
const current = new Set(cards);
for (const card of entries.keys()) {
if (!current.has(card)) release(card);
}
for (const card of cards) refresh(card);
},
dispose() {
for (const card of entries.keys()) release(card);
},
};
}
+111
View File
@@ -0,0 +1,111 @@
.mengsan-hand-zone-shuying {
--mengsan-hand-gold: #d8ba83;
--mengsan-hand-jade: #234844;
--mengsan-hand-ink: #fff1cb;
}
.mengsan-hand-scroll-shuying {
overflow-x: auto !important;
overflow-y: hidden !important;
scrollbar-width: thin;
scrollbar-color: #d8ba83 #234844;
overscroll-behavior-x: contain;
touch-action: pan-x;
}
.mengsan-hand-scroll-shuying:focus-visible {
outline: 2px solid #d8ba83;
outline-offset: -2px;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying {
position: relative !important;
display: flex !important;
align-items: flex-end;
gap: 8px;
width: max-content !important;
min-width: 100% !important;
height: 100% !important;
left: 0 !important;
top: auto !important;
bottom: 0 !important;
margin: 0 !important;
padding: 24px 8px 6px !important;
box-sizing: border-box;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card {
position: relative !important;
flex: 0 0 auto;
width: 90px !important;
height: 110px !important;
left: auto !important;
right: auto !important;
top: auto !important;
bottom: auto !important;
margin: 0 !important;
transform: none !important;
transition: transform 120ms ease-out !important;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.selected {
transform: translateY(-18px) !important;
outline: 2px solid var(--mengsan-hand-gold);
outline-offset: -2px;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.removing,
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.display-none {
display: none !important;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card > .info,
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card > .name {
transform: none !important;
}
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card > .info > span {
display: inline !important;
}
.mengsan-hand-zone-shuying > .card > .mengsan-hand-cost-shuying,
.mengsan-hand-zone-shuying > .card > .mengsan-hand-upgrade-shuying {
position: absolute !important;
display: grid !important;
place-items: center;
z-index: 8 !important;
margin: 0 !important;
box-sizing: border-box;
pointer-events: none;
writing-mode: horizontal-tb !important;
font: 700 13px/1 "Microsoft YaHei", sans-serif;
box-shadow: 0 2px 4px #0008;
}
.mengsan-hand-zone-shuying > .card > .mengsan-hand-cost-shuying {
top: 48% !important;
left: 3px !important;
width: 24px;
height: 24px;
border: 1px solid var(--mengsan-hand-gold);
border-radius: 50%;
background: var(--mengsan-hand-jade);
color: var(--mengsan-hand-ink);
}
.mengsan-hand-zone-shuying > .card > .mengsan-hand-cost-shuying::before {
content: attr(data-mengsan-cost);
}
.mengsan-hand-zone-shuying > .card > .mengsan-hand-upgrade-shuying {
top: 4px !important;
right: 4px !important;
width: 26px;
height: 20px;
border: 1px solid #86bd85;
border-radius: 4px;
background: #214f36;
color: #f5ffea;
}
.mengsan-hand-zone-shuying > .card > .mengsan-hand-upgrade-shuying::before {
content: "+" attr(data-mengsan-upgrade);
}
@media (max-width: 640px), (max-height: 500px) {
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card {
width: 80px !important;
height: 100px !important;
}
}
@media (prefers-reduced-motion: reduce) {
.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card {
transition: none !important;
}
}
+11 -4
View File
@@ -114,11 +114,17 @@ export function mountEnemyIntent(player, session, assetBase, document,
if (deathBlow && hits !== 1) {
throw new RangeError("濒死一击只能造成一次伤害");
}
const damage = previewAttackDamage(intent, player, getTarget());
const target = getTarget();
const targets = Array.isArray(target) ? target : [target];
const damages = (targets.length ? targets : [null]).map(
current => previewAttackDamage(intent, player, current));
const damage = Math.max(...damages);
const minimum = Math.min(...damages);
const range = minimum === damage ? String(damage) :
`${minimum}~${damage}`;
const tier = deathBlow ? null :
attackIconForDamage(damage * hits);
const damageText = hits > 1 ? `${damage}×${hits}` :
String(damage);
const damageText = hits > 1 ? `${range}×${hits}` : range;
if (amount.textContent !== damageText) {
amount.textContent = damageText;
}
@@ -126,7 +132,7 @@ export function mountEnemyIntent(player, session, assetBase, document,
`${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` : ""}`);
effects.push(`${deathBlow ? "Death Blow" : "Attack"}: ${range} damage${hits > 1 ? ` × ${hits} hits` : ""}`);
}
if (hasDebuff) {
const label = intent.debuff === "脆弱" ? "Frail" :
@@ -143,6 +149,7 @@ export function mountEnemyIntent(player, session, assetBase, document,
effects.push("Unknown");
}
const label = effects.join(", ");
badge.title = "攻击与负面效果作用于所有存活敌对角色;伤害按各目标状态计算,护甲另行抵扣。";
if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label);
badge.hidden = false;
};
+24 -8
View File
@@ -2,6 +2,8 @@ import { openCardLibrary } from "../card-library.js";
import { askMenu, openModeSelection } from "../navigation.js";
import { ui, get } from "../../../../../noname.js";
import config from "../../config.js";
import { getRelic } from "../../relics/definitions.js";
import { renderBonds } from "../bonds.js";
import { getSelectableNodes } from "../../progression/state.js";
// Original vector pictograms; no external image/font requests.
@@ -88,15 +90,16 @@ const getTraversedEdgeIds = map => {
return traversed;
};
export const showMap = run => new Promise(resolve => {
export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
const act = config.acts[run.actIndex];
const selectable = getSelectableNodes(run);
const selectableIds = new Set(selectable.map(node => node.id));
const traversedEdgeIds = getTraversedEdgeIds(run.map);
const { overlay, panel, header } = createMapOverlay(act.name);
let closeCardLibrary = null;
let savingBonds = false;
const finish = result => {
if (overlay.dataset.resolved) return;
if (overlay.dataset.resolved || savingBonds) return;
overlay.dataset.resolved = "true";
document.removeEventListener("keydown", handleEscape);
closeCardLibrary?.();
@@ -173,7 +176,7 @@ export const showMap = run => new Promise(resolve => {
exitCopy.append(exitName, exitDescription);
exitButton.appendChild(exitCopy);
exitButton.addEventListener("click", async () => {
if (exitButton.disabled) return;
if (exitButton.disabled || savingBonds) return;
exitButton.disabled = true;
try {
const result = await askMenu("返回模式选择?", "保留上次完成节点的存档,未完成内容不会提交。", [
@@ -189,6 +192,7 @@ export const showMap = run => new Promise(resolve => {
menuFooter.appendChild(exitButton);
const setMenuOpen = open => {
if (savingBonds) return;
if (open) renderMainMenu();
menuLayer.hidden = !open;
menuTrigger.setAttribute("aria-expanded", String(open));
@@ -214,7 +218,7 @@ export const showMap = run => new Promise(resolve => {
return button;
};
const showMenuPage = (title, subtitle, render) => {
const showMenuPage = (title, subtitle, render, focusSelector = null) => {
menuTitle.textContent = title;
menuContent.replaceChildren();
const intro = ui.create.div(".mengsan-map-menu-page-intro-shuying", menuContent);
@@ -229,7 +233,11 @@ export const showMap = run => new Promise(resolve => {
back.appendChild(label);
back.addEventListener("click", renderMainMenu);
menuContent.appendChild(back);
requestAnimationFrame(() => back.focus());
requestAnimationFrame(() => {
const target = focusSelector ?
menuContent.querySelector(focusSelector) : back;
target?.focus({ preventScroll: Boolean(focusSelector) });
});
};
const renderBackpack = parent => {
@@ -260,8 +268,9 @@ export const showMap = run => new Promise(resolve => {
}
values.forEach(value => {
const row = ui.create.div(".mengsan-map-inventory-row-shuying", list);
createText("", get.translation(value) || value, row);
createText("", "已获得", row);
const relic = getRelic(value);
createText("", relic?.name || get.translation(value) || value, row);
createText("", relic?.description || "已获得", row);
});
};
const supports = run.player.supports || [];
@@ -273,7 +282,7 @@ export const showMap = run => new Promise(resolve => {
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.items, "尚未获得遗物。");
appendNames("永久技能", run.player.permanentSkills, "尚未获得永久技能。");
};
@@ -289,6 +298,13 @@ export const showMap = run => new Promise(resolve => {
menuContent.replaceChildren();
createMenuAction({ icon: "map", name: "继续行军", description: "关闭菜单并返回当前路线", onClick: () => setMenuOpen(false) });
createMenuAction({ icon: "bag", name: "行囊", description: `查看牌组、道具与永久技能 · ${run.player.deck.length}张牌`, onClick: () => showMenuPage("行囊", "本次征程携带的资源", renderBackpack) });
createMenuAction({ icon: "book", name: "羁绊", description: "查看羁绊等级、成长进度与助战角色", onClick: () => showMenuPage("羁绊", "本次征程的同行之谊 · 仅可指定一名助战", parent => renderBonds(parent, run, saveRun, busy => {
savingBonds = busy;
closeMenu.disabled = busy;
exitButton.disabled = busy;
const back = menuContent.querySelector(".mengsan-map-menu-back-shuying");
if (back) back.disabled = busy;
}), ".mengsan-bonds-shuying button") });
createMenuAction({ icon: "book", name: "图鉴", description: "查看已发现的敌人、事件与奖励 · 开发中", onClick: () => showMenuPage("图鉴", "征程见闻与收集记录", parent => renderPlaceholder(parent, "后续将收录已发现的节点、敌人和奖励。")) });
createMenuAction({ icon: "record", name: "征程记录", description: `已通过 ${run.statistics?.completedNodes || 0} 个节点`, onClick: () => showMenuPage("征程记录", "本次征程的阶段统计", parent => {
const list = ui.create.div(".mengsan-map-record-list-shuying", parent);
+81
View File
@@ -0,0 +1,81 @@
import { ui } from "../../../../noname.js";
import { heldRelics } from "../relics/definitions.js";
export function mountRelics(session, run, battleRelics) {
let dialog = null, list = null, previousFocus = null, disposed = false;
const relics = heldRelics(run);
const button = document.createElement("button");
button.type = "button";
button.className = "mengsan-battle-piles-button-shuying " +
"mengsan-battle-relics-button-shuying";
button.textContent = `遗物 ${relics.length}`;
button.setAttribute("aria-haspopup", "dialog");
ui.window.appendChild(button);
const close = () => {
if (!dialog) return;
const current = dialog;
dialog = null; list = null;
if (current.open) current.close();
current.remove();
if (previousFocus?.isConnected) previousFocus.focus();
};
const refresh = () => {
if (!list) return;
list.replaceChildren();
if (!relics.length) {
const empty = document.createElement("p");
empty.textContent = "本次征程尚未获得遗物。";
list.appendChild(empty);
}
for (const relic of relics) {
const row = document.createElement("section");
const name = document.createElement("h3");
name.textContent = relic.name;
const description = document.createElement("p");
description.textContent = relic.description;
const status = document.createElement("small");
status.textContent = battleRelics.status(relic);
row.append(name, description, status);
list.appendChild(row);
}
};
button.addEventListener("click", () => {
if (disposed || !session.active || dialog) return;
previousFocus = document.activeElement;
dialog = document.createElement("dialog");
const currentDialog = dialog;
dialog.className = "mengsan-deck-dialog-shuying " +
"mengsan-relic-dialog-shuying";
dialog.setAttribute("aria-label", "遗物(道具)");
const header = document.createElement("header");
header.className = "ms-deck-header";
const title = document.createElement("h2");
title.textContent = "遗物(道具)";
const dismiss = document.createElement("button");
dismiss.type = "button";
dismiss.className = "ms-deck-button";
dismiss.textContent = "关闭";
dismiss.addEventListener("click", close);
header.append(title, dismiss);
list = document.createElement("div");
list.className = "mengsan-relic-list-shuying";
dialog.append(header, list);
dialog.addEventListener("cancel", event => {
event.preventDefault(); close();
});
dialog.addEventListener("close", () => {
if (dialog === currentDialog) close();
});
dialog.addEventListener("keydown", event => event.stopPropagation());
refresh();
document.body.appendChild(dialog);
try { dialog.showModal(); dismiss.focus(); }
catch (error) { close(); throw error; }
});
const dispose = () => {
if (disposed) return;
disposed = true; close(); button.remove();
};
session.ownResource(button, dispose);
return { dispose, refresh };
}
+81
View File
@@ -0,0 +1,81 @@
import { get } from "../../../../noname.js";
import { cardUpgradeRule } from "../cards/upgrades.js";
import { getRelic } from "../relics/definitions.js";
import {
chooseCardDialog, chooseRelicDialog, chooseVictoryOptions,
RETURN_TO_VICTORY,
} from "./reward-ui.js";
const upgradeDescription = choice => {
const card = choice.card;
const rule = cardUpgradeRule(card.name);
const damage = card.name === "sha" ? 6 : 8;
const suit = get.translation(card.suit);
return `${suit}${card.number} · 伤害 ${damage} → ${rule.damage}` +
(rule.vulnerable ? `;易伤 2 → ${rule.vulnerable} 层` : "") +
"。费用不变,最多强化一次。";
};
export async function chooseRewardPackage(choices, {
rewardPackage: pack, fixedRewards = [],
}) {
let cardId = null, relicId = null;
const upgradeChoices = pack.upgradeChoices.map(card => ({
id: card.id, card,
}));
const ready = () => (!upgradeChoices.length || cardId !== null) &&
(!pack.relicChoices.length || relicId !== null);
const upgrade = {
id: "upgrade", label: "强化一张牌",
description: upgradeChoices.length
? "从个人牌库中择一磨砺,查看强化前后的变化。"
: "当前没有可强化卡牌,此项无需选择。",
isDisabled: () => !upgradeChoices.length,
async choose() {
const selected = await chooseCardDialog(upgradeChoices, {
title: "磨砺 · 强化一张牌", skipLabel: "返回奖励",
description: "择一磨砺,锋芒更盛。只强化你选中的一张牌。",
describeChoice: upgradeDescription, actionLabel: "强化此牌",
});
if (selected !== null) {
cardId = selected;
const choice = upgradeChoices.find(card => card.id === selected);
upgrade.label = `已选强化 · ${get.translation(choice.card.name)}`;
upgrade.description = upgradeDescription(choice);
}
return RETURN_TO_VICTORY;
},
};
const relic = {
id: "relic", label: "遗物 · 三选一",
description: pack.relicChoices.length
? "检视本次随机珍藏,择一伴你踏上下一段征途。"
: "已持有奖励池中的全部遗物,此项无需选择。",
isDisabled: () => !pack.relicChoices.length,
async choose() {
const selected = await chooseRelicDialog(pack.relicChoices, {
skipLabel: "返回奖励", actionLabel: "携此遗物",
});
if (selected !== null) {
relicId = selected;
const choice = pack.relicChoices.find(item => item.id === selected);
const definition = getRelic(choice.relic);
relic.label = `已选遗物 · ${definition.name}`;
relic.description = definition.description;
}
return RETURN_TO_VICTORY;
},
};
return chooseVictoryOptions([upgrade, relic, {
id: "claim-package", label: "领取全部奖励",
description: "完成以上选择后,金币、强化、遗物与固定卡牌一并入囊。",
isDisabled: () => !ready(),
choose: () => ({ cardId, relicId }),
}], {
title: "初识的卢", allowSkip: false,
rewardLabel: "固定收获 · 完成两项选择后领取",
description: `固定获得 ${pack.gold} 金币与${fixedRewards.map(
reward => `【${reward.name}】`).join("、")}。` +
"另可强化一张牌,并从随机遗物中择一件。",
});
}
+100 -19
View File
@@ -1,5 +1,7 @@
import { lib, get } from "../../../../noname.js";
import { cardCost } from "../battle/combat-rules.js";
import { cardUpgradeRule } from "../cards/upgrades.js";
import { getRelic } from "../relics/definitions.js";
import { chooseButtons } from "./flow-ui.js";
const element = (tag, className, text, parent) => {
@@ -23,23 +25,57 @@ 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 modeImage = info?.image;
const localArt = typeof modeImage === "string" &&
/^ext:术樱包\/mengsan\/assets\/cards\/[a-z0-9_]+\.png$/.test(modeImage)
? modeImage.replace(/^ext:/, "extension/") : null;
const image = localArt ? `${lib.assetURL || ""}${localArt}` :
/^[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 || "暂无卡牌介绍。"),
description: plainText(cardUpgradeRule(name) && info?.cardPrompt
? info.cardPrompt(choice.card || { name })
: lib.translate[`${name}_info`] || choice.description ||
"暂无卡牌介绍。"),
};
};
const restoreFocus = previous => { if (previous?.isConnected) previous.focus(); };
const RETURN_TO_VICTORY = Symbol("return to victory choice");
export 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 = "跳过" } = {}) {
export function chooseCardDialog(choices, options = {}) {
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 chooseTilesDialog(choices, options, viewCard);
}
export function chooseRelicDialog(choices, options = {}) {
if (!Array.isArray(choices) || !choices.length || choices.some(choice =>
!choice?.id || !getRelic(choice.relic))) {
return Promise.reject(new TypeError("遗物候选无效"));
}
return chooseTilesDialog(choices, {
title: "择一件遗物",
description: `${choices.length} 件珍藏,择一伴你远行。`,
actionLabel: "携此遗物",
...options,
}, choice => {
const relic = getRelic(choice.relic);
return { name: relic.name, type: "遗物", image: null, cost: null,
description: relic.description };
});
}
function chooseTilesDialog(choices, {
title = "选择一张牌", allowSkip = true, skipLabel = "跳过",
description = "择一入阵,余者留于身后", describeChoice,
actionLabel = "选择此牌",
} = {}, viewChoice) {
return new Promise((resolve, reject) => {
const previousFocus = document.activeElement;
const dialog = element("dialog", "mengsan-card-choice-dialog-shuying", null);
@@ -48,7 +84,7 @@ export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip
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);
element("p", "", description, 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;
@@ -61,12 +97,15 @@ export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip
resolve(id);
};
for (const choice of choices) {
const view = viewCard(choice);
const view = viewChoice(choice);
if (describeChoice) view.description = describeChoice(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.setAttribute("aria-label", `${view.name},${view.type},${view.description}。${actionLabel}`);
card.addEventListener("click", () => finish(choice.id));
element("span", "mengsan-card-choice-cost-shuying", String(view.cost), card);
if (view.cost !== null) {
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) {
@@ -77,6 +116,7 @@ export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip
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);
element("span", "mengsan-reward-tile-action-shuying", actionLabel, card);
}
if (allowSkip) {
const skip = element("button", "mengsan-card-choice-skip-shuying", skipLabel, stage);
@@ -94,7 +134,11 @@ export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip
// 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 } = {}) {
export function chooseVictoryOptions(options, {
title = "战后抉择", allowSkip = true,
description = "此役已定,选择下一段征途的收获。",
rewardLabel = "可选战利品",
} = {}) {
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"));
}
@@ -107,10 +151,19 @@ export function chooseVictoryOptions(options, { title = "战后抉择", allowSki
element("span", "mengsan-reward-overline-shuying", "梦三 · 战后", intro);
element("span", "mengsan-victory-seal-shuying", "胜", intro);
element("h2", "", title, intro);
element("p", "", "此役已定,选择下一段征途的收获。", intro);
element("p", "", description, intro);
const board = element("div", "mengsan-victory-options-shuying", null, stage);
element("span", "mengsan-victory-eyebrow-shuying", "可选战利品", board);
element("span", "mengsan-victory-eyebrow-shuying", rewardLabel, board);
let settled = false, choosing = false;
const entries = [];
const refresh = () => {
for (const { option, entry, label, detail } of entries) {
label.textContent = option.label;
detail.textContent = option.description || "";
entry.disabled = Boolean(option.isDisabled?.());
entry.setAttribute("aria-label", `${option.label}。${option.description || ""}`);
}
};
const finish = id => {
if (settled) return;
settled = true;
@@ -125,19 +178,20 @@ export function chooseVictoryOptions(options, { title = "战后抉择", allowSki
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);
const label = element("strong", "", option.label, copy);
const detail = element("small", "", option.description || "", copy);
entries.push({ option, entry, label, detail });
element("span", "mengsan-victory-option-arrow-shuying", "›", entry).setAttribute("aria-hidden", "true");
entry.addEventListener("click", async () => {
if (settled || choosing) return;
if (settled || choosing || entry.disabled) 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) {
refresh();
dialog.showModal();
entry.disabled = false;
choosing = false;
entry.focus();
} else finish(selected);
@@ -147,6 +201,7 @@ export function chooseVictoryOptions(options, { title = "战后抉择", allowSki
}
});
}
refresh();
if (allowSkip) {
const skip = element("button", "mengsan-victory-skip-shuying", "放弃本次选择", stage);
skip.type = "button";
@@ -155,15 +210,36 @@ export function chooseVictoryOptions(options, { title = "战后抉择", allowSki
dialog.addEventListener("cancel", event => event.preventDefault());
dialog.addEventListener("keydown", event => event.stopPropagation());
document.documentElement.appendChild(dialog);
try { dialog.showModal(); board.querySelector("button")?.focus(); }
try {
dialog.showModal();
entries.find(({ entry }) => !entry.disabled)?.entry.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);
export function chooseBattleReward(choices, { fixedRewards = [] } = {}) {
if (!Array.isArray(choices)) return Promise.resolve(null);
const fixedDescription = fixedRewards.map(reward =>
`${reward.name || reward.id}${reward.description
? `:${plainText(reward.description)}` : ""}`
).join(";");
if (!choices.length) {
if (!fixedRewards.length) return Promise.resolve(null);
return chooseVictoryOptions([{
id: "claim-fixed-rewards",
label: "领取固定奖励",
description: fixedDescription,
choose: () => null,
}], {
title: "过关奖励",
description: "此役已定,以下固定奖励将全部获得。",
rewardLabel: "固定战利品",
allowSkip: false,
});
}
const gold = choices.find(choice => choice.kind === "gold");
const rewards = choices.filter(choice => choice.kind !== "gold");
const options = [];
@@ -186,5 +262,10 @@ export function chooseBattleReward(choices) {
},
});
}
return chooseVictoryOptions(options);
return chooseVictoryOptions(options, {
title: "过关奖励",
description: fixedRewards.length
? `固定奖励:${fixedDescription}。另可选择一项战利品。`
: "此役已定,可选择一项战利品,也可放弃本次选择。",
});
}
+59 -19
View File
@@ -1100,6 +1100,11 @@ button.mengsan-card-choice-tile-shuying:hover, button.mengsan-card-choice-tile-s
.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; }
.mengsan-reward-tile-action-shuying {
display: block !important; margin-top: 12px; padding: 10px 8px;
border-top: 1px solid #9e8261; color: #f4d7a0;
font: 700 16px/1.4 "KaiTi", "STKaiti", serif;
}
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;
@@ -1366,24 +1371,59 @@ body.mengsan-battle-ui-shuying :is(
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;
.mengsan-battle-relics-button-shuying { top: 162px; }
.mengsan-relic-dialog-shuying { max-width: 620px; }
.mengsan-relic-list-shuying { overflow-y: auto; padding: 12px 0; }
.mengsan-relic-list-shuying section {
padding: 14px 0;
border-bottom: 1px solid #b9975d55;
}
.handcards > .card[data-mengsan-cost] > .mengsan-hand-cost-shuying::before {
content: attr(data-mengsan-cost);
.mengsan-relic-list-shuying h3 { margin: 0 0 8px; color: #ffe0a1; }
.mengsan-relic-list-shuying p { margin: 0 0 6px; line-height: 1.7; }
.mengsan-relic-list-shuying small { color: #d1bd9c; }
@media (max-height: 500px) {
.mengsan-battle-relics-button-shuying { top: 122px; }
}
.mengsan-bonds-shuying {
display: grid;
gap: 12px;
}
.mengsan-bond-card-shuying {
border: 1px solid rgba(116, 89, 56, .35);
border-radius: 10px;
padding: 16px;
overflow-wrap: anywhere;
}
.mengsan-bonds-shuying p {
margin: 8px 0;
line-height: 1.6;
}
.mengsan-bonds-shuying h3 {
margin: 0 0 8px;
}
.mengsan-bonds-shuying progress {
width: 100%;
accent-color: #8c6a34;
}
.mengsan-bonds-shuying button {
min-height: 44px;
padding: 8px 16px;
border: 1px solid currentColor;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.mengsan-bonds-shuying button[aria-pressed="true"] {
background: rgba(140, 106, 52, .18);
font-weight: bold;
}
.mengsan-bonds-shuying button:disabled {
opacity: .5;
cursor: not-allowed;
}
.mengsan-bonds-shuying button:focus-visible {
outline: 2px solid #8c6a34;
outline-offset: 3px;
}
+3
View File
@@ -16,8 +16,10 @@ EXCLUDE_DIRS = {
"dist",
"tools",
"__pycache__",
"verification",
}
EXCLUDE_FILES = {".gitignore", "log.txt"}
EXCLUDE_EXTENSIONS = {".md", ".mjs"}
def sha256_file(path: Path) -> str:
@@ -33,6 +35,7 @@ def should_include(path: Path, root: Path) -> bool:
return (
path.is_file()
and relative.name not in EXCLUDE_FILES
and path.suffix.lower() not in EXCLUDE_EXTENSIONS
and not any(part in EXCLUDE_DIRS for part in relative.parts)
)