评鉴Maze源码(1):GamePlayKit的ECS“实体-组件-系统”


苹果去年发布了GameplayKit的代码框架,为游戏开发者提供了很多超级实用的API及工具,来提升游戏开发者的效率,使得制作游戏能更加聚焦在游戏本身-也就是游戏性的策划和创意上了。

由于业余时间会用Spritekit做些小游戏demo,GameplayKit出来后感觉给了极大的方便,我就借由苹果提供的Maze的Sample代码,来跟大家介绍下GameplayKit提供的新功能,希望大家能够喜欢!

一,朴素的游戏编程的陷阱

之前小武并不是做游戏开发出身的,所以并没有学习很多游戏开发里面已经成熟的算法或者编程模式来提升开发效率。仅仅凭借爱好和随性的编写,完成游戏代码(还真是随便啦)。

1,需求描述

比如小武以前曾经制作过一款类似“合金装备”的逃脱类游戏:

(1)战士需要躲避敌人的监视,逃到制定的出口。

(2)敌人巡逻有规定路线,敌人的能力也有些变化。

(2)战士能够利用各种道具,来完成逃离。

2,朴素的编程

朴素的编程设计(其实就是不过脑子,嘿嘿!):

(1)对“战士”,和“敌人”做了一个公共的类“人”作为祖先,然后“战士”和“敌人”类分别继承自“人”,根据各自需要扩展私有部分。

(2)其它操作、移动、AI等都在各自私有类中完成。

(3)后续需求增加了,我想做更多类型的“敌人”,不同“敌人”的能力不尽相同。一开始,将相同部分的能力,放到一个“敌人”的公共类中,所有“敌人”都继承自这个公共类。后来,随着“敌人”类型的不同,可以有很多种能力搭配。“敌人”公共类的代码就越来越多,类也越来越大了。

(4)有时候,同样的能力也想为“战士”提供,要在“战士”和“敌人”类里重复Copy很多代码。不管怎么样,随着游戏越来越复杂,里面重复的代码也有可能越来越多。

3,具体分析

(1)一开始“战士”和“敌人”继承自“人”,“人”提供基本的移动能力和精灵(负责实体图像);“战士”和“敌人”分别有隐藏,跑动和侦查,射击等功能。

如下图所示:


最初的类继承图


(2)现在,“敌人”的种类加了3种,分别具有狙击,透视,喷火这些能力的组合。而且,战士升级了,需要有射击能力了。此时,只能将公共的狙击,透视和喷火能力,提升到“敌人”的公共类;而射击能力,要提到“人”这个公共类。

如下图所示:


需求变化后的类继承图


因为不是专职做游戏,仅仅是业余时间玩一下,所以写个小Demo后,也就没有继续开发,继续深究下去(其实是实在写不下去了,扩展性和可维护性太差了!)。

小武有一颗积极向好的心,但是懒癌重度,放着吧~~!

二,GameplayKit的“实体-组件-系统”

直到苹果推出了GameplayKit框架,我又持续对游戏开发有点兴致。开始学习后,发现游戏开发里面,早就有很多事实上的开发模式和方法了。其中“系统-实体-组件”是比较好的解决以上问题的方法。(因为小武水平有限,且比较懒,恶意吐槽伤害作者玻璃心的。。。。。也懒得理你们!)

上面小武所做的那个游戏Demo,实际上是一种面向对象的继承(Inheritance)体系,按照这个体系组织游戏代码会造成公共祖先类巨大无比,子孙类也默默继承了很多无用的代码,代码明显有“坏味道”。

实际上,有很多能力是可以抽象出来的,同时给“战士”和不同类型的“敌人”使用,比如渲染,移动,AI,特殊技能等。这些功能通过不同组件的组合,就构成了战士和不同类型敌人。这是一种组合(Composite)体系。也就是下面需要介绍的ECS模式了。

