6 基础系统
6.1 UI系统框架设计
接下来就是系统功能的开发,系统功能通常指背包系统、好友系统、工会系统等一系列系统,这些系统是有很多共同点的,相当重要的一部分由UI来支撑的。很多系统的大部分都是进行UI交互来呈现给玩家信息,然后实现这种交互的一个功能,大部分的系统都是交互系统。这里涉及到的就是在之后的开发中想要省时省力,那么前期UI系统的结构设计就需要做的比较到位一点,这个地基是要好好搭建的,当然之前也有地基的搭建,如网络底层、日志、数据访问数据库这些都是地基的一部分,也是架构的一个基础,在一个好的地基之上能够让系统快速成型,而这里的UI就是拔高的一个部分,UI框架能够让整体框架更完善。在有了UI的基础之后,在这个基础之上,再去做不同的系统开发,然后会发现后面的系统好开发了。本节课的重点在于从零开始的一个UI框架。那么如何写出一个适合自己的需要的UI框架?首先要有一个基础的知识点,做UI之前要做一个大概的分类,虽然并不一定全面,因为在开发一款新游戏的时候,前期是不可能想的特别全面的,但是想的不全并不会影响开发,这里将UI分为两类,静态-动态。
| 静态 | ||
|---|---|---|
| 普通窗口 | 对话框 | 消息框 |
| 动态 | ||
|---|---|---|
| Tips | 公告 | 战斗飘字 |
如上,静态UI中有三种,动态UI中也有三种,有可能将来还会有更多的类型,但是这些更多的类型还是会填进这两个类型之中,在这两个框架之下是可以无限扩展的,只要符合对应的类型即可,这就起到了扩展性,一个框架订好了,并且易于扩展,那么就么有必要在前期便将所有的事情都想的特别完善。
静态UI:它弹出来我们要不操作它,它就始终都在这里,关掉它就关掉了,主要指需要手动打开和关闭。
动态UI:可以是当鼠标移动到上面时就显示出来了,移开就消失了,随时显示,随时消失,包括公告也是,但这里的公告指的是全服公告,又或者是战斗时的UI,打到敌人身上不断冒出的小数字,这都是动态的,生存周期很短,持续时间也比较短。

暂时分为这几类,UIMain为主UI,左上角的头像,右上角的小地图,都属于主UI,因为只要进入游戏,他们就一定会显示,不管是那张地图。那么主UI包括什么?住UI包括了上面提到的左上角角色信息,右上角的小地图,以及将来在下面的技能栏,还有左边右边各种各样的小边框,工具栏什么的都可以是主UI。主UI是一个常驻UI,管理的是游戏桌面上的UI,特点是什么:永远存在,只可能隐藏,但不会销毁,除非退出游戏。
UIManager:各个游戏系统,例如商店对话框、NPC对话框、任务对话框。这个有什么特点,只要不去NPC或者商店,那么这些资源就不会加载,即用不到的时候,不会进行加载,用到之后才会进行加载,加载关掉之后就销毁掉了。
UITipsManager:动态UI,这里用管理器来管理动态UI,因为这个逻辑较为复杂,鼠标移动到哪里显示一下,随时有什么特殊事情也会弹出一个,极其动态,逻辑复杂。
UIMessageBox:
还会有更多UI,如新手引导UI也做一个新的管理器,因为新手引导通常在多个UI之间进行交互,不过这里我们暂时不考虑新手引导问题,暂时忽略。
这节课的要点:了解UI的类型、了解UI的设计
6.1.1 小BUG解决
这里发现了之前遗留的一些小问题,如果直接关闭游戏,角色并不会自动退出,而是遗留在原地。

不是点击角色选择而是直接关掉游戏,这个角色并不会移除,而是留在原地。

那么本节就是为了解决这个小BUG,那么这个BUG怎么解决呢?在服务器断开的时候将角色删除掉。从下图看,服务器是知道什么时候断开的,所以只需要在断开的时候将角色删除掉就好了。

