10. 打磨与发布
2026/4/14大约 8 分钟
休闲益智——打磨与发布
你的三消游戏已经能玩了:有棋盘、有消除、有计分、有关卡、有UI、有音效。但现在它更像一个"原型"而不是一个"产品"。就像刚盖好的毛坯房——结构有了,但还没刷墙、没装灯、没摆家具。
本章就是"精装修"环节——把游戏从"能玩"打磨成"好玩",然后发布出去让别人也能玩到。
本章你将学到
- 动画缓动效果(让方块移动更自然)
- 无解局面检测和处理
- 数据持久化(存档系统)
- 发布前检查清单
- 导出发布到不同平台
- 后续扩展方向
动画打磨
缓动效果选择
Tween 动画的缓动函数(Ease)决定了动画的"节奏感"。不同的缓动函数给人完全不同的感受:
| 缓动函数 | 效果 | 适用场景 |
|---|---|---|
EaseOut | 先快后慢,自然停止 | 方块下落 |
EaseIn | 先慢后快,加速启动 | 不太常用 |
EaseInOut | 两头慢中间快 | UI 弹出 |
Bounce | 弹跳效果 | 方块落地 |
Back | 超过目标再回弹 | 连击文字弹出 |
Elastic | 弹性抖动 | 特殊方块生成 |
C
// 方块下落:带弹性的缓动,让方块"落地"时有弹跳感
var tween = CreateTween();
tween.TweenProperty(block, "position", targetPos, 0.3f)
.SetTrans(Tween.TransitionType.Bounce)
.SetEase(Tween.EaseType.Out);
// 方块消除:先放大再缩小消失
var removeTween = CreateTween();
removeTween.TweenProperty(block, "scale", Vector2.One * 1.3f, 0.1f);
removeTween.TweenProperty(block, "scale", Vector2.Zero, 0.15f)
.SetDelay(0.05f);
removeTween.TweenCallback(Callable.From(() => block.QueueFree()));
// 连击文字:弹出效果
var comboTween = CreateTween();
comboLabel.Scale = Vector2.One * 0.5f;
comboTween.TweenProperty(comboLabel, "scale",
Vector2.One * 1.5f, 0.2f)
.SetTrans(Tween.TransitionType.Back);
comboTween.TweenProperty(comboLabel, "scale",
Vector2.One, 0.3f);
// 交换动画:平滑过渡
var swapTween = CreateTween();
swapTween.TweenProperty(blockA, "position", posB, 0.2f)
.SetTrans(Tween.TransitionType.Cubic)
.SetEase(Tween.EaseType.InOut);
swapTween.Parallel().TweenProperty(blockB, "position", posA, 0.2f)
.SetTrans(Tween.TransitionType.Cubic)
.SetEase(Tween.EaseType.InOut);GDScript
# 方块下落:带弹性的缓动
var tween = create_tween()
tween.tween_property(block, "position", target_pos, 0.3)
tween.set_trans(Tween.TRANS_BOUNCE)
tween.set_ease(Tween.EASE_OUT)
# 方块消除:先放大再缩小消失
var remove_tween = create_tween()
remove_tween.tween_property(block, "scale", Vector2.ONE * 1.3, 0.1)
remove_tween.tween_property(block, "scale", Vector2.ZERO, 0.15)
remove_tween.set_delay(0.05)
remove_tween.tween_callback(block.queue_free)
# 连击文字:弹出效果
var combo_tween = create_tween()
combo_label.scale = Vector2.ONE * 0.5
combo_tween.tween_property(combo_label, "scale", Vector2.ONE * 1.5, 0.2)
combo_tween.set_trans(Tween.TRANS_BACK)
combo_tween.tween_property(combo_label, "scale", Vector2.ONE, 0.3)
# 交换动画:平滑过渡
var swap_tween = create_tween()
swap_tween.tween_property(block_a, "position", pos_b, 0.2)
swap_tween.set_trans(Tween.TRANS_CUBIC)
swap_tween.set_ease(Tween.EASE_IN_OUT)
swap_tween.parallel().tween_property(block_b, "position", pos_a, 0.2)
swap_tween.set_trans(Tween.TRANS_CUBIC)
swap_tween.set_ease(Tween.EASE_IN_OUT)无解局面检测
有时候棋盘上没有任何可以交换产生匹配的位置——这就是"无解局面"。如果不处理,玩家会被困住无法操作。
解决方案
最简单的做法是:检测到无解时,自动重新打乱棋盘。
C
/// <summary>
/// 检查棋盘上是否存在至少一个有效的交换。
/// 遍历所有相邻格子对,模拟交换后检查是否产生匹配。
/// </summary>
private bool HasValidMoves()
{
for (int row = 0; row < Rows; row++)
{
for (int col = 0; col < Cols; col++)
{
// 尝试和右边交换
if (col < Cols - 1)
{
SwapInData(row, col, row, col + 1);
if (FindAllMatches().Count > 0)
{
SwapInData(row, col, row, col + 1); // 换回来
return true;
}
SwapInData(row, col, row, col + 1);
}
// 尝试和下面交换
if (row < Rows - 1)
{
SwapInData(row, col, row + 1, col);
if (FindAllMatches().Count > 0)
{
SwapInData(row, col, row + 1, col); // 换回来
return true;
}
SwapInData(row, col, row + 1, col);
}
}
}
return false;
}
/// <summary>
/// 打乱棋盘 —— 收集所有方块类型,随机重排,确保有解。
/// </summary>
private void ShuffleBoard()
{
// 收集所有方块类型
var types = new List<int>();
for (int row = 0; row < Rows; row++)
{
for (int col = 0; col < Cols; col++)
{
types.Add((int)board[row, col]);
}
}
// 不断打乱直到有解
int attempts = 0;
do
{
// Fisher-Yates 洗牌算法
for (int i = types.Count - 1; i > 0; i--)
{
int j = rng.RandiRange(0, i);
(types[i], types[j]) = (types[j], types[i]);
}
// 写回棋盘
int idx = 0;
for (int row = 0; row < Rows; row++)
{
for (int col = 0; col < Cols; col++)
{
board[row, col] = (PieceType)types[idx++];
}
}
attempts++;
}
while (!HasValidMoves() && attempts < 100);
RenderBoard();
// 显示提示
ShowFloatingText("棋盘已重新排列!", new Vector2(360, 400));
}GDScript
## 检查棋盘上是否存在至少一个有效的交换
func has_valid_moves() -> bool:
for row in range(ROWS):
for col in range(COLS):
if col < COLS - 1:
_swap_in_data(row, col, row, col + 1)
if find_all_matches().size() > 0:
_swap_in_data(row, col, row, col + 1)
return true
_swap_in_data(row, col, row, col + 1)
if row < ROWS - 1:
_swap_in_data(row, col, row + 1, col)
if find_all_matches().size() > 0:
_swap_in_data(row, col, row + 1, col)
return true
_swap_in_data(row, col, row + 1, col)
return false
## 打乱棋盘
func shuffle_board() -> void:
var types: Array = []
for row in range(ROWS):
for col in range(COLS):
types.append(board[row][col])
var attempts: int = 0
while attempts < 100:
# Fisher-Yates 洗牌
for i in range(types.size() - 1, 0, -1):
var j: int = rng.randi_range(0, i)
var temp = types[i]
types[i] = types[j]
types[j] = temp
var idx: int = 0
for row in range(ROWS):
for col in range(COLS):
board[row][col] = types[idx]
idx += 1
if has_valid_moves():
break
attempts += 1
render_board()数据持久化
用 JSON 文件保存玩家的游戏进度,下次打开游戏时自动恢复。
C
/// <summary>
/// 存档管理器 —— 把玩家的进度保存到文件,下次打开游戏时恢复。
/// 就像游戏的"记忆":记住你玩到了第几关、每关拿了多少星。
/// </summary>
public partial class SaveManager : Node
{
private const string SavePath = "user://match_three_save.json";
/// <summary>存档数据</summary>
public class SaveData
{
public int MaxUnlockedLevel { get; set; } = 1;
public Dictionary<int, int> LevelStars { get; set; } = new();
public int TotalCoins { get; set; } = 0;
public int HighScore { get; set; } = 0;
public float SfxVolume { get; set; } = 1.0f;
public float BgmVolume { get; set; } = 0.7f;
}
/// <summary>保存进度</summary>
public void Save(SaveData data)
{
var dict = new Godot.Collections.Dictionary
{
["max_unlocked_level"] = data.MaxUnlockedLevel,
["total_coins"] = data.TotalCoins,
["high_score"] = data.HighScore,
["sfx_volume"] = data.SfxVolume,
["bgm_volume"] = data.BgmVolume,
};
var starsDict = new Godot.Collections.Dictionary();
foreach (var kvp in data.LevelStars)
{
starsDict[kvp.Key.ToString()] = kvp.Value;
}
dict["level_stars"] = starsDict;
var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write);
file.StoreString(Json.Stringify(dict, "\t"));
file.Close();
}
/// <summary>加载进度</summary>
public SaveData Load()
{
if (!FileAccess.FileExists(SavePath))
return new SaveData();
var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read);
var json = Json.ParseString(file.GetAsText());
file.Close();
if (json.VariantType != Variant.Dictionary)
return new SaveData();
var dict = json.AsGodotDictionary();
var data = new SaveData
{
MaxUnlockedLevel = (int)dict["max_unlocked_level"],
TotalCoins = (int)dict["total_coins"],
HighScore = (int)dict["high_score"],
SfxVolume = (float)dict["sfx_volume"],
BgmVolume = (float)dict["bgm_volume"],
};
var starsDict = dict["level_stars"].AsGodotDictionary();
foreach (var key in starsDict.Keys)
{
data.LevelStars[int.Parse(key.ToString())] = (int)starsDict[key];
}
return data;
}
}GDScript
extends Node
## 存档管理器
const SAVE_PATH: String = "user://match_three_save.json"
## 保存进度
func save(data: Dictionary) -> void:
var json_string: String = JSON.stringify(data, "\t")
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
file.store_string(json_string)
file.close()
## 加载进度
func load_data() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return _default_data()
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
var json_string: String = file.get_as_text()
file.close()
var json = JSON.new()
var result = json.parse(json_string)
if result != OK:
return _default_data()
return json.data
## 默认存档数据
func _default_data() -> Dictionary:
return {
"max_unlocked_level": 1,
"total_coins": 0,
"high_score": 0,
"level_stars": {},
"sfx_volume": 1.0,
"bgm_volume": 0.7
}发布前检查清单
在导出发布之前,逐项检查以下清单:
功能检查
体验检查
技术检查
导出发布
平台注意事项
| 平台 | 分辨率 | 特殊注意 |
|---|---|---|
| Web | 自适应 | 压缩素材,包体控制在 20MB 以内 |
| Android/iOS | 竖屏优先 | 适配不同屏幕尺寸,棋盘自适应 |
| PC (Windows/Mac/Linux) | 720x1280 | 支持鼠标拖拽,窗口可缩放 |
导出步骤
- 打开
项目 → 导出 - 添加目标平台(Android、Windows 等)
- 配置导出预设(图标、权限等)
- 点击"导出"按钮
Web 版本的额外优化
导出 Web 版本时,在导出预设中启用"HTML Shell"的"显示控制面板"选项关闭,并设置"/headless"模式。同时确保所有素材都经过了压缩。
后续扩展方向
完成基础版本后,你可以根据兴趣选择扩展方向:
| 扩展方向 | 说明 | 难度 |
|---|---|---|
| 更多关卡 | 设计 50-100 个关卡,分章节 | 中等 |
| 障碍物系统 | 冰块(需要多次消除)、锁链(不能移动) | 中等 |
| 社交功能 | 排行榜、好友挑战 | 较高 |
| 每日挑战 | 每天一个特殊关卡,限定步数 | 中等 |
| 主题皮肤 | 节日主题棋盘和方块外观 | 简单 |
| 内购系统 | 道具购买、去广告 | 较高 |
| 无尽模式 | 无步数限制,计时挑战 | 简单 |
总结
恭喜你完成了一个完整的三消游戏教程!回顾一下我们走过的路:
- 核心玩法设计 —— 分析了三消游戏的核心循环和三大设计原则
- 项目搭建 —— 创建了项目结构、游戏管理器、场景骨架
- 网格逻辑 —— 用二维数组管理棋盘,实现了坐标转换和渲染
- 方块移动与消除 —— 实现了交换、匹配检测、消除、下落填充的完整流程
- 计分与连击 —— 设计了计分规则和连击倍率,制作了分数飘字
- 关卡递进 —— 实现了关卡目标、步数限制、星级评价、解锁机制
- 特殊方块 —— 实现了条纹、炸弹、彩虹三种特殊方块及其组合
- 游戏 UI —— 制作了 HUD、过关/失败弹窗、连击显示
- 音效与特效 —— 添加了音效管理器、粒子特效、屏幕震动
- 打磨与发布 —— 优化了动画、处理了无解局面、实现了存档系统
三消游戏的规则简单,但要做到"让人停不下来",需要在动画反馈和数值设计上花很多心思。建议你先发布一个最小可玩版本,然后根据玩家反馈持续迭代。
