游戏场景的转换--状态模式

思考并回答以下问题:

  • 状态模式的官方定义是什么?如何用德鲁伊来描述状态模式?Context类就是德鲁伊怎么理解?Context和State接口互相用类成员调用怎么理解?
  • “一个对象”,Context就是那个对象。怎么理解?
  • 动手画一下状态模式的类图。
  • 德鲁伊怎么玩?首先我要告诉德鲁伊最初的状态是什么,然后操作德鲁伊攻击。
  • 什么场景使用状态模式?
  • A类作为B类的成员,然后在B类的function SetA(A)方法里赋值,参数是A类对象,怎么理解?
  • 状态在自身里面进行状态转换怎么理解?
  • if else的判断条件跑到了哪里?
  • Context这个词表示上下文和执行环境的意思。怎么理解?
  • 理解状态模式最关键的两点在Context类:1.调用Context类的对象的一个方法(一个对象的行为)时要设置一个初始状态;2.给这个方法传会触发转换的参数。怎么理解?
  • 代码转成使用状态模式时要新建状态接口和具体状态子类。怎么理解?
  • 几个状态之间无法通过引用进行调用,虽然都是状态基类的子类,但只能通过Context互相通信。这就是为什么状态类要引用Context的原因。怎么理解?

本章涵盖:

  • 游戏场景
    • 场景的转换
    • 游戏场景可能的实现方式
  • 状态模式
    • 状态模式的定义
    • 状态模式的说明
    • 状态模式的实现范例
  • 使用状态模式实现游戏场景的转换
    • SceneState的实现
    • 实现说明
    • 使用状态模式的优点
    • 游戏执行流程及场景转换说明
  • 状态模式面对变化时
  • 结论

游戏场景

场景的转换

当游戏比较复杂时,通常会设计成多个场景,让玩家在几个场景之间转换。

我们可规划出数个场景,每个场景分别负责多项功能的执行。

  • 登录场景:负责游戏片头、加载游戏数据、出现游戏主画面、等待玩家登录游戏。
  • 主画面场景:负责进入游戏画面、玩家在主城/主画面中的操作、在地图上打怪打宝…
  • 战斗场景:负责与玩家组队之后进入副本关卡、挑战王怪…

在游戏场景规划完成后,就可以利用“状态图”将各场景的关系连接起来,并且说明它们之间的转换条件以及状态转换的流程,如图1所示。

图1 各场景转换条件以及状态转换的“状态图”

切分场景的好处

将游戏中不同的功能分类在不同的场景中来执行,除了可以将游戏功能执行时需要的环境明确分类之外,“重复使用”也是使用场景转换的好处之一。

从上面几个例子中可以看出,“登录场景”几乎是每款游戏必备的场景之一。而一般在登录场景中,会实现游戏初始化功能或玩家登录游戏时需要执行的功能,例如:

  • 单机游戏:登录场景可以有加载游戏数据、让玩家选择存盘、进入游戏等步骤。
  • 在线游戏:登录场景包含了许多复杂的在线登录流程,比如使用第三方认证系统、使用玩家自定义账号、与服务器连接、数据验证…

对于大多数的游戏开发公司来说,登录场景实现的功能,会希望通用于不同的游戏开发项目,使其保持流程的一致性。尤其对于在线游戏这种类型的项目而言,由于登录流程较为复杂,若能将各项目共同的部分(场景)独立出来,由专人负责开发维护并同步更新给各个项目,那么效率就能获得提升,也是比较安全的方式。在项目开发时,若是能重复使用这些已经设计良好的场景,将会减少许多开发时间。

《P级阵地》规划了3个场景,如图2所示。

图2 《P级阵地》规划的3个场景

  • 开始场景(StartScene):GameLoop游戏对象的所在,游戏启动及相关游戏设置的加载。
  • 主画面场景(MainMenuScene):显示游戏名称和“开始”按钮。
  • 战斗场景(BattleScene):游戏主要执行的场景。

