前置知识: Godot 引擎

2D 游戏对象:精灵与相机

11 min入门

用 Node2D Sprite2D AnimatedSprite2D 与 Camera2D 搭建 2D 画面,理解坐标变换与镜头缩放边界

在 Godot 的 2D 世界里,你能”看见”的一切都出自同一族节点:角色、敌人、子弹、背景,最后还得加上一台 Camera2D(2D 相机)决定玩家看到哪里。本篇从继承关系讲起,依次介绍 Node2D 的变换属性、Sprite2D(2D 精灵)的静态贴图与图集帧、AnimatedSprite2D(2D 动画精灵)的帧动画,最后落到 Camera2D 的锚点、缩放与边界控制。读完本篇,你应该能独立搭出一个”会动、会播动画、镜头会跟人”的 2D 场景。

知识点地图

  • 知识类别:2D 游戏物体与相机——Node2D/Sprite2D/AnimatedSprite2D/Camera2D 四件套与坐标变换,对应 Godot 官方 2D 入门主线。
  • 解决什么问题:把美术资源变成场景里「会动、会播动画」的物体;控制玩家「看到哪里、跟得多紧」;处理镜头移动衍生出的两个必然问题——HUD 不能跟着镜头跑、远景需要视差滚动。
  • 什么时候用到:所有 2D 项目的画面层;横版/俯视角的镜头跟随;多层卷轴背景;HUD 与计分板搭建的前置知识。
  • 真实工程锚点:本篇的 CanvasLayer 与视差两节取材自本机扫描素材 e-core 2D 教程九集快照(A1/A3 节:Game.tscn 的双 Background 无限滚动 + CanvasLayer 计分板结构、A4 第 10 条「HUD 不放 CanvasLayer 会随相机滚动」的踩坑实录)。
  • 本篇不讲:物理移动与碰撞(见 080 篇);Tween 动画(见 110 篇);HUD 容器布局(见 100 UI 篇,本篇只讲「为什么要 CanvasLayer」)。

前置说明:本篇默认你已经了解节点与场景的基本概念,会挂脚本、会用 _process 回调,这些内容在《第一个脚本与生命周期》中讲解。角色如何真正”走上斜坡、撞墙停下”,属于物理话题,由《角色移动与碰撞检测》负责,本篇只关心”显示”。

学习目标

  • 画出 Node2D < CanvasItem < Node 的继承链,说清 Node2D 与 Control 的共同点
  • 熟练使用 position、rotation、scale、skew 等变换属性及其全局对应版本
  • 用 to_local、to_global、look_at、get_angle_to 完成坐标换算与朝向控制
  • 掌握 Sprite2D 的图集帧属性,会做鼠标命中检测
  • 分清 AnimatedSprite2D 中 pause 与 stop、animation_finished 与 animation_looped 的区别
  • 配置 Camera2D 的锚点、缩放、边界与平滑,避开 zoom 的反直觉陷阱

1. CanvasItem 与 Node2D:一切可见 2D 物体的根基

Godot 的类继承链是 Node2D < CanvasItem < Node < Object。CanvasItem(画布项)是所有能在 2D 画布上绘制内容的基类,它的两个重要子类是 Node2D 与 Control:Node2D 承载游戏世界中的物体,Control 承载 UI 界面。因为它们都继承 CanvasItem,所以共享一批基础属性——例如 z_index(绘制顺序)与 visible(可见性),你可以在游戏物体和 UI 上用同一套思路控制它们。

Node2D 在 CanvasItem 之上补上了”空间变换”能力,核心属性如下:

  • position:Vector2 类型,节点在父节点坐标系中的位置。
  • rotation:float 类型,旋转角,单位是弧度;配套的 rotation_degrees 以角度读写,二者改一个,另一个自动同步。
  • scale:Vector2 类型,缩放。有个值得知道的细节:把 X 设为负值做”水平镜像”时,引擎会把它分解为”负 Y 缩放 + 旋转 180 度”的等价形式,看到数值变化不要慌,这是矩阵分解的正常结果。
  • skew:float 类型,倾斜(错切)角。
  • transform:Transform2D 类型,把平移、旋转、缩放合并表达的完整变换矩阵。

每个属性还有全局对应版本:global_position、global_rotation、global_scale、global_transform,读写在全局坐标系中的值。本地坐标相对父节点,全局坐标相对整个画布,二者沿父节点链换算。

来看官方的第一个脚本示例,它同时用到了 rotation 与 position:

gdscript
extends Sprite2D

var speed = 400
var angular_speed = PI

func _process(delta):
    rotation += angular_speed * delta
    var velocity = Vector2.UP.rotated(rotation) * speed
    position += velocity * delta

