fixed # 新增羁绊系统

fixed # 新增遗物系统
fixed # 梦三模式下移除护甲上限限制
This commit is contained in:
2026-09-30 14:33:17 +08:00
parent f2da9bce65
commit 82d80397b6
45 changed files with 2403 additions and 130 deletions
+17
View File
@@ -0,0 +1,17 @@
# 遗物资源待补清单
当前所有遗物的 `image` 均为 `null`,使用文字名称和效果说明,
不会加载不存在的图片。后续图片统一放入 `mengsan/assets/relics/`,
补齐时更新 `definitions.js` 并接入展示;本次不生成占位图片。
| 遗物 | 稳定 ID | 建议图片文件名 | 状态 |
| --- | --- | --- | --- |
| 燃烧之血 | `mengsan_burning_blood_shuying` | `burning_blood.png` | 待提供 |
| 蛇之戒指 | `mengsan_snake_ring_shuying` | `snake_ring.png` | 待提供 |
| 长蛇戒指 | `mengsan_long_snake_ring_shuying` | `long_snake_ring.png` | 待提供 |
| 千年积木 | `mengsan_millennium_puzzle_shuying` | `millennium_puzzle.png` | 待提供 |
| 摆动球 | `mengsan_pendulum_shuying` | `pendulum.png` | 待提供 |
| 束带(既有道具) | `mengsan_hand_charm_shuying` | `hand_charm.png` | 待提供 |
建议使用统一尺寸的透明背景 PNG,实际尺寸和视觉风格在资源制作时确定。
图片仅负责外观,不承载名称、效果或触发计数。
+89
View File
@@ -0,0 +1,89 @@
# 梦三遗物(道具)
本目录负责遗物定义、持有规则与战斗效果管理。
已实现五件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。
## 已实现的遗物
| 遗物 | 效果 |
| --- | --- |
| 燃烧之血 | 胜利结算后回复 6 点生命,不超过上限;失败不回血 |
| 蛇之戒指 | 每场战斗开始额外摸 2 张,开场手牌整理不会移除这两张 |
| 长蛇戒指 | 每场战斗第 1~3 个自身回合开始,各额外摸 2 张 |
| 千年积木 | 每场战斗首次实际生命减少且仍存活时摸 3 张;纯护甲损失不触发 |
| 摆动球 | 每场战斗第 3、6、9……个自身回合开始额外摸 1 张 |
| 束带 | 原有手牌上限 +1,重复获得不叠加 |
遗物互相独立:同时持有两种戒指时,开场和前三回合效果分别触发。
所有回合计数按玩家自身实际开始的回合计算,不计敌方和支援回合,
不使用界面显示的全局轮数。每场战斗重置计数及“首次损失生命”标记。
额外摸牌沿用个人牌堆规则;牌堆与弃牌堆不足时仍按原规则挑战失败。
### 获得、保存和查看
- 五件新遗物加入共享 Boss 奖励池及第一章宝箱奖励池;
后续章节使用共享 Boss 奖励池的宝箱也可出现它们。
- 角色默认不携带新遗物;未新增商店价格或精英掉落规则。
- 持有记录沿用 `run.player.items`,已持有遗物会从随机奖励中排除。
- `definitions.js` 提供注册、资格检查和去重获得;旧“束带”ID 与效果保留。
- `battle.js` 管理战斗计数、摸牌效果及战后回血;战斗运行状态不写入征程。
- 战后回血写入结算候选快照,之后才领取奖励,重试、重复提交和读档
复用快照,不再次回血;刚从本场奖励获得的燃烧之血从下一场生效。
- 战斗中的“遗物”按钮显示名称、效果与触发状态;地图行囊显示持有详情。
- 获得和效果触发使用游戏日志反馈;没有图片时只显示文字。
## 参考来源
- [杀戮尖塔 2 中文百科:遗物](https://sts2.huijiwiki.com/wiki/%E9%81%97%E7%89%A9)
- 查阅日期:2026-09-30。
- 页面直连返回 403;以下概述依据搜索索引返回的该页正文整理。
本文概述机制,不复制完整遗物列表、具体效果或图片。
## 网页中的遗物概念
遗物是单局游戏中获得并持续持有的物品,会影响牌组构筑、
手牌管理和其他玩法。这里的“永久”指当前这一局,
不代表跨新征程永久保留。
- 每个角色拥有自己的初始遗物,此外也有通用和角色专属遗物。
- 获得途径包括击败精英或 Boss、宝箱、商店、部分事件和先古之民。
- 已经持有的遗物不会再次出现。
- 通常不能主动移除;部分事件可以交换或移除遗物。
- 部分特殊遗物可以失效,但失效后仍保留在持有列表中。
- 页面按稀有度或获取类别区分初始、普通、罕见、稀有、
商店、先古之民、事件及其他遗物。
## 接入前的项目背景
接入前,项目已有以下零散道具逻辑:
| 位置 | 现有职责 |
| --- | --- |
| `../progression/state.js` | 新征程创建 `player.items` 和手牌上限加成数据 |
| `../mode.js` | 获得“束带”时记录道具 ID,手牌上限加成增加 1,重复获得不叠加 |
| `../content/shared/rewards.js` | 配置“束带”和“斥候援令”等奖励 |
| `../battle/battle-director.js` | 管理援令带来的支援单位与重复来源处理 |
| `../ui/map/renderer.js` | 行囊展示永久道具,数量读取 `player.items` |
“斥候援令”走支援管理流程,不能仅凭奖励名中的“道具”
认定它已经进入 `player.items` 或统一遗物系统。
## 设计约定与后续扩展
1. 将“遗物”作为本征程持续持有道具的统一概念,
与手牌、装备牌、消耗品及临时战斗状态分别管理。
2. 每个遗物定义稳定 ID、名称、说明、图标、角色限制、
获取类别及效果规则;具体三国题材名称与数值另行配置。
3. 根据效果需要提供获得时、战斗开始、回合开始、出牌后、
战斗结束等触发时机;不能假定所有遗物只提供固定属性加成。
4. 获得前统一检查角色资格和是否已持有,
默认不重复获取;失效、交换和移除规则由具体遗物明确声明。
5. 将持有记录和需要跨战斗保存的计数写入征程存档,
战斗内计数由战斗会话管理,读档不重复执行“获得时”效果。
6. 先以“束带”作为接入现有机制的候选,保留它当前的效果与去重行为;
“斥候援令”需要明确与支援系统的关系,再决定如何接入。
7. 获得提示、持有列表、说明与触发反馈放在 `../ui/`,
图片建议放在 `../assets/relics/`;本目录负责遗物自身规则。
资源、稀有度、精英掉落、商店价格、角色初始遗物以及特殊交换或移除
规则仍待后续配置;本次保留原道具与存档字段,不引入独立遗物存档副本。
+105
View File
@@ -0,0 +1,105 @@
import { heldRelics } from "./definitions.js";
export function createRelicBattle(run, { active, draw, log }) {
const relics = heldRelics(run);
let started = false, turns = 0, firstLoss = false;
let openingHandBonus = 0, pendingHpDraw = null;
const seenTurns = new WeakSet();
const usable = () => started && active();
const drawEffect = async relic => {
if (!usable()) return;
if (turns === 0) openingHandBonus += relic.amount;
log(relic, `额外摸${relic.amount}张牌`);
await draw(relic.amount);
};
return {
get openingHandBonus() { return openingHandBonus; },
get openingDraw() {
return relics.filter(relic => relic.effect === "openingDraw")
.reduce((sum, relic) => sum + relic.amount, 0);
},
async start() {
if (started || !active()) return;
started = true;
for (const relic of relics) {
if (relic.effect === "openingDraw") await drawEffect(relic);
}
if (pendingHpDraw) {
const relic = pendingHpDraw;
pendingHpDraw = null;
await drawEffect(relic);
}
},
async turnStart(event) {
if (!usable() || seenTurns.has(event)) return;
seenTurns.add(event);
turns++;
for (const relic of relics) {
if ((relic.effect === "earlyTurnDraw" &&
turns <= relic.turns) ||
(relic.effect === "periodicTurnDraw" &&
turns % relic.turns === 0)) await drawEffect(relic);
}
},
async loseHp(event) {
if (!active() || firstLoss || !(event.num < 0)) return;
const relic = relics.find(item =>
item.effect === "firstHpLossDraw");
if (!relic) return;
firstLoss = true;
if (!started) {
pendingHpDraw = relic;
return;
}
await drawEffect(relic);
},
status(relic) {
if (relic.effect === "firstHpLossDraw") {
return firstLoss ? "本场已触发" : "本场尚未触发";
}
if (relic.effect === "earlyTurnDraw") {
return `本场已开始${turns}个自身回合`;
}
if (relic.effect === "periodicTurnDraw") {
return `回合计数:${turns % relic.turns}/${relic.turns}`;
}
if (relic.effect === "openingDraw") {
return started ? "本场已触发" : "等待战斗开始";
}
return "持续持有";
},
};
}
export function applyBattleEndRelics(run, outcome) {
if (outcome !== "victory" || !(run.player.hp > 0)) return 0;
const amount = heldRelics(run).filter(relic =>
relic.effect === "battleHeal").reduce((sum, relic) =>
sum + relic.amount, 0);
const before = run.player.hp;
run.player.hp = Math.min(run.player.maxHp ?? before,
before + amount);
return run.player.hp - before;
}
export function createRelicSkills(getBattle, getOwner) {
return {
mengsan_relics_shuying: {
trigger: { global: ["phaseBegin", "changeHp"] },
forced: true, silent: true, popup: false, priority: 100,
filter(event) {
const battle = getBattle(), owner = getOwner();
return Boolean(battle?.session.active && battle.relics &&
owner?.hp > 0 && event.player === owner &&
(event.name === "phase" || event.num < 0));
},
async content(event, trigger) {
const battle = getBattle();
if (!battle?.session.active) return;
if (event.triggername === "phaseBegin") {
await battle.relics.turnStart(trigger);
} else await battle.relics.loseHp(trigger);
},
},
};
}
+60
View File
@@ -0,0 +1,60 @@
const entries = [
{ id: "mengsan_burning_blood_shuying", name: "燃烧之血",
description: "战斗结束后回复6点生命,不能超过生命上限。",
effect: "battleHeal", amount: 6 },
{ id: "mengsan_snake_ring_shuying", name: "蛇之戒指",
description: "每场战斗开始时,额外摸2张牌。",
effect: "openingDraw", amount: 2 },
{ id: "mengsan_long_snake_ring_shuying", name: "长蛇戒指",
description: "每场战斗的前3个自身回合开始时,额外摸2张牌。",
effect: "earlyTurnDraw", amount: 2, turns: 3 },
{ id: "mengsan_millennium_puzzle_shuying", name: "千年积木",
description: "每场战斗首次实际损失生命且仍存活时,摸3张牌。",
effect: "firstHpLossDraw", amount: 3 },
{ id: "mengsan_pendulum_shuying", name: "摆动球",
description: "每场战斗每到第3个自身回合开始时,额外摸1张牌。",
effect: "periodicTurnDraw", amount: 1, turns: 3 },
{ id: "mengsan_hand_charm_shuying", name: "束带",
description: "基础手牌上限额外+1;重复获得不叠加。",
effect: "handLimit", amount: 1 },
];
export const relicDefinitions = Object.freeze(Object.fromEntries(
entries.map(entry => [entry.id, Object.freeze({ ...entry,
owner: null, image: null })])));
export const getRelic = id => Object.hasOwn(relicDefinitions, id)
? relicDefinitions[id] : null;
export const heldRelics = run => [...new Set(run?.player?.items || [])]
.map(getRelic).filter(Boolean);
export function canAcquireRelic(run, id) {
const relic = getRelic(id);
return Boolean(run?.player && relic &&
(relic.owner === null || relic.owner === run.player.character) &&
!run.player.items?.includes(id));
}
export function grantRelic(run, id) {
if (!getRelic(id)) throw new Error(`梦三遗物未注册:${id}`);
if (!canAcquireRelic(run, id)) return false;
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;
}
return true;
}
export const relicRewardIds = entries.filter(entry =>
entry.effect !== "handLimit").map(entry => `shared.reward.relic.${entry.id}`);
export const relicRewards = Object.fromEntries(relicRewardIds.map(id => {
const relicId = id.slice("shared.reward.relic.".length);
const relic = getRelic(relicId);
return [id, { relic: relicId, name: `遗物·${relic.name}`,
description: relic.description }];
}));