第八部分:状态机 State Machine:让 Player.gd 不再变成一锅粥

2026-05-19
401914 分钟
...

状态机 State Machine,这一部分很重要

你现在的 Player.gd 里已经开始有这些东西了:

移动
动画
交互
对话时锁定控制
附近交互物检测

接下来还会继续加:

攻击
翻滚 / 冲刺
受伤
死亡
采集
钓鱼
开宝箱
释放卡牌技能
骑马 / 上载具

如果所有逻辑都写在一个 _physics_process() 里,很快就会变成这样:

func _physics_process(delta):
    if is_dead:
        ...
    elif is_hurt:
        ...
    elif is_attacking:
        ...
    elif is_talking:
        ...
    elif is_dashing:
        ...
    elif is_moving:
        ...
    else:
        ...

这还只是开始,后面再加动画、音效、冷却、输入锁定,代码会逐渐变成野生藤蔓,哪哪都缠着。

状态机就是为了解决这个问题。

1. 状态机是什么

状态机,全称通常叫:

Finite State Machine

也就是有限状态机。

简单理解:

一个对象在同一时间只处于一种主要状态
每种状态有自己的行为
状态之间可以切换

比如玩家可以有这些状态:

Idle站立
Walk行走
Attack攻击
Dialogue对话
Hurt受伤
Dead死亡

同一时间,玩家通常不会既在攻击、又在死亡、又在对话。

所以可以规定:

当前状态是 Idle就只执行 Idle 逻辑
当前状态是 Walk就只执行 Walk 逻辑
当前状态是 Attack就只执行 Attack 逻辑

GDQuest 对有限状态机的解释也很适合游戏开发:它把代码拆成多个状态,每个状态有自己的行为,并且机器同一时间只能处于一个状态。状态机会接收输入或事件,然后按规则切换到另一个状态。(GDQuest)

2. 为什么 Player 特别需要状态机

因为玩家是游戏里最容易膨胀的对象。

它既要响应输入,又要播放动画,又要处理物理移动,还要和地图、NPC、敌人、UI 互动。

不用状态机时,你会写很多布尔变量:

var can_move = true
var is_attacking = false
var is_hurt = false
var is_dead = false
var is_talking = false
var is_dashing = false

然后到处判断:

if not can_move:
    return

if is_attacking:
    return

if is_hurt:
    return

if is_talking:
    return

这会越来越乱。

状态机的目标是把这些“布尔地狱”变成更清楚的状态:

enum PlayerState {
    IDLE,
    WALK,
    ATTACK,
    DIALOGUE,
    HURT,
    DEAD
}

var current_state: PlayerState = PlayerState.IDLE

这样你只关心:

当前玩家处于什么状态
这个状态允许做什么
这个状态什么时候切换到下一个状态

3. 状态机解决的核心问题

状态机主要解决三个问题:

1. 当前该执行哪一段逻辑
2. 当前能不能响应某个输入
3. 当前应该播放什么动画

比如:

Idle 状态
可以移动
可以攻击
可以交互

Walk 状态
可以继续移动
可以攻击
可以交互

Attack 状态
不能移动
不能交互
攻击动画结束后回到 Idle

Dialogue 状态
不能移动
不能攻击
只能按 E 继续对话

Hurt 状态
不能移动
不能攻击
受伤动画结束后回到 Idle

Dead 状态
什么都不能做

这样逻辑就清楚很多。

4. 状态机和动画状态机不是一回事

这里很容易混。

Godot 里 AnimationTree 有自己的 AnimationNodeStateMachine。官方文档说明,AnimationNodeStateMachine 是给 AnimationTree 使用的动画状态机,它包含多个动画状态节点,并且可以通过图上的连接进行自动或代码控制的动画切换。(Godot Engine documentation)

但我们现在讲的是:

代码逻辑状态机

不是动画树里的状态机。

区别:

代码状态机
管理玩家行为比如能不能移动能不能攻击能不能交互

动画状态机
管理动画怎么从 idle 切到 walk再切到 attack

它们可以配合,但不是同一个东西。

前期建议你先做代码状态机。

动画仍然先用:

animated_sprite.play("walk_down")

不要一开始就上 AnimationTree,否则你会同时被“代码状态”和“动画状态”双打,脑袋直接冒烟。

5. 最简单的状态机:enum + match

Godot 里最简单的状态机写法是:

enum PlayerState {
    IDLE,
    WALK,
    ATTACK,
    DIALOGUE,
    HURT,
    DEAD
}

var current_state: PlayerState = PlayerState.IDLE

然后在 _physics_process() 里:

func _physics_process(delta):
    match current_state:
        PlayerState.IDLE:
            physics_idle(delta)

        PlayerState.WALK:
            physics_walk(delta)

        PlayerState.ATTACK:
            physics_attack(delta)

        PlayerState.DIALOGUE:
            physics_dialogue(delta)

        PlayerState.HURT:
            physics_hurt(delta)

        PlayerState.DEAD:
            physics_dead(delta)

