Unity3D实现状态同步联机小游戏

0. 介绍

0.1 工程源码

https://github.com/Raytto/UnityOnlineGameDemo/

0.2 Demo说明

Demo 主要模拟一个 SLG 游戏的以联盟为单位的战场体验。

模拟时,一个玩家控制一个联盟。

战场机制主要为:

  1. 以联盟为单位进行
  2. 地图中有很多塔
  3. 每个联盟初始在地图边沿有一个出生塔
  4. 从出生塔出发,每次只能占领已占领的塔的相邻的塔。
  5. 越靠近中心的塔,被占领后单位时间产出越高
  6. 一个联盟占领的塔如果被切断和出生塔的连接,将在倒计时结束后失去
  7. 一个联盟占领的塔越多,占领下一个塔的时间会越长

一些 Demo 中需要涉及的功能要素:

  1. 能联机实时操作
  2. 能随意调整地图大小
  3. 能随意调整塔数量或密度
  4. 有迷雾系统
  5. 能模拟士兵逻辑:派遣、战斗、驻扎、自动返回等
  6. 能模拟战场记分体系
  7. 能通过连线、颜色等比较清晰看出目前形势
  8. 能设置战场的倍速(模拟不需要总是真实节奏)
  9. 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 的逻辑一致。

Demo 中的逻辑统一由服务器管理,采用以下同步方式:

  • 客户端接收用户操作,并将操作传给服务器。
  • 服务器统一处理所有操作信息,包括处理一些实时按帧自动更新的信息,并将一些需要展示的信息分发给客户端。
  • 客户端收到服务器的消息后,对展示的内容进行更新。

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[campsNum+1];
    for (int i = 0; i < myNetworkClients.Length; i++)
    {
        MyNetworkClient myNetworkClient = new MyNetworkClient();
        myNetworkClient.order = i;
        //myNetworkClient.tcpListenerThreads = CreateNewListener(i);
        myNetworkClients[i] = 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

同时为了使一个类的实例能被这样序列化,还需要在协议类的前面打上[Serializable]的标记。

但还有一个问题当收到了一个字节流后,如何分辨这个字节流是哪个协议的?

为此我们还需要在协议以外,套一个通用协议,用通用协议写明协议的类型号和长度,并附带上真实协议序列化后的内容。

每次解析时,首先解析通用协议,得到协议类型号后,根据协议号来确定协议的类,再根据长度以进行反序列化,完成一次信息传输。

2. 游戏逻辑

2.1 服务器逻辑

代码详见:https://github.com/Raytto/UnityOnlineGameDemo/blob/main/Assets/Scripts/BattleLogic/Battlefeild.cs

首先把服务器需要处理的逻辑按功能拆成各个函数:

  1. 对客户端的请求的处理逻辑
    • 如请求出兵、请求撤兵、请求加入游戏、请求更变游戏速度等等
  2. 自发逻辑
    • 如每帧自动加时间、每帧自动产出积分、自动行军等等

由于每一帧可能接收多个客户端发来的消息,所以需要把接收到的消息放入一个队列。

每一帧处理时可选择将消息队列给清空。也可以为了保证 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[sizeX, sizeY];

    for (int y = 0; y < texture.height; y++)
    {
        for (int x = 0; x < texture.width; x++) //Goes through each pixel
        {
            canSeeNum[x, y] = 0;
            Color pixelColour;
            pixelColour = new Color(0.8f, 0.8f, 0.8f, 1);
            texture.SetPixel(x, y, pixelColour);
        }
    }

    texture.Apply();
}

Unity 处理单个像素边界采取的方式是渐变,正好符合云所需要的效果。

当然,这种处理仅限于 Demo ,实际造云通常还是需要一块一块贴图,或者用云的 tiles 去拼接。

发表评论