《一个帧同步的联机占领类小游戏实现|社区征文》

经我社区潜水多年和Creator下载多年的经验来看,帧同步联机游戏还是有很多人想做的。这不,我做了,直接看看游戏对战录屏吧:

玩法介绍

  • 每个玩家进入游戏会替代其中的电脑国家,每个国家初始占用两个地块,拥有地块0失败,占领全图地块胜利

  • 每个地块会在少于50兵力的时候自动增长

  • 用手指拖拽自己的地块到敌方地块出兵,士兵遇到任何敌方单位的碰撞都算作攻击,根据剩余兵力决定死亡占领,往自己地块上出兵增兵

玩一局后会发现,这不仅仅是个拼手速的游戏哦~

在线体验

二维码传送门

当前是同服全局游戏模式,所有人都退出后,才会重置游戏。

TSGF框架诞生记

其实一开始只是想做个对战小游戏的,可又是服务端又是客户端的,发现里面的门道挺多,并且很多理念都是通用的. 于是想到为何不封装一套联机游戏的全栈框架呢? 也可以让更多的人快速的实现自己的联机游戏。

于是 ts-gameframework 诞生了!

PS:开头的小游戏包含在这个框架的示例游戏中。

TSGF的定位

  • 对标MGOBE
  • 开源 的联机游戏 全栈式解决方案服务端 + 客户端 皆由 TypeScript 语言编写
  • 黑盒式” 实现联机游戏:客户端对接SDK,不用关心通信和同步逻辑
  • 自定义服务端逻辑:拓展自己的同步逻辑和各种交互逻辑

TSGF详解

服务器结构图

拓扑图

登陆流程

说明

  • gate 是入口服务器(或者叫调度服务器)+登录服务器+集群管理服务器(简单三合一),有需要可以分离出来

  • backend 游戏逻辑服务器,可以设计成本游戏一样,一个服务器对应一个全局游戏,所有连接玩家都视作加入这个游戏。也可以加入房间的概念

  • frontend 游戏客户端(包含2个demo)

  • 需要部署 redis 服务器,在 gatebackend 的配置文件中配置连接信息

  • 使用 TSRPC 作为通讯框架,所以用了这个框架自带的代码生成/同步模块,导致各项目的目录名以及相对路径的固定(不熟悉乱改会导致出错)

PS:各项目根目录需要各自执行 npm ifrontend 需要用Creator打开一次即可消除报错

1. gate

  • 开发时,执行 dev 的脚本,会启动服务并监视 ts 代码,并生成和同步相关api

  • 如果仅修改了 TSRPC 的通讯协议,并且没启动dev,那么可以单独执行proto and sync

  • 所有接口都写了 jest 的单元测试,执行所有单元测试:test

  • 如果需要部署,则执行 buildTS ,然后打包下列文件:

    ./deploy
    ./dist
    ./node_modules
    ./gf.*.config.json    (配置根据实际情况自行修改)
    
  • windows部署,提供了快捷部署服务方式,右键管理员运行 deploy/install_runasAdmin.cmd 即可

  • linux部署,可以使用 pm2 启动:pm2 start dist/index.js

2. backend

  • 开发时,执行 dev 的脚本,会启动服务并监视 ts 代码,并生成和同步相关api

  • 包含两个示例,都是单服务器游戏实例(即一个游戏服务器一个游戏实例)

  • 可以拓展成一个房间一个游戏,或者一个房间多个游戏等等

  • 部署参考 gate

3. frontend

  • 导入为 Creator 3.4.2 项目

  • 场景为 assets/occupationTheWar/occupationScene.scene

  • assets/scripts/occupationTheWar/ 为本游戏的客户端代码目录

  • assets/occupationTheWar/为本游戏的客户端资源文件目录

(预览模式下,因为将断线重连的信息写到了web存储里,所以要开第二个客户端的话需要用 Private 窗口来打开预览地址,或者改用两种浏览器)

经验分享

包含状态同步的帧同步实现

这个功能是本游戏或者本框架的核心封装!

思路来自:干货丨腾讯高级工程师宝爷:帧同步游戏在技术层面的实现细节 (qq.com)

帧同步示意图

  • “输入操作"的定义:所有会影响游戏逻辑的"输入”,如玩家加入离开操作,拖拽产生的攻击操作,而不是移动等因操作而产生的数据变化。 这点很重要!

  • 客户端的输入操作发给服务端,插入到当前帧的下一帧

  • 服务端按照帧率广播每一帧数据,每一帧中包含所有客户端的输入操作

  • 客户端收到帧后,加入帧队列,按给定频率执行

  • 执行每一帧里的多个输入操作,就需要客户端实现各种输入操作的逻辑,执行完输入操作后,客户端触发逻辑帧更新(用来运算坐标、物理等)。注意,这里要和渲染帧进行区分!