这样每个状态都有自己的函数。

这就比一个超长 _physics_process() 舒服很多。

6. match 是什么

match 有点像其他语言里的 switch

比如:

match current_state:
    PlayerState.IDLE:
        print("站立")

    PlayerState.WALK:
        print("行走")

    PlayerState.ATTACK:
        print("攻击")

前端类比:

switch (currentState) {
  case 'idle':
    break
  case 'walk':
    break
  case 'attack':
    break
}

Godot 的 GDScript 里常用 match 来根据枚举值分发逻辑。

7. 状态切换函数 change_state()

不要到处直接写:

current_state = PlayerState.ATTACK

更推荐封装一个方法:

func change_state(next_state: PlayerState):
    if current_state == next_state:
        return

    exit_state(current_state)
    current_state = next_state
    enter_state(current_state)

这样每次切换状态时,都能统一执行:

离开旧状态时做什么
进入新状态时做什么

比如:

func enter_state(state: PlayerState):
    match state:
        PlayerState.IDLE:
            print("进入 Idle")

        PlayerState.ATTACK:
            print("进入 Attack")
            velocity = Vector2.ZERO

func exit_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            print("离开 Attack")

这个设计非常关键。

因为后面你会需要:

进入攻击状态停止移动播放攻击动画
离开攻击状态清理攻击 hitbox
进入对话状态锁定移动显示 UI
离开对话状态恢复移动
进入死亡状态停止输入播放死亡动画

都应该放在状态切换里统一管理。

8. 一个最小 Player 状态机

先看一个小版本。

extends CharacterBody2D

enum PlayerState {
    IDLE,
    WALK,
    ATTACK,
    DIALOGUE
}

@export var speed: float = 120.0

@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D

var current_state: PlayerState = PlayerState.IDLE
var last_direction: Vector2 = Vector2.DOWN

func _physics_process(delta):
    match current_state:
        PlayerState.IDLE:
            physics_idle(delta)

        PlayerState.WALK:
            physics_walk(delta)

        PlayerState.ATTACK:
            physics_attack(delta)

        PlayerState.DIALOGUE:
            physics_dialogue(delta)

func _unhandled_input(event):
    match current_state:
        PlayerState.IDLE, PlayerState.WALK:
            handle_normal_input(event)

        PlayerState.DIALOGUE:
            handle_dialogue_input(event)

func physics_idle(delta):
    var direction = get_input_direction()

    if direction != Vector2.ZERO:
        change_state(PlayerState.WALK)
        return

    velocity = Vector2.ZERO
    move_and_slide()
    update_animation(Vector2.ZERO)

func physics_walk(delta):
    var direction = get_input_direction()

    if direction == Vector2.ZERO:
        change_state(PlayerState.IDLE)
        return

    velocity = direction * speed
    move_and_slide()
    update_animation(direction)

func physics_attack(delta):
    velocity = Vector2.ZERO
    move_and_slide()

func physics_dialogue(delta):
    velocity = Vector2.ZERO
    move_and_slide()

func handle_normal_input(event):
    if event.is_action_pressed("attack"):
        change_state(PlayerState.ATTACK)
        return

    if event.is_action_pressed("interact"):
        try_interact()
        return

func handle_dialogue_input(event):
    pass

func get_input_direction() -> Vector2:
    return Input.get_vector(
        "move_left",
        "move_right",
        "move_up",
        "move_down"
    )

func change_state(next_state: PlayerState):
    if current_state == next_state:
        return

    exit_state(current_state)
    current_state = next_state
    enter_state(current_state)

func enter_state(state: PlayerState):
    match state:
        PlayerState.IDLE:
            update_animation(Vector2.ZERO)

        PlayerState.ATTACK:
            velocity = Vector2.ZERO
            animated_sprite.play("attack_" + get_direction_name(last_direction))

        PlayerState.DIALOGUE:
            velocity = Vector2.ZERO

func exit_state(state: PlayerState):
    pass

func update_animation(direction: Vector2):
    if direction == Vector2.ZERO:
        animated_sprite.play("idle_" + get_direction_name(last_direction))
        return

    last_direction = direction
    animated_sprite.play("walk_" + get_direction_name(direction))

func get_direction_name(direction: Vector2) -> String:
    if abs(direction.x) > abs(direction.y):
        return "right" if direction.x > 0 else "left"

    return "down" if direction.y > 0 else "up"

这个版本还不完整,但结构已经出来了。

重点不是代码多高级,而是它把逻辑分开了。

9. 状态分发的好处

以前你可能这样写:

func _physics_process(delta):
    if is_dialogue:
        return

    if is_attacking:
        velocity = Vector2.ZERO
        move_and_slide()
        return

    var direction = Input.get_vector(...)
    velocity = direction * speed
    move_and_slide()

状态多了之后会越来越乱。

现在变成:

