前言
杀戮尖塔这类卡牌 Roguelike 游戏,看起来操作很简单:
text
抽牌
-> 拖动卡牌
-> 选择目标
-> 打出卡牌
-> 造成伤害或获得格挡
-> 回合结束
但如果从 Unity 工程实现角度看,它并不是"卡牌 UI 直接扣敌人血量"这么简单。
一套可扩展的卡牌战斗系统,通常要处理:
- 卡牌配置
- 抽牌堆、手牌、弃牌堆、消耗区
- 能量系统
- 出牌合法性判断
- 目标选择
- 行动队列
- 伤害和格挡结算
- Buff、Debuff、Power
- 遗物触发
- 敌人意图
- 回合流转
- 卡牌升级
- 动画和 UI 表现
- 战斗日志和调试
所以本文不分析杀戮尖塔2真实源码,而是用它的玩法类型来讨论:
text
如果要在 Unity 中做一个类似杀戮尖塔的卡牌系统,应该如何设计数据、对象、流程和机制。
核心思路可以概括成一句话:
text
卡牌不是直接改变战斗结果,而是生成一组战斗行动,进入行动队列后按顺序结算。
一、Unity 中不要让 CardView 直接改战斗状态
很多初学项目会把卡牌逻辑写在 UI 脚本里:
csharp
public class CardView : MonoBehaviour
{
public int damage;
public void OnDropToEnemy(EnemyView enemy)
{
enemy.Hp -= damage;
Destroy(gameObject);
}
}
这个写法能做出最小 Demo,但很快会失控。
因为卡牌效果会受到大量系统影响:
text
玩家当前能量
卡牌费用变化
卡牌是否被禁用
目标是否合法
玩家是否拥有力量
玩家是否虚弱
敌人是否易伤
敌人是否拥有格挡
遗物是否触发
能力是否触发
卡牌是否消耗
是否抽牌
是否生成新卡
是否触发连锁行动
如果这些都写在 CardView 中,UI 会变成战斗系统的核心,后续会很难维护。
更合理的分层是:
text
CardView:负责显示、拖拽、点击、动画
CardModel:负责运行时卡牌状态
CardData:负责卡牌静态配置
BattleManager:负责战斗流程
ActionQueue:负责行动顺序
BattleAction:负责具体结算动作
也就是说:
text
UI 只提交出牌意图。
战斗系统决定能否出牌,以及如何结算。
二、整体架构:Unity 卡牌战斗可以拆成哪些模块
一个简化版 Unity 卡牌战斗系统可以拆成:
text
CardData
-> CardModel
-> CardView
-> HandController
-> DeckController
-> BattleManager
-> PlayCardCommand
-> ActionQueue
-> BattleAction
-> DamageResolver
-> PowerSystem
-> RelicSystem
-> BattleEventBus
-> BattleView
每一层职责不同:
text
CardData:ScriptableObject 配置卡牌基础数据
CardModel:战斗中某张卡牌的运行时实例
CardView:卡牌 UI 表现和交互
HandController:管理手牌区域
DeckController:管理抽牌堆、弃牌堆、消耗区
BattleManager:管理回合、能量、敌人、胜负
PlayCardCommand:描述一次出牌请求
ActionQueue:按顺序执行战斗行动
BattleAction:伤害、格挡、抽牌、加状态等动作
DamageResolver:统一处理伤害修正
PowerSystem:处理力量、虚弱、易伤、中毒等状态
RelicSystem:处理遗物触发
BattleEventBus:广播出牌、受伤、回合开始等事件
BattleView:播放动画、刷新 UI、显示数字
这套结构的重点不是类多,而是边界清楚。
卡牌 UI 不负责结算。
卡牌数据不负责动画。
行动队列不负责卡牌拖拽。
伤害结算不关心卡牌来自哪里。
这样系统才能扩展到几十张、几百张卡牌。
三、用 ScriptableObject 配置卡牌数据
Unity 做卡牌系统,非常适合用 ScriptableObject 存储卡牌静态配置。
例如:
csharp
public enum CardType
{
Attack,
Skill,
Power,
Status,
Curse
}
public enum CardTargetType
{
None,
Self,
SingleEnemy,
AllEnemies,
RandomEnemy
}
[CreateAssetMenu(menuName = "Card Game/Card Data")]
public class CardData : ScriptableObject
{
public string cardId;
public string cardName;
public CardType type;
public CardTargetType targetType;
public int cost;
public Sprite artwork;
[TextArea] public string description;
public List<CardEffectData> effects;
public bool exhaust;
}
效果配置可以继续拆:
csharp
public enum CardEffectType
{
DealDamage,
GainBlock,
DrawCards,
ApplyPower,
GainEnergy,
ExhaustCard,
AddCardToHand
}
[System.Serializable]
public class CardEffectData
{
public CardEffectType effectType;
public int value;
public string powerId;
public int duration;
}
这样一张攻击牌可以配置成:
text
名称:打击
类型:Attack
费用:1
目标:SingleEnemy
效果:DealDamage 6
一张防御牌可以配置成:
text
名称:防御
类型:Skill
费用:1
目标:Self
效果:GainBlock 5
一张复合牌可以配置成:
text
名称:重击
类型:Attack
费用:2
目标:SingleEnemy
效果:
DealDamage 8
ApplyPower Vulnerable 2
这就是数据驱动的基础。
程序不需要为每张卡都写一个新类,而是先用通用效果系统解释配置。
四、CardData 和 CardModel 要分开
CardData 是静态数据,不能直接代表战斗中的一张牌。
因为同一张卡牌在战斗中可能有运行时状态:
text
本场战斗临时费用变化
是否被升级
是否被复制
是否被保留
是否本回合不可打出
是否被某个效果修改描述
所以需要 CardModel:
csharp
public class CardModel
{
public CardData Data { get; }
public int RuntimeCost { get; private set; }
public bool IsUpgraded { get; private set; }
public bool IsRetained { get; set; }
public string CardId => Data.cardId;
public CardType Type => Data.type;
public CardTargetType TargetType => Data.targetType;
public CardModel(CardData data)
{
Data = data;
RuntimeCost = data.cost;
}
public void ModifyCostForTurn(int delta)
{
RuntimeCost = Mathf.Max(0, RuntimeCost + delta);
}
}
这样就能区分:
text
CardData:这张牌原本是什么
CardModel:这场战斗里这张牌当前是什么状态
CardView:这张牌在屏幕上长什么样
这是 Unity 卡牌项目里非常重要的分层。
五、CardView 只负责显示和交互
CardView 应该绑定 CardModel,但不要负责结算。
它主要做:
text
显示名称、费用、描述、图片
响应鼠标悬停
处理拖拽
显示可用或不可用状态
播放出牌动画
把出牌请求交给控制器
示例:
csharp
public class CardView : MonoBehaviour
{
[SerializeField] private TMP_Text nameText;
[SerializeField] private TMP_Text costText;
[SerializeField] private TMP_Text descriptionText;
[SerializeField] private Image artworkImage;
private CardModel model;
private HandController handController;
public void Bind(CardModel card, HandController controller)
{
model = card;
handController = controller;
Refresh();
}
public void Refresh()
{
nameText.text = model.Data.cardName;
costText.text = model.RuntimeCost.ToString();
descriptionText.text = model.Data.description;
artworkImage.sprite = model.Data.artwork;
}
public void OnReleasedOverTarget(CombatantView targetView)
{
handController.RequestPlayCard(model, targetView?.Model);
}
}
注意这里的关键点:
text
CardView 不判断最终伤害。
CardView 不扣能量。
CardView 不移入弃牌堆。
CardView 只提交 RequestPlayCard。
这能避免 UI 和战斗逻辑缠在一起。
六、出牌请求:用 PlayCardCommand 表达玩家意图
玩家拖动卡牌到敌人身上时,不应该立即结算。
应该创建一个命令:
csharp
public class PlayCardCommand
{
public CardModel Card { get; init; }
public PlayerModel Player { get; init; }
public CombatantModel Target { get; init; }
public BattleContext Context { get; init; }
}
这个命令表达的是:
text
玩家想在当前战斗上下文中,对某个目标打出某张牌。
HandController 可以这样提交:
csharp
public void RequestPlayCard(CardModel card, CombatantModel target)
{
var command = new PlayCardCommand
{
Card = card,
Player = battleContext.Player,
Target = target,
Context = battleContext
};
battleManager.TryPlayCard(command);
}
真正的校验和执行交给 BattleManager。
这样做的好处是:
- UI 输入和战斗执行解耦。
- 出牌请求可以被记录和调试。
- 后续可以支持键盘操作、手柄操作、自动战斗。
- 后续可以做回放或战斗日志。
七、出牌校验:能不能打出必须统一判断
打出一张牌之前,至少要检查:
text
是否处于玩家回合
行动队列是否空闲
卡牌是否在手牌中
能量是否足够
目标是否符合卡牌目标类型
玩家是否被某些状态限制
卡牌是否被禁用
可以设计一个 CardPlayValidator:
csharp
public class CardPlayValidator
{
public ValidateResult Validate(PlayCardCommand command)
{
var context = command.Context;
var card = command.Card;
if (!context.IsPlayerTurn)
return ValidateResult.Fail("Not player turn");
if (!context.Hand.Contains(card))
return ValidateResult.Fail("Card is not in hand");
if (context.Player.Energy < card.RuntimeCost)
return ValidateResult.Fail("Not enough energy");
if (!IsValidTarget(card, command.Target, context))
return ValidateResult.Fail("Invalid target");
return ValidateResult.Success();
}
}
目标判断可以独立:
csharp
private bool IsValidTarget(CardModel card, CombatantModel target, BattleContext context)
{
return card.TargetType switch
{
CardTargetType.None => target == null,
CardTargetType.Self => target == context.Player,
CardTargetType.SingleEnemy => target != null && context.Enemies.Contains(target),
CardTargetType.AllEnemies => true,
CardTargetType.RandomEnemy => true,
_ => false
};
}
不要让每张卡自己判断这些基础规则。
否则后期很容易出现:
text
某张牌能量不足也能打
某张牌能对尸体释放
某张牌在敌人回合还能打出
某张牌从弃牌堆也能被 UI 拖出来使用
统一校验是战斗系统稳定的基础。
八、行动队列:卡牌效果要排队执行
杀戮尖塔式战斗最核心的机制之一就是行动队列。
一次出牌可以被拆成多个动作:
text
消耗能量
-> 从手牌移除
-> 播放出牌动画
-> 造成伤害
-> 施加易伤
-> 触发遗物
-> 移入弃牌堆或消耗区
这些动作需要按顺序执行。
可以定义一个基础行动类:
csharp
public abstract class BattleAction
{
public abstract IEnumerator Execute(BattleContext context);
}
Unity 中用协程实现队列很自然:
csharp
public class ActionQueue : MonoBehaviour
{
private readonly Queue<BattleAction> queue = new();
private bool isRunning;
public void Enqueue(BattleAction action)
{
queue.Enqueue(action);
if (!isRunning)
{
StartCoroutine(Run());
}
}
public void EnqueueRange(IEnumerable<BattleAction> actions)
{
foreach (var action in actions)
{
queue.Enqueue(action);
}
if (!isRunning)
{
StartCoroutine(Run());
}
}
private IEnumerator Run()
{
isRunning = true;
while (queue.Count > 0)
{
var action = queue.Dequeue();
yield return action.Execute(BattleContext.Current);
}
isRunning = false;
}
}
队列的价值是:
text
保证结算顺序
支持动画等待
支持行动执行中继续插入新行动
让连锁效果可控
方便调试和回放
例如遗物触发抽牌时,不是立刻打断当前逻辑,而是追加一个新行动:
csharp
context.ActionQueue.Enqueue(new DrawCardAction(1));
这样复杂连锁仍然能保持有序。
九、把卡牌效果转换成 BattleAction
卡牌被打出后,BattleManager 可以把配置转成行动:
csharp
public class BattleManager : MonoBehaviour
{
[SerializeField] private ActionQueue actionQueue;
[SerializeField] private CardPlayValidator validator;
[SerializeField] private CardActionFactory actionFactory;
public void TryPlayCard(PlayCardCommand command)
{
var result = validator.Validate(command);
if (!result.Success)
{
return;
}
var actions = new List<BattleAction>
{
new SpendEnergyAction(command.Card.RuntimeCost),
new RemoveCardFromHandAction(command.Card),
new PlayCardAnimationAction(command.Card)
};
actions.AddRange(actionFactory.CreateActions(command));
if (command.Card.Data.exhaust)
actions.Add(new MoveCardToExhaustPileAction(command.Card));
else
actions.Add(new MoveCardToDiscardPileAction(command.Card));
actionQueue.EnqueueRange(actions);
}
}
CardActionFactory 根据 CardEffectData 生成行动:
csharp
public class CardActionFactory
{
public IEnumerable<BattleAction> CreateActions(PlayCardCommand command)
{
foreach (var effect in command.Card.Data.effects)
{
yield return effect.effectType switch
{
CardEffectType.DealDamage => new DamageAction(command.Target, effect.value),
CardEffectType.GainBlock => new GainBlockAction(command.Player, effect.value),
CardEffectType.DrawCards => new DrawCardAction(effect.value),
CardEffectType.ApplyPower => new ApplyPowerAction(command.Target, effect.powerId, effect.value),
CardEffectType.GainEnergy => new GainEnergyAction(effect.value),
_ => new EmptyAction()
};
}
}
}
这样一张牌可以由多个效果组合出来:
text
打击 = DamageAction
防御 = GainBlockAction
重击 = DamageAction + ApplyPowerAction
战斗专注 = DrawCardAction + GainEnergyAction
这比为每张牌写一个完整类更适合早期扩展。
如果后期出现非常特殊的卡牌,也可以为它增加自定义逻辑,而不是一开始就把所有牌都硬编码。
十、牌堆系统:抽牌堆、手牌、弃牌堆、消耗区
杀戮尖塔式卡牌系统至少有四个区域:
text
DrawPile:抽牌堆
Hand:手牌
DiscardPile:弃牌堆
ExhaustPile:消耗区
可以用 DeckController 管理:
csharp
public class DeckController
{
public List<CardModel> DrawPile { get; } = new();
public List<CardModel> Hand { get; } = new();
public List<CardModel> DiscardPile { get; } = new();
public List<CardModel> ExhaustPile { get; } = new();
public void Draw(int count)
{
for (int i = 0; i < count; i++)
{
if (DrawPile.Count == 0)
{
ReshuffleDiscardIntoDrawPile();
}
if (DrawPile.Count == 0)
{
return;
}
var card = DrawPile[0];
DrawPile.RemoveAt(0);
Hand.Add(card);
}
}
private void ReshuffleDiscardIntoDrawPile()
{
DrawPile.AddRange(DiscardPile);
DiscardPile.Clear();
Shuffle(DrawPile);
}
}
对应行动:
csharp
public class DrawCardAction : BattleAction
{
private readonly int count;
public DrawCardAction(int count)
{
this.count = count;
}
public override IEnumerator Execute(BattleContext context)
{
context.Deck.Draw(count);
context.EventBus.Publish(new CardsDrawnEvent(count));
yield return context.View.RefreshHand();
}
}
这里有一个重要原则:
text
牌堆状态由 DeckController 修改。
手牌 UI 根据事件和状态刷新。
不要让 UI 自己维护一套真假难辨的手牌列表。
十一、伤害和格挡:不要在 DamageAction 里写死所有规则
攻击牌造成伤害时,最终伤害不一定等于卡牌数值。
需要考虑:
text
力量
虚弱
易伤
格挡
护甲类能力
遗物加成
敌人特殊状态
多段伤害
生命损失和普通伤害的区别
所以 DamageAction 不应该直接扣血,而应该交给 DamageResolver:
csharp
public class DamageAction : BattleAction
{
private readonly CombatantModel target;
private readonly int baseDamage;
public DamageAction(CombatantModel target, int baseDamage)
{
this.target = target;
this.baseDamage = baseDamage;
}
public override IEnumerator Execute(BattleContext context)
{
var request = new DamageRequest
{
Source = context.Player,
Target = target,
BaseDamage = baseDamage,
DamageType = DamageType.Attack
};
var result = context.DamageResolver.Resolve(request);
context.EventBus.Publish(new DamageDealtEvent(result));
yield return context.View.PlayDamageNumber(target, result.FinalDamage);
}
}
DamageResolver 统一处理修正:
csharp
public class DamageResolver
{
public DamageResult Resolve(DamageRequest request)
{
var damage = request.BaseDamage;
damage += request.Source.GetPowerValue("Strength");
if (request.Source.HasPower("Weak"))
damage = Mathf.FloorToInt(damage * 0.75f);
if (request.Target.HasPower("Vulnerable"))
damage = Mathf.FloorToInt(damage * 1.5f);
var blocked = Mathf.Min(request.Target.Block, damage);
request.Target.Block -= blocked;
var hpDamage = damage - blocked;
request.Target.Hp -= hpDamage;
return new DamageResult(request.Source, request.Target, damage, hpDamage, blocked);
}
}
这样以后新增遗物、能力、敌人特性时,可以统一接入结算流程。
十二、Power 系统:力量、易伤、中毒都可以统一管理
杀戮尖塔式游戏中,状态系统非常核心。
常见状态包括:
text
Strength:力量,增加攻击伤害
Dexterity:敏捷,增加格挡
Weak:虚弱,降低攻击伤害
Vulnerable:易伤,受到更多伤害
Poison:中毒,回合结束受到伤害
Artifact:抵消一次负面状态
Intangible:降低受到的伤害
可以把这些统一抽象成 Power:
csharp
public abstract class Power
{
public string PowerId { get; }
public CombatantModel Owner { get; }
public int Amount { get; protected set; }
public virtual void OnApply(int amount)
{
Amount += amount;
}
public virtual void OnTurnStart(BattleContext context) { }
public virtual void OnTurnEnd(BattleContext context) { }
public virtual void OnCardPlayed(CardPlayedEvent e) { }
public virtual int ModifyDamage(DamageRequest request, int damage) => damage;
public virtual int ModifyBlock(int block) => block;
}
力量可以实现成:
csharp
public class StrengthPower : Power
{
public override int ModifyDamage(DamageRequest request, int damage)
{
if (request.Source == Owner && request.DamageType == DamageType.Attack)
return damage + Amount;
return damage;
}
}
易伤可以实现成:
csharp
public class VulnerablePower : Power
{
public override int ModifyDamage(DamageRequest request, int damage)
{
if (request.Target == Owner)
return Mathf.FloorToInt(damage * 1.5f);
return damage;
}
}
这样 DamageResolver 不需要知道所有状态细节,只需要遍历修正器:
csharp
foreach (var power in request.Source.Powers)
{
damage = power.ModifyDamage(request, damage);
}
foreach (var power in request.Target.Powers)
{
damage = power.ModifyDamage(request, damage);
}
这就是状态系统的扩展点。
十三、遗物系统:用事件驱动触发机制
杀戮尖塔式玩法的乐趣,很大一部分来自遗物和卡牌之间的联动。
遗物通常监听事件:
text
战斗开始
回合开始
回合结束
打出卡牌
造成伤害
获得格挡
击杀敌人
抽牌
弃牌
消耗卡牌
所以需要事件系统:
csharp
public class BattleEventBus
{
private readonly Dictionary<Type, Delegate> handlers = new();
public void Subscribe<T>(Action<T> handler)
{
var type = typeof(T);
handlers[type] = Delegate.Combine(
handlers.TryGetValue(type, out var existing) ? existing : null,
handler);
}
public void Publish<T>(T evt)
{
if (handlers.TryGetValue(typeof(T), out var handler))
{
((Action<T>)handler)?.Invoke(evt);
}
}
}
遗物可以这样写:
csharp
public abstract class Relic
{
public virtual void Register(BattleEventBus eventBus) { }
}
public class DrawOnFirstTurnRelic : Relic
{
public override void Register(BattleEventBus eventBus)
{
eventBus.Subscribe<TurnStartedEvent>(OnTurnStarted);
}
private void OnTurnStarted(TurnStartedEvent e)
{
if (e.TurnNumber == 1)
{
e.Context.ActionQueue.Enqueue(new DrawCardAction(2));
}
}
}
卡牌不需要知道有哪些遗物。
卡牌只发布事件:
csharp
context.EventBus.Publish(new CardPlayedEvent(command.Card, command.Target, context));
遗物自己决定是否响应。
这样联动越多,事件系统越重要。
十四、回合系统:玩家回合和敌人回合的切换
卡牌战斗的节奏由回合驱动。
一个简化流程:
text
战斗开始
-> 玩家回合开始
-> 获得能量
-> 抽牌
-> 玩家出牌
-> 玩家点击结束回合
-> 弃掉手牌
-> 敌人回合开始
-> 敌人按意图行动
-> 敌人回合结束
-> 下一轮玩家回合
可以写成 BattleTurnManager:
csharp
public class BattleTurnManager : MonoBehaviour
{
public void StartPlayerTurn(BattleContext context)
{
context.IsPlayerTurn = true;
context.Player.Energy = context.Player.MaxEnergy;
context.Player.Block = 0;
context.EventBus.Publish(new TurnStartedEvent(context, TurnOwner.Player));
context.ActionQueue.Enqueue(new DrawCardAction(5));
}
public void EndPlayerTurn(BattleContext context)
{
context.IsPlayerTurn = false;
context.ActionQueue.Enqueue(new DiscardHandAction());
context.ActionQueue.Enqueue(new StartEnemyTurnAction());
}
}
敌人回合也可以用行动队列:
csharp
public class StartEnemyTurnAction : BattleAction
{
public override IEnumerator Execute(BattleContext context)
{
foreach (var enemy in context.Enemies)
{
foreach (var action in enemy.Intent.CreateActions(context))
{
context.ActionQueue.Enqueue(action);
}
}
context.ActionQueue.Enqueue(new EndEnemyTurnAction());
yield break;
}
}
这样敌人的攻击、加盾、上状态也和卡牌一样进入队列。
整个战斗系统就只有一套执行方式。
十五、敌人意图:提前展示下一步行动
杀戮尖塔式战斗里,敌人意图是很重要的策略信息。
敌人不会随机马上出手,而是先告诉玩家:
text
下回合我要攻击
下回合我要防御
下回合我要给你上负面状态
下回合我要召唤
可以定义:
csharp
public enum EnemyIntentType
{
Attack,
Defend,
Buff,
Debuff,
Special
}
public class EnemyIntent
{
public EnemyIntentType Type;
public int Value;
public List<BattleAction> Actions;
public IEnumerable<BattleAction> CreateActions(BattleContext context)
{
return Actions;
}
}
敌人每回合结束后生成下一回合意图:
csharp
public class EnemyAI
{
public EnemyIntent DecideNextIntent(EnemyModel enemy, BattleContext context)
{
if (enemy.Hp < enemy.MaxHp * 0.5f)
{
return CreateDefendIntent(enemy);
}
return CreateAttackIntent(enemy);
}
}
UI 只显示意图:
text
攻击 12
防御 8
上易伤 2
真正执行仍然是敌人回合时把意图转成 BattleAction。
十六、卡牌升级:不要只改 UI 文案
杀戮尖塔式游戏通常有卡牌升级机制。
例如:
text
打击:造成 6 点伤害
打击+:造成 9 点伤害
不要只改描述文本。
应该让升级影响数据或运行时效果。
一种简单方式是在 CardData 里配置升级版本:
csharp
[CreateAssetMenu(menuName = "Card Game/Card Data")]
public class CardData : ScriptableObject
{
public string cardId;
public string cardName;
public int cost;
public string description;
public List<CardEffectData> effects;
public int upgradedCost;
public string upgradedDescription;
public List<CardEffectData> upgradedEffects;
}
CardModel 根据是否升级返回对应效果:
csharp
public IReadOnlyList<CardEffectData> Effects =>
IsUpgraded ? Data.upgradedEffects : Data.effects;
public int Cost =>
IsUpgraded ? Data.upgradedCost : Data.cost;
这样升级会影响真实结算。
UI 只是根据 CardModel 刷新显示。
十七、动画表现:表现层跟着事件走
Unity 项目里,动画和战斗逻辑很容易耦合。
例如:
text
扣血前要播放飞牌动画
伤害后要显示数字
抽牌时要从牌堆飞到手牌
弃牌时要飞到弃牌堆
敌人攻击时要向前冲一下
这些表现可以放在对应 Action 里等待完成:
csharp
public class PlayCardAnimationAction : BattleAction
{
private readonly CardModel card;
public PlayCardAnimationAction(CardModel card)
{
this.card = card;
}
public override IEnumerator Execute(BattleContext context)
{
yield return context.View.PlayCardAnimation(card);
}
}
或者让表现层订阅事件:
csharp
context.EventBus.Publish(new CardMovedEvent(card, CardZone.Hand, CardZone.Discard));
两种方式可以结合:
text
必须阻塞结算的动画,放进 BattleAction 等待。
不影响结算的表现,用事件广播给 ViewSystem。
例如:
text
抽牌飞行动画可能需要等待
伤害飘字可以不阻塞
按钮闪烁可以不阻塞
遗物发光可以不阻塞
这样战斗节奏会更容易控制。
十八、BattleContext:所有战斗状态的入口
为了避免到处找对象,可以定义 BattleContext:
csharp
public class BattleContext
{
public PlayerModel Player { get; init; }
public List<EnemyModel> Enemies { get; init; }
public DeckController Deck { get; init; }
public ActionQueue ActionQueue { get; init; }
public DamageResolver DamageResolver { get; init; }
public BattleEventBus EventBus { get; init; }
public BattleView View { get; init; }
public bool IsPlayerTurn { get; set; }
public int TurnNumber { get; set; }
}
BattleContext 的作用是:
text
保存当前战斗中的核心状态和服务。
让 Action 执行时能访问必要系统。
减少 MonoBehaviour 之间互相 Find。
但也要注意:
text
BattleContext 不应该变成万能垃圾桶。
它适合放战斗核心对象,不适合把所有 UI 按钮、音效源、粒子引用都塞进去。
表现引用可以放在 BattleView 或专门的 ViewSystem 中。
十九、Unity 落地时的目录建议
一个简单项目可以这样组织:
text
Scripts
Battle
BattleManager.cs
BattleContext.cs
BattleTurnManager.cs
Cards
CardData.cs
CardModel.cs
CardView.cs
CardActionFactory.cs
CardPlayValidator.cs
Actions
BattleAction.cs
ActionQueue.cs
DamageAction.cs
DrawCardAction.cs
GainBlockAction.cs
ApplyPowerAction.cs
Deck
DeckController.cs
CardZone.cs
Combatants
CombatantModel.cs
PlayerModel.cs
EnemyModel.cs
EnemyAI.cs
EnemyIntent.cs
Powers
Power.cs
StrengthPower.cs
WeakPower.cs
VulnerablePower.cs
Relics
Relic.cs
RelicSystem.cs
Events
BattleEventBus.cs
CardPlayedEvent.cs
DamageDealtEvent.cs
TurnStartedEvent.cs
Views
BattleView.cs
EnemyView.cs
HandController.cs
资源目录可以这样:
text
ScriptableObjects
Cards
Strike.asset
Defend.asset
Bash.asset
Enemies
Relics
Powers
Prefabs
CardView.prefab
EnemyView.prefab
DamageNumber.prefab
目录不是重点,重点是:
text
数据、运行时状态、队列结算、UI 表现要分开。
二十、一个最小可运行闭环
如果自己从零开始做,不建议一上来就实现所有机制。
可以先做最小闭环:
text
创建 CardData
-> 生成初始牌组
-> 战斗开始抽 5 张牌
-> 拖动攻击牌到敌人
-> 校验能量和目标
-> 扣能量
-> 进入行动队列
-> 造成伤害
-> 卡牌进入弃牌堆
-> 回合结束弃掉手牌
-> 敌人攻击玩家
-> 下一回合重新抽牌
对应实现顺序:
- 实现
CardData和CardModel。 - 实现
DeckController,支持抽牌、弃牌、洗牌。 - 实现
CardView和HandController,能显示和拖拽卡牌。 - 实现
PlayCardCommand和CardPlayValidator。 - 实现
BattleAction和ActionQueue。 - 实现
DamageAction、GainBlockAction、DrawCardAction。 - 实现
BattleManager,把出牌转成行动。 - 实现
BattleTurnManager,处理玩家和敌人回合。 - 再加入
PowerSystem、RelicSystem和事件系统。
做完这个闭环后,再逐步扩展:
text
卡牌升级
消耗
保留
多段攻击
随机目标
敌人意图
遗物
能力牌
状态牌和诅咒牌
战斗奖励
地图和事件
这样系统会更稳。
二十一、常见坑
1. 卡牌 UI 和战斗逻辑混在一起
问题表现:
text
CardView 里直接扣血、扣能量、移动牌堆。
后果是 UI 一改,战斗逻辑就坏。
建议:
text
CardView 只负责表现和输入。
战斗状态只由 BattleManager、DeckController、ActionQueue 修改。
2. 不使用行动队列
问题表现:
text
出牌后立即执行所有效果。
遗物和能力直接嵌套调用。
后果是连锁触发顺序混乱,动画也很难同步。
建议:
text
所有会改变战斗状态的操作都变成 BattleAction。
BattleAction 统一进入 ActionQueue。
3. 状态效果写死在伤害函数里
问题表现:
text
DamageAction 里写满 Strength、Weak、Vulnerable、Relic、Power 判断。
后果是每加一个新机制都要改老代码。
建议:
text
用 DamageResolver 和 Power 修正器统一处理。
4. 只配置描述,不配置真实效果
问题表现:
text
卡牌描述写着造成 8 点伤害,但实际代码里写死 6。
后果是数据和表现不一致。
建议:
text
描述尽量由效果数据生成,或者至少保证 CardData 是唯一真实来源。
5. 随机数不可控
问题表现:
text
抽牌、随机敌人、随机奖励到处调用 Random.Range。
后果是回放、调试、复现问题困难。
建议:
text
战斗中使用统一 RandomService,并记录随机种子。
二十二、总结
通过 Unity 做杀戮尖塔2式卡牌系统,重点不是先把卡牌 UI 做得多华丽,而是先把战斗管线设计清楚。
玩家看到的是:
text
拖出一张牌,敌人掉血。
工程里真正发生的是:
text
CardView 提交出牌请求
-> BattleManager 校验能量和目标
-> CardData 生成一组 BattleAction
-> ActionQueue 按顺序执行
-> DamageResolver 结算伤害
-> PowerSystem 和 RelicSystem 响应事件
-> DeckController 移动卡牌区域
-> BattleView 刷新 UI 和播放动画
这套设计的核心价值是:
- UI 和战斗逻辑解耦。
- 卡牌可以通过 ScriptableObject 数据驱动。
- 复杂效果可以拆成行动队列。
- 遗物、能力、状态可以通过事件扩展。
- 动画和结算顺序更容易控制。
- 后续支持调试、回放、随机种子会更容易。
所以,实现杀戮尖塔式卡牌系统时,可以先抓住这一条主线:
text
卡牌提交意图,意图生成行动,行动进入队列,队列驱动战斗状态变化。
只要这条主线稳定,后续无论是新增卡牌、遗物、敌人意图、能力牌,还是卡牌升级和随机事件,都能在同一套架构上继续扩展。