3. 牌面系统
2026/4/14大约 9 分钟
长沙麻将——牌面系统
什么是牌面系统?
牌面系统负责两件事:
- 生成:创建108张麻将牌的数据
- 渲染:把每张牌画到屏幕上,让玩家看到"一万"、"五筒"、"九条"等
就像一副扑克牌——牌面系统就是"印制扑克牌的工厂",它按照花色和数字的组合,一张张地把牌"印"出来。
108张牌的构成
长沙麻将的108张牌,由三种花色各36张组成:
| 花色 | 数字 | 每种数量 | 小计 |
|---|---|---|---|
| 万子 | 一万~九万 | 各4张 | 36张 |
| 筒子 | 一筒~九筒 | 各4张 | 36张 |
| 条子 | 一条~九条 | 各4张 | 36张 |
| 合计 | 108张 |
每种数字有4张,是因为麻将4个人打,每人理论上都可能需要用到同一张牌。
牌面管理器
TileManager 负责生成所有牌的实例,并提供牌面素材的加载功能。
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 牌面管理器
/// 负责生成麻将牌、管理牌面素材
/// </summary>
public partial class TileManager : Node
{
// ===== 素材路径配置 =====
/// <summary>牌面图片的基础路径</summary>
private const string TILE_ASSET_PATH = "res://assets/tiles/";
/// <summary>花色目录名</summary>
private static readonly string[] SuitFolders = { "wan", "tong", "tiao" };
/// <summary>牌面纹理缓存</summary>
private readonly Dictionary<int, Texture2D> _tileTextures = new Dictionary<int, Texture2D>();
/// <summary>牌背面纹理</summary>
private Texture2D _tileBackTexture;
/// <summary>牌的场景</summary>
[Export] public PackedScene TileScene { get; set; }
public override void _Ready()
{
LoadTileTextures();
GD.Print("[TileManager] 牌面管理器初始化完成");
}
/// <summary>
/// 生成完整的108张牌
/// 每种花色的1-9各生成4张
/// </summary>
/// <returns>所有牌的列表</returns>
public List<Tile> GenerateFullDeck()
{
var deck = new List<Tile>();
for (int suit = 0; suit < GameManager.SUIT_COUNT; suit++)
{
for (int number = 1; number <= GameManager.NUMBER_RANGE; number++)
{
for (int copy = 0; copy < GameManager.TILE_COPIES; copy++)
{
deck.Add(new Tile((TileSuit)suit, number));
}
}
}
GD.Print($"[TileManager] 生成了 {deck.Count} 张牌");
return deck;
}
/// <summary>
/// 加载所有牌面纹理
/// </summary>
private void LoadTileTextures()
{
for (int suit = 0; suit < GameManager.SUIT_COUNT; suit++)
{
for (int number = 1; number <= GameManager.NUMBER_RANGE; number++)
{
string path = $"{TILE_ASSET_PATH}{SuitFolders[suit]}/{number}.png";
var texture = GD.Load<Texture2D>(path);
if (texture != null)
{
int id = suit * 9 + (number - 1);
_tileTextures[id] = texture;
}
else
{
GD.PrintWarn($"[TileManager] 找不到牌面图片: {path}");
}
}
}
// 加载牌背
_tileBackTexture = GD.Load<Texture2D>($"{TILE_ASSET_PATH}back.png");
GD.Print($"[TileManager] 加载了 {_tileTextures.Count} 张牌面纹理");
}
/// <summary>
/// 获取指定牌的纹理
/// </summary>
public Texture2D GetTileTexture(Tile tile)
{
if (_tileTextures.TryGetValue(tile.Id, out var texture))
{
return texture;
}
return null;
}
/// <summary>
/// 获取牌背纹理
/// </summary>
public Texture2D GetBackTexture() => _tileBackTexture;
}GDScript
extends Node
## ===== 素材路径配置 =====
## 牌面图片的基础路径
const TILE_ASSET_PATH: String = "res://assets/tiles/"
## 花色目录名
var SUIT_FOLDERS: Array = ["wan", "tong", "tiao"]
## 牌面纹理缓存
var _tile_textures: Dictionary = {}
## 牌背面纹理
var _tile_back_texture: Texture2D
## 牌的场景
@export var tile_scene: PackedScene
func _ready() -> void:
_load_tile_textures()
print("[TileManager] 牌面管理器初始化完成")
## 生成完整的108张牌
## 每种花色的1-9各生成4张
func generate_full_deck() -> Array:
var deck: Array = []
for suit in range(GameManager.SUIT_COUNT):
for number in range(1, GameManager.NUMBER_RANGE + 1):
for copy in range(GameManager.TILE_COPIES):
deck.append(Tile.new(suit, number))
print("[TileManager] 生成了 %d 张牌" % deck.size())
return deck
## 加载所有牌面纹理
func _load_tile_textures() -> void:
for suit in range(GameManager.SUIT_COUNT):
for number in range(1, GameManager.NUMBER_RANGE + 1):
var path: String = "%s%s/%d.png" % [TILE_ASSET_PATH, SUIT_FOLDERS[suit], number]
var texture: Texture2D = load(path) as Texture2D
if texture != null:
var id: int = suit * 9 + (number - 1)
_tile_textures[id] = texture
else:
push_warning("[TileManager] 找不到牌面图片: %s" % path)
# 加载牌背
_tile_back_texture = load("%sback.png" % TILE_ASSET_PATH) as Texture2D
print("[TileManager] 加载了 %d 张牌面纹理" % _tile_textures.size())
## 获取指定牌的纹理
func get_tile_texture(tile: Tile) -> Texture2D:
if _tile_textures.has(tile.id):
return _tile_textures[tile.id]
return null
## 获取牌背纹理
func get_back_texture() -> Texture2D:
return _tile_back_texture牌面渲染(无图片的替代方案)
如果没有准备牌面图片,我们可以用代码生成简单的文字牌面——在每个牌上显示花色和数字的文字。
C
using Godot;
/// <summary>
/// 单张麻将牌的视觉节点
/// </summary>
public partial class TileNode : PanelContainer
{
/// <summary>牌面标签(显示文字)</summary>
private Label _tileLabel;
/// <summary>牌面背景</summary>
private Panel _background;
/// <summary>是否显示牌面(false则显示牌背)</summary>
private bool _faceUp = true;
/// <summary>这张牌的数据</summary>
public Tile Data { get; private set; }
/// <summary>是否被选中(高亮状态)</summary>
public bool IsSelected { get; private set; }
/// <summary>牌被点击时发出</summary>
[Signal] public delegate void TileClickedEventHandler(TileNode tileNode);
public override void _Ready()
{
_tileLabel = GetNode<Label>("TileLabel");
_background = GetNode<Panel>("Background");
// 初始大小
CustomMinimumSize = new Vector2(60, 80);
// 连接鼠标事件
GuiInput += OnGuiInput;
}
/// <summary>
/// 初始化牌面
/// </summary>
public void Initialize(Tile tile, bool faceUp = true)
{
Data = tile;
_faceUp = faceUp;
UpdateDisplay();
}
/// <summary>
/// 更新牌面显示
/// </summary>
private void UpdateDisplay()
{
if (_faceUp && Data != default)
{
_tileLabel.Text = Data.DisplayName;
_tileLabel.Visible = true;
// 根据花色设置颜色
_tileLabel.Modulate = Data.Suit switch
{
TileSuit.Wan => new Color("FF4444"), // 万子:红色
TileSuit.Tong => new Color("4488FF"), // 筒子:蓝色
TileSuit.Tiao => new Color("44BB44"), // 条子:绿色
_ => Colors.White
};
}
else
{
_tileLabel.Visible = false;
}
}
/// <summary>
/// 翻开/翻下牌面
/// </summary>
public void SetFaceUp(bool faceUp)
{
_faceUp = faceUp;
UpdateDisplay();
}
/// <summary>
/// 设置选中状态
/// </summary>
public void SetSelected(bool selected)
{
IsSelected = selected;
if (selected)
{
// 选中时上移一点,表示"准备出这张牌"
Position += new Vector2(0, -15);
_background.Modulate = new Color("FFFF88"); // 黄色高亮
}
else
{
Position -= new Vector2(0, -15);
_background.Modulate = Colors.White;
}
}
/// <summary>
/// 处理鼠标输入
/// </summary>
private void OnGuiInput(InputEvent inputEvent)
{
if (inputEvent is InputEventMouseButton mouseButton
&& mouseButton.Pressed
&& mouseButton.ButtonIndex == MouseButton.Left)
{
EmitSignal(SignalName.TileClicked, this);
}
}
}GDScript
extends PanelContainer
## 牌面标签(显示文字)
@onready var _tile_label: Label = $TileLabel
## 牌面背景
@onready var _background: Panel = $Background
## 是否显示牌面(false则显示牌背)
var _face_up: bool = true
## 这张牌的数据
var data: Tile = null
## 是否被选中(高亮状态)
var is_selected: bool = false
## 牌被点击时发出
signal tile_clicked(tile_node)
func _ready() -> void:
# 初始大小
custom_minimum_size = Vector2(60, 80)
# 连接鼠标事件
gui_input.connect(_on_gui_input)
## 初始化牌面
func initialize(tile: Tile, face_up: bool = true) -> void:
data = tile
_face_up = face_up
_update_display()
## 更新牌面显示
func _update_display() -> void:
if _face_up and data != null:
_tile_label.text = data.display_name
_tile_label.visible = true
# 根据花色设置颜色
match data.suit:
TileSuit.WAN:
_tile_label.modulate = Color("FF4444") # 万子:红色
TileSuit.TONG:
_tile_label.modulate = Color("4488FF") # 筒子:蓝色
TileSuit.TIAO:
_tile_label.modulate = Color("44BB44") # 条子:绿色
_:
_tile_label.modulate = Color.WHITE
else:
_tile_label.visible = false
## 翻开/翻下牌面
func set_face_up(face_up: bool) -> void:
_face_up = face_up
_update_display()
## 设置选中状态
func set_selected(selected: bool) -> void:
is_selected = selected
if selected:
# 选中时上移一点,表示"准备出这张牌"
position += Vector2(0, -15)
_background.modulate = Color("FFFF88") # 黄色高亮
else:
position -= Vector2(0, -15)
_background.modulate = Color.WHITE
## 处理鼠标输入
func _on_gui_input(input_event: InputEvent) -> void:
if input_event is InputEventMouseButton:
var mouse_button := input_event as InputEventMouseButton
if mouse_button.pressed and mouse_button.button_index == MOUSE_BUTTON_LEFT:
tile_clicked.emit(self)手牌排序
玩家手中的牌需要按照花色和数字排列,方便查看。排序规则:
- 先按花色排:万子 → 筒子 → 条子
- 同花色内按数字从小到大排
C
/// <summary>
/// 手牌排序器
/// </summary>
public static class HandSorter
{
/// <summary>
/// 对手牌进行排序
/// 排序规则:先按花色,同花色按数字
/// </summary>
public static void Sort(List<Tile> hand)
{
hand.Sort((a, b) =>
{
// 先比较花色
int suitCompare = a.Suit.CompareTo(b.Suit);
if (suitCompare != 0) return suitCompare;
// 花色相同,比较数字
return a.Number.CompareTo(b.Number);
});
}
/// <summary>
/// 统计手牌中每种牌的数量
/// </summary>
/// <returns>字典:key=牌的ID, value=数量</returns>
public static Dictionary<int, int> CountTiles(List<Tile> hand)
{
var counts = new Dictionary<int, int>();
foreach (var tile in hand)
{
if (counts.ContainsKey(tile.Id))
counts[tile.Id]++;
else
counts[tile.Id] = 1;
}
return counts;
}
}GDScript
## 手牌排序工具
class_name HandSorter
## 对手牌进行排序
## 排序规则:先按花色,同花色按数字
static func sort(hand: Array) -> void:
hand.sort_custom(func(a: Tile, b: Tile) -> bool:
# 先比较花色
if a.suit != b.suit:
return a.suit < b.suit
# 花色相同,比较数字
return a.number < b.number
)
## 统计手牌中每种牌的数量
## 返回字典:key=牌的ID, value=数量
static func count_tiles(hand: Array) -> Dictionary:
var counts: Dictionary = {}
for tile in hand:
if counts.has(tile.id):
counts[tile.id] += 1
else:
counts[tile.id] = 1
return counts手牌渲染
把手牌显示为扇形排列,是麻将游戏的经典视觉效果。
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 手牌显示组件
/// 以扇形排列显示玩家的手牌
/// </summary>
public partial class HandDisplay : Control
{
[Export] public PackedScene TileScene { get; set; }
/// <summary>牌的宽度</summary>
private const float TILE_WIDTH = 60f;
/// <summary>牌的重叠宽度(相邻牌重叠部分)</summary>
private const float TILE_OVERLAP = 35f;
/// <summary>牌之间的间距</summary>
private const float TILE_SPACING = TILE_WIDTH - TILE_OVERLAP; // 25px
/// <summary>存储牌节点</summary>
private readonly List<TileNode> _tileNodes = new List<TileNode>();
/// <summary>
/// 更新手牌显示
/// </summary>
/// <param name="hand">手牌数据</param>
/// <param name="faceUp">是否正面显示</param>
public void UpdateHand(List<Tile> hand, bool faceUp = true)
{
// 清除旧的牌节点
foreach (var node in _tileNodes)
{
node.QueueFree();
}
_tileNodes.Clear();
// 排序手牌
HandSorter.Sort(hand);
// 计算起始位置(居中)
float totalWidth = (hand.Count - 1) * TILE_SPACING + TILE_WIDTH;
float startX = (Size.X - totalWidth) / 2f;
// 创建新的牌节点
for (int i = 0; i < hand.Count; i++)
{
var tileNode = TileScene.Instantiate<TileNode>();
tileNode.Initialize(hand[i], faceUp);
tileNode.Position = new Vector2(startX + i * TILE_SPACING, 0);
tileNode.TileClicked += OnTileClicked;
AddChild(tileNode);
_tileNodes.Add(tileNode);
}
}
/// <summary>
/// 处理牌被点击
/// </summary>
private void OnTileClicked(TileNode tileNode)
{
// 切换选中状态
tileNode.SetSelected(!tileNode.IsSelected);
}
/// <summary>
/// 获取当前选中的牌
/// </summary>
public TileNode GetSelectedTile()
{
foreach (var node in _tileNodes)
{
if (node.IsSelected) return node;
}
return null;
}
/// <summary>
/// 取消所有选中
/// </summary>
public void DeselectAll()
{
foreach (var node in _tileNodes)
{
if (node.IsSelected) node.SetSelected(false);
}
}
}GDScript
extends Control
@export var tile_scene: PackedScene
## 牌的宽度
const TILE_WIDTH: float = 60.0
## 牌的重叠宽度
const TILE_OVERLAP: float = 35.0
## 牌之间的间距
const TILE_SPACING: float = TILE_WIDTH - TILE_OVERLAP # 25px
## 存储牌节点
var _tile_nodes: Array = []
## 更新手牌显示
## hand: 手牌数据, face_up: 是否正面显示
func update_hand(hand: Array, face_up: bool = true) -> void:
# 清除旧的牌节点
for node in _tile_nodes:
node.queue_free()
_tile_nodes.clear()
# 排序手牌
HandSorter.sort(hand)
# 计算起始位置(居中)
var total_width: float = (hand.size() - 1) * TILE_SPACING + TILE_WIDTH
var start_x: float = (size.x - total_width) / 2.0
# 创建新的牌节点
for i in range(hand.size()):
var tile_node = tile_scene.instantiate()
tile_node.initialize(hand[i], face_up)
tile_node.position = Vector2(start_x + i * TILE_SPACING, 0)
tile_node.tile_clicked.connect(_on_tile_clicked)
add_child(tile_node)
_tile_nodes.append(tile_node)
## 处理牌被点击
func _on_tile_clicked(tile_node) -> void:
# 切换选中状态
tile_node.set_selected(!tile_node.is_selected)
## 获取当前选中的牌
func get_selected_tile():
for node in _tile_nodes:
if node.is_selected:
return node
return null
## 取消所有选中
func deselect_all() -> void:
for node in _tile_nodes:
if node.is_selected:
node.set_selected(false)牌面颜色方案
为了在没有图片素材时也能区分不同花色,我们用颜色来区分:
| 花色 | 颜色 | 色值 |
|---|---|---|
| 万子 | 红色 | #FF4444 |
| 筒子 | 蓝色 | #4488FF |
| 条子 | 绿色 | #44BB44 |
这和很多线上麻将APP的配色方案类似,玩家可以快速通过颜色识别花色。
本章小结
| 完成项 | 说明 |
|---|---|
| 108张牌生成 | 三花色 x 九数字 x 四张 |
| 牌面素材管理 | 按路径加载纹理,缓存到字典 |
| 文字牌面 | 无图片时用花色名+数字名显示 |
| 花色颜色 | 万红、筒蓝、条绿 |
| 手牌排序 | 先花色后数字 |
| 手牌渲染 | 重叠排列,居中显示 |
下一章,我们将实现发牌与摸牌——洗牌算法、发牌逻辑、摸牌动画,以及流局的判断。
