前言



经典HTML5小游戏h5游戏源代码合集 40个h5小游戏源码_h5游戏源码

接触了大概半年时间的Cocos Creator,做过不少同步类的游戏,其中多数的双人对战类游戏大多游戏我都选择了状态同步,这主要是因为个人开发习惯导致,个人也更加熟悉状态同步,另外H5类小游戏大多是轻量级的游戏,所以不比ACG类手游、端游要求高,使用帧同步的小游戏就特别少。

最初在入行小游戏时,觉得H5小游戏的帧同步技术上的实现是非常麻烦的,因为H5小游戏的协议用的是WebSocket,而WebSocket协议是HTTP的升级版,通常来说,HTTP的传输层协议用的是TCP,为了保证数据的可靠性,降速在所难免,所以帧同步基本不会考虑使用TCP做传输层协议。也正因为TCP,所以当初的考虑是觉得WebSocket协议上实现帧同步,这样的同步效果是非常差的。

这样的想法在我脑子里持续了很久,后来想到,两年前很火的 IO 类H5小游戏,协议也是WebSocket,却也实现了同步,并且还是多人游戏,要知道客户端越多,状态同步压力是越大的,这也意味着这些IO类游戏极可能用的就是帧同步。

Top IO Games in October 2019iogames.space

后来查阅了大量资料,自己操作一番,总算是把一个基本的帧同步实现了,运行起来还算OK,暂时没发现有严重延迟或者卡顿问题。这里抛转引玉分享出来。



客户端继续用的是Cocos Creator,服务器用的是开源的 Colyseus。

服务端 Colyseus 源码地址:

 https://github.com/colyseus/colyseusgithub.com

客户端的实现

BUGyyc/LockStepH5github.com

按照帧同步的思路,我们需要将游戏推进的决定权交给服务器,服务器通知客户端去进行下一帧,游戏才会继续。

为此,我们需要暂停游戏

//在当前帧暂停cc.game.pause();1.

直到收到服务器的指令,进行下一帧

//进行下一帧cc.game.step();1.

加速表现丢失的帧



加入游戏房间

通过IP及端口号,创建或加入房间

//加入房间    onJoinRoom() {
this.client = new Colyseus.Client(this.server_url);
this.client.joinOrCreate(this.room_name, {/* options */ }).then(room => {
            console.log("joined successfully", room);
this.room = room;
//成功加入房间            this.joinSuccess();
        }).catch(e => {
            console.log("join error:\n", e);
        });
    },1.2.3.4.5.6.7.8.9.10.

游戏的推进

在接收到服务器消息后,我们需要对消息进行对应的处理。如下,我们将帧数据缓存在 frames,并且按顺序执行,这样保证了有序执行游戏逻辑。

onMessageListener(message) {
switch (message[0]) {
case "f":
this.onReceiveServerFrame(message);
break;
case "fs":
this.onReceiveServerFrame(message);
//把服务器帧同步到本地帧缓存后,读取并执行本地帧缓存                this.nextTick();
break;
default:
                console.warn("未处理的消息:");
                console.warn(message);
break;
        }
    },

//接收到服务器的帧数据后,放入本地缓存帧    onReceiveServerFrame(message) {
this.addFrames(message[1]);
    },

//按顺序保存至本地    addFrames(_frames) {
        _frames.forEach((m) => {
this.frames[m[0]] = m[1];
for (let i = m[0]; i > m[0] - this.serverFrameAcc; i--) {
if (this.frames[i] == undefined) {
this.frames[i] = [];
                }
            }
        });
    },1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.

拿到帧数据后,从 frames 中取缓存好的指令,按顺序执行。

//推进游戏    nextTick() {
this.runTick();
if (this.frames.length - this.frame_index > 100) {
//当缓存帧过多时,一次处理多个帧信息,加速执行            this.frame_inv = 0;
        } else if (this.frames.length - this.frame_index > this.serverFrameAcc) {
this.frame_inv = 0;
        } else {
if (this.readyToControl == false) {
this.readyToControl = true;
this.onReadyToControl();
            }
this.frame_inv = 1000 / (this.serverFrameRate * (this.serverFrameAcc + 1));
        }
