怎样快速做成联网对战游戏
做游戏的都知道,近几年来游戏市场简直就是在飞速发展,小至三消解谜大到竞技策略等各类游戏层出不穷,畅销榜付费榜等不同榜单也是一天一个样儿,乱哄哄你方唱罢我登场,看着别人的游戏赚的盆满笨满,再反观自己的游戏却受限于技术水平实现不了玩家更多的需求登不上大雅之堂,纵有一身才华也只能仰天长叹徒呼奈何!不过没关系,幸好你点进了这篇文章,我们用自己多年的游戏开发经验来手把手教你做游戏的联网对战功能!!莫问原因,同是天涯沦落人,四海之内皆兄弟,你的困难就是我的困难,想当年没有被技术门槛惨虐过的游戏开发就不是一个好开发~ 好了言归正传,接下来我们就教你怎么实现游戏的联网功能,在这之前,你需要准备一款需要联网的游戏Demo,然后就是见证奇迹的时刻了! 普通的手机游戏做联网对战就只要简单的几种模式,一种是"1 vs 1"的双人竞技模式,这种模式典型的就是《拳皇》这一类的游戏,一种是"n VS n"的多人同时在线游戏模式,这种模式就是《贪吃蛇大作战》此类的游戏,再多的还有就是两人合作过关的模式,诸如《魂斗罗》这样的游戏,其他的各种模式基本都是基于上述几种模式演化出来的,就不一一赘述了,那我们就从这几种主要的模式来介绍一个如何添加联网功能。
"1 VS 1" 手游
实现原理
"1 VS 1" 联网对战模式实现原理基本如下图所示:
① 网关:玩家进入游戏,网关按照IP或其他的逻辑对玩家进行判定,符合逻辑判定的玩家进入游戏,不符合的玩家被拒绝进入游戏;
② 分区:由于实时对战游戏对延迟高敏感, 为尽量降低网络延迟, 通常把地理位置接近的国家地区分配在一个分区,在服务器上可以为应用选择一组分区,客户端连接时, 通常由服务器根据客户端IP地址自动进行分区,分区之后按照不同的匹配机制进行联网匹配;
③ 连接:根据匹配结果,衡量物理距离等因素在子服务器列表中选择最优路线进行连接;
④对战:玩家在最优子服务器中连接成功,实现联网对战功能。、
基本定义
应用协议版本: 实时对战游戏的对战协议可能变更,并且各个版本可能兼容或不兼容,服务器可以设置每个应用的最小接入版本, 确保线上应用版本兼容。 通过config (“version”, “xxx”)
设定应用协议版本号, 0-255,缺省为 0
客户端ID: 可以是邮件地址, OpenID等形式, 或者是 16字符16进制字符串 通过config (“client-id”, “xxx”)
设定客户端ID,通过getClientId ()
可以获得客户端ID ,如果没有指定, 初始化时随机生成客户端ID
等级: 可以是邮件地址, OpenID等形式, 或者是 16字符16进制字符串 通过config (“client-id”, “xxx”)
设定客户端ID,通过getClientId ()
可以获得客户端ID ,如果没有指定, 初始化时随机生成客户端ID
房间: 房间名称为字符串;房间名称可以重复使用, 完成配对后注销;房间名称在connect2
时指定
分区: 由于实时对战游戏对延迟高敏感, 为尽量降低网络延迟, 玩家将被分配到一个延迟相对较低的区服务器上 每个应用可以在后台选择启用/禁用某个分区
区服务器选择: 网关根据客户端IP 地址分区 客户端自动延迟测试
自动分区: 当玩家注册进入游戏时, 服务器首先根据客户端IP地址识别所在国家地区作为优先选择, 并根据服务器上该游戏配置的服务器列表对不同的服务器逐个进行测试延迟,将玩家分配在延迟最低的服务器上。 Ps,自动分区可能导致邀请者与被邀请的玩家不在同个分区
客户端指定分区: 通过config (“server-id”,serverId)
可以指定玩家的分区服务器ID, 忽略服务器自动分区 玩家进入连接等待时, 可以通过getServerId ()
获得玩家所在分区服务器ID Ps,客户端指定分区可以将邀请者和被邀请者配置在相同的分区上 例如: A邀请B进入房间R1时, 为确保A,B在同个分区 A可以进入连接等待, 并获得serverId发送给B,B 在连接前通过config (“server-id”, serverId)
设定所在的分区, 跳过服务器自动分配分区。
匹配机制
外网随机匹配(根据时间和等级进行随机匹配)
玩家在进入游戏后进行随机匹配,主要根据玩家的等级按照进入游戏的时间顺序进行匹配,游戏开发者可自行设置匹配时间限制和等级宽容度,等级宽容度最多可设置5个,匹配时每秒进行一个宽容度的匹配,该秒内匹配不到玩家则下秒在下个等级宽容度内进行匹配,超过最后一个宽容度还没有匹配成功, 一直用最后宽容度匹配, 直到完成或者超时失败。
比如在设置匹配时间为10s,等级宽容度为“±5、±10、±15、±20”的情况下,A玩家等级是3,B玩家等级是7,C玩家等级是12,玩家进入匹配的时间几乎一致。
则A玩家在匹配时优先匹配“3±5”的等级范围内的玩家,匹配到玩家B。
如果匹配时间段内没有满足“3±5”等级的玩家,则扩大范围至“3±10”等级内的玩家进行匹配,匹配到玩家C。
若玩家B、玩家C都不满足匹配条件则按照设置的等级宽容度依次进行顺延,在10s之内最大等级宽容度下仍然匹配不到在线的玩家,判定为匹配超时,需重新进行匹配。
外网好友邀请匹配(根据好友邀请进行匹配)
游戏玩家有时喜欢跟自己熟悉的朋友进行匹配对战,这种情况下可以根据外网好友邀请匹配机制进行准确匹配。
比如玩家A想要邀请玩家B和自己匹配进行游戏,则玩家A可创建一个房间,然后将创建的房间号发送给玩家B,玩家B启动游戏输入玩家A创建的房间号便可进入房间跟玩家A完成匹配进行游戏,同时此房间也可在下次匹配时使用。若玩家B在进入房间之前玩家A已离开所创建的房间或玩家B在超出规定时间(时间可由开发者定义)进入房间,则A所创建的房间失效关闭,服务器重新创建房间并注册玩家B,等待下一次匹配。
另外,在某些情况下还有可能希望实现以下功能:
玩家A 邀请玩家B, 玩家B 收到邀请后加入匹配时A已经离开匹配,这时立即匹配失败(而不是 B 去创建一个房间并等待),这种情况下房间名称前加上“ ” 修饰符, 服务器收到房间匹配时, 发现第一个字符是 “ ”, 如果房间不存在或者没有有效玩家正在等待, 立即匹配失败。
局域网随机匹配(根据局域网进行匹配)
若玩家想要在局域网条件下进行匹配对战,便可根据局域网匹配机制进行联机匹配。
比如玩家A想要在跟在相同局域网之内的玩家进行匹配,玩家B和玩家C正好也想在相同局域网内进行匹配,则玩家A发起匹配时服务器根据玩家B和玩家C进入匹配的时间顺序进行连接匹配。
以上便是"1 VS 1"联网对战下三种可供匹配的不同机制,玩家可根据自己需求进行匹配对战。
使用教程
为了节省时间,我们在Unity找一个官方的单机游戏Space Shooter,用天梯实时对战工具将它改造成双人对战模式,接下来看看我们是怎么在最快的速度做到联网的吧。
先放一张完成后的对战界面
那具体是怎么实现的呢?我们接着看。
1. 涉及同步数据
- 玩家飞机位置同步
- 掉落障碍物(石头,攻击性敌机)同步
- 分值同步
- 对战结束胜负同步
- 存档回放(新功能)
2. 基本UI修改
(先介绍一种数据同步 “玩家飞机位置同步“ 简要代码和逻辑吧,其它数据同步都是类似的,主要就是看游戏本身的数据同步点。大家有什么问题也可以留言,一起探讨,握手)
主要就是:
1,复制一架新的战机,分别命名为 Player-0, Player-1。这样命名也是为了跟 NanoLink 实时对战服务返回的玩家索引 getClientIndex 一致,方便控制当前玩家的战机。
2,添加 UI 对象,也就是 UI -> Canvas。
添加 4个 Button 入口,“等级匹配”, “局域网匹配”, “房间号匹配”。这也是 NanoLink 实时对战服务 支持的 3中匹配连接方式。
3. 玩家飞机位置同步
鼠标点击 Hierarchy 窗口的 Player-0 对象,可以看到 Space Shooter 战机控制脚本为 Done_PlayerController,下面重点看下这个脚本。( 简要代码 )
1,2个玩家匹配成功后,射击。
2,玩家控制战机的位置,发送数据到 NanoLink 协议
Done_PlayerController.cs
void Update () { // if (Input.GetButton("Fire1") && Time.time > nextFire) // 连接上后自动开火 if (MyClient.isConnected() && Time.time > nextFire) { nextFire = Time.time fireRate; // Instantiate(shot, shotSpawn.position, shotSpawn.rotation); GameObject obj = GameObject.Instantiate(shot, shotSpawn.position, shotSpawn.rotation) as GameObject; obj.name = "Bolt-Player-" playerIndex; // 当前飞机的id GetComponent().Play (); } // XXX 原 FixedUpdate if(playerIndex != NanoClient.getInt ("client-index")) { return; } bool bMoved = false; // moveHorizontal = Input.GetAxis ("Horizontal"); // moveVertical = Input.GetAxis ("Vertical"); #region KeyboardEvents float deltaLR = 0; float deltaUD = 0; // 键盘操作,用于电脑上调试 if (Input.GetKey (KeyCode.LeftArrow)) { deltaLR = -DELTA; } else if (Input.GetKey (KeyCode.DownArrow)) { deltaUD = -DELTA; } else if (Input.GetKey (KeyCode.RightArrow)) { deltaLR = DELTA; } else if (Input.GetKey (KeyCode.UpArrow)) { deltaUD = DELTA; } if(deltaLR != 0 || deltaUD != 0) { targetPosition = transform.position new Vector3 (deltaLR, 0, deltaUD); bMoved = true; } #endregion #region TouchEvents if(Input.touchCount == 1) { Touch touch =Input.touches[0]; if(touch.phase == TouchPhase.Moved) { Vector3 touchPosition = Camera.main.ScreenToWorldPoint (new Vector3 (touch.position.x, touch.position.y, 0)); float diffTime = Time.realtimeSinceStartup - lastMove; float diffX = Mathf.Abs(touchPosition.x - targetPosition.x); float diffZ = Mathf.Abs(touchPosition.z - targetPosition.z); // 频率控制 if(((diffX >= 0.25f || diffZ >= 0.25f) && diffTime >= 0.05f) || ((diffX > 0.1f || diffZ > 0.1) && diffTime > 0.1f)) { targetPosition = new Vector3 (touchPosition.x, transform.position.y, touchPosition.z); bMoved = true; } // moveHorizontal = Input.GetAxis("Mouse X"); // moveVertical = Input.GetAxis("Mouse Y"); } } #endregion if (bMoved) { lastMove = Time.realtimeSinceStartup; fireEvent (); } } void FixedUpdate () { transform.position = Vector3.LerpUnclamped (transform.position, targetPosition, Time.deltaTime * speed); // 10 } public void fireEvent() { Hashtable values = new Hashtable (); values.Add ("name", "move"); // 位置 position values.Add ("x", targetPosition.x); values.Add ("z", targetPosition.z); MyClient.send (GameSerialize.toBytes(values)); }
4. 新版Nanolink SDK 调整优化
然后,开发者只需要在代码中实现 已封装好的虚拟函数 (onMessage, onStatusChanged, onConnected, onDisconnected, onEvent )即可。
看了下新版本 SDK, 理论上只需要 实现 onMessage, onEvent两个函数即可,比上一个版本方便多了。
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; using Nanolink; // 游戏联网服务 public class MyClient : NanoClient { protected override void onMessage(byte[] data, byte fromIndex) { Hashtable values = GameSerialize.fromBytes (data); // 事件处理 onEvent (values, fromIndex); } protected override void onStatusChanged(string newStatus, string oldStatus) { Debug.Log ("状态发生改变, newStatus:" newStatus "; oldStatus:" oldStatus); } protected override void onConnected() { Debug.Log ("连接成功, playerIndex:" getInt("client-index") "; serverId:" getString("server-id")); // 连接上后,开火 } protected override void onDisconnected(int error) { if (error == 0) { if (disconnectedBySelf) Debug.Log ("主动断开"); else Debug.Log ("对方断开"); } else { // 错误代码具体参考 "Nanolink SDK 接口说明" 中 lastError 定义 if (error == 501) { if (getInt ("last-time", -2) < 2000) Debug.Log ("超时断开, 可能是对方原因"); else Debug.Log ("超时断开, 可能是己方原因"); } } // XXX 断开连接后自动保存 存档,用于 “回放上局” if(getInt("mode") != 0) { string archivesFilePath = ""; string archivesFileName = "archives_file.dat"; #if UNITY_EDITOR archivesFilePath = Application.dataPath; #else archivesFilePath = Application.persistentDataPath; #endif save (archivesFilePath "/" archivesFileName); } // 断开连接 重新reload 当前关卡 SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); } protected override void onResync(byte fromIndex) { Debug.Log ("同步数据"); GameObject gameObj = GameObject.Find ("Player-" getInt ("client-index")); if(gameObj != null) { Done_PlayerController player = gameObj.GetComponent(); player.fireEvent (); } // 连接(或者 重新连接)后,发送随机函数数据 // 主机发送 if(fromIndex == 0) { Hashtable values = new Hashtable (); values.Add ("name", "seed"); values.Add ("value", NanoRandom.Seed); send (GameSerialize.toBytes(values)); } } void onEvent(Hashtable values, byte playerIndex) { string name = (string)values["name"]; switch(name) { case "move": { // int playerIndex = (NanoClient.getClientIndex () 1) % 2; GameObject gameObj = GameObject.Find ("Player-" playerIndex); if (gameObj != null) { Done_PlayerController player = gameObj.GetComponent (); player.onEvent (values); } } break; case "die": { GameObject gameObj = GameObject.Find ("Player-" values["player"]); if (gameObj != null) { Done_PlayerController player = gameObj.GetComponent (); player.onEvent (values); } } break; case "score": { GameObject gameControllerObj = GameObject.FindGameObjectWithTag ("GameController"); if (gameControllerObj == null) return; Done_GameController gameController = gameControllerObj.GetComponent (); if (gameController == null) return; if(getInt("mode") == 0) { // 当前数据玩家索引 == clientIndex,为当前玩家的数据 if((int)playerIndex == getInt("client-index")) { gameController.AddScore ((int)values["score"]); } else { gameController.AddScore2 ((int)values["score"]); } } else { gameController.AddScore2 ((int)values["score"]); } } break; case "seed": // 处理接收到的 同步随机数种子命令 // 主要是 客机响应 if(NanoClient.getInt ("client-index") == 1) { NanoRandom.Seed = (long)values["value"]; } break; case "hazard": { GameObject gameControllerObj = GameObject.FindGameObjectWithTag ("GameController"); if (gameControllerObj == null) return; Done_GameController gameController = gameControllerObj.GetComponent (); if (gameController == null) return; gameController.onEvent (values); } break; default: Debug.Log ("无效事件"); break; } }
5. 新功能:存档回放
新版本SDK 支持存档回放功能,很赞,联机对战存档文件每分钟才几K,才几K,对才几K 。。。
这样游戏实现存档回放简直太容易啦。下面分享的 Space Shooter 源码中也实现了 回放的功能。
最后,展示几张实际联机对战截图,把实际发送的基本数据和实际的延迟展示在左上角,可以留意下。我这边显示延迟有时会在10毫秒左右,很夸张。
另外:
决定研究 NanoLink 的另一个重要的原因是 NanoLink 实时对战服务 有完整的数据统计后台,可以观察游戏的 当前在线人数,匹配连接人次,流量,具体某个地区数据,而且支持全球区服匹配,地区数据支持更详细的次均时长,延迟分布,流量分布等等 一系列的数据指标。
下面提供一个联机版本的Space Shooter 源码。 直接下载 .unitypackage 即可查看。
(注意:有开发者跟我说下载.unitypackage,运行不能联机,这是因为之前分享的包,我去掉了个人的appKey, 应要求重新分享一个新版本,直接运行就可以联机啦。)
(另外:编译到设备时,需要在 Unity 编辑器中配置 “File” - “Build Settings” - “Player Settings” - “Other Settings” - “Internet Access” 改为 Require;)
Unity 工程包:
链接: https://pan.baidu.com/s/1eRZ4LGm 密码: irwg
安卓.apk包下载(两台安卓设备直接对战即可):
链接: https://pan.baidu.com/s/1bpeuEjP 密码: syq6