GameTech/Godot 4.7 互动教程
目录示例游戏
第二部分 · 2D 基础 · 第 8 章

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 生成了这个文件,结果和手动配置完全相同)。

level/tileset.tres第 159–172 行tileset.tres:TileSet 本身的设置在工程中查看 ↗
[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:

level/tileset.tres第 120–125 行木平台:单向碰撞,只挡住从上往下落的物体在工程中查看 ↗
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) 这块,上、右、下三条边都挨着地面,只有左边是空的,所以它画了左描边:

level/tileset.tres第 52–59 行一块地面瓦片的地形位:左边没有标注,表示左边应该是空的在工程中查看 ↗
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:

main.tscn第 79–93 行main.tscn:背景、地面、装饰三个图层在工程中查看 ↗
[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 = false
  • Back:远景土块,collision_enabled = false 关掉碰撞,并用一个简单的着色器把颜色往天空色混合,看起来更远("空气透视")。
  • Ground:地面、木平台、尖刺,有碰撞,所有查询都针对这一层。
  • Decor:草丛、花、路牌,没有碰撞。

节点在场景树里的顺序就是绘制顺序:后面的画在前面的上面。需要前景遮挡角色时,可以再加一个排在主角后面的图层。

用字符画生成关卡

通常的做法是在编辑器里选中图层,用画笔、矩形、填充工具在画布上"刷"瓦片;用地形工具刷时会自动连接。示例为了让关卡结构一目了然,选择用代码根据一段字符画生成,每个字符一格:

level/level.gd第 28–51 行level.gd:整关的字符画(# 地面,= 木平台,^ 尖刺,o 金币,E 史莱姆……)在工程中查看 ↗
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....##################",
	"#################...###############.......#################...........#####################.........##################################################",
	"#################...###############^^^^^^^#################...........#####################.........##################################################",
	"#################...#######################################...........#####################^^^^^^^^^##################################################",
	"#################...#######################################...........################################################################################",
]
level/level.gd第 78–102 行把字符画变成瓦片在工程中查看 ↗
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 最常用的几个方法:

snippets/tilemap_api.gd第 7–16 行TileMapLayer 常用 API在工程中查看 ↗
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() 转换;图层不在原点或被缩放时,漏掉这一步就会查错格子。

示例把"查某个世界坐标处的瓦片属性"封装成了几个函数:

level/level.gd第 162–175 行level.gd:世界坐标 → 格子 → TileData → 自定义数据在工程中查看 ↗
## 世界坐标 → 地面层的格子 → 这一格的 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,碰到尖刺就受伤:

player/player.gd第 230–237 行player.gd:用自定义数据判断尖刺在工程中查看 ↗
## 尖刺没有碰撞形状:每帧用身体上的几个采样点去查瓦片的自定义数据 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,邻居会被重新匹配:

level/level.gd第 187–205 行level.gd:游戏里用鼠标改地形在工程中查看 ↗
## 在游戏里用鼠标改地形:左键放地面,右键清除。邻居格子的瓦片会自动重新匹配。
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)

"显示碰撞和危险格"的调试图层,就是遍历所有用到的格子,读出每块瓦片的碰撞多边形和自定义数据画出来:

level/collision_debug.gd第 9–25 行collision_debug.gd:读取 TileData 画出碰撞形状在工程中查看 ↗
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)) 返回什么?

  1. Vector2i(0, 2)
  2. Vector2i(-1, 2)
  3. Vector2i(0, 3)
  4. 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 被标记为废弃。

snippets/tilemap_api.gd第 21–28 行新旧写法对照在工程中查看 ↗
## 旧写法(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 节点",然后把代码里的图层参数去掉即可。

迁移的坑:Godot 3 的 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 提供了几种手段来改善,示例把它们都做成了参数:

fx/game_camera.gd第 8–34 行game_camera.gd:相机参数,setter 把值转给 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 是总开关。示例在开局时把它设成整个关卡的矩形,这样站在关卡最左边时,主角在画面左侧,而不是让画面露出关卡外的空白:

