TileMapLayer、相机与关卡
用 TileSet 配置瓦片的碰撞、地形自动连接和自定义数据,用多个 TileMapLayer 搭关卡;再让 Camera2D 平滑跟随、限制边界、留出死区和前瞻,最后加上 Parallax2D 视差背景、移动平台、敌人和检查点,并初步认识碰撞层。
前两章的主角已经能跑能跳、有动画有反馈。这一章搭它所在的世界:地面、平台、尖刺是怎么用瓦片拼出来的,镜头怎么跟着角色走才不晕,远处的山为什么比近处的树动得慢,敌人和移动平台又是怎么和主角发生关系的。
示例里本章相关的参数是相机的 7 个参数和一个调试开关。打开"显示死区和目标点"和"显示碰撞和危险格",再跑一跑,会更容易看懂本章讲的东西。在画面上点鼠标左键能放一块地面,右键清除,可以顺便观察瓦片的自动连接。
TileSet:瓦片的"字典"
瓦片地图(tilemap)把关卡切成大小相同的格子,每格放一块瓦片。它的好处是:美术只需要画几十块瓦片,就能拼出任意大小的关卡;碰撞、属性也按格子存储,查询非常快。
Godot 把这件事分成两部分:TileSet 是资源,描述"有哪些瓦片、每块瓦片有什么属性";TileMapLayer 是节点,记录"哪个格子放了哪块瓦片"。同一个 TileSet 可以被多个图层、多个关卡共用。
图集
示例所有瓦片都画在一张 256×192 的图片 art/tiles.png 里(用 Python 脚本 art_src/make_art.py 生成,没有用外部素材)。这样的一张图在 TileSet 里叫一个图集源(TileSetAtlasSource),每块瓦片用它在图集中的格子坐标表示,比如 Vector2i(4, 4) 是尖刺。
| 图集位置 | 内容 |
|---|---|
| 第 0–3 行,前 4 列 | 地面,16 种"四边是否和地面相连"的组合 |
| 第 4 行 0–3 列 | 单向木平台:单块、左端、中间、右端 |
| (4, 4) | 尖刺 |
| 第 5 行 | 装饰:草丛、花、蘑菇、石头、灌木、路牌 |
在编辑器里,新建 TileMapLayer → 检查器里给 tile_set 新建一个 TileSet → 底部 TileSet 面板把图片拖进去,编辑器会问是否自动按格子切出瓦片。下面我们看配置完成后保存在 level/tileset.tres 里的内容(示例用脚本 art_src/make_tileset.gd 生成了这个文件,结果和手动配置完全相同)。
[resource]
tile_size = Vector2i(32, 32)
physics_layer_0/collision_layer = 1
physics_layer_0/collision_mask = 0
terrain_set_0/mode = 2
terrain_set_0/terrain_0/name = "地面"
terrain_set_0/terrain_0/color = Color(0.42352942, 0.7529412, 0.2901961, 1)
terrain_set_0/terrain_1/name = "木平台"
terrain_set_0/terrain_1/color = Color(0.7882353, 0.54901963, 0.32156864, 1)
custom_data_layer_0/name = "hazard"
custom_data_layer_0/type = 1
custom_data_layer_1/name = "dust_color"
custom_data_layer_1/type = 20
sources/0 = SubResource("TileSetAtlasSource_g6068")tile_size:格子大小 32×32。physics_layer_0:一个物理层。它属于碰撞层 1(下面会讲碰撞层)。物理层本身只是一个"槽位",每块瓦片可以在这个槽里画自己的碰撞多边形。terrain_set_0:一个地形集,模式 2 即TERRAIN_MODE_MATCH_SIDES(只看四条边),里面有"地面"和"木平台"两种地形。custom_data_layer_0/1:两个自定义数据层,hazard是 bool,dust_color是 Color(type 20 就是TYPE_COLOR)。
物理层:每块瓦片自己的碰撞形状
地面瓦片的碰撞是整个方块 (-16,-16) 到 (16,16)(坐标以瓦片中心为原点)。木平台只有上面 10 像素有碰撞,而且勾选了 one_way:
0:4/0 = 0
0:4/0/terrain_set = 0
0:4/0/terrain = 1
0:4/0/physics_layer_0/polygon_0/points = PackedVector2Array(-16, -16, 16, -16, 16, -6, -16, -6)
0:4/0/physics_layer_0/polygon_0/one_way = true
0:4/0/custom_data_1 = Color(0.9411765, 0.8117647, 0.6117647, 1)单向碰撞只在物体从上方进入时起作用,所以角色可以从平台下面跳上去,站在上面时又不会掉下来。尖刺瓦片没有碰撞多边形——它不需要挡住谁,伤害靠自定义数据判断。打开示例的"显示碰撞和危险格":蓝色是实心碰撞,橙色是单向平台,红色是危险格子。
TileMapLayer 不会给每个格子单独创建物理对象,而是按区块(physics_quadrant_size,默认 16×16 格)把一片格子的碰撞形状放进同一个静态体,所以一个关卡几千格也不会产生几千个物理节点。
地形和自动连接
手动铺地面时,每一格都要挑对瓦片:最上面一排要带草皮,左边缘要有左描边,左上角要圆角……16 种组合一格一格选,很快就会出错。地形(terrain)让 Godot 自动挑选:你只说"这些格子是地面",它根据邻居来选瓦片。
原理是给每块瓦片标注地形位(peering bit):这块瓦片的每条边"应该挨着什么地形"。比如图集 (3, 1) 这块,上、右、下三条边都挨着地面,只有左边是空的,所以它画了左描边:
3:1/0 = 0
3:1/0/terrain_set = 0
3:1/0/terrain = 0
3:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(-16, -16, 16, -16, 16, 16, -16, 16)
3:1/0/terrains_peering_bit/right_side = 0
3:1/0/terrains_peering_bit/bottom_side = 0
3:1/0/terrains_peering_bit/top_side = 0
3:1/0/custom_data_1 = Color(0.8509804, 0.9019608, 0.72156864, 1)放置时,Godot 看一个格子四周的实际邻居,在 TileSet 里找地形位最匹配的那块瓦片。示例把 16 块瓦片按"上=1、右=2、下=4、左=8"的二进制组合排列,序号 m 的瓦片放在图集 (m % 4, m / 4)。下面的小部件演示了这个匹配过程,以及接下来要讲的坐标换算:
地形模式有三种:MATCH_CORNERS_AND_SIDES(8 个邻居都看,完整的一套需要 47 块瓦片,能处理凹角)、MATCH_CORNERS(只看 4 个角)、MATCH_SIDES(只看 4 条边,16 块)。示例用最简单的 MATCH_SIDES,代价是"凹角"处(比如台阶内侧)不会有专门的转角瓦片。
自定义数据层
自定义数据层让每块瓦片带上任意属性。示例有两个:尖刺的 hazard = true,以及每种地面的 dust_color(脚下扬起的灰尘颜色)。游戏逻辑只问"这一格的 hazard 是不是 true",不需要关心它是哪块瓦片。以后加岩浆、电网,只要在 TileSet 里勾上 hazard,代码一行都不用改。
TileMapLayer:把瓦片放进格子
示例的关卡有三个 TileMapLayer,共用同一个 TileSet:
[node name="Level" type="Node2D" parent="."]
script = ExtResource("4")
[node name="Back" type="TileMapLayer" parent="Level"]
material = SubResource("haze")
tile_set = ExtResource("5")
collision_enabled = false
[node name="Ground" type="TileMapLayer" parent="Level"]
tile_set = ExtResource("5")
[node name="Decor" type="TileMapLayer" parent="Level"]
tile_set = ExtResource("5")
collision_enabled = falseBack:远景土块,collision_enabled = false关掉碰撞,并用一个简单的着色器把颜色往天空色混合,看起来更远("空气透视")。Ground:地面、木平台、尖刺,有碰撞,所有查询都针对这一层。Decor:草丛、花、路牌,没有碰撞。
节点在场景树里的顺序就是绘制顺序:后面的画在前面的上面。需要前景遮挡角色时,可以再加一个排在主角后面的图层。
用字符画生成关卡
通常的做法是在编辑器里选中图层,用画笔、矩形、填充工具在画布上"刷"瓦片;用地形工具刷时会自动连接。示例为了让关卡结构一目了然,选择用代码根据一段字符画生成,每个字符一格:
const MAP: Array[String] = [
"....................................................................................................................................................##",
"....................................................................................................................................................##",
"....................................................................................................................................................##",
"....................................................................................................................................................##",
"....................................................................................................................................................##",
"....................................................................................................................................................##",
"##........................................................................................................................................BBBBB.....##",
"##........................................................................................................................................BBBBB.....##",
"##.......................................................................o..........E...^.................................................BBBBB.....##",
"##......................................................................===....############...oo.......................................oo.BBBBB.G...##",
"##...BBB.....................oo................................................############..====......K......BBB...................##################",
"##...BBB....................====.............................................o.############.........#########.BBB...................##################",
"##..BBBBB..................................BBB..............................===############.........#########BBBBB..............o...##################",
"##..BBBBB...............oo......BBB........BBB...............o.o.o.o...........############.........#########BBBBB..................##################",
"##..BBBBB.oo......o....====.....BBB...o....BBB....E......................o.....############.........#########BBBBB.......oo.....o...##################",
"##..BBBBBo.......o.o............BBB..===...BBB#######...................===....############.........#########BBBBB...oo.............##################",
"##..BBBBB...#####...............BBB........BBB#######.......M..................############.........#########BBBBB..................##################",
"##P.BBBBB.s.#####...........E...BBB........BBB#######..K.......................############.........#########BBBBB.E...E.^^....V....##################",
"#################...###############.......#################...........#####################.........##################################################",
"#################...###############^^^^^^^#################...........#####################.........##################################################",
"#################...#######################################...........#####################^^^^^^^^^##################################################",
"#################...#######################################...........################################################################################",
]func build() -> void:
for layer: TileMapLayer in [back, ground, decor]:
layer.clear()
var dirt: Array[Vector2i] = []
var wood: Array[Vector2i] = []
var far: Array[Vector2i] = []
var spikes: Array[Vector2i] = []
for y in MAP.size():
for x in MAP[y].length():
var cell := Vector2i(x, y)
match MAP[y][x]:
"#": dirt.append(cell)
"=": wood.append(cell)
"B": far.append(cell)
"^": spikes.append(cell)
"s": decor.set_cell(cell, SOURCE, SIGN)
".": pass
var ch: _spawn(ch, cell)
# 地形自动连接:只告诉它"这些格子是地面",用哪块瓦片由 TileSet 里的地形位决定
ground.set_cells_terrain_connect(dirt, TERRAIN_SET, T_GROUND, false)
ground.set_cells_terrain_connect(wood, TERRAIN_SET, T_WOOD, false)
back.set_cells_terrain_connect(far, TERRAIN_SET, T_GROUND, false)
for cell in spikes:
ground.set_cell(cell, SOURCE, SPIKE)
_decorate(dirt)关键是 set_cells_terrain_connect(格子数组, 地形集, 地形):传入所有地面格子,Godot 根据地形位为每格挑瓦片,也会更新邻居。尖刺不属于任何地形,用 set_cell(格子, 源编号, 图集坐标) 直接放。脚本开头有 @tool,所以在编辑器里打开 main.tscn 也能看到整个关卡。
set_cells_terrain_connect 的第四个参数 ignore_empty_terrains 默认是 true,意思是匹配时"不在乎邻居是空的"。这样边缘格子可能会选到一块"以为旁边还有地面"的瓦片,导致描边缺失。示例都显式传了 false,让空邻居也参与匹配。坐标换算和查询
TileMapLayer 最常用的几个方法:
func api_example() -> void:
var cell := ground.local_to_map(ground.get_local_mouse_position()) # 本地坐标 → 格子坐标
ground.set_cell(cell, 0, Vector2i(1, 0)) # 放一块:图集源 0 里 (1, 0) 那块瓦片
var center := ground.map_to_local(cell) # 格子坐标 → 这一格中心的本地坐标
var data := ground.get_cell_tile_data(cell) # 空格子返回 null,用之前要判断
if data:
print(data.get_custom_data("hazard"))
print(ground.get_cell_source_id(cell)) # 空格子返回 -1
print(ground.get_used_rect(), center) # 用到的格子范围(以格子为单位)
ground.erase_cell(cell) # 清除一格local_to_map(本地坐标):本地像素坐标 → 格子坐标,相当于每个分量除以格子大小再向下取整。注意是向下取整:x = -5 在格子 -1 里,不是 0。在上面的小部件里把图层位置拖到正数,然后把鼠标移到原点左边试试。map_to_local(格子):格子 → 该格中心的本地坐标。- 两个方法都用图层的本地坐标。手里是全局坐标(比如角色的
global_position)时,要先用to_local()转换;图层不在原点或被缩放时,漏掉这一步就会查错格子。
示例把"查某个世界坐标处的瓦片属性"封装成了几个函数:
## 世界坐标 → 地面层的格子 → 这一格的 TileData(空格子返回 null)。
func tile_data_at(world_pos: Vector2) -> TileData:
var cell := ground.local_to_map(ground.to_local(world_pos))
return ground.get_cell_tile_data(cell)
func is_hazard(world_pos: Vector2) -> bool:
var data := tile_data_at(world_pos)
return data != null and data.get_custom_data("hazard")
func is_solid(world_pos: Vector2) -> bool:
var data := tile_data_at(world_pos)
return data != null and data.get_collision_polygons_count(0) > 0主角每帧用身体上的三个采样点查 is_hazard,碰到尖刺就受伤:
## 尖刺没有碰撞形状:每帧用身体上的几个采样点去查瓦片的自定义数据 hazard。
func _check_hazards() -> void:
if level == null:
return
for p: Vector2 in [Vector2(-8, -3), Vector2(8, -3), Vector2(0, -20)]:
if level.is_hazard(global_position + p):
die()
return鼠标放置和清除地面也只有几行。清除时把地形设成 -1,邻居会被重新匹配:
## 在游戏里用鼠标改地形:左键放地面,右键清除。邻居格子的瓦片会自动重新匹配。
func _unhandled_input(event: InputEvent) -> void:
if Engine.is_editor_hint():
return
var mb := event as InputEventMouseButton
if mb == null or not mb.pressed:
return
var cell := ground.local_to_map(ground.get_local_mouse_position())
if mb.button_index == MOUSE_BUTTON_LEFT and ground.get_cell_source_id(cell) == -1:
ground.set_cells_terrain_connect([cell], TERRAIN_SET, T_GROUND, false)
decor.erase_cell(cell)
elif mb.button_index == MOUSE_BUTTON_RIGHT:
ground.set_cells_terrain_connect([cell], TERRAIN_SET, -1, false)
ground.erase_cell(cell)
decor.erase_cell(cell + Vector2i.UP)
else:
return
$CollisionDebug.queue_redraw()
Tuning.stat("点击的格子", cell)"显示碰撞和危险格"的调试图层,就是遍历所有用到的格子,读出每块瓦片的碰撞多边形和自定义数据画出来:
func _draw() -> void:
for cell in ground.get_used_cells():
var data := ground.get_cell_tile_data(cell)
var center := ground.map_to_local(cell)
if data.get_custom_data("hazard"):
draw_rect(Rect2(center - Vector2(16, 16), Vector2(32, 32)), Color(1, 0.2, 0.2, 0.35))
draw_rect(Rect2(center - Vector2(16, 16), Vector2(32, 32)), Color(1, 0.2, 0.2), false, 2.0)
for i in data.get_collision_polygons_count(0):
var pts := data.get_collision_polygon_points(0, i)
var one_way := data.is_collision_polygon_one_way(0, i)
var col := Color(1.0, 0.6, 0.1) if one_way else Color(0.25, 0.6, 1.0)
var poly := PackedVector2Array()
for p in pts:
poly.append(center + p)
draw_colored_polygon(poly, Color(col, 0.25))
poly.append(poly[0])
draw_polyline(poly, col, 1.5)图层 Ground 的 position 是 (0, 0),格子大小 32。ground.local_to_map(Vector2(-10, 70)) 返回什么?
- Vector2i(0, 2)
- Vector2i(-1, 2)
- Vector2i(0, 3)
- Vector2i(-1, 3)
-10 / 32 = -0.31,向下取整是 -1;70 / 32 = 2.19,向下取整是 2。
从 TileMap 迁移到 TileMapLayer
Godot 4.0 到 4.2 用的是一个 TileMap 节点,节点内部有多个"图层",每个方法都多一个图层编号参数。从 4.3 开始,每个图层变成了一个独立的 TileMapLayer 节点,TileMap 被标记为废弃。
## 旧写法(Godot 4.0–4.2 的 TileMap 节点,已废弃)都多一个"图层编号"参数:
## tile_map.set_cell(0, cell, 0, Vector2i(1, 0))
## tile_map.get_cell_tile_data(0, cell)
## tile_map.set_cells_terrain_connect(0, cells, 0, 0)
## Godot 3 的写法:tile_map.set_cell(x, y, tile_id)、tile_map.world_to_map(pos)
## 换成 TileMapLayer 后,每个图层就是一个独立节点,去掉第一个参数即可:
func migrated(cells: Array[Vector2i]) -> void:
ground.set_cells_terrain_connect(cells, 0, 0)独立节点的好处是:每个图层可以单独设置 z_index、材质、可见性、碰撞开关,可以用普通的节点方式排序、加脚本、在动画里控制。旧工程迁移时,选中 TileMap 节点,在底部 TileMap 面板的工具菜单里选"将 TileMap 图层提取为单独的 TileMapLayer 节点",然后把代码里的图层参数去掉即可。
world_to_map() / map_to_world() 在 4.x 改名为 local_to_map() / map_to_local(),并且 map_to_local 返回的是格子中心,3.x 返回的是格子左上角。3.x 的 set_cell(x, y, id) 用整数瓦片 ID,4.x 用"源编号 + 图集坐标 + 备选编号"三个参数定位一块瓦片。搜索教程时看到 TileMap 加图层编号的写法,就是 4.0–4.2 时代的。Camera2D:让镜头舒服地跟随
示例的相机是主角的子节点,所以默认就会跟着主角。但"死死贴住主角"的镜头会让人头晕:每次小跳画面都上下抖,转身时画面一下子跟过来。Camera2D 提供了几种手段来改善,示例把它们都做成了参数:
@export var smoothing_speed := 6.0: ## 平滑速度,0 = 关闭平滑(画面硬跟)
set(v):
smoothing_speed = v
position_smoothing_enabled = v > 0.0
position_smoothing_speed = maxf(v, 0.1)
@export var look_ahead := 64.0 ## 朝面对方向多看多少像素
@export var drag_margin_h := 0.15: ## 水平死区(占半个屏幕宽的比例)
set(v):
drag_margin_h = v
drag_horizontal_enabled = v > 0.0
drag_left_margin = v
drag_right_margin = v
@export var drag_margin_v := 0.2: ## 竖直死区
set(v):
drag_margin_v = v
drag_vertical_enabled = v > 0.0
drag_top_margin = v
drag_bottom_margin = v
@export var use_limits := true: ## 不让镜头拍到关卡外面
set(v):
use_limits = v
limit_enabled = v
@export var zoom_level := 1.0:
set(v):
zoom_level = v
zoom = Vector2(v, v)
@export var show_debug := false ## 在屏幕上画出死区和前瞻目标平滑
position_smoothing_enabled 打开后,画面中心不再瞬间到达目标,而是每帧走剩余距离的一部分(比例约为 position_smoothing_speed × delta)。速度越小越"拖"。完全关闭时画面和角色一起硬移动,跑动时背景显得在跳。
边界
limit_left / top / right / bottom 规定画面不能超出的范围,limit_enabled 是总开关。示例在开局时把它设成整个关卡的矩形,这样站在关卡最左边时,主角在画面左侧,而不是让画面露出关卡外的空白:
func _ready() -> void:
player.level = level
player.global_position = level.player_start
player.set_checkpoint(level.player_start)
camera.set_bounds(level.get_bounds())
camera.reset_smoothing()死区(drag margin)
打开 drag_horizontal_enabled / drag_vertical_enabled 后,屏幕中央有一个矩形区域,叫死区。目标点在框里移动时,画面完全不动;目标点碰到框的边缘,才会推着画面走。drag_left_margin 等四个值是框的边到屏幕中心的距离,以半个屏幕为 1。竖直死区特别有用:在平地上小跳时画面不会跟着上下晃。
前瞻(look-ahead)
Camera2D 没有内置前瞻,但很容易实现:既然相机跟随的是相机节点自己的位置,那就把相机节点往角色面对的方向挪一段。跑得越快挪得越多,再用指数平滑避免转身时镜头猛甩。这样角色前方能看到更多东西,玩家有时间对前面的敌人和坑做出反应。
## 前瞻:相机目标点朝主角面对的方向偏移,跑得越快偏得越多;用指数平滑避免转身时镜头猛甩。
func _update_look_ahead(delta: float) -> void:
var speed_k := clampf(absf(player.velocity.x) / player.max_speed, 0.0, 1.0)
var target := player.facing * look_ahead * speed_k
_look = lerpf(_look, target, 1.0 - exp(-3.0 * delta))
position = Vector2(_look, _base_y)下面的小部件只演示水平方向,按 Camera2D 同样的顺序处理:前瞻得到目标点 → 死区推动中心 → 边界限制 → 平滑。黄框是死区,红点是目标点,下方的小地图里黑框是相机看到的范围。
缩放
zoom 大于 1 时放大(看到的范围变小),小于 1 时缩小。注意边界:缩小到看到的范围比关卡还大时,左右边界无法同时满足,画面会贴在一边。把示例的缩放拖到 0.5 可以看到这一点,这时可以把"限制在关卡内"关掉对比。
offset,前瞻改的是相机节点的 position,两者互不干扰。如果你把震动也写进 position,平滑会把震动"抹平",死区也会吃掉小幅度的震动。另外,主角复活传送后要调用 reset_smoothing(),否则镜头会从死亡位置一路滑过整个关卡。Parallax2D:视差背景
远处的东西看起来移动得慢。视差背景利用这一点营造纵深:天空不动,远山动得很慢,近处的山坡快一些,地面和角色正常速度。Godot 4.3 新增的 Parallax2D 节点取代了旧的 ParallaxBackground + ParallaxLayer 组合,每一层就是一个 Parallax2D 节点:
[node name="Clouds" type="Parallax2D" parent="."]
scroll_scale = Vector2(0.1, 0.05)
repeat_size = Vector2(1024, 0)
autoscroll = Vector2(-12, 0)
repeat_times = 3
[node name="Sprite" type="Sprite2D" parent="Clouds"]
position = Vector2(0, 0)
texture = ExtResource("7")
centered = false
[node name="Mountains" type="Parallax2D" parent="."]
scroll_scale = Vector2(0.2, 0.1)
repeat_size = Vector2(1024, 0)
repeat_times = 3
[node name="Sprite" type="Sprite2D" parent="Mountains"]
position = Vector2(0, 130)
texture = ExtResource("8")
centered = falsescroll_scale:相对相机移动的比例。0.2 表示相机移动 100 像素,这一层只移动 20 像素,看起来很远;0 是完全不动;大于 1 则比地面还快,适合前景。示例的云、远山、山坡分别是 0.1、0.2、0.45,竖直方向的比例更小,跳跃时背景几乎不上下动。repeat_size:设成图片宽度 1024 后,这一层在水平方向无限重复。图片的左右两边必须能无缝接上(生成脚本里用了整数周期的正弦和"左右各画一次"的方法)。repeat_times:重复几份。相机缩小时一屏能看到更宽的范围,需要多画几份。autoscroll:自动滚动速度,云层每秒向左飘 12 像素。
天空本身是一个 CanvasLayer(layer = -10)里铺满屏幕的渐变纹理,它在所有东西后面,并且完全不随相机移动。
ParallaxBackground 必须放在单独的 CanvasLayer 里,镜头缩放和多个视口时问题很多。Parallax2D 是普通的 2D 节点,可以和关卡放在同一个场景树里、用 z_index 排序,在编辑器里移动视图时也能直接预览视差效果。关卡里的其他东西
字符画里除了瓦片,还有金币、敌人、检查点、终点和移动平台。build() 遇到这些字符时实例化对应的场景,放在格子中心或格子底边:
func _spawn(ch: String, cell: Vector2i) -> void:
var center := ground.map_to_local(cell) # 格子中心(TileMapLayer 的本地坐标)
var feet := center + Vector2(0, TILE / 2.0) # 格子底边:角色和旗子的"脚"踩在这里
if ch == "P":
player_start = ground.to_global(feet)
return
if Engine.is_editor_hint():
return # 编辑器里只显示瓦片,不生成会动的东西
var node: Node2D
match ch:
"o":
node = COIN.instantiate()
node.position = center
coin_total += 1
"E":
node = SLIME.instantiate()
node.position = feet
node.level = self
"K":
node = CHECKPOINT.instantiate()
node.position = feet
"G":
node = GOAL.instantiate()
node.position = feet
"M", "V":
node = PLATFORM.instantiate()
node.position = center - Vector2(TILE, TILE) / 2.0
node.travel = Vector2(5 * TILE, 0) if ch == "M" else Vector2(0, -7 * TILE)
_:
push_warning("关卡里有不认识的字符:%s" % ch)
return
entities.add_child(node)移动平台:AnimatableBody2D
移动平台要求两件事:它按我们规定的路线动(不受重力和碰撞影响),站在上面的角色会被一起带走。AnimatableBody2D 正是为此设计的:你直接改它的位置,打开 sync_to_physics 后,引擎会把每个物理帧的位移换算成速度,CharacterBody2D 在 move_and_slide() 时就会把这个速度加到自己身上。
func _ready() -> void:
var shape := RectangleShape2D.new()
shape.size = Vector2(width_tiles * 32, 10)
$Collision.shape = shape
$Collision.position = Vector2(width_tiles * 16, 5)
var start := position
var tw := create_tween().set_loops()
tw.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS) # 在物理帧里移动,和角色的碰撞同步
tw.tween_property(self, "position", start + travel, duration) \
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tw.tween_interval(0.4)
tw.tween_property(self, "position", start, duration) \
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tw.tween_interval(0.4)这里用的是第 7 章的 Tween。set_process_mode(Tween.TWEEN_PROCESS_PHYSICS) 让补间在物理帧里更新,和角色的移动保持同一节奏;否则在高刷新率屏幕上,站在平台上的角色会轻微抖动。
StaticBody2D 做移动平台然后每帧改它的位置:静态体是"瞬移"的,引擎不知道它的速度,角色不会被带走,甚至会被卡进平台里。也不要用 RigidBody2D:它会被角色压得往下掉。敌人和检查点
史莱姆是一个 CharacterBody2D,和主角一样用 velocity + move_and_slide()。它利用本章的瓦片查询来巡逻:脚下前方没有实心格子(快到边缘了)或者前方是尖刺,就掉头。
func _physics_process(delta: float) -> void:
if _dead:
return
velocity.y += gravity * delta
velocity.x = dir * speed
move_and_slide()
if is_on_floor() and level:
var ahead := global_position + Vector2(dir * 14.0, 0)
# 前方脚下没有实心格子(要掉下去了)或者前方是尖刺 → 掉头
if not level.is_solid(ahead + Vector2(0, 8)) or level.is_hazard(ahead + Vector2(0, -8)):
dir = -dir
if is_on_wall():
dir = -dir检查点是一个 Area2D:主角进入时调用 player.set_checkpoint(global_position) 更新复活点,并用 Tween 把旗子升上去。终点同理,只是改为发出通关信号。
碰撞层初步
关卡里有地面、主角、敌人、金币四类物体,但并不是所有东西之间都需要碰撞:主角不应该被金币挡住,敌人之间也不用互相推挤。Godot 用两个属性来控制:
collision_layer:我在哪些层。collision_mask:我要检测哪些层。
A 会被 B 挡住(或者 A 的区域能检测到 B),当且仅当 A 的 mask 包含 B 所在的层。给层起名字可以在 项目设置 → 层名称 → 2D 物理 里完成:
[layer_names]
2d_physics/layer_1="world"
2d_physics/layer_2="player"
2d_physics/layer_3="enemies"
2d_physics/layer_4="pickups"| 物体 | layer(我是) | mask(我检测) | 效果 |
|---|---|---|---|
| 地面(TileSet 物理层)、移动平台 | 1 world | 无 | 只被别人检测 |
| 主角 | 2 player | 1 world | 只被地面挡住,穿过敌人和金币 |
| 史莱姆身体 | 3 enemies | 1 world | 站在地上,史莱姆之间互不阻挡 |
| 史莱姆的 Hitbox、金币、检查点(Area2D) | 3 或 4 | 2 player | 只对主角触发 body_entered |
[node name="Player" type="CharacterBody2D" groups=["player"]]
collision_layer = 2
collision_mask = 1
floor_snap_length = 6.0
script = ExtResource("1")在 .tscn 里,layer 和 mask 存的是位掩码:第 n 层对应 2n−1。所以 collision_layer = 2 是第 2 层,4 是第 3 层,8 是第 4 层;同时在第 1 和第 3 层就是 1 + 4 = 5。在检查器里则是直接勾选格子。
主角不和史莱姆发生物理碰撞,那怎么知道被碰到了?史莱姆身上另有一个稍大的 Area2D(Hitbox),它的 mask 是第 2 层,主角一进入就触发信号,再根据主角是从上面落下来还是从侧面撞上来,决定是"踩扁"还是"受伤"(第 7 章的 stomp 代码)。物理阻挡和伤害判定分开处理,是动作游戏里很常见的做法。
想让史莱姆之间互相阻挡(不会重叠在一起走),最简单的改法是?
- 把史莱姆的 collision_layer 改成 1
- 把主角的 mask 加上第 3 层
- 把史莱姆的 collision_mask 加上第 3 层(改成 1 + 4 = 5)
史莱姆在第 3 层,只要它也检测第 3 层,就会被其他史莱姆挡住。改成第 1 层会让主角也被史莱姆挡住;改主角的 mask 只影响主角。
站在 AnimatableBody2D 平台上的角色没有被带走,最可能的原因是?
sync_to_physics被关掉了,或者平台是在_process里用非物理方式移动的- 平台的 collision_mask 没有包含玩家所在的层
- 角色用的是
is_on_floor()而不是is_on_wall()
角色是否被带走取决于引擎能否得到平台的速度。关闭 sync_to_physics 或者不在物理帧里移动,平台就相当于每帧瞬移。平台的 mask 与此无关(它不需要检测任何东西)。
小结
- TileSet 是瓦片字典:图集切出瓦片,物理层给碰撞,地形位用于自动连接,自定义数据层存任意属性。
- TileMapLayer 是一个图层一个节点;
set_cells_terrain_connect自动选瓦片,local_to_map/map_to_local换算坐标(向下取整、返回中心),get_cell_tile_data读属性。 - 旧的 TileMap 方法去掉图层参数即可迁移;Godot 3 的
world_to_map改名为local_to_map。 - Camera2D:平滑减少抖动,边界不拍关卡外,死区让小动作不晃镜头,前瞻让玩家看到前方;震动走
offset。 - Parallax2D 用
scroll_scale做纵深,repeat_size做无限重复。 - 移动平台用
AnimatableBody2D+sync_to_physics;碰撞层决定"谁和谁发生关系"。
到这里,第二部分的平台跳跃示例就完整了:可调的手感、有反馈的动画、可以直接修改的关卡和舒服的镜头。点"下载工程"可以在 Godot 4.7 里打开它,改 level.gd 里的字符画来设计你自己的关卡。