6. 关卡递进
2026/4/14大约 9 分钟
开心消消乐——关卡递进
什么是关卡系统?
关卡系统给游戏设定了明确的目标和限制。没有关卡的游戏就像跑步没有终点——虽然跑着也很开心,但总觉得少了点什么。
有了关卡系统后,每一关都有一个目标(比如"在30步内获得1000分"),玩家达标了就进入下一关,否则重新来过。这就像打游戏通关一样,一关比一关难,越玩越有挑战。
关卡目标类型
| 目标类型 | 说明 | 示例 |
|---|---|---|
| 分数目标 | 在限步内达到指定分数 | 30步内获得2000分 |
| 收集目标 | 消除指定数量的某种颜色 | 消除20个红色方块 |
| 障碍目标 | 清除指定位置的障碍物 | 清除所有冰块 |
我们的游戏先实现最基础的分数目标。
关卡数据结构
每个关卡需要记录以下信息:
C
/// <summary>
/// 关卡数据
/// 每一关的配置信息
/// </summary>
public class LevelData
{
/// <summary>关卡编号</summary>
public int LevelNumber { get; set; }
/// <summary>目标分数</summary>
public int TargetScore { get; set; }
/// <summary>可用步数</summary>
public int MaxMoves { get; set; }
/// <summary>方块种类数(难度控制)</summary>
public int PieceTypes { get; set; }
/// <summary>一星评价所需分数</summary>
public int OneStarScore { get; set; }
/// <summary>二星评价所需分数</summary>
public int TwoStarScore { get; set; }
/// <summary>三星评价所需分数(等于目标分数)</summary>
public int ThreeStarScore { get; set; }
/// <summary>关卡描述</summary>
public string Description { get; set; }
}GDScript
## 关卡数据
## 每一关的配置信息
class LevelData:
## 关卡编号
var level_number: int = 1
## 目标分数
var target_score: int = 1000
## 可用步数
var max_moves: int = 30
## 方块种类数(难度控制)
var piece_types: int = 5
## 一星评价所需分数
var one_star_score: int = 500
## 二星评价所需分数
var two_star_score: int = 1000
## 三星评价所需分数(等于目标分数)
var three_star_score: int = 2000
## 关卡描述
var description: String = "在限步内获得目标分数"关卡配置
我们可以创建一个关卡管理器来存储所有关卡配置。用公式来生成关卡参数,比手动配置每一关更方便,也更容易调整难度曲线。
难度曲线设计
| 关卡 | 目标分数 | 步数 | 方块种类 | 难度说明 |
|---|---|---|---|---|
| 1 | 500 | 30 | 4 | 入门,4种方块很容易匹配 |
| 2 | 800 | 28 | 4 | 稍微提高分数要求 |
| 3 | 1000 | 25 | 4 | 减少步数 |
| 4 | 1200 | 25 | 5 | 增加第5种方块 |
| 5 | 1500 | 22 | 5 | 分数步数双重提升 |
| 6-10 | 递增 | 递减 | 5 | 持续提高难度 |
| 10+ | 2000+ | 20 | 5 | 高难度挑战 |
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 关卡管理器
/// 管理所有关卡的配置和进度
/// </summary>
public partial class LevelManager : Node
{
/// <summary>所有关卡数据</summary>
private List<LevelData> _levels = new List<LevelData>();
/// <summary>当前关卡编号(从1开始)</summary>
private int _currentLevel = 1;
/// <summary>已解锁的最高关卡</summary>
private int _maxUnlockedLevel = 1;
/// <summary>每关的星级评价(key=关卡号, value=星星数)</summary>
private Dictionary<int, int> _levelStars = new Dictionary<int, int>();
/// <summary>关卡数据变化时发出</summary>
[Signal] public delegate void LevelDataChangedEventHandler(LevelData data);
public override void _Ready()
{
GenerateLevels();
LoadProgress();
GD.Print($"[LevelManager] 已生成 {_levels.Count} 个关卡");
}
/// <summary>
/// 根据公式批量生成关卡
/// 这样只需要调参数就能改变整体难度曲线
/// </summary>
private void GenerateLevels()
{
const int totalLevels = 50; // 生成50个关卡
for (int i = 1; i <= totalLevels; i++)
{
var level = new LevelData
{
LevelNumber = i,
// 目标分数:从500开始,每关增加100,每5关额外加200
TargetScore = 500 + (i - 1) * 100 + (i / 5) * 200,
// 步数:从30开始递减,最低20步
MaxMoves = Mathf.Max(20, 32 - i),
// 前3关4种方块,之后5种
PieceTypes = i <= 3 ? 4 : 5,
// 星级评价
OneStarScore = 0, // 只要过关就是1星
TwoStarScore = 0, // 稍后计算
ThreeStarScore = 0, // 稍后计算
};
// 二星 = 目标的80%,三星 = 目标的120%
level.TwoStarScore = (int)(level.TargetScore * 0.8f);
level.ThreeStarScore = level.TargetScore;
// 修正一星分数
level.OneStarScore = (int)(level.TargetScore * 0.5f);
_levels.Add(level);
}
}
/// <summary>
/// 获取指定关卡的数据
/// </summary>
public LevelData GetLevel(int levelNumber)
{
if (levelNumber < 1 || levelNumber > _levels.Count)
{
GD.PrintErr($"[LevelManager] 关卡 {levelNumber} 不存在!");
return _levels[0];
}
return _levels[levelNumber - 1]; // 列表索引从0开始
}
/// <summary>
/// 获取当前关卡数据
/// </summary>
public LevelData GetCurrentLevel()
{
return GetLevel(_currentLevel);
}
/// <summary>
/// 进入下一关
/// </summary>
public void NextLevel()
{
if (_currentLevel < _levels.Count)
{
_currentLevel++;
if (_currentLevel > _maxUnlockedLevel)
{
_maxUnlockedLevel = _currentLevel;
}
GD.Print($"[LevelManager] 进入第 {_currentLevel} 关");
EmitSignal(SignalName.LevelDataChanged, GetCurrentLevel());
}
}
/// <summary>
/// 重新开始当前关卡
/// </summary>
public void RestartLevel()
{
GD.Print($"[LevelManager] 重新开始第 {_currentLevel} 关");
EmitSignal(SignalName.LevelDataChanged, GetCurrentLevel());
}
/// <summary>
/// 根据分数计算星级评价
/// </summary>
public int CalculateStars(int levelNumber, int score)
{
var level = GetLevel(levelNumber);
if (score >= level.ThreeStarScore) return 3;
if (score >= level.TwoStarScore) return 2;
if (score >= level.OneStarScore) return 1;
return 0; // 没过关
}
}GDScript
extends Node
## 所有关卡数据
var _levels: Array[LevelData] = []
## 当前关卡编号(从1开始)
var _current_level: int = 1
## 已解锁的最高关卡
var _max_unlocked_level: int = 1
## 每关的星级评价
var _level_stars: Dictionary = {}
## 关卡数据变化时发出
signal level_data_changed(data: LevelData)
func _ready() -> void:
_generate_levels()
_load_progress()
print("[LevelManager] 已生成 %d 个关卡" % _levels.size())
## 根据公式批量生成关卡
## 这样只需要调参数就能改变整体难度曲线
func _generate_levels() -> void:
var total_levels: int = 50 # 生成50个关卡
for i in range(1, total_levels + 1):
var level := LevelData.new()
level.level_number = i
# 目标分数:从500开始,每关增加100,每5关额外加200
level.target_score = 500 + (i - 1) * 100 + (i / 5) * 200
# 步数:从30开始递减,最低20步
level.max_moves = max(20, 32 - i)
# 前3关4种方块,之后5种
level.piece_types = 4 if i <= 3 else 5
# 星级评价
level.one_star_score = int(level.target_score * 0.5)
level.two_star_score = int(level.target_score * 0.8)
level.three_star_score = level.target_score
_levels.append(level)
## 获取指定关卡的数据
func get_level(level_number: int) -> LevelData:
if level_number < 1 or level_number > _levels.size():
push_error("[LevelManager] 关卡 %d 不存在!" % level_number)
return _levels[0]
return _levels[level_number - 1] # 数组索引从0开始
## 获取当前关卡数据
func get_current_level() -> LevelData:
return get_level(_current_level)
## 进入下一关
func next_level() -> void:
if _current_level < _levels.size():
_current_level += 1
if _current_level > _max_unlocked_level:
_max_unlocked_level = _current_level
print("[LevelManager] 进入第 %d 关" % _current_level)
level_data_changed.emit(get_current_level())
## 重新开始当前关卡
func restart_level() -> void:
print("[LevelManager] 重新开始第 %d 关" % _current_level)
level_data_changed.emit(get_current_level())
## 根据分数计算星级评价
func calculate_stars(level_number: int, score: int) -> int:
var level := get_level(level_number)
if score >= level.three_star_score:
return 3
if score >= level.two_star_score:
return 2
if score >= level.one_star_score:
return 1
return 0 # 没过关星级评价
星级评价就像考试评分——不是"过"和"不过"两个选项,而是有好几个档次,鼓励玩家追求更高分数。
| 星级 | 条件 | 视觉效果 |
|---|---|---|
| 0星 | 没达到最低分数 | 失败弹窗 |
| 1星 | 达到50%目标分 | 过关弹窗,显示1颗亮星 |
| 2星 | 达到80%目标分 | 过关弹窗,显示2颗亮星 |
| 3星 | 达到100%目标分 | 过关弹窗,显示3颗亮星 + 特效 |
关卡完成检测
GameManager 需要在每次分数变化时检查是否达到目标:
C
/// <summary>
/// 检查关卡是否完成
/// 在每次分数变化和步数变化时调用
/// </summary>
public void CheckLevelCompletion()
{
int score = GetScore();
int moves = GetMovesLeft();
int target = _targetScore;
if (score >= target)
{
// 达到目标分数 → 胜利
GD.Print($"[GameManager] 关卡完成!分数: {score}/{target}");
CurrentState = GameState.Paused;
EmitSignal(SignalName.GameWon);
}
else if (moves <= 0 && CurrentState == GameState.Idle)
{
// 步数用完但没达到目标 → 失败
GD.Print($"[GameManager] 关卡失败!分数: {score}/{target}");
CurrentState = GameState.Paused;
EmitSignal(SignalName.GameLost);
}
}
/// <summary>
/// 用关卡数据初始化游戏
/// </summary>
public void SetupLevel(LevelData levelData)
{
_score = 0;
_movesLeft = levelData.MaxMoves;
_targetScore = levelData.TargetScore;
_comboCount = 0;
CurrentState = GameState.Idle;
GD.Print($"[GameManager] 关卡 {levelData.LevelNumber} 开始");
GD.Print($" 目标分数: {levelData.TargetScore}");
GD.Print($" 可用步数: {levelData.MaxMoves}");
GD.Print($" 方块种类: {levelData.PieceTypes}");
}GDScript
## 检查关卡是否完成
## 在每次分数变化和步数变化时调用
func check_level_completion() -> void:
var score: int = get_score()
var moves: int = get_moves_left()
var target: int = _target_score
if score >= target:
# 达到目标分数 → 胜利
print("[GameManager] 关卡完成!分数: %d/%d" % [score, target])
current_state = GameState.PAUSED
game_won.emit()
elif moves <= 0 and current_state == GameState.IDLE:
# 步数用完但没达到目标 → 失败
print("[GameManager] 关卡失败!分数: %d/%d" % [score, target])
current_state = GameState.PAUSED
game_lost.emit()
## 用关卡数据初始化游戏
func setup_level(level_data: LevelData) -> void:
_score = 0
_moves_left = level_data.max_moves
_target_score = level_data.target_score
_combo_count = 0
current_state = GameState.IDLE
print("[GameManager] 关卡 %d 开始" % level_data.level_number)
print(" 目标分数: %d" % level_data.target_score)
print(" 可用步数: %d" % level_data.max_moves)
print(" 方块种类: %d" % level_data.piece_types)存档系统
玩家的进度(已解锁关卡、每关的星级评价)需要保存到本地,这样下次打开游戏时可以继续。
Godot 提供了 ConfigFile 类来方便地读写配置文件,它本质上就是一个简单的键值对文件。
C
/// <summary>
/// 存档文件路径
/// </summary>
private const string SaveFilePath = "user://save_data.cfg";
/// <summary>
/// 保存游戏进度
/// </summary>
public void SaveProgress()
{
var config = new ConfigFile();
// 保存当前关卡
config.SetValue("progress", "current_level", _currentLevel);
config.SetValue("progress", "max_unlocked", _maxUnlockedLevel);
// 保存每关的星级
config.SetValue("stars", "data", _levelStars);
// 写入文件
Error err = config.Save(SaveFilePath);
if (err == Error.Ok)
{
GD.Print("[LevelManager] 进度已保存");
}
else
{
GD.PrintErr($"[LevelManager] 保存失败: {err}");
}
}
/// <summary>
/// 加载游戏进度
/// </summary>
public void LoadProgress()
{
var config = new ConfigFile();
// 尝试读取存档文件
Error err = config.Load(SaveFilePath);
if (err != Error.Ok)
{
GD.Print("[LevelManager] 没有找到存档,使用默认进度");
return;
}
// 读取进度
_currentLevel = (int)config.GetValue("progress", "current_level", 1);
_maxUnlockedLevel = (int)config.GetValue("progress", "max_unlocked", 1);
// 读取星级
var stars = config.GetValue("stars", "data", new Dictionary<int, int>());
if (stars is Dictionary<int, int> starDict)
{
_levelStars = starDict;
}
GD.Print($"[LevelManager] 进度已加载: 第{_currentLevel}关,已解锁{_maxUnlockedLevel}关");
}
/// <summary>
/// 记录关卡评价
/// </summary>
public void RecordLevelResult(int levelNumber, int stars)
{
// 只保留最高星级
if (!_levelStars.ContainsKey(levelNumber)
|| _levelStars[levelNumber] < stars)
{
_levelStars[levelNumber] = stars;
}
// 保存进度
SaveProgress();
}GDScript
## 存档文件路径
const SAVE_FILE_PATH: String = "user://save_data.cfg"
## 保存游戏进度
func save_progress() -> void:
var config := ConfigFile.new()
# 保存当前关卡
config.set_value("progress", "current_level", _current_level)
config.set_value("progress", "max_unlocked", _max_unlocked_level)
# 保存每关的星级
config.set_value("stars", "data", _level_stars)
# 写入文件
var err: Error = config.save(SAVE_FILE_PATH)
if err == OK:
print("[LevelManager] 进度已保存")
else:
push_error("[LevelManager] 保存失败: %s" % err)
## 加载游戏进度
func _load_progress() -> void:
var config := ConfigFile.new()
# 尝试读取存档文件
var err: Error = config.load(SAVE_FILE_PATH)
if err != OK:
print("[LevelManager] 没有找到存档,使用默认进度")
return
# 读取进度
_current_level = config.get_value("progress", "current_level", 1)
_max_unlocked_level = config.get_value("progress", "max_unlocked", 1)
# 读取星级
var stars = config.get_value("stars", "data", {})
if stars is Dictionary:
_level_stars = stars
print("[LevelManager] 进度已加载: 第%d关,已解锁%d关" % [
_current_level, _max_unlocked_level
])
## 记录关卡评价
func record_level_result(level_number: int, stars: int) -> void:
# 只保留最高星级
if not _level_stars.has(level_number) or _level_stars[level_number] < stars:
_level_stars[level_number] = stars
# 保存进度
save_progress()本章小结
| 完成项 | 说明 |
|---|---|
| 关卡数据 | LevelData 存储目标分数、步数、方块种类 |
| 难度曲线 | 目标分数递增、步数递减、方块种类逐步增加 |
| 星级评价 | 0-3星,根据分数百分比计算 |
| 关卡管理器 | 生成、查询、切换关卡 |
| 完成检测 | 分数达标则胜利,步数用完则失败 |
| 存档系统 | 用 ConfigFile 保存/加载进度 |
现在游戏有了明确的目标和进度系统。下一章,我们将实现特殊方块——条纹方块、炸弹、彩虹球,让消除玩法更加丰富多彩。