match current_state:
    PlayerState.IDLE:
        physics_idle(delta)

    PlayerState.WALK:
        physics_walk(delta)

    PlayerState.ATTACK:
        physics_attack(delta)

    PlayerState.DIALOGUE:
        physics_dialogue(delta)

读起来非常清楚:

Idle 逻辑在 physics_idle
Walk 逻辑在 physics_walk
Attack 逻辑在 physics_attack
Dialogue 逻辑在 physics_dialogue

以后新增一个 HURT 状态,也不会到处乱插 if。

10. 状态机的三个核心函数

一个舒服的状态机一般有这几个函数:

func enter_state(state):
    pass

func update_state(delta):
    pass

func exit_state(state):
    pass

在我们当前写法里:

enter_state进入状态时执行一次
physics_xxx状态持续期间每个物理帧执行
exit_state离开状态时执行一次

例如攻击状态:

进入攻击状态
停止移动
播放攻击动画
打开攻击判定

攻击状态中
保持不能移动

离开攻击状态
关闭攻击判定
回到 Idle

这就比到处写 is_attacking 清楚很多。

11. Idle 状态

Idle 是站立状态。

特点:

可以接收移动输入
可以攻击
可以交互
播放 idle 动画
速度为 0

代码:

func physics_idle(delta):
    var direction = get_input_direction()

    if direction != Vector2.ZERO:
        change_state(PlayerState.WALK)
        return

    velocity = Vector2.ZERO
    move_and_slide()
    update_animation(Vector2.ZERO)

意思是:

如果有移动输入就切到 Walk
否则保持站立

12. Walk 状态

Walk 是行走状态。

特点:

持续读取方向输入
可以攻击
可以交互
播放 walk 动画
方向为空时回到 Idle

代码:

func physics_walk(delta):
    var direction = get_input_direction()

    if direction == Vector2.ZERO:
        change_state(PlayerState.IDLE)
        return

    velocity = direction * speed
    move_and_slide()
    update_animation(direction)

意思是:

只要还有方向输入就移动
方向没了就回到 Idle

13. Attack 状态

Attack 是攻击状态。

特点:

不能移动
不能交互
播放攻击动画
攻击动画结束后回到 Idle

先写基础版:

func enter_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            velocity = Vector2.ZERO
            animated_sprite.play("attack_" + get_direction_name(last_direction))

攻击中:

func physics_attack(delta):
    velocity = Vector2.ZERO
    move_and_slide()

那什么时候回到 Idle?

可以先简单用 Timer。

func enter_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            velocity = Vector2.ZERO
            animated_sprite.play("attack_" + get_direction_name(last_direction))
            await get_tree().create_timer(0.35).timeout

            if current_state == PlayerState.ATTACK:
                change_state(PlayerState.IDLE)

这个版本能跑,但有一点要注意:

await 写在 enter_state 里可以用
但状态切换多了之后要小心旧 await 回来影响新状态

所以这里加了判断:

if current_state == PlayerState.ATTACK:

防止攻击期间被打断后,旧 timer 又把状态切回 Idle。

14. 更好的攻击结束方式:监听动画结束

如果你用 AnimatedSprite2D,它有动画结束信号。

AnimatedSprite2D 可以播放 SpriteFrames 中的动画,并且有 animation_finished 等信号可用于知道动画何时播放完。(Godot Engine documentation)

可以在 _ready() 里连接:

func _ready():
    animated_sprite.animation_finished.connect(_on_animation_finished)

然后:

func _on_animation_finished():
    if current_state == PlayerState.ATTACK:
        change_state(PlayerState.IDLE)

这样攻击持续时间由动画本身决定。

比写死 0.35 秒更自然。

推荐这种。

15. Dialogue 状态

Dialogue 是对话状态。

特点:

不能移动
不能攻击
不能重复交互
等待 DialogueBox 处理输入
对话结束后回到 Idle

进入对话状态:

func enter_state(state: PlayerState):
    match state:
        PlayerState.DIALOGUE:
            velocity = Vector2.ZERO
            update_animation(Vector2.ZERO)

对话中:

func physics_dialogue(delta):
    velocity = Vector2.ZERO
    move_and_slide()

对话开始时,Player 可以切到 Dialogue:

func lock_control_for_dialogue():
    change_state(PlayerState.DIALOGUE)

对话结束时:

func unlock_control_from_dialogue():
    change_state(PlayerState.IDLE)

然后 UI 里可以这样调用:

func _on_dialogue_started():
    if player != null and player.has_method("lock_control_for_dialogue"):
        player.lock_control_for_dialogue()

func _on_dialogue_finished():
    if player != null and player.has_method("unlock_control_from_dialogue"):
        player.unlock_control_from_dialogue()

这样就不需要再用一堆 can_movecan_interact 乱飞了。

16. 状态机版本的 Player 控制锁

之前我们是:

var can_move = true
var can_interact = true

状态机之后,可以少用这些布尔变量。

因为:

Dialogue 状态天然不能移动不能攻击不能交互
Attack 状态天然不能移动不能交互
Dead 状态天然什么都不能做