游戏场景可能的实现方式

实现Unity3D的场景转换较为直接的方式如下:

Listing1 一般场景控制的写法

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
using UnityEngine;
using System.Collections;
using UnityEngine.UI;
using UnityEngine.SceneManagement;

public class SceneMgr : MonoBehaviour
{
private string m_state ="开始";

// 改换场景
public void ChangeScene(string StateName)
{
m_state = StateName;

switch (m_state)
{
case "菜单":
SceneManager.LoadScene("MainMenuScene");
break;

case "主场景":
SceneManager.LoadScene("GameScene");
break;
}
}

// Update is called once per frame
void Update()
{
switch (m_state)
{
case "开始":
Debug.Log("现在在开始场景");
break;

case "菜单":
Debug.Log("现在在菜单场景");
break;

case "主场景":
Debug.Log("现在在主场景");
break;
}
}
}
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
using UnityEngine;
using System.Collections;

public class GameMgr : MonoBehaviour
{
private SceneMgr scene_mgr;
// Use this for initialization
void Start ()
{
scene_mgr = gameObject.GetComponent<SceneMgr>();
scene_mgr.ChangeScene("菜单");
DontDestroyOnLoad(this.gameObject);
}

// Update is called once per frame
void Update () {
if (Input.GetMouseButtonDown(0))
{
scene_mgr.ChangeScene("主场景");
}

if (Input.GetMouseButtonDown(1))
{
scene_mgr.ChangeScene("开始");
}
}
}

上述的实现方式会有以下缺点:

  • 只要增加一个状态,则所有switch(m_state)的程序代码都需要增加对应的程序代码。
  • 与每一个状态有关的对象,都必须在SceneManager类中被保留,当这些对象被多个状态共享时,可能会产生混淆,不太容易识别是由哪个状态设置的,造成游戏程序调试上的困难。
  • 每一个状态可能使用不同的类对象,容易造成SceneManager类过度依赖其他类,让SceneManager类不容易移植到其他项目中。

为了避免出现上述缺点,修正的目标会希望使用一个“场景类”来负责维护一个场景,让与此场景相关的程序代码和对象能整合在一起。这个负责维护的“场景类”,其主要工作如下:

  • 场景初始化;
  • 场景结束后,负责清除资源;
  • 定时更新游戏逻辑单元;
  • 转换到其他场景;
  • 其他与该场景有关的游戏实现。

由于在范例程序中我们规划了3个场景,所以会产生对应的3个“场景类”,但如何让这3个“场景类”相互合作、彼此转换呢?我们可以使用GoF的状态模式来解决这些问题。

状态模式

状态模式(State),在多数的设计模式书籍中都会提及,它也是游戏程序设计中应用最频繁的一种模式。主要是因为“状态”经常被应用在游戏设计的许多环节中,包含AI人工智能状态、账号登录状态、角色状态。

状态模式的定义

状态模式,在GoF中的解释是:

1
让一个对象的行为随着内部状态的改变而变化,而该对象也像是换了类一样。

自己的理解:对象的行为可以认为是类方法做的事情。对象(Mathf)的某个方法(计算次方CalculatePower)因为传入参数的不同而变的行为和之前完全不一样。传入小于等于10的值返回平方,传入了大于10的实参返回了该值的3次方。所以,有多个状态时,只需要传对应的值进这个对象的那个方法即可得到正确的返回值。本来的实现是在Mathf.CalculatePower里直接if elseif else判断,现在因为开闭原则,要去掉if语句,改为交由具体状态类来计算。

使用场景:对象.方法(形参)里面对形参进行了各种判断,返回不同的结果。此时可以用状态模式。

如果将GoF对状态模式的定义改以游戏的方式来解释,就会像下面这样:

“当德鲁伊(对象)由人形变化为兽形状态(内部状态改变)时,他所施展的技能(对象的行为)也会有所变化,玩家此时就像是在操作另一个不同的角色(像是换了类)”。

