用CocosCreater开发一个对战游戏,并实现物理引擎的帧同步(一)

2,034 阅读11分钟

做游戏的起因

当时我看到掘金举办了一个游戏竞赛活动,我看了之后发现马上就要结束了,但我觉得游戏是我非常感兴趣的领域,我觉得我可以参加一下,抱着试试看的想法参加了。 游戏体验地址

构思一下游戏做成啥样

对战的话,球类不错,那就做个踢球的游戏吧,那么构思一下,游戏的轮廓:

首先大概是这样,两个人物,一个球。

image.png

但是这样的门没啥意思,只要人物挡住门了,那就很难踢进去了,不行门得提高一点,小一点,比如这样

image.png

然后中间放个分数。

image.png

然后,开始抢球,左边的人物率先跑到球前,这时右边的急忙想要防守住这个球。

image.png

就在这时,左边的人物领空抽射,右边的人物向前猛扑,很遗憾没能防住,摔了一跤,球进了。

image.png

嗯嗯,可以可以,有画面感了,那就是着手实现,先思考一下具体实现需要用什么。

技术层面的考量

用什么开发游戏

开发游戏肯定要用到游戏引擎,那么cocoscreater可以,好用,文档全

开发游戏就用CocosCreater

实现联网

首先是联网游戏,得能通信,http显然后不合适,那就用websocket,那么就用socket.io 这个库简单方便

联网就用Socket.io

游戏需要服务

游戏作为前端需要个后端服务进行数据的逻辑处理和同步,那么用啥写呢?我也就会写Nodejs了,那就用Express写一个游戏服务吧。

使用Express开发

有服务了就得有个服务器吧

之前买过腾讯的服 务器,配置一般,但跑个游戏服务应该可以了,至于服务器的运维配置就直接用宝塔,简单好用。

腾讯服务器+宝塔运维

画面效果的考量

游戏需要几个场景

游戏是和欢快,那么用卡通风比较合适,那么就需要有一整套UI设计啊,那么就要想想游戏应该都有哪些场景,除了游戏对战之外,得有个大厅,不然上来就对战不好,而且光有对战也不行,那么得再想一个单机类型的游戏,那么就做个子弹游戏吧,我挺喜欢玩的,整好做一个,所以得出结论:

场景主要有:

  • 大厅
  • 对战游戏场景
  • 单机游戏场景

游戏得好看的UI画面啊

一个游戏玩法之外,非常关键的就是画面,二者互为表里,一起组成了一款好玩的游戏,那么画面不能马虎,我之前认识的一个老哥,以前约过UI,他用蓝湖给我做好了。

UI设计

image.png

角色需要好好设计一下,而且必须得有动画

角色需要设计,不能就是一张静态图在跑吧,所以在设计出角色之外,还有有一定的动画,比如蹦跑,跳跃啊,踢球啊什么的,那么用什么来做动画呢,我知道的骨骼动画比较省图片,做起来成本小,那么骨骼动画制作软件出名的是Spine收费的,搞不起搞不起,但国内有一个非常牛批的公司做的一款免费的,叫龙骨。

image.png (题外话:最近听说这个传奇的公司退场了,叹息,respect,我的第一款引擎就是白鹭引擎,那个时候就用Ts开发了,不可谓不先锋,我觉得退场并不是结束,只是换了一种方式存在,老兵不死,只是凋零,历史上一定会给予应有的位置。)

那么设计了几个角色和对应的成套动画

小龙人:

ezgif.com-gif-maker (2).gif

摇滚哥

ezgif.com-gif-maker (3).gif

小傻狗

ezgif.com-gif-maker (4).gif

游戏是球类,所以必须得有物理效果啊

首先这个一个球类游戏,球是可以转的,有弹性的,能踢的,符合加速度计算的,越仿真越好的,那么据我所知实现这样的效果,是需要物理引擎的,那么物理引擎比较有名的应该就是box2d了,性能比较好,使用比较简单。

物理引擎使用box2d

那就开始做起来

先用socket.io把网络通信这块搞起来

装载socket.io

上网下载socket.io文件,就是这个

image.png

然后放在项目下,然后在CocosCreater开发工具上进行设置成为全局组件,类似前端在index.html中cdn引入文件,这样就可以全局使用socket.io

image.png

基于socket.io开发游戏端的网络通信模块

