2020游戏开发入门-02(概述+客户端框架封装)

更好的阅读体验


title: 2020游戏开发入门-02(概述+客户端框架封装)
date: 2020-05-31 00:11:24
tags:
- 游戏开发
- Unity3D
categories: 游戏开发

目录

项目概述

Unity3D + C# +Python 2.7 。服务端框架都是自己写的。啥第三方库都没有。资源文件太大。客户端项目里面是Assest/script文件夹下面的代码。完整项目在里面有个云盘链接。

在windows下直接打开客户端。如果有python环境(我测试的时候是py 2.7。理论上3也可以只是我没全面测试)也可以跑起来服务端。然后就可以登入进去玩了。

玩法大概就是登入后在一个匹配房间。点匹配会在服务端的匹配列表里面。人够了就一起丢到一个场景。按吃鸡的规则最后一个活下来的胜利。

客户端概述

题外话:

因为没框架用,所以要自己封装。但是我又不知道Unity3D客户端要封装什么。其实刚开始有一种感觉,按照文档说的在Start(), Update() 里面加代码,感觉可以了,又觉得差了点什么。

然后我在全网找 Unity3D框架 的视频和文章。感觉一半在讲UI。。。。emmmmm

当时看了一个视频 UNITE -Unity项目架构设计与开发管理 前辈大概讲了下Unity开发项目结构的演进。

然后下面是之前我自己写的总结:

游戏引擎提供的最初始的开发方式是,开发者可以在游戏中的任意物体上挂载脚本。我们把物体的加载,到运行中不停的渲染,到物体的消亡的过程。称之为物体的生命周期。我们可以在每帧调用的Update方法中,编写我们的物体行为,最终构成一个完整的游戏世界。

在Unity3D中所有物体被称为GameObject。大多数的引擎都会将GameObject在运行时的引用存储在内存中。用户可以通过引擎所提供的反射机制获得它。在Unity3D中,开发者可以通过引擎提供的Game Object的Find方法根据物体名称或者标记找到它。看似极其方便的功能却有着两个比较明显的缺点。

一个是引擎的反射系统很慢。其本质是在运行时对对象用哈希表对其引用做了存储。每次使用的时候都会在这个表查一次。比较的慢。因此通常即使用到它也会在游戏的初始化过程中使用。在游戏的运行时避免多次查找。

另一个缺点其实是更致命的。是不利于代码的维护性。以Unity3D为例。引擎希望的开发模式是,开发者重写一个继承自MonoBehaviour 类的子类,重写其生命周期函数。在渲染的各个阶段被调用。一个比较直接的想法是把所有的逻辑都写在脚本的更新方法中。于是乎我们可以想象到一个场景,游戏物体多的时候,他们之间的关系就会变得像蜘蛛网一样。当我们后期需要修改一个业务的时候就有可能造成比较严重的连锁反应。不符合我们软件开发中的业务低耦合原则。

其中一个解决思路是在一个空物体上挂在脚本。由这个空物体统筹全局。其他的物体也从这个物体衍生。类似一个主函数的概念。但是逐渐的出于对业务解耦,模块划分的考虑,空物体越来越多,越来越乱。而且物体在跨场景的是否销毁也有可能成为可能的逻辑错误。

另一个思路是与单例模式结合。系统首次调用时单例类被构造,而后不随着场景的切换而销毁。通过该模式设计大量管理类。如场景管理类,美术资源管理类,事件订阅发布管理类等。

然后我在客户端项目中建了一个 Engine 文件夹。开发封装。

客户端框架封装

单例模式和空物体

在C#中使用单例模式,我先写一个泛型类。

    public class Singleton<T> where T : new()
    {
        private static T instance;
        
        public static T Instance
        {
            get
            {
                if (instance == null)
                {
                    instance = new T();
                }
                return instance;
            }
        }
    }

这样比如我们需要另一个成为单例类。只需要继承这个基类即可

public class NetworkMgr : Singleton<NetworkMgr>
{
    。。。
}

然后我们就可以在任意需要的地方。调用方法。

NetworkMgr.Instance.Function()

另一个方面是空物体。EngineHandler是我放在初始场景的空物体。加载后设置过场景不删除。详情看客户端代码的EngineHandler.cs

UI模块的封装

参考资料:

《Unity3D网络游戏实战(第一版) 》《Unity3D网络游戏实战(第2版)》罗培羽。里面有提到PanelBase, PanelMgr的概念