“德鲁伊”是一种经常出现在角色扮演游戏(RPG)中的角色名称。变化外形是他们常使用的能力,通过外形的变化,使德鲁伊具备了转换为其他形体的能力,而变化为“兽形”是比较常见的游戏设计。当玩家决定施展外形转换能力时,德鲁伊会进入“兽形状态”,这时候的德鲁伊会以“兽形”来表现其行为,包含移动和攻击施展的方式;当玩家决定转换回人形时,德鲁伊会复原为一般形态,继续与游戏世界互动。

所以,变化外形的能力可以看成是德鲁伊的一种“内部状态的转换”。通过变化外形的结果,角色表现出另外一种行为模式,而这一切的转化过程都可以由德鲁伊的内部控制功能来完成,玩家不必理解这个转化过程。但无论怎么变化,玩家操作的角色都是德鲁伊,并不会因为他内部状态的转变而有所差异。

当某个对象状态改变时,虽然它“表现的行为”会有所变化,但是对于客户端来说,并不会因为这样的变化,而改变对它的“操作方法”或“信息沟通”的方式。也就是说,这个对象与外界的对应方式不会有任何改变。但是,对象的内部确实是会通过“更换状态类对象”的方式来进行状态的转换。当状态对象更换到另一个类时,对象就会通过新的状态类,表现出它在这个状态下该有的行为。但这一切只会发生在对象内部,对客户端来说,完全不需要了解这些状态转换的过程及对应的方式。

状态模式的说明

状态模式的结构如图3所示。

图3 状态模式的结构图

参与者的说明如下:

  • Context(状态拥有者)
    • 是一个具有“状态”属性的类,可以制定相关的接口,让外界能够得知状态的改变或通过操作让状态改变。
    • 有状态属性的类,例如:游戏角色有潜行、攻击、施法等状态;好友上线、脱机、忙碌等状态;GoF使用TCP联网为例,有已连接、等待连接、断线等状态。这些类中会有一个ConcreteState[X]子类的对象为其成员,用来代表当前的状态。
  • State(状态接口类):制定状态的接口,负责规范Context (状态拥有者)在特定状态下要表现的行为。
  • ConcreteState(具体状态类)
    • 继承自State(状态接口类)。
    • 实现Context(状态拥有者)在特定状态下该有的行为。例如,实现角色在潜行状态时该有的行动变缓、3D模型变半透明、不能被敌方角色察觉等行为。

状态模式的实现范例

首先定义Context类:

