C++ 游戏引擎状态机¶
本文档详细描述了 Mahjong C++ 引擎的完整状态机实现。理解状态机是正确使用引擎、与前端对接、以及扩展功能的基础。
概述¶
引擎使用阶段制(Phase-based)状态机,每次 make_selection() 调用推进一个阶段。所有游戏状态集中在 Table 类中,通过 PhaseEnum 追踪当前所处的阶段。
PhaseEnum 完整列表¶
enum PhaseEnum {
P1_ACTION = 0, // 玩家0自摸阶段
P2_ACTION = 1, // 玩家1自摸阶段
P3_ACTION = 2, // 玩家2自摸阶段
P4_ACTION = 3, // 玩家3自摸阶段
P1_RESPONSE = 4, // 玩家0响应阶段
P2_RESPONSE = 5, // 玩家1响应阶段
P3_RESPONSE = 6, // 玩家3响应阶段
P4_RESPONSE = 7, // 玩家3响应阶段
P1_CHANKAN_RESPONSE = 8, // 玩家0抢杠响应阶段
P2_CHANKAN_RESPONSE = 9, // 玩家1抢杠响应阶段
P3_CHANKAN_RESPONSE = 10, // 玩家2抢杠响应阶段
P4_CHANKAN_RESPONSE = 11, // 玩家3抢杠响应阶段
P1_CHANANKAN_RESPONSE = 12, // 玩家0抢暗杠响应阶段
P2_CHANANKAN_RESPONSE = 13, // 玩家1抢暗杠响应阶段
P3_CHANANKAN_RESPONSE = 14, // 玩家2抢暗杠响应阶段
P4_CHANANKAN_RESPONSE = 15, // 玩家3抢暗杠响应阶段
GAME_OVER = 16, // 游戏结束
UNINITIALIZED = 17 // 未初始化
};
关键辅助函数(均定义于 Table.h):
函数 |
说明 |
|---|---|
|
返回当前需要行动的玩家 ID: |
|
|
|
|
完整状态转移图¶
┌────────────────┐
│ UNINITIALIZED │
└───────┬────────┘
│ game_init() / game_init_with_config()
▼
┌─────────────────────────────────────┐
│ from_beginning() │
│ ├─ 流局检测(4风/4立/4杠/无牌) │
│ ├─ 摸牌 (draw_normal / draw_rinshan)│
│ └─ _generate_self_actions() │
└───────┬─────────────────────────────┘
│ set phase = Pn_ACTION
▼
┌─────────────────────────────────┐
│ Pn_ACTION (自摸阶段) │◄──────────────┐
│ turn = n │ │
│ │ │
│ get_kyushukyuhai() ──────────────────────────►流局(9种9牌) → GAME_OVER
│ get_tsumo() ──────────────────────────►和牌(tsumo) → GAME_OVER
│ get_discard() ──► P1_RESPONSE │
│ get_riichi() ──► 摸牌 → get_riichi() ──► P1_RESPONSE
│ get_ankan() ──► P1_CHANANKAN_RESPONSE │
│ get_kakan() ──► P1_CHANKAN_RESPONSE │
└──────┬──────────────────────────┘ │
│ make_selection() │
▼ │
┌─────────────────────────────────────────────────────┐
│ P1_RESPONSE ─► P2_RESPONSE ─► P3_RESPONSE ─► P4_RESPONSE │
│ 玩家1 玩家2 玩家3 玩家4 │
│ │
│ 优先级: Ron > Pon/Kan > Chi > Pass │
│ │
│ Ron ──► generate_result_ron() ──────────────► GAME_OVER
│ Pon/Kan/Chi ──► execute_naki() ──► from_beginning()
│ Pass(所有人) ──► _handle_response_final_pass_impl() │
│ ──► next_turn() ──► from_beginning()─────┘
└─────────────────────────────────────────────────────┘
P1_CHANKAN_RESPONSE ─► P2_CHANKAN ─► P3_CHANKAN ─► P4_CHANKAN
(抢加杠)
ChanKan ──► generate_result_chankan() ──► GAME_OVER
Pass(所有人) ──► _handle_response_final_chankan_execution()
──► from_beginning()
P1_CHANANKAN_RESPONSE ─► P2 ─► P3 ─► P4
(抢暗杠)
ChanAnKan ──► generate_result_chanankan() ──► GAME_OVER
Pass(所有人) ──► _handle_response_final_chanankan_execution()
──► from_beginning()
阶段详解¶
Pn_ACTION(自摸阶段)¶
玩家 n(n = turn)可执行的动作为 _generate_self_actions() 返回的列表:
动作类型 |
方法 |
结果 |
|---|---|---|
Kyushukyuhai |
|
流局,9种9牌, |
Tsumo |
|
自摸和牌, |
Discard |
|
舍牌,进入 P1_RESPONSE |
Riichi |
|
宣言立直,后续须再次舍牌,进入 P1_RESPONSE |
AnKan |
|
暗杠,进入 P1_CHANANKAN_RESPONSE |
KaKan |
|
加杠,进入 P1_CHANKAN_RESPONSE |
立直的两步流程¶
立直是一个两阶段操作:
第一步:玩家选牌 →
make_selection(Riichi_discard_index)(action_type=Discard, 但selected_action.action = BaseAction::Riichi)。引擎进入下一个 Pn_ACTION,尚未实际舍牌。第二步:前端显示”确认立直”弹窗,玩家再次选同一张牌 →
make_selection(discard_index)正式舍牌并宣告立直。
前端必须正确处理此两步流程(详见 pymahjong/env_pymahjong.py 中的 step() 函数对立直的特殊处理)。
Pn_RESPONSE(响应阶段)¶
针对当前 turn 玩家舍出的牌,其他玩家按顺序响应。actions[4] 数组记录各玩家的选择,优先级如下:
Ron > Pon > Kan > Chi > Pass
具体规则:
Chi:仅当
player == (turn + 1) % 4时可用(只能吃上家)Pon:手中有该牌2张以上时可执行
Kan:手中有该牌3张以上时可执行(大手替打)
Ron:任何玩家在和牌型时可执行
_handle_response_final_execution() 逻辑¶
所有4个玩家响应完成后,按以下优先级确认最终动作:
存在 Ron:
generate_result_ron()→GAME_OVER存在 Pon/Kan/Chi:执行鸣牌(调用
execute_naki()),from_beginning()重新开始全部 Pass:
next_turn()→from_beginning()继续下一巡
Chankan / ChanAnKan(抢杠)¶
**加杠(KaKan)**后,触发 P1_CHANKAN_RESPONSE:
其他玩家可以抢杠(
ChanKan)被抢则
generate_result_chankan()→GAME_OVER
**暗杠(AnKan)**后,触发 P1_CHANANKAN_RESPONSE:
其他玩家可以抢暗杠(
ChanAnKan)被抢则
generate_result_chanankan()→GAME_OVER
流局(Ryukyoku)检测¶
在每局开始时(from_beginning()),引擎检查以下流局条件:
条件 |
检测方式 |
结果类型 |
|---|---|---|
四风连打 |
第1巡,4家首张舍牌均为同一风牌,无鸣牌 |
|
四立直 |
|
|
四杠子 |
|
|
九种九牌 |
玩家手牌有9种以上幺九牌(kyushukyuhai) |
|
无牌 |
|
|
get_remain_kan_tile() 实现¶
inline int get_remain_kan_tile() const {
// 找到 dora_indicator[0] 在 yama 中的位置
// 该位置之前的所有牌(不含 dora_indicator 本身)= 岭上牌
return int(std::find(yama.begin(), yama.end(), dora_indicator[0]) - yama.begin() - 1);
}
get_remain_tile() 实现¶
inline int get_remain_tile() const {
// yama.size() - 14 = 剩余可摸牌数
// 当剩余≤14时游戏结束(最后14张为王牌)
return int(yama.size() - 14);
}
关键状态变量¶
变量 |
类型 |
位置 |
说明 |
|---|---|---|---|
|
|
Table |
当前行动玩家 ID (0-3) |
|
|
Table |
庄家玩家 ID(每局固定,连庄时不变) |
|
|
Table |
本场数,每非庄和牌/流局+1 |
|
|
Table |
立直棒累计金额(1000点/棒) |
|
|
Table |
上一个动作(决定是否摸牌) |
|
|
Table |
自摸阶段选中的动作(Ron检测用) |
|
|
Table |
当前阶段 |
|
|
Table |
当前自摸阶段可用动作列表 |
|
|
Table |
当前响应阶段可用动作列表 |
|
|
Table |
4名玩家的响应选择 |
|
|
Player |
玩家手牌 |
|
|
Player |
鸣牌组(吃/碰/杠) |
|
|
Player |
是否已宣言立直 |
|
|
Player |
是否双立直 |
|
|
Player |
是否门清(无鸣牌) |
|
|
Player |
三种振听状态 |
|
|
Player |
当前听牌列表 |
|
|
Player |
当前得分(点) |
BaseAction 枚举¶
enum class BaseAction : uint8_t {
Pass, // 跳过
Chi, // 吃(仅上家)
Pon, // 碰
Kan, // 大明杠
Ron, // 荣和
ChanAnKan, // 抢暗杠
ChanKan, // 抢加杠
AnKan, // 暗杠
KaKan, // 加杠
Discard, // 舍牌
Riichi, // 立直宣言
Tsumo, // 自摸
Kyushukyuhai // 九种九牌
};
动作索引(Python API)¶
在 Python 绑定中,所有动作被编码为 0-53 的整数索引:
索引范围 |
动作 |
|---|---|
0-33 |
34种标准牌的舍牌 |
34-36 |
赤5万/筒/索的舍牌 |
37-39 |
吃左/中/右(标准) |
40-42 |
吃左/中/右(赤宝牌) |
43 |
碰(标准) |
44 |
碰(赤宝牌) |
45 |
暗杠 |
46 |
大明杠 |
47 |
加杠 |
48 |
立直 |
49 |
荣和 |
50 |
自摸 |
51 |
自动通过(推进对手回合) |
52 |
确认立直(第二步) |
53 |
跳过响应 |
摸牌规则(last_action 决定)¶
inline bool after_chipon() { return last_action == BaseAction::Chi || last_action == BaseAction::Pon; }
inline bool after_daiminkan() { return last_action == BaseAction::Kan; }
inline bool after_ankan() { return last_action == BaseAction::AnKan; }
inline bool after_kakan() { return last_action == BaseAction::KaKan; }
inline bool after_kan() { return after_daiminkan() || after_ankan(); }
上一个动作 |
本巡是否摸牌 |
说明 |
|---|---|---|
Discard / Riichi |
是( |
正常摸牌 |
Chi / Pon |
否(不摸牌) |
吃碰后不补牌,直接由鸣牌者继续 |
Kan / AnKan / KaKan |
是( |
杠后从岭上补牌 |
from_beginning() 完整流程¶
void Table::from_beginning() {
// 1. 检查流局条件
if (is_4wind_ryukyoku()) { result = generate_result_4wind(); phase = GAME_OVER; return; }
if (is_4riichi()) { result = generate_result_4riichi(); phase = GAME_OVER; return; }
if (is_4kan()) { result = generate_result_4kan(); phase = GAME_OVER; return; }
if (get_remain_tile() == 0) { result = generate_result_notile(); phase = GAME_OVER; return; }
// 2. 补牌
if (after_chipon()) {
// 吃碰后不补牌,轮转到鸣牌者
} else if (after_kan()) {
draw_rinshan(turn); // 岭上摸牌
} else {
draw_normal(turn); // 正常从牌山摸牌
}
// 3. 生成自摸动作
self_actions = _generate_self_actions();
phase = PhaseEnum(turn); // P1_ACTION ~ P4_ACTION
}
鸣牌后轮转规则¶
当 Pon/Kan/Chi 发生时,轮到鸣牌者(而非被鸣牌者)继续:
void Table::_handle_response_final_chiponkan_impl(int response_player) {
// 鸣牌后轮到鸣牌者(response_player)继续
// turn 更新为 response_player
last_action = ...; // Chi/Pon/Kan
from_beginning();
}
与前端对接注意事项¶
阶段轮询:前端通过
GET /api/game/{id}/state轮询,phase字段指示当前状态立直两步:前端必须在
selected_action.action == Riichi时显示确认弹窗响应阶段:前端通过
response_actions列表渲染按钮(Chi/Pon/Ron/Pass)SSE 推送:AI 动作完成后通过 SSE 推送,前端无需高频轮询
牌谱记录:每个
make_selection()调用的索引都应记录,用于重放
参考文件¶
Mahjong/Table.h-PhaseEnum定义及核心状态Mahjong/Table.cpp- 状态转移实现(_handle_self_action()、_handle_response_final_execution()等)Mahjong/Player.h/cpp-get_*()系列方法实现Mahjong/GameResult.h/cpp- 流局和结算结果生成