基于ECS模型的卡牌战斗框架1
这篇文档基于我在《Thor》项目中对战斗架构的设计实现,对此类卡牌游戏的战斗进行通用型技术的整理,并梳理记录当前的一些实现细节。当再度开发此类游戏时,可以基于这些内容快速搭建起核心战斗框架,并能迅速实现功能铺量。
这篇文档计划基于以下内容进行分类整理:
- 战斗框架思路
- ECS模式的设计与使用,以及具体实现的一些优化内容与思路细节
- 技能、buff、子物体架构
- 战斗业务逻辑的通用数据与组织
- 帧同步技术的使用与实现
- 开发工具整理及思路
因篇幅在整理过程中发现较大,因而分章节说明记录。本章先介绍战斗框架。
战斗框架
战斗框架遵循逻辑与显示分离的原则进行设计。在基本原则基础上,划分出逻辑层与显示层,二者中间加入通讯层实现数据传输。
框架层级流程逻辑与显示分离的设计原则,来自逻辑可移植的需求。我们要确保逻辑代码与显示代码的彻底剥离,这样可以收获几个好处:
- 逻辑可单独打包,并可以保证在输入一致的情况下运算结果唯一,这样可以直接用于逻辑验证、或用于服务器开房间等;
- 代码移植过程中,不必担心因为引入了显示逻辑而导致的代码打包时找不到相关显示类的报错;
- 排除掉显示相关代码后,确保了逻辑层代码运行的独立性,排除其运行结果被其他代码干扰的可能;
- 代码结构清晰。
如果没有验证需求,那么逻辑与显示也可以合并实现(可以参考下文的ECS模式部分),但是在这种情况下,还是需要单独剥离一套显示层,用于战中的模型等资源管理、特殊显示需求实现等功能;而通讯层则可以省去,转而将显示层以单例实现供逻辑层调用。
简易框架流程所以按照逻辑与现实分离模式,对各模块进行介绍。
逻辑层
帧率的设计
逻辑层即战斗的核心逻辑,基于上文中的需求,逻辑层需要设计为定帧执行模式。
定帧模式下,具体帧率有多种参考:
- 跟随服务器帧率。如果我们要把核心逻辑用于开发帧同步游戏,因为服务器本身也有帧率以用于向各客户端同步信息,所以可以直接将逻辑帧率设置为服务器帧率,这样还可以省去逻辑帧与服务帧速率同步的问题。
- 游戏引擎刷新帧率。如果我们将游戏的刷新率设置为60FPS,那么也可以将逻辑设置为60帧,这样从显示效果上,可以更为真实地去模拟计算战斗逻辑。
- 其他。除了前两个方案,其实其他值同样可以,即便逻辑帧我们设置为1秒执行1次也是可行的,但是带来的问题则会在运行时表现得淋漓尽致。
虽然定帧方案就这么几种,但是实际开发时,帧率设置的具体大小也有很多需要注意的地方:
- 需要尽量去令一些计算结果更贴近实际。从积分的角度上讲,需要我们把时间切割的足够小,即帧率足够高,我们的逻辑运算才会更为贴合实际。
- 需要考虑帧率对性能的影响。帧率无限大,带来的问题就是CPU资源被逻辑大量占用,所以从性能的角度分析,帧率又需要足够小。
- 需要考虑策划对时间的配置。游戏离不开策划,而他们设置的一些CD、持续时间等数据会反向促使我们选择逻辑帧率,即我们的帧率能尽可能的体现出策划所配时间数据的差异性。举例来讲,这一点就是需要两个CD不同的技能同时计时,二者在逻辑上是分先后走完CD的,而不是在同一逻辑真内完成CD计算。如果不能保证这一点,那么策划配置的很多数据将没有意义,但是也需要把握度。
所以综合以上几点,逻辑帧率的设置是一个需要在游戏开发前期就要设置好的值,具体也需要参考项目规划。这里给出一个参考值是25帧,即每40毫秒计算一次。在这样的帧率下,首先能确保我们的一些拟合运算能够尽量贴近实际,使玩家在观感上不会有撕裂感;同时,40毫秒对于策划配置的时间相关数据而言,也已足够覆盖9成以上的时间差需求(因项目而异,如果项目对时间差的需求在秒级,那么也就无需考虑这么多了)。
逻辑驱动器(LogicMgr)的设计
如果我们把逻辑已经封装成了一个黑盒,那么逻辑驱动器就是运行这个黑盒的外衣。之所以设计逻辑驱动器,本质还是为了满足不同的逻辑运行场景需求。其接口结构如下:
ILogicMgr接口当有了LogicMgr接口的定义之后,针对不同的使用场景,只需要实现对应接口内容即可。
关于使用场景,以我开发项目举例:
- 核心战斗。这部分作为游戏的核心玩法,便是最基本的使用场景,拥有完整的输入输出功能。
- 游戏主场景。我们的游戏主场景内,也需要主角进行战斗演示,虽然这一部分可以是假数据,但是表现与核心逻辑需要与战中一致,因而作为一个特殊的使用场景,这里也需要单独进行LogicMgr的实现。
- 多人战斗。此处包含了多人的GVE游戏模式,也有多人的PVP模式,这两个游戏模式的本质都是以帧同步为数据同步基础的多人联机游戏。此时对应心跳以及玩家输入数据的处理都会发生较大的不同,因而会有单独的LogicMgr进行实现。
- 战斗验证服务器。这也是一种使用场景,因为这种验证设计,其本质就是对战场进行完整的重演。所以在此情境下,心跳内容会发生变动,以使全场游戏能够瞬间计算完成,同时玩家操作也替换成了玩家上传的操作队列,需要LogicMgr去分批次加载并生效。
这就是逻辑驱动器的核心设计思路。 在项目的具体使用中,ILogicMgr内部调用战场逻辑,这一部分内容主体使用ECS模式实现,但是一些周边模块还需我们单独抽象结构出来作为辅助,即下面会说到的通用环境组件。
定帧的实现
通用环境组件(IEnv)
如果LogicMgr是一个黑盒,ECS是黑盒内的核心逻辑部分,那么通用环境组件则是黑盒内部与基础数据交互的组件。
LogicMgr因不同的用途而具有不同的实现,那么作为基础的数据支持服务,环境组件也需要适当的进行扩展。这部分内容可根据项目实际开发进行扩展,此处仅用《Thor》所用内容举例。
在我的项目中,通用环境组件核心负责下面这几件事:
- 配置数据读取接口。因不同环境下,数据来源不同,所以可以通过在环境组件内设计数据接口,以匹配底层不同的数据记载机制;此外,一些配置常量型数据也可在此处实现。
- 特殊数据缓存实现。战场初始数据的缓存实现也在此,主要用于战斗验证这种计算密集型的GC优化。
通讯层
通讯数据的结构设计
所有内部通讯数据,均派生自MsgBase
,其结构如下,
现对内部数据与方法做必要说明:
- opcode:
Opdefine
为自定义的消息类型枚举,由每个具体协议声明具体内容。 -
Write
与Read
方法:这两个方法主要用于协议的序列化与反序列化。这一部分用处较多,即可用于战场信息的记录,也可作为战斗验证的依据,具体可根据项目需求考虑是否添加。
这就是通讯数据的设计,具体某一个协议内的数据根据需求在对应类中进行添加,但是所有的协议需要以MsgBase
作为基类,以便于自动化代码生成操作。自动化生成相关内容可参考后文了解详情,此处不作赘述。
通讯数据内部数据类型要求
为了便于后续的自动化处理,也是为了保证通讯数据本身的独立性,所以需要对数据类型进行一些要求:
- 允许值类型的使用,无论C#基础值类型或是自定义值类型;
- 引用类型需要在声明时就执行new操作(即默认不可为null);
- 自定义的值类型或引用类型需要在制定的命名空间下(便于自动化操作);
- 引用类型除自定义类型外,仅支持List(考虑到哈希顺序的问题,所以不支持Dictionary等结构)。
通讯数据交互流程
通讯数据交互时序图逻辑通讯流程大致如上图。
逻辑层向显示层发送消息,需要逻辑层先获取一个空内容的消息实例,赋值,并将该消息实例发送给显示层。
显示层收到消息实例后,不对该消息实例本身做任何操作,而是完整复制一个消息出来,并将副本加入自身的待处理队列。
上述操作执行完后,因是同步操作,所以逻辑层会在流程最后将刚刚发送的消息进行回收操作。
而加入显示层待处理队列的内容,会在显示层下次心跳时执行对应处理的Action(显示层Action在下文有详细说明),并在执行完后清空待处理队列,等待下一波协议的到来。
GC优化所做的工作
在前面的流程图中,存在显示层与逻辑层各自的消息缓存,这个缓存的作用就是用于GC优化的。
当需要用到某个消息时,便可以从消息缓存中拿到一个干净的消息实例,这样可以避免某些使用频率较高的协议频繁的new操作导致GC飙升。
当逻辑层赋值一个消息实例并向显示层发送后,需要及时回收;而显示层收到的消息实例会在随后被显示层回收,因而无法直接使用,所以需要立即对内容进行拷贝以得到一个副本,这样我们对副本的任何操作都不会影响原始数据,原始数据的修改也不会影响副本内的数据变动。基于此,我们的消息缓存就需要有提取、拷贝、回收操作。
这部分代码我们通过自动化工具进行代码生成,详情可参考下文。而内部的具体实现,仅仅是通过栈进行实例缓存,并提供对应接口,因无甚技术含量不在此处赘述。
显示层
显示层因完全贴合客户端进行开发,所以开发手段会更为灵活。较为简便的方式,就是通过单例模式创建显示层单例,这样代码开发起来更为方便。但是即便如此,显示层依旧有几个模块是不可或缺的,下文就进行记录与介绍。
Action
Action模块本身由显示层与逻辑层的通讯功能引申而来。
显示层会在收到逻辑层的数据之后拷贝出一份副本并入队,所以在显示层的心跳中,第一个要做的就是从消息队列中获取消息并处理,对不同的消息就是由不同的Action进行处理的,所以这一部分整合为Action模块。
显示层消息处理流程显示层消息处理流程如上,这也就是说,我们定义了n个消息类型,就需要定义n个对应的Action。
如果像我的项目一般存在多种逻辑运行的场景,那么这些Action也需要有对应的多种方案,因为每种逻辑运行场景下,消息的处理逻辑都会有所不同,需要分开单独实现。
显示实体数据管理
逻辑层可以将各种对象抽象为数据的组合,这点在后面的ECS模式中会讲到,但是在显示层,英雄、buff这些实体都是有实际存在与其一一对应的,所以这就需要显示层有单独的显示实体数据,并对其进行生命周期与逻辑的管理。
API
API可以理解为是方法的集合,遵循后文会提到的逻辑与数据分离的思想。既然我们已经将显示层的实体抽象出对象并进行生命周期管理,那么让他们执行具体的逻辑,无论是赋值或是播放动画,都可以将这些方法整理到API当中。
API的设计思路有很多,可以每个实体类型对应一个API集合,也可以每个显示功能对应一个API集合,一切以代码方便管理为前提。
需要额外说明的是,显示层很多方法是需要放到API中的,但并不是所有的都需要放在这里。根据项目需求,很多功能会需要我们单独开发一些组件或模块以协助实现某些特定的需求,这种情况下,组件与模块的方法依旧归于其内部,而API更多负责的是去调用这些方法以实现具体的功能。
辅助模块
辅助模块是显示层依据需求开发的独立功能,派生自基础模块接口,以实现一些统一时机调用的方法,且为了开发方便,每个模块应当是以单例形式存在的。
举例来说,我们的项目需要实现在特定情况下除核心英雄,其他英雄与场景都置灰的操作,且该状态下还要控制角色模型、特效按照规定的效果去展示,这就需要一个独立的模块去执行对应逻辑
显示逻辑控制
类似逻辑层有一个LogicMgr,显示层也需要一个DisplayMgr去驱动所有的实体运行,以及逻辑层消息的处理。
在显示逻辑控制的代码内,核心方法就是一个心跳方法Tick。在该方法中,优先执行逻辑消息处理,即从消息队列内取出消息并交由对应的Action处理具体逻辑。然后就是执行所有实体的心跳方法。
显示层心跳逻辑流程战斗管理器
无论逻辑层或显示层,想要运行起来终究需要外部驱动。因此,就需要引入战斗管理器(BattleMgr)来进行。
需要说明的是,在我的项目中,因为各类实际场景需要多个模块存在组合关系,所以战斗管理器的内容已经抽象到IBattleUnit
接口中。所以此处为了讲解方便,我会把IBattleUnit
的内容放回至BattleMgr
中进行讲解,这并不影响理解。
战斗管理器结构简介
BattleMgr结构战斗管理器内持有显示层与逻辑层的实例,并在Update
函数中对两个实例进行心跳的调用。
需要说明的是,这里调用顺序需要先调用逻辑层的心跳,再调用显示层的心跳。这么做的目的,是为了保证逻辑运算结果能够及时交给显示层进行渲染。
BattleMgr心跳流程此处的delta时间专门开了一个方法计算,就是为了应对项目内一些变更时间的需求。如最常用的战斗倍数功能,即可在该方法内参与计算,以获取实际需要的delta时间。
暂停组件采用计数暂停方案,用于处理战斗暂停逻辑。当战斗处于暂停状态时,则战斗管理器的心跳停止运行。但这并不是唯一方案,有些游戏也可以修改delta时间为0来实现暂停效果,并使得显示层继续运行,以满足自己项目的需求。
战斗管理器的使用
战斗管理器本身是以单例的形式存在的。因为这部分内容仅用于我们的客户端,基本不必考虑多线程、多实例的情况。
在此基础下,当通过入战数据将战斗全部初始化完成后,只需在对应的外部战斗模块内调用战斗管理器单例的心跳方法,即可运行战斗。
此处再无特殊技术要点,一切以项目实际需求为准即可。
以上就是一个完整的卡牌战斗框架的核心内容。在具体开发中,我们当然还会面临很多其他需求,需要我们去实现或引入一些独立的插件、公共方法等,这些就依据各自游戏的需求单独添加即可。而框架核心,上述内容便已足够。
下一篇文章,将着重记录逻辑的ECS模式与实现。