人物移动(Godot 4.x):CharacterBody2D + AnimatedSprite2D + 状态机(Idle/Walk)

目标:

  1. CharacterBody2D 实现 2D 角色移动
  2. AnimatedSprite2D 播放四方向 idle / walk
  3. 用一个轻量 状态机(State Machine) 管理 Idle ↔ Walk 切换
  4. 解决常见坑:停止移动后朝向丢失输入判定耦合/重复状态里 if-else 爆炸

1. 背景:为什么推荐 CharacterBody2D?

在 Godot 4.x 里,CharacterBody2D 是“专门为角色移动设计”的节点,优势是:

  • 自带 velocity(速度向量)
  • 自带 move_and_slide()(处理滑动/碰撞)
  • 把“角色移动”从普通 Node2D 抽象成更清晰的角色模型

对于像素 RPG/种田游戏这类四方向移动,它是最常用的底座。

image-20260205215707872
image-20260205215707872


2. 核心原理:我们真正要解决的其实是三件事

2.1 输入(Input) → 方向(Vector2)

玩家按键最终要变成一个方向向量:

  • 左:Vector2.LEFT
  • 右:Vector2.RIGHT
  • 上:Vector2.UP
  • 下:Vector2.DOWN
  • 没按:Vector2.ZERO

image-20260205221239184
image-20260205221239184

2.2 方向(Direction) → 动画(Animation)

  • Walk 状态:播放 walk_left / walk_right / walk_front / walk_back
  • Idle 状态:播放 idle_left / idle_right / idle_front / idle_back
  • 关键点:停止移动后,Idle 动画要沿用“最后一次移动的方向”。

2.3 状态(State) → 行为(Behavior)

Idle、Walk、Attack… 每个状态负责自己那部分逻辑 状态机负责切换与调度。

image-20260205215910114
image-20260205215910114

image-20260205220228053
image-20260205220228053


3. 推荐结构:状态机怎么“像人一样思考”?

状态即对象(每个状态一个脚本) + 状态机做调度器

建议用这个心智模型:

  • NodeState:定义统一接口(enter/exit/physics/transition check)
  • NodeStateMachine:保存当前状态、负责切换、在 _physics_process 里驱动当前状态

4. 代码编写


4.1 Player.gd:保存最后朝向

Player.gd
# 目的:扩展 CharacterBody2D,让角色保存“最后一次移动方向”
class_name Player
extends CharacterBody2D
# 角色最后一次的移动方向(Idle 时用它决定朝向动画)
var player_direction: Vector2 = Vector2.DOWN

默认朝下是个常见选择(也可以默认 UP/RIGHT)。


4.2 GameInput.gd:输入工具(建议放 autoload 或工具类)

GameInput.gd
# 目的:统一输入读取,避免每个状态里写一堆 if-else
class_name GameInput
extends Object
static func movement_input() -> Vector2:
# Input.get_vector(左,右,上,下) 会返回一个标准化方向向量
# 注意:需要你在 Input Map 里配置好这四个 action
# walk_left / walk_right / walk_up / walk_down
return Input.get_vector("walk_left", "walk_right", "walk_up", "walk_down")
static func is_moving(dir: Vector2) -> bool:
return dir != Vector2.ZERO

4.3 NodeState.gd:状态基类(你原来的可以继续用,我加了注释)

NodeState.gd
class_name NodeState
extends Node
# 状态切换信号:发出目标状态名(如 "walk" / "idle")
signal transition(target_state_name: String)
func _on_enter() -> void:
pass
func _on_exit() -> void:
pass
func _on_process(_delta: float) -> void:
pass
func _on_physics_process(_delta: float) -> void:
pass
func _on_next_transitions() -> void:
pass

4.4 NodeStateMachine.gd:状态机(你原版 OK,这里给一个小优化版)

NodeStateMachine.gd
class_name NodeStateMachine
extends Node
@export var initial_node_state: NodeState
var node_states: Dictionary = {}
var current: NodeState
func _ready() -> void:
# 自动收集子节点里的所有 NodeState
for child in get_children():
if child is NodeState:
node_states[child.name.to_lower()] = child
child.transition.connect(_transition_to)
# 初始化进入初始状态
if initial_node_state:
current = initial_node_state
current._on_enter()
func _process(delta: float) -> void:
if current:
current._on_process(delta)
func _physics_process(delta: float) -> void:
if current:
current._on_physics_process(delta)
current._on_next_transitions()
func _transition_to(state_name: String) -> void:
var key := state_name.to_lower()
if current and key == current.name.to_lower():
return
var next_state: NodeState = node_states.get(key)
if next_state == null:
return
if current:
current._on_exit()
current = next_state
current._on_enter()

4.5 WalkState.gd:移动 + 播 walk 动画 + 记录最后方向

需要绑定,不然会报错

image-20260209223019213
image-20260209223019213

WalkState.gd
extends NodeState
@export var player: Player
@export var sprite: AnimatedSprite2D
@export var speed: float = 50.0
var direction: Vector2 = Vector2.ZERO
func _on_enter() -> void:
# 进入 Walk 时可以不做事,动画在 physics 中根据方向播放
pass
func _on_physics_process(_delta: float) -> void:
# 1) 读取输入方向
direction = GameInput.movement_input()
# 2) 根据方向播放 walk 动画
_play_walk_animation(direction)
# 3) 更新最后朝向(用于 Idle)
if direction != Vector2.ZERO:
player.player_direction = direction
# 4) 真正移动:速度 = 方向 * speed
player.velocity = direction * speed
player.move_and_slide()
func _on_next_transitions() -> void:
# 如果没有输入了,切回 Idle
if direction == Vector2.ZERO:
transition.emit("idle")
func _on_exit() -> void:
# 离开 Walk 可选择停止动画,避免残留
sprite.stop()
func _play_walk_animation(dir: Vector2) -> void:
# 说明:只处理四方向,避免斜向导致动画混乱
if dir == Vector2.LEFT:
sprite.play("walk_left")
elif dir == Vector2.RIGHT:
sprite.play("walk_right")
elif dir == Vector2.DOWN:
sprite.play("walk_front")
else:
# dir == Vector2.UP
sprite.play("walk_back")

