6. 关卡设计与滚动地图
2026/4/13大约 9 分钟
关卡设计与滚动地图
关卡设计概述
什么是关卡设计?
关卡设计就是决定"玩家在每一关经历什么"。就像电影的剧本一样——哪段是和平的、哪段是紧张的、什么时候出现Boss。好的关卡设计让玩家感到"一直有新鲜感",不会觉得无聊。
关卡的基本结构
一个典型的魂斗罗关卡分为以下几个部分:
┌──────────────────────────────────────────────┐
│ 开始区域 │
│ (简单敌人,让玩家热身) │
├──────────────────────────────────────────────┤
│ 第一个挑战区 │
│ (引入新敌人类型,稍难一点) │
├──────────────────────────────────────────────┤
│ 安全区域 │
│ (没有敌人,让玩家喘口气) │
├──────────────────────────────────────────────┤
│ 密集战斗区 │
│ (大量敌人,弹幕密集) │
├──────────────────────────────────────────────┤
│ 道具奖励区 │
│ (有武器升级道具,奖励玩家) │
├──────────────────────────────────────────────┤
│ Boss战区域 │
│ (关底Boss,打败就通关) │
└──────────────────────────────────────────────┘关卡节奏的核心原则
就像音乐有"强弱强弱"的节奏一样,关卡也有"紧张→放松→紧张→放松"的节奏。如果一直很紧张,玩家会累;如果一直很轻松,玩家会无聊。交替出现才是最好的。
视差滚动背景
什么是视差滚动?
视差滚动就是"背景的近处和远处以不同速度移动",制造出"深度感"。你可以想象坐在火车上往外看——近处的树木飞快地往后退,远处的山慢慢移动,更远的天空几乎不动。这种速度差异让你的大脑觉得"有深度",而不是一个平面。
三层视差结构
| 层级 | 移动速度 | 内容 | 与摄像机的距离 |
|---|---|---|---|
| 远景层 | 摄像机的 20% | 天空、远山、云朵 | 最远(Z = 50) |
| 中景层 | 摄像机的 50% | 树林、建筑、山丘 | 中间(Z = 30) |
| 近景层 | 摄像机的 80% | 灌木、石头、栅栏 | 较近(Z = 15) |
| 游戏层 | 摄像机的 100% | 地面、角色、敌人 | 最近(Z = 0) |
远景层(20%速度): ☁ ☁ ☁ 云朵慢慢飘
中景层(50%速度): 🌲🌲🌲🌲 树木中等速度后退
近景层(80%速度): 🌿🌿🌿 灌木快速后退
游戏层(100%速度): 👤 →→→ 角色/敌人正常速度视差滚动实现
C#
using Godot;
/// <summary>
/// 视差背景层 - 以不同速度跟随摄像机移动
/// </summary>
public partial class ParallaxLayer : Node3D
{
/// <summary>
/// 视差系数(0~1)
/// 0 = 完全不动(像天空)
/// 1 = 完全跟随摄像机(像地面)
/// </summary>
[Export] public float ParallaxFactor = 0.5f;
private Camera3D _camera;
private Vector3 _cameraStartPos;
public override void _Ready()
{
_camera = GetViewport().GetCamera3D();
if (_camera != null)
{
_cameraStartPos = _camera.GlobalPosition;
}
}
public override void _Process(double delta)
{
if (_camera == null) return;
// 计算摄像机移动了多少
Vector3 cameraOffset = _camera.GlobalPosition - _cameraStartPos;
// 只取X轴方向的偏移(横向滚动)
float offsetX = cameraOffset.X;
// 按视差系数移动背景层
Position = new Vector3(
_cameraStartPos.X + offsetX * ParallaxFactor,
Position.Y,
Position.Z
);
}
}
/// <summary>
/// 视差背景管理器 - 管理所有背景层
/// </summary>
public partial class ParallaxBackground : Node3D
{
[Export] public ParallaxLayer FarLayer; // 远景
[Export] public ParallaxLayer MidLayer; // 中景
[Export] public ParallaxLayer NearLayer; // 近景
public override void _Ready()
{
// 设置各层的视差系数
if (FarLayer != null) FarLayer.ParallaxFactor = 0.2f;
if (MidLayer != null) MidLayer.ParallaxFactor = 0.5f;
if (NearLayer != null) NearLayer.ParallaxFactor = 0.8f;
}
}GDScript
extends Node3D
## 视差背景层 - 以不同速度跟随摄像机移动
## 视差系数(0~1)
## 0 = 完全不动(像天空)
## 1 = 完全跟随摄像机(像地面)
@export var parallax_factor: float = 0.5
var _camera: Camera3D
var _camera_start_pos: Vector3
func _ready():
_camera = get_viewport().get_camera_3d()
if _camera:
_camera_start_pos = _camera.global_position
func _process(_delta):
if not _camera:
return
# 计算摄像机移动了多少
var camera_offset = _camera.global_position - _camera_start_pos
# 只取X轴方向的偏移(横向滚动)
var offset_x = camera_offset.x
# 按视差系数移动背景层
position = Vector3(
_camera_start_pos.x + offset_x * parallax_factor,
position.y,
position.z
)地形设计
地形元素
| 地形类型 | 用途 | 实现方式 |
|---|---|---|
| 地面 | 角色行走的基础 | StaticBody3D + 碰撞 |
| 平台 | 可以跳上去的高处 | StaticBody3D,放在空中 |
| 台阶 | 逐级升高的地形 | 多个平台组合 |
| 坑洞 | 掉下去就死 | 地面中断,加触发器 |
| 水面 | 装饰性障碍 | 带动画的平面 |
| 可破坏墙 | 打碎后通过 | 需要多次射击 |
平台场景
C#
using Godot;
/// <summary>
/// 可站立平台 - 角色可以从下方跳上来
/// </summary>
public partial class Platform : StaticBody3D
{
[Export] public Vector2 PlatformSize = new Vector2(4, 1);
public override void _Ready()
{
// 平台模型
var mesh = new MeshInstance3D();
var boxMesh = new BoxMesh();
boxMesh.Size = new Vector3(PlatformSize.X, PlatformSize.Y, 5);
mesh.Mesh = boxMesh;
var material = new StandardMaterial3D();
material.AlbedoColor = new Color(0.5f, 0.4f, 0.3f);
mesh.MaterialOverride = material;
AddChild(mesh);
// 碰撞体
var collision = new CollisionShape3D();
var shape = new BoxShape3D();
shape.Size = new Vector3(PlatformSize.X, PlatformSize.Y, 5);
collision.Shape = shape;
AddChild(collision);
}
}GDScript
extends StaticBody3D
## 可站立平台 - 角色可以从下方跳上来
@export var platform_size: Vector2 = Vector2(4, 1)
func _ready():
# 平台模型
var mesh = MeshInstance3D.new()
var box_mesh = BoxMesh.new()
box_mesh.size = Vector3(platform_size.x, platform_size.y, 5)
mesh.mesh = box_mesh
var material = StandardMaterial3D.new()
material.albedo_color = Color(0.5, 0.4, 0.3)
mesh.material_override = material
add_child(mesh)
# 碰撞体
var collision = CollisionShape3D.new()
var shape = BoxShape3D.new()
shape.size = Vector3(platform_size.x, platform_size.y, 5)
collision.shape = shape
add_child(collision)坑洞(死亡区域)
C#
using Godot;
/// <summary>
/// 死亡区域 - 掉进去就死
/// </summary>
public partial class DeathZone : Area3D
{
public override void _Ready()
{
BodyEntered += OnBodyEntered;
}
private void OnBodyEntered(Node3D body)
{
if (body is Player player)
{
player.TakeDamage(999); // 直接秒杀
GD.Print("玩家掉入坑洞!");
}
}
}GDScript
extends Area3D
## 死亡区域 - 掉进去就死
func _ready():
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node3D):
if body is Player:
body.take_damage(999) # 直接秒杀
print("玩家掉入坑洞!")关卡触发器
什么是触发器?
触发器就是"当玩家走到某个位置时,自动发生某件事"。比如:走到桥头,桥断了;走到某个区域,敌人从四面八方涌来;走到关底,Boss出现了。触发器是让关卡"活起来"的关键。
触发器类型
| 触发器类型 | 触发条件 | 触发效果 |
|---|---|---|
| 敌人触发器 | 玩家进入区域 | 生成一波敌人 |
| 事件触发器 | 玩家进入区域 | 触发剧情事件(桥断/追击) |
| Boss触发器 | 玩家到达位置 | Boss出现,锁住退路 |
| 检查点 | 玩家到达位置 | 保存进度 |
| 终点触发器 | 玩家到达位置 | 关卡通关 |
触发器实现
C#
using Godot;
/// <summary>
/// 关卡触发器 - 玩家进入区域时触发事件
/// </summary>
public partial class LevelTrigger : Area3D
{
[Export] public string TriggerId = ""; // 触发器唯一ID
[Export] public bool TriggerOnce = true; // 是否只触发一次
[Export] public string EventName = ""; // 触发的事件名称
private bool _hasTriggered = false;
public override void _Ready()
{
BodyEntered += OnBodyEntered;
// 创建碰撞体(一个不可见的区域)
var collision = new CollisionShape3D();
var shape = new BoxShape3D();
shape.Size = new Vector3(2, 4, 5); // 宽2米,高4米,深5米
collision.Shape = shape;
AddChild(collision);
}
private void OnBodyEntered(Node3D body)
{
if (!(body is Player)) return;
// 如果只触发一次,且已经触发过
if (TriggerOnce && _hasTriggered) return;
_hasTriggered = true;
GD.Print($"触发器 [{TriggerId}] 被激活!事件: {EventName}");
// 通知关卡管理器
var levelManager = GetParent();
if (levelManager != null && levelManager.HasMethod("OnTrigger"))
{
levelManager.Call("OnTrigger", TriggerId, EventName);
}
}
}GDScript
extends Area3D
## 关卡触发器 - 玩家进入区域时触发事件
@export var trigger_id: String = "" # 触发器唯一ID
@export var trigger_once: bool = true # 是否只触发一次
@export var event_name: String = "" # 触发的事件名称
var _has_triggered: bool = false
func _ready():
body_entered.connect(_on_body_entered)
# 创建碰撞体(一个不可见的区域)
var collision = CollisionShape3D.new()
var shape = BoxShape3D.new()
shape.size = Vector3(2, 4, 5) # 宽2米,高4米,深5米
collision.shape = shape
add_child(collision)
func _on_body_entered(body: Node3D):
if not body is Player:
return
# 如果只触发一次,且已经触发过
if trigger_once and _has_triggered:
return
_has_triggered = true
print("触发器 [%s] 被激活!事件: %s" % [trigger_id, event_name])
# 通知关卡管理器
var level_manager = get_parent()
if level_manager and level_manager.has_method("OnTrigger"):
level_manager.OnTrigger(trigger_id, event_name)关卡管理器
关卡管理器是整个关卡的大脑。它负责协调所有子系统:背景滚动、敌人生成、触发器事件、Boss出现。
C#
using Godot;
/// <summary>
/// 关卡管理器 - 协调关卡中的所有系统
/// </summary>
public partial class LevelManager : Node3D
{
[Export] public WaveManager WaveManager;
[Export] public string LevelName = "丛林关卡";
private int _score = 0;
private bool _bossSpawned = false;
public override void _Ready()
{
GD.Print($"关卡开始: {LevelName}");
// 初始化:开始第一波敌人
if (WaveManager != null)
{
WaveManager.StartNextWave();
}
}
/// <summary>
/// 处理触发器事件
/// </summary>
public void OnTrigger(string triggerId, string eventName)
{
switch (eventName)
{
case "spawn_wave":
WaveManager?.StartNextWave();
break;
case "spawn_boss":
if (!_bossSpawned)
{
SpawnBoss();
_bossSpawned = true;
}
break;
case "bridge_break":
BreakBridge();
break;
case "checkpoint":
SaveCheckpoint(triggerId);
break;
case "level_complete":
LevelComplete();
break;
}
}
private void SpawnBoss()
{
GD.Print("Boss 出现了!");
// Boss生成逻辑在Boss战章节实现
}
private void BreakBridge()
{
GD.Print("桥断了!玩家必须跳过去!");
// 桥断裂动画和碰撞体移除
}
private void SaveCheckpoint(string id)
{
GD.Print($"检查点保存: {id}");
// 保存玩家位置和状态
}
private void LevelComplete()
{
GD.Print($"关卡 {LevelName} 通关!");
// 显示通关画面,切换到下一关
}
/// <summary>
/// 增加分数
/// </summary>
public void AddScore(int points)
{
_score += points;
GD.Print($"当前分数: {_score}");
}
}GDScript
extends Node3D
## 关卡管理器 - 协调关卡中的所有系统
@export var wave_manager: WaveManager
@export var level_name: String = "丛林关卡"
var _score: int = 0
var _boss_spawned: bool = false
func _ready():
print("关卡开始: %s" % level_name)
# 初始化:开始第一波敌人
if wave_manager:
wave_manager.start_next_wave()
## 处理触发器事件
func on_trigger(trigger_id: String, event_name: String):
match event_name:
"spawn_wave":
if wave_manager:
wave_manager.start_next_wave()
"spawn_boss":
if not _boss_spawned:
_spawn_boss()
_boss_spawned = true
"bridge_break":
_break_bridge()
"checkpoint":
_save_checkpoint(trigger_id)
"level_complete":
_level_complete()
func _spawn_boss():
print("Boss 出现了!")
# Boss生成逻辑在Boss战章节实现
func _break_bridge():
print("桥断了!玩家必须跳过去!")
# 桥断裂动画和碰撞体移除
func _save_checkpoint(id: String):
print("检查点保存: %s" % id)
# 保存玩家位置和状态
func _level_complete():
print("关卡 %s 通关!" % level_name)
# 显示通关画面,切换到下一关
## 增加分数
func add_score(points: int):
_score += points
print("当前分数: %d" % _score)关卡数据结构
为了让关卡设计更灵活,我们可以用数据来描述关卡布局:
| 数据字段 | 类型 | 说明 |
|---|---|---|
level_name | 字符串 | 关卡名称 |
background_theme | 字符串 | 背景主题(丛林/基地/雪山) |
music_track | 字符串 | 背景音乐 |
platforms | 数组 | 平台位置和大小列表 |
enemies | 数组 | 敌人类型和出现位置 |
triggers | 数组 | 触发器位置和事件 |
boss_position | 向量 | Boss出现位置 |
关卡数据的意义
把关卡数据从代码中分离出来,有两个好处:
- 非程序员也能设计关卡——美术或策划只需要改数据文件,不用写代码
- 方便做很多关——复制数据文件,修改参数就能做出新关卡
常见问题
| 问题 | 原因 | 解决方法 |
|---|---|---|
| 背景不滚动 | 视差层没连接摄像机 | 确保获取到摄像机引用 |
| 背景滚动太快 | 视差系数设错了 | 远景用小值(0.10.3),近景用大值(0.60.9) |
| 角色掉出地图 | 地面有缺口 | 检查地面碰撞体是否连续 |
| 触发器不触发 | 碰撞体太大或太小 | 调整碰撞体的 Size |
| 触发器重复触发 | 没设为只触发一次 | 设置 TriggerOnce = true |
本章小结
本章我们实现了完整的关卡系统:
- 视差滚动背景——三层背景以不同速度移动,制造深度感
- 地形设计——平台、台阶、坑洞等可站立和危险区域
- 关卡触发器——走到特定位置触发事件
- 关卡管理器——统一协调所有子系统
- 关卡数据结构——用数据驱动关卡设计
关卡设计的黄金法则
- 每30秒至少有一个"新东西"(新敌人、新地形、新事件)
- 每2分钟有一个"高潮"(大量敌人或Boss战)
- 高潮之后给玩家10-20秒的休息时间
- 道具放在"危险但能拿到"的位置
- 永远不要让玩家不知道该往哪走