1,“实体-组件-系统”:ECS(Entity,Component,System)

ECS(Entity,Component,System)即“实体-组件-系统”。实体,是可以容纳很多组件的容器。组件代表一种功能和行为,不同的组件代表不同的功能。实体可以根据自己的需求来加入相应的组件。

比如上面的那个游戏,我们将游戏角色的移动,精灵,隐藏,跑动,侦查,设计,狙击,透视,跑步等都作为组件类,每一个组件都描述了一个实体某些属性特征。我们的实体根据自己的实际需要,来组合这些组件,让对应的实体获得组件所代表的功能。

现在实体“战士” “敌人1”“敌人2”“敌人3”“敌人4”实体与功能组件之间的对应关系如下图所示:


ECS模式下游戏对象管理方式


从继承(Inheritance)体系到组合(Composite)体系好处显而易见:

(1)方便通过聚合多种组件,构建复杂的新实体。

(2)不同的实体,通过聚合不同种类的组件来实现。

(3)不必重复写组件,组件可以复用,也易于扩展。

(4)实体可以实现动态添加组件,以动态获得或者删除某些功能。

 2,游戏运行的发动机

我们需要将“战士”“敌人”这些实体放入到游戏中运行,游戏需要驱动这些实体的组件发挥功能。我们如何驱动游戏运行呢?我来跟大家来八一八。

游戏引擎(如SpriteKit和SceneKit,也包括客户定制的引擎及直接使用Metal或者OpenGL)在一个称为“更新/渲染”循环里,执行游戏有关的代码逻辑。这个循环贯穿游戏的生命周期,按字面意思分为更新(Update)阶段和渲染(Render)阶段。在更新阶段,与游戏逻辑相关所有的状态更新,数值计算,操作指令,执行序列计算,AI处理,动画调度等都在该阶段完成。在渲染阶段,游戏引擎部分进行自动处理,主要处理动画渲染和物理引擎的执行,基于当前状态渲染画出所有游戏的场景。通常,游戏引擎的设计,是让游戏开发者完成更新阶段的所有逻辑,然后引擎负责游戏渲染阶段的所有工作。苹果的SpriteKit和SceneKit就是这样设计的,从苹果提供的SpriteKit游戏引擎的运行Loop图中可以略窥一二。

具体用SpriteKit引擎来验证下上面的描述。游戏时看到的每一帧画面,展示在我们面前时,游戏引擎都会经历下面这个Loop图所示的处理:


SpriteKit的生命周期


(1)每一帧(Frame)在SKScene里会调用update:来进行更新操作,游戏开发者在这里放入所有的游戏逻辑层面的代码。

(2)后面的Action,Physics,Render阶段分别负责Action执行,物理引擎检测处理,和游戏图像的绘制渲染处理,这些工作都是交由SpriteKit来进行。

驱动游戏运行的动力:

更新阶段,开发者在update里面计算出所有游戏逻辑和游戏状态;

渲染阶段,物理检测,图像渲染由游戏引擎完成。

3,GameplayKit里的ECS运行方式

因此,驱动实体和组件在游戏里面运行可以放在更新阶段,在SpriteKit引擎里的update方法里。GameplayKit提供了

GKEntity类GKComponent类及GKComponetSystem类

来实现ECS模式。GKEntity提供接口添加GKComponent组件,并管理这些组件(增,删,查操作)。GKComponentSystem也能对GKComponent提供同样的管理操作。

游戏执行时,每一次调用update的更新操作时(spriteKit的update:操作和senceKit的renderer:updateAtTime:方法),调用游戏实体的更新方法,实体会将更新消息分发给组件(GKComponent),组件执行updateWithDeltaTime来驱动组件运行。

GameplayKit提供了两种分发策略:

(1)实体更新:如果一个游戏仅有很少的游戏实体,遍历所有活动的实体,执行实体(GKEntity)的updateWithDeltaTime方法。实体(GKEntity)对象会将updateWithDeltaTime消息转发给该实体的所有组件(GKComponent),驱动组件执行updateWithDeltaTime方法。

