简介:这套基于Java语言开发的多人联机飞机游戏项目,完整提供客户端与服务器端两套源码,面向有一定Java基础的开发者,适合学习网络编程、多线程同步、GUI界面设计及Socket通信等游戏开发核心技能。项目共25个文件,包含9个Java类文件、8个Java源文件,另有项目配置文件、classpath、偏好设置、授权协议及说明文档等辅助材料,压缩包仅36KB,结构清晰便于快速上手。已有319人学习浏览。客户端负责界面渲染与用户交互,服务器端承担游戏逻辑与状态同步,分层设计思路值得借鉴。通过研究此项目,读者能掌握多人联机游戏从网络通信到界面呈现的完整实现路径,理解Java在游戏开发中的实际应用,为后续独立开发积累可复用的实践经验。
1. 为什么要拆这套 Java 多人飞机游戏:三个值得带走的骨架
做 Java 游戏练手项目的人很多,能跑通单机版的也不少,但一到“多人联机”这一步,大多数人会卡死在同一个地方:两个客户端各画各的飞机,谁也看不见谁。这套基于 JAVA 语言开发的多人联机飞机游戏客户端及服务器端设计源码,正好把这个痛点完整趟了一遍。它不是一个画面多华丽的成品游戏,而是一套把“客户端 + 服务器端”拆开的联机框架——服务器管状态与转发,客户端管渲染与输入,中间用 Socket 通信串起来。对想搞懂 Java 网络编程、Swing 界面、多线程同步在实际项目里怎么配合的人来说,它比看一百篇碎片教程都直观。适合正在做课设、想补联机游戏知识盲区、或者打算在这套骨架上改出自己游戏的开发者。接下来我按“架构 → 联机机制 → 界面 → 避坑 → 进阶”的顺序把这份源码讲透。
2. 客户端与服务器端分工:先把线程模型画对,再谈 Socket 协议
2.1 为什么必须拆成两个独立进程
这套源码最值得学习的一点,是它从结构上就把客户端和服务器端做成了两个独立工程。JFlyServer目录下有自己的src、bin、.classpath,JFlyClient目录下同样如此,两者只通过 Socket 通信,不共享内存,不直接调方法。
这个设计和单机游戏有本质区别。单机游戏里,玩家飞机的位置、子弹的碰撞、分数的累加都在同一个进程内完成,变量随手可取;联机游戏里,每个玩家都运行在自己电脑上,你永远无法直接读取别人的内存。唯一能做的就是“把自己发生的事告诉服务器,再由服务器转告其他人”,这个“告诉”和“转告”就需要一套通信协议,而协议必须建立在清晰的线程模型之上。
常见做法是把服务器端拆成三个层次:监听线程、客户端处理线程、游戏逻辑线程。监听线程只负责 accept 新连接;每来一个玩家,就为它单独开一个处理线程,专门收发这个玩家的消息;游戏逻辑则集中在主循环里统一推进,所有玩家消息进来后先入队,逻辑线程再从队列里取。
2.2 Socket 连接的建立与参数说明
下面这段代码是这套源码里客户端连接服务器最核心的一段,也是所有后续通信的前提。实际项目中我一般会把它单独封装成一个NetService类,避免和界面代码耦合在一起。
// NetService.java public class NetService { private Socket socket; private BufferedReader reader; private PrintWriter writer; private volatile boolean connected = false; public boolean connect(String host, int port) { try { socket = new Socket(); // 连接超时设 3 秒,避免服务器没起时界面卡死 socket.connect(new InetSocketAddress(host, port), 3000); reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8")); writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); connected = true; return true; } catch (IOException e) { connected = false; return false; } } }这段代码从“能连上”和“连不上不死等”两个角度做了处理。socket.connect的第二个参数3000是连接超时,单位毫秒。如果服务器没启动或者 IP 不通,默认的new Socket(host, port)会一直阻塞很久,在 Swing 界面里直接表现为窗口无响应。设了这个超时后,3 秒内连不上就返回失败,UI 层可以弹出提示而不是卡死。PrintWriter第二个参数true表示自动刷新,每次println后立即发送,对实时性要求高的联机游戏是必要的;如果是批处理类通信,可以改成false手动 flush。
服务器端对应的 accept 循环长这样:
// 服务器端:每来一个客户端就开一个线程 ServerSocket serverSocket = new ServerSocket(8888); while (running) { Socket client = serverSocket.accept(); ClientHandler handler = new ClientHandler(client); Thread t = new Thread(handler); t.setDaemon(true); t.start(); }setDaemon(true)很重要。如果不设成守护线程,主程序退出时这些线程还会拖着进程不结束,导致 IDE 里反复“重新启动”失败,或者命令行下 Ctrl+C 都无法干净退出。联机服务器一般会一直运行,但开发调试阶段,守护线程能省掉很多“端口被占用”的麻烦。
2.3 收发线程分离:接收线程独立是底线
很多入门项目在客户端直接写while ((line = reader.readLine()) != null)放在主线程里,结果就是一收消息界面就冻结。这套源码的正确性在于,它把“读服务器消息”放进了独立线程,主线程只负责渲染和响应键盘。
// 客户端接收线程 Thread receiveThread = new Thread(() -> { String line; try { while (connected && (line = reader.readLine()) != null) { onMessage(line); // 回调到主界面线程 } } catch (IOException e) { // 连接断开,进入重连流程 } }); receiveThread.start();为什么一定要独立线程?因为readLine()是阻塞方法——没有消息到来时它会一直等在那里。如果放在主线程里,等消息的这个过程会堵住键盘响应和画面重绘。放在独立线程里后,主线程可以从容地跑 Swing 事件循环。但这里又引出一个新的问题:onMessage回调跑在接收线程里,不能直接操作 Swing 组件,必须通过SwingUtilities.invokeLater切回事件分发线程。这是 Java GUI 编程里最容易踩的坑,后面避坑章节我会专门展开。
2.4 协议设计:为什么用文本行而不是对象序列化
协议是整个联机游戏的“共同语言”,客户端和服务器两边必须用同一种格式解析消息。这套源码最简朴的可行方案是:每条消息一行文本,字段之间用分隔符隔开,比如MOVE|player1|120|80,SHOOT|player1|130|85。
相对ObjectOutputStream直接传对象,文本行协议有三个不可替代的好处。第一,可调试——你可以在服务器控制台直接打印收到的原始字符串,一眼看出哪个字段丢了;对象序列化是二进制,没法肉眼排查。第二,跨版本兼容——客户端和服务器端即使不是同一次编译,只要字段顺序不变就能通信;对象序列化对类版本极其敏感,加一个字段旧客户端直接反序列化失败。第三,容易做流量控制——一条MOVE消息大约 30 字节,而序列化一个对象动辄几百字节,飞机游戏需要高频同步位置,流量差距非常可观。
消息字段的顺序需要固定,我一般习惯用类型|玩家ID|参数1|参数2的格式,解析时直接split("\\\\|")。这套源码如果要做扩展,建议保留这个格式,新增消息类型时只加新的分支,不要改动旧格式。
提示:不要在客户端做游戏逻辑裁决。所有“子弹是否命中”“谁先得分”这类判断,都应该由服务器端完成,客户端只负责上报操作和渲染结果。否则高延迟玩家会获得明显优势,这是联机游戏常见的作弊根源。
3. 联机玩法三件套:位置同步、子弹管理与碰撞判定的实现细节
3.1 位置同步:全量同步还是增量同步
飞机游戏里每个玩家都要看到其他飞机的实时位置,位置同步是所有联机玩法的地基。最朴素的做法是每帧把每个玩家的 x、y 坐标全量广播出去,但这在玩家多起来后带宽会迅速吃紧。5 个人 × 每帧 30 字节 × 30 帧 / 秒,每秒就是 4.5KB,看起来不多,但加上子弹、爆炸效果、状态变更后,量级会翻好几倍。
这套源码采用的更合理思路是“变化时同步”与“周期同步”结合。每个客户端本地维护自己的飞机位置,只有位置变化超过阈值才向服务器发送消息;服务器收到后转发给其他客户端;同时每 200ms 强制全量同步一次,用于校正累积误差。
// 位置同步消息体 public class MoveMsg { public String playerId; public int x; public int y; public long timestamp; } // 发送侧:超过阈值才发 public void sendMoveIfNeeded() { int dx = currentX - lastSentX; int dy = currentY - lastSentY; if (dx * dx + dy * dy > THRESHOLD_SQUARE) { // 位移平方和超过阈值 send("MOVE|" + playerId + "|" + currentX + "|" + currentY); lastSentX = currentX; lastSentY = currentY; } }阈值THRESHOLD_SQUARE的选择是个平衡题。设太大会导致其他玩家看到的飞机一顿一顿的,设太小则消息量爆炸。我一般取 4~9 个像素的平方,也就是飞机位移超过 2~3 像素才发一次。这样 30 帧里真正发消息的帧大约只有 1/3,带宽压力直接降一个量级。
接收侧要做插值,否则看到的其他飞机位置也是跳变的。最简单的插值是线性插值:记录上一个已知位置和当前已知位置,在两次消息之间按时间比例补中间帧。接收侧代码如下:
// 接收侧:线性插值平滑 public void onMove(String[] fields) { String playerId = fields[1]; int targetX = Integer.parseInt(fields[2]); int targetY = Integer.parseInt(fields[3]); PlayerRemote p = players.get(playerId); p.prevX = p.renderX; p.prevY = p.renderY; p.targetX = targetX; p.targetY = targetY; p.updateTime = System.currentTimeMillis(); } // 渲染时按时间比例取中间值 public void updateRender(PlayerRemote p) { long elapsed = System.currentTimeMillis() - p.updateTime; float ratio = Math.min(1.0f, elapsed / 50.0f); p.renderX = (int) (p.prevX + (p.targetX - p.prevX) * ratio); p.renderY = (int) (p.prevY + (p.targetY - p.prevY) * ratio); }50.0f是插值窗口,单位毫秒。如果消息每秒 20 次,那么 50ms 恰好覆盖两帧间隔;如果网络抖动导致消息晚到,Math.min会保证播放完最后一段后停在目标位置,不会飘出边界。这个值要根据实际同步频率调,频率低就适当加大,频率高就减小,否则会出现“飞机在自己身后”的错觉。
3.2 子弹管理:对象池避免 GC 卡顿
子弹是飞机游戏里生命周期最短的对象,创建和销毁最频繁。如果每发子弹都new一个对象,射完再等 GC 回收,Java 的垃圾回收停顿就会周期性地卡一下游戏画面。这套源码里比较合理的做法是引入子弹对象池。
// BulletPool.java 简化版 public class BulletPool { private final Queue<Bullet> pool = new LinkedList<>(); private static final int MAX_POOL_SIZE = 64; public Bullet obtain(float x, float y, float vx, float vy) { Bullet b = pool.poll(); if (b == null) { b = new Bullet(); } b.reset(x, y, vx, vy); return b; } public void recycle(Bullet b) { if (pool.size() < MAX_POOL_SIZE) { pool.offer(b); } } }LinkedList做池的好处是取和还都是 O(1),MAX_POOL_SIZE限制了池的最大容量,防止长期运行时池子无限膨胀。reset方法把对象的坐标、速度和存活状态清一遍,复用一个旧对象而不是新建。GC 压力最大的往往是短生命周期对象,池化后堆内存分配量骤减,就算帧率不高的机器也能保持稳定输出。
注意一个细节:recycle时要先把子弹从游戏场景中摘除,再还回池里,否则会出现“屏幕上子弹消失了但碰撞检测还在计算”的灵异现象。顺序是:标记死亡 → 执行碰撞判定时跳过 → 从绘制列表移除 → 归还池。
3.3 碰撞判定:先粗筛后细判
碰撞判定如果对每个子弹和每架飞机都做精确计算,复杂度是 O(子弹数 × 飞机数),一梭子弹打出去几十发,三五个玩家时就到了每帧几百次计算,虽然不至于卡,但为了稳妥,工程上会加一个粗筛阶段。
// 碰撞判定:先判断包围盒,再算圆心距离 public boolean hitTest(Bullet b, Plane p) { // 粗筛:包围盒 if (Math.abs(b.x - p.x) > 40) return false; if (Math.abs(b.y - p.y) > 40) return false; // 细判:圆心距 int dx = b.x - p.x; int dy = b.y - p.y; return dx * dx + dy * dy < HIT_RADIUS_SQUARE; }HIT_RADIUS_SQUARE是命中判定半径的平方,具体数值取决于游戏里飞机的实际贴图大小。粗筛的40是飞机贴图最大半径 + 一个冗余量,用绝对值和比较代替了开平方。这个优化在单机里看不出来,但服务器端要同时处理多个玩家和大量子弹时,能省下可观的 CPU 时间。
3.4 服务器端状态管理:权威服还是客户端预测
前边提到“服务器端做裁决”,这里展开说为什么。如果每个人在自己电脑上判定“我打中了你”并把结果发给服务器,那高延迟玩家的消息总是后到,但在自己的判定里却先发生,就会出现“明明我先打中他,他先死了”的争议。解决方案是服务器端权威:客户端只上报“我在什么位置发射了子弹”,服务器统一计算所有子弹与所有飞机的碰撞,再把结果广播下去。
代价是服务器实现复杂度上升,而且玩家自己会感觉到“我发射子弹后,命中提示延迟了 RTT/2”。为了抵消这个延迟,客户端可以做“预测”——本地立刻把子弹打出去,渲染上也立刻出现弹道,但最终命中与否以服务器广播为准,本地预测的结果只是视觉反馈。这套源码没做到预测那么深,但从头实现服务器裁决已经是正确方向,预测是它的后续优化项。
注意:飞机游戏的服务端主循环里,计算量一定要和消息处理解耦。消息收发是 IO 操作,碰撞判定是 CPU 操作,两者混在一起会导致 IO 等待阻塞判定,判定阻塞又导致消息积压。分开线程后用队列交接,是保证高并发下不翻车的基本要求。
4. 基于 Swing 的渲染循环:从按键响应到窗口重绘的完整链路
4.1 为什么用 Swing 而不是 JavaFX
这套源码的界面基于 Java 的 AWT/Swing 技术栈。在 JavaFX 出现后,很多人会觉得 Swing 过时,但放在“学习用”和“课设用”两个场景里,Swing 有三个不可替代的理由。第一,JDK 自带,不需要额外引入依赖,解压即跑;第二,Swing 的事件模型和 AWT 一脉相承,理解了它再看 JavaFX 的 Listeners 是一马平川的事;第三,它的paintComponent重绘机制简单直接,很适合理解“渲染循环”的本质——清屏、画图、等待下一帧。
界面的代码结构遵循 MVC 的简化版:模型是飞机坐标、子弹列表;视图是画布;控制器是键盘监听器。三者之间的数据流是单向的——键盘事件修改模型,模型变化触发重绘,重绘读模型生成画面。这套思路理清楚后,换成 JavaFX 或 LibGDX 只是换 API 的事,底层逻辑完全一致。
4.2 键盘响应:KeyBinding 优先于 KeyListener
Swing 里监听键盘有两种常见姿势:KeyListener和KeyBinding。KeyListener需要组件获得焦点才能收到事件,如果界面上有多个组件,焦点一转移,按键就失灵了。这是键盘控制类 Swing 程序里最高发的“玄学问题”——明明代码没问题,按下去就是没反应。
更稳的做法是使用KeyBinding,它注册在JComponent上,不依赖焦点:
// GameCanvas.java 键盘绑定 InputMap im = getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap am = getActionMap(); im.put(KeyStroke.getKeyStroke("LEFT"), "moveLeft"); im.put(KeyStroke.getKeyStroke("RIGHT"), "moveRight"); im.put(KeyStroke.getKeyStroke("SPACE"), "shoot"); am.put("moveLeft", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { plane.vx = -MOVE_SPEED; } });WHEN_IN_FOCUSED_WINDOW的含义是“只要这个窗口是当前活动窗口,按键就生效”,彻底摆脱了焦点问题。MOVE_SPEED是每帧移动的像素数,一般设 3~5。需要注意松开按键后要把速度归零,否则飞机会持续朝一个方向飘。松键事件在KeyBinding里用KeyStroke.getKeyStroke("released LEFT")注册,和处理按下逻辑一一对应。
4.3 渲染循环:Timer 驱动还是线程驱动
Swing 的重绘不能用while(true) { repaint(); }死循环,因为repaint()只是发出重绘请求,真正绘制是在事件分发线程上执行的。如果用独立线程疯狂调用repaint(),事件队列会被重绘请求灌满,键盘响应全部排队,游戏操作感会明显变肉。
常见做法是用javax.swing.Timer定时触发重绘,帧率可控且不会阻塞事件线程:
// GameCanvas.java 构造器中的定时器 Timer timer = new Timer(16, e -> { // 约 60 FPS updateGame(); // 1. 更新游戏逻辑:移动飞机、推进子弹、检测命中 repaint(); // 2. 请求重绘画布 }); timer.start();16毫秒对应约 60 FPS。如果游戏逻辑较重导致每帧超过 16ms,不要调小这个值,否则事件线程会积压。更合理的做法是把updateGame放到独立线程,但这时就必须注意数据竞争。Swing 的绘制线程和逻辑线程同时读写飞机坐标,可能画出半个位置的飞机。加synchronized保底,或者把坐标改为volatile修饰、绘制时做一次快照,都能解决。
4.4 双缓冲与画面撕裂
paintComponent是所有 Swing 绘制的核心方法,这套源码里的战斗场景就靠它渲染。Swing 本身默认开启了双缓冲,所以不会出现 AWT 时代常见的画面闪烁。但有个细节容易被忽视:repaint()请求的是整个组件区域的重绘,如果整个画布都重绘,开销大且效率低。
// GameCanvas.java 核心绘制 @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; // 开启抗锯齿 g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 绘制所有玩家飞机 for (PlayerRemote p : players.values()) { g2.drawImage(planeImage, p.renderX, p.renderY, null); } // 绘制所有子弹 for (Bullet b : activeBullets) { g2.fillRect(b.x - 2, b.y - 1, 4, 2); } // 绘制分数 g2.drawString("SCORE: " + score, 10, 20); }需要特别留意的是Graphics2D的字体平滑:绘制文字时如果不设置KEY_TEXT_ANTIALIASING,分数在低分辨率下会有锯齿感;绘制图片时则要注意drawImage的最后一个参数null是ImageObserver,在游戏画布里传this更好,图片未加载完成时会自动触发重绘。这些细节不一定影响功能,但直接影响“这游戏是不是个认真做的作品”的第一印象。
5. 联机必踩的五个坑:连接失败、粘包与延迟抖动的排查记录
5.1 坑一:客户端连不上服务器,界面卡死无提示
现象:双击客户端后窗口能打开,但点“开始游戏”就一直转圈,最后整个窗口无响应,只能从任务管理器杀掉。
原因:最常见的是服务器没先启动,或者端口被防火墙挡了。但更隐蔽的原因是客户端new Socket(host, port)没有设连接超时,服务器 IP 不可达时,Socket 构造会阻塞十几秒到几十秒不等,界面线程被占死。
解决:所有连接操作一律放进子线程,并用connect(addr, timeout)设置超时。UI 层先显示“连接中”,超时后弹提示并恢复界面。排查时先在本机telnet 127.0.0.1 8888验证端口通不通,再用netstat -ano | findstr 8888看服务器有没有监听。我后来固定了一套流程:先起服务器、再看监听、最后起客户端,杜绝了大多数“连不上”的假警报。
5.2 坑二:粘包与半包导致消息错乱
现象:客户端收到的消息有时候一行是完整的MOVE|p1|100|50,有时候两行黏在一起变成MOVE|p1|100|50MOVE|p1|102|50,有时候一行只有半截。解析时直接NumberFormatException崩溃。
原因:TCP 是字节流协议,没有消息边界。发送方写了两条消息,接收方可能一次读到全部,也可能分两次读。用readLine()虽然以换行符为边界,但如果网络拥塞或对方write和flush间隔过大,仍然可能把两条消息塞进同一个缓冲区。
解决:在应用层约定消息必须以换行符结尾,并在解析前对缓冲区做完整性校验。更稳妥的做法是给每条消息加长度头,比如前 4 字节是大端序的消息长度,读完长度再读对应字节数。但飞机游戏这类简单场景,用换行符加“解析失败就丢弃该行”的策略足够。关键是要保证发送端每条消息只println一次,不要分段写。
5.3 坑三:延迟抖动导致飞机“飘移”
现象:别人的飞机在某次同步间隔里突然从屏幕左边瞬移到右边,或者明明按了停止键,飞机还在往一个方向滑。
原因:局域网环境下延迟通常低于 10ms,问题不大,但一旦跨网络或有 Wi-Fi 丢包,MOVE消息之间的间隔就会拉长。接收侧如果按收到消息的先后直接设置目标位置,前一条消息还没播完,后一条已经到了,插值表现就是瞬移。服务器端如果不做位置校正,客户端之间会长期偏差。
解决:第一,插值窗口updateTime改用服务器时间戳而不是本地收到时间;第二,服务器在转发MOVE时带上原始时间戳,客户端按时间戳计算插值比例。第三,降低全量同步频率,从 200ms 放宽到 300ms,给网络抖动留出余量。这三个参数调试的依据很简单——看其他玩家飞行是否平滑,不平滑就查消息到达间隔的方差,不只看平均值。
5.4 坑四:GC 停顿导致画面周期性卡顿
现象:游戏运行十几分钟后,画面每隔几秒卡一下,每次大约 200ms,随后恢复。卡顿频率逐步变高。
原因:子弹对象无节制地new和丢弃,短生命周期对象大量堆积,老年代频繁变满,触发 Full GC。用 jstat 查看 GC 日志会看到FGC次数持续上涨。
解决:把子弹改成对象池,并且确保子弹离开屏幕边界时立即归还池子。还有一个小细节:不要每帧都调用getGraphics()或创建Graphics2D对象,组件的Graphics对象应该从paintComponent参数里拿。审计代码时多找“循环内 new 对象”的模式,这样一个一个排查掉,GC 卡顿会自然消失。
5.5 坑五:退出程序后端口仍被占用
现象:改完代码想重启服务器,报java.net.BindException: Address already in use,起不来。
原因:前一个服务器进程没有完全退出,尤其是非守护线程还挂在accept()上。IDE 里停止程序往往只停主线程,后台处理线程因为是普通线程仍然存活。
解决:处理线程全部setDaemon(true),主类里加Runtime.getRuntime().addShutdownHook()兜底清理。开发时如果已经出现端口占用,用jps找到 Java 进程 PID,再taskkill /F /PID杀干净。更好的习惯是ServerSocket设置setReuseAddress(true),但这不是根本解,根子在于线程模型要规范。
注意:上面五条里,粘包问题是新人最难看懂的,因为它不会稳定复现,时好时坏。遇到“消息偶尔正常偶尔乱”的情况,优先怀疑的不是逻辑而是协议边界。
6. 把 demo 升级成可维护的联机框架:协议可观测性与压力验证
源码拿到手能跑通只是第一步,真正让这套骨架变成自己东西的,是给它加上“可观测”能力。联机游戏比单机难调试一百倍,因为它有两台以上机器在同时跑,出问题没法单步断点。我的习惯是在服务器端加两层日志:消息层日志和连接层日志。
消息层日志打印每次收发的原始协议文本。客户端发来的每一条消息,服务器端按“收到|方向|字段”格式打印;转发出去时打印“广播|目标数|字段”。这样当你发现玩家 A 的飞机不动时,一翻日志就能知道是 A 没发消息,还是发了但服务器没转发,还是转发了但 B 没收到——三层里哪一层断了立刻清楚。注意不要在生产环境全量打印,调试时才打开DEBUG级别的开关。
连接层日志记录每个客户端的连接建立、断开和心跳超时。心跳机制是联机游戏的安全网:服务器定期向客户端发PING,客户端回PONG,如果连续 5 次无回应,服务器判定该玩家掉线,广播LEAVE消息让其他人把他的飞机移除。建议心跳间隔 3 秒、超时阈值 5 次,局域网络环境下这个参数不会误杀,跨公网时可以放宽到 8 次。
验证这套框架扛不扛得住多人,最直接的办法是写一个压力模拟脚本。开 20 个线程模拟 20 个客户端连进来,每个线程随机发送移动和射击消息,跑 5 分钟观察服务器 CPU 占用和消息丢失率。下面是一个简化版的多客户端模拟器:
// Simulator.java 模拟多客户端压测 ExecutorService pool = Executors.newFixedThreadPool(20); for (int i = 0; i < 20; i++) { final int id = i; pool.submit(() -> { try (Socket s = new Socket("127.0.0.1", 8888)) { PrintWriter out = new PrintWriter(s.getOutputStream(), true); BufferedReader in = new BufferedReader( new InputStreamReader(s.getInputStream(), "UTF-8")); Random rnd = new Random(); for (int j = 0; j < 3000; j++) { out.println("MOVE|p" + id + "|" + rnd.nextInt(500) + "|" + rnd.nextInt(400)); Thread.sleep(10); } out.println("QUIT|p" + id); } catch (Exception e) { System.err.println("Client " + id + " error: " + e.getMessage()); } }); }3000是每个客户端发送的消息条数,配合Thread.sleep(10)模拟约 100 条 / 秒的发送频率。如果 20 个客户端全部跑完,服务器没有累积延迟、没有报异常,协议这一层就算合格。如果中途出现Broken pipe,说明服务器处理不过来关闭了连接,优先排查线程池大小和消息队列积压情况。如果出现消息计数对不上,重点检查广播环节是否遗漏了某个客户端。
最后说一个我的个人习惯:每次改完协议字段,我会强制走一遍“连接 → 心跳 → 并发 → 退出清理”四步检查——先单客户端跑,再双客户端互看位置,再压测脚本打一轮,最后退出程序确认端口释放成功。四步全绿才提交代码。这个习惯救过我很多次,尤其“退出清理”这一步,往往在别人机器上才会暴露问题。希望这套源码和这篇拆解笔记能帮你在 Java 联机游戏这条路上少踩几个坑,把更多时间花在做出真正好玩的玩法上。
本文还有配套的精品资源,点击获取