概述
继承和组合是编程中常用的两种策略,旨在尽可能多地重用代码。继承应用得非常广泛,但我认为组合在很多场景下会更加合适一些。
基于组合,游戏开发前辈们专门设计出了实体组件模式(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面向对象的思想,也难免落入父类、子类、继承和扩展的惯性思维
- 但是一味的使用继承,可能会导致设计的类越来越多,类与类之间的关系越来越复杂,最后导致可扩展性下降,并出现不可消除的冗余和重复代码
- 我们可以使用组合思维,将游戏中的类分为“实体”和“组件”,实体可以根据游戏的复杂度不断增加,组件也可以按需开发和增加,而实体的功能由添加的组件定义,而且可以随时增删。
- “组合”形式,大大降低了类之间的关系的复杂度,并避免了复杂继承关系,父类修改导致子类崩坏的可能性。
还没有评论,来说两句吧...