Unity3D的界面设计--组合模式

思考并回答以下问题:

  • 组合模式的官方定义是什么?
  • 组合模式属于什么类型的模式?
  • 两种节点都是继承自同一个操作接口,能够对根节点调用的操作,同样能在叶节点上使用。是什么意思?

本章涵盖:

  • 玩家界面设计
  • 组合模式
    • 组合模式的定义
    • 组合模式的说明
    • 组合模式的实现范例
    • 分了两个子类但是要使用同一个操作接口
  • Unity3D游戏对象的分层式管理功能
    • 游戏对象的分层管理
    • 正确有效地获取UI的游戏对象
    • 游戏用户界面的实现
    • 兵营界面的实现
  • 结论

玩家界面设计

在前面的几个章节中,我们介绍了《P级阵地》的游戏系统设计、角色设计、角色产生流程和各个工厂,从这一个章节开始,我们将说明如何让这些功能及系统与玩家产生互动。

在一般的Unity3D开发实践中,会在Hierarchy窗口中,将界面分门别类地组装起来。图1所示是在编辑模式中,组装战斗场景(Battle Scene)会使用到的界面。

图1 组装界面

在Hierarchy窗口中(如图2所示)可以看到,Canvas下除了一个BackGroundImage的背景图像外,还包含了4个组群,而这4个群组代表在《P级阵地》中是用来与玩家互动的4个主要的“用户界面”。

图2 加入用户界面

CampInfoUI:兵营界面,提供给玩家查看当前兵营信息和单位训练情况,以及提供升级按钮来升级兵营,如图3所示。

图3 兵营界面

SoldierInfoUI:玩家单位信息,提供玩家查看我方某个单位当前的生命力、行动力等等信息,如图4所示。

图4 玩家单位信息

GameStateInfo:游戏状态界面,用来主动向玩家提供当前的游戏状态等信息,包含当前左上角阵地被占领的状态、右上方当前的关卡提示、下面则是当前的精力值(AP)、暂停按钮以及中间的提示信息,如图5所示。

图5 游戏状态界面

GamePauseUI:游戏暂停界面,提供给玩家用来中断游戏的按钮,并提供当前游戏的记录等信息,如图6所示。

图6 游戏暂停界面

这些界面都是直接使用Unity3D的界面工具组装而成,输入完成相关的参数之后就可以使用,并与游戏系统产生互动,如图7所示。

图7 Unity3D的界面工具

这些Unity3D的2D组件的互动对象通常是,已经放置在相同场景下的游戏对象,或者是一个已经继承自MonoBehaviour脚本类内的操作方法。所以,一般会在Inspector窗口中输入要互动的游戏对象GameObject或脚本组件中的方法名称。

但是,《P级阵地》就如同游戏的主循环-Game-Loop中所说的,采用的是“类不继承MonoBehaviour”的开发方式,再加上场景上的角色单位都是随着游戏进行而由系统实时产生的。所以在Unity3D的编辑模式中,根本无法指定要互动的对象。

另外,游戏系统的信息在某些情况下,必须通过2D组件(Text组件)主动显示在屏幕画面上,所以系统还必须事先记录这些组件的引用(object reference),并通过这些组件引用把信息显示给玩家。所以,《P级阵地》中2D界面组件的互动方式,将采用另一种实现方式:

“在游戏运行的状态下,在程序代码中主动获取这些UI组件的GameObject后,针对每个组件希望互动的方式,再指定对应的行为。”

因此,了解UI组件的组装以及如何在运行模式下正确有效地获取2D组件的引用,是实现上必须清楚了解的前提。

UI组件的组装

在设计UI界面时,通常会将整个功能界面,以一个单独文件的形式存储起来,Unity3D也可以使用相同的方式来呈现,就如图8中显示的编排方式:《P级阵地》中使用一个xxxxUI结尾的游戏对象来代表一整个完整的界面,而这个游戏对象也可以转换为Unity3D的Prefab形式,最终以一个资源形式(Asset)存储起来。

图8 UI的组装方式

