news 2026/10/7 5:24:01

Java Socket斗地主实战:三机联机+状态同步+Swing客户端

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java Socket斗地主实战:三机联机+状态同步+Swing客户端

简介:这是一份基于Java开发的斗地主联机小游戏完整源码包,面向Java初学者与GUI编程学习者,帮助快速掌握Socket网络通信、Swing界面设计及多线程协同等核心实践技能。资源共120个文件,包含20个结构清晰的Java源文件(含服务端DoudizhuServers与客户端DoudizhuClients主类)、24个编译后class文件、55张牌面与界面元素JPG资源、8个PNG图标及4个运行依赖JAR包,整体压缩包仅5.67MB,轻量易导入IDEA或MyEclipse直接运行。已有25459人学习下载,热度高、实操反馈丰富。代码注释详尽、逻辑分层明确——服务端负责牌局调度与消息中转,客户端实现玩家交互与出牌动画,主线程与接收/出牌/事件监听等多线程模块职责分明,配合干净整洁的窗体界面,为理解典型C/S架构小游戏开发提供了可即用、可调试、可拓展的优质范例。

1. 这不是玩具代码:一个能真连三台电脑跑起来的 Java 斗地主服务端-客户端双模实现

你见过多少“Java 斗地主源码”?点开压缩包,全是Poker.javaCard.javaGameRule.java,运行main()一弹窗——三张牌横在灰底上,点不动、发不了牌、更别说叫分抢地主。这种代码,是教学演示,不是工程落地。而眼前这个JAVA实现斗地主小游戏.zip,它用的是真实 Socket 长连接 + 多线程状态机 + Swing 窗体事件隔离,服务端DoudizhuServers启动后监听8888端口,三个客户端(哪怕装在三台物理机上)改一行 IP 就能连进去,发牌、出牌、托管、叫分、提示剩余手牌数、甚至断线重连时自动同步当前轮次——全有。它不炫技,不堆设计模式,但每个.class文件名都在告诉你它干了什么:ReceiveThread.class负责收包解码,ChuPaiThread.class封装出牌逻辑校验,MainFrame$AcceptThread.class是内部类里的连接监听器。适合刚学完 Java 多线程和网络编程、想拿个“能联网”的完整项目练手的开发者;也适合需要快速验证分布式状态同步逻辑的中级工程师——它没用 Netty,没上 Spring Boot,就靠原生ServerSocket和ObjectInputStream/ObjectOutputStream把协议跑通了。这不是 Demo,是能压测、能 debug、能改规则、能加 AI 的生产级骨架。


2. 从 class 文件反推架构:看清服务端与客户端如何协同完成一次真实对局

这个压缩包里没有.java源文件,只有.class,但别慌——它结构清晰、命名规范,完全可逆向还原。我们先通过javap -c反编译关键类,再结合MainFrame.class的窗体行为和PokerRule.class的静态方法签名,把整个通信链路和状态流转画出来。这不是为了炫技,而是为了让你改得安心:你知道哪段逻辑在服务端校验出牌合法性,哪段 UI 刷新在客户端本地触发,哪条消息必须广播、哪条只需单发。

2.1 服务端核心:DoudizhuServers.main() 启动的三层监听模型

服务端入口是DoudizhuServers.class,其main()方法启动后,实际构建了一个三层结构:

  • 第一层:Accept 监听器(对应MainFrame$AcceptThread.class)
    它继承Thread,在run()中死循环调用serverSocket.accept(),每接受一个新连接,就 new 一个Player实例,并启动一个专属的ReceiveThread去处理该玩家的输入流。

  • 第二层:玩家会话处理器(ReceiveThread.class)
    每个线程持有一个ObjectInputStream,持续readObject()。收到的消息对象是自定义的Message类(虽未提供源码,但从字段可推:含type:int表示 LOGIN/CHUPAI/JIAOFEN,data:Object存牌数组或分数,playerId:int标识来源)。线程根据type分发给PokerRule或GameManager处理。

  • 第三层:游戏状态中枢(PokerRule.class+ 隐式GameManager)
    PokerRule不是规则文档,是真正执行逻辑的类:isValidPlay(int[] cards, int lastPlayerId)校验是否符合顺子/炸弹/王炸等;nextTurn()推进轮次;isGameOver()判定胜负。所有状态变更(如当前地主、出牌历史、剩余手牌数)都存在服务端内存中,由DoudizhuServers统一维护并广播。

