State design pattern

简介

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

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

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

  • Enums can be used in conjunction with a match statement.

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

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

注解

There is a great resource explaining the concept of the state design pattern here: https://gameprogrammingpatterns.com/state.html

Script setup

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

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

# 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")

注解

由于``Run``和``Idle``状态是从``State``延伸出来的,而``State``又是``Node2D``的延伸,所以函数``_physics_process(delta)```是从**底向上**调用的,也就是说``Run``和``Idle``将调用它们的实现``_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 脚本包含检测输入的代码。这是为了使教程简单化,但通常这样做不是最好的做法。

项目设置

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

../../_images/llama_run.gif

注解

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

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

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

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