每一帧先让 rotation 增加,再让 position 沿”自身朝向”前进:Vector2.UP.rotated(rotation) 得到旋转后的方向向量,乘速度、乘 delta,就是帧率无关的移动。运行后节点会一边自转一边朝自身朝向前进,画出一条圆形轨迹。

2. 常用坐标与朝向方法

Node2D 提供了一组高频工具方法,做瞄准、跟随、命中判断时离不开它们:

  • to_local(global_point):把全局坐标点换算成本地坐标。
  • to_global(local_point):把本地坐标点换算成全局坐标。
  • look_at(point):旋转节点,让自身局部 +X 轴指向全局点 point。注意是 +X 轴:素材默认朝右时可以直接用,素材朝上就要自己补一个偏转。
  • get_angle_to(point):返回从自身指向目标点所需的角度差(弧度),常配合 rotate() 使用。
  • translate(offset):在本地坐标下平移 offset。
  • rotate(radians):在当前旋转基础上再转 radians 弧度。

CanvasItem 层面还有几个常用的显示属性:z_index 控制同层物体的绘制先后;modulate 用颜色调制节点整体,做受击闪红很常用;y_sort_enabled 开启后按 Y 坐标排序绘制,俯视角”后面的物体遮住前面的”就靠它;texture_filter 控制纹理过滤方式(线性插值或最近邻),像素风游戏通常会调整它。

下面是一个鼠标命中检测的例子:

gdscript
func _unhandled_input(event):
    if event is InputEventMouseButton and event.pressed:
        if get_rect().has_point(to_local(event.position)):
            print("命中精灵")

get_rect() 返回精灵在本地坐标系中的矩形范围,把鼠标事件位置 to_local 之后用 has_point 判断是否落在矩形内。如果还想”点到透明像素不算命中”,再用 is_pixel_opaque(pos) 检查该点像素是否不透明。

3. Sprite2D:显示一张图与一串帧

Sprite2D 是最常用的 2D 显示节点,属性围绕一张贴图展开:

  • texture:Texture2D 类型,要显示的贴图。
  • centered:bool,默认 true,贴图中心对齐节点原点。像素风游戏常改成 false,或改用项目设置里的 snap_2d_vertices_to_pixel,避免贴图落在半像素位置导致的模糊变形。
  • offset:Vector2,绘制偏移,只挪图不挪节点本身。
  • flip_h / flip_v:水平、垂直翻转,切换角色左右朝向时比换素材省事。
  • hframes / vframes:把 texture 横向、纵向各切成多少格。两者有大于 1 的值时,texture 就成了一张网格图集(sprite sheet),配合 frame 属性选择显示第几格(从 0 开始)。
  • region_enabled / region_rect:不做网格切分,而是只显示贴图中 region_rect 指定的子区域。

一个易错点:frame 属性只有当 hframes > 1 或 vframes > 1 时才有意义,默认状态下改它看不到任何效果。Sprite2D 还提供两个信号:frame_changed(帧号变化时发出)与 texture_changed(贴图变化时发出),适合做”帧变化时同步碰撞盒”这类联动。

4. AnimatedSprite2D:让图动起来

逐帧动画不需要 AnimationPlayer,AnimatedSprite2D 一个节点就够。它的动画数据放在 sprite_frames 属性里,指向一个 SpriteFrames 资源:内部管理若干个”动画”,每个动画由一组帧贴图与帧率等参数组成,在编辑器底部面板中编辑帧与动画。autoplay 属性填一个动画名,场景一运行就自动播放它。

播放控制方法的语义差异是本节的考试重点:

  • play(name = &"", custom_speed = 1.0, from_end = false):开始播放。无参调用会从当前位置恢复播放当前动画。
  • play_backwards(name = &""):倒着播放。
  • pause():暂停。保留当前 frame 与 frame_progress,之后无参 play() 能从暂停处继续。
  • stop():停止。播放位置归 0,速度重置。
  • set_frame_and_progress(frame, progress):跳帧的同时保留进度,比直接给 frame 赋值更平滑(直接设置 frame 会重置 frame_progress)。
  • speed_scale:播放速度倍率,设为负值即可倒放。
  • is_playing() 与 get_playing_speed():查询播放状态与当前速度。

信号方面注意两点:

  1. animation_finished 只有没有设置循环的动画播完时发出;循环动画永远不会”结束”,它发出的是 animation_looped(每绕一圈一次)。想让循环动画每圈触发一次逻辑,请连接 animation_looped。
  2. 非循环动画播完时,AnimatedSprite2D 处于”暂停”而非”停止”状态:进度还在原地,无参 play() 可以接着播。这与 stop() 的归零行为完全不同。

另外,4.x 的 animation_finished 信号不带参数,回调里若要区分是哪个动画播完,需要自己读 animation 属性判断。

gdscript
extends AnimatedSprite2D

func _ready():
    sprite_frames = preload("res://player_frames.tres")
    animation_finished.connect(_on_animation_finished)
    animation_looped.connect(_on_animation_looped)
    play("run")