搜索一下Disconnected,找到它在哪里

在NetService里面,只要断开连接,这里就会发送断开连接的信息,在这里有Session,既然有,就可以在这里做一些事。

看一看sender有什么,可以看到有Session,这个才是我们的角色。

再看Session里面有什么,有角色(Character)、Entity还有User,那么如果这个类里面也有一个Disconnected的方法,这里调用一次,在方法里面写具体要做的事,就可以了,

这里是一个空方法,修复一下,修复前前预览以下是不是自己需要的。

修复完成后要记得过来检查一下是不是在需要的哪个类中。

现在对这个方法进行修复,现在需要将角色删掉,只要在方法里将角色删除即可,而上方还有我们所需要的所有角色信息了,接下来就很简单了,先判断一下角色是否为空,如果不为空就将角色删除,这里的角色用的是UserService来触发的,角色在进入的时候,用的就是UserService,这里的CharacterLeave还没有写,先转到UserService补全这个方法。
internal void Disconnect()
{
// 判断一下,角色如果不为空,
if (this.Character != null)
{
UserService.Instance.CharacterLeave(this.Character);
}
}
来到User Service,先看一下正常的角色退出是什么,先从manager中将角色删除,在从地图中将角色删除掉,要保证角色成功厉害,这两句代码必须顺利执行,在这里为什么要将这个呢?是为了将这两个方法提取出来,这里又快捷操作。
// 从决策列表中删除角色
CharacterManager.Instance.RemoveCharacter(character.Id);
// 从地图管理器中删除角色
MapManager.Instance[character.Info.mapId].CharacterLeave(character);
直接选取需要提取出来的代码,直接右键,可以看到有一个“快速操作和重构…”,点击,再点击提取方法。


这里先不要动,可以看到NewMethod是选中状态,而且下面有一个小框,里面正式方法的名字,直接输入我们需要的方法就好。


这样方法就改造完成了,但是这里默认生成是私有的,将它改为公有。
public void CharacterLeave(Character character)
{
// 从决策列表中删除角色
CharacterManager.Instance.RemoveCharacter(character.Id);
// 从地图管理器中删除角色
MapManager.Instance[character.Info.mapId].CharacterLeave(character);
}

那么至此,暂时我们要做的事就完成了,在NetSession中增加删除方法,将UserService中的角色删除方法提取出来。在这里(服务器断开)只增加了删除角色的方法,那么如果之后还有别的其他功能,也要在这里追加代码,Disconnect代表的是玩家断开服务器的时候主动要做的事情,现在要解决的就是在服务器断开的时候让角色可以退出去,那么这个代码在哪里进行调用呢?

在原来输出服务器断开日志的时候进行调用。
static void Disconnected(NetConnection<NetSession> sender, SocketAsyncEventArgs e)
{
//Performance.ServerConnect = Interlocked.Decrement(ref Performance.ServerConnect);
sender.Session.Disconnect();
Log.WarningFormat("Client[{0}] Disconnected", e.RemoteEndPoint);
}

完成之后就马上进行试验,反复进入游戏会不会角色残留,进去之后给角色挪个位置,然后直接关掉游戏。

服务端这里可以看到角色离开地图1和服务器断开连接的信息已经发送过来了,逻辑已经成功在跑了。

再次进入游戏,在周围看看有没有“角色”还在里面。


转一圈之后也没有找到,那么至此角色残留的BUG已经解决了。
6.1.2 主UI
既然要开始做主UI先来看看我们的UI做到了什么程度,主城的UI都有了。

野外的UI虽然也有了,但是右上角的小地图不会刷新,名字也没有进行更新 ,这是因为前期只是单纯的将主城小地图做成了单例,还没有进行完善。

为了让它更像主UI,先将它的名字改了,改为UIMain,同时将类也重命名一下。


同时还要记得将里面的类名改一下,不然会引用不到。

改完之后记得检查一下:

