人物移动
约 1625 字大约 5 分钟
2026-02-05
目标:
- 用 CharacterBody2D 实现 2D 角色移动
- 用 AnimatedSprite2D 播放四方向 idle / walk
- 用一个轻量 状态机(State Machine) 管理 Idle ↔ Walk 切换
- 解决常见坑:停止移动后朝向丢失、输入判定耦合/重复、状态里 if-else 爆炸
1. 背景:为什么推荐 CharacterBody2D?
在 Godot 4.x 里,CharacterBody2D 是“专门为角色移动设计”的节点,优势是:
- 自带
velocity(速度向量) - 自带
move_and_slide()(处理滑动/碰撞) - 把“角色移动”从普通
Node2D抽象成更清晰的角色模型
对于像素 RPG/种田游戏这类四方向移动,它是最常用的底座。

2. 核心原理:我们真正要解决的其实是三件事
2.1 输入(Input) → 方向(Vector2)
玩家按键最终要变成一个方向向量:
- 左:
Vector2.LEFT - 右:
Vector2.RIGHT - 上:
Vector2.UP - 下:
Vector2.DOWN - 没按:
Vector2.ZERO

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… 每个状态负责自己那部分逻辑 状态机负责切换与调度。


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.ZERO4.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:
pass4.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 动画 + 记录最后方向
需要绑定,不然会报错

# 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_value5.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")后续浇水、种植基本上就是重复这两个操作

贡献者
flycodeu
版权所有
版权归属:flycodeu
