Godot 4 像素动画:导入、AnimatedSprite2D 与状态机

Godot 4 做 2D 像素动画最容易踩的三个坑:纹理过滤、帧率与状态切换。附一份不到 80 行的猫状态机,可以直接跑。

Godot动画源码

像素动画的难点从来不是画,而是「让 8 张图看起来像活的」。Godot 4 给了足够好的工具,但默认设置几乎每一条都和像素艺术相反。

导入:三处必须改

把 spritesheet 拖进项目后,选中图片,在导入面板里改这三项:

设置默认值像素项目应该是
FilterLinearNearest
Mipmaps开启关闭
Compress ModeVRAM CompressedLossless

Filter 选 Linear 会让像素边缘糊成一片;Mipmaps 在 2D 缩放时引入模糊;VRAM 压缩会让纯色块出现色带——像素画最怕色带。

改完点 Reimport。如果画面还是糊的,检查项目设置里的 rendering/textures/canvas_textures/default_texture_filter,把它也改成 Nearest。

节点结构

text
Cat (CharacterBody2D)
├── Sprite (AnimatedSprite2D)
├── Hitbox (Area2D)
│   └── CollisionShape2D
├── Shadow (Sprite2D)
└── StateMachine (Node)
    ├── Idle
    ├── Walk
    └── Hop

AnimatedSprite2D 自带 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,一开始没问题,等到有跳跃、受击、扔东西就开始互相覆盖。用一个显式状态机,每个状态只关心自己的进入、退出和物理更新。

gdscript
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: pass
gdscript
extends 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)
gdscript
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 后按帧序号判断:

gdscript
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 帧的机械臂都没有换过。