使用状态编程模式开发模块化、灵活的代码库
通过在您的 Unity 项目中实施常见的游戏编程设计模式,您可以高效地构建和维护一个简洁、有序和可读的代码库。设计模式不仅能减少重构和测试时间,还能加快入职和开发流程,为游戏、开发团队和业务的发展奠定坚实的基础。
不要把设计模式看成是你可以复制粘贴到代码中的成品解决方案,而应把它看成是一种额外的工具,只要使用得当,就能帮助你构建更大的、可扩展的应用程序。
本页将介绍状态模式,以及它如何使代码库管理变得更容易。
这里的内容基于免费电子书、 使用游戏编程模式提升代码水平该电子书解释了众所周知的设计模式,并分享了在您的 Unity 项目中使用这些模式的实用示例。
Unity 游戏编程设计模式系列中的其他文章可在Unity 最佳实践中心查阅,或点击以下链接:
想象一下构建一个可玩角色的过程。在某一时刻,角色可能站在地上。移动控制器,它就会跑起来或走起来。按下跳跃键,角色就会跃上半空。几帧之后,它降落下来,重新进入空闲的站立状态。
计算机游戏的交互性要求对运行时发生变化的许多系统进行跟踪和管理。如果你画一张图来表示角色的不同状态,你可能会得出类似上图的结果:
它类似于流程图,但有一些不同之处:
- 它由多种状态(闲庭/站立、行走、奔跑、跳跃等)组成,在特定时间内只有一种当前状态处于活动状态。
- 每个状态都可以根据运行时的条件触发向另一个状态的过渡。
- 当发生转换时,输出状态将成为新的活动状态。
该图说明了一种称为有限状态机 (FSM)的东西。在游戏开发中,FSM 的一个典型用例是跟踪道具或 "游戏角色"(如可玩角色)的内部状态。FSM 在游戏开发中有很多使用案例,如果您有一些在 Unity 中开发项目的经验,很可能已经在 Unity 的动画状态机中使用过 FSM。
FSM 由其状态列表定义。它有一个初始状态,每个过渡都有条件。在任何给定时间内,FSM 都可以处于有限的几种状态中的一种,并有可能根据导致转换的外部输入从一种状态转换到另一种状态。
另一方面,"状态 "设计模式定义了一个代表状态的接口和一个为每个状态实现该接口的类。需要根据状态改变其行为的上下文或类持有对当前状态对象的引用。当上下文的内部状态发生变化时,它只需更新状态对象的引用,使其指向一个不同的对象,从而改变上下文的行为。
状态模式与 FSM 相似,也可以管理不同的状态以及状态之间的转换。不过,FSM 通常使用开关语句来实现,而状态设计模式则定义了一个代表状态的接口和一个为每个状态实现该接口的类。
状态模式被广泛应用于游戏开发中,它可以有效地管理游戏的不同状态,如主菜单、游戏状态和游戏结束状态。
让我们以下面的示例来看看状态模式的实际应用。
在代码中描述基本 FSM 的简化方法可能与下面使用枚举和开关语句的示例类似。
首先,定义一个由三种状态组成的枚举 PlayerControllerState:空闲、行走和跳跃。
然后,在 Update 循环中使用 switch 作为条件语句,测试当前处于哪种状态。根据不同的状态,可以调用相应的函数来执行适用的特定行为。
这种方法可以奏效,但 PlayerController 脚本很快就会变得一团糟,尤其是当你需要制定状态之间的转换条件时。使用开关语句通过一个脚本来管理游戏状态并不是最佳做法,因为这会导致代码复杂且难以维护。随着状态和转换次数的增加,开关语句会变得庞大而难以理解。
此外,由于需要修改switch语句,增加新状态或转换也变得更加困难。另一方面,"状态 "模式允许采用更具模块化和可扩展性的设计,从而更容易添加新的状态或转换。
让我们重新实现状态模式,重组 PlayerController 的逻辑。该代码示例也可在Github 上的演示项目中找到。
根据最初的四人帮,状态设计模式解决了两个问题:
- 当一个对象的内部状态发生变化时,它就应该改变自己的行为。
- 针对特定状态的行为是独立定义的。添加新状态不会影响现有状态的行为。
在前面的代码示例中, 未重构的玩家控制器类可以跟踪状态变化,但并不能解决第二个问题。在添加新状态时,要尽量减少对现有状态的影响。相反,你可以将状态封装为一个对象。
想象一下,在你的示例中,每个状态的结构都如上图所示。在此,您要进入相应的状态,并循环运行每一帧,直到某个条件导致控制流退出。换句话说,你可以用 "进入"、"更新 "和 "退出 "来封装特定的状态。
为实现上述模式,请创建一个名为IState 的接口。然后,游戏中的每个具体状态都将按照这一约定来实现接口:
- 一个入口该逻辑在首次进入状态时执行。
- 更新:该逻辑每帧运行一次(有时称为 Execute 或 Tick)。您可以像 MonoBehaviour 一样进一步细分 Update 方法,使用固定更新(FixedUpdate)进行物理更新,使用延迟更新(LateUpdate)等。更新中的任何功能都会在每一帧运行,直到检测到触发状态变化的条件。
- 一个出口这里的代码会在离开状态并过渡到新状态之前运行。
您需要为每个状态创建一个实现IState 的类。在示例项目中,为WalkState、IdleState 和JumpState 分别建立了一个单独的类。
然后,另一个名为StateMachine.cs 的类将管理控制流如何进入和退出这些状态。有了这三个示例状态,状态机看起来就像下面的代码示例。
按照这种模式,状态机为其管理的每个状态(本例中为walkState、jumpState 和idleState)引用一个公共对象。由于状态机没有从 MonoBehaviour 继承,因此要使用构造函数来设置每个实例。
您可以向构造函数传递任何所需的参数。在示例项目中,每个状态都引用了一个 PlayerController。然后使用它来更新每帧的每个状态(请参阅下面的 IdleState 示例)。
请注意以下有关状态机的概念:
- 通过 Serializable 属性,可以在检查器中显示 StateMachine.cs(及其公共字段)。然后,另一个 MonoBehaviour(如 PlayerController 或 EnemyController)就可以将状态机用作一个字段。
- CurrentState属性只读。StateMachine.cs 本身并未明确设置该字段。然后,像 PlayerController 这样的外部对象就可以调用Initialize方法来设置默认状态。
- 每个状态对象都决定了调用TransitionTo方法改变当前活动状态的条件。在设置 StateMachine 实例时,你可以将任何必要的依赖关系(包括状态机本身)传递给每个状态。
在示例项目中,PlayerController 已包含对 StateMachine 的引用,因此只需传入一个播放器参数。
每个状态对象都将管理自己的内部逻辑,您可以根据需要创建任意多个状态来描述您的 GameObject 或组件。每种状态都有自己的实现IState 的类。根据SOLID原则,添加更多状态对之前创建的状态影响最小。
下面是IdleState 的示例。
与StateMachine.cs脚本类似,构造函数用于传递 PlayerController 对象。该播放器包含对状态机的引用以及更新逻辑所需的其他内容。IdleState监视字符控制器的速度或跳跃状态,然后适当调用状态机的TransitionTo方法。
同时查看示例项目,了解WalkState和JumpState的实现。每个状态都有自己的更新逻辑,而不是用一个大类来切换行为,这样它们就能独立运行。
在为对象设置内部逻辑时,状态模式可以帮助你遵守 SOLID 原则。每个状态相对较小,只跟踪过渡到另一个状态的条件。根据开放-封闭原则,您可以在不影响现有状态的情况下添加更多状态,并避免在单一脚本中使用繁琐的开关或if语句。
您还可以扩展其功能,将状态变化与外部对象进行通信。您可能需要添加事件(参见观察者模式)。进入或退出状态时发生的事件可以通知相关监听器,并让它们在运行时做出响应。
另一方面,如果您只需要跟踪几个州,那么额外的结构可能会显得多余。只有当你希望你的状态发展到一定的复杂程度时,这种模式才有意义。与其他设计模式一样,您需要根据特定游戏的需求来评估利弊。
更多有关 Unity 编程的高级资源
电子书 使用游戏编程模式提升代码水平提供了更多如何在 Unity 中使用设计模式的示例。