//this.frame_inv 直接影响到了游戏的表现快慢,如果游戏卡顿,大量帧未及时执行,        //那么加快表现,直到本地帧追上服务器的帧数是最好的解决方式。        setTimeout(this.nextTick.bind(this), this.frame_inv)
    },1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.

runTick 对应执行玩家的操作广播、以及玩家的创建广播

//执行服务器的指令    runTick() {
let frame = null;
if (this.frames.length > 1) {
//第一帧延时处理,以免在初始的时候丢失第一帧            frame = this.frames[this.frame_index];
        }
if (frame) {
if (frame.length > 0) {
                frame.forEach((cmd) => {
//这里对应两个函数:cmd_input |  cmd_addplayer                    if (typeof this["cmd_" + cmd[1][0]] == "function") {
this["cmd_" + cmd[1][0]](cmd);
                    } else {
                        console.log("服务器处理函数cmd_" + cmd[1][0] + " 不存在");
                    }
                })
            }
this.frame_index++;
            cc.game.step();
        }
    },

//接收服务器的输入    cmd_input(cmd) {
this.players.forEach((p) => {
let PlayerScript = p.getComponent(PlayerController);
if (PlayerScript.sessionId == cmd[0]) {
//根据输入指令,更新玩家坐标                PlayerScript.updateInput(cmd[1][1])
            }
        })
    },

//接收服务器指令,进行创建玩家    cmd_addplayer(cmd) {
//已经存在的玩家不需要创建        let existPlayer = this.players.filter((p) => {
let PlayerScript = p.getComponent(PlayerController);
return (PlayerScript == null) ? false : (PlayerScript.sessionId == cmd[0]);
        });
if (existPlayer.length > 0) {
        } else {
//在本地创建一个对应的玩家            let player = cc.instantiate(this.playerPrefab);
            player.parent = cc.find("Canvas/gameWorld");
            player.position = cc.v2(0, 0);
let PlayerScript = player.getComponent(PlayerController);
            PlayerScript.sessionId = cmd[0];
            PlayerScript.isLocal = cmd[0] == this.room.sessionId;
            PlayerScript.setPlayerLab('玩家:' + cmd[0]);
this.players.push(player);
        }
    },1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.

服务端的实现

客户端同学很有必要了解一下服务端的实现

游戏房间

在index.ts 定义了游戏房间,并且给定了名称,客户端连接时,需要指定房间名称,或者房间ID,这里采取的是名称的方式

//房间名称是 gamegameServer.define('game', GameRoom);1.

服务器处理消息

服务器接收到客户端消息后,进行响应。其中 onGetAllFrames 使得重连的玩家,不至于丢失所有游戏进程,有一个表现过程。

//处理消息    onMessage(client: Client, message: any) {
switch (message[0]) {
case "cmd":
this.onCmd(client, message);
break;
case "fs":
this.onGetAllFrames(client, message);
break;
default:
                console.log("接收到未处理的message:")
                console.log(message)
break;
        }
    }

//当收到用户的输入,存入frame_list    onCmd(client: Client, message: any) {
if (message[0] == "cmd" && message[1][0] == "addplayer") {
        }
this.frame_list_push([client.sessionId, message[1]]);
    }

//获取全部的帧数据    onGetAllFrames(client: Client, message: any) {
let frames = [];
for (let i = 0, len = this.frame_list.length; i < len; i++) {
if (this.frame_list[i] !== undefined) {
                frames.push([i, this.frame_list[i]]);
            }
        }
if (frames.length == 0) {
            frames = [[0, []]];
        }
this.send(client, ["fs", frames])
    }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.

演示流程

客户端源代码

BUGyyc/LockStepH5github.com

服务器源代码

BUGyyc/colyseus-examplesgithub.com

启动服务器

在服务器代码路径下执行命令:

  • 如果首次clone代码,可能需要在clone 后,执行 npm install

  • 执行 npm start 启动服务器,监听2567端口

然后服务器就启动成功了,根据客户端指定的房间名称,玩家就可以加入房间进行游戏




其他

还有需要进一步完善的地方:

  • 需要解决随机种子的问题,保持多个客户端随机数一样。

  • 物理引擎的确定性问题,如:浮点数计算偏差

作者:Delevin