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

0. 介绍

0.1 工程源码

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

0.2 制作动机

设计一个战场玩法时制作的玩法 Demo

作为游戏策划,经常在设计一个较新的玩法时会面临很多困难。

当猜想某种机制比较有趣或者有策略性(需要根据功能的设计目标来确立)时,通常只能通过逻辑推演进行思考。

逻辑推演当然是低成本且必要的手段,但也有很大的局限:

  1. 不一定能完全说服他人:
    • 逻辑或者理性是依赖假设或者公理的,而最初的假设和公理一定非理性,是先验与直觉的,而这些很难讨论正误。
  2. 甚至不能说服自己:
    • 我的逻辑推演考虑了所有重要的因素吗?十分钟的等待,到底是点两下聊两句天就过了,还是长到想退出?这种节奏设计时也比较难确定。

对于这些局限,幸在,我们可以尽可能地用理性压缩非理性的占比,正如数学所干的事情–通过大量的定理,使一些复杂的问题还原至大家很容易共识的一些公理,如皮亚诺公理,或者欧几里得公设。这是个人认为数学能稳定发展的重要依托。

对于游戏设计也可以用类似的思路,即在设计时尽可能丰富细节,完善逻辑链。通过丰富多方位考虑的推理(不像数学能严谨,设计的逻辑更可能是堆情景推理数量),如果能让听众只要认可一些极易共识的结论,比如“我们游戏的玩家,战胜比失败更爽”、“占多数的小R玩家不喜欢充钱”,就能顺着设计者地推理论证整个设计的有效性,则能极大减少冲突观点导致的僵局。

但是,这样做很难,不仅要求设计者自己有极强的情景模拟能力(如果经验和总结多,可以提升一些)。还要求听者也能进入相同的模拟情景,这对听者的情景模拟依旧提出了极大的要求。或者说是对设计者表达能力的极大要求。所以个人认为在一些团队中,如果核心人物的能力足够强,采用独裁的制度,其他人仅做建议者和执行者,反而可能很有效。

除了情景模拟和推理,还有一种更直观的前期设计手段,就是做出 Demo ,粗暴又直观。

Demo 可以是粗陋的桌游形式,也可以是程序形式。

如果能直接用桌游形式固然更好,但桌游形式的局限也大:

  1. 不实时,不好感受真实节奏
  2. 手动计算麻烦,影响实际体验
  3. 桌游不太好模拟迷雾、视野等会影响战略的重要元素

所以有些时候需要诉诸程序型 Demo。这时找一个程序帮忙固然也是可以的,但设计者能自己实现某些情况下也可能更好:

  1. 省去大量沟通成本。
  2. Demo 往往需要大幅且高频修改,对设计而言是提升,对程序而言感觉是否定,体感不好。
  3. 设计者更可能知道哪些地方可能改,做的时候更会留出空间,减少改的成本。

所以总地而言,设计者偶尔自己制作 Demo 还是很有效的设计手段。

甚至,有了 Demo ,之后还可以容易地拓展为一个播放器,可以载入真实玩家的战场数据,看看实际玩家的战斗过程。如上帝视角看战场风云变幻,也是挺有趣的。

0.3 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 去拼接。

发表评论