在前阵子举办的 Cocos 开发者沙龙上,来自华夏乐游 BigRoad 工作室的客户端主程李清,为现场开发者分享了其团队制作的实时竞技小游戏《保卫豆豆-欢乐枪战》的技术实现方案,深受开发者喜爱。在征得华夏乐游的同意后,技术派专栏将通过本文对李清的演讲进行梳理总结,把他们宝贵的技术经验分享给因为各种原因没能去到沙龙现场的开发者伙伴们!

Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

李清

关于华夏乐游

北京华夏乐游科技股份有限公司是一家专注于游戏研发与发行、泛娱乐IP打造和经营的创意文化企业。代表作品《奔跑吧-撕名牌大战》3、4、5系列,《极速前进-狂野飞车》、《星河战神》。目前聚焦于以 H5 为代表的无端和轻端游戏,先后研发和发行了《欢乐消消消》、《保卫豆豆-欢乐枪战》、《全民小镇》等微信小游戏,同时多款游戏上线微信小游戏精品平台。

Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

游戏简介

**《保卫豆豆-欢乐枪战》**是一款基于 Cocos 引擎研发的休闲射击乱斗小游戏,融合了射击、MOBA、吃鸡等热门玩法。

Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

游戏特点:

萌宠射击,实时竞技

四人乱斗,双人组队

多个英雄,身怀绝技

本文主要从三个方面来进行分享,分别是:

  • ECS 架构
  • 网络同步机制
  • 技术难点及解决方案

一、ECS架构

1、ECS 架构目的:

降低不断增长的代码库的复杂度。

2、游戏原型需求:

子弹:移动、碰撞

英雄:移动、碰撞、发射子弹

炮台:发射子弹

3、传统架构的弊端

要实现游戏原型,按照我们之前的做法,是用一个类来实现一种游戏实体的所有功能,这个类既有状态,又有行为。代码复用使用继承来解决。如果用这种做法,那么类大概长这个样子:

Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

类图

大家可以看到,父类会有很多共享的属性和方法,子类继承父类去做具体的事情。但是这种做法有很多弊端,比如说,随着项目规模的增长,代码库复杂度也不断增长,父类会越来越复杂,子类的功能越来越不明确,与多个类相关的代码你不能太确切知道应该放在哪里,拓展功能的时候极其不灵活,如果后期需要增加新功能的话,我们需要对整个继承树进行功能重构才能使其比较合理。

在经历过几个项目之后,**我们回头反思,发现之前的做法,违反了很多面向对象设计原则。**比如说:

  • 单一责任原则(Single responsibility principle)

每个类都应该只有单一的功能,并且该功能应该由这个类完全封装起来。

  • 组合重用原则(Composite Reuse Principle)

默认情况下应当使用组合,只有在必须时才使用继承。

在总结了从前的项目经验,并参考了大量技术文章后,我们找到了一种架构,把大量的模块进行拆分解耦,然后再集成起来,这就是我们接下来要介绍的 ECS 架构。

4、ECS架构

ECS 分别是:

  • Entity(实体)
  • Component(组件)
  • System(系统)

看到实体和组件大家可能觉得比较熟悉,但是这里要注意,这跟我们引擎中的实体组件框架可不是一回事,接下来我为大家简单介绍一下 ECS 架构的元素。

(1)ECS 架构元素:

Component:组件,存储游戏状态

Entity:实体,组件的集合

System:系统,实现游戏行为

World:系统和实体的集合,就是我们的游戏世界,他们的关系大概是这个样子的:
Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

我们可以看到,游戏世界中有很多 System,每个 System 负责实现一种游戏行为,同时有很多组件,每种组件中会有一些游戏状态,实体上可以挂载一个或多个组件,实体和 System 聚合成了我们的游戏世界。

(2)ECS 架构设计:

这个架构有个基础原则:

组件只有状态,没有行为

系统只有行为,没有状态

刚看到这个原则的时候,大家可能会有一些疑问,什么是游戏行为呢?

游戏行为,其实就是根据一定的规则去修改游戏状态。比如说移动,就是根据实体的方向和移动速度去改变这个实体的位置。