那么接下来就要针对主UI以及相关逻辑更新下来,先从小地图开始,在进入野外场景后右上角的小地图并没没有进行更新,小地图没有更新的原因是因为切换场景之后并没有触发资源的重新加载(即初始化函数只执行一次,只有在进入主城的时候)

然后进行初始化加载地图,设置初始位置等,但是以后就不会在执行了,只有这一次。

在这里需要它执行多次,每次切换场景就需要进行一次更新,即让它知道是不是切换地图了,如果地图更换了就更新地图,先给地图初始化改个名,改为UpdateMap

在之前的时候就已经写好了一个MinimapManager,现在来给它增加一些功能,现在有着不同的地图,不同的地图有着不同的小地图的包围盒,需要经常换的东西,都交给Manager来管理,这里用get来获取小地图的包围盒(小地图用它的时候只能get)。

小地图流程是怎么样子的呢:UI层更新,从哪里请求数据,从MinimapManager来请求数据。

而Manager这个数据又从哪里来呢?(谁给它做更新的?)其他系统来给它做更新。这样做是为什么呢?因为Manager是一个单例,而Minimap是个脚本对象,意味着这个Minimap是挂在场景当中的,没有办法随随便便从别的地方引用到它,如果要引用到它,就需要建立复杂的逻辑关联,所以用MinimapManager来做一个中介,让数据能够传递过去。此外还有一个变量,那就是小地图,别人可以不知道小地图是谁,但是MinimapManager必须要知道小地图是谁,那么怎么就能够知道呢?很简单,先新建一个变量,在回到Minimap中,让Minimap来告诉Manager小地图是谁即可。


这样MinimapManager就知道minimap是谁了,那么只要Minimap中有地图,那么MinimapManager就一定知道这个小地图是谁。当MinimapManager知道小地图是谁后,就可以给MinimapManager提供一个更新Minimap的方法。为什么要将minimapBoundingBox传进来,因为这里的更新是要在场景变化的时候进行更新,也就是要更新包围盒,包围盒是绑定在场景当中的,在这里管理器是没有办法直接知道的,没有办法直接从场景中找出来,效率太低了。所以开放一个接口,谁知道,谁告诉我,只管接受就好。

这里要记得将UpdateMap方法公开一下,不然会报错。

管理器,别人告诉他需要更新小地图了,然后管理器在告诉小地图该更新了。
那么小地图得到通知后怎么办?以前的包围盒是绑定的,那么这里还需要更新一下包围盒,包围盒的数据从管理器获取,同时要将角色清空掉。

那是因为在下面的Update中,如果不清空角色的话,playerTransform是不会进行更新的,当然这里也可以删掉,只是需要单独再赋值,也没有大的问题,只是这样性能上会比判断差一点,这样小地图管理器与UI之间的协作关系就做好了。

这里需要改掉,之前的时候小地图不会经常更新(只更新一次),就需要判断一下,但是现在需要更新,如果这里不删除的话,在调试过程中小地图就不会更新。

这里可以发现,UI只调用唯一一个第三方即MinimapManager,也就是说UI只会调用管理器数据,而且是自己的UI调用自己的管理器。

而管理器需要管它自己的组件,也需要从全局的单例里面来获取一些数据。

这里我们开放了一个接口(UpdateMinimap),有应该有谁来调用呢?

那自然是地图发生改变的时候,地图怎么就发生变化了?怎么能够知道地图发生变化呢?这里也有一个简单的方法,每个地图都有一个唯一的脚本那每个地图加载的时候,这个脚本就一定会执行,所以在这里加一个这样的脚本,首先先给加一个Mao的根节点。

并在上面绑一个新脚本,MapController。

进入这个脚本,让它通知MinimapManager该更新小地图了,更新的方法需要包围盒,我们就给一个包围盒,这里只需要让别人告诉脚本是那个地图的包围盒,再由MinimaManager通知小地图UI进行更新即可。

那这里应当有谁来告诉小地图的包围盒是谁呢?那肯定是脚本自己,只需要将包围盒拖入到MapRoot中即可。