(2)组件更新:在一个更加复杂的游戏里面,不需要跟踪所有实体和组件对应关系,同类型组件能按顺序独立更新更加有用。为了满足此种情况,可以创建GKComponetSystem对像,GKComponetSystem管理一具体类型的组件,当每次游戏更新的时候,调用GKComponetSystem的updateWithDeltaTime的方法,系统(GKComponetSystem)对象会将updateWithDeltaTime消息转发给该系统的所有组件(GKComponent),驱动组件执行updateWithDeltaTime。

在方式(2)里面,GKComponetSystem系统(System)出现了,它可以被认为是管理同一类组件的容器,并驱动它更新所有该类型的更新操作(Update)。

综合以上说明,就是ECS模式。

三,Maze游戏代码评鉴

Ok,我们回到正题,苹果为我们更好的进入苹果开发这个大坑,提供各种便利。代码的Sample是个非常好的手段。Maze游戏是一个类似吃豆人的简化版,只是这里没有吃豆豆,就是一个菱形与四个方形敌人的捕猎和反捕猎的循环。

Maze游戏代码下载地址


1, Maze游戏的需求分析

Maze游戏的实体很少,就是player(菱形)和enemies(四个方块)。分析下它们的需求:

(1)player和enemies都需要在游戏中显示,很显然,它们需要显示渲染的组件。

(2)player需要受到控制,在Maze迷宫里面运动。它需要一个控制组件。

(3)enemies的控制需要计算机来给,因此要一个组件来给出enemies的状态。

(4)enemies需要一个系统对象来管理状态的更新。


所以,我们现在至少需要:

(1)2个实体,player(菱形)和enemies(四个方块)。

(2)3个组件,负责渲染的组件SpriteComponent,负责player控制的。PlayerControlComponent,负责enemies的AI的IntelligenceComponent。

(3)1个系统,负责AI即IntelligenceComponent的控制。

如下图所示意:


Maze游戏ECS设计架构


2,代码实现

(1)实体entity实现:

代码里面,实体部分因为仅仅是个容器,所以比较简单,直接继承了GKEntity。在Maze游戏里面,每个实体其实在迷宫(Maze)里面移动,都会有位置信息,因此,在公共的AAPLEntity类中,加入gridPosition信息(此信息为迷宫坐标值,并非SKScence里面的sprite位置)。

@import GameplayKit;

@interface AAPLEntity : GKEntity
 
@property vector_int2 gridPosition;
 
@end

(2)组件component实现:

a,组件都是继承的GKComponent对象,该对象仅提供了谁持有该组件的变量entity和系统System对象。

b,实现更新阶段,调用的updateWithDeltaTime方法以及其他Helper方法。

Sprite组件

:继承GKComponent,相关变量和方法负责持有该组件实体的图像表现和计算控制运动表现两部分。Sprite部分的渲染和图像展示方法可查阅我之前写的相关的SpriteKit引擎教程,学习使用。

@interface AAPLSpriteComponent : GKComponent 

@property AAPLSpriteNode *sprite;
...
 
#pragma mark - Appearance
// 重生时候心跳动画
@property (nonatomic) BOOL pulseEffectEnabled;
 
// 捕猎状态外在
- (void)useNormalAppearance;
...
 
#pragma mark - Movement
 
// 移动相关方法
@property (nonatomic) vector_int2 nextGridPosition;
- (void)followPath:(NSArray<GKGridGraphNode *> *)path completion:(void(^)(void))completionHandler;
 
@end


PlayerControl组件

:实现play的控制逻辑,在更新阶段,通过实体Entity执行updateWithDeltaTime方法来实现对Player实体的控制的计算,这里可以用手势(Mobile系统),也可以用键盘(iMac系统)来确定移动的方向,PlayerControl组件来完成实际控制操作。