如果系统没有游戏状态,它如何去实现游戏行为呢?

这就是 ECS 架构最重要的职责了:为系统筛选出它关心的实体子集,只展示给它关心的游戏状态。

具体我们是怎么做的呢?

首先把可能单独使用的游戏状态归纳为一个个组件:
Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

比如最常见的位置、方向我们可以归纳为变换组件;移动速度这个组件可能会在移动系统中单独使用,所以我们把它归纳到移动组件中;碰撞组件则有碰撞盒的大小;攻击组件有攻击方向,这样我们就把各种属性给拆开了。

接着,我们在系统实现的时候,要向框架声明我关心哪些“组件元组”(Component Tuple)
Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

**什么是“组件元组”?**还是举刚刚移动的例子。移动系统的移动行为,应该是关心实体的位置、方向以及移动速度,就是我们归纳的变换组件和移动组件,那么只要一个实体同时挂载这 2 个组件,它就可以被移动系统遍历到,系统就会进行操作从而实现移动行为。

Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

最关键的一点,“组件元组”其实就是用来实现框架筛选实体的功能,实体只需要根据自身功能需求挂载相应的组件元组就可以了。比如说子弹它有移动和碰撞的功能,那么就挂载上变换、移动和碰撞这 3 个组件。

最终实现的效果就是移动系统遍历了英雄和子弹实体,在他们身上实现了移动的行为。攻击系统遍历了英雄和炮台实体,然后他们就可以发射子弹。

(3)ECS 架构实例:

接下来,我们看一下比较复杂的碰撞逻辑,这里我们可以对碰撞进行拆解:

首先是碰撞的触发系统。当碰撞发生时将产生一个碰撞事件,然后这个系统只干这件事。剩下的碰撞处理呢,对于子弹来说,会有一个碰撞后销毁系统,它会在碰撞之后把子弹销毁。对于英雄来说,他有一个碰撞后的损血系统,通过这种方式,我们就可以把碰撞进行拆分,再通过刚刚的方式集成在一起。

(4)ECS 架构作用:

这种架构可以**让每个开发人员负责不同模块的开发,有效地提高多人开发效率。****最重要的就是模块的复用,可以便于功能拓展。**如果你想改变一个实体的功能,只需要添加或者移除实体的组件就可以了。

比如说:一个英雄死亡之后,他应该失去移动功能,那么在英雄死亡之后,我们只需要把移动组件给移除就可以了,等他复活的时候再给他加回来。可以看到,这种方式非常方便。既然这么方便了,我们就可以做出一个编辑器,把这种能力开放给策划人员。

Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

实际上,暴雪就专门为 Overwatch 开发了一套 Statescript 的脚本语言,它用起来就是一个可视化的编辑器,策划人员可以在这个编辑器中编辑每个英雄在各种游戏状态中拥有什么游戏能力,程序只要实现具体的功能模块,然后开放给策划人员使用,非常地灵活。

以下是我们在实践过程中参考的技术文章:

[参考文档]

《守望先锋》架构设计与网络同步

《守望先锋》回放技术-阵亡镜头、全场最佳和亮眼表现

《守望先锋》中的网络脚本化的武器和技能系统

浅谈《守望先锋》中的 ECS 构架

二、网络同步机制

1、常见同步机制:

常见的网络同步机制可以分为以下三种:

  • 确定性帧同步(Deterministic lockstep)
  • 快照插值(Snapshot interpolation)
  • 状态同步(State synchronization)

(1)确定性帧同步

服务端:收集并转发玩家输入数据,不运算游戏逻辑

客户端:在玩家输入数据以后各自运算游戏逻辑

优点:只有玩家输入会被传输,数据流量非常小;代码都是写在客户端上的,所以代码复杂度较低。

缺点:对网络延迟要求非常高;每个机器浮点数运算不一致,需要将浮点数运算转换成整数运算;断线重连时间较长;因为游戏逻辑写在客户端,所以不是很安全。

(2)快照插值

服务端:运算游戏逻辑,将快照发送给客户端。

快照,就是我这一帧所有游戏实体的游戏状态。

客户端:不运算游戏逻辑,收到快照以后进行差值平滑播放。

