简介:这是一份基于Netty框架的Java QQ斗地主游戏完整源码,共199个文件,压缩包约5.45MB。项目以126个Java源文件为核心,涵盖登录窗口、游戏大厅、房间管理及Netty客户端/服务端消息处理等模块;辅以55张JPG与7张PNG图片用于界面设计,XML文件负责网络与游戏参数配置,并包含项目说明文档和Maven构建配置。资源适合希望深入Netty网络编程、多线程并发处理及在线游戏架构设计的Java开发者,尤其适合有一定基础并想通过完整项目提升实战能力的学习者。代码采用QQLandlordsServer、QQLandlordsClient与common模块的划分,便于理解客户端与服务端职责分离,同时覆盖了连接管理、并发交互等关键问题。目前已有358人学习下载,可作为从零梳理多人实时对战游戏开发流程的参考资料,也可在此基础上扩展功能或优化并发性能。
1. 基于Netty的Java斗地主:为什么这套源码值得你拆开看
如果你见过市面上流传的“Java课设斗地主”,多半是一个Swing窗口加一个控制台算法,双人机对战,连网络都没有。而“基于Netty框架的Java QQ斗地主游戏设计源码”这个名字,说明它是一套真正走网络的CS架构实现:客户端连上服务端,服务端用Netty处理连接、粘包、房间和牌局状态。这类东西才是面试时能拿出来讲的源码,也是你从“会写算法”到“会写网络服务”的一个短路径。
这套方案的受众很明确:Java后端初学者、准备实习的应届生,以及那些想把课设做成简历项目但不知道服务端该从哪下手的人。难点不在斗地主算法——那套拆牌逻辑是纯内存计算,难在你怎么把牌局状态和Netty的异步I/O模型捏到一起。这篇文章就从协议、核心算法、房间同步、坑位排查一路讲到压测验证,给你一条能照着复现的路线。
2. 把业务拆成Netty能听懂的话:通信协议与粘包处理
2.1 选Netty而不是原生Socket/WebSocket:三个决定性理由
很多课设代码用的是原生ServerSocket加多线程,那也能跑,但只能撑住百人以内的小并发。这个标题既然点名Netty,核心卖点是它帮你把NIO的通道、选择器、线程模型全封装好了。第一个理由是解码能力:Netty内置LengthFieldBasedFrameDecoder,粘包半包问题用两个参数就解决,不用自己拼缓冲区。第二个理由是生命周期管理:ChannelHandler的入站出站方法把拆包、业务、响应串成一条流水线,新人也能把代码写到能维护。第三个理由是生态:心跳用IdleStateHandler,广播用ChannelGroup,这些都是现成的类,而不是你自己手写的轮子。
有人会问为什么不用WebSocket。WebSocket适合浏览器客户端,但它把消息格式限制成文本帧和二进制帧,你要自己再定义一层JSON;而斗地主这种回合制游戏,服务端主动推送也很频繁,WebSocket确实能做,但讲不出Netty的线程模型和编解码细节。面java面试题的时候,Netty更能展示你对NIO、零拷贝、堆外内存这些底层的理解。
2.2 设计一份基于长度域的帧协议:从登录帧到出牌帧
协议是网络服务的命脉。我这里用最短的格式:帧头4个字节代表整包长度,长度后面是消息ID和JSON体。客户端登录时发一个LOGIN帧,服务端返回LOGIN_OK;出牌时发PLAY_CARDS,服务端把最终牌面广播给房间内其余玩家。协议设计的要点是:长度字段必须包含消息ID和JSON体的长度,但不包含长度字段本身,这样解码器好算;消息ID要跟枚举对应,不能直接用字符串,字符串在线上状态里会浪费带宽、也难看日志。
下面是编码器,把ProtocolMessage对象编码成字节流:
public class MessageEncoder extends MessageToByteEncoder<ProtocolMessage> { @Override protected void encode(ChannelHandlerContext ctx, ProtocolMessage msg, ByteBuf out) throws Exception { byte[] jsonBytes = JSON.toJSONBytes(msg.getBody()); // 用fastjson或Jackson都行 out.writeInt(4 + jsonBytes.length); // 长度 = msgId(4字节) + body字节数 out.writeInt(msg.getMsgId()); // 消息ID out.writeBytes(jsonBytes); // 业务JSON } }这里writeInt会写入4个字节,长度字段这样算:4(后面的msgId)+ json长度,正好覆盖后续所有字节。解码端就依赖这个长度值来切分帧。MessageToByteEncoder是Netty下行的出口,它保证每个业务对象都按照同一套规则变成字节流,避免有的地方手写writeBytes、有的忘记写长度。
再看解码端,这是处理粘包半包的关键:
pipeline.addLast(new LengthFieldBasedFrameDecoder( 1024 * 1024, // maxFrameLength 单帧最大值 0, // lengthFieldOffset 长度字段偏移 4, // lengthFieldLength 长度字段字节数 0, // lengthAdjustment 长度调整,因为长度值刚好是剩余字节数 4 // initialBytesToStrip 跳过长度的4个字节 ));参数说明:第一个参数1MB,斗地主的牌面数据不可能超过这个,防止恶意大包打爆内存;偏移0表示长度字段就是帧开头;长度4字节;调整量0是因为我们约定的长度值已经等于后面所有字节数,不需要再加头部长度;initialBytesToStrip=4是让解码完成后自动把长度字段丢掉,上层Handler拿到的就是干净的msgId+JSON。这样粘包时Netty会按长度切出完整包,半包则继续等剩余字节——这就是Netty粘包处理的典型配置。
2.3 解码后的业务分发:用SimpleChannelInboundHandler锁住消息类型
解码完你怎么把字节转回业务对象?常见做法是用SimpleChannelInboundHandler<ProtocolMessage>,它自带自动释放ByteBuf的能力,省得每条消息都手动ReferenceCountUtil.release。下面这个ServerLogicHandler是我惯用的写法:
@Sharable public class ServerLogicHandler extends SimpleChannelInboundHandler<ProtocolMessage> { private final RoomManager roomManager; @Override protected void channelRead0(ChannelHandlerContext ctx, ProtocolMessage msg) { switch (msg.getMsgId()) { case MsgId.LOGIN -> processLogin(ctx, msg); case MsgId.ENTER_ROOM -> roomManager.enterRoom(ctx.channel(), msg); case MsgId.PLAY_CARDS -> roomManager.playCards(ctx.channel(), msg); default -> System.out.println("未识别的消息ID: " + msg.getMsgId()); } } }注意@Sharable注释不是随便加的:只有当这个Handler内部不持有会话相关的实例变量,只用线程安全组件时才可加。这里把房间状态放到RoomManager,消息处理只按连接分发,所以是安全的。每个业务方法里都要重新检查玩家是否真的在这个房间、是否轮到自己出牌,因为网络消息不可信,你永远要假设客户端会发超范围指令。
3. 斗地主核心逻辑:牌型判断与大小比较
3.1 牌型枚举与编码:用byte数组表示54张牌
斗地主算法不复杂,但代码组织不好就会变成一坨if-else。我的做法是先把牌定义成byte:0-51是普通牌,52是小王,53是大王。普通牌按点数分成四组,但大小比较只看点数不看花色。牌型用Java枚举统一管理:
public enum CardType { SINGLE, // 单张 PAIR, // 对子 THREE, // 三张 THREE_ONE, // 三带一 THREE_TWO, // 三带二 STRAIGHT, // 顺子 5张起 PAIR_STRAIGHT, // 连对 3对起 PLANE, // 飞机不带 PLANE_SINGLE, // 飞机带单 PLANE_PAIR, // 飞机带对 BOMB, // 炸弹 ROCKET // 王炸 }这个枚举本身不存值,判断逻辑写在CardPattern类里。把牌按点数降序排序后,统计每个点数的出现次数,是判断一切牌型的基础。比如出现次数只有1的连续点数超过5张,就是顺子;出现次数为2的连续点数超过3组,就是连对;出现次数为3的连续点数超过2组,就是飞机。
3.2 拆牌算法:从候选牌型到最小出牌策略
拆牌意思是:当你是地主且必须出牌时,把手牌拆成一组能压过上一手的最优组合。常见做法是回溯搜索,先找所有能压过当前牌的候选牌型,再计算剩余手牌的评估值。这块就是纯算法,不涉及Netty,但它是斗地主源码里最容易被面试官追问的一块。评估函数可以用手牌剩余张数加权重,剩余张数越少分数越高。
下面是一段判断牌型并返回牌Type的代码,适合嵌入到服务端的校验逻辑里:
public static CardType judgeType(int[] counts, int handSize) { if (handSize == 1) return CardType.SINGLE; if (handSize == 2) { if (counts[52] > 0 && counts[53] > 0) return CardType.ROCKET; // 王炸 if (counts[13] > 0 || counts[14] > 0) return CardType.PAIR; // 大小王不是对子,需特殊处理 int pairCount = countValue(counts, 2); return pairCount == 1 ? CardType.PAIR : CardType.INVALID; } if (handSize == 4 && isBomb(counts)) return CardType.BOMB; if (handSize == 5 && isStraight(counts, 5)) return CardType.STRAIGHT; // 其他牌型继续补充 return CardType.INVALID; }实际中大小王要映射到点数15和16,不能和普通牌混在一起,否则counts[15]会越界。judgeType里我只列出最短路径,真正工程实现还要判断三带一、三带二和连对,判断原则都是一样的:先做点数统计,再检查数量结构是否匹配连续或成对。
3.3 大小比较规则:炸弹和火箭的特殊优先级
牌型相同才比点数,炸弹可以压任何非炸弹牌,火箭压一切。比较逻辑要写在服务端,不能只依赖客户端判断。最省事的做法是给每手牌算一个power值,牌型枚举有序号,点数也有从3到2的序号,组合出来的power就是一个long。比较时先比牌型权重,再比主牌点数。这里有个坑:三带一比较的是三张的点数,不是带的那张;飞机带的单牌也不参与比较。
实现时我推荐把“主牌点数”单独提取,不是简单取最大张。比如三带二,主牌是出现3次的点数。下面这个函数提取主牌值:
public static int mainValue(int[] counts) { for (int i = 15; i >= 3; i--) { if (counts[i] == 3) return i; // 三张或飞机的主体 } for (int i = 15; i >= 3; i--) { if (counts[i] == 2) return i; // 对子和连对的主体 } for (int i = 15; i >= 3; i--) { if (counts[i] > 0) return i; // 单张 } return -1; }这套函数要配好单测:王炸、普通炸弹、同点数炸弹间的大小,以及顺子只有同为5张时才能比。如果你把这些判断写成嵌套if,后面加“癞子”玩法时会非常痛苦,所以一开始就用枚举和mainValue做通用比较。
4. 房间、回合与状态同步:从连接建立到一局结束
4.1 ChannelGroup管理房间:一个房间一张ChannelTable
Netty里房间管理的常见方案是维护一个ConcurrentHashMap<Integer, Room>,Room里保存三个玩家的Channel。顶多再放一个ChannelGroup用来给房间内广播。为什么不用全局ChannelGroup?因为全局广播会把消息发给大厅里的所有玩家,房间内部消息只该发给本房间三个人。
public class Room { private static final AtomicInteger ID_SEED = new AtomicInteger(1000); private final int roomId; private final ChannelGroup players = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); private final int[] playerSeats = new int[3]; private int landlordIndex = -1; private int currentTurn; }DefaultChannelGroup是Netty自带线程安全集合,向组内某个非活跃Channel发送消息时不会抛异常。playerSeats数组记录座位号,currentTurn标记当前轮到谁出牌。你可能会疑惑为什么还要ChannelGroup,直接存Channel[]不行吗?当玩家断线重连,旧Channel要失效、新Channel要加入,用ChannelGroup的remove/add更好管理广播目标。
4.2 状态机驱动回合:叫分、加倍、摸牌、出牌的超时处理
牌局不适合用“回调套回调”的写法,我见过把出牌逻辑写在channelRead0里的,一旦加了超时重发就乱成麻。正确做法是把Room当状态机:状态枚举WAIT_PLAYER, BIDDING, DOUBLE, PLAYING, SETTLED,每个状态只接收对应的消息ID。状态流转可以全放在channelRead0之外的一个RoomService里,Netty线程只负责把消息丢进房间队列,剩下是单线程模型执行。
出牌超时用Netty的ScheduledFuture特别方便:
public void startTurn(Room room) { room.setState(PLAYING); room.setCurrentTurn(room.getNextPlayer()); // 每个玩家思考时间20秒,20秒后自动出最小单牌 ScheduledFuture<?> timeout = ctx.executor().schedule(() -> { Card auto = room.getSmallestPlayableCard(); roomManager.doPlay(room, auto, true); }, 20, TimeUnit.SECONDS); room.setTimeoutTask(timeout); }注意这个ctx.executor()必须属于连接绑定的EventLoop,这样调度任务和消息读取就在同一个线程,不会产生并发问题。玩家出牌后要调用timeoutTask.cancel(false)取消超时任务,否则20秒后还会自动补一张。一个常见翻车点:玩家碰巧在超时那一帧同时出了牌,doPlay会执行两次。解决办法是给每次turn生成一个序列号,出牌时带上turnId,turnId不匹配就拒绝。
4.3 断线重连与心跳:IdleStateHandler的三个时间参数
斗地主中途掉线很常见,你不能直接判负。服务端要给玩家保留房间状态,等客户端用同样的玩家ID重新建立连接后,把当前手牌和场上的牌发给他。心跳用Netty自带类:
pipeline.addLast(new IdleStateHandler( 60, // readerIdleTime 秒,客户端60秒没发数据就判定读空闲 30, // writerIdleTime 秒,服务端30秒没给客户端写数据算写空闲 0 // allIdleTime 不用 ));客户端每个30秒发一个PING,服务端在userEventTriggered里监听到读空闲就关闭连接并触发handleDisconnect。写空闲一般用来给客户端发“房间内有人出牌”的活跃同步,如果30秒没有写入,说明对端可能已经僵死。注意读空闲的计时是“从最近一次收到数据开始”的,不是固定间隔,所以客户端只需要在空闲时主动PING,有牌局消息时自然被重置。
断线重连的代码要放到channelInactive里,不要把房间删除放在那里做,因为重连可能发生在同一个Channel关闭之后。我的习惯是:channelInactive只标记玩家离线,创建一个30秒的延时任务,30秒内没重连才清理房间;重连成功后把这个延时任务取消。这样做的好处是玩家闪退重进不会被踢出房间。
5. 避坑清单:5个让Netty斗地主翻车的边界问题
5.1 客户端快速连发消息导致的半包/队列堆积
现象:快速点击出牌按钮时,服务端偶尔丢消息或解析出乱码。原因是TCP粘包后,一次read里包含多个请求,如果解码器没配好,业务Handler就拿到了一串无法解析的字节流。
原因:我用过一个半路出家的解码器,只按行读取,斗地主这种二进制长度协议根本不该按行分帧。
解决:换成LengthFieldBasedFrameDecoder并保证长度字段计算一致。还要记住LengthFieldBasedFrameDecoder本身只能处理粘包,它输出的每个帧仍然可能是不完整的业务对象,所以我后续还套了一层JSON反序列化,失败就返回错帧。血泪经验:客户端自动重试会让问题更糟,重试前要确保上一个包已经发出并被服务端确认。
5.2 出牌校验放在客户端:被内存补丁一秒钟破解
现象:有人能打出“五张牌的顺子带炸弹”,有人能连续出两次牌。原因很直白:客户端本地记录了手牌,并自己判断“这张牌能不能出”,还把自己选的牌发给服务端。服务端如果没有重新校验手牌和牌型,被破解就是必然的。
原因:为了减少服务端计算,我把牌型校验放在客户端,出牌只传一个字符串,服务端没有反向查该玩家实际手牌。
解决:服务端每次都从Room里的playerCards重新校验:出的牌必须在手牌中、牌型合法、能压过上家。可以先把校验逻辑抽成CardValidator,这个类用前文提到的judgeType和mainValue,两边共用同一套实现。说到这,这也是java面试题里高频考察点:服务端是否信任客户端输入。
5.3 EventLoop里执行阻塞操作
现象:房间人数多时,其他玩家加房响应变慢,甚至整个服务卡住几秒。
原因:我在出牌处理里加了一段日志“落盘到MySQL”,用的是JDBC同步连接。出牌Handler跑在Netty的EventLoop线程上,一个线程要处理大量连接的读事件,一条JDBC查询阻塞,所有排队的连接都遭殃。
解决:把数据库写操作丢进业务线程池。在Handler里拿到消息后,通过roomService.submit(() -> saveAction(roomId, action))异步落库。更干净的方法是整个过程不落库,只在每局结束落一次对局记录。EventLoop里只做内存操作和Channel写入,这是Netty性能的核心纪律。
5.4 ByteBuf没有release
现象:运行一小时后GC飙高,堆外内存报OutOfDirectMemoryError,但对象数量看起来正常。
原因:使用ByteBuf时没读干净,或者手写了解码器但忘记ReferenceCountUtil.release。Netty使用引用计数管理堆外内存,不释放就是泄漏,用Ohc这种堆外缓存也救不回来。
解决:优先用SimpleChannelInboundHandler,它会对入站消息自动释放。如果是自定义Decoder,保证每个ByteBuf都被消费:读完了调用in.skipBytes(in.readableBytes()),然后用in.release()释放。上线前加上-Dio.netty.leakDetection.level=paranoid跑一轮压测,日志里出现LEAK就是泄漏证据。
5.5 断线重连引发的消息风暴
现象:客户端网络一抖动,服务端连续打出几十条“Channel inactive”日志,接着一片重连请求打进来,房间状态被覆盖。
原因:客户端用固定5秒重试,断开后大量连接同时在短时间内SYN,服务端每个新连接都创建Handler并触发登录,导致旧连接和新连接状态互相覆盖。
解决:给重连接口加一个30秒滑动窗口,只有相同玩家ID且相距超过20秒的重连请求才接受。服务端用ConcurrentHashMap<Long, Long>记录每个玩家最近连接时间,收到LOGIN时先校验时间戳。同时channelInactive触发后先取消旧Channel的所有心跳定时器,避免旧连接还往客户端发消息。
6. 从能跑到能上线:压测、监控与面试加分点
验证这套源码能不能顶住真实对战,我用的是两件套。第一件是协议压测:写一个模拟客户端,启动200个并发连接,每个连接随机叫分、出牌,持续压10分钟。关注三个数字:无错误帧比例、服务端CPU占用、内存增速。第二件是断线重连压测:脚本每10秒随机断开50个连接再重连,看房间是否出现错乱。下方是常见压测参考值:
| 指标 | 单人版期望值 | 200人压力下警戒值 |
|---|---|---|
| 帧解码错误率 | 0 | 超过0.1%就要排查粘包 |
| CPU占用 | 10%以内 | 超过70%阻塞EventLoop |
| 堆外内存增量 | 稳定 | 持续增长就是ByteBuf泄漏 |
| 平均出牌响应延迟 | <50ms | 超过200ms要考虑线程阻塞 |
要背的加分点也在这套源码里:LengthFieldBasedFrameDecoder是Java面试题常客,它能从“什么是粘包”一直聊到“最大帧长度设多少”;IdleStateHandler可以接“怎么处理客户端掉线”;ChannelGroup接“服务端如何向指定房间广播”;@Sharable接“单例Handler的线程安全边界”。
再往后扩展,我建议把出牌AI换成在服务端算的简单策略,至少能自动托管掉线玩家;想做得再深就把每局录成JSON回放文件,复盘拆牌正确率。我自己的习惯是每改一轮算法,先跑一万局AI对局验证牌型判断没有错,再做协议压测。这套流程看着慢,但能帮你把这个源码从“能打一局”推到“能持续跑一天不崩”。希望这些思路能帮你在自己的Netty项目里少走几趟弯路。
本文还有配套的精品资源,点击获取