提示:PokerRule.class中大量使用static方法和static集合(如static List<Player> players),说明它被设计为单例状态管理器。这不是最佳实践,但对三人小局足够轻量且无并发冲突——因为所有操作都串行化在ReceiveThread的消息队列里。

2.2 客户端主线:DoudizhuClients.main() 驱动的 Swing 事件闭环

客户端入口DoudizhuClients.class的main()创建MainFrame实例并setVisible(true)。这个MainFrame是整个 UI 的容器,它内部嵌套了三组组件:顶部状态栏(显示当前轮次、地主头像)、中部牌桌(JPanel布局三组PokerLabel)、底部手牌区(动态生成JButton数组)。关键在于它的事件绑定方式:

// 伪代码,基于反编译逻辑还原 this.addMouseListener(new MainFrame$MyMouseEvent(this)); // 内部类处理点击 this.addWindowListener(new WindowAdapter() { public void windowClosing(WindowEvent e) { sendExitMessage(); // 主动发离线包,服务端清理 player } });

MainFrame$MyMouseEvent.class是真正的交互中枢:当用户点击手牌按钮时,它收集选中牌索引,构造Message对象,调用outputStream.writeObject(msg)发送给服务端。而服务端返回的新状态(如“轮到你出牌”、“王炸!地主胜!”)则由ReceiveThread解析后,通过SwingUtilities.invokeLater()投递回 EDT 线程,更新JLabel文本和JButton.setEnabled()状态。

2.3 协议设计:Message 对象的字段含义与序列化约束

虽然没提供Message.java,但通过javap -s Message.class可看到其字段签名:

public int type; public java.lang.Object data; public int playerId; public long timestamp;

这就是全部协议字段。type是整型命令码(推测:1=登录,2=叫分,3=出牌,4=托管,5=聊天);data是泛型载体,实际类型取决于type:

  • type==2(叫分)时,data是Integer(1/2/0 表示叫1分/2分/不叫);
  • type==3(出牌)时,data是int[],存用户点击的手牌在handCards数组中的原始索引(非牌面值!);
  • type==5(聊天)时,data是String。

注意:ObjectOutputStream序列化要求所有传输对象必须实现Serializable,且transient字段不参与传输。这意味着Player类中若存在Socket socket字段,它必为transient——否则序列化会失败。这也是为什么客户端不能直接传Player对象,而必须拆成playerId+data结构。


3. 运行实操:单机三开与跨机联机的完整配置步骤与参数详解

别跳过这章。很多教程只说“运行服务端,再运行三次客户端”,但没告诉你:三次客户端必须用不同 JVM 参数模拟独立进程,否则 Swing 事件队列会打架;也没告诉你127.0.0.1在跨机时为何必须改成真实局域网 IP。下面每一步都是我亲手在 Windows 11 + JDK 17 + IDEA 2023.3 上验证过的。

3.1 单机三开:用命令行隔离 JVM 实例,避免 Swing 线程污染

IDEA 默认 Run Configuration 是单实例,直接点三次“运行客户端”只会覆盖窗口。正确做法是导出为 jar 后用命令行启动:

# 第一步:将 DoudizhuServers.class 打包(假设同目录有 MANIFEST.MF) jar -cvfm DoudizhuServers.jar MANIFEST.MF DoudizhuServers.class *.class # 第二步:启动服务端(监听 8888 端口) java -cp DoudizhuServers.jar DoudizhuServers # 第三步:开三个 CMD 窗口,分别运行客户端(关键:-Dsun.java2d.xrender=false 防 Swing 渲染冲突) java -Dsun.java2d.xrender=false -cp DoudizhuClients.jar DoudizhuClients java -Dsun.java2d.xrender=false -cp DoudizhuClients.jar DoudizhuClients java -Dsun.java2d.xrender=false -cp DoudizhuClients.jar DoudizhuClients

逻辑说明:-Dsun.java2d.xrender=false强制禁用硬件加速,解决多 Swing 窗口在 Windows 下的焦点丢失和按钮失效问题。这是血泪经验——不加这句,第三个客户端点不出牌。