也就是说,与其问:

can_move true
can_interact true
is_attacking false
is_dialogue false

不如问:

当前状态是不是允许移动的状态

例如:

func can_accept_normal_input() -> bool:
    return current_state == PlayerState.IDLE or current_state == PlayerState.WALK

这样就干净很多。

17. 输入也要根据状态分发

不推荐所有输入都直接响应。

比如:

func _unhandled_input(event):
    if event.is_action_pressed("attack"):
        change_state(PlayerState.ATTACK)

    if event.is_action_pressed("interact"):
        try_interact()

这会导致对话时也可能攻击、攻击时也可能开宝箱。

更推荐:

func _unhandled_input(event):
    match current_state:
        PlayerState.IDLE, PlayerState.WALK:
            handle_normal_input(event)

        PlayerState.ATTACK:
            handle_attack_input(event)

        PlayerState.DIALOGUE:
            handle_dialogue_input(event)

        PlayerState.HURT, PlayerState.DEAD:
            pass

然后:

func handle_normal_input(event):
    if event.is_action_pressed("attack"):
        change_state(PlayerState.ATTACK)
        return

    if event.is_action_pressed("interact"):
        try_interact()
        return

这样很清楚:

只有 Idle / Walk 状态能响应攻击和交互
Dialogue 状态不让 Player 响应这些输入

18. 状态切换规则

状态机最重要的不只是“当前状态是什么”,还有“哪些状态可以切到哪些状态”。

比如:

IdleWalk
WalkIdle
IdleAttack
WalkAttack
IdleDialogue
WalkDialogue
AttackIdle
DialogueIdle
HurtIdle
AnyDead

但有些切换应该禁止:

DeadWalk 不允许
DialogueAttack 不允许
AttackDialogue 不允许
HurtAttack 不允许

可以在 change_state() 里检查:

func can_change_to(next_state: PlayerState) -> bool:
    if current_state == PlayerState.DEAD:
        return false

    if current_state == PlayerState.DIALOGUE:
        return next_state == PlayerState.IDLE

    if current_state == PlayerState.ATTACK:
        return next_state == PlayerState.IDLE or next_state == PlayerState.HURT

    return true

然后:

func change_state(next_state: PlayerState):
    if current_state == next_state:
        return

    if not can_change_to(next_state):
        return

    exit_state(current_state)
    current_state = next_state
    enter_state(current_state)

这个会让状态切换更安全。

19. 先不要把状态机做得太复杂

状态机也有一个坑:

刚开始就过度设计

比如你一上来就拆成 20 个脚本:

IdleState.gd
WalkState.gd
AttackState.gd
HurtState.gd
DeadState.gd
DialogueState.gd
StateMachine.gd
PlayerState.gd
BaseState.gd

这对初学阶段会有点重。

你现在先用:

enum + match + change_state

就够了。

等状态真的多了,再拆成节点式状态机。

20. 两种常见状态机写法

Godot 里常见两种:

1. enum 简单状态机
2. 节点式状态机

enum 简单状态机:

所有状态逻辑还在 Player.gd
但用 match 分开
适合状态较少的角色

节点式状态机:

每个状态是一个独立脚本/节点
IdleState.gd Idle
WalkState.gd Walk
AttackState.gd Attack
适合状态很多的角色敌人 AI

GDQuest 的状态机教程也提到,既可以用简单变量、if / match 和函数实现 FSM,也可以用更复杂的节点式 FSM;不同写法适合不同复杂度。(GDQuest)

你现在建议先用简单状态机。

别急着一步登天,游戏开发里“先能跑,再变优雅”是活命法则。

21. 完整一点的 Player.gd 状态机版本

下面这份可以当作你当前阶段的参考模板。

extends CharacterBody2D

signal interactable_changed(interactable: Node)

enum PlayerState {
    IDLE,
    WALK,
    ATTACK,
    DIALOGUE,
    HURT,
    DEAD
}

@export var speed: float = 120.0

@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var interact_area: Area2D = $InteractArea

var current_state: PlayerState = PlayerState.IDLE
var last_direction: Vector2 = Vector2.DOWN

var nearby_interactables: Array[Node] = []
var current_interactable: Node = null

func _ready():
    interact_area.area_entered.connect(_on_interact_area_entered)
    interact_area.area_exited.connect(_on_interact_area_exited)
    animated_sprite.animation_finished.connect(_on_animation_finished)

func _physics_process(delta):
    match current_state:
        PlayerState.IDLE:
            physics_idle(delta)

        PlayerState.WALK:
            physics_walk(delta)

        PlayerState.ATTACK:
            physics_attack(delta)

        PlayerState.DIALOGUE:
            physics_dialogue(delta)

        PlayerState.HURT:
            physics_hurt(delta)

        PlayerState.DEAD:
            physics_dead(delta)