实际上,客户端只是一个播放器。

优点:客户端运算量小;断线重连容易实现;游戏逻辑全在客户端,所以非常安全。

缺点:带宽占用非常大。

所以这种方式之前多用于像 CS 这种局域网对战。

(3)状态同步

服务端:运算游戏逻辑, 将玩家输入和部分状态发送给客户端

客户端:在玩家输入时,不等服务器就立马运算游戏逻辑,就有点像单机游戏了,但这种运算结果未经过服务器,不一定是正确的,所以它实际上是一个游戏逻辑的预测。在收到服务器数据后,会对预测结果进行校验,如果错误,就需要平滑地将其纠正到正确的状态。

这里说一下校验的过程,其实就是先回滚再前滚。

服务端下发的数据是之前一个时间点的数据,我们本地赋值以后相当于回滚到之前的时间,然后我们会一帧帧的运算到当前的时间,这就叫前滚,最后将计算结果与预测结果进行比较,可以看到校验的计算量是非常大的。

优点:客户端可以进行游戏逻辑预测;网络游戏体验好;以服务器数据为准,比较安全。

缺点:代码复杂度高;客户端运算量大;因为有客户端预测,所以客户端之间是不完全同步的。

2、小游戏平台特点

一开始我们的项目采用的是状态同步的方式,但由于我们的项目是针对小游戏平台的,小游戏平台有以下几个特点:

运算性能较差,客户端计算量不能太大

Javascript 代码很容易被破解,玩家想要作弊的话很容易

网络连接只能使用 TCP,所以带宽占用不能太高

3、欢乐枪战的实现方案

(1)带宽优化

基于小游戏平台的特点,我们项目从状态同步开始做简化,一直简化到以下这种实现方案:

服务端:运算游戏逻辑,将变化的状态发送给客户端

客户端:不运算游戏逻辑,收到数据以后进行差值平滑播放

优化了带宽占用的快照插值

这个大家可能看着就有点眼熟了,其实就是优化了带宽占用的快照插值。这种方案最关键的一点是,你要把带宽优化下来。而带宽优化最关键的,是只有在必要的情况比如游戏开始和断线重连时才发送全量状态,平时玩的过程中,只发送变化的状态。

另外一方面是数据压缩,比如方向,刚开始我们用的是方向向量,但其实用弧度制乘以一千就可以了,这样就把两个 Float 优化成一个 Short。

经过带宽优化成果:

上行:2~15pkg/s,流量占用:0.1 KB/s

下行:0~15pkg/s,流量占用:2.5 KB/s

这个流量占用对于目前的手机网络来说,是完全可以接受的。

(2)网络抖动优化

介绍完了带宽优化,接下来我们来聊聊网络抖动。

‍‍‍网络抖动指的是,网络的传输是不稳定的,服务端每个逻辑帧会发送一个包,它发送的频率是稳定的,但是对于客户端,可能在一个逻辑帧内收不到包,也可能收到多个包。

这在游戏中的体现就是,玩家在移动过程中,这一帧没有收到包,就停下来了,下一帧收到 2 个包,就跳过去了,体现出走走停停的状态。

对于这种网络抖动,最常见的优化方法是航位推测法。

航位推测法(Dead Reckoning):

客户端和服务端约定至少每500ms同步一次

客户端若没有按时收到移动状态,则用最后一次收到的移动状态继续预测一段时间

服务端若没有按时收到玩家输入,则用最后一次收到的玩家输入继续运算一段时间

Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

用这种方案优化之后,走走停停的现象就基本没有了。

抖动缓存法

另一种优化方案是抖动缓存,这是指收到包后不立马处理,而是放入抖动缓存中,延迟一段时间后再取出。

Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家

这种优化方案关键点在于缓存的大小。如果缓存太小,对于抖动还是比较敏感,抗抖动效果比较弱,缓存太大,玩家的延迟又特别高,所以你需要根据算法动态调整缓存的大小以适应网络环境。

(3)全区全服

所有玩家都在同一个大区里

前台服务器处理登录等战斗外逻辑

游戏服务器处理战斗逻辑

(4)分地域部署