func _on_animation_finished():
    # 只有非循环动画才会走到这里;此刻是暂停而非停止,无参 play 即可续播
    play()

func _on_animation_looped():
    # 循环动画每绕一圈发出一次
    pass

帧动画适合逐帧绘制的素材;如果要做属性级的补间(位移、缩放、透明度过渡)或复杂时间线,Godot 还有 AnimationPlayer 与 Tween 两条路线,属于动画与补间篇章的内容,本篇不展开。

5. Camera2D:决定玩家看到哪里

2D 世界可以做得很大,屏幕却只有一块,Camera2D 就是那块”取景框”。它是普通节点,放进场景、(可选地)挂到玩家下面,启用后所在 Viewport 就以它为准显示画面。

基础控制:

  • anchor_mode:锚定模式。默认 DRAG_CENTER,相机节点的 position 对应屏幕中心;FIXED_TOP_LEFT(枚举值 0)则对应屏幕左上角,做像素级精确的地图时更直观。
  • enabled:bool,默认 true。场景里有多个相机时,调用 make_current() 让某个相机接管画面,用 is_current() 查询它是否正在生效。每个 Viewport 同一时刻只有一个活动相机。

缩放(重点,容易反直觉):

  • zoom 越大,画面越拉近。zoom = Vector2(2, 2) 时视野是原来的四分之一;zoom = Vector2(0.5, 0.5) 时视野反而扩大四倍——与”数值大等于看得多”的直觉正好相反。
  • zoom 的 X 与 Y 必须设成相同的值,否则画面会被拉伸变形。

边界与平滑:

  • limit_left / limit_top / limit_right / limit_bottom:限定镜头能到达的像素范围,防止玩家看到地图之外。limit_smoothed 可以让镜头抵达边界时平滑停下,但它只在开启了位置平滑时生效。
  • position_smoothing_enabled 与 position_smoothing_speed(默认 5.0):镜头位置平滑跟随,不做瞬移。传送或重开关卡后想立刻到位,调用 reset_smoothing()。
  • offset:在锚点基础上整体偏移画面,并且可以突破 limit 的限制。
  • ignore_rotation:默认 true,相机旋转时画面保持端正,一般保持默认即可。
  • 另有一组 drag_*_margin(默认 0.2):拖拽边距,玩家移出屏幕边缘一定比例后镜头才跟动,适合做有”松弛感”的跟随。

最后一个高频易错点:Camera2D 节点的 global_position 并不等于画面实际中心——平滑、限界、拖拽边距都会让二者错开。想知道”此刻画面中心在世界中的位置”,请调用 get_screen_center_position()。

gdscript
extends Camera2D

func _ready():
    make_current()
    zoom = Vector2(2, 2)               # 拉近两倍,视野为原来的四分之一
    limit_left = 0
    limit_top = 0
    limit_right = 3200
    limit_bottom = 640
    position_smoothing_enabled = true
    position_smoothing_speed = 5.0

func teleport_to(pos: Vector2) -> void:
    global_position = pos
    reset_smoothing()                  # 传送后立刻对齐,不做平滑过渡

6. CanvasLayer:让 HUD 摆脱镜头

镜头一动,场景里的一切都会在屏幕上跟着位移——包括你辛苦摆好的计分板。CanvasLayer(画布层)的职责就是「开一个不随镜头移动的绘制层」:

text
Game (Node2D)
  Background / Player / Enemy ...   <- 默认画布层(layer 0),随镜头滚动
  Camera2D
  HUD (CanvasLayer)                 <- layer 1,永远钉在屏幕上
    ScoreLabel
    GameOverLabel

踩坑实录(e-core 2D 教程 A4 第 10 条):教程前几集把计分 Label 直接摆在场景里,镜头跟随玩家后计分板「飘出了屏幕」——修复就是把两个 Label 挪进 CanvasLayer。这条坑的机制解释:Camera2D 变换的是当前 canvas 的所有内容,CanvasLayer 拥有独立的 canvas 变换,天然豁免。

三个使用要点:

  • layer 属性决定叠放次序(默认 0,越大越靠上);HUD 用 1,全屏过场遮罩用 2;
  • CanvasLayer 不是 Control,直接往里面摆 Control 类(Label/Button)没问题,但要做全屏布局时先放一个 Control 子节点再设锚点(100 UI 篇展开);
  • 反向需求也存在:小地图、某些特效想让「镜头也照不到它」——同样用独立 CanvasLayer 配合 viewport 挂载实现。记住一句话:内容跟世界走放普通层,跟屏幕走放 CanvasLayer。

7. Parallax2D:视差滚动背景