main.gd第 13–18 行main.gd:开局时设置角色位置和相机边界在工程中查看 ↗
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 没有内置前瞻,但很容易实现:既然相机跟随的是相机节点自己的位置,那就把相机节点往角色面对的方向挪一段。跑得越快挪得越多,再用指数平滑避免转身时镜头猛甩。这样角色前方能看到更多东西,玩家有时间对前面的敌人和坑做出反应。

fx/game_camera.gd第 80–85 行game_camera.gd:前瞻在工程中查看 ↗
## 前瞻:相机目标点朝主角面对的方向偏移,跑得越快偏得越多;用指数平滑避免转身时镜头猛甩。
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 同样的顺序处理:前瞻得到目标点 → 死区推动中心 → 边界限制 → 平滑。黄框是死区,红点是目标点,下方的小地图里黑框是相机看到的范围。

在示例里打开"显示死区和目标点"。先把前瞻距离调到 0、水平死区调到 0.4,向右跑:角色要跑到屏幕右侧才推动画面,前方视野很少。再把前瞻调到 160:角色跑起来后会停在画面偏左的位置,右边留出大片空间。最后把平滑速度调到 0 和 2 各跑一次,比较画面的"硬"和"拖"。

缩放

zoom 大于 1 时放大(看到的范围变小),小于 1 时缩小。注意边界:缩小到看到的范围比关卡还大时,左右边界无法同时满足,画面会贴在一边。把示例的缩放拖到 0.5 可以看到这一点,这时可以把"限制在关卡内"关掉对比。

相机的震动(第 7 章)改的是 offset,前瞻改的是相机节点的 position,两者互不干扰。如果你把震动也写进 position,平滑会把震动"抹平",死区也会吃掉小幅度的震动。另外,主角复活传送后要调用 reset_smoothing(),否则镜头会从死亡位置一路滑过整个关卡。

Parallax2D:视差背景

远处的东西看起来移动得慢。视差背景利用这一点营造纵深:天空不动,远山动得很慢,近处的山坡快一些,地面和角色正常速度。Godot 4.3 新增的 Parallax2D 节点取代了旧的 ParallaxBackground + ParallaxLayer 组合,每一层就是一个 Parallax2D 节点:

main.tscn第 42–62 行main.tscn:云和远山两层视差在工程中查看 ↗
[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 = false
  • scroll_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() 遇到这些字符时实例化对应的场景,放在格子中心或格子底边:

level/level.gd第 122–153 行level.gd:生成关卡里的物体在工程中查看 ↗
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() 时就会把这个速度加到自己身上。

world/moving_platform.gd第 13–26 行moving_platform.gd:循环 Tween 驱动 AnimatableBody2D在工程中查看 ↗
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()。它利用本章的瓦片查询来巡逻:脚下前方没有实心格子(快到边缘了)或者前方是尖刺,就掉头。

world/slime.gd第 21–33 行slime.gd:利用瓦片数据巡逻在工程中查看 ↗
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 物理 里完成:

project.godot第 46–51 行project.godot:给前四个物理层起名在工程中查看 ↗
[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 player1 world只被地面挡住,穿过敌人和金币
史莱姆身体3 enemies1 world站在地上,史莱姆之间互不阻挡
史莱姆的 Hitbox、金币、检查点(Area2D)3 或 42 player只对主角触发 body_entered
player/player.tscn第 568–572 行player.tscn:主角在第 2 层,只检测第 1 层在工程中查看 ↗
[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 代码)。物理阻挡和伤害判定分开处理,是动作游戏里很常见的做法。

想让史莱姆之间互相阻挡(不会重叠在一起走),最简单的改法是?

  1. 把史莱姆的 collision_layer 改成 1
  2. 把主角的 mask 加上第 3 层
  3. 把史莱姆的 collision_mask 加上第 3 层(改成 1 + 4 = 5)

史莱姆在第 3 层,只要它也检测第 3 层,就会被其他史莱姆挡住。改成第 1 层会让主角也被史莱姆挡住;改主角的 mask 只影响主角。

站在 AnimatableBody2D 平台上的角色没有被带走,最可能的原因是?

  1. sync_to_physics 被关掉了,或者平台是在 _process 里用非物理方式移动的
  2. 平台的 collision_mask 没有包含玩家所在的层
  3. 角色用的是 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 里的字符画来设计你自己的关卡。