在Player实体的控制移动过程中,实际上也要调用Sprite组件。Sprite组件控制实体在游戏场景中的move渲染,即运动表现部分。

所以这是组件间配合,PlayerControl组件,仅负责处理告诉Player实体该怎么移动,具体移动渲染表现,还是交给Sprite组件来负责。其实运行示意图如下所示:


PlayerControl组件处理逻辑


Enemies的AI组件:

通过状态机来实现enemies实体状态的迁移。状态机在后面的系列文章里面会具体描述,这里仅理解为控制敌人方块行为的算法即可。状态机初始化代码如下所示:

// 初始化enemies的四种不同状态

AAPLEnemyChaseState *chase = [[AAPLEnemyChaseState alloc] initWithGame:game entity:enemy];
AAPLEnemyFleeState *flee = [[AAPLEnemyFleeState alloc] initWithGame:game entity:enemy];
AAPLEnemyDefeatedState *defeated = [[AAPLEnemyDefeatedState alloc] initWithGame:game entity:enemy];
defeated.respawnPosition = origin;
AAPLEnemyRespawnState *respawn = [[AAPLEnemyRespawnState alloc] initWithGame:game entity:enemy];
 
// 初始化状态机,并进入chase状态
_stateMachine = [GKStateMachine stateMachineWithStates:@[chase, flee, defeated, respawn]];
[_stateMachine enterState:[AAPLEnemyChaseState class]];

enemies的AI组件需要加入到intelligenceSystem的系统进行管理,因为所有的AI都需要在更新阶段进行执行,使用System管理,更加方便。系统的初始化代码如下所示(在游戏初始化过程代码里):

// 初始化intelligenceSystem
_intelligenceSystem = [[GKComponentSystem alloc] initWithComponentClass:[AAPLIntelligenceComponent class]];
NSMutableArray<AAPLEntity *> *enemies = [NSMutableArray arrayWithCapacity:_level.enemyStartPositions.count];
[_level.enemyStartPositions enumerateObjectsUsingBlock:^(GKGridGraphNode *node, NSUInteger index, BOOL *stop) {
AAPLEntity *enemy = [[AAPLEntity alloc] init];
enemy.gridPosition = node.gridPosition;
[enemy addComponent:[[AAPLSpriteComponent alloc] initWithDefaultColor:colors[index]]];
[enemy addComponent:[[AAPLIntelligenceComponent alloc] initWithGame:self enemy:enemy startingPosition:node]];
// 讲enemy实体加入到intelligenceSystem
[_intelligenceSystem addComponentWithEntity:enemy];
[enemies addObject:enemy];
        }];
_enemies = [enemies copy];

其具体的更新运行图见(3)节所示。

(3)更新阶段

SpriteKit引擎的每一帧画面渲染前,SKScene都会调用update:方法(

Maze游戏里是设置了Delegate,执行update:forScene方法

)来执行更新,即前面分析的更新阶段。此阶段,Maze游戏对Player和Enemies两组实体对象进行更新,如代码所示:

// Update components with the new time delta.
[self.intelligenceSystem updateWithDeltaTime:dt];
[self.player updateWithDeltaTime:dt];

分别执行相关对象的updateWithDeltaTime方法即可。

另外,下图显示了enemies如何在update更新阶段,驱动IntelligenceCompoent运行的:


Enemies的AI状态处理组件逻辑示意图


现在,我们的Maze游戏的ECS就在Spritekit游戏引擎的驱动下运行着,大家赶快试试这个小游戏啊!

四,何去何从

在Maze代码里面,还有很多其它重要功能,如:

1,状态机。

2,寻路功能。

3,一个GKRuleSystem,用来管理判断与Player的距离。

4,随机数的生成。

我会按照写作计划一步一步完成,希望大家都能获得收获。



推荐阅读更多精彩内容