4.6 IdleState.gd:不移动 + 播 idle 动画(使用最后朝向)

IdleState.gd
extends NodeState
@export var player: Player
@export var sprite: AnimatedSprite2D
var direction: Vector2 = Vector2.ZERO
func _on_physics_process(_delta: float) -> void:
# Idle 永远用“最后朝向”播放动画
_play_idle_animation(player.player_direction)
func _on_next_transitions() -> void:
# 读取输入:如果开始移动,切到 Walk
direction = GameInput.movement_input()
if direction != Vector2.ZERO:
transition.emit("walk")
func _on_exit() -> void:
sprite.stop()
func _play_idle_animation(dir: Vector2) -> void:
if dir == Vector2.LEFT:
sprite.play("idle_left")
elif dir == Vector2.RIGHT:
sprite.play("idle_right")
elif dir == Vector2.DOWN:
sprite.play("idle_front")
else:
sprite.play("idle_back")

这样 Idle 动画永远会沿用最后方向,不会出现“停下后永远朝前”。

5. 添加其他动作,例如浇水

5.1 增强全局动作类型枚举

class_name DataTypes
enum Tools{
None,
AxeWood,
TillGround,
WaterGround,
PlantCorn,
PlantTomato
}
GameInput.gd
# 目的:统一输入读取,避免每个状态里写一堆 if-else
class_name GameInput
extends Object
static func movement_input() -> Vector2:
# Input.get_vector(左,右,上,下) 会返回一个标准化方向向量
# 注意:需要你在 Input Map 里配置好这四个 action
# walk_left / walk_right / walk_up / walk_down
return Input.get_vector("walk_left", "walk_right", "walk_up", "walk_down")
static func is_moving(dir: Vector2) -> bool:
return dir != Vector2.ZERO
static func use_tool()->bool:
var use_tool_value =Input.is_action_just_pressed("hit")
return use_tool_value

5.2 创建浇水脚本

class_name ChoppingState
extends NodeState
@export var player: Player
@export var sprite:AnimatedSprite2D
func _on_process(_delta : float) -> void:
pass
func _on_physics_process(_delta : float) -> void:
pass
func _on_next_transitions() -> void:
if !sprite.is_playing():
transition.emit("Idle")
func _on_enter() -> void:
if player.player_direction == Vector2.UP:
sprite.play("chopping_back")
elif player.player_direction == Vector2.DOWN:
sprite.play("chopping_front")
elif player.player_direction == Vector2.LEFT:
sprite.play("chopping_left")
elif player.player_direction == Vector2.RIGHT:
sprite.play("chopping_right")
else:
sprite.play("chopping_front")
func _on_exit() -> void:
sprite.stop();

5.3 静止判断动作是否使用工具,有就进行动作切换

IdleState.gd
extends NodeState
@export var player: Player
@export var sprite: AnimatedSprite2D
var direction: Vector2 = Vector2.ZERO
func _on_physics_process(_delta: float) -> void:
# Idle 永远用“最后朝向”播放动画
_play_idle_animation(player.player_direction)
func _on_next_transitions() -> void:
# 读取输入:如果开始移动,切到 Walk
direction = GameInput.movement_input()
if direction != Vector2.ZERO:
transition.emit("walk")
if player.current_tool == DataTypes.Tools.AxeWood && GameInput.use_tool():
transition.emit("Chopping")
func _on_exit() -> void:
sprite.stop()
func _play_idle_animation(dir: Vector2) -> void:
if dir == Vector2.LEFT:
sprite.play("idle_left")
elif dir == Vector2.RIGHT:
sprite.play("idle_right")
elif dir == Vector2.DOWN:
sprite.play("idle_front")
elif dir == Vector2.DOWN:
sprite.play("idle_back")
else:
sprite.play("idle_front")

5.4 我们可以根据以上的逻辑,创建其他动作,例如锄地

class_name TillingState
extends NodeState
@export var player: Player
@export var sprite:AnimatedSprite2D
func _on_process(_delta : float) -> void:
pass
func _on_physics_process(_delta : float) -> void:
pass
func _on_next_transitions() -> void:
if !sprite.is_playing():
transition.emit("Idle")
func _on_enter() -> void:
if player.player_direction == Vector2.UP:
sprite.play("tilled_back")
elif player.player_direction == Vector2.DOWN:
sprite.play("tilled_front")
elif player.player_direction == Vector2.LEFT:
sprite.play("tilled_left")
elif player.player_direction == Vector2.RIGHT:
sprite.play("tilled_right")
else:
sprite.play("tilled_front")
func _on_exit() -> void:
sprite.stop();

sprite需要移除自动播放动画,即可。

我们只需要在静止脚本再加上判断即可

func _on_next_transitions() -> void:
# 读取输入:如果开始移动,切到 Walk
direction = GameInput.movement_input()
if direction != Vector2.ZERO:
transition.emit("walk")
if player.current_tool == DataTypes.Tools.AxeWood && GameInput.use_tool():
print("state=Chopping")
transition.emit("Chopping")
if player.current_tool == DataTypes.Tools.TillGround && GameInput.use_tool():
print("state=tilling")
transition.emit("tilling")

后续浇水、种植基本上就是重复这两个操作

image-20260211202209097
image-20260211202209097