塔防游戏开发小记——防御塔结构设计
本文探讨了塔防游戏中防御塔设计从传统继承到组件化架构的演进过程。最初采用继承设计虽然结构清晰但扩展性差,难以应对复杂功能需求。随后转向组合设计,将功能拆分为独立组件,提高复用性和灵活性,但存在管理复杂度。最终引入ScriptableObject实现攻击方式的灵活配置,通过数据驱动降低代码耦合。三种方案各有优劣:继承适合简单层级关系,组件化提升可扩展性,ScriptableObject则优化动态配置
在本人最近做的一个简单的塔防游戏 demo 中,因为各种各样的需求使得防御塔这种 GameObject 的结构频繁发生变动,从功能的组织形式到与其他系统之间的信息交互都在发生变化,之前在学习和迭代的时候还没感觉太多,现在一个结构一个结构这样迭代下来再回头看的时候感觉自己已经走得挺远了,有感而发,想要把这个结构的迭代过程记录下来
继承设计(传统OOP)
一开始在设计类结构时,由于学校对 C++ 的教授,自然而然就想到使用继承来进行设计——先设计一个通用的父类,然后对每个防御塔写一个实现的子类,子类中需要重新实现的方法就设计为虚方法进行重写:
public class Entity
{
protected int _maxHealth;
protected int _currentHealth;
protected virtual void Attack() {...}
public virtual void TakeDamage(int damage) {...}
}
public class Tower : Entity
{
protected override void Attack() {...}
public override void TakeDamage(int damage) {...}
}
这样设计的优点有:
- 符合面向对象设计的直觉:对于熟悉 C++ 或经典 OOP 思维的人来说,很自然就能想到“抽象父类 + 具体子类”的设计。
- 结构清晰,层级明确:相同逻辑可以放在父类中,减少重复代码;子类只需关注差异化逻辑。
- 多态支持方便:可以在运行时通过父类引用指向不同的子类对象,从而统一调用接口,不管是什么防御塔受伤都是使用
tower.TakeDamage(int)来处理。
这样设计的缺点有:
- 扩展性受限:如果新增功能与父类结构不完全契合,就可能需要修改父类,从而影响所有子类。就比如说王国保卫战中的兵营和弓箭塔就不能使用同一个
Attack()方法,因为兵营本身就不具有Attack()这样的功能,就算是在兵营的类实现中将Attack()重写为空方法,仔细研究也会发现兵营的逻辑与普通的防御塔区别很大,它没有攻击、没有攻击范围检测,取而代之的是生产士兵以及士兵部署范围约束,这样子来看的话完全可以重新设计一个父类了,因为原本的父类与兵营的功能差别可以说是很大了 - 继承层级容易变得复杂:当防御塔种类增多时,继承链可能变长,维护和阅读成本增加。之前看到一个讲这个问题的例子,我现在有一个敌人父类
Enemy,他是陆地近战单位,现在我做一个陆地远程单位Archer : Enemy,然后我再做一个空中近战单位Air : Enemy,后面我要干什么应该很明显了,我还需要做一个空中远程单位AirArcher,继承的做法就是让它同时继承自Archer和Air,在 C# 中一个类只能有一个父类,这个时候问题就发生了,你无论继承自哪一个类,都得重新去写另一个类的功能,这样就失去了继承对功能复用的作用了
我自己在开发过程中越设计一个防御塔就越感觉到父类 Entity 对后面设计的约束,很多时候都是想到一个好点子一写,然后发现不能复用到父类的虚方法,不能复用到的话其他地方就不好调用,那整个设计就垮掉了,基于此我就开始寻找解决方案。
组合设计
与继承式结构不同,组合设计不是通过继承来复用功能,而是将各个功能拆分成独立的组件。不同组件可以根据需求替换或组合,部分组件也可以通过继承来派生不同实现(例如 CombatComponent 就可能有多种子类,不同的防御塔攻击方式对应不同的实现)。
比如之前提到的 AirArcher 继承问题,我们可以把“空中移动”拆分成 AirMovementComponent ,而“陆地移动”就是 MovementComponent ;“近战攻击”对应 CombatComponent ,而“远程攻击”是 RemoteCombatComponent 。这样,每个组件只关注自己的一项功能,既能在不同防御塔间自由组合,也能被高效复用。
通常来说,在继承体系中越靠近父类的字段和方法,拆分成独立组件后复用性就越高。例如:
public class HealthComponent
{
private int _currentHealth;
[SerializeField] private int _maxHealth;
public void TakeDamage(int damage) {...};
private void OnDeath() {...};
}
类似地,之前的“兵营型防御塔”问题(需要产出单位而非攻击敌人),在组合设计中只需为它挂载一个 SpawnWarriorComponent ,而不是 CombatComponent 。这样,防御塔的最终结构可能会挂载十多个组件,每个组件各司其职,互不干扰。挂载到最后一个防御塔上可能会有 10 多个组件,就像这样
这样设计的优点有:
- 高复用性:组件是功能单元,可以跨不同防御塔复用,减少重复代码。
- 高灵活性:通过挂载不同组件即可组合出新功能,无需修改原有代码或继承结构。
- 低耦合度:各组件彼此独立,修改一个组件的实现不会影响其他组件。
- 更契合 Unity 的开发模式:Unity 的 MonoBehaviour 本身就是组件化架构,这种设计更容易与引擎生态契合。
- 支持运行时动态添加/移除功能:比如某个防御塔升级后可以直接添加新的组件,而不必重新实例化子类对象。
这样设计的缺点有:
- 组件数量多,管理成本高:一个复杂的防御塔可能会挂载十多个组件,查找和维护变得繁琐。
- 功能关联不易追踪:某个功能可能依赖多个组件协作,排查 bug 需要跨组件分析逻辑。
- 需要额外的协调机制:组件间的交互要靠事件、消息或管理器来实现,否则容易出现依赖混乱。
采用组件设计后防御塔的设计就变得灵活方便了很多,同时这套流程也可以用于敌人的设计当中,敌人可看作会移动的防御塔,那么再为敌人挂载一个 MovementComponent 即可完成敌人的基本设计,到这里为止针对防御塔整体的结构设计就已经完成了,不过这里面还有很多地方需要改进的,就比如想要对组件的功能进行扩展还是需要通过继承,因为需要复用上层的方法,这样就会产生和上面继承设计中一样的问题
使用 ScriptableObject 实现攻击方式的灵活配置
在之前的方案中,如果想让攻击组件支持不同的攻击方式,通常需要新建一个子类并重写内部部分方法。但进一步分析会发现,整个攻击流程中真正变化的,其实只有攻击方式这一环节:
- 获取攻击对象
void GetTargets(List<Entity> targets) - 执行索敌逻辑(如果有)
Entity GetPriorityTarget(List<Entity> targets) - 执行攻击方式
void ExecuteAttack() - 结束
既然变化点集中在执行攻击方式,那么我们只需将这一步抽离出来,就能避免为每种攻击方式都去写一个新的组件子类。
基于这个思路,我们引入一个 ScriptableObject 类型的辅助类,用于描述可以在现有框架下执行的各种攻击方式:
[CreateAssetMenu(menuName = "ScriptableObject/CombatPattern")]
public class CombatPattern : ScriptableObject
{
public enum AttackForm
{
Bullet, // 子弹攻击
Range, // 范围攻击
Melee // 近战攻击
}
[Serializable]
public struct BuffConfig
{
public BuffType BuffType; // 要施加的 Buff 类型
public float Duration; // 施加 Buff 的持续时间
public List<float> Values; // 用于初始化 Buff 的参数列表
public void Apply(Entity target)
{
// 其中为根据不同的 Buff 类型来创建不同的 Buff 类实例并施加到目标中
}
}
/// <summary> 单次攻击配置 </summary>
[Serializable]
public struct SingleAttack
{
public AttackForm Form; // 攻击类型
public BulletType BulletType; // 子弹类型(Form == AttackForm.Bullet 时生效)
public float Lifetime; // 子弹生命周期 (Form == AttackForm.Bullet 时生效)
public float Interval; // 攻击间隔
public List<BuffConfig> Buffs; // 攻击要施加的 Buff 列表
}
// 攻击组件所执行的攻击方式配置列表
// 该列表中的每一个元素都是一个单独的攻击,拥有自己的攻击间隔
// 这样设计能比较好的实现一个防御塔有多种攻击
// 比如像王国保卫战中 4 级防御塔升级技能后会有多种攻击方式
public List<SingleAttack> Patterns;
}
这样,每个攻击组件只需绑定一个 CombatPattern 配置,就能定义自己的攻击方式,而无需通过继承来区分。
这相当于在攻击组件中预留了一个“攻击方式插槽”,只要替换 CombatPattern 这个“零件”,就能完成攻击方式的切换,甚至可以在运行时动态调整。
这样设计的优点有:
- 减少继承层级:不同攻击方式无需创建新的子类,降低类数量和继承复杂度。
- 高度灵活:只需替换配置文件即可切换攻击方式,支持运行时修改。
- 易于扩展:新增攻击方式时只需创建新的 ScriptableObject 实例,无需改动现有代码。
- 可视化编辑:借助 Unity Inspector 直观配置参数,方便策划和美术参与调整,后期还可以自定义一个管理窗口来管理配置所有的
CombatPattern配置 - 支持复杂组合:一个
CombatPattern中可包含多种攻击方式,实现类似“王国保卫战”中高阶防御塔的多技能效果。
这样设计的缺点有:
- 类型安全降低:配置参数错误(如 Buff 类型与攻击类型不匹配)可能导致运行时错误,需要额外验证机制。这点可以在设计管理窗口时考虑添加校验机制避免这方面的问题。
- 难以处理特殊攻击流程:如果某种攻击方式的执行过程高度特殊,仍可能需要额外代码支持,降低复用度。这也是后文要解决的问题。
- 资产管理复杂:项目中可能会积累大量
CombatPattern资源,需要有清晰的命名与目录规范。
抽离攻击方式作为接口独立出来
之前我们用 ScriptableObject 把攻击方式数据化,但能表达的攻击形状仍然有限。举几个例子就能看出局限性:
- 《植物大战僵尸》中杨桃的五角攻击无法用固定枚举表示;
- 《保卫萝卜》中多重箭的扇形、多角攻击同样难以通过单一
AttackForm枚举覆盖。
这些复杂且各异的攻击形状在塔防游戏里只会越来越多,因此扩展攻击形状变得很关键。问题的根源在于 AttackForm 这个枚举把攻击“形状”钉死了。最灵活的做法是把形状独立成类——为每种形状实现一个 IAttackShape 接口,然后在 CombatPattern 中只保存形状的引用(或通过枚举查表得到形状实例),攻击组件通过统一接口来选取目标并执行攻击。
示例接口与一个形状实现如下:
public interface IAttackShape
{
IEnumerable<Entity> SelectTargets(Vector3 origin, Quaternion direction, LayerMask targetMask);
}
// 线形(Line)形状的实现示例
public class LineShape : IAttackShape
{
public float Length;
public float Width;
public IEnumerable<Entity> SelectTargets(Vector3 origin, Quaternion direction, LayerMask targetMask)
{
Vector3 center = origin + direction * Vector3.forward * (Length / 2);
Vector3 halfExtents = new Vector3(Width / 2, 1f, Length / 2);
Collider[] colliders = Physics.OverlapBox(center, halfExtents, direction, targetMask);
return colliders
.Select(c => c.GetComponent<Entity>())
.Where(e => e != null);
}
}
不过这东西我还没开始做,只是因为想到了上一层设计时产生的问题,百思不得一个比较好的解法,于是问了 AI 如何解决,这个方案是 AI 提供的,还没有真的上手实践过,不过看起来确实能解决之前的问题。
后记
写到这里回头看,颇有感触。从最初凭直觉使用继承,到越来越多地考虑生产环境中的扩展性与可维护性,再对照市面上各类塔防玩法去验证设计,整个思路在不断演进。每一次架构或实现的改进,都是能力的积累:分析问题更敏捷,寻找解决方案也更高效。量变终究会带来质变。
更多推荐



所有评论(0)