综合实战:棋牌/桌游类服务器(上)
概述
以斗地主为例,把前 13 周的知识串成一套可运行的棋牌服务器:协议设计(编解码)、玩家系统(登录与数据)、房间管理(状态机)、回合制对战(服务端权威出牌校验)、结算系统(积分与流水)。本篇覆盖从连接到结算的完整链路,下篇补好友、商城、安全与压测。
一、项目整体设计
1.1 模块划分
工程模块(Maven 多模块):
game-common 公共(常量、工具、协议模型)
game-proto Protobuf 消息定义
game-gateway 接入网关(Netty + 编解码)
game-logic 业务逻辑(玩家/房间/对局/结算)
game-dao 数据访问(MySQL/Redis)
线程模型:
网关:Netty EventLoop(编解码、透传)
逻辑:按房间/玩家分片执行(同房间串行)
数据:连接池异步读写技术栈:
网络:Netty(TCP 长连接)
序列化:Protobuf
缓存:Redis(在线状态、房间、结算流水)
存储:MySQL(玩家档案、对局记录)
配置:application.yml + 环境分离1.2 关键流程串讲
完整链路:
登录(网关 → 玩家服务 → Redis 会话)
→ 进大厅(房间列表)
→ 创建/加入房间(房间服务)
→ 对局(牌局服务:发牌/叫地主/出牌)
→ 结算(积分 + 货币 + 流水 + 战报)
→ 退出/断线重连二、协议设计
2.1 消息定义
// 协议头(自定义协议,见协议章节)
magic(2) + version(1) + flags(1) + opcode(2)
+ length(4) + seq(4) + body
// 关键 Opcode
LOGIN_REQ(1001) / LOGIN_RESP(1002)
CREATE_ROOM(1101) / JOIN_ROOM(1102) / ROOM_STATE(1103)
READY(1104) / GAME_START(1105)
PLAY_CARD(1201) / PLAY_CARD_RESP(1202)
SETTLE(1301) / SETTLE_RESP(1302)// Protobuf 定义(节选)
message PlayCardReq {
int64 roomId = 1;
int64 playerId = 2;
repeated Card cards = 3; // 要出的牌
int32 cardType = 4; // 牌型(服务器校验)
}
message Card { int32 suit = 1; int32 rank = 2; }2.2 编解码接入
// Netty Pipeline
LengthFieldBasedFrameDecoder(长度域 = length)
→ ProtocolDecoder(解出 opcode + body)
→ 业务分发(按 opcode 路由 Handler)
→ ProtocolEncoder(响应编码)
→ 客户端
// 序列化
Protobuf 编解码器(ProtobufVarint32FrameDecoder 等)
业务层收到的是反序列化后的消息对象三、玩家系统
3.1 登录与会话
登录流程:
客户端登录(Token 校验,见账号章节)
玩家服务加载玩家数据(MySQL → Redis 缓存)
建立会话(Session 绑定玩家 ID)
加入网关在线表(Redis Global Session)
断线重连:
连接断开 → 保留宽限期(如 30s)
重连成功 → 恢复房间上下文
超时 → 离线(房间内托管/解散逻辑)// 登录处理
public void login(long playerId, Channel ch) {
Player player = playerService.load(playerId); // DB → 缓存
sessionManager.bind(ch, playerId); // 绑定连接
// 校验是否在对局中 → 恢复现场
Room room = roomService.findByPlayer(playerId);
if (room != null) { sendSnapshot(room); }
}3.2 数据加载与落库
数据策略:
热数据(在线状态/房间)→ Redis
档案数据(金币/等级)→ Redis 缓存 + MySQL 落库
落库时机:关键操作即写(结算/消费)+ 定时快照
幂等:货币变动带 bizId(见经济章节)四、房间管理
4.1 房间状态机
房间状态机:
WAITING(等待)→ READY(就绪)→ PLAYING(对局中)→ END(结算)
任一状态可 → DISSOLVE(解散)
状态转换:
创建 → WAITING(房主)
3 人就绪 → PLAYING(发牌开始)
对局结束 → END → 回到 WAITING 或解散
房主退出/超时 → 解散4.2 房间操作
创建房间:
校验玩家不在其他房间
生成房间号(Redis INCR + 随机混淆)
创建 Room 实体 → Redis Hash 存储
加入房间:
校验房间存在且人数未满
加入成员列表 → 广播成员变化
房主可开始对局
广播:
房间内成员变化/状态变化 → 房间频道广播
(同房间玩家共享 RoomChannel)// 房间存储(Redis)
HSET room:{roomId} owner {pid} players {json} state WAITING
// 房间状态变更 → 广播 ROOM_STATE五、回合制对战
5.1 牌局状态机
牌局流程:
DEAL(发牌,每人 17 张 + 底牌 3)
→ BID(叫地主,按顺序叫/抢)
→ PLAYING(出牌回合,上一手赢者先出)
→ WIN_CHECK(出完者胜)
→ SETTLE(结算)
每个回合:
当前出牌玩家 → 出牌 → 服务器校验 → 广播
→ 下家(跳过则轮转)
超时 → 自动托管出牌/跳过5.2 出牌合法性校验(服务端权威)
出牌校验(服务端,不信任客户端):
1. 牌属于该玩家(从手牌扣除)
2. 牌型合法(单张/对子/顺子/炸弹等)
3. 大于上家出的牌(同牌型比大小/炸弹翻倍)
4. 是否轮到该玩家出牌
校验通过 → 从手牌移除 → 广播 → 流转下家
校验失败 → 返回错误码(不改变状态)// 出牌校验核心
public PlayResult play(Player p, List cards) { // cards 手牌列表
if (!isTurn(p)) return FAIL("未轮到你出牌");
if (!ownAll(p, cards)) return FAIL("手牌不足");
CardType type = analyze(cards); // 牌型分析
if (type == null) return FAIL("牌型不合法");
if (!bigger(type, lastPlay)) return FAIL("压不过上家");
p.removeCards(cards); // 扣手牌
return broadcastAndNext(p, cards);
}5.3 超时与托管
超时处理:
每个玩家回合设置倒计时(如 15s)
HashedWheelTimer 触发
超时 → 自动出最小牌 / 跳过(托管)
多次超时 → 标记托管状态
断线重连:
对局中重连 → 快照恢复(手牌/底牌/当前状态)
离线 → 托管继续对局六、结算系统
6.1 胜负与积分
结算逻辑(服务端权威):
出完手牌者胜
积分 = 基础分 × 地主倍率 × 炸弹翻倍
炸弹/春天加分项
积分变更 → 统一货币入口(幂等 bizId)
落库:
对局记录(玩家手牌、出牌序列、结果)
积分变动流水
排行榜数据更新(见排行榜章节)// 结算幂等
public SettleResult settle(long roomId, long bizId) {
if (opLog.exist(bizId)) return opLog.get(bizId); // 幂等
// 计算各家分数
Map scores = computeScore(room); // playerId → 分数
for (entry : scores) {
currencyService.add(entry.playerId, entry.score, bizId);
}
saveBattleRecord(room, scores);
return buildResult(scores);
}6.2 战报与流水
战报内容:
双方手牌、出牌过程、关键节点(叫地主/炸弹)
各玩家积分与最终金币变化
发送给所有玩家 + 可查询历史
流水审计:
每笔金币变动带 bizId/场景/余额快照
对账可查(见经济与审计章节)七、实现要点
综合实战(上)清单:
协议设计:Protobuf + 自定义协议头 + 编解码
玩家系统:登录会话 + 数据加载落库
房间管理:状态机 + 创建/加入/广播
回合制对战:牌局状态机 + 服务端出牌校验 + 超时托管
结算系统:权威结算 + 幂等 + 流水战报
常见坑:
出牌校验放客户端 → 刷牌
结算不幂等 → 重复发分
断线直接判负 → 体验差(先托管)
房间状态并发 → 同房间串行处理
下篇预告:
好友邀请 + 排行榜 + 商城购买 + 安全校验 + 性能压测