状态同步信息的加入

  • 由客户端定时将当前的状态数据同步一份给服务端保存

  • 服务端保存最后的状态数据以及对应的帧索引

  • 玩家掉线新玩家加入 时,就下发 最后的状态数据+后续的追帧列表。 这样可以 大大减少追帧时间

  • 该功能对游戏设计有更高的要求:数据分离,但可选择关闭本功能

核心代码

服务端:src\FrameSyncExecutor.ts

export class FrameSyncExecutor {
    /**
     * 一个逻辑帧的处理,正常由内部定时器调用,只有在单元测试时可以让外部调用进行测试
     */
    public onSyncOneFrameHandler(): void {
    }
    /**
     * 同步游戏状态数据
     * @param stateData
     * @param stateFrameIndex
     */
    public syncStateData(stateData: any,stateFrameIndex: number): void {
    }

    /**
     * 添加连接的输入操作到下一帧
     * @param connectionId
     * @param inpFrame
     */
    public addConnectionInpFrame(connectionId: string,inpFrame: MsgInpFrame): void {
    }

    /**获取给连接发追帧数据(最后状态数据+追帧包) */
    public buildAfterFramesMsg(): MsgAfterFrames {
    }
}

客户端:assets\scripts\common\FrameSyncExecutor.ts

export class FrameSyncExecutor {
    /**当服务端要求追帧时触发,更新相关数据*/
    onMsgAfterFrames(msg: MsgAfterFrames) {
    }

    /**收到服务端的一帧,会推在帧队列里,并做一些数据校验*/
    onSyncFrame(frame: MsgSyncFrame) {
    }

    /**真正执行下一帧(按顺序触发多个输入操作的实现),最后触发逻辑帧事件,返回是否执行完所有帧了(可能是执行前就完了,也可能是执行后完了)*/
    executeOneFrame(dt: number): boolean {
    }

    /**开始执行下一帧(或者追上落后帧),并且自动根据情况执行之后的帧,执行完则自动停下来,设置 executeFrameStop=true*/
    executeNextFrame() {
    }
}

逻辑帧和渲染帧分离

  • 逻辑帧:服务端广播的帧,这个帧频率可能没渲染帧那么高
  • 渲染帧:即 FPS 里的帧

一般单机游戏,都是在update里去计算移动坐标等。

但因为帧同步的频率和渲染的频率大概率是不同步的,如移动功能,输入操作一般是 0帧:开始向上移动,3帧停止移动,如果放在渲染帧里,那么就不能保证所有客户端的移动距离一致。

所以必须区分逻辑帧和渲染帧!

本游戏的设计原则:

我认为,不管什么框架或实现方案,都应该规范设计原则,在团队协作中是很重要的,不然各自有各自的习惯,当发生需要交互时,问题就大条了。

