mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-13 18:46:17 +08:00
fixed # 更新刘备配置和奖励
This commit is contained in:
+4
-3
@@ -6,11 +6,12 @@ task_plan.md
|
|||||||
dist/manifest.json
|
dist/manifest.json
|
||||||
|
|
||||||
tools/__pycache__/
|
tools/__pycache__/
|
||||||
tools/test_update.mjs
|
tools/*.mjs
|
||||||
tools/test_intent.mjs
|
|
||||||
|
|
||||||
/mengsan/.planning/
|
/mengsan/.planning/
|
||||||
/mengsan/verification
|
/mengsan/verification
|
||||||
/mengsan/assets/sts2/
|
/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), {
|
: await chooseReward(copy(job.pending.choices), {
|
||||||
boss: job.pending.boss,
|
boss: job.pending.boss,
|
||||||
fixedRewards: copy(job.pending.fixedRewards || []),
|
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");
|
if (job.pending.outcome !== "defeat" && job.pending.choices.length && job.choiceId !== null && !job.pending.choices.some(c => c.id === job.choiceId)) throw new Error("Invalid reward selection");
|
||||||
job.hasChoice = true;
|
job.hasChoice = true;
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
// Candidate settlement adapter. Dependencies are the current mode/state reward functions.
|
// Candidate settlement adapter. Dependencies are the current mode/state reward functions.
|
||||||
import { applyBattleEndRelics } from "../relics/battle.js";
|
import { applyBattleEndRelics } from "../relics/battle.js";
|
||||||
import { settleBondBattle } from "../bonds/state.js";
|
import { settleBondBattle } from "../bonds/state.js";
|
||||||
|
import { prepareRewardPackage, applyRewardPackage }
|
||||||
|
from "../progression/reward-package.js";
|
||||||
|
|
||||||
const copy = value => JSON.parse(JSON.stringify(value));
|
const copy = value => JSON.parse(JSON.stringify(value));
|
||||||
const idFor = (run, node) => JSON.stringify([run.runId, run.actIndex, node.id]);
|
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];
|
const readRun = () => store.read()[config.saveKey];
|
||||||
function current(storage, runId) {
|
function current(storage, runId) {
|
||||||
const run = storage[config.saveKey];
|
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.
|
// Persist the configured amount as a candidate, never as automatic victory income.
|
||||||
const choices = outcome === "victory" && !encounter.skipRandomReward
|
const choices = outcome === "victory" && !encounter.skipRandomReward
|
||||||
? [{id:"mengsan.reward.gold.shuying", kind:"gold", name:"金币", amount:encounter.gold}, ...rewards] : [];
|
? [{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 = {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;
|
return run.battleFlow.pending;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -57,21 +66,29 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a
|
|||||||
return store.update(storage => {
|
return store.update(storage => {
|
||||||
const {item} = pending(storage, runId, id);
|
const {item} = pending(storage, runId, id);
|
||||||
if (item.state === "chosen") {
|
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;
|
return item;
|
||||||
}
|
}
|
||||||
const result = copy(item.base);
|
const result = copy(item.base);
|
||||||
let route = "defeat";
|
let route = "defeat";
|
||||||
if (item.outcome === "victory") {
|
if (item.outcome === "victory") {
|
||||||
|
if (item.rewardPackage) {
|
||||||
|
applyRewardPackage(result, item.rewardPackage, choiceId,
|
||||||
|
applyReward);
|
||||||
|
}
|
||||||
const choice = item.choices.find(c => c.id === choiceId);
|
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) throw new Error("Reward is not a fixed candidate");
|
||||||
if (choice.kind === "gold") {
|
if (choice.kind === "gold") {
|
||||||
if (!Number.isSafeInteger(choice.amount) || choice.amount < 0) throw new Error("Invalid pending gold reward");
|
if (!Number.isSafeInteger(choice.amount) || choice.amount < 0) throw new Error("Invalid pending gold reward");
|
||||||
result.player.gold += choice.amount;
|
result.player.gold += choice.amount;
|
||||||
result.statistics.goldEarned += choice.amount;
|
result.statistics.goldEarned += choice.amount;
|
||||||
} else applyReward(result, choice.effectId || choice.id);
|
} 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);
|
for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId);
|
||||||
if (!completeNode(result, item.nodeId)) throw new Error("Node completion failed");
|
if (!completeNode(result, item.nodeId)) throw new Error("Node completion failed");
|
||||||
route = item.boss && !enterNextAct(result) ? "victory" : "map";
|
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.
|
// Save the exact result (including random card ID/target) before final commit.
|
||||||
item.state = "chosen"; item.choiceId = choiceId; item.result = result;
|
item.state = "chosen"; item.choiceId = choiceId; item.result = result;
|
||||||
item.route = route; item.completedAt = now();
|
item.route = route; item.completedAt = now();
|
||||||
|
if (item.rewardPackage && item.outcome === "victory") {
|
||||||
|
logRewardPackage(item.rewardPackage, choiceId);
|
||||||
|
}
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { relicRewardIds } from "../../../relics/definitions.js";
|
import { relicRewardIds } from "../../../relics/definitions.js";
|
||||||
|
|
||||||
export const rewardPools = {
|
export const rewardPools = {
|
||||||
|
"act1.pool.liubeiOpening.relics": [...relicRewardIds],
|
||||||
"act1.pool.chest": [
|
"act1.pool.chest": [
|
||||||
...relicRewardIds,
|
...relicRewardIds,
|
||||||
"shared.reward.item.handCharm",
|
"shared.reward.item.handCharm",
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ export const openingRewards = {
|
|||||||
export default {
|
export default {
|
||||||
id: "act1.battle.liubeiOpening", kind: "battle", name: "刘备初始剧情", mapName: "初识的卢",
|
id: "act1.battle.liubeiOpening", kind: "battle", name: "刘备初始剧情", mapName: "初识的卢",
|
||||||
requiredCharacter: "mengsan_liubei_shuying",
|
requiredCharacter: "mengsan_liubei_shuying",
|
||||||
enemies: ["mengsan_soldier_shuying"], gold: 20,
|
enemies: ["mengsan_soldier_shuying"], gold: 400,
|
||||||
skipRandomReward: true, fixedRewards: ["act1.reward.dilu"], openingDialogue, victoryDialogue,
|
skipRandomReward: true, fixedRewards: ["act1.reward.dilu"], openingDialogue, victoryDialogue,
|
||||||
|
rewardPackage: { gold: 400, relicPool: "act1.pool.liubeiOpening.relics" },
|
||||||
battlePlan: {
|
battlePlan: {
|
||||||
units: [{
|
units: [{
|
||||||
id: "soldier", character: "mengsan_soldier_shuying", camp: "enemy",
|
id: "soldier", character: "mengsan_soldier_shuying", camp: "enemy",
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ Object.entries(nodeContents).forEach(([contentId, content]) => {
|
|||||||
validateDialogue(content.victoryDialogue, contentId + " 战后");
|
validateDialogue(content.victoryDialogue, contentId + " 战后");
|
||||||
validateDialogue(content.openingDialogue, contentId + " 开场");
|
validateDialogue(content.openingDialogue, contentId + " 开场");
|
||||||
for (const id of content.fixedRewards || []) if (!rewards[id]) throw new Error("固定奖励不存在:" + id);
|
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);
|
if (content.battlePlan) validateBattlePlan(content.battlePlan);
|
||||||
(content.choices || []).forEach(choice => {
|
(content.choices || []).forEach(choice => {
|
||||||
if (!choice.id) throw new Error(`梦三剧情选项缺少ID:${contentId}`);
|
if (!choice.id) throw new Error(`梦三剧情选项缺少ID:${contentId}`);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
- 苏双&张世平为一名男性蜀势力武将,暂设无技能、4/4、4张初始手牌,可自行调整。
|
- 苏双&张世平为一名男性蜀势力武将,暂设无技能、4/4、4张初始手牌,可自行调整。
|
||||||
- `discardEquipment` 弃置指定单位装备区的指定牌;已不在装备区则不重复弃置,不影响其他装备。
|
- `discardEquipment` 弃置指定单位装备区的指定牌;已不在装备区则不重复弃置,不影响其他装备。
|
||||||
- `fixedRewards` 固定获得梅花5的卢一张,加入征程个人牌库;此关关闭随机三选一。奖励与节点完成一起事务提交,重试不会重复发牌。
|
- `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 { createScenarioCharacters, scenarioTranslations } from "./content/scenario-characters.js";
|
||||||
import { createMengsanCards } from "./cards/mode-cards.js";
|
import { createMengsanCards } from "./cards/mode-cards.js";
|
||||||
import { createRelicBattle, createRelicSkills } from "./relics/battle.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 { 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 { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport } from "./battle/battle-director.js";
|
||||||
import { installPersonalPiles } from "./cards/personal-piles.js";
|
import { installPersonalPiles } from "./cards/personal-piles.js";
|
||||||
import { createMonster } from "./monsters/monster.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 { addCardToDeck, createRandomCardData } from "./cards/card-data.js";
|
||||||
import { hasUpgradeableCard, upgradeRandomCard } from "./cards/upgrades.js";
|
import { hasUpgradeableCard, upgradeRandomCard } from "./cards/upgrades.js";
|
||||||
import { chooseBattleReward } from "./ui/reward-ui.js";
|
import { chooseBattleReward } from "./ui/reward-ui.js";
|
||||||
|
import { chooseRewardPackage } from "./ui/reward-package.js";
|
||||||
import { applyStoryOutcome, getAvailableStoryChoices } from "./progression/story.js";
|
import { applyStoryOutcome, getAvailableStoryChoices } from "./progression/story.js";
|
||||||
import { playDialogue } from "./ui/dialogue.js";
|
import { playDialogue } from "./ui/dialogue.js";
|
||||||
import { chooseButtons } from "./ui/flow-ui.js";
|
import { chooseButtons } from "./ui/flow-ui.js";
|
||||||
@@ -201,14 +206,19 @@ const finishStoryNode = async (run, node) => {
|
|||||||
const finishUtilityNode = async (run, node) => {
|
const finishUtilityNode = async (run, node) => {
|
||||||
if (node.type == "rest") {
|
if (node.type == "rest") {
|
||||||
const choice = await chooseButtons("休息节点", [
|
const choice = await chooseButtons("休息节点", [
|
||||||
{ id: "heal", name: "休息", description: "回复 8 点生命" },
|
{ id: "heal", name: "休息",
|
||||||
|
description: `回复 ${restRecovery(run)} 点生命` },
|
||||||
{ id: "upgrade", name: "磨砺",
|
{ id: "upgrade", name: "磨砺",
|
||||||
description: hasUpgradeableCard(run.player.deck)
|
description: hasUpgradeableCard(run.player.deck)
|
||||||
? "随机强化一张尚未满级的可强化牌"
|
? "随机强化一张尚未满级的可强化牌"
|
||||||
: "没有可强化卡牌",
|
: "没有可强化卡牌",
|
||||||
disabled: !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") {
|
else if (node.type == "chest") {
|
||||||
await chooseReward(run, {
|
await chooseReward(run, {
|
||||||
@@ -218,6 +228,11 @@ const finishUtilityNode = async (run, node) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (node.type == "shop") {
|
else if (node.type == "shop") {
|
||||||
|
const result = applyRoomRelics(run, node, "enter");
|
||||||
|
if (result) {
|
||||||
|
game.log(`遗物【餐券】进入商店回复${result.recovered}点生命`);
|
||||||
|
await saveRun(run);
|
||||||
|
}
|
||||||
const price = 20;
|
const price = 20;
|
||||||
const choice = await chooseButtons("商店 Demo", [
|
const choice = await chooseButtons("商店 Demo", [
|
||||||
{ id: "card_sha", name: `购买【杀】(${price}金币)`, description: "加入个人牌组", disabled: run.player.gold < price || !canAcquireCard(run.player.character, "sha") },
|
{ 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.maxHp = me.maxHp;
|
||||||
run.player.hp = me.hp;
|
run.player.hp = me.hp;
|
||||||
}
|
}
|
||||||
|
initializeRelicMaxHp(run);
|
||||||
me.maxHp = run.player.maxHp;
|
me.maxHp = run.player.maxHp;
|
||||||
me.hp = Math.max(1, Math.min(run.player.hp, me.maxHp));
|
me.hp = Math.max(1, Math.min(run.player.hp, me.maxHp));
|
||||||
me.update();
|
me.update();
|
||||||
currentBattle.relics = createRelicBattle(run, {
|
currentBattle.relics = createRelicBattle(run, {
|
||||||
active: () => session.active && me.hp > 0,
|
active: () => session.active && me.hp > 0,
|
||||||
draw: number => game.mengsanDraw_shuying(me, number),
|
draw: number => game.mengsanDraw_shuying(me, number),
|
||||||
|
effect: createOpeningEffects(game, me,
|
||||||
|
() => currentBattle.energyUI.get(me)?.()),
|
||||||
log: (relic, effect) => {
|
log: (relic, effect) => {
|
||||||
game.log(me, `遗物【${relic.name}】发动:${effect}`);
|
game.log(me, `遗物【${relic.name}】发动:${effect}`);
|
||||||
currentBattle.relicUI?.refresh();
|
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_skip_shuying", "mengsan_stun_recover_shuying",
|
||||||
"mengsan_stun_clear_shuying",
|
"mengsan_stun_clear_shuying",
|
||||||
"mengsan_relics_shuying",
|
"mengsan_relics_shuying",
|
||||||
|
"mengsan_relic_attack_shuying",
|
||||||
]) {
|
]) {
|
||||||
if (!lib.skill.global.includes(skill)) {
|
if (!lib.skill.global.includes(skill)) {
|
||||||
game.addGlobalSkill(skill);
|
game.addGlobalSkill(skill);
|
||||||
@@ -556,8 +575,17 @@ const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({
|
|||||||
return playDialogue(pending.victoryDialogue,
|
return playDialogue(pending.victoryDialogue,
|
||||||
{ run: pending.base, title: "战后剧情" });
|
{ run: pending.base, title: "战后剧情" });
|
||||||
},
|
},
|
||||||
settlement:createBattleSettlement({store:modeStorage, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct}),
|
settlement:createBattleSettlement({store:modeStorage, config,
|
||||||
chooseReward: (choices, options) => chooseBattleReward(choices, {
|
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,
|
...options,
|
||||||
fixedRewards: options.fixedRewards.map(id => ({
|
fixedRewards: options.fixedRewards.map(id => ({
|
||||||
...config.rewards[id], id,
|
...config.rewards[id], id,
|
||||||
@@ -839,6 +867,7 @@ const createMode = identityMode => {
|
|||||||
skill: {
|
skill: {
|
||||||
...(identityMode.skill || {}),
|
...(identityMode.skill || {}),
|
||||||
...createRelicSkills(() => activeBattle, () => game.me),
|
...createRelicSkills(() => activeBattle, () => game.me),
|
||||||
|
...createRelicCombatSkills(() => activeBattle, () => game.me),
|
||||||
mengsan_taoyuan_bond_shuying: {
|
mengsan_taoyuan_bond_shuying: {
|
||||||
locked: true, mark: true, marktext: "义",
|
locked: true, mark: true, marktext: "义",
|
||||||
intro: { content: "先天结识关羽、张飞,初始羁绊均为8级。助战角色在行军菜单中选择。" },
|
intro: { content: "先天结识关羽、张飞,初始羁绊均为8级。助战角色在行军菜单中选择。" },
|
||||||
@@ -1088,6 +1117,10 @@ const createMode = identityMode => {
|
|||||||
mengsan_yingyong_shuying_info: "锁定技,每回合限一次,你于自己的回合内使用牌造成的伤害+1。",
|
mengsan_yingyong_shuying_info: "锁定技,每回合限一次,你于自己的回合内使用牌造成的伤害+1。",
|
||||||
mengsan_flyconid_action_shuying: "孢子行动",
|
mengsan_flyconid_action_shuying: "孢子行动",
|
||||||
mengsan_raider_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_card_strength_shuying: "力量加成",
|
||||||
mengsan_raider_strength_shuying: "力量",
|
mengsan_raider_strength_shuying: "力量",
|
||||||
mengsan_raider_strength_shuying_info: "攻击造成的伤害按力量层数增加。",
|
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,
|
requiredCharacter: encounter?.requiredCharacter || null,
|
||||||
openingDialogue: encounter?.openingDialogue || [],
|
openingDialogue: encounter?.openingDialogue || [],
|
||||||
fixedRewards: encounter?.fixedRewards || [],
|
fixedRewards: encounter?.fixedRewards || [],
|
||||||
|
rewardPackage: encounter?.rewardPackage || null,
|
||||||
skipRandomReward: encounter?.skipRandomReward === true,
|
skipRandomReward: encounter?.skipRandomReward === true,
|
||||||
victoryDialogue: encounter?.victoryDialogue || [],
|
victoryDialogue: encounter?.victoryDialogue || [],
|
||||||
battlePlan: encounter?.battlePlan || (enemy === RAIDER_TRIO_ENCOUNTER ?
|
battlePlan: encounter?.battlePlan || (enemy === RAIDER_TRIO_ENCOUNTER ?
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
| 千年积木 | `mengsan_millennium_puzzle_shuying` | `millennium_puzzle.png` | 待提供 |
|
| 千年积木 | `mengsan_millennium_puzzle_shuying` | `millennium_puzzle.png` | 待提供 |
|
||||||
| 摆动球 | `mengsan_pendulum_shuying` | `pendulum.png` | 待提供 |
|
| 摆动球 | `mengsan_pendulum_shuying` | `pendulum.png` | 待提供 |
|
||||||
| 束带(既有道具) | `mengsan_hand_charm_shuying` | `hand_charm.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,实际尺寸和视觉风格在资源制作时确定。
|
建议使用统一尺寸的透明背景 PNG,实际尺寸和视觉风格在资源制作时确定。
|
||||||
图片仅负责外观,不承载名称、效果或触发计数。
|
图片仅负责外观,不承载名称、效果或触发计数。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 梦三遗物(道具)
|
# 梦三遗物(道具)
|
||||||
|
|
||||||
本目录负责遗物定义、持有规则与战斗效果管理。
|
本目录负责遗物定义、持有规则与战斗效果管理。
|
||||||
已实现五件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。
|
已实现十三件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。
|
||||||
|
|
||||||
## 已实现的遗物
|
## 已实现的遗物
|
||||||
|
|
||||||
@@ -13,6 +13,22 @@
|
|||||||
| 千年积木 | 每场战斗首次实际生命减少且仍存活时摸 3 张;纯护甲损失不触发 |
|
| 千年积木 | 每场战斗首次实际生命减少且仍存活时摸 3 张;纯护甲损失不触发 |
|
||||||
| 摆动球 | 每场战斗第 3、6、9……个自身回合开始额外摸 1 张 |
|
| 摆动球 | 每场战斗第 3、6、9……个自身回合开始额外摸 1 张 |
|
||||||
| 束带 | 原有手牌上限 +1,重复获得不叠加 |
|
| 束带 | 原有手牌上限 +1,重复获得不叠加 |
|
||||||
|
| 餐券 | 进入商店时回复 15 点生命,每个商店节点一次 |
|
||||||
|
| 草莓 | 获得时生命上限 +7,当前生命不变,重复获得不叠加 |
|
||||||
|
| 弹珠袋 | 开战时给予当时所有存活敌人 1 层易伤 |
|
||||||
|
| 灯笼 | 首个自身回合可用费用 +1,费用上限不变,下回合恢复正常 |
|
||||||
|
| 红面具 | 开战时给予当时所有存活敌人 1 层虚弱 |
|
||||||
|
| 皇家枕头 | 选择休息回血时额外恢复 15;现有基础 8,总计 23 |
|
||||||
|
| 节日拉炮 | 开战时对当时所有存活敌人造成 9 点伤害 |
|
||||||
|
| 金刚杵 | 开战时获得 1 点力量,只在当前战斗有效 |
|
||||||
|
|
||||||
|
虚弱使攻击伤害减少 25%(向下取整),自身回合结束减少 1 层;
|
||||||
|
力量使玩家攻击牌伤害增加对应点数,意图攻击沿用原有力量/虚弱计算。
|
||||||
|
节日拉炮是非卡牌伤害,正常消耗目标护甲,不享受力量、易伤、虚弱的攻击修正。
|
||||||
|
开场效果先摸牌和施加状态,再结算节日拉炮;仅影响开战时的敌人,不影响后续援军。
|
||||||
|
餐券在打开商店前保存回血和节点记录,读档不会重复触发;
|
||||||
|
皇家枕头不影响磨砺、商店治疗或其他回复效果。所有回复均不超过生命上限。
|
||||||
|
草莓若在角色生命尚未初始化时获得,会保存待应用加成,初始化后只应用一次。
|
||||||
|
|
||||||
遗物互相独立:同时持有两种戒指时,开场和前三回合效果分别触发。
|
遗物互相独立:同时持有两种戒指时,开场和前三回合效果分别触发。
|
||||||
所有回合计数按玩家自身实际开始的回合计算,不计敌方和支援回合,
|
所有回合计数按玩家自身实际开始的回合计算,不计敌方和支援回合,
|
||||||
@@ -21,7 +37,7 @@
|
|||||||
|
|
||||||
### 获得、保存和查看
|
### 获得、保存和查看
|
||||||
|
|
||||||
- 五件新遗物加入共享 Boss 奖励池及第一章宝箱奖励池;
|
- 新遗物通过自动生成的遗物奖励列表加入共享 Boss 和第一章宝箱奖励池;
|
||||||
后续章节使用共享 Boss 奖励池的宝箱也可出现它们。
|
后续章节使用共享 Boss 奖励池的宝箱也可出现它们。
|
||||||
- 角色默认不携带新遗物;未新增商店价格或精英掉落规则。
|
- 角色默认不携带新遗物;未新增商店价格或精英掉落规则。
|
||||||
- 持有记录沿用 `run.player.items`,已持有遗物会从随机奖励中排除。
|
- 持有记录沿用 `run.player.items`,已持有遗物会从随机奖励中排除。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { heldRelics } from "./definitions.js";
|
import { heldRelics } from "./definitions.js";
|
||||||
|
|
||||||
export function createRelicBattle(run, { active, draw, log }) {
|
export function createRelicBattle(run, { active, draw, log, effect }) {
|
||||||
const relics = heldRelics(run);
|
const relics = heldRelics(run);
|
||||||
let started = false, turns = 0, firstLoss = false;
|
let started = false, turns = 0, firstLoss = false;
|
||||||
let openingHandBonus = 0, pendingHpDraw = null;
|
let openingHandBonus = 0, pendingHpDraw = null;
|
||||||
@@ -24,6 +24,15 @@ export function createRelicBattle(run, { active, draw, log }) {
|
|||||||
for (const relic of relics) {
|
for (const relic of relics) {
|
||||||
if (relic.effect === "openingDraw") await drawEffect(relic);
|
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) {
|
if (pendingHpDraw) {
|
||||||
const relic = pendingHpDraw;
|
const relic = pendingHpDraw;
|
||||||
pendingHpDraw = null;
|
pendingHpDraw = null;
|
||||||
@@ -35,6 +44,11 @@ export function createRelicBattle(run, { active, draw, log }) {
|
|||||||
seenTurns.add(event);
|
seenTurns.add(event);
|
||||||
turns++;
|
turns++;
|
||||||
for (const relic of relics) {
|
for (const relic of relics) {
|
||||||
|
if (relic.effect === "firstTurnEnergy" && turns === 1 &&
|
||||||
|
usable()) {
|
||||||
|
log(relic, `获得${relic.amount}点临时费用`);
|
||||||
|
await effect(relic);
|
||||||
|
}
|
||||||
if ((relic.effect === "earlyTurnDraw" &&
|
if ((relic.effect === "earlyTurnDraw" &&
|
||||||
turns <= relic.turns) ||
|
turns <= relic.turns) ||
|
||||||
(relic.effect === "periodicTurnDraw" &&
|
(relic.effect === "periodicTurnDraw" &&
|
||||||
@@ -63,9 +77,12 @@ export function createRelicBattle(run, { active, draw, log }) {
|
|||||||
if (relic.effect === "periodicTurnDraw") {
|
if (relic.effect === "periodicTurnDraw") {
|
||||||
return `回合计数:${turns % relic.turns}/${relic.turns}`;
|
return `回合计数:${turns % relic.turns}/${relic.turns}`;
|
||||||
}
|
}
|
||||||
if (relic.effect === "openingDraw") {
|
if (relic.effect.startsWith("opening")) {
|
||||||
return started ? "本场已触发" : "等待战斗开始";
|
return started ? "本场已触发" : "等待战斗开始";
|
||||||
}
|
}
|
||||||
|
if (relic.effect === "firstTurnEnergy") {
|
||||||
|
return turns ? "本场已触发" : "等待首个自身回合";
|
||||||
|
}
|
||||||
return "持续持有";
|
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: "束带",
|
{ id: "mengsan_hand_charm_shuying", name: "束带",
|
||||||
description: "基础手牌上限额外+1;重复获得不叠加。",
|
description: "基础手牌上限额外+1;重复获得不叠加。",
|
||||||
effect: "handLimit", amount: 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(
|
export const relicDefinitions = Object.freeze(Object.fromEntries(
|
||||||
@@ -39,16 +63,31 @@ export function canAcquireRelic(run, id) {
|
|||||||
export function grantRelic(run, id) {
|
export function grantRelic(run, id) {
|
||||||
if (!getRelic(id)) throw new Error(`梦三遗物未注册:${id}`);
|
if (!getRelic(id)) throw new Error(`梦三遗物未注册:${id}`);
|
||||||
if (!canAcquireRelic(run, id)) return false;
|
if (!canAcquireRelic(run, id)) return false;
|
||||||
|
const relic = getRelic(id);
|
||||||
run.player.items ||= [];
|
run.player.items ||= [];
|
||||||
run.player.items.push(id);
|
run.player.items.push(id);
|
||||||
const relic = getRelic(id);
|
|
||||||
if (relic.effect === "handLimit") {
|
if (relic.effect === "handLimit") {
|
||||||
run.player.handLimitBonus = (run.player.handLimitBonus || 0) +
|
run.player.handLimitBonus = (run.player.handLimitBonus || 0) +
|
||||||
relic.amount;
|
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;
|
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 =>
|
export const relicRewardIds = entries.filter(entry =>
|
||||||
entry.effect !== "handLimit").map(entry => `shared.reward.relic.${entry.id}`);
|
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 { lib, get } from "../../../../noname.js";
|
||||||
import { cardCost } from "../battle/combat-rules.js";
|
import { cardCost } from "../battle/combat-rules.js";
|
||||||
import { cardUpgradeRule } from "../cards/upgrades.js";
|
import { cardUpgradeRule } from "../cards/upgrades.js";
|
||||||
|
import { getRelic } from "../relics/definitions.js";
|
||||||
import { chooseButtons } from "./flow-ui.js";
|
import { chooseButtons } from "./flow-ui.js";
|
||||||
|
|
||||||
const element = (tag, className, text, parent) => {
|
const element = (tag, className, text, parent) => {
|
||||||
@@ -42,14 +43,39 @@ const viewCard = choice => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const restoreFocus = previous => { if (previous?.isConnected) previous.focus(); };
|
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.
|
// 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.
|
// 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))) {
|
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 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const previousFocus = document.activeElement;
|
const previousFocus = document.activeElement;
|
||||||
const dialog = element("dialog", "mengsan-card-choice-dialog-shuying", null);
|
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);
|
const banner = element("header", "mengsan-card-choice-heading-shuying", null, stage);
|
||||||
element("span", "mengsan-reward-overline-shuying", "梦三 · 战利品", banner);
|
element("span", "mengsan-reward-overline-shuying", "梦三 · 战利品", banner);
|
||||||
element("h2", "", title, banner);
|
element("h2", "", title, banner);
|
||||||
element("p", "", "择一入阵,余者留于身后", banner);
|
element("p", "", description, banner);
|
||||||
const cards = element("div", "mengsan-card-choice-grid-shuying", null, stage);
|
const cards = element("div", "mengsan-card-choice-grid-shuying", null, stage);
|
||||||
cards.style.setProperty("--choice-count", String(Math.min(choices.length, 5)));
|
cards.style.setProperty("--choice-count", String(Math.min(choices.length, 5)));
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -71,12 +97,15 @@ export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip
|
|||||||
resolve(id);
|
resolve(id);
|
||||||
};
|
};
|
||||||
for (const choice of choices) {
|
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);
|
const card = element("button", "mengsan-card-choice-tile-shuying", null, cards);
|
||||||
card.type = "button";
|
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));
|
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);
|
element("span", "mengsan-card-choice-name-shuying", view.name, card);
|
||||||
const artwork = element("span", "mengsan-card-choice-art-shuying", null, card);
|
const artwork = element("span", "mengsan-card-choice-art-shuying", null, card);
|
||||||
if (view.image) {
|
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-glyph-shuying", view.name.slice(0, 1), artwork);
|
||||||
element("span", "mengsan-card-choice-kind-shuying", view.type, card);
|
element("span", "mengsan-card-choice-kind-shuying", view.type, card);
|
||||||
element("span", "mengsan-card-choice-description-shuying", view.description, card);
|
element("span", "mengsan-card-choice-description-shuying", view.description, card);
|
||||||
|
element("span", "mengsan-reward-tile-action-shuying", actionLabel, card);
|
||||||
}
|
}
|
||||||
if (allowSkip) {
|
if (allowSkip) {
|
||||||
const skip = element("button", "mengsan-card-choice-skip-shuying", skipLabel, stage);
|
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);
|
const board = element("div", "mengsan-victory-options-shuying", null, stage);
|
||||||
element("span", "mengsan-victory-eyebrow-shuying", rewardLabel, board);
|
element("span", "mengsan-victory-eyebrow-shuying", rewardLabel, board);
|
||||||
let settled = false, choosing = false;
|
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 => {
|
const finish = id => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
@@ -139,19 +178,20 @@ export function chooseVictoryOptions(options, {
|
|||||||
entry.setAttribute("aria-label", `${option.label}。${option.description || ""}`);
|
entry.setAttribute("aria-label", `${option.label}。${option.description || ""}`);
|
||||||
element("span", "mengsan-victory-option-index-shuying", String(index + 1).padStart(2, "0"), entry);
|
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);
|
const copy = element("span", "mengsan-victory-option-copy-shuying", null, entry);
|
||||||
element("strong", "", option.label, copy);
|
const label = element("strong", "", option.label, copy);
|
||||||
if (option.description) element("small", "", option.description, 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");
|
element("span", "mengsan-victory-option-arrow-shuying", "›", entry).setAttribute("aria-hidden", "true");
|
||||||
entry.addEventListener("click", async () => {
|
entry.addEventListener("click", async () => {
|
||||||
if (settled || choosing) return;
|
if (settled || choosing || entry.disabled) return;
|
||||||
choosing = true;
|
choosing = true;
|
||||||
entry.disabled = true;
|
entry.disabled = true;
|
||||||
if (dialog.open) dialog.close();
|
if (dialog.open) dialog.close();
|
||||||
try {
|
try {
|
||||||
const selected = option.choose ? await option.choose() : option.id;
|
const selected = option.choose ? await option.choose() : option.id;
|
||||||
if (selected === RETURN_TO_VICTORY) {
|
if (selected === RETURN_TO_VICTORY) {
|
||||||
|
refresh();
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
entry.disabled = false;
|
|
||||||
choosing = false;
|
choosing = false;
|
||||||
entry.focus();
|
entry.focus();
|
||||||
} else finish(selected);
|
} else finish(selected);
|
||||||
@@ -161,6 +201,7 @@ export function chooseVictoryOptions(options, {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
refresh();
|
||||||
if (allowSkip) {
|
if (allowSkip) {
|
||||||
const skip = element("button", "mengsan-victory-skip-shuying", "放弃本次选择", stage);
|
const skip = element("button", "mengsan-victory-skip-shuying", "放弃本次选择", stage);
|
||||||
skip.type = "button";
|
skip.type = "button";
|
||||||
@@ -169,7 +210,10 @@ export function chooseVictoryOptions(options, {
|
|||||||
dialog.addEventListener("cancel", event => event.preventDefault());
|
dialog.addEventListener("cancel", event => event.preventDefault());
|
||||||
dialog.addEventListener("keydown", event => event.stopPropagation());
|
dialog.addEventListener("keydown", event => event.stopPropagation());
|
||||||
document.documentElement.appendChild(dialog);
|
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); }
|
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-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-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-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 {
|
button.mengsan-card-choice-skip-shuying {
|
||||||
min-height: 48px; padding: 9px 28px; border: 1px solid #82978b; border-radius: 3px;
|
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;
|
color: var(--reward-muted); background: #1a2b2f; font: 16px/1.4 "Microsoft YaHei",sans-serif; cursor: pointer;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ EXCLUDE_DIRS = {
|
|||||||
"__pycache__",
|
"__pycache__",
|
||||||
}
|
}
|
||||||
EXCLUDE_FILES = {".gitignore", "log.txt"}
|
EXCLUDE_FILES = {".gitignore", "log.txt"}
|
||||||
|
EXCLUDE_EXTENSIONS = {".md", ".mjs"}
|
||||||
|
|
||||||
|
|
||||||
def sha256_file(path: Path) -> str:
|
def sha256_file(path: Path) -> str:
|
||||||
@@ -33,6 +34,7 @@ def should_include(path: Path, root: Path) -> bool:
|
|||||||
return (
|
return (
|
||||||
path.is_file()
|
path.is_file()
|
||||||
and relative.name not in EXCLUDE_FILES
|
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)
|
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 assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { grantRelic, canAcquireRelic, getRelic, heldRelics,
|
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,
|
import { createRelicBattle, createRelicSkills,
|
||||||
applyBattleEndRelics } from "../mengsan/relics/battle.js";
|
applyBattleEndRelics } from "../mengsan/relics/battle.js";
|
||||||
import { canAcquireReward, getRandomRewardChoices }
|
import { canAcquireReward, getRandomRewardChoices }
|
||||||
@@ -32,6 +35,97 @@ const runtime = run => {
|
|||||||
return { battle, draws, logs, stop: () => { running = false; } };
|
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", () => {
|
test("relics use existing items, reject duplicates and preserve belt saves", () => {
|
||||||
const run = newRun();
|
const run = newRun();
|
||||||
assert.equal(grantRelic(run, id("burning_blood")), true);
|
assert.equal(grantRelic(run, id("burning_blood")), true);
|
||||||
|
|||||||
Reference in New Issue
Block a user