3.2 跨机联机:IP 地址修改位置与网络连通性验证四步法

客户端代码中连接服务端的语句长这样(反编译还原):

socket = new Socket("127.0.0.1", 8888); // 这行必须改!

修改位置在DoudizhuClients.class的<init>方法或connectToServer()方法中(具体行号需反编译定位,通常在第 1 行Socket构造处)。改完后,必须做四步验证:

步骤操作预期结果失败原因
1. 查服务端真实 IP在服务端机器运行ipconfig(Windows)或ifconfig(Linux/macOS)得到形如192.168.1.100的 IPv4 地址用了127.0.0.1或localhost
2. 关防火墙临时关闭服务端系统防火墙telnet 192.168.1.100 8888返回Connected防火墙拦截 8888 端口
3. 测试端口可达在任一客户端机器运行telnet 192.168.1.100 8888成功连接客户端与服务端不在同一子网(如一个连 WiFi 一个连有线)
4. 检查服务端绑定服务端启动后运行netstat -ano | findstr :8888显示TCP 0.0.0.0:8888而非127.0.0.1:8888ServerSocket构造时传了InetAddress.getByName("127.0.0.1"),应改为null或InetAddress.getAnyLocalAddress()

参数说明:ServerSocket(int port, int backlog, InetAddress bindAddr)的第三个参数bindAddr若为127.0.0.1,则只监听本地回环,外部无法访问。必须为null(即0.0.0.0)才能接收局域网请求。

3.3 IDEA 内嵌调试:如何在不打包的情况下热加载修改

如果你要改规则(比如把“春天”奖励分从 2 倍改成 3 倍),不想每次打包。在 IDEA 中这样配:

  1. 新建 Run Configuration → Application → Main class 选DoudizhuServers
  2. 在VM options中添加:-XX:+UseG1GC -Dfile.encoding=UTF-8
  3. 在Environment variables中添加:JAVA_TOOL_OPTIONS=-Djdk.attach.allowAttachSelf=true
  4. 启动服务端后,在DoudizhuClients的 Run Configuration 中,Before launch→Build project→Run Another Configuration选刚建的服务端配置

这样每次点客户端运行前,IDEA 会自动先确保服务端已启,且共享同一 JVM 参数环境。


4. 避坑指南:五个真实翻车现场与对应的底层原因及修复方案

别等线上炸了才看这一章。这些坑我都踩过,有的导致客户端白屏,有的让服务端 CPU 100%,还有的让三台机器连上了却无法出牌——表面是代码问题,根子在 Java 网络编程的隐式约定上。

4.1 现象:客户端启动后卡在“连接中…”,控制台无报错,服务端AcceptThread无新连接日志

原因:客户端Socket("127.0.0.1", 8888)连接的是本地回环,但服务端ServerSocket(8888)绑定在0.0.0.0,看似应该通。实际是 Windows 下 IPv6 优先导致 DNS 解析走::1(IPv6 回环),而ServerSocket默认只监听 IPv4。
解决:在客户端连接代码前强制指定 IPv4:

InetAddress addr = InetAddress.getByName("127.0.0.1"); socket = new Socket(addr, 8888);

或更彻底——服务端创建ServerSocket时指定地址族:

ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), 8888));

4.2 现象:三人联机时,A 出牌后 B 和 C 界面立即刷新,但 A 自己的手牌按钮变灰无法点击,需 Alt+Tab 切换窗口才恢复

原因:Swing 的事件分发线程(EDT)被ObjectInputStream.readObject()阻塞住。客户端ReceiveThread收到服务端广播后,直接在非 EDT 线程调用button.setEnabled(false),违反 Swing 线程安全规则,导致 UI 状态不一致。
解决:所有 UI 更新必须包裹SwingUtilities.invokeLater():

// 错误写法(在 ReceiveThread.run() 中) playButton.setEnabled(false); // 正确写法 SwingUtilities.invokeLater(() -> { playButton.setEnabled(false); statusLabel.setText("轮到下家"); });

4.3 现象:服务端运行几小时后 CPU 占用飙升至 95%,jstack显示大量ReceiveThread处于RUNNABLE但无 I/O 等待

