概述
继承和组合是编程中常用的两种策略,旨在尽可能多地重用代码。继承应用得非常广泛,但我认为组合在很多场景下会更加合适一些。
基于组合,游戏开发前辈们专门设计出了实体组件模式(EC模式)和进阶的ECS模式。本篇所提及的Godot中的“组合”可能更倾向于UML类图中描述类与类关系的“组合”,而并不完全是EC模式或ECS模式所提到的严格的形式,尤其是在Godot中,更是以一种父子节点的关系来实现。
但是这对理解什么是“组合”和“继承”,以及两者的区别和优劣,并为进一步学习ECS模式提供基础。
参考视频
在笔者搜寻已有教程资料的过程中,发现B站这个搬运视频是最简单和容易理解的。
【中文语音】如何在 Godot 4 中轻松简化代码_哔哩哔哩_bilibili
所以我就在扒字幕的基础上,进行了扩充和修改,让其变成了一篇更容易阅读和理解组合模式的文章。建议在看原视频的基础上,将本文当做是一个笔记和总结来看。希望对Godoter们的学习发挥一点小小的作用。
继承
假设我们有这样一个设定:
- 我们有一个玩家类
Player
,它能够攻击,有生命值,并且有一个HitBox(攻击范围盒),同时还能处理用户输入。 - 相应地,我们也有一个敌人类
Enemy
,它同样能够攻击,拥有生命值和HitBox,以及一些敌人特有的东西,比如AI。
如果我们使用继承的思想和原则,就可以将Player
类和Enemy
类中重复和相同的部分helth属性、attack()方法和hitbox属性提取出来,放到一个单独的类Entity
中,并让Player
类和Enemy
继承自Entity
。
这样做的好处是:
- 敌人和玩家的代码得到简化
- 而且将重复性的内容放到单独的类,可以提高代码的可重用性
- 除了敌人和玩家,我们可以基于
Entity
创建其他具有类似特征(属性)和行为(方法)的类 - 修改
Entity
类,会自动将修改传递到子类
继承的缺陷
如果故事到这里就结束,继承将是完美的解决方案。因为这一切看起来十分自然,也很容易理解。
但是如果让我们引入另一个类Tree
来代表游戏中的树。
和玩家及敌人一样,树也可以受到伤害,也应该有生命值。
我们可以让Tree
类也继承自Entity
,但是Tree
在继承Entity
的health
属性的同时也继承了attack()
方法和hitbox
属性,这意为着树拥有了发起攻击的能力。
除非你做的是魔幻类的游戏和类似指环王里的树人角色,否则作为一般意义的“树”是不应该具有攻击能力的。
注意
这里的Tree
类名在Godot中肯定是不能使用的,因为Tree
控件已经占用了这个类名。这里只做理解,实际中可以起其他名称。
可以发现单纯的抽离父类和通过继承实现子类的方法,会存在父类某些属性或方法子类用不上的情况。此时,就表明你的父类需要进行更细致的拆分,并增加继承的层级。而这就会增加继承的复杂度,并让修改变得牵一发而动全身,甚至引发不可控的错误。
在Godot中,还有一个更深层次的问题:
- 玩家(
Player
类)和敌人(Enemy
类)都继承自CharacterBody2D
- 而
Tree
则应该继承自StaticBody2D
- 在Godot中,你无法同时继承多个类,也就是“即是…又是…”。
通过上面的类图其实已经可以发现:
- 玩家(
Player
类)和敌人(Enemy
类)已经出现多重继承的问题,也就是既是Entity
类的子类,又是CharacterBody2D
的子类,而这在目前Godot4.2为止的GDScript中是无法实现的 - 树(
Tree
类)也是相同的多重继承的问题,既是Entity
类的子类,又是StaticBody2D
的子类
如果我们将Entity
设为CharacterBody2D
的子类:
- 则可以瞬间解决玩家(
Player
类)和敌人(Enemy
类)的多重继承问题 - 但是树(
Tree
类)的多重继承问题则继续存在,Tree
不仅是Entity
,同时还是StaticBody2D
,而且还是CharacterBody2D
,这在Godot中是无法实现的
此时,你或许会想到将Entity
类拆分为StaticEntity
和CharacterEntity
,分别用来代表不能自主移动的“静态实体”和可以自由移动的“角色实体”。
并让Tree
继承自StaticEntity
,Player
和Enemy
继承自CharacterEntity
。
并分别设计StaticEntity
和CharacterEntity
的成员。
这样做的好处,确实是解决了多重继承的问题,但是肉眼可见的增加了类设计的难度,并且无法消除重复代码的问题。
这还只是引入了一个Tree
类,如果你需要创建一个具有很多游戏实体类型的复杂游戏,则每添加一两个类,就要想方设法增加一些基类用于继承。
这便是使用继承存在的问题。
组合
好在面向对象设计中,除了继承我们还可以使用其他的方式,而其中类的组合关系,可以解决一部分继承关系带来的问题。
我们换个角度思考一下我们面临的问题:我们只是需要将不同的特征(属性)和行为(方法)附加到不同的类上而已。而这就是“组合”思想。
我们将所需要添加的特征(属性)和行为(方法)称为是“组合”方法中的“组件”。
在UML类图中,用一条带实心菱形箭头的连线代表类之间的组合关系。
我们将之前继承关系改为组合关系:
- 首先我们将需要添加到各个类的特征(属性)和行为(方法)单独封装为一个类,并以“xxxComponent”形式命名,比如:
AttackComponent
、HealthComponent
、HitboxComponent
- 接着我们使用组合关系,将这些组件按需求添加到需要它们的类之上
可以看到类的关系一下子清晰起来,数量也有所减少,而且每个组件都是可以重用于之后的类的。
而且因为组件不是某个游戏实体的子类,所以不受继承影响。实体本身是什么类型,组件也不关心。并且重复代码的问题也消失了。
而这就是“组合”的魅力。
在Godot中实现组合
在Godot中,2D游戏中的“组件”是用Node2D
节点扩展而来的。它们通常作为某个“游戏实体”,比如Player
(CharacterBody2D
类型)节点、Enemy
(CharacterBody2D
类型)节点或Tree
(StaticBody2D
类型)节点的子节点而添加和发挥作用。
作为组件,我们可以按需添加到任何游戏实体上,比如我们新增了一个代表子弹的类Bullet
,则我们可以将AttackComponent
和HitboxComponent
添加到Bullet
上,这将让它自动拥有发动攻击和攻击碰撞检测的能力。而为子弹设定血量是没有必要的,因为一般来说子弹与某些可被射中的游戏实体接触后,便会自动销毁。因此不需要给Bullet
添加HealthComponent
组件。
实际案例
extends Node2D
class_name HealthComponent
@export var MAX_HEALTH := 10.0
var health:float
func _ready():
health = MAX_HEALTH
func damage(attack:Attack):
health -= attack.attack_damage
if health <=0:
get_parent().queve_free()
extends Area2D
class_name HitboxComponent
@export var health_component:HealthComponent
func damage(attack:Attack):
if health_component:
health_component.damage(attack)
上面定义了两个简单的组件:HealthComponent
和HitboxComponent
,我们便可以在任何需要相应功能的实体上添加组件,并调用其中的方法。
【原视频有使用组件的演示】
总结
- 游戏开发中难免使用OOP面向对象的思想,也难免落入父类、子类、继承和扩展的惯性思维
- 但是一味的使用继承,可能会导致设计的类越来越多,类与类之间的关系越来越复杂,最后导致可扩展性下降,并出现不可消除的冗余和重复代码
- 我们可以使用组合思维,将游戏中的类分为“实体”和“组件”,实体可以根据游戏的复杂度不断增加,组件也可以按需开发和增加,而实体的功能由添加的组件定义,而且可以随时增删。
- “组合”形式,大大降低了类之间的关系的复杂度,并避免了复杂继承关系,父类修改导致子类崩坏的可能性。