人物移动(Godot 4.x):CharacterBody2D + AnimatedSprite2D + 状态机(Idle/Walk)
目标:
- 用 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:保存最后朝向
# 目的:扩展 CharacterBody2D,让角色保存“最后一次移动方向”class_name Playerextends CharacterBody2D
# 角色最后一次的移动方向(Idle 时用它决定朝向动画)var player_direction: Vector2 = Vector2.DOWN默认朝下是个常见选择(也可以默认 UP/RIGHT)。
4.2 GameInput.gd:输入工具(建议放 autoload 或工具类)
# 目的:统一输入读取,避免每个状态里写一堆 if-elseclass_name GameInputextends 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:状态基类(你原来的可以继续用,我加了注释)
class_name NodeStateextends 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,这里给一个小优化版)
class_name NodeStateMachineextends 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 动画 + 记录最后方向
需要绑定,不然会报错

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 动画(使用最后朝向)
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 DataTypesenum Tools{ None, AxeWood, TillGround, WaterGround, PlantCorn, PlantTomato}# 目的:统一输入读取,避免每个状态里写一堆 if-elseclass_name GameInputextends 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 ChoppingStateextends 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 静止判断动作是否使用工具,有就进行动作切换
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 TillingStateextends 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")后续浇水、种植基本上就是重复这两个操作

评论