杀戮尖塔 2 效果实现教程 —— 卡牌 / 遗物 / 药水 / Power 怎么写
打个广告:我给《杀戮尖塔2》开发的《星塔旅人》mod《杀戮星塔》正在锐意开发(咕咕)中,欢迎来围观!仓库地址:Github:SinonJZH/slay-the-stella
写在前面:这是我和 AI 斗智斗勇了 6 个小时后弄出来的教程。
这份教程讲什么:只讲一件事——效果代码怎么写。从"造成 6 点伤害"到"越打越强""复活""每有一张打击牌伤害 +2", 每种效果给出可以直接抄进项目改名的完整代码,并解释为什么这么写。
这份教程不讲什么:mod 环境搭建、新文件该放哪、本地化 JSON 的基本格式、调试与热重载——这些 官方中文 mod 教程仓库(下称《官方教程》)已经讲得很清楚了 (推荐先读
Basics/01-环境配置、BaseLib/01-添加卡牌、Basics/05-变量与描述、Basics/07-快速调试&热重载)。文中所有中文术语(人工制品、集中、奇巧、辉星……)均为游戏官方简中本地化用词(以游戏内实际显示为准)。
0. 开工前:十分钟背景知识
0.1 三层世界观(理解了这个,效果代码就不再神秘)
把游戏想成一家餐厅:
一条铁律:永远通过命令层改状态,绝不直接改数字。命令内部会自动处理钩子通知、数值修正(力量/易伤)、 战斗历史记录、特效音效——绕过它就等于绕过了整个游戏规则。
0.2 读效果代码需要的 C# 最小知识
效果代码用到的 C# 语法其实非常少,看懂下面六个写法就能读 95% 的效果代码:
// ① 类与继承:我的卡 = 一个继承 CardModel(RitsuLib 脚手架里是 ModCardTemplate)的类
public sealed class Foo() : ModCardTemplate(1, CardType.Attack, CardRarity.Common, TargetType.AnyEnemy)
// ↑ 主构造函数:小括号直接跟在类名后,参数原样传给基类
// 四个参数 = 费用, 卡型, 稀有度, 目标类型
// ② override:重写基类的虚方法/属性——"游戏会在恰当时机调用它,我提供我的版本"
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay) { ... }
protected override void OnUpgrade() { ... }
// ③ 属性(property):看起来像字段,其实是方法
public override bool GainsBlock => true; // "表达式体属性":每次读取返回 true
// ④ async / await / Task:效果是异步的(要播动画、要等动画)
// 规则很简单:基类方法是 async Task 你就写 async Task;里面每一步用 await 串起来
await DamageCmd.Attack(6m).FromCard(this, cardPlay).Targeting(cardPlay.Target).Execute(choiceContext);
// ⑤ 泛型 <T>:把"类型"当参数传。Apply<VulnerablePower> = 上"易伤"这个具体的 Power
await PowerCmd.Apply<VulnerablePower>(choiceContext, target, 2m, Owner.Creature, this);
// ⑥ 集合表达式 [ ]:新建一个数组/列表的简写(C# 12)
protected override IEnumerable<DynamicVar> CanonicalVars => [new DamageVar(6m, ValueProp.Move)];
其余语法(?. 空条件、?? 空合并、lambda x => x * 2)遇到时查任何 C# 入门资料即可。
0.3 RitsuLib 脚手架的基类与注册约定(30 秒版)
本地化键:mod id 前缀 + 类名转大写下划线(如 MyStrike → MYMOD_CARD_MY_STRIKE.title/.description); 卡图放 mod 资源目录的 images/cards/{类名}.png 即被 AssetProfile 自动找到。
1. 一切的地基:数值声明(DynamicVar)
核心思想:卡的数值不写死在代码里,而是声明成"变量",卡面文字用占位符引用它。 这样升级、临时增减益、力量修正才能自动反映到卡面上。
每个 DynamicVar 内部有三个值:
写卡的固定套路:
// ① 声明数值(类里重写这一个属性)
protected override IEnumerable<DynamicVar> CanonicalVars =>
[
new DamageVar(6m, ValueProp.Move), // 伤害 6,性质 = 普通攻击(详见 §1.2)
new RepeatVar(3), // 段数 3
];
// ② 效果里读 BaseValue
await DamageCmd.Attack(DynamicVars.Damage.BaseValue)...
// ③ 升级时加
protected override void OnUpgrade()
{
DynamicVars.Damage.UpgradeValueBy(3m); // 升级 +3,卡面自动变绿
}
为什么不直接写死 6? 因为描述 JSON 里写的是
造成{Damage:diff()}点伤害——数值、升级预览、 力量加成后的实时变色全靠这个变量体系。写死数字 = 放弃这一切。注意:以上是静态数值的套路。当数值需要运行时计算(依赖目标、战斗状态、其他变量,比如 "每有一张打击牌伤害 +2"),用下一节 §1.3 的
ComputedDynamicVar,声明和读取方式都不同。
1.1 常用 Var 速查
需要运行时计算的数值("每有一张 X 牌 +2"这类)不要硬凑静态 Var,用下一节的 ComputedDynamicVar。
1.2 ValueProp:伤害的"性质"(新手最常懵的点)
DamageVar/BlockVar 的第二个参数是性质标记,决定这次数值吃不吃力量/敏捷:
记忆法:官方注释把
Move解释为"出招"性质——是"一招"就吃加成;遗物/药水/Power 造成的伤害默认Unpowered(所以荆棘反伤不吃你自己 stacks 的力量)。
1.3 计算型数值:ComputedDynamicVar(RitsuLib 推荐)
当数值需要运行时计算——依赖目标("+目标身上的力量层数")、战斗状态("每有一张打击牌 +2")、 其他变量、甚至预览模式——静态 Var 就不够了。官方原版有 CalculatedDamageVar + CalculationBaseVar/CalculationExtraVar 三件套(读官方卡代码时能认出来即可), 但设计繁琐;RitsuLib 提供了更好用的 ModCardVars.Computed* 系列:传一个委托,显示值与结算值 运行时直接计算。
最常用的形态(伤害/格挡包装:预览自动走力量、易伤、附魔等官方修正管道):
protected override IEnumerable<DynamicVar> CanonicalVars =>
[
// 显示与结算值 = 底数(6) + 2 × 场上"打击"牌数;预览会经过 Hook.ModifyDamage
ModCardVars.ComputedDamage("Damage", 6m, CountStrikes),
];
// 委托建议用 static:变量会随卡牌克隆,static 不会意外捕获创建时的卡牌实例
private static decimal CountStrikes(CardModel card, Creature? _)
{
if (card.Owner?.PlayerCombatState == null) // 规范卡(图鉴/奖励)无战斗上下文
return card.DynamicVars["Damage"].BaseValue; // 直接回底数
return card.DynamicVars["Damage"].BaseValue
+ 2m * card.Owner.PlayerCombatState.AllCards.Count(c => c.Tags.Contains(CardTag.Strike));
}
三条铁规矩:
委托不会自动返回底数——
baseValue只是后备数据,算式里要自己读card.DynamicVars["名"].BaseValue(上下文形式里是ctx.BaseValue)。写死常量会丢升级。升级仍然走
UpgradeValueBy改 BaseValue:DynamicVars["Damage"].UpgradeValueBy(3m);效果里读取用
EvaluateValueOrDefault(不是.BaseValue!它会对目标求值):
decimal dmg = DynamicVars.EvaluateValueOrDefault("Damage", target: cardPlay.Target);
await DamageCmd.Attack(dmg).FromCard(this, cardPlay).Targeting(cardPlay.Target).Execute(choiceContext);
ModCardVars 家族速查(按需求对号入座):
还有上下文形式:委托写成 static ctx => ...,ctx 自带一批空值保护成员——ctx.BaseValue、 ctx.Target/ctx.HasTarget、ctx.IsInCombat、ctx.CombatState、ctx.Player、ctx.IsPreview/ ctx.IsUpgradePreview、ctx.GetCardIntOrDefault("其他变量")、防递归的 ctx.EvaluateCardVarOrDefault(...) ——不用再手写判空:
ModCardVars.ComputedDamage(
"Damage",
static ctx => ctx.BaseValue + (ctx.HasTarget ? 2m : 0m),
baseValue: 6m);
完整 API(独立预览工厂、共享 tooltip
WithSharedTooltip、ctx 成员总表)见《官方教程》 RitsuLib 篇的《01 - 添加基础内容 / 19 - 计算动态变量》。
2. 通用效果积木(直接抄,改名字就能用)
以下每块都是完整可编译的最小卡牌类。演示统一用官方 Power(易伤/力量等),你换成任何 Power 类名都行。
TargetType怎么选:单体攻击AnyEnemy、全体AllEnemies、纯自我Self(完整枚举看 IDE 的补全提示即可)。
2.1 造成伤害(单体)
/// <summary>造成 6 点伤害。</summary>
public sealed class FooStrike() : ModCardTemplate(1, CardType.Attack, CardRarity.Common, TargetType.AnyEnemy)
{
protected override IEnumerable<DynamicVar> CanonicalVars => [new DamageVar(6m, ValueProp.Move)];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target); // 单体目标卡:目标必不为空,防御性检查
await DamageCmd.Attack(DynamicVars.Damage.BaseValue) // 每击伤害(读 BaseValue!)
.FromCard(this, cardPlay) // 声明"这刀是谁出的"→ 自动设攻击者/动画
.Targeting(cardPlay.Target) // 打玩家选的目标
.WithHitFx("vfx/vfx_attack_slash") // 命中特效(可换,也可整个不写)
.Execute(choiceContext); // 结算!
}
protected override void OnUpgrade() => DynamicVars.Damage.UpgradeValueBy(3m);
}
要点:链式调用的顺序习惯是 Attack → FromCard → Targeting → 特效 → Execute,Execute 才真正动手。
2.2 多段攻击(打 X 次)
段数也是数值,用 RepeatVar 声明,升级就能"多打一次":
public sealed class FooMultiHit() : ModCardTemplate(1, CardType.Attack, CardRarity.Common, TargetType.AnyEnemy)
{
protected override IEnumerable<DynamicVar> CanonicalVars =>
[
new DamageVar(3m, ValueProp.Move),
new RepeatVar(3),
];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
await DamageCmd.Attack(DynamicVars.Damage.BaseValue)
.WithHitCount(DynamicVars.Repeat.IntValue) // ← 段数来自 RepeatVar
.FromCard(this, cardPlay)
.Targeting(cardPlay.Target)
.Execute(choiceContext);
}
protected override void OnUpgrade() => DynamicVars.Repeat.UpgradeValueBy(1m); // 升级 = 多一段
}
2.3 全体攻击(+ 顺手给全体上减益)
public sealed class FooAoe() : ModCardTemplate(1, CardType.Attack, CardRarity.Common, TargetType.AllEnemies)
{
protected override IEnumerable<DynamicVar> CanonicalVars =>
[
new DamageVar(4m, ValueProp.Move),
new PowerVar<VulnerablePower>(1m), // 官方 zhs 名:易伤
];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
await DamageCmd.Attack(DynamicVars.Damage.BaseValue)
.FromCard(this, cardPlay)
.TargetingAllOpponents(CombatState!) // 不需要具体目标;! 表示"断言非空"
.Execute(choiceContext);
// 全体上易伤:HittableEnemies = 当前"打得到"的敌人列表
await PowerCmd.Apply<VulnerablePower>(choiceContext, CombatState!.HittableEnemies,
DynamicVars.Vulnerable.BaseValue, Owner.Creature, this);
}
protected override void OnUpgrade() => DynamicVars.Damage.UpgradeValueBy(3m);
}
2.4 随机目标攻击
把 2.2 的 .Targeting(...) 换成 .TargetingRandomOpponents(CombatState!)——每一击都会重新随机摇人。 同一段数打不同人,这就是"随机 3 次"类卡牌的全部秘密。
2.5 X 费卡(费多少能量打多少下)
public sealed class FooXCost() : ModCardTemplate(0, CardType.Attack, CardRarity.Uncommon, TargetType.AllEnemies)
{
protected override bool HasEnergyCostX => true; // ← 声明这是 X 费卡(费用填 0)
protected override IEnumerable<DynamicVar> CanonicalVars => [new DamageVar(5m, ValueProp.Move)];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
int x = ResolveEnergyXValue(); // 解析 X = 剩余能量(含 ChemicalX 类修正)
await DamageCmd.Attack(DynamicVars.Damage.BaseValue)
.WithHitCount(x) // X 费的经典用法:段数 = X
.FromCard(this, cardPlay)
.TargetingAllOpponents(CombatState!)
.Execute(choiceContext);
}
protected override void OnUpgrade() => DynamicVars.Damage.UpgradeValueBy(3m);
}
2.6 获得格挡
public sealed class FooDefend() : ModCardTemplate(1, CardType.Skill, CardRarity.Common, TargetType.Self)
{
public override bool GainsBlock => true; // 声明"我是格挡卡"(悬停提示/系统逻辑用)
protected override IEnumerable<DynamicVar> CanonicalVars => [new BlockVar(5m, ValueProp.Move)];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
await CreatureCmd.GainBlock(Owner.Creature, DynamicVars.Block, cardPlay);
}
protected override void OnUpgrade() => DynamicVars.Block.UpgradeValueBy(3m);
}
2.7 给自己上增益(Power 卡)
public sealed class FooBuff() : ModCardTemplate(1, CardType.Power, CardRarity.Uncommon, TargetType.Self)
{
protected override IEnumerable<DynamicVar> CanonicalVars => [new PowerVar<StrengthPower>(2m)]; // 力量 2
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
await PowerCmd.Apply<StrengthPower>(choiceContext, Owner.Creature,
DynamicVars.Strength.BaseValue, // PowerVar 的属性名 = Power 类名
Owner.Creature, this);
}
protected override void OnUpgrade() => DynamicVars.Strength.UpgradeValueBy(1m);
}
Power 卡黄金法则:卡牌只负责"把 Power 挂上去";每回合触发什么,写在 Power 类自己的钩子里。 比如"恶魔形态"卡只有一行
Apply<DemonFormPower>,"每回合开始 +力量"在那边。见 §3.5。
2.8 攻击附带减益(最经典的卡牌形状)
攻击部分照抄 §2.1,末尾追加一行 Apply 即可:
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
await DamageCmd.Attack(DynamicVars.Damage.BaseValue)
.FromCard(this, cardPlay).Targeting(cardPlay.Target)
.Execute(choiceContext);
await PowerCmd.Apply<WeakPower>(choiceContext, cardPlay.Target, // 官方 zhs 名:虚弱
DynamicVars.Weak.BaseValue, Owner.Creature, this);
}
给敌人上 debuff 的 TargetType 要用 AnyEnemy;想"全体敌人虚弱+易伤"就仿照 §2.3 对 HittableEnemies 施加。
2.9 抽牌
protected override IEnumerable<DynamicVar> CanonicalVars => [new CardsVar(3)];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
await CardPileCmd.Draw(choiceContext, DynamicVars.Cards.BaseValue, Owner);
}
2.10 获得能量 / 辉星
// 能量 +2(CanonicalVars: new EnergyVar(2))
await PlayerCmd.GainEnergy(DynamicVars.Energy.BaseValue, Owner);
// 辉星 +3(CanonicalVars: new StarsVar(3))——储君的专属资源,官方名"辉星"
await PlayerCmd.GainStars(DynamicVars.Stars.BaseValue, Owner);
"下回合才获得能量"不要自己记状态,直接上现成 Power:PowerCmd.Apply<EnergyNextTurnPower>(...)(抢占先机 Outmaneuver 同款)。
2.11 回血 / 加上限
await CreatureCmd.Heal(Owner.Creature, 5m); // 回 5 点
await CreatureCmd.GainMaxHp(Owner.Creature, 3m); // 上限 +3(永久)
回血/上限类效果记得在类里加
public override bool CanBeGeneratedInCombat => false;, 防止战斗中随机生成打破平衡(官方 FruitJuice/RegenPotion 都这么做)。
2.12 弃牌 与"弃 X 抽 X"
// 弃一张(配选择界面)
IEnumerable<CardModel> picked = await CardSelectCmd.FromHandForDiscard(
choiceContext, Owner, new CardSelectorPrefs(SelectionScreenPrompt, 1), null, this);
await CardCmd.Discard(choiceContext, picked);
// 弃任意张、再抽等量(一条命令搞定,别自己拼 Discard+Draw——时序会错!)
List<CardModel> hand = PileType.Hand.GetPile(Owner).Cards.ToList();
await CardCmd.DiscardAndDraw(choiceContext, hand, hand.Count);
2.13 消耗别的牌换收益
// 烧掉手里所有非攻击牌,每张获得 5 格挡(重振精神 SecondWind 模式)
foreach (CardModel card in PileType.Hand.GetPile(Owner).Cards.Where(c => c.Type != CardType.Attack).ToList())
{
await CardCmd.Exhaust(choiceContext, card);
await CreatureCmd.GainBlock(Owner.Creature, DynamicVars.Block, cardPlay);
}
⚠️ 消耗牌必须一张一张来(官方注释明言不要做批量版):每张牌的消耗钩子要完整跑完再烧下一张。
2.14 生成牌(小刀模式)
生成一张官方现成的牌(如小刀 Shiv),直接调它自带的静态工厂:
protected override IEnumerable<DynamicVar> CanonicalVars => [new CardsVar(3)];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
await Shiv.CreateInHand(Owner, DynamicVars.Cards.IntValue, CombatState!); // 3 把小刀进手牌
}
生成三选一的牌(攻击药水模式):
List<CardModel> options = CardFactory.GetDistinctForCombat(Owner,
Owner.Character.CardPool.GetUnlockedCards(Owner.UnlockState, Owner.RunState.CardMultiplayerConstraint)
.Where(c => c.Type == CardType.Attack),
3, Owner.RunState.Rng.CombatCardGeneration);
CardModel? pick = await CardSelectCmd.FromChooseACardScreen(choiceContext, options, Owner, canSkip: true);
if (pick != null)
{
pick.SetToFreeThisTurn(); // 本回合免费(按需保留)
await CardPileCmd.AddGeneratedCardToCombat(pick, PileType.Hand, Owner); // ← 必须用这个 Add!
}
⚠️ 战斗中新生成的卡必须走
AddGeneratedCardToCombat(它会写生成历史、触发AfterCardGeneratedForCombat), 普通的CardPileCmd.Add会漏掉这些。变体:羽化(Metamorphosis)——随机攻击牌洗入抽牌堆且本场战斗 0 费:生成后
EnergyCost.SetThisCombat(0),再CardPileCmd.AddGeneratedCardToCombat(card, PileType.Draw, Owner, CardPilePosition.Random)(生成入堆一律走 AddGeneratedCardToCombat,用 CardPilePosition 控制塞进抽牌堆的位置)。
2.15 状态牌(回合结束在手里烧你)
状态/诅咒牌的三件套:费用 -1 + MaxUpgradeLevel => 0 + Unplayable 关键词:
public sealed class FooStatus() : ModCardTemplate(-1, CardType.Status, CardRarity.Status, TargetType.None)
{
public override int MaxUpgradeLevel => 0; // 不可升级
public override IEnumerable<CardKeyword> CanonicalKeywords => [CardKeyword.Unplayable]; // 不能被打出
public override bool HasTurnEndInHandEffect => true; // ← 必须与 OnTurnEndInHand 成对出现!
protected override IEnumerable<DynamicVar> CanonicalVars =>
[new DamageVar(2m, ValueProp.Unpowered | ValueProp.Move)]; // 无力自伤:不吃力量、不吃易伤
protected override async Task OnTurnEndInHand(PlayerChoiceContext choiceContext)
{
await CreatureCmd.Damage(choiceContext, Owner.Creature, DynamicVars.Damage, this, null);
}
}
忘写
HasTurnEndInHandEffect => true是新手第一名坑:OnTurnEndInHand根本不会被调用。 所有 11 张官方手牌触发牌都是状态/诅咒。
3. "每当……就……":钩子与时机效果
3.1 钩子是什么
AbstractModel 基类上有 180 多个 virtual 方法(钩子),游戏引擎在恰当时机对所有在场的模型逐个调用它们。 你的卡/遗物/Power 只要 override 自己关心的那个,就实现了"每当 X 发生就 Y"。
三个家族:
** Early/Late 怎么选**:很多钩子有 XxxEarly / Xxx / XxxLate 三个版本(游戏对全员先跑一遍 Early、再跑一遍普通、再跑一遍 Late)。 无脑用普通版。只有两种情况用别的:你的效果必须赶在所有人前面(VeryEarly/Early),或必须等所有人结算完(Late)。 官方注释里反复写 "CAREFUL! You should usually use xxx instead of this" 的就是在警告你这件事。
3.2 遗物形态一:拾取即生效(最简单)
/// <summary>拾取时最大生命 +7。</summary>
public sealed class FooRelic() : ModRelicTemplate(RelicRarity.Common)
{
public override bool HasUponPickupEffect => true; // 声明"拾取即生效"(UI/交易规则用)
protected override IEnumerable<DynamicVar> CanonicalVars => [new MaxHpVar(7m)];
public override async Task AfterObtained()
{
await CreatureCmd.GainMaxHp(Owner.Creature, DynamicVars.MaxHp.BaseValue);
}
}
AfterObtained 是遗物最大的入口(官方 300 件里 84 件重写它)。
3.3 遗物形态二:战斗开始给敌人挂减益
/// <summary>战斗开始时,给予所有敌人 1 层易伤。</summary>
public sealed class FooAggroRelic() : ModRelicTemplate(RelicRarity.Common)
{
protected override IEnumerable<DynamicVar> CanonicalVars => [new PowerVar<VulnerablePower>(1m)];
public override async Task BeforeSideTurnStart(PlayerChoiceContext choiceContext, CombatSide side,
IReadOnlyList<Creature> participants, ICombatState combatState)
{
// 首回合才触发:TurnNumber <= 1
if (participants.Contains(Owner.Creature) && Owner.PlayerCombatState.TurnNumber <= 1)
{
Flash(); // 遗物图标闪光(玩家看到"是它触发了")
await PowerCmd.Apply<VulnerablePower>(choiceContext, combatState.HittableEnemies,
DynamicVars.Vulnerable.BaseValue, Owner.Creature, null);
}
}
}
官方同类:弹珠袋(BagOfMarbles,首回合全敌易伤)与本例完全同款;赤牛(Akabeko,首回合给活力)同样做"首回合判定 + Flash()",只是挂在 AfterSideTurnStart 上——照抄即可。
3.4 遗物形态三:持续数值修正(改抽牌数/能量上限)
不做"某时刻触发",而是只要遗物在,数值就一直被改:
/// <summary>每回合开始时多抽 1 张牌。</summary>
public sealed class FooDrawRelic() : ModRelicTemplate(RelicRarity.Uncommon)
{
public override decimal ModifyHandDraw(Player player, decimal count)
{
return player != Owner ? count : count + 1; // 多人模式:只改自己;其他人原样放行
}
}
改能量上限用 ModifyMaxEnergy、改金币获取用 ModifyGoldGained、改 HP 流失用 ModifyHpLostXxx。 官方"副作用遗物"——灵体外质(Ectoplasm):+1 能量/金币归零 (ModifyMaxEnergy/ModifyGoldGained)就是这个模式;添水(Sozu):+1 能量/无法获得药水——前者走 Modify*,后者"禁不禁止"走 ShouldProcurePotion 谓词。
3.5 遗物形态四:计数器与用完即毁
/// <summary>每进入 3 个房间获得 25 金币,用完 3 次后作废。</summary>
public sealed class FooCounterRelic() : ModRelicTemplate(RelicRarity.Uncommon)
{
private const int MaxUses = 3;
[SavedProperty] // ← 存档持久化(跨存档保存计数)
public int UsesLeft { get; private set; } = MaxUses;
public override bool ShowCounter => true; // 图标上显示数字
public override int DisplayAmount => Math.Max(0, UsesLeft);
public override bool IsUsedUp => UsesLeft <= 0; // 用尽:图标变灰"已用完"
public override async Task AfterRoomEntered(AbstractRoom room)
{
if (IsUsedUp || room != Owner.RunState.BaseRoom) return; // BaseRoom = 地图节点本体(防重复)
Flash();
await PlayerCmd.GainGold(25m, Owner);
UsesLeft--;
if (IsUsedUp) Status = RelicStatus.Disabled;
InvokeDisplayAmountChanged(); // 通知 UI 刷新数字
}
}
计数器三件套:
[SavedProperty](存档)+ShowCounter/DisplayAmount(显示)+IsUsedUp(作废)。 setter 里改值后记得InvokeDisplayAmountChanged()。
更多值得研读的官方遗物(306 条描述逐条核对后挑选,机制都能用本节 + §2.2 钩子拼出来):
3.6 写自己的 Power(增益/减益)
Power 是"挂在生物身上的状态"。先想清楚 StackType:可叠层用 Counter(易伤/力量),不可叠用 Single(壁垒 Barricade)。
/// <summary>你的回合开始时,获得 X 点格挡。(示例:可叠层的回合格挡)</summary>
[RegisterPower]
public sealed class FooAuraPower : ModPowerTemplate
{
public override PowerType Type => PowerType.Buff; // Buff / Debuff
public override PowerStackType StackType => PowerStackType.Counter;
public override async Task AfterSideTurnStart(CombatSide side,
IReadOnlyList<Creature> participants, ICombatState combatState)
{
if (!participants.Contains(Owner)) return; // 多人模式:只在自己回合生效
Flash();
await CreatureCmd.GainBlock(Owner, Amount, ValueProp.Unpowered, null); // Power 给的格挡不吃敏捷
}
}
持续型减益(易伤式:几回合后自动消失)——递减时机抄官方三大减益的标准写法:
public override async Task AfterSideTurnEnd(PlayerChoiceContext choiceContext, CombatSide side,
IEnumerable<Creature> participants)
{
if (side == CombatSide.Enemy) // 在"敌方回合结束"时递减(怪给你上的当回合不扣)
await PowerCmd.TickDownDuration(this);
}
Power 的类名会直接成为卡面占位符(PowerVar<FooAuraPower> → {FooAuraPower}), 所以类名起好一点。Power 本地化在 powers 表,键格式与卡牌同理(mod id 前缀 + POWER + 类名大写下划线), 记得在语言目录里补齐 powers.json 并核对一次实际键名。
3.7 数值修改器:做一个"你的力量"
/// <summary>(教学用)打出攻击牌时,每击额外 +1 伤害。</summary>
[RegisterPower]
public sealed class FooMightPower : ModPowerTemplate
{
public override PowerType Type => PowerType.Buff;
public override PowerStackType StackType => PowerStackType.Counter;
public override bool AllowNegative => true; // 允许被减益扣成负数(力量同款)
// "这次伤害要加多少?"——返回加值。游戏对每个在场模型都问一遍。
public override decimal ModifyDamageAdditive(Creature? target, decimal amount, ValueProp props,
Creature? dealer, CardModel? cardSource, CardPlay? cardPlay)
{
if (Owner != dealer) return 0m; // 只加成宿主自己打出的伤害
if (!props.IsPoweredAttack()) return 0m; // 无力攻击不吃(药水/荆棘伤害等)
return Amount; // 每击 +层数
}
}
两行 return 0m 的判定是官方 StrengthPower 的原样骨架:第一问"是我干的吗",第二问"这刀吃加成吗"。 易伤/虚弱这类"乘算"写 ModifyDamageMultiplicative 返回 1.5m / 0.75m;格挡类写 ModifyBlockXxx。
3.8 Should 谓词:阻止某件事
// 壁垒式(Barricade):挂着此 Power 的生物,回合开始格挡不清空
public override bool ShouldClearBlock(Creature creature)
{
return Owner != creature; // 问的不是我 → 不干涉;问我 → false(不许清)
}
// 免死式(瓶中精灵的核心):
public override bool ShouldDie(Creature creature) => creature != Owner.Creature;
实战例子:储备(Reserves)的"能量不够支付时用辉星垫上"(每缺 1 点能量垫 2 颗辉星)走的就是 ShouldPayExcessEnergyCostWithStars 谓词——谓词家族的用途远不止"禁止",还有"代偿"。
谓词是"一问全场上百个模型"的短路问答,返回 false 即阻止,部分谓词还带 out preventer 让 UI 显示是谁阻止的。
4. 选修:特殊效果实现图鉴
从这里开始是选修内容:§2–§3 的必修积木能拼出约六成官方卡的形状,这一章收录的是有特殊实现的效果。 全章按主题分为六组(4.1 数值与公式 → 4.2 费用操作 → 4.3 牌堆与卡牌操作 → 4.4 生死与驱散 → 4.5 资源与专属系统 → 4.6 流程与规则), 组内由浅入深;每节独立、附完整可抄代码,按需取用、不用按顺序读。
都是官方验证过的实现模式,代码为压缩摘录。
4.1 组一:数值与公式——当数值不是写死的常数
4.1.1 计算型数值与求值来源(完美打击 PerfectedStrike / 全身撞击 BodySlam 式)
用 §1.3 的 ComputedDamage——预览会自动经过力量、易伤等官方修正管道,结算与卡面永远一致。 "每有一张 X 牌伤害 +2"(完美打击式):
public sealed class FooPerfected() : ModCardTemplate(1, CardType.Attack, CardRarity.Uncommon, TargetType.AnyEnemy)
{
protected override IEnumerable<DynamicVar> CanonicalVars =>
[
// 总值 = 底数(6) + 2 × 场上"打击"牌数;预览自动过 Hook.ModifyDamage
ModCardVars.ComputedDamage("Damage", 6m, CountStrikes),
];
// 委托用 static:防止变量随卡牌克隆时捕获创建时的实例
private static decimal CountStrikes(CardModel card, Creature? _)
{
if (card.Owner?.PlayerCombatState == null) // 图鉴/奖励里的规范卡无战斗上下文
return card.DynamicVars["Damage"].BaseValue; // 直接回底数
return card.DynamicVars["Damage"].BaseValue
+ 2m * card.Owner.PlayerCombatState.AllCards.Count(c => c.Tags.Contains(CardTag.Strike));
}
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
// 计算型数值的读取:EvaluateValueOrDefault 对目标求值(不是 .BaseValue!)
decimal dmg = DynamicVars.EvaluateValueOrDefault("Damage", target: cardPlay.Target);
await DamageCmd.Attack(dmg).FromCard(this, cardPlay).Targeting(cardPlay.Target).Execute(choiceContext);
}
protected override void OnUpgrade() => DynamicVars["Damage"].UpgradeValueBy(2m); // 底数升级 +2
}
描述 JSON 里 {Damage:diff()} 会显示 6+2×张数 并实时变色。官方原版的 CalculatedDamageVar + CalculationBase/ExtraVar 三件套能实现同样效果,但 RitsuLib 文档明确不建议再用—— 读官方卡代码时能认出来即可,自己写就用 ComputedDamage。格挡版用 ComputedBlock(预览走敏捷/脆弱管道)。
求值来源速查——"造成等于当前格挡的伤害"(全身撞击 BodySlam 式)"格挡 = 弃牌堆张数"(堆栈式)…… 套路相同,唯一的问题是从正确的位置把量读出来:
BodySlam 式骨架——求值时才读,预览和结算自然一致:
protected override IEnumerable<DynamicVar> CanonicalVars =>
[ModCardVars.ComputedDamage("Damage", 0m, CountBlock)];
private static decimal CountBlock(CardModel card, Creature? _)
=> card.Owner?.Creature == null
? 0m // 规范卡(图鉴/奖励)无战斗上下文
: card.Owner.Creature.Block; // 求值那一刻的当前格挡
4.1.2 战斗历史计数(谋杀 Murder 式)
"查询过去"用战斗历史:CombatManager.Instance.History.Entries.OfType<具体条目>()。 配合 §1.3 的 ComputedDamage,数值直接跟着历史走,不需要自己维护计数器:
/// <summary>你在本场战斗中每抽过一张牌,此牌额外造成 1 点伤害。</summary>
public sealed class FooMurder() : ModCardTemplate(3, CardType.Attack, CardRarity.Rare, TargetType.AnyEnemy)
{
protected override IEnumerable<DynamicVar> CanonicalVars =>
[
// 显示与结算 = 底数(1) + 本场战斗中"我"抽过的牌数;预览自动过力量/易伤管道
ModCardVars.ComputedDamage("Damage", 1m, CountDrawn),
];
private static decimal CountDrawn(CardModel card, Creature? _)
{
if (card.Owner?.Creature == null) // 规范卡(图鉴/奖励)无拥有者
return card.DynamicVars["Damage"].BaseValue;
return card.DynamicVars["Damage"].BaseValue
+ CombatManager.Instance.History.Entries
.OfType<CardDrawnEntry>() // 官方的"抽牌"历史条目
.Count(e => e.Actor == card.Owner.Creature); // 只数自己抽的
}
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
decimal dmg = DynamicVars.EvaluateValueOrDefault("Damage", target: cardPlay.Target);
await DamageCmd.Attack(dmg).FromCard(this, cardPlay).Targeting(cardPlay.Target)
.WithHitFx("vfx/vfx_attack_slash").Execute(choiceContext);
}
protected override void OnUpgrade() => EnergyCost.UpgradeBy(-1); // 官方谋杀(Murder)的升级是降费
}
常用历史条目:
CardDrawnEntry(抽牌)、CardPlayFinishedEntry(卡打出完成,条目上有HappenedThisTurn(...)判"是不是本回合")。两种计数姿势怎么选:加伤害/格挡这类"数值"用历史查询(免维护,如本节); 改费用这类"没有计算型机制"的用 §4.2.2 的钩子记账。
4.1.3 持久成长与防降级(爪击 Claw 式)
public sealed class FooClaw() : ModCardTemplate(0, CardType.Attack, CardRarity.Common, TargetType.AnyEnemy)
{
private decimal _extraFromPlays; // 记录"本局打出来的成长"
protected override IEnumerable<DynamicVar> CanonicalVars =>
[
new DamageVar(3m, ValueProp.Move),
new DynamicVar("Increase", 2m), // 自定义数值 Var:"每次+2"里的 2
];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
await DamageCmd.Attack(DynamicVars.Damage.BaseValue).FromCard(this, cardPlay)
.Targeting(cardPlay.Target).Execute(choiceContext);
// 遍历全场(含抽/弃牌堆)所有同名卡,一起变强
foreach (FooClaw c in Owner.PlayerCombatState.AllCards.OfType<FooClaw>())
{
c.DynamicVars.Damage.BaseValue += DynamicVars["Increase"].BaseValue;
c._extraFromPlays += DynamicVars["Increase"].BaseValue; // 私有字段:本卡累计成长量
}
}
// 降级(被效果打回原形)会把 BaseValue 重置——必须在这里把成长补回来,否则白打!
protected override void AfterDowngraded() => DynamicVars.Damage.BaseValue += _extraFromPlays;
}
官方爪击(Claw)/Rampage/KinglyPunch 全是这个形状:私有字段记账 + AfterDowngraded 回补。 跨战斗还要保留的( GeneticAlgorithm 式),再叠一层
[SavedProperty]+ 写回DeckVersion。
4.1.4 临时属性:仅本回合生效的力量/敏捷(凌虐 Mangle / 预备打击 SetupStrike 式)
"本回合 +3 力量"和"敌人力量 -10(本回合)"是同一套机制的两面。官方为每张来源卡配一个 临时包装 Power,继承抽象类 TemporaryStrengthPower(敏捷/集中同理有 TemporaryDexterityPower / TemporaryFocusPower)。子类薄到只有两行——不要自己在卡牌里写"回合结束移除",同步与还原 全部由包装自动完成:
// 预备打击(SetupStrike):给自己 +3 临时力量(本回合)
public class SetupStrikePower : TemporaryStrengthPower
{
public override AbstractModel OriginModel => ModelDb.Card<SetupStrike>(); // 来源卡(图标/名称显示用)
// IsPositive 默认 true = 正向临时力量
}
// 凌虐(Mangle):敌人力量 -10(本回合)——同一个抽象类的负向版
public class ManglePower : TemporaryStrengthPower
{
public override AbstractModel OriginModel => ModelDb.Card<Mangle>();
protected override bool IsPositive => false; // 负向:显示为减益
}
卡牌侧就是普通的 PowerCmd.Apply(凌虐:攻击后给敌人挂负向包装;预备打击:攻击后给自己挂正向包装):
// 预备打击(CanonicalVars 用 new PowerVar<StrengthPower>(3m) 声明层数)
await PowerCmd.Apply<SetupStrikePower>(choiceContext, Owner.Creature,
DynamicVars.Strength.BaseValue, Owner.Creature, this);
// 凌虐(CanonicalVars: new DynamicVar("StrengthLoss", 10m))
await PowerCmd.Apply<ManglePower>(choiceContext, cardPlay.Target,
DynamicVars["StrengthLoss"].BaseValue, Owner.Creature, this);
机制(TemporaryStrengthPower 内部已完成,mod 不用管,但要知道原理):
包装挂上时(
BeforeApplied):自动对目标施加真正的StrengthPower(+Sign×Amount,静默)—— 本回合的伤害加成走的是真力量,所以"按力量层数"类的效果全部正常工作。包装层数变化时(
AfterPowerAmountChanged):给真力量同步增减。宿主回合结束时(
AfterSideTurnEnd):闪光 → 移除包装 → 对真力量施加-Sign×Amount精确还原(正负由IsPositive决定——同一套代码支撑"给自己临时力量"与"给敌人临时减力量")。图标名动态显示来源(
OriginModel的卡/药水/遗物标题,这就是游戏里 buff 显示为"预备打击"的原因);ITemporaryPower实现者还会被统计类效果特殊对待——苦难(Misery)复制减益、狩猎判定都靠InternallyAppliedPower把包装映射回真身。
同族:
TemporaryDexterityPower(临时敏捷)、TemporaryFocusPower(临时集中)、SleightOfFleshPower(吸血变体)、IllusionPower。想给敌人"本回合 -X 敏捷", 继承TemporaryDexterityPower并IsPositive => false即可。
4.1.5 Power 花样:定期上增益(仪式 Ritual 式)与一次性强化(活力 Vigor 式)
仪式式——"Power 定期给自己(或别人)上别的 Power":
private bool _active; // 私有状态;写它的 setter 里按惯例 AssertMutable()
public override Task AfterApplied(Creature? applier, CardModel? cardSource)
{
_active = true; // 刚挂上:开始生效
return Task.CompletedTask;
}
public override async Task AfterSideTurnEnd(PlayerChoiceContext choiceContext, CombatSide side,
IEnumerable<Creature> participants)
{
if (!_active || !participants.Contains(Owner)) return;
await PowerCmd.Apply<StrengthPower>(choiceContext, Owner, 1m, Owner, null);
}
活力式——"只强化下一次攻击":用基类的 InternalData(实例隔离、克隆时经 InitInternalData 重建) 记住"是哪一次攻击",加成只作用于它:
private sealed class Data { public AttackCommand? Cmd; }
protected override object InitInternalData() => new Data();
public override Task BeforeAttack(AttackCommand command)
{
if (command.Attacker != Owner || !command.DamageProps.IsPoweredAttack())
return Task.CompletedTask;
GetInternalData<Data>().Cmd = command; // 记下"这一次"攻击
return Task.CompletedTask;
}
// 在 ModifyDamageAdditive 里:仅当本次结算属于 Data.Cmd 记录的那次攻击时返回层数,然后清空 Cmd
// (完整官方实现见活力 VigorPower.cs)
官方仪式(Ritual)Power 还会用私有布尔记录"宿主是否为敌人"(用于跳过首次触发,setter 内
AssertMutable())。
4.1.6 翻倍现有格挡(巩固 Entrench / 固化药水 Fortifier 式)
// "翻倍"= 再获得等于现有格挡的量(现有格挡保持不动):
await CreatureCmd.GainBlock(Owner.Creature, Owner.Creature.Block, ValueProp.Unpowered, null);
// 固化药水(Fortifier)则是"三倍":再获得 2 × 现有格挡
await CreatureCmd.GainBlock(target, target.Block * 2, ValueProp.Unpowered, null);
要点:带 Unpowered——翻倍出来的量不该再吃一次敏捷。想让卡面实时显示"翻倍后总格挡", 用 §1.3 的 ComputedBlock,算式里读 Owner.Creature.Block。
4.2 组二:费用操作——让牌更便宜(或更贵)
4.2.1 静态减费 / 免费 / 提费
someCard.SetToFreeThisTurn(); // 本回合 0 费(生成卡常用)
someCard.EnergyCost.AddThisCombat(-1); // 本场战斗费用 -1
someCard.EnergyCost.SetThisTurn(0); // 仅本回合 0 费
"每打出一张技能牌,手牌费 -1"这类持续减费是"三件套": 进入战斗时翻战斗历史补账 + AfterCardPlayed 实时续减 + 落点 EnergyCost.AddThisTurn/AddThisCombat(-n) (按"本回合/本场战斗"口径选)——外加 if (card != this) return; 守卫。完整可抄实现在 §4.2.2(官方精密瞄准 Pinpoint 同款)。 提费同理:重构(Transfigure)式 EnergyCost.AddThisCombat(1) + 公开属性 BaseReplayCount++(打两次)。
4.2.2 动态减费:"每打出过一张技能牌,费用 -1"(精密瞄准 Pinpoint 式双钩子记账)
费用没有"计算型"机制,所以要记账。官方精密瞄准(Pinpoint)的完整骨架是"双钩子":
/// <summary>你本回合每打出过一张技能牌,这张牌的费用 -1。</summary>
public sealed class FooPinpoint() : ModCardTemplate(3, CardType.Attack, CardRarity.Uncommon, TargetType.AnyEnemy)
{
protected override IEnumerable<DynamicVar> CanonicalVars => [new DamageVar(15m, ValueProp.Move)];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
await DamageCmd.Attack(DynamicVars.Damage.BaseValue).FromCard(this, cardPlay)
.Targeting(cardPlay.Target).Execute(choiceContext);
}
// ① 补账:这张牌是战斗中途才进手的(生成/检索),把手牌前本回合已发生的技能牌一次性算回来
public override Task AfterCardEnteredCombat(CardModel card)
{
if (card != this || IsClone) return Task.CompletedTask; // 两个标准守卫
int amount = CombatManager.Instance.History.CardPlaysFinished
.Count(e => e.CardPlay.Card.Type == CardType.Skill
&& e.CardPlay.Player == Owner
&& e.HappenedThisTurn(CombatState!));
ReduceCostBy(amount);
return Task.CompletedTask;
}
// ② 续账:之后每打出一张技能牌,实时再减
public override Task AfterCardPlayed(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
if (cardPlay.Card.Owner != Owner || cardPlay.Card.Type != CardType.Skill)
return Task.CompletedTask;
ReduceCostBy(1);
return Task.CompletedTask;
}
private void ReduceCostBy(int amount) => EnergyCost.AddThisTurn(-amount); // 本回合口径用 ThisTurn
protected override void OnUpgrade() => DynamicVars.Damage.UpgradeValueBy(4m);
}
要点:
"本回合每……"用
AddThisTurn(-n),"本场战斗每……"用AddThisCombat(-n)——口径别选错。两次判重守卫是官方惯例:
if (card != this) return;(钩子会对每张卡触发)+if (IsClone) return;(复制品不重复记账)。官方同类:午夜(Midnight)/精密瞄准(Pinpoint)/踩踏(Stomp)/女妖之嚎(BansheesCry)/重压(Flatten) (触发源分别是消耗/技能/攻击/虚无牌/奥斯提攻击)。
4.2.3 "下一张牌"效果家族(无情猛攻 Unrelenting / 猛扑 Pounce 式)
"你打出的下一张攻击牌耗能为 0""下一张技能牌耗能为 0"……官方做法是挂一个计数包装 Power, 在 Power 里拦截下一次对应事件。四张 0 费卡共用同一套骨架(Unrelenting → FreeAttackPower、 Pounce → FreeSkillPower、Synthesis → FreePowerPower、Veilpiercer → VeilpiercerPower):
public class FreeAttackPower : PowerModel // 官方原文骨架(略去无关行)
{
public override PowerType Type => PowerType.Buff;
public override PowerStackType StackType => PowerStackType.Counter;
public override bool TryModifyEnergyCostInCombatLate(CardModel card, decimal originalCost, out decimal modifiedCost)
{
modifiedCost = originalCost;
if (card.Owner.Creature != Owner) return false; // 只改自己打出的牌
if (card.Type != CardType.Attack) return false; // 只改攻击牌
if (card.Pile?.Type is not (PileType.Hand or PileType.Play)) return false; // 手牌中/正在打出
modifiedCost = 0m; // 免费!
// ……随后消耗自身一层(在 BeforeCardPlayed 钩子里做,用完即止),原文略
return true;
}
}
卡牌侧照常一行:PowerCmd.Apply<FreeAttackPower>(choiceContext, Owner.Creature, 1m, Owner.Creature, this);
同族变体只是换一个拦截钩子:
4.3 组三:牌堆与卡牌操作——牌去哪儿、牌从哪来
4.3.1 发现 / 检索:把牌弄进手里(富足 Abundance / 指导 Tutor / 捞牌)
三型入门流程都在 §2.14 的"三选一"(CardFactory.GetDistinctForCombat → FromChooseACardScreen → SetToFreeThisTurn → AddGeneratedCardToCombat)。这里补两个变体:
// A 型(富足 Abundance):发现的候选是【升级版】、不可放弃
foreach (CardModel option in options) CardCmd.Upgrade(option); // 候选先升级
CardModel? pick = await CardSelectCmd.FromChooseACardScreen(choiceContext, options, Owner); // 不传 canSkip = 不可放弃
// 另注意:Abundance 本体 CanBeGeneratedInCombat => false(Ancient 查询奖励,防再生成)
// B 型(指导 Tutor):检索【队友】的抽牌堆,挑一张放进其手牌(多人卡)
public sealed class FooTutor() : ModCardTemplate(1, CardType.Skill, CardRarity.Rare, TargetType.AnyAlly)
{
public override CardMultiplayerConstraint MultiplayerConstraint => CardMultiplayerConstraint.MultiplayerOnly;
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
Player ally = cardPlay.Target.Player;
CardModel? pick = (await CardSelectCmd.FromCombatPile(choiceContext,
PileType.Draw.GetPile(ally), ally,
new CardSelectorPrefs(SelectionScreenPrompt, 1), null)).FirstOrDefault();
if (pick != null)
await CardPileCmd.Add(pick, PileType.Hand); // 牌本来属于队友 → 自动回队友手牌(用 Add,不是生成)
}
}
4.3.2 自动出牌(破灭 Havoc / 倾泻 Cascade 式)
// 从抽牌堆顶自动打出 N 张(不够自动洗牌);破灭(Havoc)传 forceExhaust: true
await CardPileCmd.AutoPlayFromDrawPile(choiceContext, Owner, DynamicVars.Cards.IntValue,
CardPilePosition.Top, forceExhaust: false);
// 免费自动打"指定的某张牌"(低语耳环 WhisperingEarring 同款;skipXCapture 防 X 费卡二次捕获)
await CardCmd.AutoPlay(choiceContext, someCard, target, AutoPlayType.Default, skipXCapture: true);
skipXCapture: true用于 X 费卡:资源已由调用方花掉,别再捕获一次 X 值。变体:地狱狂徒(Hellraiser)——在
AfterCardDrawnEarly里检测抽到的牌带CardTag.Strike标签(打击族)就CardCmd.AutoPlay自动打出(带每回合次数上限防死循环)。自动出牌的触发时机可以挂在任何钩子上。
4.3.3 打完后的去向:复制自己(愤怒 Anger 式)与改去向钩子
// 愤怒(Anger)式:打完再塞一张克隆进弃牌堆
CardModel copy = CreateClone();
CardCmd.PreviewCardPileAdd(await CardPileCmd.AddGeneratedCardToCombat(copy, PileType.Discard, Owner), 2.2f);
// 改"打完去哪":重写去向钩子(魔球 TheBall 用它在多人模式把弃置改投队友牌堆)
public override CardLocation GetResultLocationForCardPlay()
{
if (Keywords.Contains(CardKeyword.Exhaust))
return new CardLocation(Owner, PileType.Exhaust, CardPilePosition.Bottom);
return new CardLocation(Owner, PileType.Discard, CardPilePosition.Bottom);
}
重写会覆盖默认逻辑(Power/复制品→移除、消耗关键词→消耗堆、否则弃牌堆),照抄默认分支再改你要改的。 ⚠️ 给别的玩家转移卡不要在
OnPlay里直接调GiveToAnotherPlayer(避坑 #6)。
4.3.4 延迟回手 / 自我循环(流星锤 Bolas / 得力助手 RightHandHand 式)
"下个回合开始时返回手牌"——官方做法依然是查历史而不是记状态(和 §4.1.2 同思路):
/// <summary>流星锤:造成 3 点伤害。在你的下个回合开始时,将此卡返回你的手牌。</summary>
public sealed class FooBolas() : ModCardTemplate(0, CardType.Attack, CardRarity.Rare, TargetType.AnyEnemy)
{
protected override IEnumerable<DynamicVar> CanonicalVars => [new DamageVar(3m, ValueProp.Move)];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
await DamageCmd.Attack(DynamicVars.Damage.BaseValue).FromCard(this, cardPlay)
.Targeting(cardPlay.Target).Execute(choiceContext);
// 注意:OnPlay 里什么都不安排!
}
// 每次回合开始抽牌前查一遍历史:"上一回合我打过这张牌吗?打过、且现在不在手里 → 回手"
public override async Task BeforeHandDraw(Player player, PlayerChoiceContext choiceContext,
ICombatState combatState)
{
if (player != Owner) return;
bool playedLastTurn = CombatManager.Instance.History.CardPlaysFinished
.Any(e => e.HappenedLastPlayerTurn(Owner) && e.CardPlay.Card == this);
if (playedLastTurn && Pile?.Type != PileType.Hand)
await CardPileCmd.Add(this, PileType.Hand);
}
}
同族:无休手斧(ThrummingHatchet,同款)、粒子墙(ParticleWall,即时回手——重写 GetResultLocationForCardPlay 把打完的弃置去向改成手牌)、得力助手(RightHandHand,"每打出能量值 ≥2 的牌 后把自己从弃牌堆回手"——在 AfterCardPlayedLate 里查条件后回手)。
4.3.5 上关键词:虚无 / 保留 / 奇巧(雕琢打击 SculptingStrike / 虚空之唤 CallOfTheVoid 式)
关键词施加只有一个核心 API:CardCmd.ApplyKeyword(card, params CardKeyword[])(void 同步调用)。 难点不在 API,在三种时机怎么选:
① 永久上关键词——雕琢打击:攻击后从手牌选一张(排除已有虚无的),给它永久虚无:
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
await DamageCmd.Attack(DynamicVars.Damage.BaseValue).FromCard(this, cardPlay)
.Targeting(cardPlay.Target).Execute(choiceContext);
// 过滤:排除"自身已带虚无"的牌——注意用 GetKeywordsWithSources(KeywordSources.Local)
// 只看卡自身的关键词,避免全局来源(别的 Power 给的)干扰判断
CardModel? pick = (await CardSelectCmd.FromHand(choiceContext, Owner,
new CardSelectorPrefs(SelectionScreenPrompt, 1),
(CardModel c) => !c.GetKeywordsWithSources(KeywordSources.Local).Contains(CardKeyword.Ethereal),
this)).FirstOrDefault();
if (pick != null)
CardCmd.ApplyKeyword(pick, CardKeyword.Ethereal); // 永久虚无(void 同步调用)
}
② 生成的牌自带关键词——虚空之唤:每回合起手抽牌时,额外生成 N 张"带虚无的随机牌"。 关键点:在入堆之前给生成的卡上关键词(AddGeneratedCardsToCombat 之前),并排除 Basic/Ancient 这类"不该被改造"的稀有度:
// CallOfTheVoidPower 内部(层数 = 每回合生成几张)
public override async Task BeforeHandDraw(Player player, PlayerChoiceContext choiceContext,
ICombatState combatState)
{
if (player != Owner.Player) return; // 多人:只管自己
List<CardModel> pool = Owner.Player.Character.CardPool
.GetUnlockedCards(player.UnlockState, player.RunState.CardMultiplayerConstraint)
.Where(c => c.Rarity != CardRarity.Basic && c.Rarity != CardRarity.Ancient) // 排除初始/古卡
.ToList();
var generated = new CardModel[Amount];
for (int i = 0; i < Amount; i++)
{
generated[i] = CardFactory.GetDistinctForCombat(player, pool, 1,
player.RunState.Rng.CombatCardGeneration).First();
CardCmd.ApplyKeyword(generated[i], CardKeyword.Ethereal); // 入堆前打上虚无
}
Flash();
await CardPileCmd.AddGeneratedCardsToCombat(generated, PileType.Hand, Owner.Player);
}
③ 仅本回合的关键词——保留/奇巧有现成的单回合 API(常驻的才走 ApplyKeyword):
// 本回合抽的牌全部保留(Expertise 式):对 Draw 返回的卡逐张调用
foreach (CardModel c in await CardPileCmd.Draw(choiceContext, 3, Owner))
CardCmd.ApplySingleTurnRetain(c);
// 选一张手牌获得奇巧(HandTrick 式)
CardModel? pick = (await CardSelectCmd.FromHand(choiceContext, Owner,
new CardSelectorPrefs(SelectionScreenPrompt, 1), null, this)).FirstOrDefault();
if (pick != null) CardCmd.ApplySingleTurnSly(pick);
还有一种"全局关键词来源":
TryModifyKeywordsInCombat钩子(战斗中给某张卡增删关键词, 不落库、随来源存在而存在,如 Hex 系)。移除关键词用CardCmd.RemoveKeyword。
4.3.6 变形:把一张卡换成另一张
await CardCmd.TransformTo<Abundance>(someCard); // 原地变成指定卡
await CardCmd.Transform(someCard, CombatState.CreateCard<OtherCard>(Owner)); // 换成构造好的新卡(官方 CreateCard + Transform 模式)
await CardCmd.TransformToRandom(someCard, rng); // 随机变形
4.3.7 复制敌人身上的减益(苦难 Misery 式)
不要盲目 Apply——先找目标身上有没有同名实例:有就 PowerCmd.ModifyAmount 叠层,没有才 Apply 克隆 (盲目 Apply 会丢叠层语义)。简化骨架:
// 快照目标身上的全部减益
List<PowerModel> snapshot = target.Powers
.Where(p => p.TypeForCurrentAmount == PowerType.Debuff)
.Select(p => (PowerModel)p.ClonePreservingMutability())
.ToList();
foreach (Creature enemy in CombatState!.HittableEnemies.Where(e => e != target))
{
foreach (PowerModel clone in snapshot)
{
PowerModel? existing = PowerCmd.FindExistingInstanceForStacking(clone, enemy, Owner.Creature);
if (existing != null)
await PowerCmd.ModifyAmount(choiceContext, existing, clone.Amount, Owner.Creature, this); // 已有:叠层
else
await PowerCmd.Apply(choiceContext, clone, enemy, clone.Amount, Owner.Creature, this); // 没有:上克隆
}
}
4.4 组四:生死与驱散
4.4.1 斩杀(Fatal):击杀奖励模板
"击杀时获得 X"的坑在于:目标可能带免死效果(瓶中精灵类)。官方模板两步走:
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
// ① 打之前先问一遍:目标身上有没有"免死也不触发击杀奖励"的 Power
bool shouldTriggerFatal = cardPlay.Target.Powers.All(p => p.ShouldOwnerDeathTriggerFatal());
AttackCommand cmd = await DamageCmd.Attack(DynamicVars.Damage.BaseValue)
.FromCard(this, cardPlay).Targeting(cardPlay.Target).Execute(choiceContext);
// ② 打完检查结果:Results 是"每击一个列表"的嵌套结构,必须 SelectMany 摊平
if (shouldTriggerFatal && cmd.Results.SelectMany(r => r).Any(r => r.WasTargetKilled))
{
await CreatureCmd.GainMaxHp(Owner.Creature, DynamicVars.MaxHp.IntValue);
}
}
卡面记得加 HoverTipFactory.Static(StaticHoverTip.Fatal) 提示词(官方中文关键词名:斩杀; 官方狂宴 Feed/贪婪之手 HandOfGreed 同款)。
4.4.2 复活与自毁(瓶中精灵 FairyInABottle / 孤注一掷 TheGambit 式)
复活药水是全自动触发的药水(Usage => PotionUsage.Automatic,玩家不能主动喝):
public sealed class FooFairy() : ModPotionTemplate(PotionRarity.Rare, PotionUsage.Automatic, TargetType.Self)
{
public override bool CanBeGeneratedInCombat => false; // 不可在战斗中随机生成
protected override async Task OnUse(PlayerChoiceContext choiceContext, Creature? target)
{
AssertValidForTargetedPotion(target);
await CreatureCmd.Heal(target, Math.Max((decimal)target.MaxHp * 0.3m, 1m));
}
// 死亡流程会问全场"还允许死吗"——不是宿主就放行,是宿主就否决
public override bool ShouldDie(Creature creature) => creature != Owner.Creature;
// 否决成功后游戏回调这里:复活 = 把"使用药水"的完整流程手动跑一遍
public override async Task AfterPreventingDeath(Creature creature)
=> await OnUseWrapper(new ThrowingPlayerChoiceContext(), creature);
}
反向操作:孤注一掷(TheGambit)——它附加的 TheGambitPower 在 AfterDamageReceived 里检测"受到未被 格挡的攻击伤害"后 CreatureCmd.Kill(Owner.Creature) 自毁。同一套死亡钩子,既能救场也能立死亡 flag; Kill(force: true) 连免死保护都能一起绕过。
4.4.3 清空敌人的格挡与人工制品(暴露 Expose 式)
public sealed class FooExpose() : ModCardTemplate(0, CardType.Skill, CardRarity.Uncommon, TargetType.AnyEnemy)
{
public override IEnumerable<CardKeyword> CanonicalKeywords => [CardKeyword.Exhaust];
protected override IEnumerable<DynamicVar> CanonicalVars => [new DynamicVar("Power", 2m)];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
Creature target = cardPlay.Target;
// ① 清空格挡:数量直接传 target.Block,一句"全删"
await CreatureCmd.LoseBlock(choiceContext, target, target.Block, Owner.Creature);
// ② 显式吃掉人工制品——不先移除,下一步的易伤会被它抵消
if (target.HasPower<ArtifactPower>())
{
await PowerCmd.Remove<ArtifactPower>(target);
}
// ③ 现在再上易伤就稳了
await PowerCmd.Apply<VulnerablePower>(choiceContext, target,
DynamicVars["Power"].IntValue, Owner.Creature, this);
}
protected override void OnUpgrade() => DynamicVars["Power"].UpgradeValueBy(1m);
}
通用结论:怕被人工制品挡掉的减益,先
HasPower<ArtifactPower>()+PowerCmd.Remove<ArtifactPower>摘除。 人工制品的抵消发生在系统层,你的 Apply 不会报错,只会静默无效。
4.4.4 给牌附附魔 / 畸变
CardCmd.Enchant<Inky>(someCard, 1m); // 附魔(BladeOfInk 给生成的小刀附魔同款)
// ⚠️ void 同步调用,不 await
await CardCmd.Afflict<SomeAffliction>(someCard, 1m); // 畸变:异步,失败返回 null
CardCmd.ClearEnchantment(someCard); // 清除
4.5 组五:资源与专属系统
4.5.1 召唤奥斯提(护卫 Bodyguard / 骨头酿 BoneBrew 式)
protected override IEnumerable<DynamicVar> CanonicalVars => [new SummonVar(5m)];
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
await OstyCmd.Summon(choiceContext, Owner, DynamicVars.Summon.BaseValue, this);
// 目标身上已有奥斯提 → 自动转为加其最大 HP;最后一个参数 source 传 this,供钩子溯源
// 反向操作——处决自己的奥斯提换收益:CreatureCmd.Kill(BoneShards/Sacrifice 式)
}
4.5.2 铸造(王之勇气 KingsCourage 式)
protected override IEnumerable<DynamicVar> CanonicalVars => [new ForgeVar(15)];
protected override async Task OnUse(PlayerChoiceContext choiceContext, Creature? target) // 药水写法
{
await ForgeCmd.Forge(DynamicVars.Forge.IntValue, target!.Player, this);
// 本场战斗首次铸造:自动把君王之剑(Sovereign Blade)塞进手牌;之后每次调用 = 君王之剑 +15
}
4.5.3 充能球:生成 / 栏位 / 被动(球状闪电 BallLightning / 扩容 Capacitor / 漆黑 Darkness 式)
await OrbCmd.Channel<LightningOrb>(choiceContext, Owner); // 生成一个闪电充能球
await OrbCmd.AddSlots(Owner, 2); // +2 栏位(超上限自动钳制)
await OrbCmd.RemoveSlots(Owner, 1); // 移除 1 栏位(从队尾,含已生成好的球)
await OrbCmd.EvokeNext(choiceContext, Owner); // 激发最早生成的球
// 手动触发场上所有暗珠的被动(漆黑 Darkness 式;目标传 null = 珠子自选目标)
foreach (OrbModel orb in Owner.PlayerCombatState.OrbQueue.Orbs.Where(o => o is DarkOrb))
await OrbCmd.Passive(choiceContext, orb, null);
4.5.4 生成药水(炼制药水 Alchemize 式)
PotionModel potion = PotionFactory
.CreateRandomPotionInCombat(Owner, Owner.RunState.Rng.CombatPotionGeneration)
.ToMutable();
PotionProcureResult result = await PotionCmd.TryToProcure(potion, Owner); // 药水栏满 → success == false
4.6 组六:流程与规则
4.6.1 读敌人意图(眼部攻击 GoForTheEyes 式)
"如果敌人的意图是攻击,则……"——意图挂在 Monster.IntendsToAttack 上:
protected override async Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay)
{
ArgumentNullException.ThrowIfNull(cardPlay.Target);
await DamageCmd.Attack(DynamicVars.Damage.BaseValue).FromCard(this, cardPlay)
.Targeting(cardPlay.Target).Execute(choiceContext);
if (cardPlay.Target.Monster.IntendsToAttack) // ← 敌人打算攻击?
{
await PowerCmd.Apply<WeakPower>(choiceContext, cardPlay.Target,
DynamicVars.Weak.BaseValue, Owner.Creature, this);
}
}
// 加分项:卡面金光联动——场上有敌人打算攻击时发金光,告诉玩家"现在打正合适"
protected override bool ShouldGlowGoldInternal
{
get
{
if (CombatState == null) return false;
return CombatState.HittableEnemies.Any(e => e.Monster?.IntendsToAttack ?? false);
}
}
意图体系(出招表、意图图标、
Stun改写出招)属 MonsterMoves 系统,本文档只到IntendsToAttack这一层,想深入可以读反编译源码里的 MonsterMoves 相关类。
4.6.2 延时效果:"下个回合……"(抢占先机 Outmaneuver / 瓶中船 ShipInABottle 式)
"下回合获得能量 / 抽牌 / 获得格挡 / 召唤……"从不靠卡牌自己记账——挂一个包装 Power, 在合适的回合边界钩子里做事、然后自移除。官方为最常见的三种需求准备了现成 Power, 而且它们恰好示范了三种不同的钩选策略:
用法都是一行(瓶中船 ShipInABottle:现在 +10,下回合开始再 +10):
await CreatureCmd.GainBlock(Owner.Creature, DynamicVars.Block, cardPlay);
await PowerCmd.Apply<BlockNextTurnPower>(choiceContext, Owner.Creature,
DynamicVars.Block.IntValue, Owner.Creature, this);
自定义延时(官方没有现成 Power 时)——熔炉(Furnace)"每回合开始铸造"式:
public class FooTickPower : ModPowerTemplate
{
public override PowerType Type => PowerType.Buff;
public override PowerStackType StackType => PowerStackType.Counter; // 层数 = 剩余次数
public override async Task AfterSideTurnStart(CombatSide side,
IReadOnlyList<Creature> participants, ICombatState combatState)
{
if (!participants.Contains(Owner)) return; // 多人:只在宿主回合走
Flash();
/* ……做你的事:抽牌 / 召唤 / 生成充能球…… */
if (Amount <= 1) await PowerCmd.Remove(this); // 次数用完自移除
else await PowerCmd.Decrement(this); // 永久循环型则不自移除(仪式式,见 §4.1.5)
}
}
钩子怎么选:跟能量相关钩
AfterEnergyReset;跟格挡相关钩AfterBlockCleared(在清除之后给, 新格挡才站得住);跟抽牌数量相关实现ModifyHandDraw;需要弹玩家选择的用AfterPlayerTurnStart。多回合递减用 Counter 层数,无限循环用 §4.1.5 的仪式式。
4.6.3 立即结束回合(虚空形态 VoidForm 式)
await PowerCmd.Apply<VoidFormPower>(choiceContext, Owner.Creature, 1m, Owner.Creature, this);
PlayerCmd.EndTurn(Owner, canBackOut: false); // 注意:同步调用,不 await;先上增益再结束
4.6.4 任务卡(Quest):立牌子 → 记进度 → 领奖收尾
任务卡是一张不能打出的牌,躺在你的牌组里充当"任务牌子":它要么扭曲你的地图/事件(走到指定地方), 要么默默记录进度(做过 N 件事),达成后摇身一变成奖励、或者直接消失。
骨架(所有官方任务卡一致的四件套):
public LanternKey() : base(-1, CardType.Quest, CardRarity.Quest, TargetType.Self) { }
// ↑ 费用-1 ↑ 任务卡专属卡型/稀有度
public override int MaxUpgradeLevel => 0; // 不可升级
public override IEnumerable<CardKeyword> CanonicalKeywords => [CardKeyword.Unplayable]; // 不可打出
之后按需求选一条路线:
路线 A:改地图 / 事件流(灯火钥匙 LanternKey:把第二幕的未知节点全部锁成特定事件)—— 重写两个地图钩子,不需要任何进度计数:
public sealed class FooQuestA() : ModCardTemplate(-1, CardType.Quest, CardRarity.Quest, TargetType.Self)
{
public override int MaxUpgradeLevel => 0;
public override IEnumerable<CardKeyword> CanonicalKeywords => [CardKeyword.Unplayable];
// ① 未知地图节点只允许摇出事件房
public override IReadOnlySet<RoomType> ModifyUnknownMapPointRoomTypes(IReadOnlySet<RoomType> roomTypes)
{
if (Owner.RunState.CurrentActIndex != 2) return roomTypes; // 只在第二幕生效
return new HashSet<RoomType> { RoomType.Event };
}
// ② 事件本身也锁定成指定的一个
public override EventModel ModifyNextEvent(EventModel currentEvent)
{
if (Owner.RunState.CurrentActIndex != 2) return currentEvent;
return ModelDb.Event<WarHistorianRepy>(); // 指定事件(官方:WarHistorianRepy)
}
}
路线 B:记进度流(探寻 Dowsing:进 5 次未知房间后变成富足 Abundance)—— [SavedProperty] 记进度 + 钩子计数 + 达成收尾,这是任务卡的完全体:
public sealed class FooQuestB() : ModCardTemplate(-1, CardType.Quest, CardRarity.Quest, TargetType.None)
{
private const int Target = 5;
public override int MaxUpgradeLevel => 0;
public override IEnumerable<CardKeyword> CanonicalKeywords => [CardKeyword.Unplayable];
protected override IEnumerable<DynamicVar> CanonicalVars => [new DynamicVar("Rooms", Target)];
// 卡面显示"还剩几次":{Rooms}
// 进度计数:存档持久化 + setter 里同步卡面数值(官方惯例三连)
[SavedProperty]
public int RoomsEntered
{
get => _roomsEntered;
set
{
AssertMutable();
_roomsEntered = value;
DynamicVars["Rooms"].BaseValue = Target - _roomsEntered; // 同步卡面显示
}
}
private int _roomsEntered;
public override async Task BeforeRoomEntered(AbstractRoom room)
{
// 守卫:牌必须在牌组里 / 只数地图节点本体(CurrentRoomCount==1 防同节点重复触发)
if (Pile == null || Pile.Type != PileType.Deck || Owner.RunState.CurrentRoomCount > 1)
return;
if (Owner.RunState.CurrentMapPoint is not { } mapPoint || mapPoint.PointType != MapPointType.Unknown)
return; // 只数"未知"节点
RoomsEntered++;
if (RoomsEntered >= Target)
{
PlayerCmd.CompleteQuest(this); // ① 标记任务完成
await CardCmd.TransformTo<Abundance>(this); // ② 收尾:变成奖励卡(Abundance)
}
}
}
收尾方式三种(完成时选一):
细节清单:
进度条显示:
DynamicVar("Rooms", n)+ setter 同步(上面代码);也可以用遗物那套ShowCounter/DisplayAmount。卡面预告奖励:
ExtraHoverTips => [HoverTipFactory.FromCard<Abundance>()](探寻 Dowsing 同款)。修改地图本体(往地图上撒宝箱/插任务节点)用
ModifyGeneratedMap+MapPoint.AddQuest(this)——藏宝图(SpoilsMap)式。守卫顺序别省:
Pile.Type != Deck(事件/奖励界面里的副本卡不该计数)、CurrentRoomCount防重、幕数判断按需加。
5. 新手避坑清单(每条都踩过)
6. 资料地图
建议的上手路径:§2.1 打击卡 → §2.6 格挡卡 → §2.7 增益卡 → §2.8 攻击带减益(到这里你就覆盖了官方 约 60% 的卡牌形状)→ §3.2/§3.3 各写一个遗物 → §3.6 写一个自己的 Power → 之后按需回 §4 选修图鉴查抄特殊效果。