2. 项目搭建
2026/4/14大约 7 分钟
长沙麻将——项目搭建
创建项目
打开 Godot 4.x 编辑器,新建项目:
| 设置项 | 值 | 说明 |
|---|---|---|
| 项目名称 | ChangshaMahjong | 长沙麻将 |
| 渲染器 | Compatibility | 2D游戏推荐 |
| 窗口大小 | 1280 x 720 | 横屏,适合电脑和横屏手机 |
目录结构
ChangshaMahjong/
├── scenes/
│ ├── Main.tscn # 主场景
│ ├── Table.tscn # 牌桌场景
│ └── Tile.tscn # 单张牌场景
├── scripts/
│ ├── GameManager.cs # 游戏管理器
│ ├── TileManager.cs # 牌面管理
│ ├── Player.cs # 玩家逻辑
│ ├── AIPlayer.cs # AI玩家
│ ├── MeldSystem.cs # 吃碰杠系统
│ ├── WinChecker.cs # 胡牌判断
│ └── ScoreSystem.cs # 计分系统
├── resources/
│ └── Theme.tres # 主题资源
└── assets/
└── tiles/ # 牌面图片
├── wan/ # 万子图片 (1-9)
├── tong/ # 筒子图片 (1-9)
└── tiao/ # 条子图片 (1-9)牌的数据结构
麻将的核心数据就是"一张牌"。每张牌由两个属性唯一确定:花色和数字。
就像一副扑克牌,每张牌由"花色(黑桃/红心...)"和"点数(A-K)"确定。麻将也一样,花色是万/筒/条,数字是1-9。
C
/// <summary>
/// 麻将花色
/// </summary>
public enum TileSuit
{
/// <summary>万子</summary>
Wan = 0,
/// <summary>筒子</summary>
Tong = 1,
/// <summary>条子</summary>
Tiao = 2
}
/// <summary>
/// 麻将牌
/// 每张牌由花色(Suit)和数字(Number)唯一确定
/// </summary>
public struct Tile
{
/// <summary>花色(万/筒/条)</summary>
public TileSuit Suit { get; }
/// <summary>数字(1-9)</summary>
public int Number { get; }
/// <summary>
/// 构造一张牌
/// </summary>
/// <param name="suit">花色</param>
/// <param name="number">数字(1-9)</param>
public Tile(TileSuit suit, int number)
{
Suit = suit;
Number = Mathf.Clamp(number, 1, 9);
}
/// <summary>
/// 牌的唯一ID(0-107)
/// 用于快速比较和索引
/// </summary>
public int Id => (int)Suit * 9 + (Number - 1);
/// <summary>牌的显示名称</summary>
public readonly string DisplayName => $"{NumberName}{SuitName}";
/// <summary>数字的中文名</summary>
private readonly string NumberName => Number switch
{
1 => "一", 2 => "二", 3 => "三", 4 => "四", 5 => "五",
6 => "六", 7 => "七", 8 => "八", 9 => "九", _ => "?"
};
/// <summary>花色的中文名</summary>
private readonly string SuitName => Suit switch
{
TileSuit.Wan => "万",
TileSuit.Tong => "筒",
TileSuit.Tiao => "条",
_ => "?"
};
/// <summary>判断两张牌是否相同</summary>
public static bool operator ==(Tile a, Tile b) => a.Id == b.Id;
public static bool operator !=(Tile a, Tile b) => a.Id != b.Id;
public override bool Equals(object obj) => obj is Tile t && Id == t.Id;
public override int GetHashCode() => Id;
public override string ToString() => $"[{DisplayName}]";
}GDScript
## 麻将花色
enum TileSuit {
WAN = 0, ## 万子
TONG = 1, ## 筒子
TIAO = 2 ## 条子
}
## 麻将牌
## 每张牌由花色(Suit)和数字(Number)唯一确定
class Tile:
## 花色(万/筒/条)
var suit: int = TileSuit.WAN
## 数字(1-9)
var number: int = 1
## 构造一张牌
func _init(new_suit: int = TileSuit.WAN, new_number: int = 1) -> void:
suit = new_suit
number = clampi(new_number, 1, 9)
## 牌的唯一ID(0-107)
var id: int:
get:
return suit * 9 + (number - 1)
## 牌的显示名称
var display_name: String:
get:
return "%s%s" % [_number_name(), _suit_name()]
## 数字的中文名
func _number_name() -> String:
match number:
1: return "一"
2: return "二"
3: return "三"
4: return "四"
5: return "五"
6: return "六"
7: return "七"
8: return "八"
9: return "九"
return "?"
## 花色的中文名
func _suit_name() -> String:
match suit:
TileSuit.WAN: return "万"
TileSuit.TONG: return "筒"
TileSuit.TIAO: return "条"
return "?"
func _to_string() -> String:
return "[%s]" % display_nameGameManager 游戏管理器
GameManager 是麻将游戏的"总指挥",负责管理游戏流程和各个子系统。
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 长沙麻将 - 游戏管理器
/// </summary>
public partial class GameManager : Node
{
// ===== 常量 =====
/// <summary>玩家数量</summary>
public const int PLAYER_COUNT = 4;
/// <summary>每种牌的数量</summary>
public const int TILE_COPIES = 4;
/// <summary>花色种类</summary>
public const int SUIT_COUNT = 3;
/// <summary>每种花色的数字范围</summary>
public const int NUMBER_RANGE = 9;
/// <summary>总牌数</summary>
public const int TOTAL_TILES = SUIT_COUNT * NUMBER_RANGE * TILE_COPIES; // 108
/// <summary>每个玩家初始手牌数</summary>
public const int INITIAL_HAND_SIZE = 13;
/// <summary>庄家初始手牌数(多一张)</summary>
public const int DEALER_HAND_SIZE = 14;
// ===== 游戏数据 =====
/// <summary>当前游戏状态</summary>
public MahjongGameState CurrentState { get; private set; }
/// <summary>当前轮到的玩家(0-3)</summary>
public int CurrentPlayer { get; private set; } = 0;
/// <summary>庄家编号</summary>
public int Dealer { get; private set; } = 0;
/// <summary>四个玩家的手牌</summary>
private List<Tile>[] _hands = new List<Tile>[PLAYER_COUNT];
/// <summary>四个玩家的副露(吃碰杠的牌组)</summary>
private List<Meld>[] _melds = new List<Meld>[PLAYER_COUNT];
/// <summary>牌墙(还没被摸走的牌)</summary>
private List<Tile> _wall = new List<Tile>();
/// <summary>最后打出的牌</summary>
public Tile LastDiscard { get; private set; }
/// <summary>最后出牌的玩家</summary>
public int LastDiscardPlayer { get; private set; } = -1;
// ===== 信号 =====
/// <summary>游戏状态变化</summary>
[Signal] public delegate void StateChangedEventHandler(MahjongGameState newState);
/// <summary>有玩家摸牌</summary>
[Signal] public delegate void TileDrawnEventHandler(int player, Tile tile);
/// <summary>有玩家出牌</summary>
[Signal] public delegate void TileDiscardedEventHandler(int player, Tile tile);
/// <summary>有玩家胡牌</summary>
[Signal] public delegate void PlayerWinEventHandler(int winner, bool isSelfDraw);
/// <summary>流局</summary>
[Signal] public delegate void GameDrawEventHandler();
public override void _Ready()
{
// 初始化玩家数据
for (int i = 0; i < PLAYER_COUNT; i++)
{
_hands[i] = new List<Tile>();
_melds[i] = new List<Meld>();
}
GD.Print($"[GameManager] 长沙麻将初始化完成");
GD.Print($"[GameManager] 总牌数: {TOTAL_TILES}");
}
/// <summary>
/// 开始新一局
/// </summary>
public void StartNewRound()
{
// 重置所有数据
for (int i = 0; i < PLAYER_COUNT; i++)
{
_hands[i].Clear();
_melds[i].Clear();
}
_wall.Clear();
LastDiscard = default;
LastDiscardPlayer = -1;
CurrentPlayer = Dealer;
GD.Print("[GameManager] 新一局开始");
// 洗牌
ShuffleWall();
// 发牌
DealTiles();
// 庄家先出牌
SetState(MahjongGameState.WaitingForDiscard);
}
/// <summary>
/// 设置游戏状态
/// </summary>
private void SetState(MahjongGameState newState)
{
CurrentState = newState;
GD.Print($"[GameManager] 状态: {newState}");
EmitSignal(SignalName.StateChanged, newState);
}
/// <summary>
/// 获取指定玩家的手牌
/// </summary>
public List<Tile> GetHand(int player) => _hands[player];
/// <summary>
/// 获取指定玩家的副露
/// </summary>
public List<Meld> GetMelds(int player) => _melds[player];
/// <summary>
/// 获取牌墙剩余牌数
/// </summary>
public int GetWallCount() => _wall.Count;
}GDScript
extends Node
## ===== 常量 =====
## 玩家数量
const PLAYER_COUNT: int = 4
## 每种牌的数量
const TILE_COPIES: int = 4
## 花色种类
const SUIT_COUNT: int = 3
## 每种花色的数字范围
const NUMBER_RANGE: int = 9
## 总牌数
const TOTAL_TILES: int = SUIT_COUNT * NUMBER_RANGE * TILE_COPIES # 108
## 每个玩家初始手牌数
const INITIAL_HAND_SIZE: int = 13
## 庄家初始手牌数(多一张)
const DEALER_HAND_SIZE: int = 14
## ===== 游戏数据 =====
## 当前游戏状态
var current_state: int = MahjongGameState.WAITING_FOR_DEAL
## 当前轮到的玩家(0-3)
var current_player: int = 0
## 庄家编号
var dealer: int = 0
## 四个玩家的手牌
var hands: Array = [{}, {}, {}, {}] # Array[Array[Tile]]
## 四个玩家的副露(吃碰杠的牌组)
var melds: Array = [{}, {}, {}, {}] # Array[Array[Meld]]
## 牌墙(还没被摸走的牌)
var wall: Array = [] # Array[Tile]
## 最后打出的牌
var last_discard: Tile # 可能为 null
## 最后出牌的玩家
var last_discard_player: int = -1
## ===== 信号 =====
## 游戏状态变化
signal state_changed(new_state: int)
## 有玩家摸牌
signal tile_drawn(player: int, tile)
## 有玩家出牌
signal tile_discarded(player: int, tile)
## 有玩家胡牌
signal player_win(winner: int, is_self_draw: bool)
## 流局
signal game_draw()
func _ready() -> void:
# 初始化玩家数据
for i in range(PLAYER_COUNT):
hands[i] = []
melds[i] = []
print("[GameManager] 长沙麻将初始化完成")
print("[GameManager] 总牌数: %d" % TOTAL_TILES)
## 开始新一局
func start_new_round() -> void:
# 重置所有数据
for i in range(PLAYER_COUNT):
hands[i].clear()
melds[i].clear()
wall.clear()
last_discard = null
last_discard_player = -1
current_player = dealer
print("[GameManager] 新一局开始")
# 洗牌
_shuffle_wall()
# 发牌
_deal_tiles()
# 庄家先出牌
_set_state(MahjongGameState.WAITING_FOR_DISCARD)
## 设置游戏状态
func _set_state(new_state: int) -> void:
current_state = new_state
print("[GameManager] 状态: %s" % new_state)
state_changed.emit(new_state)
## 获取指定玩家的手牌
func get_hand(player: int) -> Array:
return hands[player]
## 获取指定玩家的副露
func get_melds(player: int) -> Array:
return melds[player]
## 获取牌墙剩余牌数
func get_wall_count() -> int:
return wall.size()副露数据结构
"副露"是麻将术语,指吃、碰、杠后展示在桌面上的牌组。
C
/// <summary>
/// 副露类型
/// </summary>
public enum MeldType
{
/// <summary>吃(顺子)</summary>
Chi,
/// <summary>碰(刻子)</summary>
Peng,
/// <summary>明杠</summary>
MingGang,
/// <summary>暗杠</summary>
AnGang,
/// <summary>补杠(加杠)</summary>
BuGang
}
/// <summary>
/// 副露(吃碰杠后的牌组)
/// </summary>
public class Meld
{
/// <summary>副露类型</summary>
public MeldType Type { get; }
/// <summary>副露中的牌</summary>
public List<Tile> Tiles { get; }
/// <summary>来源玩家(出牌被碰/杠的玩家)</summary>
public int FromPlayer { get; }
public Meld(MeldType type, List<Tile> tiles, int fromPlayer = -1)
{
Type = type;
Tiles = new List<Tile>(tiles);
FromPlayer = fromPlayer;
}
public override string ToString()
{
string typeName = Type switch
{
MeldType.Chi => "吃",
MeldType.Peng => "碰",
MeldType.MingGang => "明杠",
MeldType.AnGang => "暗杠",
MeldType.BuGang => "补杠",
_ => "?"
};
return $"{typeName}: [{string.Join(", ", Tiles)}]";
}
}GDScript
## 副露类型
enum MeldType {
CHI, ## 吃(顺子)
PENG, ## 碰(刻子)
MING_GANG, ## 明杠
AN_GANG, ## 暗杠
BU_GANG ## 补杠(加杠)
}
## 副露(吃碰杠后的牌组)
class Meld:
## 副露类型
var type: int = MeldType.CHI
## 副露中的牌
var tiles: Array = [] # Array[Tile]
## 来源玩家(出牌被碰/杠的玩家)
var from_player: int = -1
func _init(new_type: int = MeldType.CHI,
new_tiles: Array = [],
from: int = -1) -> void:
type = new_type
tiles = new_tiles.duplicate()
from_player = from
func _to_string() -> String:
var type_name: String
match type:
MeldType.CHI: type_name = "吃"
MeldType.PENG: type_name = "碰"
MeldType.MING_GANG: type_name = "明杠"
MeldType.AN_GANG: type_name = "暗杠"
MeldType.BU_GANG: type_name = "补杠"
_: type_name = "?"
return "%s: %s" % [type_name, str(tiles)]场景节点树
主场景 Main.tscn
Main (Control)
├── Table (Control) # 牌桌
│ ├── WallDisplay # 牌墙显示
│ ├── PlayerHand[0] (Control) # 玩家手牌(下方)
│ ├── PlayerHand[1] (Control) # AI手牌(右方)
│ ├── PlayerHand[2] (Control) # AI手牌(上方)
│ ├── PlayerHand[3] (Control) # AI手牌(左方)
│ ├── DiscardArea[0-3] # 出牌区域
│ └── MeldArea[0-3] # 副露区域
├── HUD (Control) # 信息栏
│ ├── WindLabel # 场风显示
│ ├── RemainLabel # 牌墙剩余数
│ └── TurnIndicator # 当前回合指示
├── ActionPanel (Control) # 操作按钮面板
│ ├── ChiButton # 吃按钮
│ ├── PengButton # 碰按钮
│ ├── GangButton # 杠按钮
│ └── HuButton # 胡按钮
├── ResultPopup (Control) # 结算弹窗
└── GameManager (Node) # 游戏管理器本章小结
| 完成项 | 说明 |
|---|---|
| 项目创建 | ChangshaMahjong 项目 |
| 目录结构 | scenes/、scripts/、assets/tiles/ |
| Tile 结构 | 花色 + 数字,108张牌 |
| Meld 结构 | 吃碰杠的牌组数据 |
| GameManager | 游戏状态、玩家手牌、牌墙 |
| 场景骨架 | 牌桌、手牌区、出牌区、操作按钮 |
下一章,我们将实现牌面系统——生成108张牌、渲染到屏幕上,并为每种花色的每个数字准备对应的视觉素材。