func _unhandled_input(event):
    match current_state:
        PlayerState.IDLE, PlayerState.WALK:
            handle_normal_input(event)

        PlayerState.DIALOGUE:
            pass

        PlayerState.ATTACK, PlayerState.HURT, PlayerState.DEAD:
            pass

func physics_idle(delta):
    var direction := get_input_direction()

    if direction != Vector2.ZERO:
        change_state(PlayerState.WALK)
        return

    velocity = Vector2.ZERO
    move_and_slide()
    update_animation(Vector2.ZERO)

func physics_walk(delta):
    var direction := get_input_direction()

    if direction == Vector2.ZERO:
        change_state(PlayerState.IDLE)
        return

    velocity = direction * speed
    move_and_slide()
    update_animation(direction)

func physics_attack(delta):
    velocity = Vector2.ZERO
    move_and_slide()

func physics_dialogue(delta):
    velocity = Vector2.ZERO
    move_and_slide()
    update_animation(Vector2.ZERO)

func physics_hurt(delta):
    velocity = Vector2.ZERO
    move_and_slide()

func physics_dead(delta):
    velocity = Vector2.ZERO
    move_and_slide()

func handle_normal_input(event):
    if event.is_action_pressed("attack"):
        change_state(PlayerState.ATTACK)
        return

    if event.is_action_pressed("interact"):
        try_interact()
        return

func get_input_direction() -> Vector2:
    return Input.get_vector(
        "move_left",
        "move_right",
        "move_up",
        "move_down"
    )

func try_interact():
    if current_interactable == null:
        return

    if current_interactable.has_method("interact"):
        current_interactable.interact(self)

func lock_control_for_dialogue():
    change_state(PlayerState.DIALOGUE)

func unlock_control_from_dialogue():
    change_state(PlayerState.IDLE)

func change_state(next_state: PlayerState):
    if current_state == next_state:
        return

    if not can_change_to(next_state):
        return

    exit_state(current_state)
    current_state = next_state
    enter_state(current_state)

func can_change_to(next_state: PlayerState) -> bool:
    if current_state == PlayerState.DEAD:
        return false

    if current_state == PlayerState.DIALOGUE:
        return next_state == PlayerState.IDLE

    if current_state == PlayerState.ATTACK:
        return next_state == PlayerState.IDLE or next_state == PlayerState.HURT or next_state == PlayerState.DEAD

    return true

func enter_state(state: PlayerState):
    match state:
        PlayerState.IDLE:
            update_animation(Vector2.ZERO)

        PlayerState.WALK:
            pass

        PlayerState.ATTACK:
            velocity = Vector2.ZERO
            animated_sprite.play("attack_" + get_direction_name(last_direction))

        PlayerState.DIALOGUE:
            velocity = Vector2.ZERO
            update_animation(Vector2.ZERO)

        PlayerState.HURT:
            velocity = Vector2.ZERO
            animated_sprite.play("hurt_" + get_direction_name(last_direction))

        PlayerState.DEAD:
            velocity = Vector2.ZERO
            animated_sprite.play("dead")

func exit_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            pass

        PlayerState.DIALOGUE:
            pass

func update_animation(direction: Vector2):
    if current_state == PlayerState.ATTACK:
        return

    if current_state == PlayerState.HURT:
        return

    if current_state == PlayerState.DEAD:
        return

    if direction == Vector2.ZERO:
        animated_sprite.play("idle_" + get_direction_name(last_direction))
        return

    last_direction = direction
    animated_sprite.play("walk_" + get_direction_name(direction))

func get_direction_name(direction: Vector2) -> String:
    if abs(direction.x) > abs(direction.y):
        return "right" if direction.x > 0 else "left"

    return "down" if direction.y > 0 else "up"

func _on_animation_finished():
    if current_state == PlayerState.ATTACK:
        change_state(PlayerState.IDLE)
        return

    if current_state == PlayerState.HURT:
        change_state(PlayerState.IDLE)
        return

func _on_interact_area_entered(area):
    if area.is_in_group("interactable"):
        nearby_interactables.append(area)
        update_current_interactable()

func _on_interact_area_exited(area):
    if area.is_in_group("interactable"):
        nearby_interactables.erase(area)
        update_current_interactable()

func update_current_interactable():
    current_interactable = null

    var nearest_distance := INF

    for interactable in nearby_interactables:
        if not is_instance_valid(interactable):
            continue

        var distance = global_position.distance_to(interactable.global_position)

        if distance < nearest_distance:
            nearest_distance = distance
            current_interactable = interactable

    interactable_changed.emit(current_interactable)

这份代码已经比前面的版本更像“真正项目里的 Player 控制器”了。

22. 这个版本解决了什么问题

它解决了:

移动和攻击不会混在一起
对话时不会移动
对话时不会攻击
攻击时不会交互
死亡后不会再乱切状态
动画结束后能回到 Idle
交互检测仍然保留

并且结构比较清楚:

physics_idle站立逻辑
physics_walk移动逻辑
physics_attack攻击逻辑
physics_dialogue对话逻辑
enter_state进入状态做一次性操作
exit_state离开状态做清理
change_state统一切换状态

