做游戏的起因
当时我看到掘金举办了一个游戏竞赛活动,我看了之后发现马上就要结束了,但我觉得游戏是我非常感兴趣的领域,我觉得我可以参加一下,抱着试试看的想法参加了。 游戏体验地址
构思一下游戏做成啥样
对战的话,球类不错,那就做个踢球的游戏吧,那么构思一下,游戏的轮廓:
首先大概是这样,两个人物,一个球。
但是这样的门没啥意思,只要人物挡住门了,那就很难踢进去了,不行门得提高一点,小一点,比如这样
然后中间放个分数。
然后,开始抢球,左边的人物率先跑到球前,这时右边的急忙想要防守住这个球。
就在这时,左边的人物领空抽射,右边的人物向前猛扑,很遗憾没能防住,摔了一跤,球进了。
嗯嗯,可以可以,有画面感了,那就是着手实现,先思考一下具体实现需要用什么。
技术层面的考量
用什么开发游戏
开发游戏肯定要用到游戏引擎,那么cocoscreater可以,好用,文档全
开发游戏就用CocosCreater
实现联网
首先是联网游戏,得能通信,http显然后不合适,那就用websocket,那么就用socket.io 这个库简单方便
联网就用Socket.io
游戏需要服务
游戏作为前端需要个后端服务进行数据的逻辑处理和同步,那么用啥写呢?我也就会写Nodejs
了,那就用Express
写一个游戏服务吧。
使用Express
开发
有服务了就得有个服务器吧
之前买过腾讯的服 务器,配置一般,但跑个游戏服务应该可以了,至于服务器的运维配置就直接用宝塔,简单好用。
腾讯服务器+宝塔运维
画面效果的考量
游戏需要几个场景
游戏是和欢快,那么用卡通风比较合适,那么就需要有一整套UI设计啊,那么就要想想游戏应该都有哪些场景,除了游戏对战之外,得有个大厅,不然上来就对战不好,而且光有对战也不行,那么得再想一个单机类型的游戏,那么就做个子弹游戏吧,我挺喜欢玩的,整好做一个,所以得出结论:
场景主要有:
- 大厅
- 对战游戏场景
- 单机游戏场景
游戏得好看的UI画面啊
一个游戏玩法之外,非常关键的就是画面,二者互为表里,一起组成了一款好玩的游戏,那么画面不能马虎,我之前认识的一个老哥,以前约过UI,他用蓝湖给我做好了。
UI设计
角色需要好好设计一下,而且必须得有动画
角色需要设计,不能就是一张静态图在跑吧,所以在设计出角色之外,还有有一定的动画,比如蹦跑,跳跃啊,踢球啊什么的,那么用什么来做动画呢,我知道的骨骼动画比较省图片,做起来成本小,那么骨骼动画制作软件出名的是Spine收费的,搞不起搞不起,但国内有一个非常牛批的公司做的一款免费的,叫龙骨。
(题外话:最近听说这个传奇的公司退场了,叹息,respect,我的第一款引擎就是白鹭引擎,那个时候就用Ts开发了,不可谓不先锋,我觉得退场并不是结束,只是换了一种方式存在,老兵不死,只是凋零,历史上一定会给予应有的位置。)
那么设计了几个角色和对应的成套动画
小龙人:
摇滚哥:
小傻狗:
游戏是球类,所以必须得有物理效果啊
首先这个一个球类游戏,球是可以转的,有弹性的,能踢的,符合加速度计算的,越仿真越好的,那么据我所知实现这样的效果,是需要物理引擎的,那么物理引擎比较有名的应该就是box2d了,性能比较好,使用比较简单。
物理引擎使用box2d
那就开始做起来
先用socket.io把网络通信这块搞起来
装载socket.io
上网下载socket.io文件,就是这个
然后放在项目下,然后在CocosCreater
开发工具上进行设置成为全局组件,类似前端在index.html
中cdn引入文件,这样就可以全局使用socket.io
了
基于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~~~怎么理解呢?你可以类比理解:
- 场景:类比咱们前端的路由。
- 层级:就是父子节点的层级结构。
那么根据业务划分成:
- 大厅场景:主要负责一些游戏的信息的展示,和一些页面的跳转工作,业务主要关注在辅助和设置这块,由于单机游戏比较简单,没必要单独作为场景,直接作为一个节点通过切换显隐即可。
- 对战场景:主要通过物理引擎构建出整个球场,业务主要关注在对战这一块。
那么对应的就是两个场景文件:
开发大厅场景
场景的业务分析
大厅就要有一个该有的样子
- 对战游戏入口
- 单机游戏入口
- 角色选择入口
- 球累选择入口
- 其他各种虚头巴脑的页面:成就页面,匹配页面,玩法介绍页面等等
- 单机游戏子弹球页面
那么场景的脚本文件就可以写成: 那么就为创建场景创建对应的脚本文件:
并挂载在这个场景上:
然后声明一下变量,为了跟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,
}
...
})
然后在编辑器中进行绑定对应节点:
这样就初步完成了大厅场景基本工作,那么接下来开发交互。 首先在脚本中创建一个回调函数,类似这种:
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("该按钮未配置函数");
}
}
然后在开发工具上进行绑定
对战游戏的回调就得特殊处理,因为是对战游戏,需要请求后台进行匹配,成功之后再通知,所以,回调函数仅仅发送仅仅调用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 */,
});
}
...
开发对战游戏场景
场景的业务分析
然后这个场景老规矩,我再看看应该有啥:
- 两个角色
- 两个球门
- 一个球
- 一个分数板子
- 整个球场空间
- 为触摸提供的操作按钮
- 其他一些装饰物,云啊,树啊,管子啊,箱体啊等等。
那么脚本中“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: [],
},
}
...
既然是物理世界,每个节点都要绑定对应物理引擎的刚体,这样就可以在物理引擎的世界中投射一个物体并具有物理特性。
这样就可以了,就可以实现碰撞检测和物理特性了。 比如我们要把整个球场用物理引擎打造出来,记住应该是个封闭的盒子,不然开大脚,球就飞了,比如这样:
然后~~~,然后我就被一个特别难的问题卡住了,这个问题当时一度让我觉得是我无解的,同时这个可是对战游戏的最核心功能的就是帧同步,这个必须要单独提出来好好讲讲。
如何实现帧同步
首先什么是帧同步,我不是专业的,没法给一个特别权威的解释,但是我可以一个大白话来解释:
就是两台设备通过网络可以实现同步的画面,不会因为彼此的操作,让画面不一致。
而这个可难到我了,当时做的时候我设想了,角色的移动和操作都可以通过Node服务进行同步,无非就是:
- 位置信息:用来同步角色的。
- 操作信息而已:用来同步角色操作的。
我大意了,没有闪
当时我觉得球也一样,但做着做着,我发先我还是年轻啊,这二者完全的就不一样,角色可以如此简单的同步一些信息就够了,但是球不行啊,球可是一个物理单位,它具有很多信息需要同步,比如:
- 球的旋转速度:踢球,球是会转的,不然就是一张图片,踢图片么???显然不行啊
- 球是有速度的和受重力加速度的:九年义务教育里面都学过的,会根据摩擦不断损失动能,直到停下来。
- 球可是有弹性的:比如球碰撞任何物体都会有反弹效果,这样才逼真,同时会有很动感的效果。
根本就找不到两片完全相同的叶子
首先同步球,本质上就是在同步两个物理世界的运转信息,让两个用物理引擎搭建起来物理世界有一样的运转状态,这显然是不现实的不可能的,就像根本无法找到两片完全相同的叶子,物理世界也一样,不同的设备上会有一定的误差,这个误差,就是来自网络的延迟,那么这个球你就同步不了了。
福祸相依,否定你前行的理由,也许就是你揭开问题的答案
既然同步两个物理世界不可能,那就用一个物理世界喽,既不用你的,也不用我的,我们都被同一个物理世界支配,那么这个物理世界最应该存在的问题,就是Node服务上!
由于篇幅的问题,使用Node服务实现物理世界的同步,进行实现多台设备的帧同步的具体方案留在下篇,单独提出来好好的讲讲。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。