对应的分布式实时游戏框架见:这里及 聊天室demo源码,从零开始写了三个月,如果有兴趣和疑问可以私信我,这个项目做下来对我个人影响挺大,也是参考网易的pomelo写的。
从一个游戏情怀说起
接触的第一款多人对战游戏是帝国时代,依稀记得那时候上学每周最期待的就是冲到电脑课撸一把罗马复兴,高中开始接触《魔兽争霸3》,一款真正让我迷恋十多年的游戏,怀念那时候的《魔兽争霸十大经典战役》还有到图书馆翻 《大众软件》找各种电子游戏相关的新闻的日子,之后和很多人的经历一样,有了 Dota 有了王者荣耀,打一款MOBA游戏几乎成家常便饭,最近也没忍住撸到王者六十多星 ╮(╯_╰)╭。
帝国时代魔兽争霸3阴差阳错成为了一名码农,但不幸的是从来没有机会真正去涉足游戏开发者行业。去年魔兽3重制版出来,没忍住交出了一笔情怀税,算是弥补这么多年对暴雪的亏欠,然后转念一想,码农快十载了难道还任由自己继续堕落下去吗?对战类游戏最大的乐趣就是 “与人斗主宰一切的感觉”,“Triple Kill” “Monster Kill” 缭绕于耳,然而再想想那个真正在虚拟世界主宰一切的其实是制定游戏规则的人,也就是游戏创作者,那种当作者的感觉不是2.5D视角的而是真正的上帝视角,所以去年年中开始决定转行求变,从零起步了解下游戏设计,先从技术入手,啰嗦很多,当然不是为了给自己沉迷网络游戏找借口啦。
目录
一个简单的聊天室
若要问一个能集合多人互动又需要实时同步的简单场景是什么?答案就是聊天室,这也是很多游戏框架的入门demo,不例外,我也是从聊天室开始学习的,很快,写这篇文章的现在我大概花了那么丁点时间快速撸了一个,顺带凭着这么多年积累的前端美感对界面稍微加了点样式,代码地址。
聊天室如果你是一个前端从业者,相信你很快会想到使用 socket.io, 如果你不是,相信你也听过 Websocket。是的,因为简单,我们不用花时间去理解 TCP 的三次握手,拿来即用。为什么聊天室需要Websocket,答案是长连接,在聊天室里,一个房间的任何消息变化都要通过服务端实时广播推送给各个客户端,如下,client1 发送一条消息,其他的 client 都需要收到服务端的消息,而这个的前提是服务端需要知道有多少客户端连接着。
C/S对比下 http 请求,client1 发送完消息 (Request) 服务端接收后并返回 (Response) 即断开,如此服务端是无法获得其他客户端的连接状态并推送,不过 http 可以使用轮询 (Polling),每个客户端隔一段时间发送一个请求到服务端,如果有发现别的客户端的发送聊天室消息就返回数据,消息延迟跟轮询时间间隔有关, 如此也能做一个聊天室,想想任务也就完成了,但是如果这个聊天室是马化腾发起的呢,目标是做成呢?
性能,才是一款优秀的游戏服务器追寻的目标,一条消息服务端广播的数量和客户端数量成正比,n 条消息就是 n * n,如果再配上轮询,想想王者荣耀 460ms 的延迟是一个玩家能忍受的吗。回到 Websocket 同样会带来性能瓶颈,早期的网络游戏服务器大多是单台服务器单进程架构,所有逻辑都写在一起,同时长连接也需要比短连接带来更多的内存开销,如存储所有客户端Session信息,且内部其实也是通过某种轮询去实现的,这些总总,当我们想去打造一个 “企业级的游戏框架” (这个说法来自 eggjs =。=) 的时候,简单的使用 socket.io, 在面临大量的在线客户端时候,我们可能就到此就止步了,这也是这篇文章的一个背景和初衷,我想聊聊游戏服务器为了性能到底能做什么,可能经验不足,但至少搞下来收获满满。
分布式多进程模型设计
我在 Github 搜了很多游戏框架并对比,最终映入眼帘的就是网易的 pomelo。一是网易的大厂背景,想想当年的梦幻西游,二是它的文档架构的完备性,所以我花了很多时间把它的代码几乎都看完,但是由于它的代码年久失修几乎不维护,同时秉承前端造论圈的坏风气,我重新参考它的代码以更现代化的方式写了一个游戏框架 Regax,并美化了下架构图:
我们回顾上节所讲的性能瓶颈:
单进程单服务器无法承载更多的客户端。长连接广播带来的开销巨大,特别是游戏场景很频繁需要推送消息。再看下上边的图到底做了什么:
第一点,所有业务逻辑都以进程服务器粒度拆分,拆分越细越好,提升伸缩性,进程间通过RPC调用,如此可保证进程可跨集群服务器调用,这是分布式架构的基本。第二点,Socket连接服务器单独拆分,这是最关键的,它只负责连接及广播,不负责任何其他的业务逻辑,保证其性能最大化。除了解决上节问题再进一步优化:
第三点,协议层更加灵活,不再只是Websocket,由于连接服务器的隔离加纯粹性,服务器可支持多种连接方式共存,如此我们能承载的客户端更多,还可支持灵活切换,如真正的业务场景tcp和udp可根据客户端支持情况自动切换。第四点,引入网关层,网关层用来控制连接的路由算法,想想农药里的服务器分区策略,再比如地理位置就近原则,分配就近的服务器,进一步提升网络传输效率。第五点,进程支持权重,权重越高,分配的进程越多机会越大,这也是伸缩性的一种提升。其他模块就是大众服务器所通用的扩展,如监控及存储等,这里不赘述,真正去理解专研一款优秀的框架设计时候,真的会爱不释手。
一切准备就绪,设计完框架后急需一个业务场景去试炼一番,以此来反哺框架,想想现在能做的太多了,撸一个页游传奇Online渣渣灰绰绰有余,在我所在的支付宝小程序团队也很需要创新场景,框架本身也能给业务带来更多的可能性更多的玩法,最终敲定做了一款简单的多人实时对战贪吃蛇, 可支持和好友一起玩,这时候才是体会开发游戏的乐趣所在。
多人实时对战贪吃蛇
我们参照了王者荣耀的好友匹配+对战的模式设计了下贪吃蛇,如下:
贪吃蛇房间匹配页面贪吃蛇对战页面贪吃蛇游戏结束排名首先按上节的架构,我对服务器做了拆分:
连接服务器 (ConnectorServer):负责和客户端的Websocket连接及通知,同时校验登陆Token,如果Token不合法直接关闭连接,连接后通过token再去数据库拿用户的昵称等信息。class ConnectorServer { enter({ token }){ // 1. 校验 Token // 2. 通过 Token从数据库获取用户信息, 并创建 Session // 3. 监听 Socket关闭 this.ctx.session.on(disconnect, () => { // 4. 如果当前用户在某个房间,发送RPC通知房间服务器踢掉用户 this.ctx.rpc.room.kickUser(this.ctx.session.uid) }) } }2.房间服务器 (RoomServer): 负责房间的创建及加入,并通知房间里所有的用户信息
class RoomServer { kickUser() { // 1. 踢掉用户 // 2. 发送 RPC 给 ConnectorServer 广播给客户端房间信息, 这里channel内部封装了rpc this.ctx.channel.room.pushMessage(onRoomChange, roomData) } joinUser() { // 1. 加入用户 // 2. 发送 RPC 给 ConnectorServer 广播给客户端房间信息, 这里channel内部封装了rpc this.ctx.channel.room.pushMessage(onRoomChange, roomData) } startGame() { // 1. 发送RPC给 BattleServer 开始游戏 this.ctx.rpc.battle.start(roomMembers) } }3.对战服务器 (BattleServer): 贪吃蛇开始游戏后,会在服务端建立 帧同步 模式,并定时推送消息,帧同步会再之后介绍:
class BattleServer { start() { // 模拟帧同步,真正实现会比这个复杂 setInterval(() => { // 按每秒三十帧的频率发送帧数据给所有客户端 this.ctx.channel.battle.pushMessage(onBattleFrame, currentFrame) },1000 / 30) }, syncFrameAction() { // 从客户端接收到贪吃蛇的操作动作并插入到当前帧数据里 } }而在客户端:
import { Client } from @regax/client-websocket const client = new Client({ url: ws://localhost:8002, reconnect: true }) // 监听服务端断线 client.on(disconnect, () => {}) // 1. 创建 WebSocket 连接 await client.connect() // 2. 监听房间成员变化,这里会通过服务端广播接收到 client.on(onRoomChange, ( roomData) => {} ) // 3. 监听游戏开始后的帧数据变化 client.on(onBattleFrame, ( frame) => { // 每接收到一帧,就驱动贪吃蛇渲染引擎渲染一次 }) // 4. 登陆并校验token await client.request(connector.enter, { token }) // 5. 加入房间 await client.request(room.joinUser) // 6. 点击开始游戏 await client.request(room.startGame) // 7. 操作贪吃蛇时候发送操作行为 await client.request(battle.syncFrameAction, { action })这样一款多人对战版的贪吃蛇算是基本完成了,但是真正实现的时候遇到不少的坑,如卡顿严重,另外为什么要使用帧同步,帧同步和状态同步的区别在哪,再下一章我会聊一聊这个话题。
最后
如果大家想体验可以到支付宝搜下 `福利贪吃蛇`, 目前集群机器还比较少请轻虐,最后,不忘记招聘,如果你有兴趣,可以私信我,阿里系能给你的自由度及想象空间挺大。