10. 打磨与发布
2026/4/14大约 10 分钟
10. 保卫萝卜——打磨与发布
什么是"打磨"?
"打磨"(Polish)就像是对产品进行最后的精加工——把粗糙的表面磨光滑,把不协调的细节调整到位。一个经过打磨的游戏和没打磨的游戏,玩起来会有天壤之别。
本节涵盖的内容:
| 主题 | 说明 |
|---|---|
| 关卡设计 | 如何设计有趣的关卡地图和怪物配置 |
| 数值平衡 | 让游戏难度"刚刚好" |
| 存档系统 | 保存玩家的进度 |
| 游戏导出 | 打包发布到不同平台 |
关卡设计工具
保卫萝卜是一个关卡制游戏,每一关有不同的地图和怪物配置。我们需要一个灵活的关卡数据结构。
关卡配置数据
C
using Godot;
/// <summary>
/// 关卡配置数据 —— 一关的所有信息
/// 就像一份"施工图纸",包含了这关需要的一切
/// </summary>
[GlobalClass]
public partial class LevelConfig : Resource
{
/// <summary>关卡编号</summary>
[Export] public int LevelNumber { get; set; } = 1;
/// <summary>关卡名称</summary>
[Export] public string LevelName { get; set; } = "第一关";
/// <summary>关卡描述</summary>
[Export] public string Description { get; set; } = "新手教程关卡";
/// <summary>地图场景</summary>
[Export] public PackedScene MapScene { get; set; }
/// <summary>路径曲线(可选,如果路径在场景中定义则不需要)</summary>
[Export] public Curve2D PathCurve { get; set; }
/// <summary>初始金币</summary>
[Export] public int StartingGold { get; set; } = 300;
/// <summary>初始生命值</summary>
[Export] public int StartingLives { get; set; } = 10;
/// <summary>波次配置列表</summary>
[Export] public WaveData[] Waves { get; set; }
/// <summary>可用的塔类型</summary>
[Export] public TowerData[] AvailableTowers { get; set; }
/// <summary>障碍物配置</summary>
[Export] public ObstacleData[] Obstacles { get; set; }
/// <summary>推荐策略提示</summary>
[Export] public string Hint { get; set; } = "提示:先在拐角处放瓶子塔";
/// <summary>三星评分条件</summary>
[Export] public int Star3Lives { get; set; } = 8;
[Export] public int Star2Lives { get; set; } = 5;
/// <summary>背景音乐</summary>
[Export] public AudioStream Bgm { get; set; }
}GDScript
class_name LevelConfig
extends Resource
## 关卡配置数据 —— 一关的所有信息
## 就像一份"施工图纸",包含了这关需要的一切
## 关卡编号
@export var level_number: int = 1
## 关卡名称
@export var level_name: String = "第一关"
## 关卡描述
@export var description: String = "新手教程关卡"
## 地图场景
@export var map_scene: PackedScene
## 路径曲线
@export var path_curve: Curve2D
## 初始金币
@export var starting_gold: int = 300
## 初始生命值
@export var starting_lives: int = 10
## 波次配置列表
@export var waves: Array[WaveData] = []
## 可用的塔类型
@export var available_towers: Array[TowerData] = []
## 障碍物配置
@export var obstacles: Array[ObstacleData] = []
## 推荐策略提示
@export var hint: String = "提示:先在拐角处放瓶子塔"
## 三星评分条件
@export var star3_lives: int = 8
@export var star2_lives: int = 5
## 背景音乐
@export var bgm: AudioStream关卡加载器
C
using Godot;
/// <summary>
/// 关卡加载器 —— 负责"翻开"新的一页(加载新关卡)
/// </summary>
public partial class LevelLoader : Node
{
/// <summary>关卡配置目录</summary>
private const string LevelPath =
"res://resources/levels/";
/// <summary>关卡场景根节点</summary>
private Node2D _levelRoot;
public override void _Ready()
{
_levelRoot = GetNode<Node2D>("/root/Main/LevelRoot");
}
/// <summary>
/// 加载指定关卡
/// </summary>
public async void LoadLevel(int levelNumber)
{
// 加载关卡配置
var path = $"{LevelPath}level_{levelNumber}.tres";
var config = GD.Load<LevelConfig>(path);
if (config == null)
{
GD.PrintErr($"关卡配置不存在: {path}");
return;
}
// 清除当前关卡
ClearCurrentLevel();
// 初始化游戏管理器
var gm = GameManager.Instance;
gm.StartingGold = config.StartingGold;
gm.StartingLives = config.StartingLives;
gm.TotalWaves = config.Waves.Length;
gm.InitGame();
// 加载地图场景
if (config.MapScene != null)
{
var map = config.MapScene.Instantiate<Node2D>();
_levelRoot.AddChild(map);
}
// 配置波次管理器
var waveManager = GetNode<WaveManager>("WaveManager");
waveManager.Waves = config.Waves;
// 配置可用的塔
var towerPanel = GetNode<TowerPanel>("UI/TowerPanel");
towerPanel.AvailableTowers = config.AvailableTowers;
// 播放背景音乐
if (config.Bgm != null)
{
AudioManager.Instance.PlayMusic(config.Bgm);
}
GD.Print($"关卡 {levelNumber}: {config.LevelName} 加载完成");
}
/// <summary>
/// 清除当前关卡
/// </summary>
private void ClearCurrentLevel()
{
// 清除所有子节点
foreach (var child in _levelRoot.GetChildren())
{
child.QueueFree();
}
}
/// <summary>
/// 重新加载当前关卡
/// </summary>
public void ReloadCurrentLevel()
{
var waveManager = GetNode<WaveManager>("WaveManager");
int currentWave = waveManager.CurrentWaveNumber;
LoadLevel(currentWave);
}
}GDScript
extends Node
class_name LevelLoader
## 关卡加载器 —— 负责"翻开"新的一页
const LEVEL_PATH: String = "res://resources/levels/"
var _level_root: Node2D
func _ready() -> void:
_level_root = get_node_or_null("/root/Main/LevelRoot")
## 加载指定关卡
func load_level(level_number: int) -> void:
var path: String = LEVEL_PATH + "level_%d.tres" % level_number
var config = load(path) as LevelConfig
if not config:
push_error("关卡配置不存在: %s" % path)
return
# 清除当前关卡
_clear_current_level()
# 初始化游戏管理器
var gm: GameManager = GameManager.instance
gm.starting_gold = config.starting_gold
gm.starting_lives = config.starting_lives
gm.total_waves = config.waves.size()
gm.init_game()
# 加载地图场景
if config.map_scene:
var map = config.map_scene.instantiate()
_level_root.add_child(map)
# 配置波次管理器
var wave_manager = get_node_or_null("WaveManager") as WaveManager
if wave_manager:
wave_manager.waves = config.waves
# 播放背景音乐
if config.bgm:
AudioManager.instance.play_music(config.bgm)
print("关卡 %d: %s 加载完成" % [level_number, config.level_name])
## 清除当前关卡
func _clear_current_level() -> void:
if not _level_root:
return
for child in _level_root.get_children():
child.queue_free()
## 重新加载当前关卡
func reload_current_level() -> void:
var wave_manager = get_node_or_null("WaveManager") as WaveManager
if wave_manager:
load_level(wave_manager.current_wave_number)数值平衡
数值平衡是让游戏"好玩"的关键。太难了玩家会放弃,太简单了玩家会无聊。
平衡的核心公式
| 指标 | 公式 | 说明 |
|---|---|---|
| DPS | 伤害 x 攻速 | 每秒伤害输出 |
| 杀怪时间 | 怪物血量 / 总DPS | 杀死一个怪物需要的时间 |
| 金币效率 | 总奖励金币 / 总花费 | 玩家是否"赚了" |
| 难度曲线 | 第N波怪物血量 / 第1波怪物血量 | 难度增长倍率 |
平衡参数表
以一个20波关卡为例的推荐数值:
| 波次 | 怪物血量 | 怪物数量 | 间隔 | 推荐塔数 |
|---|---|---|---|---|
| 1-3 | 80-120 | 3-5 | 2.0s | 2-3 |
| 4-6 | 120-180 | 5-8 | 1.5s | 4-5 |
| 7-9 | 180-250 | 8-10 | 1.2s | 5-7 |
| 10 (Boss) | 2000 | 1 Boss + 5 小怪 | 1.0s | 6-8 |
| 11-13 | 250-350 | 10-12 | 1.0s | 7-9 |
| 14-16 | 350-500 | 12-15 | 0.8s | 8-10 |
| 17-19 | 500-700 | 15-18 | 0.7s | 9-11 |
| 20 (Final) | 5000 | 2 Boss + 10 小怪 | 0.5s | 10-12 |
平衡调试工具
C
using Godot;
/// <summary>
/// 调试工具 —— 开发时用来快速测试和调整数值
/// </summary>
public partial class DebugTools : Node
{
[Export] public bool EnableDebug { get; set; } = false;
public override void _Process(double delta)
{
if (!EnableDebug) return;
// 快捷键调试
if (Input.IsKeyPressed(Key.F1))
AddGoldCheat();
if (Input.IsKeyPressed(Key.F2))
SkipWaveCheat();
if (Input.IsKeyPressed(Key.F3))
GodModeCheat();
if (Input.IsKeyPressed(Key.F4))
KillAllCheat();
if (Input.IsKeyPressed(Key.F5))
SpeedUpCheat();
}
/// <summary>加金币</summary>
private void AddGoldCheat()
{
if (Input.IsActionJustPressed("debug_gold"))
{
GameManager.Instance.AddGold(1000);
GD.Print("[DEBUG] +1000 金币");
}
}
/// <summary>跳过当前波次</summary>
private void SkipWaveCheat()
{
if (Input.IsActionJustPressed("debug_skip"))
{
var wm = GetNodeOrNull<WaveManager>("WaveManager");
wm?.SkipWave();
GD.Print("[DEBUG] 跳过当前波次");
}
}
/// <summary>无敌模式</summary>
private void GodModeCheat()
{
if (Input.IsActionJustPressed("debug_god"))
{
GD.Print("[DEBUG] 无敌模式切换");
}
}
/// <summary>杀死所有怪物</summary>
private void KillAllCheat()
{
if (Input.IsActionJustPressed("debug_kill"))
{
var monsters = GetTree().GetNodesInGroup("monsters");
foreach (var node in monsters)
{
if (node is Monster m) m.TakeDamage(99999);
}
GD.Print($"[DEBUG] 杀死了 {monsters.Count} 个怪物");
}
}
/// <summary>加速</summary>
private void SpeedUpCheat()
{
if (Input.IsActionJustPressed("debug_speed"))
{
Engine.TimeScale = Engine.TimeScale >= 2.0f
? 1.0f : Engine.TimeScale + 0.5f;
GD.Print($"[DEBUG] 游戏速度: {Engine.TimeScale}x");
}
}
}GDScript
extends Node
class_name DebugTools
## 调试工具 —— 开发时用来快速测试和调整数值
## 是否启用调试
@export var enable_debug: bool = false
func _process(_delta: float) -> void:
if not enable_debug:
return
# F1: 加金币
if Input.is_action_just_pressed("debug_gold"):
GameManager.instance.add_gold(1000)
print("[DEBUG] +1000 金币")
# F2: 跳过波次
if Input.is_action_just_pressed("debug_skip"):
var wm = get_node_or_null("WaveManager") as WaveManager
if wm:
wm.skip_wave()
print("[DEBUG] 跳过当前波次")
# F3: 杀死所有
if Input.is_action_just_pressed("debug_kill"):
var monsters = get_tree().get_nodes_in_group("monsters")
for node in monsters:
if node is Monster m:
m.take_damage(99999)
print("[DEBUG] 杀死了 %d 个怪物" % monsters.size())
# F4: 加速
if Input.is_action_just_pressed("debug_speed"):
if Engine.time_scale >= 2.0:
Engine.time_scale = 1.0
else:
Engine.time_scale += 0.5
print("[DEBUG] 游戏速度: %fx" % Engine.time_scale)存档系统
存档系统让玩家可以保存进度,下次继续游戏。
C
using Godot;
using System.Text.Json;
/// <summary>
/// 存档管理器 —— 游戏的"记忆系统"
/// 保存和加载玩家的进度
/// </summary>
public partial class SaveManager : Node
{
public static SaveManager Instance { get; private set; }
private const string SavePath = "user://save_data.json";
public override void _EnterTree()
{
Instance = this;
}
/// <summary>
/// 存档数据结构
/// </summary>
public class SaveData
{
public int CurrentLevel { get; set; } = 1;
public int HighestLevel { get; set; } = 1;
public int TotalStars { get; set; } = 0;
public int[] LevelStars { get; set; } = {};
public bool[] LevelsUnlocked { get; set; } = {};
public int TotalGoldEarned { get; set; } = 0;
public int TotalGamesPlayed { get; set; } = 0;
}
/// <summary>
/// 保存游戏
/// </summary>
public void SaveGame(SaveData data)
{
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions
{
WriteIndented = true
});
var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write);
if (file != null)
{
file.StoreString(json);
file.Close();
GD.Print("游戏已保存");
}
else
{
GD.PrintErr($"保存失败: {SavePath}");
}
}
/// <summary>
/// 加载游戏
/// </summary>
public SaveData LoadGame()
{
if (!FileAccess.FileExists(SavePath))
{
GD.Print("没有存档文件,创建新存档");
return CreateNewSave();
}
var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read);
if (file == null)
{
GD.PrintErr("读取存档失败");
return CreateNewSave();
}
var json = file.GetAsText();
file.Close();
try
{
return JsonSerializer.Deserialize<SaveData>(json);
}
catch (System.Exception e)
{
GD.PrintErr($"存档数据损坏: {e.Message}");
return CreateNewSave();
}
}
/// <summary>
/// 创建新存档
/// </summary>
private SaveData CreateNewSave()
{
var data = new SaveData();
SaveGame(data);
return data;
}
/// <summary>
/// 删除存档
/// </summary>
public void DeleteSave()
{
if (FileAccess.FileExists(SavePath))
{
DirAccess.RemoveAbsolute(SavePath);
GD.Print("存档已删除");
}
}
/// <summary>
/// 是否有存档
/// </summary>
public bool HasSave()
{
return FileAccess.FileExists(SavePath);
}
/// <summary>
/// 记录关卡完成
/// </summary>
public void RecordLevelComplete(int level, int livesLeft, int totalLives)
{
var data = LoadGame();
// 计算星级
int stars = 1; // 至少一星
if (livesLeft >= totalLives * 0.5f) stars = 2;
if (livesLeft >= totalLives * 0.8f) stars = 3;
// 更新记录
if (level > data.HighestLevel)
data.HighestLevel = level;
data.CurrentLevel = level + 1;
// 更新星级
if (data.LevelStars.Length < level)
{
var newArray = new int[level];
for (int i = 0; i < data.LevelStars.Length; i++)
newArray[i] = data.LevelStars[i];
data.LevelStars = newArray;
}
data.LevelStars[level - 1] =
Mathf.Max(data.LevelStars[level - 1], stars);
data.TotalStars = 0;
foreach (var s in data.LevelStars)
data.TotalStars += s;
SaveGame(data);
}
}GDScript
extends Node
class_name SaveManager
## 存档管理器 —— 游戏的"记忆系统"
## 保存和加载玩家的进度
static var instance: SaveManager
const SAVE_PATH: String = "user://save_data.json"
func _enter_tree() -> void:
instance = self
## 存档数据
class SaveData:
var current_level: int = 1
var highest_level: int = 1
var total_stars: int = 0
var level_stars: Array[int] = []
var levels_unlocked: Array[bool] = []
var total_gold_earned: int = 0
var total_games_played: int = 0
func to_dict() -> Dictionary:
return {
"current_level": current_level,
"highest_level": highest_level,
"total_stars": total_stars,
"level_stars": level_stars,
"total_gold_earned": total_gold_earned,
"total_games_played": total_games_played,
}
static func from_dict(data: Dictionary) -> SaveData:
var sd = SaveData.new()
sd.current_level = data.get("current_level", 1)
sd.highest_level = data.get("highest_level", 1)
sd.total_stars = data.get("total_stars", 0)
sd.level_stars = data.get("level_stars", [])
sd.total_gold_earned = data.get("total_gold_earned", 0)
sd.total_games_played = data.get("total_games_played", 0)
return sd
## 保存游戏
func save_game(data: SaveData) -> void:
var json_string = JSON.stringify(data.to_dict(), " ")
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file:
file.store_string(json_string)
file.close()
print("游戏已保存")
else:
push_error("保存失败: %s" % SAVE_PATH)
## 加载游戏
func load_game() -> SaveData:
if not FileAccess.file_exists(SAVE_PATH):
print("没有存档文件,创建新存档")
return _create_new_save()
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if not file:
push_error("读取存档失败")
return _create_new_save()
var json_string = file.get_as_text()
file.close()
var json = JSON.new()
var error = json.parse(json_string)
if error != OK:
push_error("存档数据损坏")
return _create_new_save()
return SaveData.from_dict(json.data)
## 创建新存档
func _create_new_save() -> SaveData:
var data = SaveData.new()
save_game(data)
return data
## 删除存档
func delete_save() -> void:
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)
print("存档已删除")
## 是否有存档
func has_save() -> bool:
return FileAccess.file_exists(SAVE_PATH)
## 记录关卡完成
func record_level_complete(level: int, lives_left: int, total_lives: int) -> void:
var data = load_game()
# 计算星级
var stars: int = 1
if lives_left >= total_lives * 0.5:
stars = 2
if lives_left >= total_lives * 0.8:
stars = 3
if level > data.highest_level:
data.highest_level = level
data.current_level = level + 1
# 更新星级
while data.level_stars.size() < level:
data.level_stars.append(0)
data.level_stars[level - 1] = max(data.level_stars[level - 1], stars)
data.total_stars = 0
for s in data.level_stars:
data.total_stars += s
save_game(data)游戏导出
Godot 支持导出到多个平台。下面是导出的基本步骤。
导出步骤
安装导出模板:在 Godot 编辑器中,点击 编辑器 → 管理导出模板,下载对应版本的模板
配置导出预设:点击 项目 → 导出,添加目标平台
各平台配置:
| 平台 | 注意事项 |
|---|---|
| Windows Desktop | 选择 x86 或 x64,设置图标 |
| macOS | 需要开发者签名(发布用) |
| Linux | 选择 x86_64 |
| Android | 需要安装 Android SDK |
| iOS | 需要 Mac 和 Xcode |
| Web (HTML5) | 注意文件大小限制 |
导出设置
在 项目 → 项目设置 → 导出 中配置:
| 设置项 | 值 | 说明 |
|---|---|---|
| 应用名称 | 保卫萝卜 | 显示名 |
| 图标 | 256x256 PNG | 应用图标 |
| 启动画面 | 自定义图片 | 加载时的画面 |
| 纵横比 | Keep | 保持宽高比 |
| 窗口大小 | 1024x640 | 初始窗口大小 |
发布前检查清单
| 检查项 | 说明 |
|---|---|
| 移除调试工具 | 将 EnableDebug 设为 false |
| 检查所有场景 | 确保没有引用缺失的资源 |
| 测试完整流程 | 从开始到结束玩一遍 |
| 测试存档功能 | 保存、退出、重新加载 |
| 性能优化 | 确保帧率稳定在 60fps |
| 多分辨率测试 | 在不同屏幕尺寸下测试 |
| 关闭调试输出 | 移除 GD.Print 或使用条件编译 |
本节小结
| 主题 | 说明 |
|---|---|
| LevelConfig | 关卡配置数据,包含地图、波次、可用塔等 |
| LevelLoader | 关卡加载器,负责切换关卡 |
| 数值平衡 | DPS、金币效率、难度曲线等平衡公式 |
| DebugTools | 调试工具,快速测试和调整数值 |
| SaveManager | 存档管理器,保存/加载玩家进度 |
| 游戏导出 | 多平台打包发布 |
到这里,保卫萝卜项目的全部10个章节都完成了!从核心玩法设计到最终发布,我们涵盖了塔防游戏开发的所有关键环节。
回顾整个项目:
| 章节 | 内容 |
|---|---|
| 1. 核心玩法 | 塔防循环、金币经济、波次系统 |
| 2. 项目搭建 | 目录结构、常量、主场景、Autoload |
| 3. 怪物与路径 | Path2D/PathFollow2D、怪物移动、生成器 |
| 4. 塔类型系统 | 基类设计、四种塔的实现 |
| 5. 升级与出售 | 升级效果、出售返还、建造模式 |
| 6. 波次系统 | 波次配置、出场控制、Boss波次 |
| 7. 特殊机制 | 障碍物、状态效果、塔组合 |
| 8. 游戏界面 | HUD、塔选择面板、升级弹窗、结算 |
| 9. 音效与特效 | 音效管理器、攻击线、爆炸粒子、飘字 |
| 10. 打磨与发布 | 关卡设计、数值平衡、存档、导出 |
