8. 游戏界面
2026/4/14大约 6 分钟
长沙麻将——游戏界面
界面布局
长沙麻将的界面需要同时展示四个玩家的手牌、出牌区和副露区。经典布局是"十字形"——玩家在下方,三个AI分别在左、上、右方。
┌──────────────────────────────────────┐
│ AI玩家2(上方) │
│ [副露区] [手牌(背面)] [副露区] │
│ │
│ AI3 ┌──────────────┐ AI1 │
│ (左) │ │ (右) │
│ [手牌] │ 牌桌中央 │ [手牌] │
│ [背面] │ (出牌区域) │ [背面] │
│ │ │ │
│ └──────────────┘ │
│ │
│ [副露区] [玩家手牌] [副露区] │
│ 人类玩家(下方) │
│ [吃] [碰] [杠] [胡] │
└──────────────────────────────────────┘四人牌桌
牌桌场景结构
Table (Control) ← 牌桌根节点
├── Background (ColorRect) ← 牌桌背景(绿色桌面)
├── CenterArea (Control) ← 中央区域
│ ├── Player0Discard (GridContainer) ← 玩家0出牌区
│ ├── Player1Discard (GridContainer) ← 玩家1出牌区
│ ├── Player2Discard (GridContainer) ← 玩家2出牌区
│ └── Player3Discard (GridContainer) ← 玩家3出牌区
├── Player0Area (Control) ← 玩家0(下方/人类)
│ ├── MeldDisplay0 ← 副露显示
│ └── HandDisplay0 ← 手牌显示
├── Player1Area (Control) ← 玩家1(右方/AI)
│ ├── MeldDisplay1
│ └── HandDisplay1
├── Player2Area (Control) ← 玩家2(上方/AI)
│ ├── MeldDisplay2
│ └── HandDisplay2
├── Player3Area (Control) ← 玩家3(左方/AI)
│ ├── MeldDisplay3
│ └── HandDisplay3
└── WallDisplay (Control) ← 牌墙显示牌桌控制器
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 牌桌控制器
/// 管理四个玩家的手牌显示、出牌区域和副露区域
/// </summary>
public partial class TableController : Control
{
/// <summary>四个玩家的手牌显示组件</summary>
[Export] public HandDisplay[] HandDisplays = new HandDisplay[4];
/// <summary>四个玩家的副露显示组件</summary>
[Export] public MeldDisplay[] MeldDisplays = new MeldDisplay[4];
/// <summary>四个玩家的出牌区域</summary>
[Export] public Control[] DiscardAreas = new Control[4];
/// <summary>当前回合指示器</summary>
[Export] public Control[] TurnIndicators = new Control[4];
/// <summary>牌墙剩余数标签</summary>
[Export] public Label WallCountLabel { get; set; }
/// <summary>游戏管理器引用</summary>
private GameManager _gameManager;
public override void _Ready()
{
_gameManager = GetNode<GameManager>("/root/Main/GameManager");
// 连接信号
_gameManager.TileDrawn += OnTileDrawn;
_gameManager.TileDiscarded += OnTileDiscarded;
_gameManager.StateChanged += OnStateChanged;
}
/// <summary>
/// 更新整个牌桌显示
/// </summary>
public void UpdateTable()
{
for (int player = 0; player < 4; player++)
{
var hand = _gameManager.GetHand(player);
var melds = _gameManager.GetMelds(player);
// 玩家0是人类,显示正面;其余AI显示背面
bool faceUp = (player == 0);
// 更新手牌
HandDisplays[player].UpdateHand(hand, faceUp);
// 更新副露
MeldDisplays[player].UpdateMelds(melds);
}
// 更新牌墙剩余数
WallCountLabel.Text = $"余牌: {_gameManager.GetWallCount()}";
// 更新回合指示
UpdateTurnIndicator();
}
/// <summary>
/// 更新回合指示器
/// </summary>
private void UpdateTurnIndicator()
{
for (int i = 0; i < 4; i++)
{
TurnIndicators[i].Visible = (i == _gameManager.CurrentPlayer);
}
}
/// <summary>
/// 摸牌回调
/// </summary>
private void OnTileDrawn(int player, Tile tile)
{
UpdateTable();
}
/// <summary>
/// 出牌回调
/// </summary>
private void OnTileDiscarded(int player, Tile tile)
{
// 在出牌区域添加这张牌
ShowDiscardedTile(player, tile);
UpdateTable();
}
/// <summary>
/// 在出牌区域显示打出的牌
/// </summary>
private void ShowDiscardedTile(int player, Tile tile)
{
var tileNode = HandDisplays[0].TileScene.Instantiate<TileNode>();
tileNode.Initialize(tile, true);
tileNode.CustomMinimumSize = new Vector2(40, 55);
DiscardAreas[player].AddChild(tileNode);
}
/// <summary>
/// 状态变化回调
/// </summary>
private void OnStateChanged(MahjongGameState newState)
{
UpdateTurnIndicator();
}
}GDScript
extends Control
## 四个玩家的手牌显示组件
@export var hand_displays: Array = [null, null, null, null]
## 四个玩家的副露显示组件
@export var meld_displays: Array = [null, null, null, null]
## 四个玩家的出牌区域
@export var discard_areas: Array = [null, null, null, null]
## 当前回合指示器
@export var turn_indicators: Array = [null, null, null, null]
## 牌墙剩余数标签
@export var wall_count_label: Label
## 游戏管理器引用
var _game_manager: GameManager
func _ready() -> void:
_game_manager = get_node("/root/Main/GameManager")
# 连接信号
_game_manager.tile_drawn.connect(_on_tile_drawn)
_game_manager.tile_discarded.connect(_on_tile_discarded)
_game_manager.state_changed.connect(_on_state_changed)
## 更新整个牌桌显示
func update_table() -> void:
for player in range(4):
var hand: Array = _game_manager.get_hand(player)
var melds: Array = _game_manager.get_melds(player)
# 玩家0是人类,显示正面;其余AI显示背面
var face_up: bool = (player == 0)
# 更新手牌
hand_displays[player].update_hand(hand, face_up)
# 更新副露
meld_displays[player].update_melds(melds)
# 更新牌墙剩余数
wall_count_label.text = "余牌: %d" % _game_manager.get_wall_count()
# 更新回合指示
_update_turn_indicator()
## 更新回合指示器
func _update_turn_indicator() -> void:
for i in range(4):
turn_indicators[i].visible = (i == _game_manager.current_player)
## 摸牌回调
func _on_tile_drawn(player: int, tile) -> void:
update_table()
## 出牌回调
func _on_tile_discarded(player: int, tile) -> void:
_show_discarded_tile(player, tile)
update_table()
## 在出牌区域显示打出的牌
func _show_discarded_tile(player: int, tile) -> void:
var tile_node = hand_displays[0].tile_scene.instantiate()
tile_node.initialize(tile, true)
tile_node.custom_minimum_size = Vector2(40, 55)
discard_areas[player].add_child(tile_node)
## 状态变化回调
func _on_state_changed(new_state: int) -> void:
_update_turn_indicator()副露显示
副露区域显示吃、碰、杠后的牌组。
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 副露显示组件
/// 显示玩家吃碰杠后的牌组
/// </summary>
public partial class MeldDisplay : HBoxContainer
{
[Export] public PackedScene TileScene { get; set; }
/// <summary>
/// 更新副露显示
/// </summary>
public void UpdateMelds(List<Meld> melds)
{
// 清除旧的显示
foreach (Node child in GetChildren())
{
child.QueueFree();
}
// 显示每组副露
foreach (var meld in melds)
{
// 添加间隔
if (melds.IndexOf(meld) > 0)
{
var spacer = new Control();
spacer.CustomMinimumSize = new Vector2(10, 0);
AddChild(spacer);
}
// 显示副露中的牌
foreach (var tile in meld.Tiles)
{
var tileNode = TileScene.Instantiate<TileNode>();
tileNode.Initialize(tile, true);
tileNode.CustomMinimumSize = new Vector2(45, 60);
// 暗杠的牌显示为背面
if (meld.Type == MeldType.AnGang)
{
tileNode.SetFaceUp(false);
}
AddChild(tileNode);
}
}
}
}GDScript
extends HBoxContainer
@export var tile_scene: PackedScene
## 更新副露显示
func update_melds(melds: Array) -> void:
# 清除旧的显示
for child in get_children():
child.queue_free()
# 显示每组副露
for meld_idx in range(melds.size()):
var meld = melds[meld_idx]
# 添加间隔
if meld_idx > 0:
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(10, 0)
add_child(spacer)
# 显示副露中的牌
for tile in meld.tiles:
var tile_node = tile_scene.instantiate()
tile_node.initialize(tile, true)
tile_node.custom_minimum_size = Vector2(45, 60)
# 暗杠的牌显示为背面
if meld.type == MeldType.AN_GANG:
tile_node.set_face_up(false)
add_child(tile_node)结算界面
胡牌后显示结算信息,包括胡牌类型、番数、各玩家的得分变化。
结算界面布局
┌──────────────────────┐
│ 胡牌结算 │
│ │
│ 胡牌者: 玩家0 (东) │
│ 胡牌方式: 自摸 │
│ 牌型: 清一色 │
│ 番数: 6番 │
│ 扎鸟: +2番 │
│ │
│ 得分变化: │
│ 玩家0: +600 │
│ 玩家1: -200 │
│ 玩家2: -200 │
│ 玩家3: -200 │
│ │
│ [继续] [退出] │
└──────────────────────┘C
using Godot;
/// <summary>
/// 结算弹窗
/// </summary>
public partial class ResultPopup : Control
{
[Export] public Label TitleLabel { get; set; }
[Export] public Label WinnerLabel { get; set; }
[Export] public Label PatternLabel { get; set; }
[Export] public Label FanLabel { get; set; }
[Export] public Label BirdLabel { get; set; }
[Export] public Label ScoreDetailLabel { get; set; }
[Export] public Button ContinueButton { get; set; }
[Export] public Button ExitButton { get; set; }
[Signal] public delegate void ContinuePressedEventHandler();
[Signal] public delegate void ExitPressedEventHandler();
private const string[] PlayerNames = { "你(东)", "AI(南)", "AI(西)", "AI(北)" };
public override void _Ready()
{
Visible = false;
ContinueButton.Pressed += () => EmitSignal(SignalName.ContinuePressed);
ExitButton.Pressed += () => EmitSignal(SignalName.ExitPressed);
}
/// <summary>
/// 显示结算结果
/// </summary>
public void ShowResult(WinResult result, int winner, bool isSelfDraw,
int[] scoreChanges)
{
Visible = true;
TitleLabel.Text = isSelfDraw ? "自摸胡牌!" : "点炮胡牌!";
WinnerLabel.Text = $"胡牌者: {PlayerNames[winner]}";
PatternLabel.Text = $"牌型: {result.PatternName}";
FanLabel.Text = $"总番数: {result.Fan}番";
BirdLabel.Text = result.BirdBonus > 0
? $"扎鸟加番: +{result.BirdBonus}番"
: "扎鸟加番: 无";
// 显示各玩家得分变化
var detail = new System.Text.StringBuilder();
for (int i = 0; i < 4; i++)
{
string sign = scoreChanges[i] >= 0 ? "+" : "";
detail.AppendLine($"{PlayerNames[i]}: {sign}{scoreChanges[i]}");
}
ScoreDetailLabel.Text = detail.ToString();
}
/// <summary>
/// 显示流局
/// </summary>
public void ShowDraw()
{
Visible = true;
TitleLabel.Text = "流局";
WinnerLabel.Text = "没有人胡牌";
PatternLabel.Text = "";
FanLabel.Text = "";
BirdLabel.Text = "";
ScoreDetailLabel.Text = "本局无得分变化";
}
}GDScript
extends Control
@export var title_label: Label
@export var winner_label: Label
@export var pattern_label: Label
@export var fan_label: Label
@export var bird_label: Label
@export var score_detail_label: Label
@export var continue_button: Button
@export var exit_button: Button
signal continue_pressed()
signal exit_pressed()
const PLAYER_NAMES: Array = ["你(东)", "AI(南)", "AI(西)", "AI(北)"]
func _ready() -> void:
visible = false
continue_button.pressed.connect(func(): continue_pressed.emit())
exit_button.pressed.connect(func(): exit_pressed.emit())
## 显示结算结果
func show_result(result, winner: int, is_self_draw: bool, score_changes: Array) -> void:
visible = true
title_label.text = "自摸胡牌!" if is_self_draw else "点炮胡牌!"
winner_label.text = "胡牌者: %s" % PLAYER_NAMES[winner]
pattern_label.text = "牌型: %s" % result.pattern_name
fan_label.text = "总番数: %d番" % result.fan
bird_label.text = "扎鸟加番: +%d番" % result.bird_bonus \
if result.bird_bonus > 0 else "扎鸟加番: 无"
# 显示各玩家得分变化
var detail: String = ""
for i in range(4):
var sign: String = "+" if score_changes[i] >= 0 else ""
detail += "%s: %s%d\n" % [PLAYER_NAMES[i], sign, score_changes[i]]
score_detail_label.text = detail
## 显示流局
func show_draw() -> void:
visible = true
title_label.text = "流局"
winner_label.text = "没有人胡牌"
pattern_label.text = ""
fan_label.text = ""
bird_label.text = ""
score_detail_label.text = "本局无得分变化"本章小结
| 完成项 | 说明 |
|---|---|
| 牌桌布局 | 四方布局,人类在下方 |
| 手牌显示 | 人类正面显示,AI背面显示 |
| 副露显示 | 吃碰杠牌组展示 |
| 出牌区域 | 每个玩家的出牌堆 |
| 回合指示 | 高亮当前玩家 |
| 牌墙计数 | 显示剩余牌数 |
| 结算弹窗 | 胡牌类型、番数、得分变化 |
下一章,我们将添加音效与特效——摸牌出牌的声音、碰杠胡的音效、牌面动画和胡牌特效。
