组合为啥比继承更高级?以构建buff系统为例

主人公"帅春"是一个猎人,他的技能是向敌人丢斧头,有一天,他击杀猎物被猎人之神赐福,进入了一种玄之又玄的顿悟状态,醒来后已经领悟了一种并不稳定的技能:

他丢出的斧子,有 15% 的概率被附着上切割之力,命中敌人后,敌人会在接下来的10秒内,每2秒损失5点生命值。

好的,背景交待完了,身为一个成熟的程序员,我们开始尝试在 Godot 里编写代码实现逻辑。

1. 当然是选择 Buff 系统

面对这个需求,我们的第一反应通常是:

"这不就是一个典型的 Buff 吗?"。

Buff 系统是 RPG 和许多动作游戏中不可或缺的一部分,它用来管理角色身上临时或永久的状态变化。

因此,我们尝试构建如下的结构:

玩家投射物敌人 都绑定一个名为 buff容器 的节点。

Buff容器 节点的作用主要为:

管理其宿主的所有buff(包括负面buff),计算它们的时间,挂载,层数,移除等,并提供便捷的接口给给代码使用。

并且,我们需要设计3个 Buff:

ok,我们先编写一个名为 BaseBuff 的基类,它具备如何能力:

  • 声明自己的基础属性:
    • 是否永久的
    • 持续时间
    • 对层数的处理策略(可叠加,还是刷新等)
  • 声明自己的基本类型
    • 属性增强?
    • 概率触发?
    • 事件触发?
    • 时间间隔触发?
  • 实现触发时的专属业务逻辑(当然,作为 BaseBuff,它没有自己的专属业务逻辑,只是预留一个函数,方便继承者在声明基础信息后,直接覆写该方法)

因此,作为惯性,最本能的写法就是去继承,我们需要写如下三个代码(伪代码):

gdscript 复制代码
# 切割射击buff
class ABuff extends BaseBuff:  
  永久 = true
  类型 = 事件触发
  触发事件 = 攻击
  触发概率 = 0.15
  执行方法(context):
    context.子弹.add_buff(BBuff.new())

# 切割弹buff
class BBuff extends BaseBuff:  
  永久 = true
  类型 = 事件触发
  触发事件 = 命中
  触发概率 = 1.0
  执行方法(context):
    context.目标.add_buff(CBuff.new())

# 流血buff
class CBuff extends BaseBuff:  
  永久 = false
  持续时间 = 10.0
  类型 = 间隔触发
  触发间隔 = 2.0
  触发概率 = 1.0
  执行方法(context):
    context.目标.take_damage(5.0)

ok,大功告成,代码看起来非常完美,我真是一个经验丰富的程序员!

2. "继承" 的代价是什么?

但是。世事就怕这个但是。

但是,第二天,策划找到了我,并掏出了一份策划方案,告诉我,他给"猎人之神"创建了20种+不同的赐福类型,方便每个玩家在每局游戏都能对"主角帅春"进行不同路线等等强化,例如:

  • 每3次攻击,就会一定会让斧子被附魔,命中后,敌人在8秒内,每2秒损失10点生命值。
  • 每3秒,猎人就会给自己恢复20点生命。
  • 每5秒,猎人会随机给一个敌人释放A技能。
  • 每4秒,猎人会随机给一个敌人释放B技能。
  • 每3秒,猎人会随机给一个敌人释放C技能。

......等等

我了个!这意味着我不得不放下手里正在进行的其他重要工作,配合策划,开始编写如上几十个buff的代码,毕竟这每个buff都需要声明属性,并且实现它的实现放方法:

gdscript 复制代码
执行方法(context):
  本buff干了啥

更操蛋的是,万一未来策划要微调属性,也还是得找到我,我来修改,毕竟这实现和赋值都在继承体内部。

太爆炸了,未来如果技能上百个了,或者上千个了怎么办?

我们发现,继承是一种非常强的(IS-A)关系。BleedBuff 是一个 BaseBuff。这种关系一旦确定,就很难再改变。当一个事物需要同时具备多种不同维度的特性时,继承就会变得异常笨拙,导致类爆炸 或者形成庞大而复杂的继承树。

或者,我们应该换一个思路,正如人们常说的一句话:

复制代码
  正如人们常说的一句话。 ------ 人们常说

不好意思,开个玩笑,重新来。

正如人们常说的一句话:

复制代码
组合大于继承。

那么,是如何大于的呢?它又应该如何工作呢?

答案是抽象。

3. "组合"是更狠的抽象

抽象是编程的基础思想。

其实"继承" 过程中我们已经进行了一定程度的抽象。

正如Java 或者 C++ 老师所讲的,所有动物都继承了Animal,但是:

Dog.say()Cat.say() 的实现是不同的,因此,DogCat 通过继承实现了多态。

我们思考了哪些属性是大部分Buff都必须考虑的共性,并且产生了一个思维惯性:

执行逻辑的也是 Buff,因此我们应该继承BaseBuff,利用多态的特性来满足不同的需求。