创建一个文件并命名gameServer,统一在该模块内部写网络业务,代码如下:

import * as EVENT from './event-names.js'

class Server {
    //构造函数
    constructor() {
        if (DEBUG) {
            window.SOCKET = io.connect('wss://zero2one.moderate.run:8058');
        } else {
            window.SOCKET = io.connect('wss://zero2one.moderate.run:8058');
        }
        SOCKET.on('connection', () => {
            g_Log("connection ok!!!");
            this.sendMsg('connected', { "openId": g_UserData.openId });
        });
        this.addMsgListen();
    }
    registerMsgCallF(callMap) {
        for (let [key, value] of callMap) {
            window.SOCKET.on(key, msg => {
                value(msg);
            });
        }
    }
    addMsgListen() {
        let self = g_GAME;
        //游戏开始
        window.SOCKET.on(EVENT.S_GAME_START, msg => {
            g_Log("gameStart");
            g_UserData.id = msg.playerId;
            g_Log("g_UserData.id" + g_UserData.id)
            G_CHAIRID = g_UserData.id;
            g_Log("g_UserData.id" + g_UserData.id);
            cc.director.preloadScene("battle", function () {
                cc.director.loadScene("battle");
            });
        });
        window.SOCKET.on(EVENT.S_LOGIN_SUCCESS, msg => {
            g_Log("!!!EVENT.S_LOGIN_SUCCESS 登陆回调")
            g_UserData.openId = msg.openId;
            window.g_openId = g_UserData.openId;
            if (!DEBUG) {
                var data = WXSDK.getLaunchOptionsSync()
                g_Log("loginSucess" + JSON.stringify(data.query));
                if (JSON.stringify(data.query) != "{}") {
                    g_Log("接受挑战向服务器发请求," + data.query);
                    this.send_joinRoom({ "type": 1, "openid": g_openId, "matchData": data.query/* data *//* g_UserData.openId */ })
                    g_GAME.m_startView.switchLayerShow(LAYER.matchL);
                }
            }
        });
        window.SOCKET.on(EVENT.S_CREATE_ROOM_OK, msg => {
            g_Log("create_room_ok,切换比赛界面");
            if (!DEBUG) {
                g_GAME.m_startView.switchLayerShow(LAYER.matchL);
                WXSDK.inviteFriend(msg);
            }
        });
    }
    sendMsg(key, data = "0") {
        window.SOCKET.emit(key, data)
    }

    /**
     * 发送登陆
     */
    send_login(data, ...paras) {
        g_Log("发送 login");
        data.msgId = EVENT.C_LOIN
        this.sendMsg('GAME_MSG', data);
    }
    /**
     * 发送创建房间
     * @param {obj-数据包} data
     * @param {array-其余参数} paras
     * @memberof Server
     */
    send_match(data, ...paras) {
        data.msgId = EVENT.C_CREATE_ROOM
        this.sendMsg('GAME_MSG', data);
    }
    /**
     * 发送加入房间
     */
    send_joinRoom(data, ...paras) {
        g_Log("发送 join_room");
        data.msgId = EVENT.C_JOIN_ROOM
        this.sendMsg('GAME_MSG', data);
    }
    /**
     * 发送左移动-开始
     */
    send_leftDown(data, ...paras) {
        g_Log("发送 left_down");
        data.msgId = EVENT.C_LEFT_DOWN
        this.sendMsg('GAME_MSG', data);
    }
    /**
     * 发送左移动-结束
     */
    send_leftUp(data, ...paras) {
        g_Log("发送 left_up");
        data.msgId = EVENT.C_LEFT_UP
        this.sendMsg('GAME_MSG', data);
    }
    /**
     * 发送右移动-开始
     */
    send_rightDown(data, ...paras) {
        g_Log("发送 right_down");
        data.msgId = EVENT.C_RIGHT_DOWN
        this.sendMsg('GAME_MSG', data);
    }
    /**
     * 发送右移动-结束
     */
    send_rightUp(data, ...paras) {
        g_Log("发送 right_up");
        data.msgId = EVENT.C_RIGHT_UP;
        this.sendMsg('GAME_MSG', data);
    }
    /**
     * 发送击球
     */
    send_hit(data, ...paras) {
        g_Log("发送 hit");
        data.msgId = EVENT.C_HIT;
        this.sendMsg('GAME_MSG', data);
    }
    /**
     * 发送跳跃
     */
    send_jump(data, ...paras) {
        g_Log("发送 jump");
        data.msgId = EVENT.C_JUMP;
        this.sendMsg('GAME_MSG', data);
    }
}
module.exports = Server;