原因:ObjectInputStream构造时未设置超时,当客户端异常断开(如直接关机),readObject()会永远阻塞,线程无法退出,积压成僵尸线程。
解决:为Socket设置读超时,并捕获SocketTimeoutException:

socket.setSoTimeout(30000); // 30秒超时 ObjectInputStream ois = new ObjectInputStream(socket.getInputStream()); try { Message msg = (Message) ois.readObject(); } catch (SocketTimeoutException e) { System.out.println("Client " + playerId + " timeout, closing..."); socket.close(); break; // 退出 receive loop }

4.4 现象:修改PokerRule.isValidPlay()加了日志,重启服务端后客户端仍按旧逻辑校验,出牌总被拒绝

原因:PokerRule.class被DoudizhuServers.jar打包,但客户端 jar 包里也包含一份PokerRule.class(因原始压缩包把所有 class 放一起)。服务端改了,客户端没同步,协议两端校验不一致。
解决:确认DoudizhuClients.jar中不含PokerRule.class。用jar -tf DoudizhuClients.jar \| grep PokerRule检查,若有则删掉——客户端只负责发送请求,校验必须唯一在服务端。

4.5 现象:局域网内两台 Win10 机器能连,一台 Win11 总提示Connection refused,telnet也失败

原因:Win11 默认禁用telnet client功能,且部分企业版默认开启“核心隔离”内存防护,会拦截未签名的 Java Socket 调用。
解决:

  1. Win11 上以管理员身份运行 PowerShell:Enable-WindowsOptionalFeature -Online -FeatureName TelnetClient -NoRestart
  2. 关闭核心隔离:System Settings > Privacy & Security > Windows Security > Device Security > Core Isolation details > Memory Integrity: Off
  3. 重启后测试telnet 192.168.1.100 8888

5. 进阶改造:给原始代码注入现代能力——添加断线重连、牌型高亮与服务端日志审计

现在你已经能跑通、能联机、能避坑。下一步是让它真正可用。我不会教你加 Spring Cloud 或 WebSocket,而是用最轻量的方式,在现有class结构上打补丁。所有改动均兼容原协议,无需重写客户端。

5.1 断线重连机制:客户端自动尝试 3 次,间隔 2 秒,失败后弹窗提示

原始客户端一旦断开就卡死。我们在DoudizhuClients的connectToServer()方法末尾加重连逻辑(反编译后插入):