Listing2 定义Context类(State.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Context
{
State m_State = null;

public void Request(int Value)
{
m_State.Handle(Value);
}

public void SetState(State theState)
{
Debug.Log("Context.SetState:"+ theState);
m_State = theState;
}
}

Context类中,拥有一个State属性用来代表当前的状态,外界可以通过Request方法,让Context类呈现当前状态下的行为。SetState方法可以指定Context类当前的状态,而State状态接口类则用来定义每一个状态该有的行为:

Listing3 State类(State.cs)

1
2
3
4
5
6
7
8
9
10
public abstract class State
{
protected Context m_Context = null;
public State(Context theContext)
{
m_Context = theContext;
}

public abstract void Handle(int Value);
}

在产生State类对象时,可以传入Context类对象,并将其指定给State的类成员m_Context,让State类在后续的操作中,可以获取Context对象的信息或操作Context对象。然后定义Handle抽象方法,让继承的子类可以重新定义该方法,来呈现各自不同的状态行为。

最后定义3个继承自State类的子类:

Listing4 定义3个状态(State.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
//状态A
public class ConcreteStateA : State
{
public ConcreteStateA(Context theContext) : base(theContext){}

public override void Handle(int Value)
{
Debug.Log("ConcreteStateA.Handle");
if( Value > 10)
m_Context.SetState( new ConcreteStateB(m_Context));
}
}

//状态B
public class ConcreteStateB : State
{
public ConcreteStateB(Context theContext) : base(theContext){}

public override void Handle(int Value)
{
Debug.Log("ConcreteStateB.Handle");

if( Value > 20)
m_Context.SetState( new ConcreteStateC(m_Context)) ;
}
}

// 状态C
public class ConcreteStateC : State
{
public ConcreteStateC(Context theContext) : base(theContext){}

public override void Handle(int Value)
{
Debug.Log("ConcreteStateC.Handle");

if( Value > 30)
m_Context.SetState( new ConcreteStateA(m_Context));
}
}

上述3个子类,都要重新定义父类State的Handle抽象方法,用来表示在各自状态下的行为。在范例中,我们先让它们各自显示不同的信息(代表当前的状态行为),再按照本身状态的行为定义来判断是否要通知Context对象转换到另一个状态。

Context类中提供了一个SetState方法,让外界能够设置Context对象当前的状态,而所谓的“外界”,也可以是由另一个State状态来调用。所以实现上,状态的转换可以有下列两种方式:

  • 交由Context类本身,按条件在各状态之间转换;
  • 产生Context类对象时,马上指定初始状态给Context对象,而在后续执行过程中的状态转换则交由State对象负责,Context对象不再介入。

笔者在实现时,大部分情况下会选择第2种方式,原因在于:

状态对象本身比较清楚“在什么条件下,可以让Context对象转移到另一个State状态”,所以在每个ConcreteState类的程序代码中,可以看到“状态转换条件”的判断,以及设置哪一个ConcreteState对象成为新的状态。

每个ConcreteState状态都可以保持自己的属性值,作为状态转换或展现状态行为的依据,不会与其他的ConcreteState状态混用,在维护时比较容易理解。

因为判断条件及状态属性都被转换到ConcreteState类中,故而可缩减Context类的大小。

4个类定义好之后,我们可以通过测试范例来看看客户端程序会怎样利用这个设计:

Listing5 State的测试范例(StateTest.cs)

1
2
3
4
5
6
7
8
9
void UnitTest()
{
Context theContext = new Context();
theContext.SetState(new ConcreteStateA(theContext));
theContext.Request(5);
theContext.Request(15);
theContext.Request(25);
theContext.Request(35);
}

首先产生Context对象theContext,并立即设置为ConcreteStateA状态;然后调用Context类的Request方法,并传入作为“状态转换判断”用途的参数,让当前状态(ConcreteStateA)判断是否要转移到ConcreteStateB;最后调用几次Request方法,并传入不同的参数。

从输出的信息中可以看到,Context对象的状态由ConcreteStateA按序转换到ConcreteStateB、ConcreteStateC状态,最后回到ConcreteStateA状态。

执行结果 State测试范例产生的信息

1
2
3
4
5
6
7
8
Context.SetState:ConcreteStateA
ConcreteStateA.Handle
ConcreteStateA.Handle
Context.SetState:ConcreteStateB
ConcreteStateB.Handle
Context.SetState:ConcreteStateC
ConcreteStateC.Handle
Context.SetState:ConcreteStateA

使用状态模式实现游戏场景的转换

在Unity3D的环境中,游戏只会在一个场景中运行,所以我们可以让每个场景都由一个“场景类”来负责维护。此时,如果将场景类当成“状态”来比喻的话,那么就可以利用状态模式的转换原理,来完成场景转换的功能。

由于每个场景所负责执行的功能不同,通过状态模式的状态转移,除了可以实现游戏内部功能的转换外,对于客户端来说,也不必根据不同的游戏状态来编写不同的程序代码,同时也减少了外界对于不同游戏状态的依赖性。

而原本的Unity3D场景转换判断功能,可以在各自的场景类中完成,并且状态模式同时间也只会让一个状态存在(同时间只会有一个状态在运行),因此可以满足Unity3D执行时只能有一个场景(状态)存在的要求。

SceneState的实现

《P级阵地》的场景分成3个:开始场景(StarScene)、主画面场景(MainMenuScene)和战斗场景(BattleScene),所以声明3个场景类负责对应这3个场景。这3个场景类都继承自ISceneState而SceneStateController则是作为这些状态的拥有者(Context),最后将SceneStateController对象放入GameLoop类下,作为与Unity3D运行的互动接口,上述结构如图4所示。

图4 3个场景类的结构图

其中的参与者如下说明:

  • ISceneState:场景类的接口,定义《P级阵地》中场景转换和执行时需要调用的方法。
  • StartState、MainMenuState、BattleState:分别对应范例中的开始场景(StartScene),主画面场景(MainMenuScene)及战斗场景(BattleScene),作为这些场景执行时的操作类。
  • SceneStateController:场景状态的拥有者(Context),保持当前游戏场景状态,并作为与GameLoop类互动的接口。除此之外,也是执行“Unity3D场景转换”的地方。
  • GameLoop:游戏主循环类。作为Unity3D与《P级阵地》的互动接口,包含了初始化游戏和定期调用更新操作。

实现说明

首先,定义ISceneState接口如下:

Listing6 定义ISceneState类(ISceneState.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
public class ISceneState
{
// 状态名称
private string m_StateName = "ISceneState";
public string StateName
{
get{ return m_StateName; }
set{ m_StateName = value; }
}

// 控制者
protected SceneStateController m_Controller = null;

// 构造函数
public ISceneState(SceneStateController Controller)
{
m_Controller = Controller;
}

// 开始
public virtual void StateBegin()
{}

// 结束
public virtual void StateEnd()
{}

// 更新
public virtual void StateUpdate()
{}

public override string ToString ()
{
return string.Format("[I_SceneState: StateName={0}]", StateName);
}
}

ISceneState定义了在《P级阵地》中,场景转换执行时需要被Unity3D通知的操作,包含:

  • StateBegin方法:场景转换成功后会利用这个方法通知类对象。其中可以实现在该场景执行时需要加载的资源及游戏参数的设置。SceneStateController在此时才传入(不像前一节范例那样在构造函数中传入),因为Unity3D在转换场景时会花费一些时间,所以必须先等到场景完全加载成功后才能继续执行。
  • StateEnd方法:场景将要被释放时会利用这个方法通知类对象。其中可以释放游戏不再使用的资源,或者重新设置游戏场景状态。
  • StateUpdate方法:“游戏定时更新”时会利用这个方法通知类对象。该方法可以让Unity3D的“定时更新功能”被调用,并通过这个方法让其他游戏系统也定期更新。这个方法可以让游戏系统类不必继承Unity3D的MonoBehaviour类,也可以拥有定时更新功能,在游戏的主循环-Game-Loop一章会对此进行更详细地说明。
  • m_StateName属性:可以在调试(Debug)时使用。

StateBegin、StateEnd及StateUpdate这3个方法,虽然是定义为IScenesState中的接口方法,但是由于不强迫子类重新实现它,所以并没有被定义为抽象方法。

共有3个子类继承自ISceneState,分别用来负责各Unity3D Scene的运行和转换。首先是负责开始场景(StartScene)的类,程序代码如下:

Listing7 定义开始状态类(StartState.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class StartState : ISceneState
{
public StartState(SceneStateController Controller) : base(Controller)
{
this.StateName = "StartState";
}

// 开始
public override void StateBegin()
{
// 可在此进行游戏数据加载和初始化等
}

// 更新
public override void StateUpdate()
{
// 更换为
m_Controller.SetState(new MainMenuState(m_Controller), "MainMenuScene");
}
}

《P级阵地》的运行,必须在开始场景(StartScene)中单击“开始”按钮才能运行,所以游戏最开始的场景状态会被设置为StartState。因此在实现上,可在StateBegin方法中,将游戏启动时所需要的资源加载,这些资源可以是游戏属性数据、角色组件预载、游戏系统初始化、版本信息等。当StartState的StateUpdate第一次被调用时,会马上将游戏场景状态转换为MainMenuState,完成StartState/StartScene初始化游戏的任务。

主画面场景(MainMenuScene)负责显示游戏的开始画面,并且提供简单的界面让玩家可以开始进入游戏,程序代码如下:

Listing8 定义主菜单状态(MainMenuState.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MainMenuState : ISceneState
{
public MainMenuState(SceneStateController Controller) : base(Controller)
{
this.StateName = "MainMenuState";
}

// 开始
public override void StateBegin()
{
// 获取开始按钮
Button tmpBtn = UITool.GetUIComponent<Button>("StartGameBtn");
if(tmpBtn != null)
tmpBtn.onClick.AddListener( ()=> OnStartGameBtnClick(tmpBtn) );
}

// 开始游戏
private void OnStartGameBtnClick(Button theButton)
{
Debug.Log ("OnStartBtnClick:"+theButton.gameObject.name);
m_Controller.SetState(new BattleState(m_Controller), "BattleScene" );
}
}

《P级阵地》的开始画面上只有一个“开始”按钮,这个按钮是使用Unity3D的UI工具增加的。从原本Unity3D的UI设置界面上,可直接设置当按钮被鼠标单击时,需要由哪一个脚本组件的方法来执行;这个设置动作也可以改由程序代码来指定。至于《P级阵地》与Unity3D的UI设计工具的整合,在Unity3D的界面设计-组合模式中有进一步的说明。

因此,在MainMenuState的StateBegin方法中,获取MainMenuScene的“开始”按钮(StartGameBtn)后,将其OnClick事件的监听者设置为OnStartGameBtnClick方法,而该方法也将直接实现在MainMenuState类中。所以,当玩家单击“开始”按钮时, OnStartGameBtnClick会被调用,并将游戏场景状态通过SceneStateController转换到战斗场景(BattleScene)。

战斗场景为《P级阵地》真正游戏玩法(阵地防守)场景,程序代码如下:

Listing9 定义战斗状态(BattleState.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
public class BattleState : ISceneState
{
public BattleState(SceneStateController Controller) : base(Controller)
{
this.StateName = "BattleState";
}

// 开始
public override void StateBegin()
{
PBaseDefenseGame.Instance.Initinal();
}

// 结束
public override void StateEnd()
{
PBaseDefenseGame.Instance.Release();
}

// 更新
public override void StateUpdate()
{
// 输入
InputProcess();

// 游戏逻辑
PBaseDefenseGame.Instance.Update();

// Render由Unity负责

// 游戏是否结束
if( PBaseDefenseGame.Instance.ThisGameIsOver())
m_Controller.SetState(new MainMenuState(m_Controller), "MainMenuScene" );
}

// 输入
private void InputProcess()
{
// 玩家输入判断程序代码......
}
}

负责战斗场景(BattleScene)的BattleState状态类在StateBegin方法中,首先调用了游戏主程序PBaseDefenseGame的初始化方法:

1
2
3
4
public override void StateBegin()
{
PBaseDefenseGame.Instance.Initinal();
}

当《P级阵地》在一场战斗结束或放弃战斗时,玩家可以回到主菜单场景(MainMenuState)。所以,当战斗场景即将结束时,StateEnd方法就会被调用,实现上,会在此调用释放游戏主程序的操作:

1
2
3
4
public override void StateEnd()
{
PBaseDefenseGame.Instance.Release();
}

BattleState的StateUpdate方法扮演着“游戏循环”的角色(GameLoop将在游戏的主循环-Game-Loop中说明)。先获取玩家的“输入操作”后,再执行“游戏逻辑”(调用PBaseDefenseGame的Update方法),并且不断地定时重复调用,直到游戏结束转换为主菜单场景(MainMenuState)为止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public override void StateUpdate()
{
// 输入
InputProcess();

// 游戏逻辑
PBaseDefenseGame.Instance.Update();

// Render由Unity负责

// 游戏是否结束
if( PBaseDefenseGame.Instance.ThisGameIsOver())
m_Controller.SetState(new MainMenuState(m_Controller), "MainMenuScene" );
}

3个主要的游戏状态类都定义完成后,接下来就是实现这些场景转换和控制的功能:

Listing10 定义场景状态控制者(SceneStateController.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
public class SceneStateController
{
private ISceneState m_State;
private bool m_bRunBegin = false;

public SceneStateController()
{}

// 设置状态
public void SetState(ISceneState State, string LoadSceneName)
{
Debug.Log ("SetState:"+State.ToString());
m_bRunBegin = false;

// 载入场景
LoadScene( LoadSceneName );

// 通知前一个State结束
if( m_State != null )
m_State.StateEnd();

// 设置
m_State = State;
}

// 载入场景
private void LoadScene(string LoadSceneName)
{
if( LoadSceneName == null || LoadSceneName.Length == 0 )
return ;
SceneManager.LoadLevel( LoadSceneName );
}

// 更新
public void StateUpdate()
{
// 是否还在加载
if( Application.isLoadingLevel)
return ;

// 通知新的State开始
if( m_State != null && m_bRunBegin ==false)
{
m_State.StateBegin();
m_bRunBegin = true;
}

if( m_State != null)
m_State.StateUpdate();
}
}

SceneStateController类中有一个ISceneState成员,用来代表当前的游戏场景状态。在SetState方法中,实现了转换场景状态的功能,该方法先使用SceneManager.LoadLevel方法来加载场景;然后通知前一个状态的StateEnd方法来释放前一个状态;最后将传入的参数设置为当前状态。

至于SceneUpdate方法,则是会先判断场景是否载入成功,成功之后才会调用当前游戏场景状态的StateBegin方法来初始化游戏场景状态。

最后,将SceneStateController与GameLoop脚本组件结合如下:

Listing11 与游戏主循环的结合(GameLoop.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
public class GameLoop : MonoBehaviour 
{
// 场景状态
SceneStateController m_SceneStateController = new SceneStateController();

void Awake()
{
// 转换场景不会被删除
GameObject.DontDestroyOnLoad( this.gameObject );

// 随机数种子
UnityEngine.Random.seed = (int)DateTime.Now.Ticks;
}

// Use this for initialization
void Start()
{
// 设置起始的场景
m_SceneStateController.SetState(new StartState(m_SceneStateController), "");
}

// Update is called once per frame
void Update()
{
m_SceneStateController.StateUpdate();
}
}

在GameLoop脚本组件中,定义并初始化SceneStateController类对象,并在Start方法中设置第一个游戏场景状态:StartState。之后在GameLoop脚本组件每次的Update更新方法中,调用SceneStateController对象的StateUpdate方法,让当前的场景状态类能够被定时更新。

使用状态模式的优点

使用状态模式来实现游戏场景转换,有下列优点:

减少错误的发生并降低维护难度

不再使用switch(m_state)来判断当前的状态,这样可以减少新增游戏状态时,因未能检查到所有switch(m_state)程序代码而造成的错误。

状态执行环境单一化

与每一个状态有关的对象及操作都被实现在一个场景状态类下,这样可以清楚地了解每一个状态执行时所需要的对象及配合的类。

项目之间可以共享场景

本章开始时就提到,有些场景可以在不同项目之间共享。以当前《P级阵地》使用的3个场景及状态类为例,其中的开始场景(StartScene)和开始状态类(StartState)都可以在不同项目之间共享。例如:可以在开始状态类(StartState)的StateBegin方法中,明确定义出游戏初始化时的步骤,并将这些步骤搭配“模版方法模式(Template Method)”或“策略模式(Strategy)”,就能让各项目自行定义符合各个游戏需求的具体实现,达到各项目共享场景的目的。

这种做法对于网络在线型的游戏项目特别有用,在此类型的项目中,玩家的上线、登录、验证、数据同步等过程,实现上存在一定的复杂度。若将这些复杂的操作放在共享的场景中,共享使用与维护,就可以节省许多的开发时间及成本。

游戏执行流程及场景转换说明

从Unity游戏开始执行的流程来看,《P级阵地》通过StartScene场景中唯一的GameLoop游戏对象,以及挂在其上的GameLoop脚本组件,将整个游戏运行起来。所以,在GameLoop的Start方法中设置好第一个游戏场景状态后, GameLoop的Update方法就将游戏的控制权交给SceneStateController。而SceneStateController内部则会记录当前的游戏场景状态类,之后再通过调用游戏场景状态的StateUpdate方法,就能够完成更新当前游戏场景状态的需求。上述流程可以参考下面的流程图,如图5所示。

图5 流程图

状态模式面对变化时

随着项目开发进度进入中后期,游戏策划可能会提出新的系统功能来增加游戏内容。这些提案可能是增加小游戏关卡、提供查看角色信息图鉴、玩家排行等功能。当程序人员在分析这些新增的系统需求后,如果觉得无法在现有的场景下实现,就必须使用新的场景来完成。而在现有的架构下,程序人员只需要完成下列几项工作:

  • 在Unity3D编辑模式下新增场景。
  • 加入一个新的场景状态类对应到新的场景,并在其中实现相关功能。
  • 决定要从哪个现有场景转换到新的场景。
  • 决定新的场景结束后要转换到哪一个场景。

上述流程,就程序代码的修改而言,只会新增一个程序文件(.cs)用来实现新的场景状态类,并修改一个现有的游戏状态,让游戏能按照需求转换到新的场景状态。除此之外,不需要修改其他任何的程序代码。

结论

在本章中,我们利用状态模式实现了游戏场景的切换,这种做法并非全然都是优点,但与传统的switch(state_code)相比,已经算是更好的设计。此外,正如前面章节所介绍的,设计模式并非只能单独使用,在实际开发中,若多种设计模式搭配得宜,将会是更好的设计。因此,本章结尾,我们将讨论,本章所做的设计还有哪些应该注意的地方,以及还可以将状态模式应用在游戏设计的哪些地方。

状态模式的优缺点

使用状态模式可以清楚地了解某个场景状态执行时所需要配合使用的类对象,并且减少因新增状态而需要大量修改现有程序代码的维护成本。

《P级阵地》只规划了3个场景来完成整个游戏,算是“产出较少状态类”的应用。但如果状态模式是应用在有大量状态的系统时,就会遇到“产生过多状态类”的情况,此时会伴随着类爆炸的问题,这算是一个缺点。不过与传统使用switch(state_code)的实现方式相比,使用状态模式对于项目后续的长期维护效益上,仍然具有优势。

角色AI-状态模式一章讲解到AI实现时,还会再次使用状态模式来实现,届时,读者可看到其他利用状态模式的应用。

与其他模式的合作

在《P级阵地》的BattleState类实现中,分别调用了PBaseDefenseGame类的不同方法,此时的PBaseDefenseGame使用的是“单例模式”,这是一种让BattleState类方法中的程序代码,可以取得唯一对象的方式。而PBaseDefenseGame也使用了“外观模式”来整合PBaseDefenseGame内部的复杂系统,因此BattleState类不必了解太多关于PBaseDefenseGame内部的实现方式。

状态模式的其他应用方式:

  • 角色AI:使用状态模式来控制角色在不同状态下的AI行为。
  • 游戏服务器连线状态:网络游戏的客户端,需要处理与游戏服务器的连线状态,一般包含开始连线、连线中、断线等状态,而在不同的状态下,会有不同的封包信息处理方式,需要分别实现。
  • 关卡进行状态:如果是通关型游戏,进入关卡时通常会分成不同的阶段,包含加载数据、显示关卡信息、倒数通知开始、关卡进行、关卡结束和分数计算,这些不同的阶段可以使用不同的状态类来负责实现。
0%