简单讲就干了三件事:

  • 使用socket.io创建一个链接

    window.SOCKET = io.connect('wss://zero2one.moderate.run:8058');
    
  • 监听服务端传过来的消息

    //游戏开始,进入对战页面
    window.SOCKET.on(EVENT.S_GAME_START, msg => {
       ...
    });
    // 登陆成功,显示大厅页面
    window.SOCKET.on(EVENT.S_LOGIN_SUCCESS, msg => {
       ...
    });
    // 匹配开始,切换匹配页面
    window.SOCKET.on(EVENT.S_CREATE_ROOM_OK, msg => {
       ...
    });
    
  • 封装发送消息给后台的接口

     /**
      * 发送登陆
      */
     send_login(data, ...paras) {
        ...
     }
     /**
      * 发送创建房间
      * @param {obj-数据包} data
      * @param {array-其余参数} paras
      * @memberof Server
      */
     send_match(data, ...paras) {
        ...
     }
     /**
      * 发送加入房间
      */
     send_joinRoom(data, ...paras) {
        ...
     }
     /**
      * 发送左移动-开始
      */
     send_leftDown(data, ...paras) {
        ...
     }
     /**
      * 发送左移动-结束
      */
     send_leftUp(data, ...paras) {
        ...
     }
     /**
      * 发送右移动-开始
      */
     send_rightDown(data, ...paras) {
        ...
     }
     /**
      * 发送右移动-结束
      */
     send_rightUp(data, ...paras) {
        ...
     }
     /**
      * 发送击球
      */
     send_hit(data, ...paras) {
        ...
     }
     /**
      * 发送跳跃
      */
     send_jump(data, ...paras) {
        ...
     }
    

使用CocosCreater搭建场景

首先划分一下场景和层级,em~~~怎么理解呢?你可以类比理解:

  • 场景:类比咱们前端的路由。
  • 层级:就是父子节点的层级结构。

那么根据业务划分成:

  • 大厅场景:主要负责一些游戏的信息的展示,和一些页面的跳转工作,业务主要关注在辅助和设置这块,由于单机游戏比较简单,没必要单独作为场景,直接作为一个节点通过切换显隐即可。
  • 对战场景:主要通过物理引擎构建出整个球场,业务主要关注在对战这一块。

那么对应的就是两个场景文件:

image.png

开发大厅场景

image.png

场景的业务分析

大厅就要有一个该有的样子

  • 对战游戏入口
  • 单机游戏入口
  • 角色选择入口
  • 球累选择入口
  • 其他各种虚头巴脑的页面:成就页面,匹配页面,玩法介绍页面等等
  • 单机游戏子弹球页面

那么场景的脚本文件就可以写成: 那么就为创建场景创建对应的脚本文件:

image.png

并挂载在这个场景上:

image.png

然后声明一下变量,为了跟UI节点进行绑定,这样就可以在脚本中使用了,类似获取dom,进行操作。

cc.Class({
    ...
    properties: {
        // 大厅页面
        m_lobbyLayer: cc.Node,
        //各个层的预制体
        // 成就页面
        m_achieventLayerPre: cc.Prefab,
        // 角色页面
        m_characterLayerPre: cc.Prefab,
        // 游戏结束页面
        m_endLayerPre: cc.Prefab,
        // 匹配页面
        m_matchLayerPre: cc.Prefab,
        // 球节点
        m_ballPrefab: cc.Prefab,
        // 游戏玩法界面
        m_gameInfoLayeyPrefab: cc.Prefab,
        // 单机游戏-子弹游戏页面
        m_gameBulletLayer: cc.Prefab,
    }
    ...
})

然后在编辑器中进行绑定对应节点:

image.png

这样就初步完成了大厅场景基本工作,那么接下来开发交互。 首先在脚本中创建一个回调函数,类似这种:

