2D 游戏对象:精灵与相机
用 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:
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 控制纹理过滤方式(线性插值或最近邻),像素风游戏通常会调整它。
下面是一个鼠标命中检测的例子:
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():查询播放状态与当前速度。
信号方面注意两点:
- animation_finished 只有没有设置循环的动画播完时发出;循环动画永远不会”结束”,它发出的是 animation_looped(每绕一圈一次)。想让循环动画每圈触发一次逻辑,请连接 animation_looped。
- 非循环动画播完时,AnimatedSprite2D 处于”暂停”而非”停止”状态:进度还在原地,无参 play() 可以接着播。这与 stop() 的归零行为完全不同。
另外,4.x 的 animation_finished 信号不带参数,回调里若要区分是哪个动画播完,需要自己读 animation 属性判断。
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()。
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(画布层)的职责就是「开一个不随镜头移动的绘制层」:
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) |
最小配置(三层纵深):
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 钉屏 + 三层视差」的横版小场景。
- 用 AnimatedSprite2D 摆一个角色,加 Camera2D 作为其子节点并配好 limit(左右 3200、上下 640)与平滑;跑动观察镜头到边界时是否平滑停住(limit_smoothed 需要配合位置平滑才生效——先开着,再关掉位置平滑对比一次);
- 放一个计分 Label 在场景里,跑动复现「计分板飘走」;然后把 Label 挪进 CanvasLayer(layer=1),验证钉屏;
- 按第 7 节配三层 Parallax2D;故意把远山的 repeat_size 设成贴图宽度的一半,观察接缝闪现,修正后记录正确值;
- 给镜头加 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() 获取。