23. 为什么 update_animation 里要判断状态

因为攻击、受伤、死亡动画不能被普通 idle/walk 动画覆盖。

如果你不判断,可能出现:

进入 Attack 状态
播放 attack_down
下一帧 _physics_process 又调用 update_animation(Vector2.ZERO)
立刻变回 idle_down
攻击动画根本看不到

所以:

func update_animation(direction: Vector2):
    if current_state == PlayerState.ATTACK:
        return

    if current_state == PlayerState.HURT:
        return

    if current_state == PlayerState.DEAD:
        return

意思是:

特殊状态下不要让普通移动动画接管

这个坑非常常见。

24. HURT 受伤状态

现在可以先简单预留。

受伤时:

func take_damage(amount: int):
    if current_state == PlayerState.DEAD:
        return

    hp -= amount

    if hp <= 0:
        change_state(PlayerState.DEAD)
        return

    change_state(PlayerState.HURT)

如果你现在还没做 hp,就先不用加。

但要知道受伤状态一般会:

停止移动
播放受伤动画
短暂无敌
击退
动画结束后回到 Idle

后面怪物攻击你时,会用到这个。

25. DEAD 死亡状态

死亡状态通常是终点状态。

特点:

不能移动
不能攻击
不能交互
不能再被普通状态覆盖
播放死亡动画
显示失败 UI 或重生

所以在 can_change_to() 里:

if current_state == PlayerState.DEAD:
    return false

意思是:

死了就不要再切回 Walk / Idle

除非你做复活系统,那可以专门写一个 revive() 方法。

26. Attack 状态和攻击判定

攻击不只是动画。

以后你还会加:

AttackArea
Hitbox
伤害数值
攻击方向
攻击冷却
攻击音效
击中特效

攻击状态进入时:

播放攻击动画
根据 last_direction 设置攻击范围位置
打开 hitbox

攻击状态离开时:

关闭 hitbox
清空已命中目标

这就是为什么 enter_state()exit_state() 很重要。

比如以后可以写:

func enter_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            velocity = Vector2.ZERO
            update_attack_area_position()
            attack_area.monitoring = true
            animated_sprite.play("attack_" + get_direction_name(last_direction))

func exit_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            attack_area.monitoring = false

这个后面讲战斗系统时会详细展开。

27. Dialogue 状态和 DialogueBox 的关系

建议关系是:

NPC interact(player)

DialogueBox.start_dialogue()

DialogueBox 发出 dialogue_started

UI 调用 player.lock_control_for_dialogue()

Player 切到 DIALOGUE 状态

对话结束:

DialogueBox 发出 dialogue_finished

UI 调用 player.unlock_control_from_dialogue()

Player 回到 IDLE

这样 Player 不直接控制对话框,对话框也不直接写 Player 移动逻辑。

它们通过信号和方法协作。

28. 状态机里的“状态”和“变量”不是敌人

用了状态机,不代表完全不能用布尔变量。

有些变量仍然需要:

var has_key: bool = false
var is_invincible: bool = false
var can_combo: bool = false
var opened: bool = false

状态机主要负责“当前主行为”。

而变量负责“附加信息”。

比如:

当前主状态HURT
附加变量is_invincible = true

不要为了“纯状态机”把所有东西都硬塞成状态。

否则你会得到这种怪物:

IDLE_WITH_KEY
IDLE_WITHOUT_KEY
WALK_INVINCIBLE
WALK_NOT_INVINCIBLE
ATTACK_CAN_COMBO
ATTACK_CANNOT_COMBO

这就过头了,属于状态机开始反噬人类。

29. 什么时候该新增状态

可以这样判断:

如果这个行为会持续一段时间
如果它会独占输入
如果它有自己的动画
如果它会禁止某些操作
如果它有明确开始和结束

那它适合成为状态。

比如适合做状态:

Attack
Dialogue
Hurt
Dead
Dash
CastSpell
Fishing
Mining
Cutscene

不一定适合做状态:

是否有钥匙
是否有任务
当前金币数量
当前装备武器
是否下雨
当前地图名字

这些更像普通数据。

30. 玩家状态和游戏全局状态

不要混。

玩家状态:

Idle
Walk
Attack
Dialogue
Hurt
Dead

游戏全局状态:

Exploring探索中
Paused暂停
Inventory打开背包
Cutscene剧情演出
Battle战斗中
Menu主菜单

以后你可能会有两个状态机:

PlayerStateMachine管理玩家动作
GameStateMachine管理游戏整体模式

比如打开背包时:

GameState = Inventory
PlayerState = Idle Locked

不要把所有状态都塞进 Player。

否则 Player 会变成整个游戏宇宙的中枢神经,听着很酷,维护起来很惨。

31. 状态机和前端开发的类比

你是前端开发,可以这样类比:

状态机更严格的 UI 状态管理

比如一个按钮可能有:

normal
hover
loading
disabled
success
error

