状态设计模式

简介

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

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

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

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

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

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

注解

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

脚本设置

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

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

GDScript

  1. # state.gd
  2. extends Node2D
  3. class_name State
  4. var change_state
  5. var animated_sprite
  6. var persistent_state
  7. var velocity = 0
  8. # Writing _delta instead of delta here prevents the unused variable warning.
  9. func _physics_process(_delta):
  10. persistent_state.move_and_slide(persistent_state.velocity, Vector2.UP)
  11. func setup(change_state, animated_sprite, persistent_state):
  12. self.change_state = change_state
  13. self.animated_sprite = animated_sprite
  14. self.persistent_state = persistent_state
  15. func move_left():
  16. pass
  17. func move_right():
  18. 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() 函数的文件路径将不再需要.

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

GDScript

  1. # idle_state.gd
  2. extends State
  3. class_name IdleState
  4. func _ready():
  5. animated_sprite.play("idle")
  6. func _flip_direction():
  7. animated_sprite.flip_h = not animated_sprite.flip_h
  8. func move_left():
  9. if animated_sprite.flip_h:
  10. change_state.call_func("run")
  11. else:
  12. _flip_direction()
  13. func move_right():
  14. if not animated_sprite.flip_h:
  15. change_state.call_func("run")
  16. else:
  17. _flip_direction()

GDScript

  1. # run_state.gd
  2. extends State
  3. class_name RunState
  4. var move_speed = Vector2(180, 0)
  5. var min_move_speed = 0.005
  6. var friction = 0.32
  7. func _ready():
  8. animated_sprite.play("run")
  9. if animated_sprite.flip_h:
  10. move_speed.x *= -1
  11. persistent_state.velocity += move_speed
  12. func _physics_process(_delta):
  13. if abs(persistent_state.velocity.x) < min_move_speed:
  14. change_state.call_func("idle")
  15. persistent_state.velocity.x *= friction
  16. func move_left():
  17. if animated_sprite.flip_h:
  18. persistent_state.velocity += move_speed
  19. else:
  20. change_state.call_func("idle")
  21. func move_right():
  22. if not animated_sprite.flip_h:
  23. persistent_state.velocity += move_speed
  24. else:
  25. change_state.call_func("idle")

注解

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

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

GDScript

  1. # state_factory.gd
  2. class_name StateFactory
  3. var states
  4. func _init():
  5. states = {
  6. "idle": IdleState,
  7. "run": RunState
  8. }
  9. func get_state(state_name):
  10. if states.has(state_name):
  11. return states.get(state_name)
  12. else:
  13. printerr("No state ", state_name, " in state factory!")

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

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

GDScript

  1. # persistent_state.gd
  2. extends KinematicBody2D
  3. class_name PersistentState
  4. var state
  5. var state_factory
  6. var velocity = Vector2()
  7. func _ready():
  8. state_factory = StateFactory.new()
  9. change_state("idle")
  10. # Input code was placed here for tutorial purposes.
  11. func _process(_delta):
  12. if Input.is_action_pressed("ui_left"):
  13. move_left()
  14. elif Input.is_action_pressed("ui_right"):
  15. move_right()
  16. func move_left():
  17. state.move_left()
  18. func move_right():
  19. state.move_right()
  20. func change_state(new_state_name):
  21. if state != null:
  22. state.queue_free()
  23. state = state_factory.get_state(new_state_name).new()
  24. state.setup(funcref(self, "change_state"), $AnimatedSprite, self)
  25. state.name = "current_state"
  26. add_child(state)

注解

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

项目设置

本教程做了一个假设,即它要连接的节点包含一个子节点,这个子节点是一个 AnimatedSprite.还有一个假设是,这个 AnimatedSprite.

../../_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

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