mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-02 01:40:04 +08:00
fixed # 更新刘备配置和奖励
This commit is contained in:
+4
-3
@@ -6,11 +6,12 @@ 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
|
||||
@@ -20,6 +20,7 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding,
|
||||
: 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;
|
||||
|
||||
@@ -1,10 +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];
|
||||
@@ -49,7 +54,11 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a
|
||||
// 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] : [];
|
||||
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;
|
||||
});
|
||||
},
|
||||
@@ -57,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";
|
||||
@@ -79,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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { relicRewardIds } from "../../../relics/definitions.js";
|
||||
|
||||
export const rewardPools = {
|
||||
"act1.pool.liubeiOpening.relics": [...relicRewardIds],
|
||||
"act1.pool.chest": [
|
||||
...relicRewardIds,
|
||||
"shared.reward.item.handCharm",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- 苏双&张世平为一名男性蜀势力武将,暂设无技能、4/4、4张初始手牌,可自行调整。
|
||||
- `discardEquipment` 弃置指定单位装备区的指定牌;已不在装备区则不重复弃置,不影响其他装备。
|
||||
- `fixedRewards` 固定获得梅花5的卢一张,加入征程个人牌库;此关关闭随机三选一。奖励与节点完成一起事务提交,重试不会重复发牌。
|
||||
- `rewardPackage` 额外固定获得400金币、手动强化1张牌、随机遗物三选一。遗物候选复用当前新遗物奖励池,排除已持有项,随机结果在打开界面前保存;不足三件时显示剩余候选。强化只允许选择尚未满级且没有永恒词缀的可强化牌;没有候选时明确提示并跳过该项。两项选择完成后,与金币、的卢及节点完成一起提交,重试或恢复存档不会重复领取。遗物池耗尽时同样提示并跳过遗物选择。
|
||||
- 固定奖励与选择奖励共用过关奖励界面,以“固定战利品”/“可选战利品”等文字区分;固定奖励显示名称和说明,点击“领取固定奖励”后继续结算,不能放弃。若同时配置两类奖励,固定奖励全部获得,候选奖励仍择一领取或放弃。
|
||||
- 初始士兵/支援手牌仍来自公共牌堆;弃掉的敌方的卢走原生弃牌流程,胜利所得的卢是持久牌库新卡,并非再从弃牌区拾取。
|
||||
- 提前击败敌人允许正常获胜,此时未到第四次回合的支援不会触发。不强行等待、不添加免死。
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# 梦三手牌 UI 子系统规划大纲
|
||||
|
||||
## 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 时是否提示玩家切回原生手牌显示
|
||||
+38
-5
@@ -1,8 +1,12 @@
|
||||
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 } from "./relics/definitions.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";
|
||||
@@ -51,6 +55,7 @@ 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";
|
||||
@@ -201,14 +206,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: "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, {
|
||||
@@ -218,6 +228,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") },
|
||||
@@ -448,12 +463,15 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
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();
|
||||
@@ -473,6 +491,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
"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);
|
||||
@@ -556,8 +575,17 @@ const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({
|
||||
return playDialogue(pending.victoryDialogue,
|
||||
{ run: pending.base, title: "战后剧情" });
|
||||
},
|
||||
settlement:createBattleSettlement({store:modeStorage, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct}),
|
||||
chooseReward: (choices, options) => chooseBattleReward(choices, {
|
||||
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,
|
||||
@@ -839,6 +867,7 @@ 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级。助战角色在行军菜单中选择。" },
|
||||
@@ -1088,6 +1117,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: "攻击造成的伤害按力量层数增加。",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -254,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 ?
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
| 千年积木 | `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,实际尺寸和视觉风格在资源制作时确定。
|
||||
图片仅负责外观,不承载名称、效果或触发计数。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 梦三遗物(道具)
|
||||
|
||||
本目录负责遗物定义、持有规则与战斗效果管理。
|
||||
已实现五件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。
|
||||
已实现十三件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。
|
||||
|
||||
## 已实现的遗物
|
||||
|
||||
@@ -13,6 +13,22 @@
|
||||
| 千年积木 | 每场战斗首次实际生命减少且仍存活时摸 3 张;纯护甲损失不触发 |
|
||||
| 摆动球 | 每场战斗第 3、6、9……个自身回合开始额外摸 1 张 |
|
||||
| 束带 | 原有手牌上限 +1,重复获得不叠加 |
|
||||
| 餐券 | 进入商店时回复 15 点生命,每个商店节点一次 |
|
||||
| 草莓 | 获得时生命上限 +7,当前生命不变,重复获得不叠加 |
|
||||
| 弹珠袋 | 开战时给予当时所有存活敌人 1 层易伤 |
|
||||
| 灯笼 | 首个自身回合可用费用 +1,费用上限不变,下回合恢复正常 |
|
||||
| 红面具 | 开战时给予当时所有存活敌人 1 层虚弱 |
|
||||
| 皇家枕头 | 选择休息回血时额外恢复 15;现有基础 8,总计 23 |
|
||||
| 节日拉炮 | 开战时对当时所有存活敌人造成 9 点伤害 |
|
||||
| 金刚杵 | 开战时获得 1 点力量,只在当前战斗有效 |
|
||||
|
||||
虚弱使攻击伤害减少 25%(向下取整),自身回合结束减少 1 层;
|
||||
力量使玩家攻击牌伤害增加对应点数,意图攻击沿用原有力量/虚弱计算。
|
||||
节日拉炮是非卡牌伤害,正常消耗目标护甲,不享受力量、易伤、虚弱的攻击修正。
|
||||
开场效果先摸牌和施加状态,再结算节日拉炮;仅影响开战时的敌人,不影响后续援军。
|
||||
餐券在打开商店前保存回血和节点记录,读档不会重复触发;
|
||||
皇家枕头不影响磨砺、商店治疗或其他回复效果。所有回复均不超过生命上限。
|
||||
草莓若在角色生命尚未初始化时获得,会保存待应用加成,初始化后只应用一次。
|
||||
|
||||
遗物互相独立:同时持有两种戒指时,开场和前三回合效果分别触发。
|
||||
所有回合计数按玩家自身实际开始的回合计算,不计敌方和支援回合,
|
||||
@@ -21,7 +37,7 @@
|
||||
|
||||
### 获得、保存和查看
|
||||
|
||||
- 五件新遗物加入共享 Boss 奖励池及第一章宝箱奖励池;
|
||||
- 新遗物通过自动生成的遗物奖励列表加入共享 Boss 和第一章宝箱奖励池;
|
||||
后续章节使用共享 Boss 奖励池的宝箱也可出现它们。
|
||||
- 角色默认不携带新遗物;未新增商店价格或精英掉落规则。
|
||||
- 持有记录沿用 `run.player.items`,已持有遗物会从随机奖励中排除。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { heldRelics } from "./definitions.js";
|
||||
|
||||
export function createRelicBattle(run, { active, draw, log }) {
|
||||
export function createRelicBattle(run, { active, draw, log, effect }) {
|
||||
const relics = heldRelics(run);
|
||||
let started = false, turns = 0, firstLoss = false;
|
||||
let openingHandBonus = 0, pendingHpDraw = null;
|
||||
@@ -24,6 +24,15 @@ export function createRelicBattle(run, { active, draw, log }) {
|
||||
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;
|
||||
@@ -35,6 +44,11 @@ export function createRelicBattle(run, { active, draw, log }) {
|
||||
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" &&
|
||||
@@ -63,9 +77,12 @@ export function createRelicBattle(run, { active, draw, log }) {
|
||||
if (relic.effect === "periodicTurnDraw") {
|
||||
return `回合计数:${turns % relic.turns}/${relic.turns}`;
|
||||
}
|
||||
if (relic.effect === "openingDraw") {
|
||||
if (relic.effect.startsWith("opening")) {
|
||||
return started ? "本场已触发" : "等待战斗开始";
|
||||
}
|
||||
if (relic.effect === "firstTurnEnergy") {
|
||||
return turns ? "本场已触发" : "等待首个自身回合";
|
||||
}
|
||||
return "持续持有";
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,30 @@ const entries = [
|
||||
{ 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(
|
||||
@@ -39,16 +63,31 @@ export function canAcquireRelic(run, 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);
|
||||
const relic = getRelic(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}`);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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("、")}。` +
|
||||
"另可强化一张牌,并从随机遗物中择一件。",
|
||||
});
|
||||
}
|
||||
+55
-11
@@ -1,6 +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) => {
|
||||
@@ -42,14 +43,39 @@ const viewCard = choice => {
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -58,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;
|
||||
@@ -71,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) {
|
||||
@@ -87,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);
|
||||
@@ -125,6 +155,15 @@ export function chooseVictoryOptions(options, {
|
||||
const board = element("div", "mengsan-victory-options-shuying", null, stage);
|
||||
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;
|
||||
@@ -139,19 +178,20 @@ export function chooseVictoryOptions(options, {
|
||||
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);
|
||||
@@ -161,6 +201,7 @@ export function chooseVictoryOptions(options, {
|
||||
}
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
if (allowSkip) {
|
||||
const skip = element("button", "mengsan-victory-skip-shuying", "放弃本次选择", stage);
|
||||
skip.type = "button";
|
||||
@@ -169,7 +210,10 @@ export function chooseVictoryOptions(options, {
|
||||
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); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,7 @@ EXCLUDE_DIRS = {
|
||||
"__pycache__",
|
||||
}
|
||||
EXCLUDE_FILES = {".gitignore", "log.txt"}
|
||||
EXCLUDE_EXTENSIONS = {".md", ".mjs"}
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
@@ -33,6 +34,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)
|
||||
)
|
||||
|
||||
|
||||
+95
-1
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { grantRelic, canAcquireRelic, getRelic, heldRelics,
|
||||
relicRewardIds } from "../mengsan/relics/definitions.js";
|
||||
relicRewardIds, initializeRelicMaxHp } from "../mengsan/relics/definitions.js";
|
||||
import { applyRoomRelics, restRecovery } from "../mengsan/relics/rooms.js";
|
||||
import { createOpeningEffects, createRelicCombatSkills }
|
||||
from "../mengsan/relics/combat.js";
|
||||
import { createRelicBattle, createRelicSkills,
|
||||
applyBattleEndRelics } from "../mengsan/relics/battle.js";
|
||||
import { canAcquireReward, getRandomRewardChoices }
|
||||
@@ -32,6 +35,97 @@ const runtime = run => {
|
||||
return { battle, draws, logs, stop: () => { running = false; } };
|
||||
};
|
||||
|
||||
test("strawberry raises max HP once without healing, including early grants", () => {
|
||||
const run = newRun();
|
||||
grant(run, "strawberry", "strawberry");
|
||||
assert.equal(run.player.maxHp, 57);
|
||||
assert.equal(run.player.hp, 20);
|
||||
run.player.maxHp = null;
|
||||
run.player.items = [];
|
||||
grant(run, "strawberry");
|
||||
run.player.maxHp = 50;
|
||||
initializeRelicMaxHp(run);
|
||||
initializeRelicMaxHp(run);
|
||||
assert.equal(run.player.maxHp, 57);
|
||||
});
|
||||
|
||||
test("shop entrance and rest healing cap HP and survive reload without repeats", () => {
|
||||
const run = newRun();
|
||||
grant(run, "meal_ticket", "regal_pillow");
|
||||
const shop = { id: "shop1", type: "shop" };
|
||||
assert.equal(applyRoomRelics(run, shop, "enter").recovered, 15);
|
||||
const restored = structuredClone(run);
|
||||
assert.equal(applyRoomRelics(restored, shop, "enter"), null);
|
||||
const rest = { id: "rest1", type: "rest" };
|
||||
assert.equal(restRecovery(run), 23);
|
||||
assert.equal(applyRoomRelics(run, rest, "upgrade"), null);
|
||||
assert.equal(applyRoomRelics(run, rest, "heal").recovered, 15);
|
||||
assert.equal(run.player.hp, 50);
|
||||
assert.equal(applyRoomRelics(run, rest, "heal"), null);
|
||||
assert.equal(restRecovery(newRun()), 8);
|
||||
});
|
||||
|
||||
test("opening effects target enemies, apply buffs before damage, and lantern is temporary", async () => {
|
||||
const run = newRun();
|
||||
grant(run, "festive_poppers", "bag_of_marbles", "red_mask", "vajra", "lantern");
|
||||
const hits = [];
|
||||
const actor = camp => ({ storage: { mengsanCamp_shuying: camp },
|
||||
isAlive: () => true, addSkill() {}, markSkill() {},
|
||||
damage(amount, source, nature) {
|
||||
hits.push({ amount, source, nature,
|
||||
vulnerable: this.storage.mengsanVulnerable_shuying,
|
||||
weak: this.storage.mengsanWeak_shuying });
|
||||
return Promise.resolve();
|
||||
} });
|
||||
const owner = actor("player"), enemy = actor("enemy"), ally = actor("ally");
|
||||
owner.storage.mengsanEnergy_shuying = 3;
|
||||
owner.storage.mengsanMaxEnergy_shuying = 3;
|
||||
let refreshes = 0;
|
||||
const battle = createRelicBattle(run, { active: () => true,
|
||||
draw: async () => {}, log: () => {},
|
||||
effect: createOpeningEffects({ players: [owner, enemy, ally] },
|
||||
owner, () => { refreshes++; }) });
|
||||
await battle.start();
|
||||
await battle.start();
|
||||
assert.equal(hits.length, 1);
|
||||
assert.equal(hits[0].amount, 9);
|
||||
assert.equal(hits[0].vulnerable, 1);
|
||||
assert.equal(hits[0].weak, 1);
|
||||
assert.equal(hits[0].nature, "nocard");
|
||||
assert.equal(owner.storage.mengsanStrength_shuying, 1);
|
||||
assert.equal(ally.storage.mengsanWeak_shuying, undefined);
|
||||
const first = {};
|
||||
await battle.turnStart(first);
|
||||
await battle.turnStart(first);
|
||||
assert.equal(owner.storage.mengsanEnergy_shuying, 4);
|
||||
assert.equal(owner.storage.mengsanMaxEnergy_shuying, 3);
|
||||
owner.storage.mengsanEnergy_shuying = owner.storage.mengsanMaxEnergy_shuying;
|
||||
await battle.turnStart({});
|
||||
assert.equal(owner.storage.mengsanEnergy_shuying, 3);
|
||||
assert.equal(refreshes, 1);
|
||||
});
|
||||
|
||||
test("card attacks add owner strength before weakness and skip scripted damage", async () => {
|
||||
const owner = { storage: { mengsanStrength_shuying: 1,
|
||||
mengsanWeak_shuying: 1 } };
|
||||
const skills = createRelicCombatSkills(() => ({ session: { active: true } }),
|
||||
() => owner);
|
||||
const attack = skills.mengsan_relic_attack_shuying;
|
||||
const event = { source: owner, card: {}, num: 6 };
|
||||
assert.equal(attack.filter(event), true);
|
||||
await attack.content({}, event);
|
||||
assert.equal(event.num, 5);
|
||||
assert.equal(attack.filter({ ...event, card: null }), false);
|
||||
assert.equal(attack.filter({ ...event, mengsanScriptedSkill_shuying: true }), false);
|
||||
const enemy = { storage: { mengsanWeak_shuying: 1 },
|
||||
removeSkill(skill) { assert.equal(skill, "mengsan_weak_shuying"); } };
|
||||
const hit = { source: enemy, card: {}, num: 9 };
|
||||
await attack.content({}, hit);
|
||||
assert.equal(hit.num, 6);
|
||||
await skills.mengsan_weak_shuying.content({}, {}, enemy);
|
||||
assert.equal(enemy.storage.mengsanWeak_shuying, 0);
|
||||
});
|
||||
|
||||
test("relics use existing items, reject duplicates and preserve belt saves", () => {
|
||||
const run = newRun();
|
||||
assert.equal(grantRelic(run, id("burning_blood")), true);
|
||||
|
||||
Reference in New Issue
Block a user