这里地图控制器MapController只做了一件事,就是告诉小地图MinimapManager要更新小地图,小地图进来之后要更新什么?先告诉自己包装盒是谁(那个地图的包装盒),另一个是告诉UI要更新了。


逻辑:由MapRoot来告知包围盒是谁,再由MinimapManager来告知小地图UIMinimap应该更新那个地图。
那既然主城地图加控制器了,也就是说所有地图都要加这个控制器,先来到野外场景(Map01),然后发现有自带的MapRoot,但是这张地图还没有包围盒,所以先做个包围盒出来,改为MinimapBoundingBox,并将其拉到合适的大小,然后将包围盒拖入到它应该去的地方。


在继续调整位置和大小不能超过地面,并使其在地面下方,这里并不需要它来到地面上方。

在上一节中将MinUI做成了单例,而MinUI的核心就是单例,只要做成单例就是MinUI的底子,在以后开发别的功能时,就可以在相应节点之下做子UI就好,接下来启动游戏,进行测试,因为MinUI已经是单例了,所以需要在下面找

而左侧的也和UI一一对应,Button是返回按钮,UIAvatar是左上角角色信息,而Minimap是右上角小地图,而在之后开发别的功能,如技能栏,只需要在这个节点之下继续新建然后制作即可。

然后接下来继续前进,看看小地图重构是不是能够达到我们预期的效果,这里可以看到右上角小地图的名字以及背景都改变了,

但这里有个bug,那就是角色的位置和小地图有些不对等,

在场景资源中找到这张地图,并在场景中查看地形,可以发现场景中篝火在左上角,而小地图资源篝火则在左下角,逆时针旋转了90°。


直接去文件夹中修改一下,将其旋转一下,使其和地形相同即可。

回到Unity中,可以看到小地图资源已经和地形相同了。

继续向前走,我们到达了营地,小地图也差不多到达了营地,小地图功能没有问题。

这里在学习的时候我没有遇到这个问题,但还是记录一下,难免以后会出。
当离传送门很远时,传送门的特效会变得畸形,而离近了之后又没事了,这是因为特效导致的。

找到场景中特效的位置(在传送门下),这里的特效是由几个环制作的,就像这样子,而会有这个问题也是因为huan3。

正常情况下:
那为什么是huan3出现了问题呢?因为它使用的贴图是UI类型(这里本人Default是正确的),将其改为Default即可。



另外一点,这张图其实是要做半透明的,但是这张图是RGB,在上面选中灰度比例。

而在改好之后大块块是没有了,还有小块块(本人这里没有问题),只需要将别的特效图也改为Default,并且选为灰度比例即可。


