kizumi_header_banner_img

Hello! Beautiful Kizumi!

加载中

文章导读

Joker_MMO


avatar
Yuyas 2025年12月18日 12

    • 项目报错解决方案

      • Exception: no aot assembly found. please run HybridCLR/Generate/All or HybridCLR/Generate/AotDlls to generate aot dlls before runing HybridCLR/Generate/MethodBridge

        • CopyStrippedAOTAssemblies

        • 这个问题是因为Unity 的构建缓存(Library/Bee)

        • 删除项目中的Library/Bee强制重新编译

        • HybridCLRData\AssembliesPostIl2CppStrip\StandaloneWindows64无法生成dll

      • 无法正确检测catalog变化从而进行更新

        • Play Mode Script要使用Existing build,否则不会检测远程的catalog

      • 启动闪退

        • 清除Addressables缓存,重新生成

      • 自定义宏在编辑器中不生效

        • 将构建平台切换到PC

        • Regenerate Project Files

      • 网络变量热更问题

      • 对于客户端和服务端NetworkManger的hash值必须相同,否则会misMatch无法正常连接

        • 双端的NetworkVariable必须统一持有

      • AOT泛型问题

        • HybirdCLR有两种解决方案(其实还有一种商业方案,只是相比第二种节约内存)

          • 在AOT程序集中调用一次这个泛型,例如 new List<Type>

          • HybridCLR.RuntimeApi.LoadMetadataForAOTAssembly加载该容器所在程序集

      • 彻底清理Addressables脏数据

        • 清理PersistentDataPath中的addressable

        • 清理ServerData中的addressable

        • 清理Library中的addressable

        • Build -> Clean All

      • Unable to open archive file: “xxxx/xxxx/aa/xxxx” ,Failed to read data for the AssetBundle “aa/ xxxx”

        • 需要将Addressable Group全设置为Remote,包括Default Local Group

     

    1. Netcode快速入门

      • 下载Netcode For GameObject插件

      • 新建空物体,加组件Network Manager

        • 选择Unity Transport

        • DefaultNetworkPrefabs存放需要实例化的预制体

        • Player存放玩家预制体,开始时生成

      • 使用Network相关组件在要同步的物体上

      • C#代码要继承NetworkBehavior

      • 重要属性:IsOwner,IsServer

      • 重要特性:[ServerRpc(RequireOwnership = false)] , [ClientRpc]

      • 重要类(自动同步变量数据):NetworkVariable<float> speed = new NetworkVariable<float>(2);

        // 需要GameManager 开始时Editor作为服务端,exe作为客户端
        public class GameManager : MonoBehaviour
        {

          void Start()
          {
              #if UNITY_EDITOR
                  NetworkManager.Singleton.StartServer();
              #else
                  NetworkManager.Singleton.StartClient();
              #endif
          }
        }


        public class PlayerControl : NetworkBehaviour
        {
          public float speed = 2f;

          void Update()
          {
          // 这里判断isOwner,否则多个窗口会同时执行操作
              if (IsOwner)
              {
                  float x = Input.GetAxis("Horizontal");
                  float y = Input.GetAxis("Vertical");
                  Vector2 dir = new Vector2(x, y).normalized;
                  print(dir);
                  HandlerMovementServerRpc(dir);
              }
          }
        // 加上此特性,表示客户端调用服务端操作
          [ServerRpc(RequireOwnership = false)]
          private void HandlerMovementServerRpc(Vector2 dir)
          {
              transform.Translate(new Vector3(dir.x, 0, dir.y) * speed * Time.deltaTime);
          }
        }
    2. 环境搭建

      • URP参数设置

      • 自定义宏定义相关

      • Editor MenuItem自动打包服务端,客户端exe

        public class BuildMenuItem
        {
           private static string buildPath = "Builds";
           private static string ServerPath = "Server";
           private static string ClientPath = "Client";

          [MenuItem("Project/Build/All")]
           public static void All()
          {
               // 注意先后顺序
               Server();
               Client();
          }

          [MenuItem("Project/Build/Server")]
           public static void Server()
          {
               Debug.Log("开始构建服务端");
               
               // 筛选场景,筛掉客户端相关场景
               List<string> list = new List<string>(EditorSceneManager.sceneCountInBuildSettings);
               for(int i = 0;i < EditorSceneManager.sceneCountInBuildSettings;++ i)
              {
                   string scenePath = SceneUtility.GetScenePathByBuildIndex(i);
                   if(scenePath != null && !scenePath.Contains("Client"))
                  {
                       Debug.Log("正在加载场景:" + scenePath);
                       list.Add(scenePath);
                  }
              }

               BuildPlayerOptions buildPlayerOptions = new BuildPlayerOptions()
              {
                   scenes = list.ToArray(), // 转成string[] scene
                   target = BuildTarget.StandaloneWindows64, // 打包目标平台Win64
                   subtarget = (int)StandaloneBuildSubtarget.Server, // 子目标平台Server
                   locationPathName = new DirectoryInfo(Application.dataPath).Parent.FullName + '/' + buildPath + '/' + ServerPath + "/Server.exe" // exe打包生成路径
              };

               BuildPipeline.BuildPlayer(buildPlayerOptions);

               Debug.Log("完成构建服务端");
          }

          [MenuItem("Project/Build/Client")]
           public static void Client()
          {
               Debug.Log("开始构建客户端");
               List<string> list = new List<string>(EditorSceneManager.sceneCountInBuildSettings);
               for (int i = 0; i < EditorSceneManager.sceneCountInBuildSettings; ++i)
              {
                   string scenePath = SceneUtility.GetScenePathByBuildIndex(i);
                   if (scenePath != null && !scenePath.Contains("Server"))
                  {
                       Debug.Log("正在加载场景:" + scenePath);
                       list.Add(scenePath);
                  }
              }

               BuildPlayerOptions buildPlayerOptions = new BuildPlayerOptions()
              {
                   scenes = list.ToArray(),
                   target = BuildTarget.StandaloneWindows64,
                   subtarget = (int)StandaloneBuildSubtarget.Player,
                   locationPathName = new DirectoryInfo(Application.dataPath).Parent.FullName + '/' + buildPath + '/' + ClientPath + "/Client.exe"
              };

               BuildPipeline.BuildPlayer(buildPlayerOptions);

               Debug.Log("完成构建客户端");
          }
        }
    1. 客户端热更新(Addressable下载资源(资源热更)和热更Dll,HybirdCLR通过Dll进行代码热更)

      • 将HybirdCLR包放入Package后Install

    • 自定义程序集HotUpdate放入Setting进行Hybird编译,获取生成的HotUpdate.dll(需要一个cs脚本与自定义程序集在同一个文件夹)

    • 构建服务端开始需要代码禁用HybridCLR.Editor.SettingsUtil.Enable = false,完成时再开启

    • 解决AOT泛型问题

    • 自定义生成DllBytes的方法(包含AOT和HotUpdate),需要提前在Setting中设置name,若生成的HotUpdate中没有自己想要的dll文件,则手动copy过去并重命名.bytes

      [MenuItem("Project/Build/GenerateDllBytesFiles")]
      public static void GenerateDllBytesFiles()
      {
         Debug.Log("开始创建DllBytes");

         string sourceDllDir = System.Environment.CurrentDirectory + '/' + SettingsUtil.GetHotUpdateDllsOutputDirByTarget(EditorUserBuildSettings.activeBuildTarget);
         string aotDllDir = Application.dataPath + "/Scripts/HotUpdate/DllBytes/AOT";
         string hotUpdateDllDir = Application.dataPath + "/Scripts/HotUpdate/DllBytes/HotUpdate";

         foreach (string dllName in SettingsUtil.AOTAssemblyNames)
        {
             File.Copy(sourceDllDir + '/' + dllName + ".dll", aotDllDir + '/' + dllName + ".dll.bytes", true);
        }

         foreach (string dllName in SettingsUtil.HotUpdateAssemblyNamesExcludePreserved)
        {
             File.Copy(sourceDllDir + '/' + dllName + ".dll", hotUpdateDllDir + '/' + dllName + ".dll.bytes", true);
        }
         AssetDatabase.Refresh();

         Debug.Log("完成创建DllBytes");
      }
    • 自定义ClientUpdate方法,标准的HybirdCLR + Addressables热更新方法

      [MenuItem("Project/Build/UpdateClient")]
      public static void UpdateClient()
      {
         Debug.Log("开始构建客户端更新包");

         CompileDllCommand.CompileDllActiveBuildTarget();
         GenerateDllBytesFiles();
         // Addressable更新包
         // 拿到上一次的更新路径
         string path = ContentUpdateScript.GetContentStateDataPath(false);
         // 拿到Addressable的总配置文件
         AddressableAssetSettings addressableAssetSettings = AddressableAssetSettingsDefaultObject.Settings;
         // 根据配置文件和上一次的路径来进行update
         ContentUpdateScript.BuildContentUpdate(addressableAssetSettings, path);

         Debug.Log("完成构建客户端更新包");
      }
    • IIs模拟资源服务器进行热更新

      • 需要设置Project Setting的All downloads over HTTP为Allow,才能从IIS上访问下载资源

      • 设置好MIME类型,.bundle .bytes .hash

      • 创建Test程序,加载HotUpdate.dll.bytes进行反射热更新

      public class HotUpdateTest : MonoBehaviour
      {
         // Start is called before the first frame update
         void Start()
        {
             Debug.Log("开始");

             TextAsset textAsset = Addressables.LoadAssetAsync<TextAsset>("HotUpdate.dll").WaitForCompletion();
             System.Reflection.Assembly.Load(textAsset.bytes);
             Addressables.InstantiateAsync("GameObject").WaitForCompletion();

             Debug.Log("结束");
        }
    • 创建VersionInfo,进入客户端优先更新显示版本信息

    • 热更新UI界面

       

    1. 核心功能准备工作

      • 创建服务端实体对象,用于后续测试玩家和服务端之间的AOI效果

      • 在客户端显示延迟,服务端对象数量,其他客户端对象数量

    1. 热更新完善

      • 断点传续

        • 客户端热更新的检测是通过Check Catalog的变化来决定是否进行更新的,如果上一次客户端启动只是更新了catalog没有更新实际内容,则下一次启动就不会继续更新,导致游戏出错

        • 使用Load和Save在本地存储一个State类,通过State类的succeed成员来判断是否需要继续进行更新,如果为false或者null(首次加载不存在State)则需要删除catalog,以便进行更新的catalog

    1. 服务端AOI

      • 只加载周围区块(通常是九宫格)的服务端内容,以节省性能

      • 使用容器存储客户端和服务端对象

        • Dictionary<Vector2Int, HashSet<NetworkObject>> chunkServerObjectDic

        • Dictionary<Vector2Int, HashSet<ulong>> chunkClientDic

      • 当离开范围时,item.NetworkHide(clientA)

      • 当进入范围时,item.NetworkShow(clientB)

    1. 客户端空间管理

      • 四叉树空间管理

      • QuadTree存有RootNode,Node再包含四个更小的Node,递归创建

      • ClientMapManager管理四叉树

      • Dictionary<Vector2Int, TarrainController>管理各块Terrain,Controller封装了相关显隐加载方法及倒计时销毁(超出视角范围时先是隐藏,过段时间才会销毁)

      • 流程:

        • 利用得到的视野Planes切片,初始化周围起始区块

          • 初始化区块使用bool CheckVisual(Bounds bounds),是在QuadTreeNode中递归调用检测来决定当前Terrain是否需要Enable(从整个rootNode开始递归检测)

          • bool CheckVisual(Bounds bounds) 不仅要检测相机视野内,为了保证游戏体验还要扩大检测包围盒的范围,使加载区域变宽,还要检测是否为相机周围的九宫格

        • 每帧检测是否所有区块是否有需要销毁的,销毁的要从字典移除(移除清单用List缓冲一下)

    1. 第三人称角色控制

      • 客户端,服务端启动逻辑梳理

        • 分为ClientOnGameScene和ServerOnGameScene

        • 加上GameSceneManager,IsClient则实例化ClientOnGameScene,IsServer则实例化ServerOnGameScene

      • 程序集梳理

        • AOT(无需热更的程序集)

        • HotUpdate(热更,存放客户端)

        • Common(热更,存放客户端服务端公共部分)

        • Server(为热更程序集,实际不用热更,不用Addressable,因为需要引用的Common为热更程序集,所以Server也必须要为热更程序集)

      • PlayerContorller分块并处理AOI

        • 使用partial将PlayerContorller类分为公共,客户端,服务端三块

        • 客户端的移动逻辑要ServerRpc请求服务端进行计算

        • AOI逻辑同样在服务端处理

          • 因为AOI在Server程序集,而PlayerController在Common程序集,无法直接使用AOI

          • 要通过AOIUtility使用EventSysten来进行搭桥调用

      • 状态机

        • 角色Idle,Move等状态的表现均在状态机中实现

        • 根运动利用OnAnimationMove配合animator.deltaPosition等来手动实现移动,而不是勾选RootMotion

          • 这里使用Action<Vector3, Quaternion> action

          • 在OnAnimatorMove中调用action?.Invoke(animator.deltaPosition, animator.deltaRotation);

          • 在状态机中设定事件,如在MoveState中设Action作用characterController.Move(deltaVector);

          • 在角色下线时要注意销毁StateMachine,否则还会update导致空引用

      • 网络变量热更问题

        • 有自定义类型网络变量时,用NetworkVariableSerializationBinder,让NetworkVariable识别如何读写复制这个新类型

        • 因为NetworkVariable的变量类型必须在编译期确定,而热更新类型肯定是不能确定的,所以这里的热更新类型变量只能由服务端来处理同步(服务端不需要热更新,打包时会取消HybridCLR),客户端为了防止报错需要重写一下同步函数

          // 这里直接修改源码也可以,这里采用不修改源码的方式
          public class NetVariable<T> : NetworkVariable<T>
          {
          // 这里是构造函数,全部复用父类的就好
            public NetVariable(T value = default,
                    NetworkVariableReadPermission readPerm = DefaultReadPerm,
                    NetworkVariableWritePermission writePerm = DefaultWritePerm) : base(value, readPerm, writePerm) { }

            public override bool IsDirty()
            {
            // 这里客户端肯定是null,不重写是要报错的
                if (NetworkVariableSerialization<T>.AreEqual == null) return false;

                return base.IsDirty();
            }
          }
      • 第三人称角色移动受到相机影响

        • 我们有自己移动的Vector3方向,现在要得到相机的Vector3方向

          • float yEuler = mainCamera.transform.eulerAngles.y; 得到相机Y旋转角度

          • Vector3 newDir = Quaternion.Euler(new Vector3(0, yEuler, 0)) * dir; 使用欧拉角 * 方向得到旋转后的方向


        • 现在需要在角色移动时,讲角色的朝向Smooth移到正确的位置

          • float tanRad = Mathf.Atan2(player.inputData.dir.x, player.inputData.dir.y); 得到目标弧度

          • float tanDeg = Mathf.Rad2Deg * tanRad; 转化成目标角度

          • nowEulerY = Mathf.SmoothDampAngle(nowEulerY, tanDeg, ref currentVelocity, 0.1f); 丝滑变换

          • player.transform.eulerAngles = new Vector3(player.transform.eulerAngles.x, nowEulerY, player.transform.eulerAngles.z); 替换角度



评论(1)

查看评论列表
评论头像
Yuyas 博主 2025年12月18日
test

发表评论

表情 颜文字
插入代码

个人信息

avatar

24
文章
1
评论
1
用户

近期文章