Unity3D实现状态同步联机小游戏
0. 介绍0.1 工程源码
https://github.com/Raytto/UnityOnlineGameDemo/
0.2 Demo说明
Demo 主要模拟一个 SLG 游戏的以联盟为单位的战场体验。
模拟时,一个玩家控制一个联盟。
战场机制主要为:
[*]以联盟为单位进行
[*]地图中有很多塔
[*]每个联盟初始在地图边沿有一个出生塔
[*]从出生塔出发,每次只能占领已占领的塔的相邻的塔。
[*]越靠近中心的塔,被占领后单位时间产出越高
[*]一个联盟占领的塔如果被切断和出生塔的连接,将在倒计时结束后失去
[*]一个联盟占领的塔越多,占领下一个塔的时间会越长
一些 Demo 中需要涉及的功能要素:
[*]能联机实时操作
[*]能随意调整地图大小
[*]能随意调整塔数量或密度
[*]有迷雾系统
[*]能模拟士兵逻辑:派遣、战斗、驻扎、自动返回等
[*]能模拟战场记分体系
[*]能通过连线、颜色等比较清晰看出目前形势
[*]能设置战场的倍速(模拟不需要总是真实节奏)
[*]Toptip
整个工程见:https://github.com/Raytto/UnityOnlineGameDemo/
-- Assets
-- Materials
-- MySprite.mat //自己定义的材质,用以展示贴图
-- Plugins
-- Google.Protobuf.dll //Google 通信协议
-- Prefab //Demo 中用到的各种 Prefab
-- 略
-- Scenes
-- Player.unity //播放器,可基于实际数据进行实际战斗过程的播放
-- SampleScene.unity // 可联机Demo场景
-- Scripts
-- BattleLogic //战场逻辑,主要服务器用
-- Battlefeild.cs //大部分战场相关逻辑
-- Faction.cs //阵营相关逻辑
-- LightTower.cs //灯塔相关逻辑
-- MovingUnit.cs //行军相关逻辑
-- Outpost.cs //据点相关逻辑
-- Unit.cs //队伍相关逻辑
-- WarTower.cs //战塔相关逻辑
-- BattleUI //展示逻辑,主要客户端用
-- FoggyUI.cs //迷雾展示逻辑
-- 略
-- LogicBodies //一些可能用到的逻辑实体
-- BattleBasicSetting.cs //保存战场的配置信息
-- FactionInfo.cs //保存阵营相关信息
-- MDs.cs //保存一些状态信息
-- MySettings.cs //保存一些设置信息
-- Managers //管理器
-- DemoManager.cs //负责整个Demo的管理
-- ClientManager.cs //负责客户端的管理,由DemoManager调用
-- ServerManager.cs //负责服务器的管理,由DemoManager调用
-- UIManager.cs //负责展示相关的管理,由ClientManager调用
-- Networkers //网络链接相关逻辑
-- TCPClient.cs //客户端网路逻辑
-- TCPServer.cs //服务器的网络逻辑
-- PlayerLogic //播放器的逻辑
-- 略
-- Protocols //通信协议
-- MessageTypes.cs //定义各种通信协议与其序号
-- MessageProrocols //具体的每种协议
-- 略
-- Utils //一些工具类函数
-- CsvReader.cs //实现csv读取
-- NetworkUtils.cs //封装一些通用的网络相关函数
-- TimeUtils.cs //封装时间管理相关的一些函数
-- Shaders
-- MySpriteShader.shader //自己定义的简单Shader,用以渲染贴图
-- Sprites //各种随意制作的简略贴图
-- 略下面记录一些重要功能的实现方式
1. 联机逻辑
1.1 客户端和服务器结构
个人采用 Host 方式。
即一个玩家选择类似“建立房间”的操作,其他玩家通过其 IP 进行加入。
“建立房间”的玩家会同时开启一个服务器线程,和一个客户端线程。其客户端线程通过本机 IP 127.0.0.1 访问服务器线程。以使 Host 的客户端逻辑与非 Host 的逻辑一致。
状态同步的逻辑管理方式:
[*]客户端接收用户操作,并将操作传给服务器。
[*]服务器统一处理所有操作信息,包括处理一些实时按帧自动更新的信息,并将一些需要展示的信息分发给客户端。
[*]客户端收到服务器的消息后,对展示的内容进行更新。
1.2 连接方式
首先开启服务器线程时,监听本机一个特定的端口。
客户端线程开启时,则通过输入的 IP 和端口访问服务器。
网络层协议为省事,采用 TCP。
一旦建立 TCP 连接,首先客户端向服务器发送加入游戏请求(通过协议)。
服务器收到加入请求之后,就可以开始判断请求合法性、以及处理后续游戏内的逻辑了。
客户端连接服务器的部分代码:https://github.com/Raytto/UnityOnlineGameDemo/blob/main/Assets/Scripts/Networkers/TCPClient.cs
关键部分:
private void ConnectToTcpServer()
{
try
{
clientReceiveThread = new Thread(new ThreadStart(StartAConnection));
clientReceiveThread.IsBackground = true;
clientReceiveThread.Name = "DemoClientListener"+clientName;
clientReceiveThread.Start();
}
catch (Exception e)
{
Debug.Log("Client" + clientName + ":On client connect exception " + e);
}
}服务器开启后监听端口的代码:https://github.com/Raytto/UnityOnlineGameDemo/blob/main/Assets/Scripts/Networkers/TCPServer.cs
关键部分:
public void StartServer()
{
tcpServerState = TCPServerState.Creating;
fromClientMessages = new List<FromClientMessage>();
myNetworkClients = new MyNetworkClient;
for (int i = 0; i < myNetworkClients.Length; i++)
{
MyNetworkClient myNetworkClient = new MyNetworkClient();
myNetworkClient.order = i;
//myNetworkClient.tcpListenerThreads = CreateNewListener(i);
myNetworkClients = myNetworkClient;
CreateNewListener(i);
}
tcpServerState = TCPServerState.Created;
}(个人偷懒采用监听4个端口,分别对应一方玩家。通常应采用端口复用,靠协议来区分)
1.3 通信协议
应用层协议是需要自己确定的。
一般用各种类进行封装,如:
[*]客户端发向服务器:
[*]加入游戏请求:包含请求的联盟序号
[*]尝试派兵请求:包含出发地 ID
[*]ping 消息
[*]服务器发向特定客户端:
[*]初始信息:地图尺寸、各个塔的位置、各个塔的积分和占领时间等等、初始迷雾的01矩阵
[*]迷雾更新信息(仅更新有变化的区域):位置和变化之后的状态
[*]Toptip信息:Toptip内容
[*]队伍状态更新:消失\创建\数量更新\行军状态更新….
[*]ping 消息
[*]….
实例中通信用到的各种协议见:https://github.com/Raytto/UnityOnlineGameDemo/tree/main/Assets/Scripts/Protocols/MessageProtocols
每一个通信协议类会包含协议需要的信息。
但类是无法直接传输的,我们需要将其序列化(Serialize)为字节串后传输,业界通常使用 Protobuf (是谷歌开发的一种与平台、语言都无关的高效序列化数据结构的协议)。
不过制作 Demo 可以偷懒,直接使用下面代码就可以完成序列化和反序列化(实质是把实例在内存中的内容原封不动给提出来)
public static byte[] Serialize(object obj)
{
if (obj == null || !obj.GetType().IsSerializable)
return null;
BinaryFormatter formatter = new BinaryFormatter();
using (MemoryStream stream = new MemoryStream())
{
formatter.Serialize(stream, obj);
byte[] data = stream.ToArray();
return data;
}
}
public static T Deserialize<T>(byte[] data) where T : class
{
if (data == null || !typeof(T).IsSerializable)
return null;
BinaryFormatter formatter = new BinaryFormatter();
using (MemoryStream stream = new MemoryStream(data))
{
object obj = formatter.Deserialize(stream);
return obj as T;
}
}见:https://github.com/Raytto/UnityOnlineGameDemo/blob/19646e74aecc40dab24ed34778cfa801bd6782fb/Assets/Scripts/Utils/NetworkUtils.cs#L9
同时为了使一个类的实例能被这样序列化,还需要在协议类的前面打上的标记。
但还有一个问题当收到了一个字节流后,如何分辨这个字节流是哪个协议的?
为此我们还需要在协议以外,套一个通用协议,用通用协议写明协议的类型号和长度,并附带上真实协议序列化后的内容。
每次解析时,首先解析通用协议,得到协议类型号后,根据协议号来确定协议的类,再根据长度以进行反序列化,完成一次信息传输。
2. 游戏逻辑
2.1 服务器逻辑
代码详见:https://github.com/Raytto/UnityOnlineGameDemo/blob/main/Assets/Scripts/BattleLogic/Battlefeild.cs
首先把服务器需要处理的逻辑按功能拆成各个函数:
[*]对客户端的请求的处理逻辑
[*]如请求出兵、请求撤兵、请求加入游戏、请求更变游戏速度等等
[*]自发逻辑
[*]如每帧自动加时间、每帧自动产出积分、自动行军等等
由于每一帧可能接收多个客户端发来的消息,所以需要把接收到的消息放入一个队列。
每一帧处理时可选择将消息队列给清空。也可以为了保证 Host 不卡顿,选择根据条件(比如当前帧运行超过了0.05秒)结束当前帧的处理。
每一帧循环时(Update 中),依次处理各个自然逻辑,再处理消息队列中的消息。
在写业务逻辑代码前最好先梳理清楚各个状态转换关系,比如可以利用流程图帮助自己梳理:
2.1 客户端逻辑
代码详见:https://github.com/Raytto/UnityOnlineGameDemo/blob/main/Assets/Scripts/Managers/UIManager.cs#L91
每帧更新的入口函数:
void Update()
{
CameraUpdate();//镜头相关更新
TipSquareUpdate();//Tip相关更新
ChooseObjUpdate();//选中操作相关处理
RightClickAction();//右键相关处理
UpdateTime();//更新整体时间
if (myFactionOrder != 0)
{
myFactionImage.SetActive(true);
myFactionImage.transform.localPosition = new Vector3(-1.3f, 70f - myFactionOrder * 40f, 0);
}
else
{
myFactionImage.SetActive(false);
}
if (toptipInWaiting.Count > 0)
{
this.TryShowAToptip();
}
}基本就是每一帧更新一下画面,再处理一下当前帧收到的操作信息。
2.1.1 镜头相关更新
个人选择使用 WASD 控制镜头平移,鼠标滚轮控制镜头拉近拉远。
这块比较简单,唯一需要注意的是为了体感舒服,鼠标滚轮和镜头距离的关系需要是指数而非线形。
其他的一些操作,比如左右键点击某个对象、点击某个按钮,直接用 Unity 带的事件机制处理即可,并在出现逻辑相关的有效操作时,封装成消息发给服务器。
2.1.2 游戏逻辑处理
服务器相关的消息处理代码详见:https://github.com/Raytto/UnityOnlineGameDemo/blob/main/Assets/Scripts/Managers/ClientManager.cs#L65
总的而言也比较简单,根据服务器发来的消息,更新场景内各个对象的状态(如存在与否、位置、图片、颜色、显示的数字等等)
对于一些实时的展示内容,则直接逐帧自动处理,比如
[*]精确到微妙的倒计时(等服务器发送则显示可能不够顺滑)
[*]行军位置更新(也是等服务器发送则显示可能不够顺滑,且主要是行军速度不变,没太大必要随时更新状态,可以客户端自己模拟行军的中间过程。仅在 出发\到达\折返 等情况下根据服务器的消息来处理)
[*]…
2.1.3 迷雾效果
个人选择的方式是逐像素绘制一张不透明的图,蒙在整张地图上,一个像素对应一块云的大小。
比如初始化一张全迷雾的图:
代码见:https://github.com/Raytto/UnityOnlineGameDemo/blob/main/Assets/Scripts/BattleUI/FoggyUI.cs
public void InitialFoggy(int sizeX,int sizeY)
{
this.sizeX = sizeX;
this.sizeY = sizeY;
this.transform.position = Vector3.zero;
this.transform.localScale = new Vector3(100,100,0);
texture = new Texture2D(sizeX, sizeY);
Sprite sprite = Sprite.Create(texture, new Rect(0, 0, sizeX, sizeY), Vector2.zero);
GetComponent<SpriteRenderer>().sprite = sprite;
GetComponent<SpriteRenderer>().sortingOrder = 10;
canSeeNum = new int;
for (int y = 0; y < texture.height; y++)
{
for (int x = 0; x < texture.width; x++) //Goes through each pixel
{
canSeeNum = 0;
Color pixelColour;
pixelColour = new Color(0.8f, 0.8f, 0.8f, 1);
texture.SetPixel(x, y, pixelColour);
}
}
texture.Apply();
}Unity 处理单个像素边界采取的方式是渐变,正好符合云所需要的效果。
当然,这种处理仅限于 Demo ,实际造云通常还是需要一块一块贴图,或者用云的 tiles 去拼接。
3. 个人博客 看上去像是服务端逻辑用帧驱动的状态同步? 噢 对,确实不算帧同步,我修改一下,感谢 客气了 一起加油~
[干杯]
页:
[1]