状态设计模式

简介

当有许多状态需要处理, 但一次只能将一个脚本附加到一个节点上时, 编写游戏脚本是很困难的. 与其在玩家的控制脚本中创建一个状态机, 不如将状态分离出来, 分成不同的类, 这样会使开发更加简单.

用Godot实现状态机的方法有很多, 下面是一些其他方法:

  • 玩家的每一个状态都可以有一个子节点, 在使用时会被调用.

  • Enums可以与匹配语句一起使用.

  • 状态脚本本身可以在运行时动态地从一个节点上换掉.

本教程将只专注于添加和删除附加有状态脚本的节点. 每个状态脚本将是不同状态的实现.

注解

这里有一个很好的资源来解释状态设计模式的概念 : https://gameprogrammingpatterns.com/state.html

脚本设置

继承的特性对于开始使用这个设计原则是很有用的. 应该创建一个类来描述玩家的基本功能. 现在, 一个玩家将被限制为两个动作. 向左移动 , 向右移动 . 这意味着将有两种状态. 闲置运行 .

下面是通用状态, 所有其他状态都将从该状态继承.

# state.gd

extends Node2D

class_name State

var change_state
var animated_sprite
var persistent_state
var velocity = 0

# Writing _delta instead of delta here prevents the unused variable warning.
func _physics_process(_delta):
    persistent_state.move_and_slide(persistent_state.velocity, Vector2.UP)

func setup(change_state, animated_sprite, persistent_state):
    self.change_state = change_state
    self.animated_sprite = animated_sprite
    self.persistent_state = persistent_state

func move_left():
    pass

func move_right():
    pass

对上面的脚本做一些说明. 首先, 这个实现使用了一个``setup(change_state, animated_sprite, persistent_state)``方法来分配引用. 这些引用将在这个状态的父体中被实例化. 这有助于在编程中被称为*内聚的东西. 玩家的状态不希望承担创建这些变量的责任, 但确实希望能够使用它们. 然而, 这确实使状态与状态的父体*耦合. 这意味着, 状态高度依赖于它是否有一个包含这些变量的父体. 所以, 请记住, 当涉及到代码管理时,*耦合* 和 内聚 是重要的概念.

注解

有关内聚力和耦合的更多详情, 请参见以下网页:https://courses.cs.washington.edu/courses/cse403/96sp/coupling-cohesion.html

其次, 脚本中还有一些移动的方法, 但没有实现. 这一点很重要.

第三, 这里实际上实现了 _physics_process(delta) 方法. 这使得状态可以有一个默认的 _physics_process(delta) 实现, 其中 velocity 用于移动玩家. 状态可以修改玩家移动的方法是使用定义在其基类中的 velocity 变量.

最后, 这个脚本实际上被指定为一个名为 State 的类. 这使得重构代码变得更容易, 因为在Godot中使用 load()preload() 函数的文件路径将不再需要.

所以, 现在有了基础状态, 前面讨论的两种状态就可以实现了.

# idle_state.gd

extends State

class_name IdleState

func _ready():
    animated_sprite.play("idle")

func _flip_direction():
    animated_sprite.flip_h = not animated_sprite.flip_h

func move_left():
    if animated_sprite.flip_h:
        change_state.call_func("run")
    else:
        _flip_direction()

func move_right():
    if not animated_sprite.flip_h:
        change_state.call_func("run")
    else:
        _flip_direction()
# run_state.gd

extends State

class_name RunState

var move_speed = Vector2(180, 0)
var min_move_speed = 0.005
var friction = 0.32

func _ready():
    animated_sprite.play("run")
    if animated_sprite.flip_h:
        move_speed.x *= -1
    persistent_state.velocity += move_speed

func _physics_process(_delta):
    if abs(persistent_state.velocity.x) < min_move_speed:
         change_state.call_func("idle")
    persistent_state.velocity.x *= friction

func move_left():
    if animated_sprite.flip_h:
        persistent_state.velocity += move_speed
    else:
        change_state.call_func("idle")

func move_right():
    if not animated_sprite.flip_h:
        persistent_state.velocity += move_speed
    else:
        change_state.call_func("idle")

注解

由于 RunIdle 状态是从 State 延伸出来的, 而 State 又是 Node2D 的延伸, 所以函数 _physics_process(delta) 是从 底向上 调用的, 也就是说 RunIdle 将调用它们的实现 _physics_process(delta) . 然后 State 将调用它的实现, 然后 Node2D 将调用它自己的实现, 以此类推. 这可能看起来很奇怪, 但它只与预定义函数有关, 如 _ready() , _process(delta) 等. 自定义函数使用正常的继承规则, 即覆盖基础实现.

有一种迂回的方法可以获得一个状态实例. 可以使用状态工厂.

# state_factory.gd

class_name StateFactory

var states

func _init():
    states = {
        "idle": IdleState,
        "run": RunState
}

func get_state(state_name):
    if states.has(state_name):
        return states.get(state_name)
    else:
        printerr("No state ", state_name, " in state factory!")

这将在字典中查找状态, 如果找到则返回状态.

现在, 所有的状态都用自己的脚本定义了, 现在是时候弄清楚如何实例化那些传递给它们的引用了. 由于这些引用不会改变, 所以调用这个新脚本 persistent_state.gd 是有意义的.

# persistent_state.gd

extends KinematicBody2D

class_name PersistentState

var state
var state_factory

var velocity = Vector2()

func _ready():
    state_factory = StateFactory.new()
    change_state("idle")

# Input code was placed here for tutorial purposes.
func _process(_delta):
    if Input.is_action_pressed("ui_left"):
        move_left()
    elif Input.is_action_pressed("ui_right"):
        move_right()

func move_left():
    state.move_left()

func move_right():
    state.move_right()

func change_state(new_state_name):
    if state != null:
        state.queue_free()
    state = state_factory.get_state(new_state_name).new()
    state.setup(funcref(self, "change_state"), $AnimatedSprite, self)
    state.name = "current_state"
    add_child(state)

注解

persistent_state.gd 脚本包含检测输入的代码. 这是为了使教程简单化, 但通常这样做不是最好的做法.

项目设置

本教程做了一个假设, 即它要连接的节点包含一个子节点, 这个子节点是一个 AnimatedSprite. 还有一个假设是, 这个 AnimatedSprite 至少有两个动画, 即空闲和运行动画. 另外, 还假设顶层节点是一个 KinematicBody2D.

../../_images/llama_run.gif

注解

本教程中使用的骆驼的压缩文件是: 下载: here <files/llama.zip> . 源自 piskel_llama , 但我在那个页面上找不到原创作者的信息...... 还有一个好的精灵动画教程已经有了. 参见 2D 精灵动画 .

所以, 唯一必须附加的脚本是 persistent_state.gd , 它应该附加在玩家的顶部节点上, 这是一个 KinematicBody2D .

../../_images/state_design_node_setup.png ../../_images/state_design_complete.gif

现在玩家已经利用状态设计模式实现了它的两种不同的状态. 这种模式的好处是, 如果想要添加另一个状态, 那么就需要创建另一个类, 而这个类只需要关注自己以及如何变化到另一个状态. 每个状态在功能上是分离的, 并且是动态实例化的.