
简介这套基于 Java 实现的多人联机飞机游戏源码包含客户端与服务器端完整设计面向具备一定 Java 基础、希望深入掌握网络编程与游戏架构的开发者。项目采用客户端/服务器分离架构客户端负责图形界面、输入响应和服务器通信服务器端负责游戏逻辑、状态同步与多玩家并发管理能够完整体现 Socket 编程、多线程处理、GUI 事件驱动等关键技术的实际用法。压缩包共 25 个文件以 Java 源文件和编译后的 class 文件为主另含 classpath、project、prefs 等工程配置以及授权协议和说明文档整体仅 36KB结构紧凑、便于速览。目录按客户端与服务器端分模块组织启动入口清晰可依次追踪界面绘制、指令发送、服务器端逻辑处理、结果广播与状态更新等环节形成完整闭环。已有 319 人学习使用适合作为 Java 网络游戏入门的研读范例也可为后续扩展或自主开发多人游戏提供可复用的框架思路。1. 这个JAVA双端飞机游戏源码先搞清楚它到底给你什么如果你搜到“基于JAVA语言开发的多人联机飞机游戏客户端及服务器端设计源码”说明你想要的不只是“一个能动的飞机”而是一套能跑通联机闭环的完整工程。这类源码通常由一个客户端程序、一个服务器端程序、一份通信协议和若干资源文件组成解决的问题很直接让多个玩家在各自电脑上操作飞机通过服务器中转在各自的屏幕上看到彼此的位置、旋转和攻击动作。对java开发工程师来说它的价值密度比单机版飞机游戏高得多——你要同时处理网络线程、数据同步、状态管理等平时 CRUD 里碰不到的东西。说个反直觉的结论这个项目最难的部分根本不在“画飞机”而在“让两台电脑上的飞机看见同一片天空”。屏幕上那个小小的机身背后是消息序列化、心跳保活、坐标插值和断线重连层层叠加的结果。适合拿来当课设、毕设也适合想补网络编程短板的java基础学习者。接下来的内容我按最常用的 TCP 长连接 状态同步方案展开告诉你每一步怎么落地、参数怎么设、坑在哪。2. 客户端与服务端的通信设计消息协议、粘包拆包和线程模型2.1 用一条“起飞指令”定义消息协议消息类型、字段和长度前缀多人联机飞机游戏里客户端和服务器端传的不是“飞机画面”而是一串串结构化消息。设计这套消息协议我一般从一条最简单的指令开始——客户端告诉服务器“我要起飞”。先定义消息类型枚举和通用消息类。public enum MsgType { LOGIN(1), // 登录 TAKE_OFF(2), // 起飞 POSITION(3), // 位置同步 HEARTBEAT(4), // 心跳 HIT(5), // 攻击命中 LOGOUT(6); // 退出 public final int code; MsgType(int code) { this.code code; } public static MsgType fromCode(int code) { for (MsgType t : values()) { if (t.code code) return t; } return null; } }public class GameMessage { private int type; // 对应 MsgType.code private int length; // 包体长度用于拆包 private byte[] body; // 包体内容JSON 或自研二进制 public static byte[] encode(int type, byte[] body) { ByteBuffer buf ByteBuffer.allocate(4 4 body.length); buf.putInt(type); buf.putInt(body.length); buf.put(body); return buf.array(); } }这段代码的逻辑很直白前 4 字节存消息类型中间 4 字节存包体长度后面是真正的数据。为什么需要 length 字段因为 TCP 是流式协议它不保证你一次 read 就能拿到一条完整消息可能收到半条也可能一次收到好几条。没有 length 就不知道从哪里切分这就是粘包拆包问题的根源。实际项目里我一般不会用 Java 自带序列化而是用 JSON 或 Protobuf。JSON 可读性好调试方便Protobuf 体积小、解析快适合消息频率高的联机游戏。参数上注意一条单条消息的 body 建议限制在 4KB 以内位置同步消息通常不到 200 字节如果出现超过 1MB 的包体先怀疑是不是有人在协议层传了资源文件——这是很常见的误用。2.2 服务器端线程模型为什么飞机游戏不能一连接一线程很多新手拿到这套源码第一反应是每个客户端连接开一个线程while 循环里 read。这在 2 个玩家时没问题但联机游戏一开房间可能就是 8 个人、16 个人加上每个连接的心跳包和位置上报一线程一连接的写法很快就会把 JVM 的线程栈耗尽。我常用的做法是 NIO 多路复用 线程池核心代码简化如下。ExecutorService bizPool Executors.newFixedThreadPool(4); // 业务线程池 ConcurrentLinkedQueueGameMessage msgQueue new ConcurrentLinkedQueue(); // 伪代码selector 线程负责读就绪事件读到完整消息后丢进队列 void onReadable(SocketChannel ch) { GameMessage msg protocolDecoder.decode(ch); if (msg ! null) { msgQueue.offer(msg); bizPool.submit(() - dispatch(msg)); } }关键点是“IO 线程不做业务”所有消息先入队由 4 个业务线程消费。线程数为什么设 4 而不是 8因为飞机游戏的消息处理都很轻量——改坐标、转发、判断碰撞——大多数时间花在等待锁和内存分配上4 个线程已经能让 CPU 保持在合理水位。如果你发现业务线程长期跑满先别急着加线程去看是不是有人在消息处理里写了数据库操作或 sleep。协议解码器顺手处理了拆包逻辑先把不完整的半包缓存起来等后续数据到达拼接成完整包再返回。这一步是 TCP 编程的必修课后面避坑章节会专门展开。3. 让飞机在屏幕上“顺滑”移动状态同步、坐标插值和旋转平滑3.1 位置同步的核心间隔广播坐标客户端“往回看”状态同步最常见的做法是服务器端以固定频率比如每秒 10 次把每个玩家的坐标广播给其他客户端。但网络有延迟客户端收到的其实是“过去某个时刻”的位置如果直接用这个坐标覆盖本地飞机你会看到别人家的飞机一顿一顿地瞬移。反正我在自己的项目里第一次跑通时那个画面就像幻灯片一样后来才明白要做延迟缓冲和插值。先看服务器广播频率参数怎么设每秒 10 次100ms 间隔省流量适合网络环境差的情况但插值后会有轻微延迟。每秒 20 次50ms 间隔手感接近单机本地局域网联机推荐广域网也能承受。客户端这边不能拿“最新坐标”直接画而是要保留一个约 100~200ms 的缓冲在缓冲窗口内做插值。下面的代码是线性插值的核心逻辑标注了参数依据。// 保存最近两帧服务器坐标targetTime 表示这一帧是“什么时间”的坐标 Position prev stateBuffer.get(0); Position next stateBuffer.get(1); float alpha (now - prev.time) / (next.time - prev.time); alpha Math.max(0f, Math.min(1f, alpha)); float x prev.x (next.x - prev.x) * alpha; float y prev.y (next.y - prev.y) * alpha;alpha 表示当前时刻在前后两帧之间的比例多个玩家的飞机都用这一个公式计算展示位置。注意 prev.time 用的是服务器时间戳而不是本地接收时间戳——如果直接用本地接收时间网络抖动会被当成时间差算进去插值结果会忽快忽慢。缓冲区大小一般取 2~3 帧小于 100ms 时插值意义不大大于 300ms 会明显感觉到“这位玩家的操作慢半拍”。3.2 旋转平滑线性插值之外的三个参数飞机游戏里比坐标更敏感的是旋转。敌机在转向时如果机头角度是瞬间跳到新朝向玩家会强烈感受到“这不是一架飞机这是一个多边形”而且射击判定也会跟着鬼畜。常见的做法是对角度做插值但角度有 0 到 360 度的环绕问题直接相减会转反方向比如从 350 度到 10 度正确路径是转 20 度直接相减却算出 340 度。我这里提供一种更省事的替代用单位向量的 nlerp归一化线性插值代替角度插值既能规避环绕问题计算量也小——对飞机游戏这种只有偏航角左右旋转的场景来说足够用。// fromDir 和 toDir 是机头朝向的单位向量x, y float nx fromDir.x (toDir.x - fromDir.x) * alpha; float ny fromDir.y (toDir.y - fromDir.y) * alpha; float len (float) Math.sqrt(nx * nx ny * ny); nx / len; ny / len; // 再把向量转成屏幕旋转角度 float radian (float) Math.atan2(ny, nx);实际调优时注意三个点一是插值 alpha 的分布速度越快、转向越大alpha 应当越靠近 1保证转向跟手二是必须使用服务器端和客户端都能对上的统一时间基准我做项目时在这里吃过亏两台机器系统时间差了 5 秒飞机飞行方向全是斜的我一直以为是数学公式写错了三是角速度上限如果敌方飞机 1 秒内转 180 度这不是插值能修的问题而是同步太稀疏需要提高广播频率或做航迹推演。4. 客户端渲染与联机逻辑分离绕过UI线程卡死和按键丢帧4.1 把网络消息搬进UI线程ConcurrentLinkedQueue 与 Swing TimerJAVA 图形界面最常见的三个选择是 Swing、JavaFX 和 AWT。不管用哪个都有一个铁律网络线程不许直接碰 UI 组件。Swing 的 EDT事件分发线程约等于 UI 界面的“心脏”你在网络线程里直接调 label.setText轻则刷屏卡一下重则直接抛异常黑屏。我见过太多人在同一个方法里既读 socket 又画飞机最后画出来的是一个卡成 PPT 的客户端。正确的做法是网络层收到消息后放进一个并发队列UI 线程定时取消息并刷新界面。public class GameClient { private final ConcurrentLinkedQueueGameMessage uiQueue new ConcurrentLinkedQueue(); // 网络线程调用 public void onNetworkMessage(GameMessage msg) { uiQueue.offer(msg); // 不要在这里调用任何 UI 方法 } // UI 线程定时器每 16ms 执行一次 javax.swing.Timer timer new javax.swing.Timer(16, e - { GameMessage msg; while ((msg uiQueue.poll()) ! null) { applyToScene(msg); } render(); }); }这段代码核心只有一句话从队列 poll 消息、更新游戏场景数据、重绘。16ms 对应 60FPS是飞机这类高速移动游戏的最低要求如果你的绘制逻辑里有大量图片缩放或粒子效果可以降到 30ms约 30FPS但会出现可感知的掉帧感。队列本身不需要加锁ConcurrentLinkedQueue 基于 CAS 实现在“一读一写”这种典型场景下性能非常好。4.2 输入采集与本地预测先处理“按了没反应”客户端另一大坑是按键输入和网络发送互相抢时间片。我在一个早期版本里直接在 KeyListener 里发 UDP 包结果键盘稍微按快一点消息乱序飞机在屏幕上鬼畜抖。后来改成“输入先入队主循环统一发送”把逻辑和渲染帧对齐。QueueInteger inputQueue new ConcurrentLinkedQueue(); // 按键监听器 public void keyPressed(KeyEvent e) { inputQueue.offer(e.getKeyCode()); } // 主循环每帧处理一次 void handleInputs() { int code; while ((code inputQueue.poll()) ! null) { if (code KeyEvent.VK_UP) sendMsg(MsgType.INPUT_PRESS_UP); } }这样每帧最多发送一次操作指令不会出现按住加速键时一秒发几十个包的情况。稍微进阶一点的做法是本地预测按 UP 时先在本地把速度改掉飞机立刻有反应等服务器广播回来再校正误差。不过我还是那句新手先别做本地预测状态同步能跑稳、画面顺滑再回来碰这个否则你会同时面对“本地预测不准”和“同步逻辑没调好”两个黑匣子排错难度翻倍。5. 联机飞机游戏避坑记录端口不通、瞬移、心跳误判和字段错位5.1 现象客户端“连接被拒绝”服务端日志里什么都没有新手跑这套源码最常见的翻车现场。先启动客户端弹出 Connection refused去查服务器又看到端口根本没监听或者日志打了但没打印任何异常。原因基本是以下三选一服务器没启动成功端口被占用没报错、防火墙拦了端口、客户端配的服务器端口和服务端配置对不上。我遇到过一个很隐蔽的情况客户端写的是 8080服务端监听的是 8080但都用了 localhost 与 127.0.0.1 混用在部分机器上会导致绑定失败。解决的步骤很固定。先用下面命令确认监听状态# Linux / macOS lsof -i :8080 # Windows netstat -ano | findstr 8080再看服务器启动日志里有没有 “BindException: Address already in use”确认端口被上一个残留进程占着。然后把客户端连接地址改成服务器的局域网 IP 而不是 localhost最后检查防火墙。注意这里的顺序别反我每次排错都按这个顺序来能砍掉一半的玄学问题。5.2 现象别人家的飞机瞬移、卡顿但自己这台很流畅如果只有自己这台流畅说明本机渲染没问题问题出在同步通道上。先说一个鲜为人知的坑TCP 的 Nagle 算法会把小包攒在一起发而接收端又有延迟 ACK 策略两者相互作用可以给你凭空加上 40ms 的延迟。对 100ms 广播周期的位置同步来说40ms 足够让插值结果变得很赶看起来就是一顿一顿。解决办法是客户端和服务器端都关掉 Nagle。Java 里对 Socket 只需要一行socket.setTcpNoDelay(true);注意检查你的服务器端每个 Socket 都要调不是只调客户端。另一个坑是广播频率不匹配客户端按 100ms 发一次位置服务端按 200ms 转发中间直接少了一半数据瞬移感当然会翻倍。我一般会在消息里带发送时间戳接收端用来算真实广播周期而不是靠代码里的 sleep 间隔去猜。5.3 现象玩家经常被踢下线日志显示 “heartbeat timeout”心跳超时是联机游戏里最常见的误判。很多 java 新手的实现是服务器每收到一个心跳包就重置计时器超过 60 秒没收包就断开。看起来没毛病但实际一跑就掉线因为 GC 停顿、定时任务调度延迟、网络拥塞都可能导致单次心跳晚到几秒。单次超时就断线等于把服务器自己的 GC 抖动当成了玩家的错。解决方法是把“单次超时”改成“连续超时”的宽容窗口并配合计数而不是只盯绝对时间。参考参数心跳间隔 5 秒连续 3 次没收心跳再判定掉线这样 15 秒的容忍窗口足够覆盖大部分抖动。// 服务端心跳检测伪代码 int missedHeartbeats 0; long lastHeartbeat System.currentTimeMillis(); // 定时任务每 5 秒执行一次 if (now - lastHeartbeat HEARTBEAT_INTERVAL) { missedHeartbeats; if (missedHeartbeats 3) { closeConnection(playerId); } } else { missedHeartbeats 0; }用 java 定时任务框架Quartz、ScheduledExecutorService 都行来跑这个检测不要自己在循环里 sleep那样浪费线程还不可控。定时任务里只做判断和断连不要做任何 IO 操作否则又会拖累心跳响应。5.4 现象高并发一上来服务器收到的消息出现乱码和错位这个坑我在许多源码包里见过协议只定义了类型和包体长度却没有处理 TCP 流的半包。当多个客户端同时发消息时一次 read 可能读到“上一条的后半截 下一条的前半截”然后解析全部出错。这不是 Java 的 bug而是 TCP 流式传输的本质决定了你必须自己实现“粘包拆包”。解决办法是引入带长度前缀的帧协议。最简单也最稳的是先读 4 字节长度再读对应长度的数据数据读完再读下一个 4 字节。Netty 的 LengthFieldBasedFrameDecoder 专为此设计如果是基于 Netty 的源码直接配置这个解码器即可。// 4 字节长度字段1 字节类型字段剩余是包体 LengthFieldBasedFrameDecoder decoder new LengthFieldBasedFrameDecoder( 65536, // maxFrameLength 1, // lengthFieldOffset 4, // lengthFieldLength -5, // lengthAdjustment 0 // initialBytesToStrip );配置完 decode 层另一个跟它伴生的问题是读写缓冲区太小导致一个大包被切成多个 TCP 段拆包逻辑写错了就会漏数据。我建议把接收缓冲区调到 64KB 以上Linux 下默认是 16KB飞机游戏的消息频率不算低小缓冲会频繁触发读事件白白增高 CPU。5.5 现象版本一升级老客户端连不上新服务器或连上后数据全错这个属于发布管理和协议设计的坑。很多人在消息类里加字段时直接往尾部追加觉得向后兼容。但一旦服务器端改了字段顺序或删了某个字段老的客户端发过来的字节流就会错位解析。更隐蔽的是 Java 序列化不加 serialVersionUID类里字段一调整就直接 InvalidClassException。规范做法是给协议加版本号并且在消息类里不依赖 Java 对象序列化而是显式控制每个字段的读和写。版本号放在帧头里服务器检查不匹配时返回一个专门的错误码让客户端弹提示而不是静默解错。字段顺序改动时把新字段追加到末尾不要插在中间能在相当长时间内保持兼容。这些血泪经验总结成一句话协议字段和版本号比代码逻辑更需要文档和纪律。6. 从状态同步到帧同步把联机对抗推进到可回放可压测6.1 帧同步改造让操作指令成为唯一真相状态同步做久了你会发现一个天花板当同屏飞机超过十架、子弹乱飞时位置广播的频率和流量都会暴涨。想继续往下走通常要换成帧同步。做法是客户端不再上报最终坐标而是上报“这一帧我按了什么键”服务器把所有人的操作按帧打包广播每个客户端在同一帧执行完全相同的逻辑得到一致的画面。这样流量只跟操作频率有关跟场景里飞机数量无关。帧同步的关键是逻辑要确定性不能有随机数、不能有时序竞争、不用服务器本机时间参与碰撞计算。改造之前我给自己的项目就此打住因为确定性的代价很大——你要把模拟逻辑从 render 里彻底拆出来还要仔细核对浮点运算在双端是否完全一致。好消息是一旦做成了你的联机项目就等于拿到了回放功能和观战功能。6.2 三个验证技巧回放日志、延迟模拟和MVP压测我给自己的联机项目定的“毕业标准”只有三条第一把每一帧的操作指令落盘重复回放三次画面必须完全一致第二人为制造 50ms、120ms、200ms 三档延迟观察玩家的感受差异第三用脚本模拟 50 个客户端同时在线发操作观察服务器的 CPU 和内存曲线。回放日志我自己常用这种格式frame1201 player3 keyUP frame1201 player7 keyLEFT frame1202 player3 keyUP延迟模拟最简单的办法是在局域网内加一层 tc 规则在 Java 进程里也可以做一个透明代理层给每个收到的包加固定等待时间。MVP 压测更直接——写一个 Java 类里面起 50 个线程每个线程模拟一个客户端登录、起飞、持续发位置消息然后看服务器什么时候开始丢消息。我做这些从来不去追求大而全的压测平台一张折线图就足够说明问题重点是要在每次改协议、改线程模型之后都重跑一遍形成习惯。现在我还保留一个怪癖任何同步算法改动都会先在本地起三个虚拟客户端一个 50ms 延迟、一个 120ms、一个 200ms各自跑三分钟再回放。这个小习惯救了我好几次有好几版代码第一眼看起来很顺滑回放之后才发现飞机轨迹在特定延迟下飘得厉害。今天讲的这套东西从消息协议到线程模型从插值到避坑都是为了让同一片天空里的飞机能稳定地飞到一起。希望帮到你。本文还有配套的精品资源点击获取