onBtnClick(event, data) {
//没有这个界面就创建
    if (!this.m_layerArr[data]) this.addLayer(data);
        //切页面
        this.switchLayerShow(data);
        data in this.m_btnCallFMgr
        ? this.m_btnCallFMgr[data].call(this, data)
        : g_Log("该按钮未配置函数");
    }
}

然后在开发工具上进行绑定

image.png

对战游戏的回调就得特殊处理,因为是对战游戏,需要请求后台进行匹配,成功之后再通知,所以,回调函数仅仅发送仅仅调用gameServer模块中的接口即可。

...
CallF_battle(data) {
    g_Log("玩家对战");
    //回调消息create_room_ok
    g_SERVER.send_match({
      type: 1,
      openid: g_openId
        ? g_openId
        : Date.now() /* data */ /* g_UserData.openId */,
    });
}
...

开发对战游戏场景

image.png

场景的业务分析

然后这个场景老规矩,我再看看应该有啥:

  • 两个角色
  • 两个球门
  • 一个球
  • 一个分数板子
  • 整个球场空间
  • 为触摸提供的操作按钮
  • 其他一些装饰物,云啊,树啊,管子啊,箱体啊等等。

那么脚本中“dom”指针变量就大体知道怎么配置了。(dom是类比,实际上是游戏中的节点)

...
  properties: {
    m_Camera: cc.Node,
    // 球
    m_Ball: cc.Node,
    // 两个玩家
    m_Player: cc.Node,
    m_Player2: cc.Node,
    // 两个球门
    m_Door1: cc.Node,
    m_Door2: cc.Node,
    // 得分
    m_Score1: cc.Label,
    m_Score2: cc.Label,
    // 为触摸提供的操作按钮
    m_BtnLeft: cc.Node,
    m_BtnRight: cc.Node,
    m_FeetSmoke: cc.Prefab,
    // 球场空间包围盒
    m_LandArr: {
      type: cc.Node,
      default: [],
    },
  }
...

既然是物理世界,每个节点都要绑定对应物理引擎的刚体,这样就可以在物理引擎的世界中投射一个物体并具有物理特性。

image.png

这样就可以了,就可以实现碰撞检测和物理特性了。 比如我们要把整个球场用物理引擎打造出来,记住应该是个封闭的盒子,不然开大脚,球就飞了,比如这样:

image.png

然后~~~,然后我就被一个特别难的问题卡住了,这个问题当时一度让我觉得是我无解的,同时这个可是对战游戏的最核心功能的就是帧同步,这个必须要单独提出来好好讲讲。

如何实现帧同步

首先什么是帧同步,我不是专业的,没法给一个特别权威的解释,但是我可以一个大白话来解释:

就是两台设备通过网络可以实现同步的画面,不会因为彼此的操作,让画面不一致。

而这个可难到我了,当时做的时候我设想了,角色的移动和操作都可以通过Node服务进行同步,无非就是:

  • 位置信息:用来同步角色的。
  • 操作信息而已:用来同步角色操作的。

我大意了,没有闪

当时我觉得球也一样,但做着做着,我发先我还是年轻啊,这二者完全的就不一样,角色可以如此简单的同步一些信息就够了,但是球不行啊,球可是一个物理单位,它具有很多信息需要同步,比如:

  • 球的旋转速度:踢球,球是会转的,不然就是一张图片,踢图片么???显然不行啊
  • 球是有速度的和受重力加速度的:九年义务教育里面都学过的,会根据摩擦不断损失动能,直到停下来。
  • 球可是有弹性的:比如球碰撞任何物体都会有反弹效果,这样才逼真,同时会有很动感的效果。

根本就找不到两片完全相同的叶子

首先同步球,本质上就是在同步两个物理世界的运转信息,让两个用物理引擎搭建起来物理世界有一样的运转状态,这显然是不现实的不可能的,就像根本无法找到两片完全相同的叶子,物理世界也一样,不同的设备上会有一定的误差,这个误差,就是来自网络的延迟,那么这个球你就同步不了了。

福祸相依,否定你前行的理由,也许就是你揭开问题的答案

既然同步两个物理世界不可能,那就用一个物理世界喽,既不用你的,也不用我的,我们都被同一个物理世界支配,那么这个物理世界最应该存在的问题,就是Node服务上!

由于篇幅的问题,使用Node服务实现物理世界的同步,进行实现多台设备的帧同步的具体方案留在下篇,单独提出来好好的讲讲。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