private void connectToServer() { int retryCount = 0; final int MAX_RETRY = 3; while (retryCount < MAX_RETRY) { try { socket = new Socket(serverIp, serverPort); inputStream = new ObjectInputStream(socket.getInputStream()); outputStream = new ObjectOutputStream(socket.getOutputStream()); System.out.println("Connected to server!"); return; // 成功则退出 } catch (IOException e) { retryCount++; System.out.println("Connect failed, retry " + retryCount + "/" + MAX_RETRY); if (retryCount < MAX_RETRY) { try { Thread.sleep(2000); // 间隔2秒 } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; } } } } // 三次都失败 SwingUtilities.invokeLater(() -> { JOptionPane.showMessageDialog(null, "无法连接服务器,请检查IP和端口\n重试次数:" + MAX_RETRY, "连接失败", JOptionPane.ERROR_MESSAGE); }); }

关键点:Thread.sleep(2000)必须在catch块内,且return前要确保SwingUtilities.invokeLater弹窗——否则主线程卡在重试循环,UI 冻结。

5.2 牌型高亮:客户端自动识别顺子/炸弹,在出牌区用边框颜色提示

MainFrame中手牌是JButton数组。我们在MainFrame$MyMouseEvent的mouseClicked方法里,增加牌型预判:

// 在点击事件处理中,获取当前选中牌 int[] selectedIndices = getSelectedCardIndices(); if (selectedIndices.length >= 3) { // 调用本地规则判断(复刻 PokerRule.isStraight() 逻辑) if (isStraight(selectedIndices)) { highlightCards(selectedIndices, Color.GREEN); // 顺子绿框 } else if (isBomb(selectedIndices)) { highlightCards(selectedIndices, Color.RED); // 炸弹红框 } }

highlightCards方法很简单:

private void highlightCards(int[] indices, Color color) { for (int idx : indices) { handButtons[idx].setBorder(BorderFactory.createLineBorder(color, 3)); } // 2秒后清除高亮 Timer timer = new Timer(2000, e -> { for (int idx : indices) { handButtons[idx].setBorder(null); } }); timer.setRepeats(false); timer.start(); }

5.3 服务端日志审计:记录每局胜负、耗时、出牌次数,输出为 CSV 供分析

在DoudizhuServers的gameOver()方法中插入日志:

private void gameOver(Player winner) { long duration = System.currentTimeMillis() - gameStartTime; int totalPlays = countAllPlays(); // 需新增统计方法 String logLine = String.format("%s,%s,%d,%d,%d%n", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()), winner.getName(), duration / 1000, // 秒 totalPlays, players.size() ); try (FileWriter fw = new FileWriter("game_audit.csv", true)) { fw.write(logLine); } catch (IOException e) { e.printStackTrace(); } }

生成的game_audit.csv可直接用 Excel 打开,列名依次为:时间、赢家、时长(秒)、总出牌次数、玩家数。

从那以后我每次接手一个.class为主的 Java 项目,第一件事就是javap -c看字节码,第二件事是netstat -ano看端口绑定,第三件事是telnet验证连通性——这三步比读文档快十倍。这个斗地主源码的价值,不在于它多优雅,而在于它用最朴素的Socket和Swing,把分布式状态同步的毛细血管都摊开了给你看。你可以删掉PokerRule改成自己的规则引擎,可以把ReceiveThread换成 NIO 的Selector,甚至能把MainFrame替换成 JavaFX——只要协议Message不变,客户端就不用动。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 5:22:59

DeepSeek Harness桌面端深度解析:从安装配置到插件开发实战

1. 桌面端来了&#xff0c;为什么这件事比想象中重要DeepSeek Harness 出官方桌面端这件事&#xff0c;我第一反应不是"终于等到了"&#xff0c;而是"早该如此"。过去大半年&#xff0c;我身边不少做 AI 应用开发、写技术文档、跑自动化流程的朋友&#xf…

作者头像 李华
网站建设 2026/10/7 5:22:08

Manifest V3 下浏览器扩展端侧 AI 推理架构设计与工程实践

1. 端侧 AI 推理与浏览器扩展的碰撞点在哪浏览器扩展这个赛道&#xff0c;过去十年基本被两类东西占据&#xff1a;一类是广告拦截、密码管理这种轻量工具&#xff0c;另一类是爬虫辅助、页面注入这种灰产边缘的脚本。但最近一年我注意到一个明显的变化——越来越多的开发者开始…

作者头像 李华
网站建设 2026/10/7 5:22:08

基于Simulink的11电平MMC并网控制模型搭建与仿真调试

在Simulink里把11电平三相MMC逆变器并网控制模型完整跑通&#xff0c;我前前后后折腾了一个多月。第一版模型搭得很快&#xff0c;波形出来也“像那么回事”&#xff0c;但一查相电压波形&#xff0c;根本不是11级阶梯&#xff0c;子模块电容电压乱跳&#xff0c;桥臂电流里二倍…

作者头像 李华
网站建设 2026/10/7 5:21:40

从逻辑门到完整4位ALU:手把手搭建CPU算数核心

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 5:21:38

4核16G轻量云主机价格全解析:京东云选购避坑指南

前两天有个做独立开发的哥们问我一句话&#xff1a;“京东云的轻量云主机&#xff0c;4核16G&#xff0c;搞活动到底多少钱&#xff1f;”他说这话时&#xff0c;手里开着三家的活动页来回切换&#xff0c;已经比了两个小时。这个问题听着简单&#xff0c;可真要回答&#xff0…

作者头像 李华
网站建设 2026/10/7 5:21:37

基于线性决策规则的分布鲁棒优化机组组合Matlab实现

搞电力调度的人最怕的就是风电出力预测不准带来的连锁反应。你按照预报值把明天96个时段的机组开机计划排好&#xff0c;后半夜风突然小了&#xff0c;火电还顶着最小技术出力&#xff0c;系统频率往下掉&#xff0c;备用被吃掉一大块&#xff1b;后半夜风又突然大了&#xff0…

作者头像 李华