所有设计都是随着使用场景一步步迭代来的,没有万能的框架,只有最适合的设计(权衡 时间成本+协作成本),本游戏的设计原则如下,提供参考:

  • 场景/资源引用,单独的类存放,如:OTWGameResource 放游戏预制体的引用,OTWSceneManager 放场景节点的引用(现在里面有做一些简单的逻辑,如果复杂了,就需要按设计原则进行分离),然后给其他业务逻辑实现组件引用,避免循环依赖

  • 游戏业务逻辑按模块封装,避免循环依赖,如: OTWGameManager 为总的游戏逻辑(进入/退出游戏,逻辑帧执行,输入操作实现等,如果输入操作类型多了,就需要进一步分离一个"Mgr"),OTWGameTouchController 为用户操作场景节点的交互,OTWTroopManager 为士兵管理和交互逻辑封装

  • 所有游戏对象的根节点都要有一个基础组件 OTWObjectComponent,(方便统一获取信息)各类型的游戏对象都使用了子类,实现自己的信息存储和基本逻辑(如:对象类型属性 objType,逻辑帧渲染方法 updateData

  • 因物理用了 Creator 内置的,碰撞检测是用 Collider 相关组件实现,所以有空间变换的游戏对象,需要分物理节点和渲染节点,并在物理节点加上对应的物理组件,如士兵有分,地块就不需要分(没有移动)

  • Data定义为包裹数据或节点引用的 model ,给所有,统一由对应的Mgr负责维护(创建和销毁),给各业务逻辑引用

  • 所有的输入都不能直接自己操作数据,必须在输入操作实现里进行操作( OTWGameManager-> 定义 'execInput_< InputType>'方法),保证所有客户端最终计算数据能一致

单元测试,这很重要

我用 jest + chai 作为单元测试模块,推荐vscode的插件:Jest Test Explorer (单独执行单元测试用起来比较方便):

但凡一个逻辑或模块复杂起来了,势必需要封装起来,减少各种依赖,尽可能"独立"。否则越复杂的逻辑,依赖性关联性越强的功能,出错后排查所花费的头皮会等比上升,而我认为 封装+单元测试 是任何一个复杂逻辑的解药。

在自己的项目中使用,执行:

npm i -D jest ts-jest chai @types/jest @types/chai

配置和代码,参考源码吧。

TSGF的后续规划

目前TSGF框架只适用于帧同步小游戏,以及用来快速验证联机游戏的核心玩法。后续版本规划:

  • V1.1.0 完善房间和匹配支持
  • V1.2.0 设计成模块引用的方式进行使用
  • V1.3.0 支持状态同步

完成框架后,还计划着上一款自己的(咳咳,这里可以叫"官方")联机游戏. 东西行不行,只有真正用的人知道!

再之后可能会尝试运营这个业务,让服务器都不想部署的开发者能直接用!也算是完成一个闭环了。

请大家多多关注!

共勉!

26赞

顶你上去.

2赞

大佬,牛牛牛 :smile:

1赞

太肝了,爆顶~

静态资源下载有点慢,建议可以开个 CDN 放~
如有需要我可以免费提供~

感谢大佬,很值得研究 :smiley:

:laughing:
您好, 我浏览的您的那个,
occupationScene 简易多人混战 的例子, 按照您 Gitee 上的说法配置了下,
启动后, 登录刷新还是显示测试服 0 人在线(多开浏览器登录也是 0 人),
另外您上面那个二维码也只是 0 人在线, 请问这个是我什么地方配置的不对吗 ?
麻烦您看下, 这是我改的部分, 改了下 7456



我研究了一下 , 单独把js, json等文件放到另外一个域名下, 貌似行不通, creator构建时路径都是写死相对的
除非包含html页面一起放到cdn, 而这个方案又会导致跨域问题, 解决ws跨域,需要服务端配合输出响应头, TSRPC我又没找到哪里跨域配置的

那有点奇怪了呀, 都能显示出游戏服务器了, 在线数据只是其中一个字段罢了, 你用调试模式运行gate的dev脚本, 断点gate\src\index.ts:line:29, 即SyncServerInfo消息, 看看更新人数的逻辑是不是哪里有问题

我开发环境刚试了是正常的, 线上部署的那个也能显示哦, 你另外开个隐私窗口连上去确认一下哈

好的, 我再看下, 不过我不是在 VsCode 里面运行的,
我是在文件夹里面执行的 cmd 命令启动的 gate 和 back , 会和这有关吗 ?

是用npm来运行吗?是的话那一样的
不是的话能跑起来, 感觉也是没影响才对
服务器上我就是用nodejs命令行包裹成win服务来运行, 原理上也是一样
你还是调试一下吧, 也可能是哪里我没注意到的问题, 你找到的话一定告诉我哈

1赞

您看我这2个 gf.gate.config.json 这里只改了 authRedisConfigport ,
是不是其它地方还有需要更改的呢?

用的是这个启动的 ts-gameframework-master\backend>npm run dev
image

Mark一下

你的配置和命令行看起来没问题
人数更新backend是连上gate的websocket后, 定时发送一个消息, 通知gate进行更新的
代码在:backend\src\getGateClusterClient.ts:


    var autoSyncGameInfoHD = setInterval(() => {
        if(!client.isConnected){
            clearInterval(autoSyncGameInfoHD);
            return;
        }
        client.sendMsg("SyncServerInfo", {
            serverInfo: {
                serverId: serverCfg.gameServer.serverId,
                serverName: serverCfg.gameServer.serverName,
                serverWSUrl: serverCfg.gameServer.serverWSUrl,
                clientCount: gameServer.connections.length,
                extendData: serverCfg.gameServer.extendData,
            }
        });
    }, 1000);

你可以断点到这里看看代码有没走到这

请问断点是这样子断点吗 ?
好像没有进入函数

你要先把之前开的服务先关掉, 端口占用啦

之前开的命令窗口关闭了, 但是没有看到断点进入
image

这个里面可以看到数据, 但是刷新没显示有人数

之前那个二维码这个可以进去了:
http://www.laikouhai.com:7701/

之前的关了之后, 要重新进入调试哦
刚服务器我发布了一个版本, 修复了观众模式的一个错误, 优化了一下显示, 让观众也可以看其他人玩

是的, 我是关闭后, 重启的服务