我们先通过对CampInfoUI的说明,让读者了解《P级阵地》UI的设计实现方式。CampInfoUI代表兵营界面中所有可显示的组件,这些2D组件都归类在CampInfoUI之下,成为其子组件。当然,如果再复杂一点的界面,可能还会有更多“分层”的展现。开发过界面功能的程序设计师会知道,将2D组件按照功能关系按层次摆放,是比较容易了解、设计和修改的。

“分层式管理架构”一般也称为“树状结构”,是经常出现在软件实现和应用中的一种结构。而Unity3D对于游戏对象的管理,也应用了“树状结构”的概念,让游戏对象之间可以被当成子对象或设置为父对象的方式来连接两个对象。就像上面提到的UI组装安排:组件B在组件A之下,所以组件B是组件A的子对象,而组件A是组件B的父对象。

若是能先清楚了解Unity3D游戏对象在“分层管理”上的设计原理,将有助于程序人员在实现时“正确有效”地获取场景上的游戏对象。

而通过GoF对组合模式的说明将更能了解,这个在软件业中最常被使用的“分层式/树状管理架构”,如何在组合模式下被呈现,并且提供一般化的实现解决方案。最后通过这个过程,让我们从中了解到,Unity3D是如何设计他们的游戏对象分层管理功能。

组合模式

在“数据结构与算法”的课程中,“树状结构”是必须要学习的一种数据组织方式。定义好子节点与父节点的关系和顺序,再配合不同的搜索算法,让树状结构成为软件系统中不可或缺的一种设计方式。例如有名的开放源码数据库系统在索引值的建立上使用B-Tree,而Unity3D也将其应用在游戏对象的管理上。

组合模式的定义

GoF对于组合模式(Composite)的定义是:

1
将对象以树状结构组合,用以表现部分-全体的层次关系。组合模式让客户端在操作各个对象或组合对象时是一致的。

分层式/树状架构除了常见于软件应用时,现今生活中的公司组织架构,通常也是以“分层式/树状管理架构”的方式呈现,如图9所示。

图9 基本的公司组织结构图

只要公司的公文内提到“研发部”,那么通常会连同之下的“研发一部”“研发二部”都会被一起包含进来,也就是“部分全体”概念。而后半段的说明“组合模式让客户端在操作各个对象或组合对象时是一致的”则是希望,之后相同的公文发送时,只需要更改“受文者”的对象,无论对象是整个“部门”还是“单一个人”,公文内容在解释时,并不会有太大的差异,都一体通用,这就是“让客户端在操作各个对象或组合对象时是一致的”所要表达的意思。

GoF的组合模式中说明,它使用“树状结构”来组合各个对象,所以实现时包含了“根节点”与“叶节点”的概念。而“根节点”中会包含“叶节点”的对象,所以当根节点被删除时,叶节点也会被一起删除,并且希望对于“根节点”和“叶节点”在操作方法上能够一致。这表示,这两种节点都是继承自同一个操作接口,能够对根节点调用的操作,同样能在叶节点上使用。

组合模式的说明

无论是根节点还是叶节点,都是继承自同一个操作接口,其结构图如图10所示。

图10 采用组合模式的类结构示意图

GoF参与者的说明如下:

  • Component(组件接口)
    • 定义树状结构中,每一个节点可以使用的操作方法。
  • Composite(组合节点)
    • 即根节点的概念;
    • 会包含叶节点的对象;
    • 会实现Component(组件接口)中与子节点操作有关的方法,如Add、Remove、GetChild等。
  • Leaf(叶节点)
    • 不再包含任何子节点的最终节点;
    • 实现Component(组件接口)中基本的行为,对于与子节点操作有关的方法可以不实现,也可提出警告或弹出异常(Exception)。

组合模式的实现范例

先定义树状结构中每一个组件/节点应有的操作接口:

