8. 打磨与发布
2026/4/13大约 10 分钟
打磨与发布
经过前面7章的开发,我们的麻将游戏已经具备了核心玩法。但一个"能玩"的游戏和一款"好玩"的游戏之间,差的就是"打磨"——音效、动画、光影、UI这些细节。
生活化比喻
打磨就像给蛋糕裱花——蛋糕胚已经烤好了(核心玩法),现在需要涂奶油、放水果、写字(音效、UI、动画)。这些装饰不会改变蛋糕的味道,但会让它看起来更诱人、吃起来更开心。
UI 美化
UI 布局设计
一个完整的麻将游戏UI包含以下区域:
┌─────────────────────────────────────┐
│ [对手C的手牌(背面)] │
│ │
│ [对手B] ┌───────────────┐ [对手A] │
│ (背面) │ │ (背面) │
│ │ 牌桌中央 │ │
│ │ (弃牌区域) │ │
│ │ │ │
│ └───────────────┘ │
│ │
│ [我的手牌(正面,可选择)] │
│ │
│ [计分板] [剩余牌数] [操作按钮] │
└─────────────────────────────────────┘UI 元素清单
| UI 元素 | 类型 | 位置 | 说明 |
|---|---|---|---|
| 手牌区域 | 3D场景 | 底部中央 | 显示玩家的手牌 |
| 弃牌区域 | 3D场景 | 桌面中央 | 显示所有打出的牌 |
| 对手手牌 | 3D场景 | 上/左/右 | 显示牌背 |
| 计分板 | Panel | 右上角 | 显示每个玩家的分数 |
| 剩余牌数 | Label | 左上角 | 牌堆还剩多少张 |
| 操作按钮 | Button | 底部中央 | 碰/杠/胡/过 |
| 回合指示 | Label | 顶部中央 | 显示轮到谁了 |
| 计时器 | ProgressBar | 手牌上方 | 出牌倒计时 |
计分板UI代码
C
using Godot;
/// <summary>
/// 计分板UI控制器
/// </summary>
public partial class ScoreBoard : Panel
{
/// <summary>
/// 玩家分数标签(4个)
/// </summary>
[Export] public Label Player1Score { get; set; }
[Export] public Label Player2Score { get; set; }
[Export] public Label Player3Score { get; set; }
[Export] public Label Player4Score { get; set; }
/// <summary>
/// 玩家名称标签
/// </summary>
[Export] public Label Player1Name { get; set; }
[Export] public Label Player2Name { get; set; }
[Export] public Label Player3Name { get; set; }
[Export] public Label Player4Name { get; set; }
/// <summary>
/// 玩家分数数据
/// </summary>
private int[] _scores = new int[4];
/// <summary>
/// 更新某个玩家的分数
/// </summary>
public void UpdateScore(int playerIndex, int score)
{
if (playerIndex < 0 || playerIndex >= 4) return;
_scores[playerIndex] = score;
// 更新UI显示
var label = playerIndex switch
{
0 => Player1Score,
1 => Player2Score,
2 => Player3Score,
3 => Player4Score,
_ => null
};
if (label != null)
{
label.Text = score.ToString();
// 分数变化时的动画效果
var tween = CreateTween();
tween.TweenProperty(label, "modulate", new Color(1, 1, 0), 0.2f);
tween.TweenProperty(label, "modulate", Colors.White, 0.3f);
}
}
/// <summary>
/// 设置玩家名称
/// </summary>
public void SetPlayerName(int playerIndex, string name)
{
var label = playerIndex switch
{
0 => Player1Name,
1 => Player2Name,
2 => Player3Name,
3 => Player4Name,
_ => null
};
if (label != null)
{
label.Text = name;
}
}
/// <summary>
/// 高亮当前玩家的计分区
/// </summary>
public void HighlightCurrentPlayer(int playerIndex)
{
// 重置所有玩家的高亮
ResetHighlights();
// 高亮当前玩家
var panel = GetChild<Panel>(playerIndex);
if (panel != null)
{
var stylebox = panel.GetThemeStylebox("panel").Duplicate() as StyleBoxFlat;
stylebox.BorderColor = new Color(1, 0.85f, 0); // 金色边框
stylebox.BorderWidthLeft = 3;
stylebox.BorderWidthRight = 3;
stylebox.BorderWidthTop = 3;
stylebox.BorderWidthBottom = 3;
panel.AddThemeStyleboxOverride("panel", stylebox);
}
}
/// <summary>
/// 重置所有高亮
/// </summary>
private void ResetHighlights()
{
for (int i = 0; i < 4; i++)
{
var panel = GetChildOrNull<Panel>(i);
if (panel != null)
{
panel.RemoveThemeStyleboxOverride("panel");
}
}
}
}GDScript
extends Panel
## 玩家分数标签
@export var player1_score: Label
@export var player2_score: Label
@export var player3_score: Label
@export var player4_score: Label
## 玩家名称标签
@export var player1_name: Label
@export var player2_name: Label
@export var player3_name: Label
@export var player4_name: Label
## 分数数据
var _scores: Array[int] = [0, 0, 0, 0]
## 更新玩家分数
func update_score(player_index: int, score: int) -> void:
if player_index < 0 or player_index >= 4:
return
_scores[player_index] = score
var label := match player_index:
0: player1_score
1: player2_score
2: player3_score
3: player4_score
_: null
if label != null:
label.text = str(score)
# 分数变化动画
var tween = create_tween()
tween.tween_property(label, "modulate", Color(1, 1, 0), 0.2)
tween.tween_property(label, "modulate", Color.WHITE, 0.3)
## 设置玩家名称
func set_player_name(player_index: int, name: String) -> void:
var label := match player_index:
0: player1_name
1: player2_name
2: player3_name
3: player4_name
_: null
if label != null:
label.text = name
## 高亮当前玩家
func highlight_current_player(player_index: int) -> void:
reset_highlights()
var panel = get_child_or_null(player_index) as Panel
if panel != null:
var stylebox = panel.get_theme_stylebox("panel").duplicate()
stylebox.border_color = Color(1, 0.85, 0)
stylebox.border_width_left = 3
stylebox.border_width_right = 3
stylebox.border_width_top = 3
stylebox.border_width_bottom = 3
panel.add_theme_stylebox_override("panel", stylebox)
func reset_highlights() -> void:
for i in range(4):
var panel = get_child_or_null(i) as Panel
if panel != null:
panel.remove_theme_stylebox_override("panel")音效系统
需要的音效
| 音效 | 触发时机 | 说明 |
|---|---|---|
| 摸牌声 | 每次摸牌 | 短促的"啪"声 |
| 出牌声 | 每次出牌 | 牌落在桌面的声音 |
| 碰牌声 | 碰牌操作 | "啪啪啪"三声 |
| 杠牌声 | 杠牌操作 | 更响亮的"啪"声 |
| 胡牌声 | 胡牌 | 欢快的音效 |
| 按钮点击 | UI按钮 | 轻微的点击声 |
| 背景音乐 | 游戏进行中 | 轻松的背景音乐 |
音效管理器
C
using Godot;
/// <summary>
/// 音效管理器——统一管理所有游戏音效
/// </summary>
public partial class SoundManager : Node
{
/// <summary>
/// 音效类型枚举
/// </summary>
public enum SoundType
{
DrawTile, // 摸牌
DiscardTile, // 出牌
Pong, // 碰牌
Kong, // 杠牌
Chi, // 吃牌
Win, // 胡牌
ButtonClick, // 按钮点击
GameStart, // 游戏开始
GameEnd // 游戏结束
}
/// <summary>
/// 音效音量(0-1)
/// </summary>
[Export] public float SoundVolume { get; set; } = 0.8f;
/// <summary>
/// 背景音乐音量(0-1)
/// </summary>
[Export] public float MusicVolume { get; set; } = 0.3f;
/// <summary>
/// 音效播放器列表
/// </summary>
private AudioStreamPlayer[] _soundPlayers = new AudioStreamPlayer[9];
/// <summary>
/// 背景音乐播放器
/// </summary>
private AudioStreamPlayer _musicPlayer;
public override void _Ready()
{
// 创建音效播放器池
for (int i = 0; i < _soundPlayers.Length; i++)
{
_soundPlayers[i] = new AudioStreamPlayer();
_soundPlayers[i].VolumeDb = Mathf.LinearToDb(SoundVolume);
AddChild(_soundPlayers[i]);
}
// 创建背景音乐播放器
_musicPlayer = new AudioStreamPlayer();
_musicPlayer.VolumeDb = Mathf.LinearToDb(MusicVolume);
_musicPlayer.Bus = "Music"; // 使用独立的音频总线
AddChild(_musicPlayer);
}
/// <summary>
/// 播放音效
/// </summary>
public void PlaySound(SoundType type)
{
int index = (int)type;
if (index < 0 || index >= _soundPlayers.Length) return;
// 如果正在播放,先停止
if (_soundPlayers[index].Playing)
{
_soundPlayers[index].Stop();
}
// 加载并播放音效
string path = GetSoundPath(type);
var stream = GD.Load<AudioStream>(path);
if (stream != null)
{
_soundPlayers[index].Stream = stream;
_soundPlayers[index].Play();
}
}
/// <summary>
/// 播放背景音乐
/// </summary>
public void PlayMusic(string musicPath)
{
var stream = GD.Load<AudioStream>(musicPath);
if (stream != null)
{
_musicPlayer.Stream = stream;
_musicPlayer.Play();
}
}
/// <summary>
/// 停止背景音乐
/// </summary>
public void StopMusic()
{
_musicPlayer.Stop();
}
/// <summary>
/// 设置音效音量
/// </summary>
public void SetSoundVolume(float volume)
{
SoundVolume = Mathf.Clamp(volume, 0, 1);
float db = Mathf.LinearToDb(SoundVolume);
foreach (var player in _soundPlayers)
{
player.VolumeDb = db;
}
}
/// <summary>
/// 设置音乐音量
/// </summary>
public void SetMusicVolume(float volume)
{
MusicVolume = Mathf.Clamp(volume, 0, 1);
_musicPlayer.VolumeDb = Mathf.LinearToDb(MusicVolume);
}
/// <summary>
/// 获取音效文件路径
/// </summary>
private string GetSoundPath(SoundType type)
{
return type switch
{
SoundType.DrawTile => "res://resources/audio/draw.wav",
SoundType.DiscardTile => "res://resources/audio/discard.wav",
SoundType.Pong => "res://resources/audio/pong.wav",
SoundType.Kong => "res://resources/audio/kong.wav",
SoundType.Chi => "res://resources/audio/chi.wav",
SoundType.Win => "res://resources/audio/win.wav",
SoundType.ButtonClick => "res://resources/audio/click.wav",
SoundType.GameStart => "res://resources/audio/game_start.wav",
SoundType.GameEnd => "res://resources/audio/game_end.wav",
_ => ""
};
}
}GDScript
extends Node
## 音效类型
enum SoundType {
DRAW_TILE, ## 摸牌
DISCARD_TILE, ## 出牌
PONG, ## 碰牌
KONG, ## 杠牌
CHI, ## 吃牌
WIN, ## 胡牌
BUTTON_CLICK, ## 按钮点击
GAME_START, ## 游戏开始
GAME_END ## 游戏结束
}
## 音效音量
@export var sound_volume: float = 0.8
## 背景音乐音量
@export var music_volume: float = 0.3
## 音效播放器
var _sound_players: Array[AudioStreamPlayer] = []
## 背景音乐播放器
var _music_player: AudioStreamPlayer
func _ready() -> void:
# 创建音效播放器
for i in range(9):
var player := AudioStreamPlayer.new()
player.volume_db = linear_to_db(sound_volume)
add_child(player)
_sound_players.append(player)
# 创建背景音乐播放器
_music_player = AudioStreamPlayer.new()
_music_player.volume_db = linear_to_db(music_volume)
_music_player.bus = "Music"
add_child(_music_player)
## 播放音效
func play_sound(type: int) -> void:
var index := type as int
if index < 0 or index >= _sound_players.size():
return
if _sound_players[index].playing:
_sound_players[index].stop()
var path := get_sound_path(type)
var stream = load(path) as AudioStream
if stream != null:
_sound_players[index].stream = stream
_sound_players[index].play()
## 播放背景音乐
func play_music(music_path: String) -> void:
var stream = load(music_path) as AudioStream
if stream != null:
_music_player.stream = stream
_music_player.play()
## 停止背景音乐
func stop_music() -> void:
_music_player.stop()
## 设置音效音量
func set_sound_volume(volume: float) -> void:
sound_volume = clampf(volume, 0, 1)
var db := linear_to_db(sound_volume)
for player in _sound_players:
player.volume_db = db
## 设置音乐音量
func set_music_volume(volume: float) -> void:
music_volume = clampf(volume, 0, 1)
_music_player.volume_db = linear_to_db(music_volume)
## 获取音效路径
func get_sound_path(type: int) -> String:
var paths := [
"res://resources/audio/draw.wav",
"res://resources/audio/discard.wav",
"res://resources/audio/pong.wav",
"res://resources/audio/kong.wav",
"res://resources/audio/chi.wav",
"res://resources/audio/win.wav",
"res://resources/audio/click.wav",
"res://resources/audio/game_start.wav",
"res://resources/audio/game_end.wav"
]
return paths[type] if type < paths.size() else ""3D 光影效果
牌面材质优化
真实的麻将牌有一种温润的质感——不是完全光滑的,也不是完全粗糙的。我们可以通过调整材质参数来模拟:
| 材质属性 | 值 | 效果 |
|---|---|---|
| Albedo Color | (0.98, 0.96, 0.92) | 象牙白 |
| Roughness | 0.2 | 光滑但不反光 |
| Metallic | 0.0 | 非金属 |
| Clearcoat | 0.5 | 清漆效果 |
| Clearcoat Roughness | 0.3 | 清漆有轻微粗糙度 |
清漆效果(Clearcoat)
Clearcoat 是模拟"上了一层清漆"的效果。真实麻将牌表面确实有一层保护漆,这让牌面有一种微微的光泽感。开启 Clearcoat 可以让3D牌面更真实。
环境反射
为了让牌面看起来更真实,可以添加环境贴图(Environment Map):
- 在 WorldEnvironment 节点中设置环境贴图
- 或者使用 Godot 内置的
PanoramaSkyMaterial - 环境贴图不需要太复杂——一个简单的"室内环境"就够了
桌面反光
牌桌也可以加入轻微的反光效果:
| 属性 | 值 | 说明 |
|---|---|---|
| Roughness | 0.4 | 绒布桌面有轻微反光 |
| Metallic | 0.0 | 非金属 |
| Normal Scale | 0.3 | 轻微的法线扰动 |
动画优化
动画性能
| 优化项 | 方法 | 说明 |
|---|---|---|
| 减少同时动画 | 动画队列 | 确保同一时间只有少量动画在播放 |
| 使用对象池 | 复用节点 | 避免频繁创建销毁牌面节点 |
| LOD | 远处的简化 | 对手的牌用简单模型 |
| 预加载资源 | 启动时加载 | 避免运行时卡顿 |
对象池模式
生活化比喻
对象池就像"租车公司"——你需要车的时候从公司租一辆,用完了还回去。公司不需要每次都造一辆新车,而是把还回来的车修一修再租给下一个人。这样既省钱又环保。
| 操作 | 不用对象池 | 用对象池 |
|---|---|---|
| 摸牌 | 创建新牌面节点 | 从池中取出 |
| 打牌 | 销毁节点 | 放回池中 |
| 碰牌 | 创建多个节点 | 从池中取出多个 |
多平台导出
支持的平台
| 平台 | 导出模板 | 注意事项 |
|---|---|---|
| Windows | Windows Desktop | 默认开发平台,最简单 |
| macOS | macOS | 需要在Mac上导出 |
| Linux | Linux/X11 | 注意依赖库 |
| Android | Android | 需要配置SDK和NDK |
| iOS | iOS | 需要Mac电脑 |
| Web | HTML5 | 性能受限,音效格式有限制 |
导出步骤(Windows)
- 打开 项目 → 导出
- 点击 添加 → 选择 Windows Desktop
- 配置导出设置:
| 设置项 | 值 |
|---|---|
| 应用程序名称 | Mahjong2.5D |
| 图标 | 设置游戏图标(.ico文件) |
| 启动画面 | 设置启动画面图片 |
| 分辨率 | 保持默认 |
- 点击 导出项目
- 选择保存位置
导出前检查
导出前确保:
- 所有资源路径使用
res://而不是绝对路径 - 没有使用编辑器专属功能(如 EditorPlugin)
- 音频文件格式兼容目标平台
- 所有
@export变量都有合理的默认值
发布检查清单
功能检查
| 检查项 | 状态 | 说明 |
|---|---|---|
| 游戏能正常启动 | [ ] | 无崩溃、无报错 |
| 摸牌打牌正常 | [ ] | 操作流畅无卡顿 |
| 胡牌判定正确 | [ ] | 所有牌型都能正确识别 |
| 碰杠吃正常 | [ ] | 边界情况都处理好 |
| AI能正常运行 | [ ] | 不会卡死或出非法操作 |
| 网络对战正常 | [ ] | 断线重连、同步正确 |
| UI显示正确 | [ ] | 所有分辨率下正常 |
| 音效正常播放 | [ ] | 无杂音、音量合适 |
性能检查
| 检查项 | 目标 | 说明 |
|---|---|---|
| 帧率 | >= 60 FPS | 流畅运行 |
| 内存占用 | < 500MB | 不内存泄漏 |
| 加载时间 | < 3秒 | 首次启动 |
| 包体大小 | < 100MB | 安装包体积 |
用户体验检查
| 检查项 | 说明 |
|---|---|
| 新手引导 | 第一次玩的人能看懂操作 |
| 错误提示 | 网络断开、操作失败有明确提示 |
| 操作反馈 | 每个操作都有视觉/音效反馈 |
| 设置选项 | 音量、难度、语言等可调 |
小结
本章我们完成了游戏的最终打磨:
- UI美化——计分板、剩余牌数、操作按钮等界面元素
- 音效系统——摸牌、出牌、碰杠胡等音效管理
- 3D光影——牌面材质优化、桌面反光、环境反射
- 动画优化——对象池、动画队列、性能提升
- 多平台导出——Windows、macOS、Linux、Android、iOS
- 发布检查清单——功能、性能、用户体验全面检查
恭喜!
到这里,我们的2.5D麻将游戏开发教程就全部完成了。从项目搭建到规则引擎、从AI对手到网络对战、从音效系统到多平台发布——你已经掌握了用Godot 4开发棋牌游戏的全部核心技能。
接下来,你可以:
- 尝试添加更多牌型(如国标麻将的全部番种)
- 开发字牌(跑胡子)的完整版本
- 优化AI策略,加入机器学习
- 添加排行榜、好友系统等社交功能
祝你开发顺利!