Unity的程序基础框架(针对有一定Unity基础的学习者) 这个视频我当时学的时候还是免费的。。。

前端Vue框架以及Mvvm相关博客。当时主要是想封装个Mvvm的数据更新模式。

使用的是Unity3D自带的UI。原生的操作方式基本就两种。一种是在可视化面板那边选回调函数。按钮添加点击事件回调。另一种是自己想办法获取对象引用。然后 xxx按钮.Onclick.AddEventListener(()=>{ ... })

显然第一种不好维护。代码多了,在一堆组件里面找函数在哪。噩梦。。。。

  • PanelBase

所以PanelBase的作用。我让每一个面板(登入面板,组成面板)都挂在一个脚本。这个脚本父类是PanelBase。PanelBase父类是 MonoBehavier。

	public class PanelBase : MonoBehaviour
    {
        private Dictionary<string, List<UIBehaviour>> controlDict = 
            new Dictionary<string, List<UIBehaviour>>();

        void Awake()
        {
            FindChildrenControl<Button>();
            FindChildrenControl<Image>();
            FindChildrenControl<Text>();
            FindChildrenControl<Toggle>();
            FindChildrenControl<Slider>();
            FindChildrenControl<ScrollRect>();
            FindChildrenControl<InputField>();
            FindChildrenControl<Dropdown>();
        }

        public T GetControl<T>(string name) where T : UIBehaviour
        {
            if (controlDict.ContainsKey(name))
            {
                for (int i = 0; i < controlDict[name].Count; i++)
                {
                    if (controlDict[name][i] is T)
                    {
                        return controlDict[name][i] as T;
                    }
                }
            }
            return null;
        }

        private void FindChildrenControl<T>() where T : UIBehaviour
        {
            T[] controls = GetComponentsInChildren<T>();
            for (int i = 0; i < controls.Length; i++)
            {
                string name = controls[i].gameObject.name;
                if (controlDict.ContainsKey(name))
                {
                    controlDict[name].Add(controls[i]);
                }
                else
                {
                    controlDict.Add(name, new List<UIBehaviour>() { controls[i] });
                }
            }
        }

所有UI组件(按钮,输入框,图片等等)都是UIBehaviour的子类。在Awake()的时候。扫描下所有的组件。按组件类型归类。

然后通过组件的名字获取他的引用。(这样也就意味着同一面板下,你想要获得的组件,命名需要是唯一的)

InputField username = GetControl<InputField>("username");
  • UIMgr

我们需要一个UI管理类。控制面板的跳转。显示和隐藏。

public class UIMgr : Singleton<UIMgr> { ... }

我们让UIMgr称为一个单例。这样我们就可以在任何地方

显示 UIMgr.Instance.ShowPanel()

隐藏 UIMgr.Instance.HidePanel()

这样我们在做面板的时候。

需要先弄一个Panel,并且挂个脚本,脚本父类是PanelBase。控制Rect Transform 布满整个面板。如下图。

然后再在用布局图片balabala。。。的弄好面板UI。最后弄成预设体。

UI1

弄好后,比如我们在菜单里面点了登录。我们就可以很方便的

UIMgr.Instance.HidePanel(菜单面板名字);	// 隐藏菜单面板
UIMgr.Instance.ShowPanel<LoginPanel>(面板预设体路径,需要加载的面板名字); // 加载登入面板

还能顺便在PanelBase写虚函数。加载时要调用什么,隐藏要做什么。加载的过程其实就是加载预设体。然后挂到Canvas下面了。

另外我封装一个不好的地方:

Unity3D物体加载顺序是按照在资源面板你摆放的上下顺序决定的。所以面板加载先后顺序会导致他们互相阻挡。

所以可以在Canvas下面建几个空物体。分为不同层级。面板挂载到不同层级上。

最开始我做的时候不知道这一点,觉得没什么用。后面怕改一点改一片。然后没弄。

  • MvvmMgr

最开始我想接近的一个问题是数据和面板显示的更新问题。类似MVC, MVP, MVVM 那种。反正就是数据,显示解耦嘛。

Mvvm是只 model viewmodel view。意思大概是数据和显示绑定。一方被改变另一方会跟着变。

最开始我其实是想把前端一个Vue.js框架的那个Mvvm搞过了。翻了几个Vue源码剖析的文章。然后魔改成了我下面的样子。

我先搞了一个类叫UIType。Int,Int32 啥都被用了,我也很绝望呀...

用起来感觉大概是这样的:

在面板类里面用MvvmMgr注册一个回调函数。

resultTitle = GetControl<Text>("title_result");
MvvmMgr.Instance.RegisterCallback(UICacheKeys.GAME_RESULT, (result) =>
{
    try
    {
    	resultTitle.text = result.ToString();
    }
    catch (Exception) { }
});

然后要用的时候:

1,先注册一个UIType

UIType<string> resultTitle = new UIType<string>(UICacheKeys.GAME_RESULT, "");

2,然后对UIType的val属性的修改会调用相同key的面板回调函数。这里的UICacheKeys.GAME_RESULT是一个字符串作为key。

resultTitle.val = "xxx";

直接改数据,面板的显示会自动更新。

  • 原理

首先是UIType。里面的val是一个泛型。重点在get, set方法。

    public class UIType<T>
    {
        public UIType(string ID, T value)
        {
            this.ID = ID;
            _value = value;
            MvvmMgr.Instance.Set(ID, _value);
        }

        private string ID;

        private T _value;
        public T val
        {
            get
            {
                T oldVal = _value;
                try
                {
                    object _tmpval = MvvmMgr.Instance.Get(ID);
                    _value = (T)_tmpval;
                }
                catch (Exception)
                {
                    _value = oldVal;
                }
                return _value;
            }
            set
            {
                _value = value;
                MvvmMgr.Instance.Set(ID, _value);
            }
        }
    }

每一个UIType有一个ID和值。MvvmMgr里面维护了 string=>objectstring=>UnityAction<object>两个字典。这里MvvmMgr是一个单例类。这里UnityAction<object>是c#里面Unity3D预定义的委托

public delegate void UnityAction();
// MvvmMgr 属性
private Dictionary<string, object> valueDict = new ...
private Dictionary<string, UnityAction<object>> callbackDict = new ...

在set的时候会把 UIType的数据往 MvvmMgr里面写。get的时候会从MvvmMgr里面同步数据。

然后在set的时候如果UnityAction<object>的委托不为空,就会顺便调用下。

MvvmMgr.Instance.RegisterCallback(key, 回调函数)

RegisterCallback就是拿来注册和UI更新相关的回调函数的。

配置管理

我项目里面的 ConfMgr.cs

要实现的一个目标是json呀或者其他方式的配置文件。能加载到内存里面。需要注意下加载路径的区别。

比如项目开发时和打包后的Application.dataPath所代表的路径。

我拿来配置了下武器后坐力啥的。剩下的就是Json格式的解析,文件读写之类的东西了。

{
  "name": "ak47",
  "localScale": {
    "x": 0.42,
    "y": 0.42,
    "z": 0.42
  },
  "localPosition": {
    "x": -0.49,
    "y": 0.408,
    "z": 0.026
  },
  "localRotation": {
    "x": 62.088,
    "y": 10.377,
    "z": 115.539
  },
  "leftHandIKPositon": {
    "x": 0.972,
    "y": -0.249,
    "z": 0.301
  },
  "rightHandIKPositon": {
    "x": -0.3,
    "y": -0.32,
    "z": -0.11
  },
  "BulletCountFirst": 30,
  "BulletCountSecond": 150,
  "BulletCountFirstFull": 30,
  "skills": [
    {
      "name": "shoot",
      "cd": 150
    },
    {
      "name": "shoot_brush",
      "cd": 1000
    },
    {
      "name": "reload",
      "cd": 3000
    }
  ],
  "recoilUp": 5.0,
  "recoilLeft": -0.5,
  "recoilRight": 0.5,
  "horizontalRevert": 0.0,
  "verticalRevert": 0.3,
  "hurt1": 30
}

事件管理

在我项目里面 EventMgr.cs

事件发布订阅模型。做了一个单例的字典。string => UnityAction<object> 然后开几个set, get, call 的函数接口

目的是业务解耦。游戏的类的嵌套关系可能很复杂。

比如用下面这一行,为某个事件添加一个回调函数。

EventMgr.Instance.AddEventListener(key, 回调函数)

在某个很遥远的地方,你想调用那个方法。使用下面的调用。

EventMgr.Instance.EventTrigger(key, 参数)

参数是回调函数的参数。因为委托是UnityAction<object>所以回调函数的参数必须是object。调用时参数只能给一个,接受它的也是object。然后只能自己解析。

    public class EventMgr : Singleton<EventMgr>
    {
        private Dictionary<string, UnityAction<object>> events = 
        	new Dictionary<string, UnityAction<object>>();

        public void AddEventListener(string name, UnityAction<object> action)
        {
            if (events.ContainsKey(name))
            {
                events[name] += action;
            }
            else
            {
                events.Add(name, action);
            }
        }

        public void DelEventListener(string name, UnityAction<object> action)
        {
            if (events.ContainsKey(name))
            {
                events[name] -= action;
            }
        }

        public void EventTrigger(string name, object info)
        {
            if (events.ContainsKey(name))
            {
                events[name](info);
            }
        }

        public void Clear()
        {
            events.Clear();
        }
    }

客户端缓存管理

做一个客户端的单例子 key-val存储

其实就是做一个单例的字典。

public class MemeryCacheMgr : Singleton<MemeryCacheMgr>
{
	private Dictionary<string, object> dict = new Dictionary<string, object>();
    
    /**
    封装一些set get方法。
    */
}

Mono管理

一直不知道Mono的中文到底是啥东西。只认为是 MonoBehavier。

首先我们有一个空物体,并且挂载脚本,如下

    public class EventHook : MonoBehaviour
    {
        private event UnityAction updateEvent;
        private event UnityAction fixedUpdateEvent;
    	
        void Update()
        {
            updateEvent?.Invoke();
        }

        void FixedUpdate()
        {
            fixedUpdateEvent?.Invoke();
        }
    }

那么我们如果吧任意函数,丢给上面的updateEvent,fixedUpdateEvent。那么他们也会安装MonoBehaviour的Update频率被调用。让任意函数有了逐帧更新的能力。

然后我们让MonoMgr在初始化的时候获取改空物体。然后获得他身上的脚本Component。

	public class MonoMgr : Singleton<MonoMgr>
    {
        public EventHook hookobj;
    
        public MonoMgr()
        {
            hookobj = GameObject.Find(EngineMacro.ENGINE_HOOK).GetComponent<EventHook>();
        }
        // ...
	}

通过单例MonoMgr。往那个空物体上的event挂在事件,这样容易函数都可以被逐帧更新

        public void AddUpdateEvent(UnityAction fun)
        {
            hookobj.AddUpdateEvent(fun);
        }

        public void DelUpdateEvent(UnityAction fun)
        {
            hookobj.DelUpdateEvent(fun);
        }

同时我们也可以在容易地方开启Unity的协程。本来我们只能在MonoBehaviour的子类中调用。

        public void StopCoroutine(IEnumerator routine)
        {
            hookobj.StopCoroutine(routine);
        }

        public void StopCoroutine(Coroutine routine)
        {
            hookobj.StopCoroutine(routine);
        }

在进一步的封装后。我们通过对协程的进一步封装搞出了延时事件。

包括延时若干毫秒调用某个函数。或者连续调用某个函数若干次。间隔若干毫秒

		private IEnumerator DelayEvent(float delayTimes, UnityAction callback)
        {
            float beginTime = Time.time * 1000;
            while (beginTime + delayTimes > Time.time * 1000)
            {
                yield return null;
            }
            callback();
        }

        private IEnumerator DelayEventMultiTimes(float delayTimes, 
                                                 int times, UnityAction callback)
        {
            float beginTime = Time.time * 1000;
            while (true)
            {
                while (beginTime + delayTimes > Time.time * 1000)
                {
                    yield return null;
                }
                callback();
                times--;
                beginTime = Time.time * 1000;
                if (times == 0)
                {
                    break;
                }
            }
        }

        public void StartDelayEvent(float delayTimes, UnityAction callback)
        {
            hookobj.StartCoroutine(DelayEvent(delayTimes, callback));
        }
		
        public void StartDelayEventMultiTimes(float delayTimes, 
                                              int times, UnityAction callback)
        {
            hookobj.StartCoroutine(DelayEventMultiTimes(delayTimes, times, callback));
        }
    }