先在unity的Scripts中新建一个UIManager脚本,并进入编辑,开始编写UIManager脚本。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
public class UIManager : Singleton<UIManager>
{
// 定义了一个UI元素
class UIElement
{
public string Resources; // UI资源路径
public bool Cache; // 是否缓存
public GameObject Instance; // 如果要Cache,则存储实例
}
// 用来保存定义的一个UI信息
private Dictionary<Type, UIElement> UIResources = new Dictionary<Type, UIElement>();
public UIManager()
{
// 测试UI
this.UIResources.Add(typeof(UITest), new UIElement() { Resources = "UI/UITest", Cache = true });
}
~UIManager()
{
}
/// <summary>
/// Show UI
/// <summary>
/// 这里用了一个泛型方法,传入一个类型参数T,返回这个UI的组件
public T Show<T>()
{
// 声音注释,没弹出一次就有一个声音,但是这里暂时没有,所以先注释
// SoundManager.Instance.PlaySound("ui_open");
Type type = typeof(T);
// 判断一下是否有这个UI,如果有,拿出来。
if (this.UIResources.ContainsKey(type))
{
UIElement info = this.UIResources[type];
// 判断实例有了没,如果有了,直接激活
if (info.Instance != null)
{
info.Instance.SetActive(true);
}
else
{
// 如果没有实例,加载资源
UnityEngine.Object prefab = Resources.Load(info.Resources);
if (prefab == null)
{
return default(T);
}
// 实例化
info.Instance = (GameObject)GameObject.Instantiate(prefab);
}
// 返回来这个UI的组件
return info.Instance.GetComponent<T>();
}
// 如果没有这个UI,返回默认值
return default(T);
}
public void Close(Type type)
{
// SoundManager.Instance.PlaySound("ui_open");
// 判断要关闭的UI是否存在
if (this.UIResources.ContainsKey(type))
{
UIElement info = this.UIResources[type];
// Cache有没有启用,如果启用了,不用它,藏起来
if (info.Cache)
{
info.Instance.SetActive(false);
}
else
{
// 如果没有启用Cache,直接销毁这个实例
GameObject.Destroy(info.Instance);
info.Instance = null;
}
}
}
}
这里可能会报错,因为UITest脚本还没有编写,无伤大雅,先写一个父类脚本UIWindow,给所有的UI当父类用,那么这个父类做了一些什么事呢?写了一个内置结果类型,写了一个Close方法。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
public abstract class UIWindow : MonoBehaviour
{
//
public delegate void CloseHandler(UIWindow sender, WindowResult result);
// 关闭窗口事件
public event CloseHandler OnClose;
// 用来获取类型,
public virtual System.Type Type { get { return typeof(UIWindow); } }
// 内置结果类型
public enum WindowResult
{
None = 0,
Yes,
No,
}
// 写了一个Close方法。
public void Close(WindowResult result = WindowResult.None)
{
// 对任何一个窗口调用了Close,其实就是调用了UIManager来Close,并且触发一些事件。
UIManager.Instance.Close(this.Type);
//
if (this.OnClose != null)
this.OnClose(this, result);
this.OnClose = null;
}
// 虚函数,当子类里面不重写,那么就会调用这个默认的CloseClick方法。(点击关闭就是关闭)
public virtual void CloseClick()
{
this.Close();
}
public virtual void YesClick()
{
this.Close(WindowResult.Yes);
}
// 这是一个测试
private void OnMouseDown()
{
Debug.Log(this.name + "Clicked");
}
}
那么去Unity中做一个新的UI系统,比如点击某个按钮要弹出商店,接下来就做这个测试UI(UITest),先加入一个新的Image(右键黑色箭头),再弹出页面中找到UI(灰色箭头),然后选中Image(棕色箭头)

将新的Canvas更名为UITest,并将其下方的Image改名为Bg,然后选中我们准备好的背景图,并将其修改到合适的大小。


然后再加入一个Image(更名为TitleBar),并在其下面加入一个按钮和文字。

并将为Button选则合适的图片,调整到合适的大小。


将Text改名为Title,并设置合适大小,将其中内容改为标题栏,调整合适的字体大小。

最后再加入一个确认按钮,同样选择合适的图片,并将其内容改为确认。

给确认按钮绑脚本,先来看看UITest的脚本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UITest : UIWindow
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}
除了一个父类UIWimdow之外,什么脚本都没有写,那它是如何工作的,回到Unity,首先再UITest上绑定了UITest的脚本。

然后给ButtonOK绑上UITest组件(注意这里是组件,而不是脚本!),并为其榜上OnYesClick的方法。

同样的,再关闭按钮绑定OnCloseClick方法。

这样,这些常用的点击这个确认,点击这个关闭,就不需要再写代码了,减轻了代码工作量,也就是说再写一个新系统的时候,UITest里面只关心界面打开了,比如打开的是一个角色信息,在Star函数里面获取角色信息,只需要写逻辑就好了,至于什么时候确定,什么时候取消,不需要每一个界面都去写,这就是框架。

然后将做好的UITest放入Assets/Resources/UI下,并保证每个Prefab与其对应脚本的名字相对应。