但"组合"必须跳开这个思路,产生更为细的抽象:

实现差异的可能并不是 动物不同,而是 动物 持有的发声器不同。

在这样的抽象细粒度里,动物的特性可以通过茫茫多不同的组合来实现。

再引入一些配置化的手段,以上图中就可以产生 3^4 = 81 种不同形态的动物了,这要是让你写继承,不得写崩溃?

因此,组合的抽象步骤一般分为两步:

  • 拆分成更细粒度的功能,并抽象成接口。
  • 实现不同的功能单元。

后续使用者可以不必再是代码,而是配置,哪怕是完全不懂代码的人也可以胜任。

4. 重构Buff成组合模式

实现 Buff 业务逻辑的不一定是 Buff 本身。

让我们按这个逻辑来进行抽象,现在我们有了一个"实现Buff具体逻辑"的工具接口"效果器":

gdscript 复制代码
class_name BaseEffect:
  func execute(context): 
    pass

每当Buff需要执行逻辑时,只需要找到Buff实例上挂载的"效果器"并执行 execute 方法即可。

让我们尝试来抽象一下,以上例子里有哪些效果器呢?

  • 效果器A:给目标施加一个新Buff。
  • 效果器B:对目标造成伤害。

咦?竟然这么简单?是的。接下来就只用配置属性和组合。

gdscript 复制代码
# 切割射击buff
class ABuff extends BaseBuff:  
  永久 = true
  类型 = 事件触发
  触发事件 = 攻击
  触发概率 = 0.15
  效果器 = 效果器A
  施加的Buff = BBuff

# 切割弹buff
class BBuff extends BaseBuff:  
  永久 = true
  类型 = 事件触发
  触发事件 = 命中
  触发概率 = 1.0
  效果器 = 效果器A
  施加的Buff = CBuff

# 流血buff
class CBuff extends BaseBuff:  
  永久 = false
  持续时间 = 10.0
  类型 = 间隔触发
  触发间隔 = 2.0
  触发概率 = 1.0
  效果器 = 效果器B
  伤害 = 5.0

现在我们看到,整个步骤已经无需写代码了,只需要配置,配置,配置。

让我们把这些配置抽取成 godot 里的 Resource 资源,或者弄一个 Json 文件,或者弄一个标准化的 Excel 表格,便可以交给策划来进行日常维护了。

5.结论:为什么组合比继承更"高级"?

回到最初的问题。

说组合比继承"高级",并不是说继承一无是处。继承在建立明确、稳定、层次分明的类型关系时非常有用(例如,Button 继承自 Control,Control 继承自 Node)。

但在构建复杂且多变的游戏系统(如技能、Buff、道具系统)时,组合的优势是压倒性的:

  • 高度灵活性 (Flexibility):你可以动态地组合行为,而不是在编译时就用继承关系写死。想创造一个新Buff?只需要在编辑器里拖拽几个效果资源,调整一下参数即可,完全无需编写新的类。

  • 高内聚,低耦合 (High Cohesion, Low Coupling):每个 BuffEffect 只关心自己的逻辑(高内聚)。BuffComponent 不知道也不关心效果的具体内容,它只负责管理Buff的生命周期(低耦合)。这使得代码更容易维护和测试。

  • 避免类爆炸 (Avoids Class Explosion):对于 N 个效果,如果想实现它们之间的任意组合,继承可能需要创建 2^N - 1 个子类。而使用组合,你只需要 N 个效果类。

  • 更符合数据驱动的设计:使用 Godot 的 Resource,游戏设计师(甚至是不会编程的策划)可以直接在编辑器里创建和调整各种Buff,极大地提高了开发效率和迭代速度。

总而言之,继承告诉我们一个对象"是什么",而组合则定义了一个对象"能做什么"。在需要灵活插拔、组合功能的游戏开发领域,优先考虑组合,而不是继承,这会让你在面对不断变化的需求时,游刃有余。

相关推荐
cngm1108 小时前
若依分离版前端部署在tomcat刷新404的问题解决方法
java·前端·tomcat
江城开朗的豌豆8 小时前
让TS函数"说到做到":返回值类型约束的实战心得
前端·javascript
Tony Bai8 小时前
从 Python 到 Go:我们失去了什么,又得到了什么?
开发语言·后端·python·golang
华如锦8 小时前
使用SSE进行实时消息推送!替换WebSocket,轻量好用~
java·开发语言·网络·spring boot·后端·websocket·网络协议
晓得迷路了8 小时前
栗子前端技术周刊第 104 期 - Rspack 1.6、Turborepo 2.6、Chrome 142...
前端·javascript·chrome
亿元程序员8 小时前
Cocos安卓小游戏如何快速接入快手联盟变现?
前端
江城开朗的豌豆8 小时前
TS泛型:让类型也学会“套娃”,但这次很优雅
前端·javascript
程序员爱钓鱼8 小时前
Python编程实战 - 面向对象与进阶语法 - 继承与多态
后端·python·ipython
西洼工作室8 小时前
vue2+vuex登录功能
前端·javascript·vue.js