资源管理

ResourceMgr.cs

原生的资源加载大概是

Resources.Load<>();

只是一个普通的同步加载资源的方式。

为了通用性。在ResourceMgr封装一个同步加载的接口出来

    public class ResourceMgr : Singleton<ResourceMgr>
    {
        public T Load<T>(string name) where T : UnityEngine.Object
        {
            T resource = Resources.Load<T>(name);
            if (resource is GameObject)
            {
                return GameObject.Instantiate(resource);
            }
            return resource;
        }
        
        //...
    }

其实最主要的是能够通过上面的MonoMgr随时随地开启协程。封装一个异步加载的接口

加载成功后还能调用回调函数。

        public void LoadAsync<T>(string name, UnityAction<T> callback) 
        	where T : UnityEngine.Object
        {
            MonoMgr.Instance.StartCoroutine(LoadAsyncCoroutine(name, callback));
        }

        private IEnumerator LoadAsyncCoroutine<T>(string name, UnityAction<T> callback) 
        	where T : UnityEngine.Object
        {
            ResourceRequest r = Resources.LoadAsync<T>(name);
            yield return r;

            if (r.asset is GameObject)
            {
                callback(UnityEngine.Object.Instantiate(r.asset) as T);
            }
            else
            {
                callback(r.asset as T);
            }
        }