如何让脚本能够运行起来?一定要在UIManagaer中写这句代码this.UIResources.Add(typeof(UITest), new UIElement() { Resources = "UI/UITest", Cache = true });先添加到管理器当中(UIResources),管理器才会管理它,才可以使用管理器来使用它,加入的类型(UITest),构建一个新的元素结构(UIElement),将路径填进去(Resources),并告诉他Cache还不是Cache。

测试一下,但是在这之前要先调用一下才行,先去MinCity中
已经做完了UI窗口,现在要进行UITest的调用,在主窗口上加个显示,将“返回角色选择按钮复制一个用来作为测试。

不过这里用的依旧是原来的BackToCharacter函数,那就再UIMain里面再加一个测试方法。

每次做完一个新的UI系统,要怎么让它能够显示出来呢?第一步UIManager,第一步UIManager,第二步Instance,第三步Show,这里Show是一个泛型方法
public void OnClickTest()
{
UIManager.Instance.Show<UITest>();
}
随后将这个函数绑到脚本上,先找到UIMain,然后再找到相应的脚本(OnClickTest)

随后运行游戏进行测试,可以看到测试按钮已经存在了。

点击测试按钮,可以看见正常弹出了窗口,内容也和之前写的相对应。

在点击右上角关闭窗口,就能关掉窗口了。
小BUG修复
但是这里本人在做的时候遇到了BUG,点击窗口没有反应,接下来就是修BUG的时间,首先先顺着这个没有反应找,窗口没有办法关闭,那就是OnCloseClick没有运行,而OnCloseClick又是调用的Close函数。

在运行了Close函数后又会去调用真正做事的UIManager中的Close函数,既然不知道问题所在,索性全部打上断点。

然后一个一个测试运行,看看是哪里出了问题,走了一圈下来后好像也没有发现问题,既没有报错也没有哪里没运行到,经过排查之后发现是上面有一句写错了,就是获取组件类型这里。

按照上面这么写确实是没有问题的,但问题就出在这里,UITest是它的子类,而当UITest调用到OnCloseClick的时候,就会调用这里,而当UIWindow开始获取组件类型时,就只会获取到父类(UIWindow)上,而不是子类UITest上,所以这里正确的写法应该是return this.GetTpye()

然后回到Unity进行测试,没有问题了。

这里使用很简单,回到UIMain,可以发现这里UIManager.Instance.show<UITest>();是带返回值(UITest)的,那么这里就可以进行改造,那这里便意味着在这里可以使用任意的方法。

比如说这里需要更新一下标题栏,回到UITest,要更新标题栏,就要先知道标题栏,新建一个title用来保存Text,用来绑定Text,在它启动的时候能够运行SetTitle方法来改变标题。

回到UIMain,调用该方法,使其更改标题为“这是一个测试UI”

而在父类里面也有很多方法,比如OnCloseClick、OnYesClick等方法,这里调用OnClose方法,用Set Title进行复制,用OnClose获取结果,这样的好处是可以让逻辑更清晰,谁调用,谁来获取反馈。

回到Untiy后先记得给UITest绑上组件,并保存,随后运行程序。

在点击新建的测试UI后,在点击关闭,就会弹出下面的框

同样的,点击确定就是另一个内容的框

那么UITest类里面有逻辑吗?有,只写与业务有关的逻辑。

而对于调用者,则是可以对它进行Set(SetTitle)或者Get都是可以的,然后还能获取它的结果,对结果进行处理(MessageBox)。

当然很多时候是不需要关心结果的,比如关闭商店,玩家关了就管理,而且商店上面只有关闭,那什么时候要对结果关心呢?创建公会的时候,要不要取消,这些时候。就以改名字为例,如果要获取到改了后的名字是什么,就可以通过sender来获得。具体代码可以类似于(send as UITest).name。调用前和调用后,就可以任意访问这个UI组件上的各种值,对UI的使用就几乎没有了限制。

完成了主UI重构
&spm=1001.2101.3001.5002&articleId=150235415&d=1&t=3&u=03ce193024ac40fc90ed95a7b9b38932)
2万+

被折叠的 条评论
为什么被折叠?



