6. 关卡设计
2026/4/14大约 7 分钟
6. 超级玛丽奥——关卡设计
简介
关卡设计就是把前面做好的所有元素(玩家、敌人、道具、地形)组合成一个可以玩的关卡。在 Godot 中,我们用 TileMap 来绘制地形——就像在画板上用不同颜色的方块画画一样。
TileMap 是 Godot 中用于绘制2D关卡的工具。你可以把它想象成一个巨大的贴纸板——上面有很多小格子,每个格子可以贴上一张"贴纸"(地面的砖块、天空的云朵等),拼起来就是一个完整的关卡。
TileMap 基础
什么是 TileSet?
TileSet(瓦片集)是所有可用"贴纸"的集合。一个 TileSet 包含多种类型的瓦片:
| 瓦片类型 | 用途 | 示例 |
|---|---|---|
| 地面瓦片 | 玛丽奥可以站立的地面 | 泥土、草地、石头 |
| 砖块瓦片 | 可以被顶开的砖块 | 普通砖块、问号砖块 |
| 装饰瓦片 | 纯装饰,没有碰撞 | 云朵、灌木、山丘 |
| 管道瓦片 | 场景装饰 | 绿色管道 |
TileSet 配置
在 Godot 中创建 TileSet 资源:
- 在资源面板右键 → 新建 → TileSet
- 打开 TileSet 编辑器面板
- 添加纹理图集(Sprite Sheet)
- 为每种瓦片设置属性:
| 属性 | 说明 | 值 |
|---|---|---|
| physics_layer_0/collision_polygon | 碰撞形状 | 地面和砖块有碰撞 |
| custom_data/type | 自定义数据 | ground / brick / question / decoration |
| custom_data/solid | 是否实体 | true/false |
问号砖块
问号砖块是超级玛丽奥最标志性的元素——从下面顶它,会弹出道具或金币。
// QuestionBlock.cs - 问号砖块
using Godot;
public partial class QuestionBlock : StaticBody2D
{
// 砖块状态
private bool _isUsed = false;
// 弹出内容类型
[Export] public string ContentType { get; set; } = "coin"; // coin / mushroom / fire_flower / star
// 节点引用
private AnimatedSprite2D _sprite;
private Area2D _hitArea; // 检测从下方顶
public override void _Ready()
{
_sprite = GetNode<AnimatedSprite2D>("AnimatedSprite2D");
_sprite.Play("idle"); // 问号闪烁动画
// 连接被顶的检测区域
_hitArea = GetNode<Area2D>("HitArea");
_hitArea.BodyEntered += OnBodyEntered;
}
// 玩家从下方顶砖块
private void OnBodyEntered(Node2D body)
{
if (_isUsed) return;
if (!body.IsInGroup("player")) return;
// 检查是否从下方碰撞(玩家的底部碰到砖块的顶部)
var player = (Player)body;
if (player.Velocity.Y < 0) // 玩家正在向上移动(跳跃中)
{
HitBlock(player);
}
}
// 砖块被顶
private void HitBlock(Player player)
{
_isUsed = true;
// 播放被顶动画
_sprite.Play("used"); // 变成灰色空砖块
// 弹出动画(砖块微微弹起再落下)
var tween = CreateTween();
tween.TweenProperty(this, "position:y", Position.Y - 4, 0.05f);
tween.TweenProperty(this, "position:y", Position.Y, 0.05f);
// 根据内容类型弹出对应物品
SpawnContent(player);
// 播放音效
AudioManager.Instance.PlaySfx("bump");
}
// 弹出内容
private void SpawnContent(Player player)
{
switch (ContentType)
{
case "coin":
SpawnCoin();
break;
case "mushroom":
if (player.IsSmall())
SpawnItem("mushroom");
else
SpawnItem("fire_flower");
break;
case "fire_flower":
SpawnItem("fire_flower");
break;
case "star":
SpawnItem("star");
break;
}
}
// 弹出金币
private void SpawnCoin()
{
var coin = GD.Load<PackedScene>("res://scenes/items/coin.tscn").Instantiate<Coin>();
coin.IsPopCoin = true;
coin.Position = Position + new Vector2(0, -24);
GetParent().AddChild(coin);
GameManager.Instance.AddCoin();
}
// 弹出道具
private void SpawnItem(string itemType)
{
var scene = GD.Load<PackedScene>($"res://scenes/items/{itemType}.tscn");
var item = scene.Instantiate<Item>();
item.Position = Position + new Vector2(0, -16);
GetParent().AddChild(item);
// 延迟激活(先弹出再移动)
var timer = GetTree().CreateTimer(0.2);
timer.Timeout += item.Activate;
}
}# question_block.gd - 问号砖块
extends StaticBody2D
# 砖块状态
var is_used: bool = false
# 弹出内容类型
@export var content_type: String = "coin"
# 节点引用
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
func _ready() -> void:
sprite.play("idle")
# 连接被顶的检测区域
var hit_area = $HitArea as Area2D
hit_area.body_entered.connect(on_body_entered)
## 玩家从下方顶砖块
func on_body_entered(body: Node2D) -> void:
if is_used:
return
if not body.is_in_group("player"):
return
# 检查是否从下方碰撞
var player = body
if player.velocity.y < 0:
hit_block(player)
## 砖块被顶
func hit_block(player: Node) -> void:
is_used = true
# 播放被顶动画
sprite.play("used")
# 弹出动画
var tween = create_tween()
tween.tween_property(self, "position:y", position.y - 4, 0.05)
tween.tween_property(self, "position:y", position.y, 0.05)
# 弹出对应物品
spawn_content(player)
# 播放音效
AudioManager.play_sfx("bump")
## 弹出内容
func spawn_content(player: Node) -> void:
match content_type:
"coin":
spawn_coin()
"mushroom":
if player.is_small():
spawn_item("mushroom")
else:
spawn_item("fire_flower")
"fire_flower":
spawn_item("fire_flower")
"star":
spawn_item("star")
## 弹出金币
func spawn_coin() -> void:
var coin = load("res://scenes/items/coin.tscn").instantiate()
coin.is_pop_coin = true
coin.position = position + Vector2(0, -24)
get_parent().add_child(coin)
GameManager.add_coin()
## 弹出道具
func spawn_item(item_type: String) -> void:
var scene = load("res://scenes/items/%s.tscn" % item_type)
var item = scene.instantiate()
item.position = position + Vector2(0, -16)
get_parent().add_child(item)
# 延迟激活
var timer = get_tree().create_timer(0.2)
timer.timeout.connect(item.activate)普通砖块
普通砖块在小玛丽奥顶时会"弹一下"(不变),在大玛丽奥顶时会被顶碎。
// BrickBlock.cs - 普通砖块
public partial class BrickBlock : StaticBody2D
{
private AnimatedSprite2D _sprite;
public override void _Ready()
{
_sprite = GetNode<AnimatedSprite2D>("AnimatedSprite2D");
var hitArea = GetNode<Area2D>("HitArea");
hitArea.BodyEntered += OnBodyEntered;
}
private void OnBodyEntered(Node2D body)
{
if (!body.IsInGroup("player")) return;
var player = (Player)body;
if (player.Velocity.Y < 0)
{
HitBlock(player);
}
}
private void HitBlock(Player player)
{
if (player.IsSmall())
{
// 小玛丽奥:只弹一下
Bounce();
AudioManager.Instance.PlaySfx("bump");
}
else
{
// 大玛丽奥:顶碎
Break();
AudioManager.Instance.PlaySfx("brick_break");
}
}
// 弹一下
private void Bounce()
{
var tween = CreateTween();
tween.TweenProperty(this, "position:y", Position.Y - 4, 0.05f);
tween.TweenProperty(this, "position:y", Position.Y, 0.05f);
}
// 破碎
private void Break()
{
// 生成砖块碎片
for (int i = 0; i < 4; i++)
{
CreateDebris();
}
// 移除砖块
QueueFree();
}
// 创建碎片
private void CreateDebris()
{
var debris = new RigidBody2D
{
Position = Position + new Vector2(
(float)GD.RandRange(-8, 8),
(float)GD.RandRange(-8, 8)
),
GravityScale = 2.0f
};
// 碎片外观
var sprite = new Sprite2D
{
Texture = _sprite.SpriteFrames.GetFrameTexture("idle", 0),
RegionEnabled = true,
RegionRect = new Rect2(
(float)GD.RandRange(0, 8),
(float)GD.RandRange(0, 8),
8, 8
)
};
debris.AddChild(sprite);
// 碎片物理:随机方向弹出
debris.ApplyCentralImpulse(new Vector2(
(float)GD.RandRange(-100, 100),
(float)GD.RandRange(-200, -50)
));
GetParent().AddChild(debris);
// 1秒后销毁碎片
var timer = debris.GetTree().CreateTimer(1.0);
timer.Timeout += debris.QueueFree;
}
}# brick_block.gd - 普通砖块
extends StaticBody2D
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
func _ready() -> void:
var hit_area = $HitArea as Area2D
hit_area.body_entered.connect(on_body_entered)
func on_body_entered(body: Node2D) -> void:
if not body.is_in_group("player"):
return
if body.velocity.y < 0:
hit_block(body)
func hit_block(player: Node) -> void:
if player.is_small():
bounce()
AudioManager.play_sfx("bump")
else:
break_brick()
AudioManager.play_sfx("brick_break")
## 弹一下
func bounce() -> void:
var tween = create_tween()
tween.tween_property(self, "position:y", position.y - 4, 0.05)
tween.tween_property(self, "position:y", position.y, 0.05)
## 破碎
func break_brick() -> void:
for i in range(4):
create_debris()
queue_free()
## 创建碎片
func create_debris() -> void:
var debris = RigidBody2D.new()
debris.position = position + Vector2(randf_range(-8, 8), randf_range(-8, 8))
debris.gravity_scale = 2.0
var debris_sprite = Sprite2D.new()
debris_sprite.texture = sprite.sprite_frames.get_frame_texture("idle", 0)
debris_sprite.region_enabled = true
debris_sprite.region_rect = Rect2(randf_range(0, 8), randf_range(0, 8), 8, 8)
debris.add_child(debris_sprite)
debris.apply_central_impulse(Vector2(randf_range(-100, 100), randf_range(-200, -50)))
get_parent().add_child(debris)
var timer = debris.get_tree().create_timer(1.0)
timer.timeout.connect(debris.queue_free)管道
管道是超级玛丽奥的经典场景元素。有些管道里住着食人花。
管道场景:
Pipe (StaticBody2D)
├── PipeTop (Sprite2D) # 管道顶部(较宽)
├── PipeBody (Sprite2D) # 管道身体(较窄)
└── CollisionShape2D # 碰撞形状管道本身没有特殊逻辑,只是一个静态障碍物。食人花作为独立的敌人放在管道内部。
旗杆终点
旗杆是每关的终点,玛丽奥碰到旗杆就通关。
// Flagpole.cs - 旗杆
public partial class Flagpole : Area2D
{
private AnimatedSprite2D _flagSprite;
private bool _isReached = false;
public override void _Ready()
{
_flagSprite = GetNode<AnimatedSprite2D>("Flag");
BodyEntered += OnBodyEntered;
}
private void OnBodyEntered(Node2D body)
{
if (_isReached) return;
if (!body.IsInGroup("player")) return;
_isReached = true;
OnFlagReached(body as Player);
}
private void OnFlagReached(Player player)
{
// 根据触碰高度计算得分
float flagY = player.Position.Y;
int score = flagY switch
{
<= 100 => 5000, // 顶部
<= 200 => 2000, // 上部
<= 300 => 800, // 中部
<= 400 => 100, // 下部
_ => 50 // 底部
};
GameManager.Instance.AddScore(score);
// 旗帜降下动画
var tween = CreateTween();
tween.TweenProperty(_flagSprite, "position:y", 80, 1.0f);
// 玩家滑下旗杆
player.StartFlagpoleSequence();
// 播放音效
AudioManager.Instance.PlaySfx("flagpole");
// 延迟后触发关卡通过
var timer = GetTree().CreateTimer(3.0);
timer.Timeout += GameManager.Instance.LevelComplete;
}
}# flagpole.gd - 旗杆
extends Area2D
@onready var flag_sprite: AnimatedSprite2D = $Flag
var is_reached: bool = false
func _ready() -> void:
body_entered.connect(on_body_entered)
func on_body_entered(body: Node2D) -> void:
if is_reached:
return
if not body.is_in_group("player"):
return
is_reached = true
on_flag_reached(body)
func on_flag_reached(player: Node) -> void:
# 根据触碰高度计算得分
var flag_y: float = player.position.y
var score: int
match true:
_ when flag_y <= 100: score = 5000
_ when flag_y <= 200: score = 2000
_ when flag_y <= 300: score = 800
_ when flag_y <= 400: score = 100
_: score = 50
GameManager.add_score(score)
# 旗帜降下动画
var tween = create_tween()
tween.tween_property(flag_sprite, "position:y", 80.0, 1.0)
# 玩家滑下旗杆
player.start_flagpole_sequence()
# 播放音效
AudioManager.play_sfx("flagpole")
# 延迟后触发关卡通过
var timer = get_tree().create_timer(3.0)
timer.timeout.connect(GameManager.level_complete)关卡设计原则
好的关卡设计需要遵循一些基本原则:
| 原则 | 说明 | 示例 |
|---|---|---|
| 由易到难 | 关卡前半部分简单,后半部分难 | 开始没有敌人,后面越来越多 |
| 教学顺序 | 先让玩家遇到简单的情况 | 先单独一个蘑菇怪,再两个一起 |
| 节奏变化 | 不要一直紧张或一直轻松 | 敌人区→安全区→悬崖→道具区 |
| 公平性 | 困难的地方有足够空间反应 | 悬崖前有视觉提示 |
| 奖励探索 | 隐藏区域有额外奖励 | 跳到云上有隐藏金币 |
下一章预告
关卡设计完成了!现在可以用 TileMap 搭建完整的关卡,有问号砖块、普通砖块、管道和旗杆。下一章我们将实现滚动摄像机——让画面跟着玛丽奥移动。