ps: Resource.Load 默认路径是从 Assest/Resource开始的。比如我的声音文件放在``Assest/Resource/sound/文件名`。那我调用的时候参数路径就会从 sound开始。

音乐管理

MusicMgr.cs

使用ResourceMgr加载一个AudioClip。然底层是Resource的加载。加载成功后挂载到一个空物体上,作为Component。播放完要删除。详情见代码。

        public void PlaySound(string name, int times = 1, 
                              UnityAction<Sound> callback = null)
        {
            ResourceMgr.Instance.LoadAsync<AudioClip>("sound/" + name, (clip) =>
            {
                AudioSource source = musicFlodder.AddComponent<AudioSource>();
                source.clip = clip;
                source.volume = 1.0f; // todo 可设置音量

                Sound sound = new Sound(source, times);
                soundList.Add(sound);
                sound.source.Play();
                sound.times--;
                callback?.Invoke(sound);
            });
        }

缓存池

很多需要使用多次的物体。如果每一次都重新加载。会很耗费事件。可能还好导致C#的GC全局暂停。如果我们只是禁用SetActive(false); 在需要使用的时候再激活。会好很多。

大概就是一个 string => List<Gameobject>的字典。加载的时候查一下之前有没有暂时不用的物体。如果没有才调用ResourceMgr去加载。物体删除也不是真的删了。而是先吧引用存起来。物体的active设置false。挂在到某个空物体下面(主要是归类用)。

详情见 ObjectPoolMgr.cs

场景管理

代码具体见 ScenceMgr.cs

主要是提供场景的异步加载。Unity教程:制作场景加载进度条(异步场景加载)AsyncOperation 最早好像是看这个视频来着。

原生场景加载

ScenceMgr.Instance.LoadScence(ScenePath.LOADING);

是同步的。场景可能很大。这样就有可能卡住。

解决方法是先跳到一个加载界面。在协程中加载场景。协程的进度条会影响到加载界面的进度条。

在加载完成后。跳转到下一个游戏场景。

        private IEnumerator LoadSceneAsynUseLoadingBarCoroutine(string name)
        {
            AsyncOperation asyncOperation = SceneManager.LoadSceneAsync(name);
            asyncOperation.allowSceneActivation = false;

            LoadPanel panel = GameObject.Find("loading").GetComponent<LoadPanel>();
            Image prograss = panel.GetControl<Image>("progress");
            Text word = panel.GetControl<Text>("Text");

            while (!asyncOperation.isDone)
            {
                word.text = string.Format("{0:F}%", asyncOperation.progress * 100);
                prograss.fillAmount = asyncOperation.progress;
                if (asyncOperation.progress >= 0.9f)
                {
                    word.text = "按下“空格键”继续";
                    prograss.fillAmount = 1.0f;
                    if (Input.GetKeyDown(KeyCode.Space))
                    {
                        asyncOperation.allowSceneActivation = true;
                    }
                }
                yield return null;
            }
        }

核心代码。这个函数是在协程中调用的。中间的progress是进度条。word是加载进度的文字显示。

客户端网络模块

客户端网络模块主要参考《Unity3D网络游戏实战(第一版) 》《Unity3D网络游戏实战(第2版)》罗培羽。里面的网络模块的封装。然后协议格式换成了我自己的。

详情在下一章将服务端的时候在写。

总结

目前将完了客户端部分框架层的封装。除了网络模块。网络模块打算放服务端一起写

猜你喜欢

转载自www.cnblogs.com/Q1143316492/p/13179682.html