横版卷轴的经典质感来自视差:远景动得慢、近景动得快,画面立刻有了纵深。教程第 2 集的「双 Background 无限滚动」是手工版:两张背景 Sprite 排在一起,脚本里每帧把超出屏幕的那张挪到队尾循环。Godot 4.3+ 提供了专门的 Parallax2D 节点,把这套逻辑收敛成两个属性:

属性作用
scroll_scale滚动比率:Vector2(0.2, 0.2) 表示镜头移 1px 它只移 0.2px(远景),1.0 = 与世界同步,>1 = 快于世界(前景)
repeat_size无限滚动的平铺尺寸:子内容每隔该距离重复一次,横版填 (宽, 0)

最小配置(三层纵深):

text
Game (Node2D)
  Camera2D(跟随玩家)
  Sky (Parallax2D)        scroll_scale = (0.05, 0.05)   天空:几乎不动
    Sprite2D(天空贴图)
  Mountains (Parallax2D)  scroll_scale = (0.3, 0.05)    远山:横向慢速
    Sprite2D(山峦贴图,repeat_size = (1024, 0))
  Bushes (Parallax2D)     scroll_scale = (0.6, 1.0)     近景灌木
    Sprite2D(灌木贴图,repeat_size = (768, 0))

讲解与易错点:

  • scroll_scale 的 y 分量通常比 x 更小(远景在纵向上几乎锁死),全 0 则完全锁定;
  • repeat_size 要等于贴图平铺单元的宽度(含拼接留白),设小了接缝闪现,设大了露底色;
  • 旧教程里的 ParallaxLayer + ParallaxBackground 组合在 4.3 后被 Parallax2D 取代(官方推荐迁移):新节点是普通 Node2D,可以直接挂在场景任意位置、也接受 lighting,心智负担更小;读旧教程时做一层翻译即可;
  • 视差层是「视觉欺骗」,不参与碰撞与导航——远景的群山不能当掩体,这条路走不通。

8. 动手实践

任务:搭一个「镜头跟人 + HUD 钉屏 + 三层视差」的横版小场景。

  1. 用 AnimatedSprite2D 摆一个角色,加 Camera2D 作为其子节点并配好 limit(左右 3200、上下 640)与平滑;跑动观察镜头到边界时是否平滑停住(limit_smoothed 需要配合位置平滑才生效——先开着,再关掉位置平滑对比一次);
  2. 放一个计分 Label 在场景里,跑动复现「计分板飘走」;然后把 Label 挪进 CanvasLayer(layer=1),验证钉屏;
  3. 按第 7 节配三层 Parallax2D;故意把远山的 repeat_size 设成贴图宽度的一半,观察接缝闪现,修正后记录正确值;
  4. 给镜头加 drag_horizontal_enabled 与左右各 0.3 的拖拽边距,对比「中心死跟」与「边缘拖拽」两种跟随手感的差异。
参考现象与解释(先自己试,再展开对照)

第 1 题:关掉位置平滑后 limit 照样生效(硬夹取),但镜头贴边界时没有缓冲——limit_smoothed 只在 position_smoothing_enabled 开启时有意义,这两个属性是配套的,单独开 limit_smoothed 是无效配置。

第 2 题:Label 在普通层时随镜头位移跑出视野;挪进 CanvasLayer 后镜头怎么动它都钉在屏幕同一位置。机制:Camera2D 的变换只作用于默认 canvas,CanvasLayer 有独立 canvas。

第 3 题:repeat_size 偏小时两张平铺重叠、图案周期性「跳格」;正确值 = 平铺单元实际宽度。视差的视觉参数没有对错,手感对了就是对了——三层比率 0.05/0.3/0.6 是起步参考。

第 4 题:拖拽边距让角色在屏幕中央区域自由移动、镜头只在角色逼近屏幕边缘 30% 时跟进——转向跑时画面更稳,是横版动作的常用手感;做精确平台跳跃的项目反而常用中心死跟(玩家对「角色在屏幕正中」的位置预期更强)。

小结

  • 继承链是 Node2D < CanvasItem < Node;Node2D 与 Control 都继承 CanvasItem,共享 z_index、visible 等属性。
  • position、rotation、scale、skew、transform 加 global_ 前缀就是全局版本;to_local 与 to_global 负责两套坐标换算,look_at 让局部 +X 指向目标点。
  • Sprite2D 用 hframes、vframes、frame 显示图集帧,frame 需要 hframes 或 vframes 大于 1 才生效;get_rect 配合 to_local 可做鼠标命中检测。
  • AnimatedSprite2D 的 pause 保留进度、无参 play 可恢复,stop 位置归零;循环动画不发 animation_finished,改用 animation_looped;非循环动画播完处于暂停态而非停止态。
  • Camera2D 的 zoom 越大越拉近且 X/Y 要同值;limit 限制范围,position_smoothing 提供平滑,reset_smoothing 立即到位;真实画面中心要用 get_screen_center_position() 获取。

参考链接