你不会希望它同时是:

loading + success + disabled + error

所以通常会设计一个主状态:

type ButtonState = 'normal' | 'loading' | 'success' | 'error'

Godot 里的玩家也是一样:

enum PlayerState {
    IDLE,
    WALK,
    ATTACK,
    DIALOGUE,
    HURT,
    DEAD
}

本质都是:

用一个明确状态代替一堆互相打架的布尔值

32. 节点式状态机是什么

等你状态很多后,可以把每个状态拆成节点。

结构可能是:

Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── InteractArea
└── StateMachine Node
    ├── IdleState Node
    ├── WalkState Node
    ├── AttackState Node
    ├── HurtState Node
    └── DeadState Node

每个状态一个脚本:

IdleState.gd
WalkState.gd
AttackState.gd

它们都有类似方法:

func enter():
    pass

func physics_update(delta):
    pass

func exit():
    pass

这样 Player.gd 会更轻。

但这个版本更抽象,不适合你现在刚开始地图、交互、对话阶段就上。

先用 enum 版,真的够用。

33. AnimationTree 的状态机什么时候用

等你动画变复杂时,可以考虑 AnimationTree

比如你有:

idle_up/down/left/right
walk_up/down/left/right
attack_up/down/left/right
hurt
dead

再加:

不同武器
不同速度
混合动画
转身动画
攻击连招

这时 AnimationTree 和它的动画状态机就有价值了。

Godot 官方文档里提到,AnimationTree 的 root 可以使用多种节点,包括 AnimationNodeStateMachineBlendSpace1DBlendSpace2DBlendTree 等,用来组织和混合动画。(Godot Engine documentation)

但现在你先别急。

现在你用:

AnimatedSprite2D.play("walk_down")

足够。

等角色动画真的复杂了,再学 AnimationTree。

34. 当前阶段推荐状态

你现在建议先做这些状态:

IDLE
WALK
DIALOGUE

然后再加:

ATTACK
HURT
DEAD

顺序建议:

1. IDLE / WALK
2. DIALOGUE
3. ATTACK
4. HURT
5. DEAD

不要一次性全上。

你可以先让状态机只管理移动和对话。

等稳定后,再把攻击接进去。

35. 最小状态机练习

先做一个最小版本:

enum PlayerState {
    IDLE,
    WALK,
    DIALOGUE
}

实现:

站着不动是 IDLE
按方向键切到 WALK
松开方向键回到 IDLE
对话开始切到 DIALOGUE
对话结束回到 IDLE
DIALOGUE 状态不能移动

这一步很重要。

你先不要急着加攻击。

先确认状态切换是对的。

可以在 change_state() 里打印:

func change_state(next_state: PlayerState):
    if current_state == next_state:
        return

    print("State: ", PlayerState.keys()[current_state], " -> ", PlayerState.keys()[next_state])

    exit_state(current_state)
    current_state = next_state
    enter_state(current_state)

这样你能在 Output 里看到:

State: IDLE -> WALK
State: WALK -> IDLE
State: IDLE -> DIALOGUE
State: DIALOGUE -> IDLE

这个调试非常有用。

36. 常见错误 1:状态一直来回切

比如你写:

func physics_idle(delta):
    change_state(PlayerState.WALK)

没有条件,就会每帧切过去。

或者 Walk 里又立刻切回 Idle。

所以状态切换一定要有条件:

if direction != Vector2.ZERO:
    change_state(PlayerState.WALK)
if direction == Vector2.ZERO:
    change_state(PlayerState.IDLE)

37. 常见错误 2:攻击动画被 idle 覆盖

前面说过,这是因为 update_animation() 没判断当前状态。

解决:

func update_animation(direction: Vector2):
    if current_state == PlayerState.ATTACK:
        return

    if current_state == PlayerState.HURT:
        return

    if current_state == PlayerState.DEAD:
        return

特殊状态动画不要被普通移动动画覆盖。

38. 常见错误 3:对话结束后还不能动

如果用状态机,检查:

DialogueBox 有没有发 dialogue_finished
UI 有没有调用 unlock_control_from_dialogue
Player 是否允许 DIALOGUE -> IDLE
can_change_to 里有没有写错

尤其是:

if current_state == PlayerState.DIALOGUE:
    return next_state == PlayerState.IDLE

如果这里写错,对话就回不去了。

39. 常见错误 4:死亡后还能移动

检查:

DEAD 状态有没有禁止切换
_physics_process 有没有 physics_dead
_unhandled_input DEAD 时有没有 pass

死亡状态通常应该拦住所有普通输入。

if current_state == PlayerState.DEAD:
    return false

40. 常见错误 5:状态枚举顺序变了,存档出问题

如果你以后要存档,不建议直接存枚举数字。

比如:

save_data["player_state"] = current_state

因为枚举背后是数字。

以后你调整顺序:

enum PlayerState {
    IDLE,
    WALK,
    DASH,
    ATTACK
}

旧存档可能就错位。

