ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Godot 4实战:用TileMapLayer与Area2D构建横版投射物平台游戏

2026/10/1 1:07:24 拓冰建站 浏览量
Godot 4实战:用TileMapLayer与Area2D构建横版投射物平台游戏 这次我们直接动手做一个 Godot 4 的横版投射物平台游戏。所谓投射物平台游戏就是玩家在 2D 平台关卡里跑跳向敌人发射子弹、火球或能量弹核心玩法拆开其实就是三件事TileMapLayer 搭关卡、CharacterBody2D 控角色、Area2D 做子弹。三者拼接起来一个能跑的最小闭环就出现了。这个项目的重点不是美术而是把平台跳跃游戏里最容易出问题的两个系统——瓦片地图碰撞和投射物碰撞——一次讲透。从 Godot 4.3 开始官方用 TileMapLayer 取代了原来的 TileMap 节点做横版关卡时地面层负责碰撞、背景层只负责显示、装饰层单独排序层次清晰之后后面加机关、加陷阱、加二段跳都不会动到底层代码。文章的操作环境是 Godot 4.3 及以上版本脚本用 GDScript不需要额外插件。我会给出完整的 Player 脚本、Projectile 脚本、TileMapLayer 程序化铺地示例以及碰撞、穿透、对象池这类常见问题的排查思路。适合刚接触 Godot 4 的开发者也适合正在从 TileMap 迁移到 TileMapLayer 的 2D 游戏开发者。1. 技术方案速览先看这套方案由哪些部分构成心里有个谱再往下走。项目类型2D 横版平台跳跃 投射物战斗 Demo引擎版本Godot 4.3 及以上建议使用 4.3/4.4 标准版开发语言GDScript不依赖 .NET 环境和外部插件关卡节点TileMapLayer替代旧版 TileMap 的瓦片地图方案玩家角色CharacterBody2Dmove_and_slide处理移动与碰撞投射物Area2D监视body_entered/area_entered命中事件敌人StaticBody2D 或 CharacterBody2D提供take_damage()接口碰撞分层地形 Layer 1、玩家 Layer 2、敌人 Layer 3、子弹 Layer 4扩展方向对象池、抛物线子弹、追踪弹、穿透弹、Boss 战这套结构不是唯一答案但它是 2D 平台游戏里最常用、最容易调试的组合。角色用 CharacterBody2D是因为平台跳跃需要重力、速度、地面检测子弹用 Area2D是因为子弹不需要复杂的物理寻路只需要“碰到东西就触发事件”地面用 TileMapLayer是因为瓦片地图的物理形状由 TileSet 自动生成不需要手动画一大堆 StaticBody2D。2. 从 TileMap 到 TileMapLayer先弄清楚为什么用新节点Godot 4.3 之前瓦片地图主要用 TileMap 节点一个 TileMap 内部可以包含多个 layer每个 layer 又有各自的 TileSet、渲染排序和碰撞设置。这种设计的问题是所有瓦片数据都堆在一个节点里代码访问某一层时要先获取 layer 索引编辑器里也不够直观。从 Godot 4.3 开始官方推荐直接用 TileMapLayer。每个 TileMapLayer 就是一个独立的瓦片图层节点你可以把背景放一个节点、地面放一个节点、前景装饰放一个节点。它们各自单独设置 y_sort、碰撞、阴影遮挡、渲染顺序职责一目了然。对于“投射物平台游戏”这个具体场景优势很明显。地面层需要开启碰撞背景层不需要装饰层如果需要角色从柱子后面走过就单独开启 y_sort。在旧的 TileMap 里这些都要在同一个节点的 layer 属性里翻来翻去现在拆成节点之后场景树里直接能看到哪一层是干什么的调试时也只需要看对应节点。使用 TileMapLayer 时最常用的几个 API 要记牢extends TileMapLayer # 将全局坐标或鼠标坐标转换成瓦片格子坐标 func _get_map_position() - Vector2i: return local_to_map(get_local_mouse_position()) # 读取某个格子当前使用的瓦片资源 func _get_cell_info(cell_pos: Vector2i) - void: var source_id : get_cell_source_id(cell_pos) var atlas_coords : get_cell_atlas_coords(cell_pos) print(source: %s, atlas: %s % [source_id, atlas_coords]) # 设置某个格子的瓦片 func _set_cell_example(cell_pos: Vector2i) - void: set_cell(cell_pos, 0, Vector2i(0, 1))注意set_cell里的第二个参数是 source_id第三个参数是瓦片在 Atlas 图集中的坐标。source_id 具体是多少以 TileSet 编辑器里显示的编号为准通常第一个添加的 AtlasSource 是 0。3. 关卡数据与 TileSet 物理层配置搭建平台游戏关卡核心不是画瓦片而是把瓦片的“物理形状”配好。没有物理层角色和子弹会直接穿过地面。先在文件系统里新建一个 TileSet 资源。步骤是这样的在 FileSystem 面板中右键选择New Resource类型选择TileSet。打开 TileSet 编辑器和 Inspector 面板。在 TileSet 底部添加Atlas Source选择你的瓦片图集素材。在 Inspector 里给 TileSet 添加一个Physics Layer 0。选中图集中的某些瓦片在右侧的物理层编辑区域里画出碰撞多边形。保存 TileSet 资源。这样就完成了最关键的物理层配置。之后在场景里新建TileMapLayer节点把刚才的 TileSet 拖给节点的tile_set属性再用编辑器里的瓦片绘制工具在画布上刷地面和平台。如果关卡用脚本程序化生成代码可以这样写extends TileMapLayer export var ground_atlas_coords : Vector2i(0, 1) func _ready() - void: _build_ground(10, 5) func _build_ground(start_x: int, width: int) - void: for x in range(start_x, start_x width): set_cell( Vector2i(x, 0), 0, ground_atlas_coords )在正式开写之前建议先把项目结构和图层规划清楚。我的建议是图层节点内容是否开启碰撞是否开启 y_sortBackgroundLayer天空、远景建筑否否GroundLayer土地、平台、墙面是否ForegroundLayer柱子、草丛、装饰物否是AreaLayer陷阱、触发区域、敌人出生点按需求按需求背景层和地面层分开之后投射物只会与 GroundLayer 产生碰撞不会因为碰到背景瓦片而消失这是一个常见坑。4. 玩家场景重力、跳跃和射击玩家是一个 CharacterBody2D。它的节点结构如下Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── ProjectileSpawn (Marker2D) └── ShootCooldown (Timer)先到 Project Settings 里的 Input Map 添加自定义输入动作move_left、move_right、jump、shoot。分别绑定键盘上的 A/D、方向键左右、空格和 J 键。玩家脚本的完整实现extends CharacterBody2D export_category(移动参数) export var speed : 300.0 export var jump_velocity : -500.0 export var gravity : 1200.0 export_category(投射物) export var projectile_scene: PackedScene export var projectile_spawn: Marker2D onready var shoot_cooldown: Timer $ShootCooldown var facing : 1 func _physics_process(delta: float) - void: # 重力只在未落地时生效 if not is_on_floor(): velocity.y gravity * delta # 读取左右方向输入-1 表示左1 表示右 var direction : Input.get_axis(move_left, move_right) if direction ! 0.0: facing int(sign(direction)) velocity.x direction * speed # 跳跃只允许在地面触发 if Input.is_action_just_pressed(jump) and is_on_floor(): velocity.y jump_velocity # 发射子弹 if Input.is_action_just_pressed(shoot): _shoot() move_and_slide() func _shoot() - void: if projectile_scene null: return if shoot_cooldown.time_left 0.0: return var projectile : projectile_scene.instantiate() as Area2D projectile.global_position projectile_spawn.global_position projectile.direction Vector2.RIGHT * facing get_tree().current_scene.add_child(projectile) shoot_cooldown.start()这段脚本里有几个关键点velocity.y gravity * delta用物理增量时间计算保证不同帧率下重力效果一致。Input.get_axis返回 -1 到 1 的连续值用sign(direction)得到朝向。子弹必须添加到current_scene而不是添加到 Player 的子节点。如果加在 Player 下面子弹会继承 Player 的 Transform玩家一旦移动子弹就会出现错误的偏移。5. 投射物场景Area2D 子弹与命中判定子弹用 Area2D 实现节点结构如下Projectile (Area2D) ├── Sprite2D 或 AnimatedSprite2D ├── CollisionShape2D └── LifetimeTimer (Timer)子弹脚本extends Area2D export_category(飞行参数) export var speed : 600.0 export var lifetime : 2.0 export_category(伤害) export var damage : 1 # 发射时由 Player 赋值不要在 _ready 里写死方向 var direction : Vector2.RIGHT onready var lifetime_timer: Timer $LifetimeTimer func _ready() - void: body_entered.connect(_on_body_entered) area_entered.connect(_on_area_entered) lifetime_timer.timeout.connect(queue_free) lifetime_timer.start(lifetime) func _physics_process(delta: float) - void: position direction * speed * delta func _on_body_entered(body: Node2D) - void: if body.is_in_group(enemies): if body.has_method(take_damage): body.take_damage(damage) queue_free() elif body.is_in_group(ground): queue_free() func _on_area_entered(area: Area2D) - void: if area.is_in_group(hazards): queue_free()子弹能否命中目标取决于 Area2D 的碰撞图层设置。建议这样分配节点Collision LayerCollision Mask说明GroundLayer1无地形只做被碰撞对象Player21、3能踩地形也能被敌人碰撞Enemy31、2敌人和地形、玩家发生碰撞Projectile41、3子弹只检测地形和敌人注意Projectile 的 collision_mask 不要勾选第 2 层。否则子弹发射点如果和玩家碰撞体重叠会出现“子弹刚生成就打到自己”的问题。很多新手做平台射击时遇到子弹秒消失八成就是这个原因。如果要做更复杂的投射物比如需要反弹、穿透或者斜向弹道不建议继续用 Area2D 加位置位移可以换成 CharacterBody2Dextends CharacterBody2D var direction : Vector2.RIGHT var speed : 600.0 var damage : 1 func _physics_process(delta: float) - void: velocity direction * speed move_and_slide() for i in get_slide_collision_count(): var collider : get_slide_collision(i).get_collider() if collider ! null: _on_hit(collider) func _on_hit(collider: Node2D) - void: if collider.is_in_group(enemies): collider.take_damage(damage) queue_free() elif collider.is_in_group(ground): queue_free()CharacterBody2D 子弹的好处是能利用move_and_slide的滑动碰撞适合做贴墙滑行、反弹的投射物缺点是每发子弹都是物理体数量多时开销比 Area2D 大。普通火球、箭矢、能量弹Area2D 方案已经足够。6. 敌人与伤害判定敌人的最小版本可以做成 StaticBody2D不需要重力也不需要移动逻辑。节点结构Enemy (StaticBody2D) ├── Sprite2D └── CollisionShape2D脚本extends StaticBody2D export var health : 3 func take_damage(amount: int) - void: health - amount if health 0: queue_free()把 Enemy 节点加入enemies组。Godot 4 中可以通过 Inspector 顶部的 Group 编辑或者在_ready里添加func _ready() - void: add_to_group(enemies)敌人的 Collision Layer 设置为 3这样子弹的 mask1、3正好能检测到它。如果你想让敌人也参与攻击比如碰到玩家角色造成伤害可以在敌人身上挂一个额外的 Area2D或者让敌人使用 CharacterBody2D再在move_and_slide的碰撞回调里检测玩家。这里要注意敌人的take_damage是约定接口不是 Godot 内置方法。子弹脚本里用has_method(take_damage)做一次安全判断避免子弹撞到没有该方法的节点时报错。实际项目里更规范的做法是定义自定义类或接口但平台射击 Demo 阶段用组加方法判断是最直接的方式。7. 主场景组合玩家、地图、摄像机把刚才的组件拼到一个主场景Main.tscn里Main (Node2D) ├── BackgroundLayer (TileMapLayer) ├── GroundLayer (TileMapLayer) ├── ForegroundLayer (TileMapLayer) ├── Player (CharacterBody2D) │ ├── Sprite2D │ ├── CollisionShape2D │ ├── ProjectileSpawn (Marker2D) │ └── ShootCooldown (Timer) ├── Camera2D └── Enemy (StaticBody2D)Camera2D 挂在 Main 下面并设置跟随 Player最简单的方式是给 Camera2D 写一段脚本或直接做成 Player 子节点。推荐的是做成独立节点通过代码控制。extends Camera2D onready var player: CharacterBody2D get_node(../Player) func _process(_delta: float) - void: global_position player.global_position如果希望摄像机平滑可以在 Inspector 里开启Position Smoothing设置Smoothing Speed为 5 到 8 之间的值。这样角色跳跃和冲刺时画面不会剧烈抖动。主场景创建完成后在 Project Settings 的Application - Run - Main Scene中选择Main.tscn按 F5 运行。这时候你应该能看到角色落地、跳跃、发射子弹子弹命中敌人后敌人掉血消失命中地面后子弹销毁。8. 调试与性能观察平台游戏最常见的问题是“东西穿透了”和“看不见碰撞体”。调试时第一件事就是打开碰撞形状可视化。在运行调试状态下点击调试菜单里的Visible Collision Shapes或者按对应快捷键开启物理碰撞绘制。开启后所有 CollisionShape2D 和 TileMapLayer 生成的碰撞多边形会以绿色线条显示。如果地面瓦片没有碰撞线条说明 TileSet 的物理层没有画或者 TileMapLayer 的 collision_enabled 没有打开。观察性能时重点看两个方向一是物理帧消耗。投射物数量多起来以后物理检测会变高。默认物理帧率是 60 FPS在 Project Settings 的Physics - Common - Physics Ticks Per Second可以看到。如果子弹速度达到 1000 像素/秒一帧最多移动约 16.7 像素碰到薄碰撞体时容易直接穿过去。要解决隧穿常用方法有两个一是降低子弹速度二是把子弹改成射线检测每物理帧用PhysicsDirectSpaceState2D.intersect_ray从当前位置向子弹方向扫一圈。射线检测模板extends Node2D var direction : Vector2.RIGHT var speed : 1200.0 var damage : 1 func _physics_process(delta: float) - void: var space : get_world_2d().direct_space_state var query : PhysicsRayQueryParameters2D.create( global_position, global_position direction * speed * delta ) query.collision_mask 0b1010 # 第 2 层和第 4 层按实际图层调整 query.collide_with_areas true query.collide_with_bodies true var result : space.intersect_ray(query) if result.is_empty(): position direction * speed * delta else: var collider: Node2D result.get(collider) _handle_hit(collider)另一个性能点是子弹对象反复实例化和释放。频繁instantiate和queue_free在射击游戏中会产生垃圾对象抖动。如果制作弹幕或连发效果建议做对象池预先创建一批 Projectile不可见地放在场景里射击时激活一发失效后隐藏而不是销毁。Godot 没有内置对象池可以自己维护一个数组。9. 常见问题与排查方法把实际操作里最容易遇到的坑整理成一张表直接对号入座。问题现象可能原因排查方式解决方案角色掉出地图TileMapLayer 没有启用碰撞或 TileSet 没画物理层开启 Visible Collision Shapes检查地面是否有绿色碰撞线条在 TileSet 中为地面瓦片添加物理多边形并确认 TileMapLayer 的 collision_enabled 为 true子弹刚发射就消失Projectile 的 collision_mask 包含玩家层生成时与玩家碰撞体重叠检查 Projectile 的 Collision Layer / Mask 配置将子弹 mask 改为只包含地形层和敌人层子弹穿墙子弹速度过快一帧移动距离大于碰撞体厚度查看物理帧率与子弹单帧位移降低速度开启更细物理帧或改用射线检测玩家能走进地面里TileMapLayer 的碰撞图层与 Player 的 mask 不匹配检查 Player 的 collision_mask 是否包含地形层设置 Player mask 勾选地形所在层背景瓦片挡住了角色背景层开启了碰撞或 y_sort分别检查各 TileMapLayer 的碰撞和 y_sort 属性背景层关闭碰撞保持渲染顺序在 GroundLayer 之后TileMapLayer 代码 set_cell 无效果source_id 或 atlas_coords 与 TileSet 内容不匹配打印get_cell_source_id与get_cell_atlas_coords对比确认 TileSet 中 AtlasSource 的 ID 和瓦片坐标敌人不受伤害敌人未加入 enemies 组或子弹 mask 没包含敌人层检查敌人节点是否在组里子弹碰撞检测图是否勾选第 3 层在敌人的_ready执行add_to_group(enemies)并调整子弹 mask发射子弹时报错 “Parent node is busy setting up children”在 Player 的_ready阶段尝试添加子弹检查调用发射逻辑的位置把子弹实例化逻辑放到_physics_process或用户输入回调中这些问题是平台射击游戏里的高频坑按这个表排查大部分场景能在一分钟之内定位。10. 可继续扩展的方向当前这个 Demo 已经能完成“跑、跳、射、杀”的最小闭环。接下来想把它做成完整游戏可以从这几个方向扩展。第一是投射物类型。在 Projectile 上增加枚举或资源数组区分火球、箭矢、穿透弹、跟踪弹。跟踪弹可以每帧把direction朝最近的敌人方向旋转旋转角度用lerp_angle控制效果非常直接。抛物线投射物可以在 Projectile 里加一个重力值模拟投掷物落地后爆开的场景。第二是程序化关卡生成。用随机种子在_ready里遍历生成地面和平台再用set_cell写入 TileMapLayer。这一步做出来之后关卡就从一个固定场景变成可复用的数据驱动系统后续做无尽模式会非常方便。第三是对象池和批量敌人管理。把 Projectile 和敌人分批量管理限制场景中的活跃数量。特别是弹幕类 Boss如果不做对象池物理检测和节点销毁会成为性能瓶颈。第四是资源管理和版本控制。素材、TileSet、脚本、场景建议分目录管理。TileSet 资源不放进场景文件单独保存成.tres文件多人协作和版本合并会省很多事。第五是合规和应用边界。如果后续要做发布或商业化所使用的美术、音频素材必须确认授权涉及玩家数据或联机功能时要做好隐私处理。游戏开发本身不存在什么安全红线但素材版权和用户数据这两件事越早确认越好。最后说一个工程化建议先跑通最小闭环再往里面加系统。这个投射物平台游戏最值得先验证的就是子弹的碰撞图层配置和 TileMapLayer 的物理层。这两点通了后面加任何敌人、Boss、武器都只是在同一个框架上叠内容。把本文的 Player、Projectile、Enemy 三个脚本复制到你的项目里改一改输入映射和图层编号就能得到一套可以继续开发的横版射击原型。