10. 打磨与发布
2026/4/14大约 11 分钟
开心消消乐——打磨与发布
打磨的重要性
打磨(Polish)是游戏开发中最后也是最重要的环节。你可以把它想象成"装修新房"——毛坯房虽然能住,但刷上墙漆、铺上地板、挂上窗帘之后,才会让人眼前一亮。
| 打磨项 | 说明 | 重要性 |
|---|---|---|
| 无解检测 | 检测并修复不可能完成的情况 | 高(不修会导致玩家卡住) |
| 存档系统 | 保存和恢复玩家进度 | 高 |
| 性能优化 | 确保游戏流畅运行 | 中 |
| 发布前检查 | 全面测试和修复bug | 高 |
| 导出配置 | 打包成各平台安装包 | 高 |
无解局面检测与打乱
什么是无解局面?
有时候,棋盘上没有任何两个相邻方块的交换能产生匹配。这种情况叫"无解局面"——玩家无论怎么交换都没用,游戏卡住了。
就像你拼拼图时,发现手上没有任何两块能拼在一起——只能把拼图打乱重来。
检测算法
遍历棋盘上的每一对相邻方块,模拟交换,检查交换后是否产生匹配。如果所有相邻对都没有可行交换,就是无解局面。
C
/// <summary>
/// 检测当前棋盘是否为无解局面
/// </summary>
/// <returns>true 表示无解</returns>
public bool IsDeadlocked()
{
for (int row = 0; row < GameManager.ROWS; row++)
{
for (int col = 0; col < GameManager.COLS; col++)
{
// 尝试与右边方块交换
if (col < GameManager.COLS - 1)
{
SwapData(row, col, row, col + 1);
if (HasMatchAt(row, col) || HasMatchAt(row, col + 1))
{
SwapData(row, col, row, col + 1); // 换回
return false; // 有解!
}
SwapData(row, col, row, col + 1); // 换回
}
// 尝试与下面方块交换
if (row < GameManager.ROWS - 1)
{
SwapData(row, col, row + 1, col);
if (HasMatchAt(row, col) || HasMatchAt(row + 1, col))
{
SwapData(row, col, row + 1, col); // 换回
return false; // 有解!
}
SwapData(row, col, row + 1, col); // 换回
}
}
}
GD.Print("[GameBoard] 检测到无解局面!");
return true; // 无解
}
/// <summary>
/// 仅交换数据(不交换节点)
/// </summary>
private void SwapData(int r1, int c1, int r2, int c2)
{
PieceType temp = _grid[r1, c1];
_grid[r1, c1] = _grid[r2, c2];
_grid[r2, c2] = temp;
}
/// <summary>
/// 检查指定位置是否是某个匹配的一部分
/// </summary>
private bool HasMatchAt(int row, int col)
{
PieceType type = _grid[row, col];
if (type == PieceType.Empty) return false;
// 检查水平方向
int hCount = 1;
// 向左
for (int c = col - 1; c >= 0 && _grid[row, c] == type; c--) hCount++;
// 向右
for (int c = col + 1; c < GameManager.COLS && _grid[row, c] == type; c++) hCount++;
if (hCount >= 3) return true;
// 检查垂直方向
int vCount = 1;
// 向上
for (int r = row - 1; r >= 0 && _grid[r, col] == type; r--) vCount++;
// 向下
for (int r = row + 1; r < GameManager.ROWS && _grid[r, col] == type; r++) vCount++;
return vCount >= 3;
}GDScript
## 检测当前棋盘是否为无解局面
## 返回 true 表示无解
func is_deadlocked() -> bool:
for row in range(GameManager.ROWS):
for col in range(GameManager.COLS):
# 尝试与右边方块交换
if col < GameManager.COLS - 1:
_swap_data(row, col, row, col + 1)
if _has_match_at(row, col) or _has_match_at(row, col + 1):
_swap_data(row, col, row, col + 1)
return false # 有解!
_swap_data(row, col, row, col + 1)
# 尝试与下面方块交换
if row < GameManager.ROWS - 1:
_swap_data(row, col, row + 1, col)
if _has_match_at(row, col) or _has_match_at(row + 1, col):
_swap_data(row, col, row + 1, col)
return false # 有解!
_swap_data(row, col, row + 1, col)
print("[GameBoard] 检测到无解局面!")
return true # 无解
## 仅交换数据(不交换节点)
func _swap_data(r1: int, c1: int, r2: int, c2: int) -> void:
var temp = grid[r1][c1]
grid[r1][c1] = grid[r2][c2]
grid[r2][c2] = temp
## 检查指定位置是否是某个匹配的一部分
func _has_match_at(row: int, col: int) -> bool:
var type: int = grid[row][col]
if type == GameManager.PieceType.EMPTY:
return false
# 检查水平方向
var h_count: int = 1
var c := col - 1
while c >= 0 and grid[row][c] == type:
h_count += 1
c -= 1
c = col + 1
while c < GameManager.COLS and grid[row][c] == type:
h_count += 1
c += 1
if h_count >= 3:
return true
# 检查垂直方向
var v_count: int = 1
var r := row - 1
while r >= 0 and grid[r][col] == type:
v_count += 1
r -= 1
r = row + 1
while r < GameManager.ROWS and grid[r][col] == type:
v_count += 1
r += 1
return v_count >= 3打乱棋盘
检测到无解局面后,需要重新打乱棋盘。打乱不是完全随机——需要确保打乱后至少有一个可行交换。
C
/// <summary>
/// 打乱棋盘
/// 重新随机排列所有方块,确保有解
/// </summary>
public async void ShuffleBoard()
{
GD.Print("[GameBoard] 打乱棋盘...");
_gameManager.CurrentState = GameState.Falling;
// 显示"打乱"提示
ShowShuffleHint();
// 收集所有方块类型
var types = new List<PieceType>();
for (int row = 0; row < GameManager.ROWS; row++)
{
for (int col = 0; col < GameManager.COLS; col++)
{
types.Add(_grid[row, col]);
}
}
// Fisher-Yates 洗牌
ShuffleList(types);
// 将打乱后的类型放回棋盘
int index = 0;
for (int row = 0; row < GameManager.ROWS; row++)
{
for (int col = 0; col < GameManager.COLS; col++)
{
_grid[row, col] = types[index++];
// 更新方块节点
Piece piece = _pieces[row, col];
if (piece != null)
{
piece.Initialize(_grid[row, col], row, col);
// 播放缩放动画
Vector2 targetPos = GridToPixel(row, col);
piece.Scale = Vector2.Zero;
Tween tween = CreateTween();
tween.TweenProperty(piece, "scale", Vector2.One, 0.3f)
.SetDelay((row + col) * 0.02f)
.SetTrans(Tween.TransitionType.Back)
.SetEase(Tween.EaseType.Out);
}
}
}
// 等待动画完成
await ToSignal(GetTree().CreateTimer(0.6), SceneTree.TimerSignalName.Timeout);
// 如果打乱后仍然无解(极小概率),再打乱一次
if (IsDeadlocked())
{
GD.Print("[GameBoard] 打乱后仍无解,再次打乱...");
ShuffleBoard();
return;
}
// 消除打乱后可能产生的初始匹配
var matches = FindMatches();
if (matches.Count > 0)
{
ProcessMatches(matches);
}
else
{
_gameManager.CurrentState = GameState.Idle;
}
}
/// <summary>
/// Fisher-Yates 洗牌
/// </summary>
private void ShuffleList<T>(List<T> list)
{
var rng = new Random();
for (int i = list.Count - 1; i > 0; i--)
{
int j = rng.Next(i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
}
/// <summary>
/// 显示"棋盘已打乱"提示
/// </summary>
private void ShowShuffleHint()
{
// 可以用一个简单的 Label 显示提示
GD.Print("[GameBoard] 提示: 棋盘已重新打乱!");
}GDScript
## 打乱棋盘
## 重新随机排列所有方块,确保有解
func shuffle_board() -> void:
print("[GameBoard] 打乱棋盘...")
_game_manager.current_state = GameManager.GameState.FALLING
# 显示"打乱"提示
_show_shuffle_hint()
# 收集所有方块类型
var types: Array = []
for row in range(GameManager.ROWS):
for col in range(GameManager.COLS):
types.append(grid[row][col])
# Fisher-Yates 洗牌
types.shuffle()
# 将打乱后的类型放回棋盘
var idx: int = 0
for row in range(GameManager.ROWS):
for col in range(GameManager.COLS):
grid[row][col] = types[idx]
idx += 1
# 更新方块节点
var piece: Piece = pieces[row][col]
if piece != null:
piece.initialize(grid[row][col], row, col)
# 播放缩放动画
piece.scale = Vector2.ZERO
var tween := create_tween()
tween.tween_property(piece, "scale", Vector2.ONE, 0.3) \
.set_delay((row + col) * 0.02) \
.set_trans(Tween.TransitionType.BACK) \
.set_ease(Tween.EaseType.OUT)
# 等待动画完成
await get_tree().create_timer(0.6).timeout
# 如果打乱后仍然无解(极小概率),再打乱一次
if is_deadlocked():
print("[GameBoard] 打乱后仍无解,再次打乱...")
shuffle_board()
return
# 消除打乱后可能产生的初始匹配
var matches := find_matches()
if matches.size() > 0:
_process_matches(matches)
else:
_game_manager.current_state = GameManager.GameState.IDLE
## 显示"棋盘已打乱"提示
func _show_shuffle_hint() -> void:
print("[GameBoard] 提示: 棋盘已重新打乱!")在游戏循环中集成无解检测
每次下落完成后,在回到 Idle 状态之前检查是否无解:
C
/// <summary>
/// 下落完成后的检查(集成无解检测)
/// </summary>
private void OnFallComplete()
{
// 先检查是否有新的匹配
var newMatches = FindMatches();
if (newMatches.Count > 0)
{
_gameManager.IncrementCombo();
ProcessMatches(newMatches);
return;
}
// 没有新匹配,检查关卡是否完成
_gameManager.CheckLevelCompletion();
// 如果游戏还在继续,检查是否无解
if (_gameManager.CurrentState == GameState.Idle && IsDeadlocked())
{
ShuffleBoard();
}
else
{
_gameManager.ResetCombo();
_gameManager.CurrentState = GameState.Idle;
}
}GDScript
## 下落完成后的检查(集成无解检测)
func _on_fall_complete() -> void:
# 先检查是否有新的匹配
var new_matches := find_matches()
if new_matches.size() > 0:
_game_manager.increment_combo()
_process_matches(new_matches)
return
# 没有新匹配,检查关卡是否完成
_game_manager.check_level_completion()
# 如果游戏还在继续,检查是否无解
if _game_manager.current_state == GameManager.GameState.IDLE \
and is_deadlocked():
shuffle_board()
else:
_game_manager.reset_combo()
_game_manager.current_state = GameManager.GameState.IDLE存档系统完善
上一章已经实现了基本的存档功能。这里补充一些细节:
C
/// <summary>
/// 完整的存档管理器
/// </summary>
public partial class SaveManager : Node
{
private const string SavePath = "user://match3_save.cfg";
/// <summary>
/// 保存完整游戏状态
/// </summary>
public void SaveGame(int currentLevel, int maxUnlocked,
Dictionary<int, int> stars, bool soundOn, bool musicOn,
float sfxVol, float musicVol)
{
var config = new ConfigFile();
// 进度数据
config.SetValue("progress", "current_level", currentLevel);
config.SetValue("progress", "max_unlocked", maxUnlocked);
// 星级数据
config.SetValue("stars", "data", stars);
// 设置数据
config.SetValue("settings", "sound_on", soundOn);
config.SetValue("settings", "music_on", musicOn);
config.SetValue("settings", "sfx_volume", sfxVol);
config.SetValue("settings", "music_volume", musicVol);
// 保存时间戳
config.SetValue("meta", "last_save",
System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
Error err = config.Save(SavePath);
if (err == Error.Ok)
{
GD.Print($"[SaveManager] 游戏已保存 ({System.DateTime.Now})");
}
}
/// <summary>
/// 加载游戏状态
/// </summary>
public Godot.Collections.Dictionary LoadGame()
{
var config = new ConfigFile();
var result = new Godot.Collections.Dictionary();
Error err = config.Load(SavePath);
if (err != Error.Ok)
{
GD.Print("[SaveManager] 没有存档文件");
return result;
}
result["current_level"] = config.GetValue("progress", "current_level", 1);
result["max_unlocked"] = config.GetValue("progress", "max_unlocked", 1);
result["stars"] = config.GetValue("stars", "data", new Godot.Collections.Dictionary());
result["sound_on"] = config.GetValue("settings", "sound_on", true);
result["music_on"] = config.GetValue("settings", "music_on", true);
result["sfx_volume"] = config.GetValue("settings", "sfx_volume", 0.8);
result["music_volume"] = config.GetValue("settings", "music_volume", 0.5);
result["last_save"] = config.GetValue("meta", "last_save", "未知");
GD.Print($"[SaveManager] 存档已加载 (上次保存: {result["last_save"]})");
return result;
}
/// <summary>
/// 清除存档
/// </summary>
public void ClearSave()
{
if (FileAccess.FileExists(SavePath))
{
DirAccess.RemoveAbsolute(SavePath);
GD.Print("[SaveManager] 存档已清除");
}
}
}GDScript
extends Node
const SAVE_PATH: String = "user://match3_save.cfg"
## 保存完整游戏状态
func save_game(current_level: int, max_unlocked: int,
stars: Dictionary, sound_on: bool, music_on: bool,
sfx_vol: float, music_vol: float) -> void:
var config := ConfigFile.new()
# 进度数据
config.set_value("progress", "current_level", current_level)
config.set_value("progress", "max_unlocked", max_unlocked)
# 星级数据
config.set_value("stars", "data", stars)
# 设置数据
config.set_value("settings", "sound_on", sound_on)
config.set_value("settings", "music_on", music_on)
config.set_value("settings", "sfx_volume", sfx_vol)
config.set_value("settings", "music_volume", music_vol)
# 保存时间戳
var datetime := Time.get_datetime_dict_from_system()
config.set_value("meta", "last_save",
"%d-%02d-%02d %02d:%02d:%02d" % [
datetime.year, datetime.month, datetime.day,
datetime.hour, datetime.minute, datetime.second
])
var err: Error = config.save(SAVE_PATH)
if err == OK:
print("[SaveManager] 游戏已保存")
## 加载游戏状态
func load_game() -> Dictionary:
var config := ConfigFile.new()
var result: Dictionary = {}
var err: Error = config.load(SAVE_PATH)
if err != OK:
print("[SaveManager] 没有存档文件")
return result
result["current_level"] = config.get_value("progress", "current_level", 1)
result["max_unlocked"] = config.get_value("progress", "max_unlocked", 1)
result["stars"] = config.get_value("stars", "data", {})
result["sound_on"] = config.get_value("settings", "sound_on", true)
result["music_on"] = config.get_value("settings", "music_on", true)
result["sfx_volume"] = config.get_value("settings", "sfx_volume", 0.8)
result["music_volume"] = config.get_value("settings", "music_volume", 0.5)
result["last_save"] = config.get_value("meta", "last_save", "未知")
print("[SaveManager] 存档已加载 (上次保存: %s)" % result["last_save"])
return result
## 清除存档
func clear_save() -> void:
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)
print("[SaveManager] 存档已清除")性能优化
对象池
频繁创建和销毁方块节点会导致性能问题。使用对象池可以重复利用方块节点,避免反复创建销毁。
C
/// <summary>
/// 方块对象池
/// 预先创建一批方块,需要时取出,不用时归还
/// 就像一个"工具借用站"——用完还回来,下个人接着用
/// </summary>
public partial class PiecePool : Node
{
[Export] public PackedScene PieceScene { get; set; }
/// <summary>池中可用的方块</summary>
private readonly List<Piece> _available = new List<Piece>();
/// <summary>池中已借出的方块</summary>
private readonly HashSet<Piece> _inUse = new HashSet<Piece>();
/// <summary>
/// 预热对象池——提前创建一批方块
/// </summary>
public void Warmup(int count)
{
for (int i = 0; i < count; i++)
{
var piece = PieceScene.Instantiate<Piece>();
piece.Visible = false;
AddChild(piece);
_available.Add(piece);
}
GD.Print($"[PiecePool] 预热完成,创建 {count} 个方块");
}
/// <summary>
/// 从池中获取一个方块
/// </summary>
public Piece Get()
{
Piece piece;
if (_available.Count > 0)
{
piece = _available[_available.Count - 1];
_available.RemoveAt(_available.Count - 1);
}
else
{
// 池中没有可用的,临时创建一个
piece = PieceScene.Instantiate<Piece>();
AddChild(piece);
GD.Print("[PiecePool] 池已空,临时创建方块");
}
piece.Visible = true;
_inUse.Add(piece);
return piece;
}
/// <summary>
/// 将方块归还到池中
/// </summary>
public void Return(Piece piece)
{
if (_inUse.Remove(piece))
{
piece.Visible = false;
piece.SetSpecial(SpecialType.None);
_available.Add(piece);
}
}
}GDScript
extends Node
@export var piece_scene: PackedScene
## 池中可用的方块
var _available: Array[Piece] = []
## 池中已借出的方块
var _in_use: Array[Piece] = []
## 预热对象池——提前创建一批方块
func warmup(count: int) -> void:
for i in range(count):
var piece: Piece = piece_scene.instantiate()
piece.visible = false
add_child(piece)
_available.append(piece)
print("[PiecePool] 预热完成,创建 %d 个方块" % count)
## 从池中获取一个方块
func get_piece() -> Piece:
var piece: Piece
if _available.size() > 0:
piece = _available.pop_back()
else:
# 池中没有可用的,临时创建一个
piece = piece_scene.instantiate()
add_child(piece)
print("[PiecePool] 池已空,临时创建方块")
piece.visible = true
_in_use.append(piece)
return piece
## 将方块归还到池中
func return_piece(piece: Piece) -> void:
var idx := _in_use.find(piece)
if idx >= 0:
_in_use.remove_at(idx)
piece.visible = false
piece.set_special(0) # SpecialType.NONE
_available.append(piece)发布前检查清单
在导出游戏之前,请逐项检查以下内容:
| 检查项 | 说明 | 状态 |
|---|---|---|
| 游戏能正常启动 | 无报错,场景加载正常 | [ ] |
| 核心玩法完整 | 交换、匹配、消除、下落、连锁 | [ ] |
| 无解检测生效 | 无解时自动打乱 | [ ] |
| 计分正确 | 分数计算、连击倍率无误 | [ ] |
| 关卡进度保存 | 关闭重开后进度不丢失 | [ ] |
| UI 显示正常 | HUD、弹窗、关卡选择 | [ ] |
| 音效正常播放 | 所有音效能正确触发 | [ ] |
| 特殊方块生效 | 条纹、炸弹、彩虹球效果正确 | [ ] |
| 无内存泄漏 | 长时间运行无卡顿、内存不增长 | [ ] |
| 触摸操作正常 | 手机上点击和拖拽响应正确 | [ ] |
导出游戏
导出步骤
- 安装导出模板:Godot 编辑器 → 编辑器 → 管理导出模板
- 添加导出平台:项目 → 导出 → 添加目标平台
- 配置导出选项:设置图标、权限等
- 导出:点击"导出"按钮
各平台导出配置
| 平台 | 文件格式 | 注意事项 |
|---|---|---|
| Windows | .exe | 64位,不需要额外安装 |
| Android | .apk | 需要安装 Android SDK |
| iOS | .ipa | 需要 Mac 电脑和 Apple 开发者账号 |
| Web | .html + .wasm | 适合嵌入网页 |
| macOS | .app | 需要 Apple 开发者签名 |
项目设置检查
在 项目 → 项目设置 中确认:
| 设置项 | 推荐值 |
|---|---|
| 应用名称 | MatchThree |
| 窗口大小 | 512 x 640 |
| Stretch Mode | Canvas Items |
| Stretch Aspect | Keep |
| 禁用鼠标光标(移动端) | Handheld |
| 渲染器 | Compatibility |
本章小结
| 完成项 | 说明 |
|---|---|
| 无解检测 | 遍历所有相邻对,模拟交换检查匹配 |
| 棋盘打乱 | 洗牌算法 + 确保有解 |
| 存档完善 | 保存进度、设置、时间戳 |
| 对象池 | 预创建方块,避免反复创建销毁 |
| 发布检查 | 10项检查清单 |
| 导出配置 | Windows/Android/iOS/Web 各平台配置 |
恭喜!经过10章的学习,你已经完成了一个完整的开心消消乐游戏。这个项目覆盖了三消游戏的核心机制,以及游戏开发中常见的状态管理、动画系统、UI设计、音效处理、性能优化和发布流程。希望你在这个过程中不仅学到了技术,也感受到了游戏开发的乐趣。