Listing1 组合体内含对象之接口(Composite.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public abstract class IComponent
{
protected string m_Value;
// 一般操作
public abstract void Operation();

// 加入节点
public virtual void Add( IComponent theComponent)
{
Debug.LogWarning("子类没实现");
}

// 删除节点
public virtual void Remove( IComponent theComponent)
{
Debug.LogWarning("子类没实现");
}

// 获取子节点
public virtual IComponent GetChild(int Index)
{
Debug.LogWarning("子类没实现");
return null;
}
}

其中包含Add、Remove和GetChild3个方法,这些都是与“根节点”有关的操作,如果继承的子类包含其他组件/节点时,这3个方法就必须重新实现。由于Composite类因为包含其他子组件/节点,所以实现了上面3个方法:

Lisiting 2 代表组合结构的元节点之行为(Composite.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Composite : IComponent
{
List<IComponent> m_Childs = new List<IComponent>();

public Composite(string Value)
{
m_Value = Value;
}

// 一般操作
public override void Operation()
{
Debug.Log("Composite["+m_Value+"]");
foreach(IComponent theComponent in m_Childs)
theComponent.Operation();
}

// 加入节点
public override void Add( IComponent theComponent)
{
m_Childs.Add ( theComponent );
}

// 删除节点
public override void Remove( IComponent theComponent)
{
m_Childs.Remove( theComponent );
}

// 获取子节点
public override IComponent GetChild(int Index)
{
return m_Childs[Index];
}
}

Composite类使用“List容器”来管理子组件,通过Add、Remove让客户端操作容器内容。而GetChild则返回List容器指定位置上的组件/节点。

Leaf是最终节点的实现,因为不包含其他组件/节点,所以仅实现了Operation一项方法:

Listing3 代表组合结构之终端对象(Composite.cs)

1
2
3
4
5
6
7
8
9
10
11
12
public class Leaf : IComponent
{
public Leaf(string Value)
{
m_Value = Value;
}

public override void Operation()
{
Debug.Log("Leaf["+ m_Value +"]执行Operation()");
}
}

虽然操作上使用相同的接口,但还是分为Composite和Leaf两种类,在初始化对象和操作对象上需要留意:

Listing4 测试组合模式1(CompositeTest.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void UnitTest () 
{
// 根节点
IComponent theRoot = new Composite("Root");
// 加入两个最终节点
theRoot.Add ( new Leaf("Leaf1"));
theRoot.Add ( new Leaf("Leaf2"));

// 子节点1
IComponent theChild1 = new Composite("Child1");
// 加入两个最终节点
theChild1.Add ( new Leaf("Child1.Leaf1"));
theChild1.Add ( new Leaf("Child1.Leaf2"));
theRoot.Add (theChild1);

// 子节点2
// 加入3个最终节点
IComponent theChild2 = new Composite("Child2");
theChild2.Add ( new Leaf("Child2.Leaf1"));
theChild2.Add ( new Leaf("Child2.Leaf2"));
theChild2.Add ( new Leaf("Child2.Leaf3"));
theRoot.Add (theChild2);

// 显示
theRoot.Operation();
}

执行Root节点的Operation后,信息上会出现整个树状结构的组成方式:

执行结果

1
2
3
4
5
6
7
8
9
Leaf[Leaf1]执行Operation()
Leaf[Leaf2]执行Operation()
Composite[Child1]
Leaf[Child1.Leaf1]执行Operation()
Leaf[Child1.Leaf2]执行Operation()
Composite[Child2]
Leaf[Child2.Leaf1]执行Operation()
Leaf[Child2.Leaf2]执行Operation()
Leaf[Child2.Leaf3]执行Operation()

如果不小心错将Leaf对象加入节点,会出现以下信息:

Listing5 测试组合模式2(CompositeTest.cs)

1
2
3
4
5
6
7
8
9
10
11
void UnitTest2 () 
{
// 根节点
IComponent theRoot = new Composite("Root");

// 产生一个最终节点
IComponent theLeaf1 = new Leaf("Leaf1");

// 加入节点
theLeaf1.Add ( new Leaf("Leaf2") ); // 错误
}

执行结果:出现警告信息

1
子类没实现

分了两个子类但是要使用同一个操作接口

因为设计和实现上的需要,所以将其分成Composite和Leaf两个类来应付不同的情况,但同时还要让它们有相同的操作行为,确实有点为难。

例如获取子组件/节点GetChild操作,在最终/叶节点上调用这个方法是没有意义的,其他如Add、Remove也是。至于组合模式(Composite)中要定义最终/叶节点,则是系统设计上必然产生的,因为如果某方法在子类重新实现上有差异的话,就必须定义出不同的子类来显示这个差异。但要维持两个差异很大的子类共享同一个接口,在实现上确实是个挑战。

有专家在书中示范了实现一套“文件管理”工具,同样是运用组合模式来完成该工具的实现。由于设计上的需求,必须设计以下两个类:

  • File类,用来表示最终存放在硬盘的文件;
  • Directory类,即目录类,用来包含其他目录及File类对象。

同样的,这两个类都继承了Node节点类,同时Node类也定义了两个子类共享的操作接口。

专家提到,对于搜索功能而言,文件管理工具如果返回的是Node类,则比较能符合搜索功能的定义,因为我们可能想找的是“目录”,也可能是“文件”,并不限定是哪一种。但是对于有针对性的功能,就有设计上的考虑。

专家在过程中,对于“针对性的功能(只对某一个类有意义)”提出了一些看法:

  • 将针对性的功能直接定义在其中一个子类,会对客户端产生负担。因为客户端必须针对获取的Node类,利用“类判断”语句,先判断是属于哪一个子类后,再调用该类才有的操作方法。对于这一类必须判断才能执行的功能,并不直接否认这样做不好,如果这个方式能在编译时期就可以检查出严重错误,那么也可以采用这个方式来强化程序执行时的安全性。
  • 反之,如果这个功能操作后不会产生严重的错误后果,那么将对应的方法声明在Node类中,并不是什么坏事,因为统一的接口将为整体系统带来简单性和良好的扩展性。

上述观点也是GoF在《设计模式》一书的组合模式这一章中提到的,“设计时必须考虑到安全性(Safety)和透明性(Transparency)之间的权衡”。

当然,如果能让Node接口停止不断地扩张,就可能不必面对这些选择。所以那位专家在Node结构中增加了访问者模式(Visitor)的功能,让接口不会因为功能的增加而不断地扩张,同时又能满足功能增加的需求。此外,将原本客户端需要使用转型语句判断的地方,改用模板方法模式(Template)实现,来减少客户端必须写出if else或switch语句的程序代码。前提是,这样的功能对于程序执行时的安全性没有重大影响,而且模式之间也可以相互配合应用。

Unity3D游戏对象的分层式管理功能

有了组合模式的概念之后,让我们回头来看看Unity3D中的游戏对象GameObject类,如何实现分层管理功能。

游戏对象的分层管理

在Unity3D引擎中,每一个可以放入场景上的对象,都是一个游戏对象GameObject,Unity3D可以通过Hierarchy窗口来查看当前放在场景的GameObject,以及它们之间的层次关系。

通过简单的操作,开发者可以对这些游戏对象进行新增、删除、调整与其他游戏对象的关系,而每一个放在场景上的游戏对象,都有一个固定无法删除的Component组件——也就是Transform组件。

这个Transform组件,除了用来代表游戏对象GameObject在场景上的位置、缩转、大小等信息外,同时也扮演了Unity3D引擎中,对于游戏对象GameObject之间“分层管理”的功能操作对象。通过Transform组件提供的方法,程序人员可以获取游戏对象GameObject之间的关系,并且利用程序代码操作这些关系的变化。

在程序代码实现时,要获取GameObject中的Transform组件只需要调用:

1
UnityEngine.GameObject.transform

即可获取该组件。而Transform组件提供了许多可以让脚本语言操作的方法,如位置设置、旋转、缩放等。

其中,Transform组件提供了几个和游戏对象分层操作有关的方法和变量:

  • 变量
    • childCount:代表子组件数量。
    • parent:代表父组件中的Transform对象引用。
  • 方法
    • DetachChildren:解除所有子组件与本身的关联。
    • Find:寻找子组件。
    • GetChild:使用Index的方式取回子组件。
    • IsChildOf:判断某个Transform对象是否为其子组件。
    • SetParent:设置某个Transform对象为其父组件。

若再仔细分析,则可以将Unity3D的Transform类当成是一个通用类,因为它并不明显可以察觉出其下又被再分成“目录节点”或是单纯的“终端节点”。其实应该这样说,Transform类完全符合组合模式的需求:“让客户端在操作各个对象或组合对象时是一致的”。因此对于场景上所有的游戏对象GameObject,可以不管它们最终代表的是什么,对于所有操作都能正确反应。

正确有效地获取UI的游戏对象

在了解Unity3D对于对象的分层管理方式后,让我们回到本章想要解决的问题上,也就是“如何在游戏运行的状态下,在程序代码中能够正确且有效地获取这些UI组件的GameObject,并根据每个组件期望互动的方式再指定其对应的行为”。

以下面例子为例:在兵营界面中,有一个用来显示当前兵营名称的Text组件,被命名为“CampNameText”,如图11所示。

图11 显示兵营名称

在游戏运行时,它的对象引用需要被获取并且保存下来,因为当玩家单击选中某一个兵营时它会被用来显示当前的兵营名称。

那么首先要做的是,在“运行状态”下,如何在场景中寻找名称为“CampNameText”的游戏对象(GameObject)?而当前它被存放在Canvas->CampInfoUI下。

为了解决这个问题,Unity3D的开发者通常会使用GameObject.Find()这个方法来表示,但会产生下列问题:

  • 性能问题:GameObject.Find()会遍历所有场景上的对象,寻找名称相符的游戏对象。如果场景上的对象不多,还可以接受;如果场景上的对象过多,而且“过度”调用GameObject.Find()的话,就很容易造成系统性能的问题。
  • 名称重复:Unity3D并不限制放在场景中的游戏对象GameObject的名称必须唯一,所以当有两个名称相同的游戏对象GameObject都在场景上时,很难预期GameObject.Find()会返回其中的哪一个,这会造成不确定性,也容易产生程序错误(Bug )。

所以,直接使用GameObjet.Find()在“性能”与“正确性”上会存在些问题。因此,在《P级阵地》中使用的是另一种比较折中的办法:

  • 1.先利用GameObjet.Find()寻找2D画布Canvas的游戏对象,不过实现者还是要先确保场景中只能有一个名称为Canvas的游戏对象。这可以直接在Hierarchy窗口中,用搜索的方式来确定。
  • 2.再利用Canvas游戏对象中Transform组件的分层管理功能,去寻找其下符合名称的游戏对象。当然,Canvas下也有可能发生名称重复的问题,必须再结合“界面群组”功能,将搜索范围缩小,并且也在“搜索工具”中加入“重复名称警告”功能,让整个对象的搜索能够更加效率和正确。

上述这些操作都发生在“游戏用户界面(IUseInterface)”及其子类中。

游戏用户界面的实现

在《P级阵地》中,每一个主要游戏功能都属于IGameSystem的子类,这些子类负责实现《P级阵地》中不同的游戏需求和功能,而它们每一个都会利用组合的方式,成为PBaseDefenseGame类的成员。

对于界面的设计需求上,同样也采用这种设计方式,将每一个界面规划成由一个单独的类来负责,这些类都继承自用户界面(IUseInterface):

Listing6 游戏用户界面(IUserInterface.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public abstract class IUserInterface
{
protected PBaseDefenseGame m_PBDGame = null;
protected GameObject m_RootUI = null;
private bool m_bActive = true;

public IUserInterface( PBaseDefenseGame PBDGame )
{
m_PBDGame = PBDGame;
}

public bool IsVisible()
{
return m_bActive;
}

public virtual void Show()
{
m_RootUI.SetActive(true);
m_bActive = true;
}

public virtual void Hide()
{
m_RootUI.SetActive(false);
m_bActive = false;
}

public virtual void Initialize()
{}
public virtual void Release()
{}
public virtual void Update()
{}
}

当前《P级阵地》中使用的4个界面都是IUseInterface的子类,并且使用组合的方式成为PBaseDefenseGame类的成员,类结构图如图12所示。

Listing7 游戏中使用的4个用户界面(PBaseDefenseGame.cs)

1
2
3
4
5
6
7
8
9
10
public class PBaseDefenseGame
{
...
// 界面
private CampInfoUI m_CampInfoUI = null; // 兵营界面
private SoldierInfoUI m_SoldierInfoUI = null; // 战士信息界面
private GameStateInfoUI m_GameStateInfoUI = null; // 游戏状态界面
private GamePauseUI m_GamePauseUI = null; // 游戏暂停界面
...
}

图12 用户界面的类结构图

就如同游戏系统(IGameSystem)那样,对内可以通过PBaseDefenseGame类的中介者模式(Mediator)来通知其他系统或界面,对外也可以通过PBaseDefenseGame类的外观模式(Facade),让客户端存取和更新与用户界面相关的功能。

兵营界面的实现

建立《P级阵地》用户界面的基本架构之后,再回到前面提到的问题:在场景对象中找到名称为CampNameText的Text组件,并在上面显示当前用鼠标单击而选中的兵营的名称。

在运用新的用户界面架构之后,对于场景中2D组件的获取及对象的保留,都可以在“兵营界面CampInfoUI“类下实现:

Listing8 兵营界面(CampInfoUI.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class CampInfoUI : IUserInterface
{
...
// 界面组件
...
private Text m_AliveCountTxt = null;
private Text m_CampLvTxt = null;
private Text m_WeaponLvTxt = null;
private Text m_TrainCostText = null;
private Text m_TrainTimerText= null;
private Text m_OnTrainCountTxt = null;
private Text m_CampNameTxt = null; // 兵营名称
private Image m_CampImage = null;

public CampInfoUI( PBaseDefenseGame PBDGame ) : base(PBDGame)
{
Initialize();
}

// 初始
public override void Initialize()
{
m_RootUI = UITool.FindUIGameObject( "CampInfoUI" );

// 显示的信息
// 兵营名称
m_CampNameTxt = UITool.GetUIComponent<Text>(m_RootUI, "CampNameText");
// 兵营图片
m_CampImage = UITool.GetUIComponent<Image>(m_RootUI, "CampIcon");
// 存活单位数
m_AliveCountTxt = UITool.GetUIComponent<Text>(m_RootUI, "AliveCountText");
// 等级
m_CampLvTxt = UITool.GetUIComponent<Text>(m_RootUI, "CampLevelText");
// 武器等级
m_WeaponLvTxt = UITool.GetUIComponent<Text>(m_RootUI, "WeaponLevelText");
// 训练中的数量
m_OnTrainCountTxt = UITool.GetUIComponent<Text>(m_RootUI, "OnTrainCountText");
// 训练花费
m_TrainCostText = UITool.GetUIComponent<Text>(m_RootUI, "TrainCostText");
// 训练时间
m_TrainTimerText = UITool.GetUIComponent<Text>(m_RootUI, "TrainTimerText");

...

Hide();
}

// 显示信息
public void ShowInfo(ICamp Camp)
{
// Debug.Log("显示兵营信息");
Show ();
m_Camp = Camp;

// 名称
m_CampNameTxt.text = m_Camp.GetName();
...
}
}

在兵营界面中,分别声明了许多用来保存2D界面组件的相关类成员:

1
2
3
4
5
6
7
8
private Text m_AliveCountTxt = null;
private Text m_CampLvTxt = null;
private Text m_WeaponLvTxt = null;
private Text m_TrainCostText = null;
private Text m_TrainTimerText= null;
private Text m_OnTrainCountTxt = null;
private Text m_CampNameTxt = null; // 兵营名称
private Image m_CampImage = null;

这些类成员会在用户界面的初始化方法(Initialize)中,被指定记录场景中的某一个2D组件。

在初始化方法中,该类会被指定要负责维护的界面是哪一个:

1
2
3
4
public override void Initialize()
{
m_RootUI = UITool.FindUIGameObject( "CampInfoUI" );
}

UITool是《P级阵地》中与UI有关的工具类,其中FindUIGameObject方法会在场景中寻找特定名称的游戏对象,而且只限定在Canvas画布游戏对象下:

Listing9 游戏中使用的UI工具(UITool.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static class UITool
{
private static GameObject m_CanvasObj = null; // 场景上的2D画布对象

// 寻找限定在Canvas画布下的UI界面
public static GameObject FindUIGameObject(string UIName)
{
if(m_CanvasObj == null)
m_CanvasObj = UnityTool.FindGameObject( "Canvas" );

if(m_CanvasObj ==null)
return null;

return UnityTool.FindChildGameObject( m_CanvasObj, UIName);
}

// 获取UI组件
public static T GetUIComponent<T>(GameObject Container,string UIName) where T : UnityEngine.Component
{
// 找出子对象
GameObject ChildGameObject = UnityTool.FindChildGameObject( Container, UIName);

if( ChildGameObject == null)
return null;

T tempObj = ChildGameObject.GetComponent<T>();
if( tempObj == null)
{
Debug.LogWarning("组件["+UIName+"]不是["+ typeof(T) +"]");
return null;
}
return tempObj;
}
}

通过搜索“只能出现在特定目标下的游戏对象”,可减少名称的重复性。而已经被搜索过的Canvas2D画布对象也被保存下来,避免重新搜索而造成的性能损失。另一个工具类UnityTool,则是利用Transform类的分层管理功能,来搜索特定游戏对象GameObject下的子对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public static class UnityTool
{
// 找到场景上的对象
public static GameObject FindGameObject(string GameObjectName)
{
// 找出对应的GameObject
GameObject pTmpGameObj = GameObject.Find(GameObjectName);
if(pTmpGameObj==null)
{
Debug.LogWarning("场景中找不到GameObject["+GameObjectName+"]对象");
return null;
}
return pTmpGameObj;
}

// 获取子对象
public static GameObject FindChildGameObject(GameObject Container, string gameobjectName)
{
if (Container == null)
{
Debug.LogError("NGUICustomTools.GetChild : Container =null");
return null;
}

Transform pGameObjectTF=null;
// 是不是Container本身
if(Container.name == gameobjectName)
pGameObjectTF=Container.transform;
else
{
// 找出所有子组件
Transform[] allChildren = Container.transform.GetComponentsInChildren<Transform>();
foreach (Transform child in allChildren)
{
if (child.name == gameobjectName)
{
if(pGameObjectTF==null)
pGameObjectTF=child;
else
Debug.LogWarning("Container["+Container.name+"]下找出重复的组件名称["+gameobjectName+"]");
}
}
}

// 都没有找到
if(pGameObjectTF==null)
{
Debug.LogError("组件["+Container.name+"]找不到子组件["+gameobjectName+"]");
return null;
}

return pGameObjectTF.gameObject;
}
}

在UnityTool工具类中,FindChildGameObject方法是用来搜索某游戏对象下的子对象。从程序代码中可以看到,先是遍历某个游戏对象下的所有子组件,判断目标对象是否存在,并在方法的最后返回找到游戏对象。此外,程序代码中也对重复命名的问题加以“防呆”(防止出错的处理),发现有名称相同游戏对象时,会提出警告要求开发人员注意。虽然实现上还是遍历了所有子对象一次,会有效率上的损失,但是“重复命名提示警告信息”这项功能,可减少重复命名所造成的错误。所以,笔者在选择上会以避免重复命名为优先(笔者在多个游戏引擎下都会遇到这个问题,每次都会浪费大量的时间进行调试)。另外,由于界面组件的搜索,只会在初始化时执行一次而已,所以因搜索对象而产生的性能损失,只会在前期发生,后期在游戏运行状态下,并不会一直使用搜索界面组件的功能。

有了这两项工具之后,兵营界面CampInfoUI就能获取所有跟兵营信息有关的2D组件,并加以保留:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 兵营名称
m_CampNameTxt = UITool.GetUIComponent<Text>(m_RootUI, "CampNameText");
// 兵营图片
m_CampImage = UITool.GetUIComponent<Image>(m_RootUI, "CampIcon");
// 存活单位数
m_AliveCountTxt = UITool.GetUIComponent<Text>(m_RootUI, "AliveCountText");
// 等级
m_CampLvTxt = UITool.GetUIComponent<Text>(m_RootUI, "CampLevelText");
// 武器等级
m_WeaponLvTxt = UITool.GetUIComponent<Text>(m_RootUI, "WeaponLevelText");
// 训练中的数量
m_OnTrainCountTxt = UITool.GetUIComponent<Text>(m_RootUI, "OnTrainCountText");
// 训练花费
m_TrainCostText = UITool.GetUIComponent<Text>(m_RootUI, "TrainCostText");
// 训练时间
m_TrainTimerText = UITool.GetUIComponent<Text>(m_RootUI, "TrainTimerText");

并且在游戏功能需要时,利用这些对象来显示信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 显示信息
public void ShowInfo(ICamp Camp)
{
Show ();
m_Camp = Camp;

// 名称
m_CampNameTxt.text = m_Camp.GetName();
// 训练花费
m_TrainCostText.text = string.Format("AP:{0}",m_Camp.GetTrainCost());
// 训练中信息
ShowOnTrainInfo();
// Icon
IAssetFactory Factory = PBDFactory.GetAssetFactory();
m_CampImage.sprite = Factory.LoadSprite( m_Camp.GetIconSpriteName());

// 升级功能
if( m_Camp.GetLevel() <= 0 )
EnableLevelInfo(false);
else
{
EnableLevelInfo(true);
m_CampLvTxt.text = string.Format("等级:" + m_Camp.GetLevel());
m_WeaponLvTxt.text = string.Format("武器等级:",m_Camp.(GetWeaponLevel));
}
}

结论

利用本章介绍的方式来实现游戏的用户界面时,就笔者过去的开发经验,可提出下列优缺点,与读者分享:

优点:

  • 界面与功能分离:若每一个界面组件都只是单纯的“显示设置”和“版面安排”,上面并不绑定任何与游戏功能相关的脚本组件,那么基本上就符合了“接口”与“功能”分离的要求。因此,就单纯的界面而言,很容易就能转换到其他项目下共享,尤其是项目之间共享的界面,如登录界面、公司版权页等。
  • 工作切分更容易:以往的界面设计,不太容易切分是由哪个部门或小组来专职负责,程序设计师、企划、美术都可能接触到。当程序功能脚本从界面设计上移除后,就很容易让程序设计师从用户界面设计中脱离,完全交由美术或企划组装。
  • 界面更改不影响项目:只要维持组件的名称不变,那么界面的更改就不太容易影响到现有程序功能的运行,如更改组件的大小、外观、显示的色彩、图标等,大多可以独立设置。

缺点:

  • 组件名称重复:如果组件搜索没有设置好策略以及界面设计上没有将层级切分好的话,就很容易发生组件名称重复的问题。预防的方式即是《P级阵地》中所示范的,在工具类UnityTool中加上“名称重复警告”功能,用以提示界面设计或测试人员,发生了“重复命名”的问题。
  • 组件更名不易: 当界面组件因为设计需要而进行更名时,会让原本程序预期获取的组件无法再获取,严重时会导致游戏功能不正确并造成程序的宕机或App的闪退。应对的方法一样是在工具类UnityTool中加上“无法获取”的警告信息,以提示界面设计或测试人员,发生了“组件无法获取”的问题。

就整体来看,笔者认为,本章所介绍的用户界面开发方式,优点多于缺点。而缺点部分也能使用“警告提示”来避免,因此将此方法带到《P级阵地》中作为玩家界面的实现方式。

0%