Godot 4 像素动画:导入、AnimatedSprite2D 与状态机
Godot 4 做 2D 像素动画最容易踩的三个坑:纹理过滤、帧率与状态切换。附一份不到 80 行的猫状态机,可以直接跑。
像素动画的难点从来不是画,而是「让 8 张图看起来像活的」。Godot 4 给了足够好的工具,但默认设置几乎每一条都和像素艺术相反。
导入:三处必须改
把 spritesheet 拖进项目后,选中图片,在导入面板里改这三项:
| 设置 | 默认值 | 像素项目应该是 |
|---|---|---|
| Filter | Linear | Nearest |
| Mipmaps | 开启 | 关闭 |
| Compress Mode | VRAM Compressed | Lossless |
Filter 选 Linear 会让像素边缘糊成一片;Mipmaps 在 2D 缩放时引入模糊;VRAM 压缩会让纯色块出现色带——像素画最怕色带。
改完点 Reimport。如果画面还是糊的,检查项目设置里的 rendering/textures/canvas_textures/default_texture_filter,把它也改成 Nearest。
节点结构
Cat (CharacterBody2D)
├── Sprite (AnimatedSprite2D)
├── Hitbox (Area2D)
│ └── CollisionShape2D
├── Shadow (Sprite2D)
└── StateMachine (Node)
├── Idle
├── Walk
└── HopAnimatedSprite2D 自带 SpriteFrames,适合逐帧动画;如果你需要「同一套动画换不同皮肤」,把 SpriteFrames 存成资源文件再用 sprite_frames = load(...) 替换。AnimationPlayer 更适合补间与事件,两者可以共存,但别用 AnimationPlayer 去驱动 frame,会打架。
帧率:12 fps 是身体的谎言
绝大多数像素游戏的角色动画不需要 60 fps。把 SpriteFrames 的 fps 设在 8 到 12 之间,整体反而更像素。经验值:
- 待机:6–8 fps,帧数少但循环长
- 走路:10–12 fps,4 到 6 帧
- 跳跃:单帧加缩放,不循环
- 受击:12 fps,2 到 3 帧,配合 0.06 秒停顿
状态机:别用 if 堆
新手常写成 if velocity.x != 0: walk else idle,一开始没问题,等到有跳跃、受击、扔东西就开始互相覆盖。用一个显式状态机,每个状态只关心自己的进入、退出和物理更新。
class_name CatState
extends Node
var cat: CharacterBody2D
var machine: Node
func enter() -> void: pass
func exit() -> void: pass
func physics_update(delta: float) -> void: passextends Node
class_name StateMachine
@export var initial_state: CatState
var current: CatState
func _ready() -> void:
for child in get_children():
if child is CatState:
child.cat = owner
child.machine = self
current = initial_state
current.enter()
func transition_to(next: CatState) -> void:
if next == current:
return
current.exit()
current = next
current.enter()
func _physics_process(delta: float) -> void:
current.physics_update(delta)extends CatState
func physics_update(delta: float) -> void:
cat.velocity.x = move_toward(cat.velocity.x, 0, 900 * delta)
cat.velocity.y += 980 * delta
cat.move_and_slide()
if Input.is_action_just_pressed("jump"):
machine.transition_to(cat.get_node("StateMachine/Hop"))
elif absf(Input.get_axis("left", "right")) > 0.1:
machine.transition_to(cat.get_node("StateMachine/Walk"))用 class_name 让每个状态成为独立脚本,加状态只加文件,不改旧代码。这个结构在 8 个状态以内都不需要重构。
动画事件:脚步与尘土
不要用 AnimationPlayer 的时间轴触发脚步声,那会把音频和动画资源绑死。更稳的做法是在连接 frame_changed 后按帧序号判断:
var last_frame := -1
func _on_sprite_frame_changed() -> void:
var f := sprite.frame
if f != last_frame and (f == 2 or f == 5):
audio.stream = sfx_step
audio.play()
last_frame = f三个常见坑
- 动画被物理插值拖慢:开了
physics_interpolation之后动画帧可能落后一帧,脚踩地时画面还在空中。把AnimatedSprite2D的process_callback设为Idle,或直接关掉插值。 - 翻转后判定框没翻:
flip_h只影响绘制,不影响碰撞。要么把Hitbox的scale.x一起翻,要么用Node2D容器同时翻。 - 精灵抖动:相机、角色、精灵三者的小数位置叠加会出现 1 像素抖动。把相机位置取整(
round),角色保持浮点,通常就够了。
这套结构我用了三个项目,从 12 帧的猫到 40 帧的机械臂都没有换过。