更推荐存字符串:

save_data["player_state"] = PlayerState.keys()[current_state]

不过 Player 当前状态通常不一定需要存档。

这属于后面存档系统内容。

41. 这一部分最重要的记忆点

状态机用一个明确状态代替一堆互相打架的 bool
玩家同一时间只处于一个主要状态
Idle / Walk / Attack / Dialogue / Hurt / Dead 都适合做状态
状态切换最好统一走 change_state()
进入状态用 enter_state()
离开状态用 exit_state()
每个状态自己的持续逻辑写成 physics_xxx()
输入也要根据状态分发
特殊动画不要被普通 idle/walk 动画覆盖
前期用 enum + match 就够不要一上来节点式状态机

42. 你现在的小练习

给 Player 加一个简单状态机:

1. 创建 enum PlayerState
2. 添加 IDLE、WALK、DIALOGUE
3. current_state 默认 IDLE
4. _physics_process 用 match 分发
5. IDLE 检测方向,有方向切 WALK
6. WALK 没方向切 IDLE
7. 对话开始切 DIALOGUE
8. 对话结束切 IDLE
9. DIALOGUE 状态下不能移动
10. change_state 里打印状态变化

做完这个之后,你的 Player.gd 会从“功能堆叠”变成“结构化控制”。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
godot2026-07-27
Godot 4 常用 UI 节点详解
Godot 4 常用 UI 节点详解 在 Godot 4 中,UI 系统基于 Control(控件) 节点构建。所有 UI 节点都继承自 Control,形成一棵完整的 UI 树。与游戏引擎中常见的"Canvas + DOM"模式不同,Godot 的 UI 系统是声明式的——你在场景中搭好节点树,引擎自动完成布局计算。 一、布局系统:Container 家族 Container 是 Godot UI 的 骨架 。它决定了子节点的大小和位...
学习
godot2026-07-09
Godot 4 自动地形系统(AutoTileSet / Terrains)完全指南
前言 在 2D 游戏开发中,地形瓦片(tile)的拼接是一个绕不开的问题。想象一下:你有一片草地、一条河流、一段平台——如果每一块边缘、角落、过渡区域都要手动选择对应的瓦片图,工作量将是巨大的。 自动地形系统 就是为了解决这个问题而生的。 一、什么是自动地形(Autotiling)? 自动地形的核心思想很简单: 你只管画,引擎帮你选对瓦片 。 当你在 TileMap 上绘制地形时,引擎会自动检测每个瓦片的上下左右邻居,然后根据预设的规则...
学习
godot2026-06-25
Godot 中 zindex 和 ysort 的区别总结
在 Godot 2D 游戏开发中,角色、树木、怪物、地面、技能特效、UI 都需要正确的显示顺序。比如角色走到树前面时,角色应该挡住树;角色走到树后面时,树又应该挡住角色。 这种显示顺序主要和两个概念有关: 和 。 其中 用来手动控制图层顺序, 用来根据物体的 Y 坐标自动排序。 一句话理解 是手动分层。 是根据 Y 坐标自动排序。 简单来说: | 属性 | 作用 | 适合场景 | | | | | | | 数值越大,显示越靠前 | 地面、...
学习
godot2026-06-11
Tileset 资源图的标准和规范
一、什么是 Tileset 资源图 Tileset,中文通常叫“图块资源图”或“瓦片图”,是 2D 游戏中非常常见的一种地图资源组织方式。 简单来说,Tileset 就是把很多小图块按照固定尺寸排列在一张图片里。游戏引擎会按照固定的格子大小去切割这张图片,然后把每个小格子当成一个独立的地图块使用。 比如一个 32×32 像素的 Tileset 中,每一个 tile 都是 32×32 像素。地图编辑器或游戏引擎会按照 32×32 的网格,...
学习
godot2026-05-29
用户角色精灵图制作角色的完整流程
在 2D RPG 游戏里,角色通常不是用一张单独图片完成的,而是用一张“角色精灵图”来做。 所谓角色精灵图,通常是一张包含多个动作帧的大图。比如角色向下走有 4 帧,向左走有 4 帧,向右走有 4 帧,向上走有 4 帧。Godot 会根据这些帧不断切换图片,看起来角色就动起来了。 这篇文章主要介绍:拿到一张角色精灵图之后,如何在 Godot 中把它做成一个可以正常移动、播放动画、和地图产生遮挡关系的角色。 一、先理解角色精灵图是什么 角...
学习
godot2026-05-26
Godot 节点系统详细介绍
Godot 里最核心的东西不是“类”,也不是“组件”,而是 节点 Node 。 你可以把 Godot 的节点理解成: 在前端里,一个页面是由很多 DOM 元素组成的; 在 Godot 里,一个游戏场景是由很多 Node 节点组成的。 比如一个玩家角色,可能不是一个单独对象,而是这样的结构: 这里的 是根节点,下面挂着显示图片、播放动画、碰撞检测、摄像机、音效等子节点。 Godot 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...