1. 核心玩法设计
2026/4/13大约 10 分钟
1. 麻将和字牌——核心玩法设计
本章你将掌握
| 技能 | 难度 | Godot节点 | 说明 |
|---|---|---|---|
| 3D牌面渲染 | ⭐⭐ | MeshInstance3D + Texture | 立体麻将牌+贴图 |
| 牌堆管理系统 | ⭐⭐⭐ | 数组+洗牌算法 | 136/80张牌的初始化和发牌 |
| 2.5D视角切换 | ⭐⭐ | Camera3D(正交) | 4个玩家位置的视角旋转 |
| 手牌排序算法 | ⭐⭐⭐ | 自定义排序逻辑 | 按花色和数值自动整理 |
| 胡牌判定引擎 | ⭐⭐⭐⭐⭐ | 递归算法 | 检测是否满足胡牌条件 |
| 碰/杠/吃判定 | ⭐⭐⭐ | 规则引擎 | 实时检测可执行操作 |
| 翻牌动画 | ⭐⭐⭐ | AnimationPlayer + Tween | 摸牌、出牌、翻牌动画 |
| AI对手 | ⭐⭐⭐⭐ | 决策树算法 | 电脑玩家智能出牌 |
| 网络对战 | ⭐⭐⭐⭐ | ENetMultiplayerPeer | 4人联机对战 |
节点层级预览
MahjongGame (Node3D)
├── GameCamera (Camera3D, 正交投影)
├── Table (Node3D)
│ ├── TableMesh (MeshInstance3D)
│ └── DiceArea (Node3D)
├── TileDeck (Node)
│ └── TilePool (脚本管理所有牌)
├── Players (Node3D)
│ ├── Player1 (Node3D, 南)
│ │ ├── HandTiles (Node3D)
│ │ ├── DiscardedTiles (Node3D)
│ │ └── MeldedTiles (Node3D)
│ ├── Player2 (Node3D, 西)
│ ├── Player3 (Node3D, 北)
│ └── Player4 (Node3D, 东)
├── GameLogic (Node)
│ ├── RuleEngine (脚本)
│ ├── AIPlayer (脚本)
│ └── NetworkManager (脚本)
├── Effects (Node3D)
│ ├── DrawTileEffect (AnimationPlayer)
│ ├── DiscardEffect (AnimationPlayer)
│ └── WinParticles (GPUParticles3D)
└── UI (CanvasLayer)
├── PlayerInfo (4个玩家信息面板)
├── ActionButtons (碰/杠/吃/胡按钮)
└── ScoreBoard (计分板)游戏核心节点
点击节点可跳转到对应文档查看详细说明。
系统架构图
┌─────────────────────────────────────────────┐
│ 麻将/字牌核心系统 │
├─────────────────────────────────────────────┤
│ │
│ 洗牌发牌 → 玩家摸牌 → 手牌整理 │
│ ↓ │
│ 出牌选择 → 其他玩家响应 → 碰/杠/吃/胡判定 │
│ ↓ │
│ 牌堆更新 → 回合切换 → AI决策 │
│ ↓ │
│ 胡牌判定 → 番型计算 → 结算 │
│ │
└─────────────────────────────────────────────┘1.1 麻将是什么?——用扑克牌来理解
如果你会打扑克牌,那理解麻将就非常简单了。
扑克牌有 54 张牌,4 种花色(黑桃、红心、梅花、方块),每种 13 张。你拿到手后,要凑成特定的组合(比如顺子、同花)来赢。
麻将也是一样的道理,只不过:
- 牌更多(通常 136 张或 144 张)
- 花色不一样(万子、条子、筒子,还有风牌和箭牌)
- 组合规则更丰富
一句话总结
麻将就像扑克牌的"东方升级版"——牌更多、规则更丰富、策略更深。
麻将的基本构成
| 类型 | 包含内容 | 数量 | 扑克类比 |
|---|---|---|---|
| 万子 | 一万到九万 | 36 张(4套) | 相当于扑克的数字牌 |
| 条子 | 一条到九条 | 36 张(4套) | 相当于另一种花色的数字牌 |
| 筒子 | 一筒到九筒 | 36 张(4套) | 相当于第三种花色的数字牌 |
| 风牌 | 东南西北 | 16 张(4套) | 相当于特殊的 J/Q/K |
| 箭牌 | 中发白 | 12 张(4套) | 相当于大小王 |
1.2 字牌(跑胡子)是什么?
字牌,在湖南、湖北、四川一带也叫跑胡子、字牌、二七十,是另一种非常受欢迎的牌类游戏。
如果说麻将是"东方扑克",那字牌就是"中国拉密"(Rummy)——规则比麻将简单,但策略性一点不差。
字牌的基本构成
| 类型 | 说明 | 举例 |
|---|---|---|
| 大字 | 红色的数字牌 | 大壹、大贰、大叁...大拾 |
| 小字 | 黑色的数字牌 | 小一、小二、小三...小十 |
| 特殊组合 | 二七十(大小可混) | 大贰+大七+大拾 |
趣味小知识
跑胡子在湖南几乎家家会玩,是逢年过节最受欢迎的娱乐活动之一。甚至有人说"不会跑胡子,别说你是湖南人"。
1.3 麻将 vs 字牌——两种游戏大对比
| 对比维度 | 麻将 | 字牌(跑胡子) |
|---|---|---|
| 牌的数量 | 136-144 张 | 80 张 |
| 玩家人数 | 4 人 | 3 人(也有 2 人玩法) |
| 每人起始手牌 | 13 张 | 通常 21 张(庄家) |
| 核心操作 | 碰、杠、吃、胡 | 吃、碰、跑、提、胡 |
| 难度 | 中等偏高 | 中等 |
| 一局时间 | 15-30 分钟 | 10-20 分钟 |
| 流行区域 | 全国 | 湖南、湖北、四川、贵州 |
| 是否需要"缺门" | 四川麻将需要 | 不需要 |
| 策略深度 | 非常深 | 较深 |
1.4 为什么用 2.5D 做棋牌游戏?
你可能会问:做棋牌游戏,干嘛要用 3D 效果?直接做 2D 不就好了吗?
好问题!让我们来对比一下:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯 2D | 开发简单、性能好 | 缺乏真实感、像网页小游戏 |
| 2.5D | 有立体感、牌面可翻转、视觉高级 | 开发难度中等 |
| 全 3D | 最真实、可自由视角 | 开发成本高、操作复杂 |
2.5D 是最佳平衡点——既能展示牌的立体效果(翻牌、码牌),又不会像全 3D 那样让人头晕。
什么是 2.5D?
2.5D 就是"用 3D 技术渲染,但用 2D 的视角来玩"。想象你坐在桌子前面打牌——你能看到牌的立体感,但不能站起来绕桌子走一圈。这就是 2.5D。
1.5 核心玩法循环
无论做麻将还是字牌,核心玩法循环都是一样的:
┌─────────────────────────────────────────────┐
│ │
│ 洗牌 → 发牌 → 玩家操作 → 判定 → 结算 │
│ ↑ │ │
│ └────────────────────┘ │
│ (下一轮) │
│ │
└─────────────────────────────────────────────┘详细循环步骤
| 步骤 | 说明 | 涉及系统 |
|---|---|---|
| 1. 洗牌 | 随机打乱所有牌 | 随机数系统 |
| 2. 发牌 | 每人发固定数量的牌 | 发牌算法 |
| 3. 摸牌 | 从牌堆顶摸一张牌 | 牌堆管理 |
| 4. 出牌 | 选择一张手牌打出 | 交互系统 |
| 5. 响应 | 其他玩家碰/杠/吃/胡 | 规则引擎 |
| 6. 判定 | 是否有人胡牌 | 胜负判定 |
| 7. 结算 | 计算分数 | 计分系统 |
1.6 最小可玩版(MVP)
在开始编码之前,我们先定义最小可玩版——也就是让游戏"能玩起来"的最少功能:
麻将 MVP
功能清单:
[x] 136 张牌的牌库
[x] 4 人发牌(每人 13 张)
[x] 摸牌和出牌
[x] 碰牌判定
[x] 胡牌判定
[x] 基本的 3D 牌面显示
暂不实现:
[ ] 杠牌
[ ] 吃牌
[ ] 番型计算
[ ] AI 对手
[ ] 网络对战
[ ] 音效和动画字牌 MVP
功能清单:
[x] 80 张牌的牌库
[x] 3 人发牌
[x] 摸牌和出牌
[x] 吃牌判定
[x] 碰牌判定
[x] 胡牌判定
[x] 基本的 3D 牌面显示
暂不实现:
[ ] 跑牌和提牌
[ ] 计分系统
[ ] AI 对手
[ ] 网络对战
[ ] 音效和动画为什么先做 MVP?
很多新手一上来就想做"完整的、完美的游戏",结果做了 3 个月还没做出能玩的东西,热情就耗尽了。MVP 的思路是:先让它能跑起来,再逐步加功能。就像先学会走,再学跑。
1.7 核心数据结构设计
在写代码之前,我们先想清楚"一张牌"在程序里长什么样。
牌的表示方式
C
/// <summary>
/// 牌的类型
/// </summary>
public enum TileType
{
// 麻将牌
Wan = 1, // 万子
Tiao = 2, // 条子
Tong = 3, // 筒子
Feng = 4, // 风牌(东南西北)
Jian = 5, // 箭牌(中发白)
// 字牌
DaZi = 10, // 大字(红色)
XiaoZi = 11 // 小字(黑色)
}
/// <summary>
/// 单张牌
/// </summary>
public class Tile
{
public TileType Type { get; set; } // 牌的类型
public int Value { get; set; } // 牌的数值(1-9 或 1-10)
public int Id { get; set; } // 唯一编号(用于区分同名的牌)
/// <summary>
/// 获取牌的显示名称
/// </summary>
public string GetDisplayName()
{
return Type switch
{
TileType.Wan => $"{Value}万",
TileType.Tiao => $"{Value}条",
TileType.Tong => $"{Value}筒",
TileType.Feng => new[] { "东", "南", "西", "北" }[Value - 1],
TileType.Jian => new[] { "中", "发", "白" }[Value - 1],
TileType.DaZi => $"大{"壹贰叁肆伍陆柒捌玖拾"[Value - 1]}",
TileType.XiaoZi => $"小{"一二三四五六七八九十"[Value - 1]}",
_ => "未知"
};
}
}GDScript
## 牌的类型
enum TileType {
WAN = 1, ## 万子
TIAO = 2, ## 条子
TONG = 3, ## 筒子
FENG = 4, ## 风牌
JIAN = 5, ## 箭牌
DA_ZI = 10, ## 大字
XIAO_ZI = 11 ## 小字
}
## 单张牌
class_name Tile
var type: int ## 牌的类型(TileType 枚举)
var value: int ## 牌的数值
var id: int ## 唯一编号
## 获取牌的显示名称
func get_display_name() -> String:
match type:
TileType.WAN:
return str(value) + "万"
TileType.TIAO:
return str(value) + "条"
TileType.TONG:
return str(value) + "筒"
TileType.FENG:
var names = ["东", "南", "西", "北"]
return names[value - 1]
TileType.JIAN:
var names = ["中", "发", "白"]
return names[value - 1]
TileType.DA_ZI:
var chars = "壹贰叁肆伍陆柒捌玖拾"
return "大" + chars[value - 1]
TileType.XIAO_ZI:
var chars = "一二三四五六七八九十"
return "小" + chars[value - 1]
_:
return "未知"牌库的初始化
C
/// <summary>
/// 牌库——管理所有牌的集合
/// </summary>
public class TileDeck
{
private List<Tile> _tiles = new();
private int _nextId = 0;
/// <summary>
/// 初始化麻将牌库(136 张)
/// </summary>
public void InitMahjongDeck()
{
_tiles.Clear();
_nextId = 0;
// 万子、条子、筒子各 36 张(每种数字 4 张)
for (int type = 1; type <= 3; type++)
{
for (int value = 1; value <= 9; value++)
{
for (int copy = 0; copy < 4; copy++)
{
_tiles.Add(new Tile
{
Type = (TileType)type,
Value = value,
Id = _nextId++
});
}
}
}
// 风牌 16 张(东南西北各 4 张)
for (int value = 1; value <= 4; value++)
{
for (int copy = 0; copy < 4; copy++)
{
_tiles.Add(new Tile
{
Type = TileType.Feng,
Value = value,
Id = _nextId++
});
}
}
// 箭牌 12 张(中发白各 4 张)
for (int value = 1; value <= 3; value++)
{
for (int copy = 0; copy < 4; copy++)
{
_tiles.Add(new Tile
{
Type = TileType.Jian,
Value = value,
Id = _nextId++
});
}
}
}
/// <summary>
/// 洗牌(Fisher-Yates 算法)
/// </summary>
public void Shuffle()
{
var random = new Random();
for (int i = _tiles.Count - 1; i > 0; i--)
{
int j = random.Next(i + 1);
(_tiles[i], _tiles[j]) = (_tiles[j], _tiles[i]);
}
}
/// <summary>
/// 摸一张牌
/// </summary>
public Tile? Draw()
{
if (_tiles.Count == 0) return null;
var tile = _tiles[0];
_tiles.RemoveAt(0);
return tile;
}
}GDScript
## 牌库——管理所有牌的集合
class_name TileDeck
var _tiles: Array[Tile] = []
var _next_id: int = 0
## 初始化麻将牌库(136 张)
func init_mahjong_deck() -> void:
_tiles.clear()
_next_id = 0
# 万子、条子、筒子各 36 张
for type_val in range(1, 4):
for value in range(1, 10):
for copy in range(4):
var tile = Tile.new()
tile.type = type_val
tile.value = value
tile.id = _next_id
_next_id += 1
_tiles.append(tile)
# 风牌 16 张
for value in range(1, 5):
for copy in range(4):
var tile = Tile.new()
tile.type = TileType.FENG
tile.value = value
tile.id = _next_id
_next_id += 1
_tiles.append(tile)
# 箭牌 12 张
for value in range(1, 4):
for copy in range(4):
var tile = Tile.new()
tile.type = TileType.JIAN
tile.value = value
tile.id = _next_id
_next_id += 1
_tiles.append(tile)
## 洗牌(Fisher-Yates 算法)
func shuffle() -> void:
for i in range(_tiles.size() - 1, 0, -1):
var j = randi() % (i + 1)
var temp = _tiles[i]
_tiles[i] = _tiles[j]
_tiles[j] = temp
## 摸一张牌
func draw() -> Tile:
if _tiles.is_empty():
return null
var tile = _tiles[0]
_tiles.pop_front()
return tile1.8 项目整体架构预览
在后续章节中,我们将按以下顺序逐步构建游戏:
| 章节 | 内容 | 产出 |
|---|---|---|
| 第 2 章 | 项目搭建 | 可运行的空场景 |
| 第 3 章 | 牌面渲染 | 能看到 3D 牌面 |
| 第 4 章 | 麻将规则 | 能打一局麻将 |
| 第 5 章 | 字牌规则 | 能打一局跑胡子 |
| 第 6 章 | AI 对手 | 单人可玩 |
| 第 7 章 | 网络对战 | 多人联机 |
| 第 8 章 | 打磨发布 | 可上线的产品 |
建议的学习方式
不要跳着看!每章都是在前一章的基础上构建的。如果你跳过了某章,后面的代码可能跑不起来。
