10. 打磨与发布
2026/4/14大约 9 分钟
10. 俄罗斯方块——打磨与发布
10.1 打磨——从"能玩"到"好玩"
经过前面9章的努力,我们的俄罗斯方块已经"能玩"了。但"能玩"和"好玩"之间还有一段距离。打磨(Polish) 就是填补这段距离的过程——优化动画、修复bug、添加存档、准备发布。
就像装修房子:主体结构已经建好了(能住),但还需要刷漆、装灯、铺地板,才能变成一个温馨的家(好住)。
10.2 存档系统
玩家关闭游戏后再打开,应该能看到之前的最高分。我们需要一个存档系统来保存和读取游戏数据。
Godot提供了 ConfigFile 类,可以方便地读写INI格式的配置文件。
C
using Godot;
/// <summary>
/// 存档系统——保存和读取游戏数据
/// </summary>
public static class SaveSystem
{
// 存档文件路径
private const string SavePath = "user://tetris_save.cfg";
// 存档节名称
private const string SettingsSection = "settings";
private const string StatsSection = "stats";
/// <summary>
/// 保存设置
/// </summary>
public static void SaveSettings(GameSettings settings)
{
var config = new ConfigFile();
config.SetValue(SettingsSection, "music_volume", settings.MusicVolume);
config.SetValue(SettingsSection, "sfx_volume", settings.SFXVolume);
config.SetValue(SettingsSection, "show_ghost", settings.ShowGhost);
config.SetValue(SettingsSection, "screen_shake", settings.ScreenShake);
config.Save(SavePath);
}
/// <summary>
/// 读取设置
/// </summary>
public static GameSettings LoadSettings()
{
var config = new ConfigFile();
var settings = new GameSettings(); // 默认值
Error err = config.Load(SavePath);
if (err != Error.Ok) return settings;
settings.MusicVolume = (float)config.GetValue(
SettingsSection, "music_volume", settings.MusicVolume);
settings.SFXVolume = (float)config.GetValue(
SettingsSection, "sfx_volume", settings.SFXVolume);
settings.ShowGhost = (bool)config.GetValue(
SettingsSection, "show_ghost", settings.ShowGhost);
settings.ScreenShake = (bool)config.GetValue(
SettingsSection, "screen_shake", settings.ScreenShake);
return settings;
}
/// <summary>
/// 保存最高分
/// </summary>
public static void SaveHighScore(int score)
{
var config = new ConfigFile();
// 先读取已有的存档(保留其他数据)
config.Load(SavePath);
config.SetValue(StatsSection, "high_score", score);
config.Save(SavePath);
}
/// <summary>
/// 读取最高分
/// </summary>
public static int LoadHighScore()
{
var config = new ConfigFile();
Error err = config.Load(SavePath);
if (err != Error.Ok) return 0;
return (int)config.GetValue(StatsSection, "high_score", 0);
}
/// <summary>
/// 保存游戏统计数据
/// </summary>
public static void SaveStats(GameStats stats)
{
var config = new ConfigFile();
config.Load(SavePath);
config.SetValue(StatsSection, "total_games", stats.TotalGames);
config.SetValue(StatsSection, "total_lines", stats.TotalLines);
config.SetValue(StatsSection, "total_tetrises", stats.TotalTetrises);
config.SetValue(StatsSection, "max_level", stats.MaxLevel);
config.SetValue(StatsSection, "max_combo", stats.MaxCombo);
config.SetValue(StatsSection, "play_time", stats.TotalPlayTime);
config.Save(SavePath);
}
/// <summary>
/// 读取游戏统计数据
/// </summary>
public static GameStats LoadStats()
{
var config = new ConfigFile();
var stats = new GameStats();
Error err = config.Load(SavePath);
if (err != Error.Ok) return stats;
stats.TotalGames = (int)config.GetValue(
StatsSection, "total_games", 0);
stats.TotalLines = (int)config.GetValue(
StatsSection, "total_lines", 0);
stats.TotalTetrises = (int)config.GetValue(
StatsSection, "total_tetrises", 0);
stats.MaxLevel = (int)config.GetValue(
StatsSection, "max_level", 0);
stats.MaxCombo = (int)config.GetValue(
StatsSection, "max_combo", 0);
stats.TotalPlayTime = (float)config.GetValue(
StatsSection, "play_time", 0f);
return stats;
}
}
/// <summary>
/// 游戏设置数据
/// </summary>
public class GameSettings
{
public float MusicVolume { get; set; } = -5f;
public float SFXVolume { get; set; } = -3f;
public bool ShowGhost { get; set; } = true;
public bool ScreenShake { get; set; } = true;
}
/// <summary>
/// 游戏统计数据
/// </summary>
public class GameStats
{
public int TotalGames { get; set; }
public int TotalLines { get; set; }
public int TotalTetrises { get; set; }
public int MaxLevel { get; set; }
public int MaxCombo { get; set; }
public float TotalPlayTime { get; set; }
}GDScript
## 存档系统——保存和读取游戏数据
class_name SaveSystem
# 存档文件路径
const SAVE_PATH: String = "user://tetris_save.cfg"
# 存档节名称
const SETTINGS_SECTION: String = "settings"
const STATS_SECTION: String = "stats"
## 保存设置
static func save_settings(settings: Dictionary) -> void:
var config = ConfigFile.new()
config.set_value(SETTINGS_SECTION, "music_volume", settings.get("music_volume", -5.0))
config.set_value(SETTINGS_SECTION, "sfx_volume", settings.get("sfx_volume", -3.0))
config.set_value(SETTINGS_SECTION, "show_ghost", settings.get("show_ghost", true))
config.set_value(SETTINGS_SECTION, "screen_shake", settings.get("screen_shake", true))
config.save(SAVE_PATH)
## 读取设置
static func load_settings() -> Dictionary:
var config = ConfigFile.new()
var settings = {
"music_volume": -5.0,
"sfx_volume": -3.0,
"show_ghost": true,
"screen_shake": true
}
var err = config.load(SAVE_PATH)
if err != OK:
return settings
settings["music_volume"] = config.get_value(SETTINGS_SECTION, "music_volume", -5.0)
settings["sfx_volume"] = config.get_value(SETTINGS_SECTION, "sfx_volume", -3.0)
settings["show_ghost"] = config.get_value(SETTINGS_SECTION, "show_ghost", true)
settings["screen_shake"] = config.get_value(SETTINGS_SECTION, "screen_shake", true)
return settings
## 保存最高分
static func save_high_score(score: int) -> void:
var config = ConfigFile.new()
config.load(SAVE_PATH)
config.set_value(STATS_SECTION, "high_score", score)
config.save(SAVE_PATH)
## 读取最高分
static func load_high_score() -> int:
var config = ConfigFile.new()
var err = config.load(SAVE_PATH)
if err != OK:
return 0
return config.get_value(STATS_SECTION, "high_score", 0)
## 保存游戏统计数据
static func save_stats(stats: Dictionary) -> void:
var config = ConfigFile.new()
config.load(SAVE_PATH)
config.set_value(STATS_SECTION, "total_games", stats.get("total_games", 0))
config.set_value(STATS_SECTION, "total_lines", stats.get("total_lines", 0))
config.set_value(STATS_SECTION, "total_tetrises", stats.get("total_tetrises", 0))
config.set_value(STATS_SECTION, "max_level", stats.get("max_level", 0))
config.set_value(STATS_SECTION, "max_combo", stats.get("max_combo", 0))
config.set_value(STATS_SECTION, "play_time", stats.get("play_time", 0.0))
config.save(SAVE_PATH)
## 读取游戏统计数据
static func load_stats() -> Dictionary:
var config = ConfigFile.new()
var stats = {
"total_games": 0,
"total_lines": 0,
"total_tetrises": 0,
"max_level": 0,
"max_combo": 0,
"play_time": 0.0
}
var err = config.load(SAVE_PATH)
if err != OK:
return stats
stats["total_games"] = config.get_value(STATS_SECTION, "total_games", 0)
stats["total_lines"] = config.get_value(STATS_SECTION, "total_lines", 0)
stats["total_tetrises"] = config.get_value(STATS_SECTION, "total_tetrises", 0)
stats["max_level"] = config.get_value(STATS_SECTION, "max_level", 0)
stats["max_combo"] = config.get_value(STATS_SECTION, "max_combo", 0)
stats["play_time"] = config.get_value(STATS_SECTION, "play_time", 0.0)
return stats10.3 设置界面
让玩家可以调整音量、开关特效等功能。
C
using Godot;
/// <summary>
/// 设置界面
/// </summary>
public partial class SettingsScreen : CanvasLayer
{
// 音量滑块
private HSlider _musicSlider;
private HSlider _sfxSlider;
// 开关
private CheckButton _ghostToggle;
private CheckButton _shakeToggle;
// 按钮
private Button _backButton;
public override void _Ready()
{
_musicSlider = GetNode<HSlider>("Panel/VBox/MusicSlider");
_sfxSlider = GetNode<HSlider>("Panel/VBox/SFXSlider");
_ghostToggle = GetNode<CheckButton>("Panel/VBox/GhostToggle");
_shakeToggle = GetNode<CheckButton>("Panel/VBox/ShakeToggle");
_backButton = GetNode<Button>("Panel/VBox/BackButton");
// 加载设置
var settings = SaveSystem.LoadSettings();
_musicSlider.Value = settings.MusicVolume;
_sfxSlider.Value = settings.SFXVolume;
_ghostToggle.ButtonPressed = settings.ShowGhost;
_shakeToggle.ButtonPressed = settings.ScreenShake;
// 连接信号
_musicSlider.ValueChanged += OnMusicVolumeChanged;
_sfxSlider.ValueChanged += OnSFXVolumeChanged;
_ghostToggle.Toggled += OnGhostToggled;
_shakeToggle.Toggled += OnShakeToggled;
_backButton.Pressed += OnBackPressed;
Visible = false;
}
private void OnMusicVolumeChanged(float value)
{
// 实时更新音量
var busIdx = AudioServer.GetBusIndex("Music");
AudioServer.SetBusVolumeDb(busIdx, value);
}
private void OnSFXVolumeChanged(float value)
{
var busIdx = AudioServer.GetBusIndex("SFX");
AudioServer.SetBusVolumeDb(busIdx, value);
}
private void OnGhostToggled(bool pressed)
{
GameManager.Instance.ShowGhost = pressed;
}
private void OnShakeToggled(bool pressed)
{
GameManager.Instance.ScreenShakeEnabled = pressed;
}
private void OnBackPressed()
{
// 保存设置
SaveSystem.SaveSettings(new GameSettings
{
MusicVolume = (float)_musicSlider.Value,
SFXVolume = (float)_sfxSlider.Value,
ShowGhost = _ghostToggle.ButtonPressed,
ScreenShake = _shakeToggle.ButtonPressed
});
Visible = false;
}
}GDScript
extends CanvasLayer
## 设置界面
@onready var _music_slider = $Panel/VBox/MusicSlider
@onready var _sfx_slider = $Panel/VBox/SFXSlider
@onready var _ghost_toggle = $Panel/VBox/GhostToggle
@onready var _shake_toggle = $Panel/VBox/ShakeToggle
@onready var _back_button = $Panel/VBox/BackButton
func _ready() -> void:
# 加载设置
var settings = SaveSystem.load_settings()
_music_slider.value = settings["music_volume"]
_sfx_slider.value = settings["sfx_volume"]
_ghost_toggle.button_pressed = settings["show_ghost"]
_shake_toggle.button_pressed = settings["screen_shake"]
# 连接信号
_music_slider.value_changed.connect(_on_music_volume_changed)
_sfx_slider.value_changed.connect(_on_sfx_volume_changed)
_ghost_toggle.toggled.connect(_on_ghost_toggled)
_shake_toggle.toggled.connect(_on_shake_toggled)
_back_button.pressed.connect(_on_back_pressed)
visible = false
func _on_music_volume_changed(value: float) -> void:
var bus_idx = AudioServer.get_bus_index("Music")
AudioServer.set_bus_volume_db(bus_idx, value)
func _on_sfx_volume_changed(value: float) -> void:
var bus_idx = AudioServer.get_bus_index("SFX")
AudioServer.set_bus_volume_db(bus_idx, value)
func _on_ghost_toggled(pressed: bool) -> void:
GameManager.show_ghost = pressed
func _on_shake_toggled(pressed: bool) -> void:
GameManager.screen_shake_enabled = pressed
func _on_back_pressed() -> void:
# 保存设置
SaveSystem.save_settings({
"music_volume": _music_slider.value,
"sfx_volume": _sfx_slider.value,
"show_ghost": _ghost_toggle.button_pressed,
"screen_shake": _shake_toggle.button_pressed
})
visible = false10.4 动画优化清单
在发布之前,检查以下动画是否都到位:
| 动画 | 描述 | 状态 |
|---|---|---|
| 方块移动 | 平滑的位移动画 | ✓ |
| 方块旋转 | 带Tween的旋转动画 | ✓ |
| 硬降 | 快速下落 + 落地震动 | ✓ |
| 消行闪烁 | 满行快速闪烁后消失 | ✓ |
| 分数弹出 | 飘动的分数数字 | ✓ |
| Ghost Piece | 半透明投影实时更新 | ✓ |
| 升级特效 | 缩放 + 粒子 | ✓ |
| 游戏结束 | 面板弹入 + 新纪录闪烁 | ✓ |
| 暂停/恢复 | 淡入淡出 | ✓ |
| 按钮悬停 | 颜色变化 | ✓ |
10.5 Bug检查清单
发布前务必检查以下常见bug:
| Bug类型 | 检查项 | 修复方法 |
|---|---|---|
| 死锁 | 方块卡在墙里出不来 | 加强碰撞检测 |
| 穿模 | 方块穿过其他方块 | 检查IsValidPosition |
| 无限锁定 | 利用锁定延迟无限拖延 | 加MaxLockResets限制 |
| 消行错位 | 消行后网格数据不一致 | 从下往上消除 |
| Hold重复 | 一回合内多次Hold | 加UsedThisTurn标记 |
| 音效重叠 | 快速操作时音效叠放 | 使用对象池限制 |
| 内存泄漏 | 粒子/弹出未销毁 | 加自动销毁Timer |
C
/// <summary>
/// 常见Bug修复示例
/// </summary>
public partial class BugFixExamples
{
/// <summary>
/// Bug修复1:确保方块不会穿到网格外面
/// </summary>
public static bool SafeIsValidPosition(
int[,] cells, int[,] shape, int px, int py,
int cols, int rows)
{
int shapeRows = shape.GetLength(0);
int shapeCols = shape.GetLength(1);
for (int r = 0; r < shapeRows; r++)
{
for (int c = 0; c < shapeCols; c++)
{
if (shape[r, c] == 0) continue;
int gx = px + c;
int gy = py + r;
// 超出左右边界或底部 → 不合法
if (gx < 0 || gx >= cols || gy >= rows)
return false;
// 顶部以上(gy < 0)允许通过
if (gy < 0) continue;
// 格子被占据 → 不合法
if (cells[gx, gy] != 0)
return false;
}
}
return true;
}
/// <summary>
/// Bug修复2:防止消行时索引越界
/// </summary>
public static int SafeClearLines(
int[,] cells, int cols, int rows)
{
int cleared = 0;
for (int y = rows - 1; y >= 0; y--)
{
bool full = true;
for (int x = 0; x < cols; x++)
{
if (cells[x, y] == 0)
{
full = false;
break;
}
}
if (full)
{
cleared++;
// 安全地上移行
for (int moveY = y; moveY > 0; moveY--)
{
for (int x = 0; x < cols; x++)
{
cells[x, moveY] = cells[x, moveY - 1];
}
}
// 安全地清空顶行
for (int x = 0; x < cols; x++)
{
cells[x, 0] = 0;
}
y++; // 重新检查当前行
}
}
return cleared;
}
}GDScript
## 常见Bug修复示例
class_name BugFixExamples
## Bug修复1:确保方块不会穿到网格外面
static func safe_is_valid_position(
cells: Array, shape: Array, px: int, py: int,
cols: int, rows: int
) -> bool:
var shape_rows = shape.size()
var shape_cols = shape[0].size()
for r in range(shape_rows):
for c in range(shape_cols):
if shape[r][c] == 0:
continue
var gx = px + c
var gy = py + r
# 超出左右边界或底部 → 不合法
if gx < 0 or gx >= cols or gy >= rows:
return false
# 顶部以上(gy < 0)允许通过
if gy < 0:
continue
# 格子被占据 → 不合法
if cells[gx][gy] != 0:
return false
return true
## Bug修复2:防止消行时索引越界
static func safe_clear_lines(
cells: Array, cols: int, rows: int
) -> int:
var cleared = 0
var y = rows - 1
while y >= 0:
var full = true
for x in range(cols):
if cells[x][y] == 0:
full = false
break
if full:
cleared += 1
# 安全地上移行
var move_y = y
while move_y > 0:
for x in range(cols):
cells[x][move_y] = cells[x][move_y - 1]
move_y -= 1
# 安全地清空顶行
for x in range(cols):
cells[x][0] = 0
y += 1 # 重新检查当前行
y -= 1
return cleared10.6 项目导出
导出平台
Godot支持导出到多个平台:
| 平台 | 要求 | 说明 |
|---|---|---|
| Windows | 无额外要求 | 导出为.exe |
| Linux | 无额外要求 | 导出为.x86_64 |
| macOS | 需要Mac电脑 | 导出为.app |
| Android | 需要Android SDK | 导出为.apk |
| iOS | 需要Mac电脑+Xcode | 导出为.ipa |
| HTML5(网页) | 无额外要求 | 导出为HTML+JS+WASM |
导出步骤
- 菜单 → 项目 → 导出
- 点击"添加"按钮,选择目标平台
- 配置导出参数
- 点击"导出项目"或"一键导出"
Windows导出配置
平台: Windows Desktop
图标: 设置游戏图标(.ico格式)
分辨率: 保持默认
启动模式: 窗口模式网页版导出配置
平台: HTML5
HTML Shell: 使用自定义的index.html
内存: 256MB10.7 后续扩展方向
完成基础版本后,可以考虑以下扩展功能:
| 扩展功能 | 难度 | 说明 |
|---|---|---|
| 对战模式 | 高 | 两个玩家对战,互相消行给对方增加垃圾行 |
| 马拉松模式 | 低 | 挑战150行/200行/无尽模式 |
| 方块皮肤 | 低 | 替换方块的颜色和纹理 |
| 背景主题 | 低 | 不同的背景颜色和风格 |
| 排行榜 | 中 | 本地或在线排行榜 |
| 触屏优化 | 中 | 滑动手势操作 |
| 每日挑战 | 中 | 每天生成相同的方块序列 |
| 成就系统 | 中 | 解锁各种成就 |
10.8 本章小结
| 内容 | 说明 |
|---|---|
| 存档系统 | 使用ConfigFile保存设置、最高分和统计数据 |
| 设置界面 | 音量调节、特效开关 |
| 动画优化 | 确保所有操作都有对应的动画反馈 |
| Bug检查 | 列出常见bug和修复方法 |
| 项目导出 | 多平台导出配置 |
| 后续扩展 | 对战模式、皮肤系统、排行榜等 |
恭喜你完成了俄罗斯方块的全部开发!回顾一下我们走过的路:
| 章节 | 完成内容 |
|---|---|
| 1 | 分析核心玩法,理解游戏循环 |
| 2 | 搭建项目结构,创建GameManager和Grid |
| 3 | 实现七种方块数据和7-Bag生成器 |
| 4 | 实现SRS旋转系统和墙踢 |
| 5 | 实现消行检测和计分系统 |
| 6 | 实现等级系统和速度递增 |
| 7 | 实现Hold暂存和Next预览 |
| 8 | 搭建完整的游戏UI |
| 9 | 添加音效和粒子特效 |
| 10 | 存档、设置、导出和发布 |
你现在拥有了一个功能完整、体验流畅的俄罗斯方块游戏。接下来,让我们进入下一个实战项目——雷霆战机!