我们的项目是**实时竞技游戏,对于延迟比较敏感,因此我们的游戏服务器采用了分地域部署。**服务器入口使用的是阿里云的“云解析 DNS”服务,按照地域自动分配游戏服务器(华北、华东、华南、西南),玩家在进行快速匹配战斗时,会根据地域分配服务器,同一地域玩家进入该地域所属服务器。

以下是我们在网络优化方面参考的文章,都是干货,如果感兴趣可以去了解一下。

[参考资料]

Networked Physics —— Glenn Fiedler

《王者荣耀》技术总监复盘回炉历程

150ms流畅体验 NBA2KOnline如何网络同步优化

三、技术难点以及解决方案

1、Javascript 语言使用

Cocos Creator上手很容易,不过 Javascript 语言非常灵活,需要统一代码规范。

所以在项目初期,我们就制定了程序和资源规范,包括代码格式、资源制作标准等,并且会定期去整理代码和资源。

2、客户端性能优化

性能优化这一块是我们比较头疼的问题。腾讯方面对于技术标准要求很高,对加载时间、帧率、内存等都有卡死的严格限制,不通过技术评审则无法上线。

我们项目一开始用的是 Cocos Creator 1.9 版本,用尽毕生所学优化了好几轮,还是只能跑到 40 多帧。在项目快要上线之际,Cocos 推出了 2.0 Beta4 版本,我们就在上线前 2 周去升级了大版本,现在想想还是挺刺激的。升级之后,体验很流畅,2.0 对于性能的提升是非常明显的。

  • iPhoneX 从 50 帧提升到 60 帧(iOS 机型)
  • 一加 5T 从 40 帧提升到 55 帧(安卓机型)
  • OPPO Y55 与 iPhone6 稳定在 25-30 帧(老机型)
3、自定义裁剪功能

出于对效率的综合考虑,Cocos Creator 2.0 移除了自动裁剪功能(cc.macro.ENABLE_CULLING),所以屏幕外的节点仍然会进行渲染,战斗中drawcall 较高。

于是我们就自己实现了一套裁剪功能。

当镜头或节点移动时判断节点是否需要进行渲染。

修改了一部分 Cocos 源码,在渲染底层添加了一个自定义标志位用来跳过不需要渲染的节点,从而快速实现我们想要的功能。

4、Spine 换装实现

整体换装:

在《保卫豆豆-欢乐枪战》中,英雄是可以进行整体换肤的,但是 Spine 官方并不支持一套动画数据对应多套图片。所以我们便开始研究源码,研究后发现,使用 SkeletonTexture.setRealTexture() 设置为另一张图片资源就可以实现整体整体换装功能了。

局部换装:

《保卫豆豆-欢乐枪战》战斗中,英雄可以自由拾取武器,但是英雄与武器是两套动画文件,具有交叉层叠关系,渲染时需要交叉渲染。这个功能 Spine 也是不支持的。在研究过源码之后,我们发现使用 Slot.setAttachment() 设置为另一个动画文件中的附件,就可以实现局部换装功能了。

结语:

Cocos 是开源的,所以大家在使用过程中,可以去多看看源码,都可以找到自己的解决方案。以上就是我今天带来的分享,感谢各位!

Cocos 技术派第4期 | 实时竞技小游戏技术实现分享-编程之家
《保卫豆豆-欢乐枪战》,欢迎各位扫码体验

非常感谢李清带来的技术实现分享,《保卫豆豆-欢乐枪战》自上线微信小游戏精品平台后受到了广大玩家的喜爱。此前,华夏乐游制作团队也曾接受 Cocos 的专访,分享项目的 2.0 升级之路,戳这里可以查看访谈内容。

技术派,是 Cocos 全新推出的专栏,我们将不定期邀请知名的游戏制作者,为广大开发者分享来自真实项目的实用的开发技术和实战经验。欢迎大家推荐想要学习的游戏产品和想要了解的技术知识,也诚邀有技术分享意愿的开发者联系我们噢~

更多精彩

技术派01 |《野蛮人大作战》H5项目从开发到上线

技术派02 | Cocos Creator 2.0 摄像机的灵活运用

技术派03 | 推荐一款可查看节点树的插件