
简介这是一套面向Java游戏开发初学者与网络编程学习者的多人联机飞机游戏完整源码包含客户端与服务器端两大部分可用于理解C/S架构下游戏逻辑与通信的协同方式。压缩包共25个文件约36KB以Java源文件与编译后的class文件为核心辅以classpath路径配置、Eclipse项目配置与用户偏好设置文件另附授权协议与说明文档结构紧凑、便于导入IDE直接阅读调试。项目将用户界面、图形渲染与输入处理放在客户端把游戏逻辑、状态管理与数据同步交由服务器端清晰体现了网络编程、多线程同步与GUI事件处理等关键技术的落地方式。目前已有320人学习关注适合作为课程设计、毕业设计或Java网络编程练手的参考案例帮助读者快速把握联机游戏的基本骨架与模块划分思路。1. 从零搭一套 Java 多人联机飞机游戏客户端与服务器端到底怎么分工很多人第一次听到「基于 JAVA 语言开发的多人联机飞机游戏客户端及服务器端设计源码」脑子里浮现的是一个大一课程设计一个窗口里几架飞机乱飞子弹乱窜局域网里两台机器能连上就算成功。但真正做过联机游戏的人知道难点从来不在飞机怎么画而在「两台机器上的世界怎么保持一致」。飞机游戏看着简单可它把联机游戏最核心的几个问题全暴露了位置同步、输入延迟、状态权威、断线重连。Java 在这件事上其实是个被低估的选择——java.net包里的 TCP/UDP 原语足够底层java.util.concurrent又能让你把网络线程和渲染线程干净地分开不用像 C 那样自己管一堆生命周期。这套方案适合两类人一是想从单机小游戏跨到联机领域的 Java 开发者二是需要一套能跑通、能改、能当模板的联机骨架的工程师。下面我按自己实际搭过的一套结构把客户端和服务器端的分工、协议、同步策略和踩过的坑讲清楚。2. 服务器端为什么必须是唯一权威状态同步的三种模型选型2.1 权威服务器、锁步、状态同步的取舍联机游戏同步模型常见三种锁步Lockstep、状态同步State Synchronization、指令同步Command Sync。锁步要求所有客户端在同一帧执行相同指令适合 RTS但对网络抖动零容忍飞机游戏里一个玩家卡一下全体卡住直接翻车。指令同步只传输入服务器不跑逻辑作弊成本极低飞机游戏里改个坐标就无敌了。我一般会选权威服务器 状态同步服务器跑唯一一份游戏逻辑客户端只负责发输入、收状态、做插值渲染。这样服务器是唯一真相来源客户端改本地坐标没用下一帧就被服务器覆盖。代价是带宽。飞机游戏里每架飞机每帧要同步位置、角度、血量、状态20 个玩家 30Hz 就是每秒 600 条状态包。所以状态同步必须做两件事一是只同步变化量delta二是对位置做量化压缩。我通常把坐标从 float 压成 short精度损失在 0.01 像素级别肉眼看不出来带宽直接砍一半。2.2 服务器端最小可运行骨架服务器端核心是一个固定 tick 的游戏循环加一个网络收发层。下面是最小骨架用 TCP 做可靠指令通道UDP 做状态广播。// GameServer.java —— 权威服务器主循环 public class GameServer { private static final int TICK_RATE 30; // 逻辑帧率 private static final long TICK_MS 1000 / TICK_RATE; private final GameWorld world new GameWorld(); // 唯一游戏状态 private final DatagramSocket udpSocket; // 状态广播 private final ServerSocket tcpSocket; // 指令接收 public void start() throws Exception { // UDP 广播线程每 tick 把世界状态发给所有客户端 new Thread(() - { while (true) { long start System.currentTimeMillis(); world.update(TICK_MS); // 推进逻辑 byte[] snapshot world.encodeSnapshot(); // 量化delta for (ClientSession c : sessions) { udpSocket.send(new DatagramPacket( snapshot, snapshot.length, c.address, c.port)); } long cost System.currentTimeMillis() - start; if (cost TICK_MS) { Thread.sleep(TICK_MS - cost); // 固定步长 } } }).start(); // TCP 线程接收玩家输入指令 while (true) { Socket s tcpSocket.accept(); new Thread(new InputHandler(s, world)).start(); } } }逻辑说明world.update(TICK_MS)用固定步长推进保证物理和碰撞结果可复现encodeSnapshot()里做两件事——把 float 坐标乘 100 转 short以及只序列化与上一帧不同的字段。参数上TICK_RATE是第一个要调的30 适合飞机游戏60 更顺滑但带宽翻倍20 以下玩家能明显感觉到延迟。Thread.sleep那行是固定步长的关键少了它循环会跑飞物理速度随机器性能变化。2.3 输入指令的接收与校验客户端发来的输入不能直接信。常见做法是每条指令带一个客户端帧号服务器收到后放进队列在对应 tick 消费。如果客户端发的帧号超前太多说明它在加速发包直接丢弃。// InputHandler.java —— 指令接收与合法性校验 public class InputHandler implements Runnable { private final Socket socket; private final GameWorld world; public void run() { try (DataInputStream in new DataInputStream(socket.getInputStream())) { while (true) { int playerId in.readInt(); int clientFrame in.readInt(); byte inputMask in.readByte(); // 位掩码上下左右开火 // 帧号超前超过 5 帧判定为异常客户端 if (clientFrame world.getCurrentFrame() 5) { continue; } world.enqueueInput(playerId, clientFrame, inputMask); } } catch (IOException e) { world.removePlayer(playerId); // 断线清理 } } }参数说明inputMask用一个 byte 存 5 个按键状态比传 5 个 boolean 省 4 字节帧号校验阈值 5 是经验值太小会误杀网络抖动的正常玩家太大给作弊留空间。world.removePlayer必须在断线时调用否则服务器里会残留「幽灵飞机」这是最常见的翻车点之一。3. 客户端怎么把服务器状态画得不卡插值、预测与回滚3.1 为什么直接渲染服务器状态会一顿一顿服务器 30Hz 发状态客户端显示器 60Hz 刷新如果收到就画玩家看到的是每秒 30 次跳变。解决办法是插值客户端维护一个状态缓冲区渲染时取当前时间往前推 100ms 的两个快照做线性插值。这 100ms 叫插值延迟是拿「画面平滑」换「操作延迟」的经典权衡。飞机游戏里 100ms 可接受格斗游戏就得压到 30ms 以内。3.2 本地预测与服务器回滚插值解决别人卡解决不了自己卡。玩家按右键如果等服务器确认再动手感延迟至少一个 RTT。所以本地要做预测按下立即在本地移动同时把输入发给服务器。服务器回包时如果本地预测位置和服务器权威位置差超过阈值就回滚到服务器位置再重放未确认的输入。// LocalPredictor.java —— 客户端预测与回滚 public class LocalPredictor { private final DequeInputFrame pending new ArrayDeque(); private static final float RECONCILE_THRESHOLD 5.0f; // 像素 public void onLocalInput(byte mask, float dt) { pending.addLast(new InputFrame(mask, dt)); localPlayer.applyInput(mask, dt); // 立即本地移动 } public void onServerState(float serverX, float serverY) { float dx Math.abs(localPlayer.x - serverX); float dy Math.abs(localPlayer.y - serverY); if (dx RECONCILE_THRESHOLD || dy RECONCILE_THRESHOLD) { localPlayer.x serverX; // 回滚 localPlayer.y serverY; for (InputFrame f : pending) { // 重放未确认输入 localPlayer.applyInput(f.mask, f.dt); } } pending.clear(); } }逻辑说明pending队列存的是「已本地执行但服务器还没确认」的输入。服务器状态回来时先比对位置超过RECONCILE_THRESHOLD才回滚否则不动——这个阈值不能设 0否则网络抖动会让飞机疯狂抽搐。重放时用同样的applyInput保证本地和服务器逻辑一致这也是为什么服务器和客户端要共用同一份移动逻辑代码。3.3 渲染线程与网络线程的隔离Java 里最容易犯的错是在网络回调里直接改渲染状态然后repaint()。Swing 不是线程安全的这么写迟早出ConcurrentModificationException。正确做法是网络线程只往一个ConcurrentLinkedQueue里塞快照渲染线程EDT每帧 poll 出来消费。// RenderLoop.java —— EDT 侧消费快照 private final ConcurrentLinkedQueueSnapshot inbox new ConcurrentLinkedQueue(); // 网络线程调用 public void onSnapshotReceived(Snapshot s) { inbox.offer(s); // 无锁入队 } // EDT 每帧调用 private void onRender() { Snapshot s; while ((s inbox.poll()) ! null) { interpolator.push(s); // 喂给插值器 } interpolator.render(g); // 用插值结果画 }参数说明ConcurrentLinkedQueue是无锁队列适合这种一生产者一消费者的场景不要用synchronized包整个渲染会掉帧。插值器内部维护两个快照的时间戳渲染时按System.nanoTime()算插值系数。4. 联机飞机游戏避坑排查五个让我通宵的翻车现场4.1 现象两台机器飞机位置差半屏越玩越远原因客户端和服务器用了不同的浮点运算顺序或者客户端在预测时用了dt而服务器用固定TICK_MS。浮点累加误差会随时间放大。解决服务器和客户端共用同一个Physics.step(mask, fixedDt)方法客户端预测也用固定步长不要传真实帧间隔。4.2 现象玩家一多服务器 CPU 飙到 100%状态包延迟暴涨原因每 tick 给每个客户端单独序列化一份完整快照20 个玩家就是 20 次全量编码。解决先编码一份全局 delta 快照再按客户端做可见性裁剪视野外的飞机不发给它最后统一广播。编码只做一次裁剪只是改字节偏移。4.3 现象玩家断线后重连发现自己飞机还在原地被打原因removePlayer只在 TCP 读异常时调用但玩家可能只是切了网络TCP 没断。解决加心跳客户端每 500ms 发一个空包服务器 3 秒没收到就主动清理。心跳包不要走 UDPUDP 丢包会误判。4.4 现象子弹打中了但服务器不判定伤害原因客户端本地预测子弹命中就扣血服务器还没收到开火指令。解决伤害判定只在服务器做客户端命中只做特效。服务器判定命中后下发血量变化客户端收到再更新血条。这就是权威服务器的代价——所有结果性事件必须服务器说了算。4.5 现象UDP 状态包偶尔乱序飞机瞬移回旧位置原因UDP 不保证顺序旧快照后到覆盖了新快照。解决每个快照带一个递增的serverFrame客户端插值器只接受比当前帧号大的快照小的直接丢。这个字段只占 4 字节但能省掉一整类玄学 bug。5. 把同步精度再压一档量化、压缩与可复现验证前面跑通之后真正决定这套源码能不能上生产的是两个指标带宽和可复现性。先说量化。飞机游戏里坐标范围假设是 0 到 2000 像素用 float 是 4 字节用 short 存「像素乘 100」是 2 字节精度 0.01 像素。角度 0 到 360 度用 byte 存「度除 1.4」是 1 字节精度 1.4 度对飞机朝向足够。一个玩家状态从 444416 字节压到 22116 字节20 人 30Hz 从 9.6KB/s 降到 3.6KB/s。// SnapshotCodec.java —— 量化编码 public static void writePlayer(ByteBuffer buf, Player p) { buf.putShort((short) (p.x * 100)); // 坐标量化 buf.putShort((short) (p.y * 100)); buf.put((byte) (p.angle / 1.4f)); // 角度量化 buf.put((byte) p.hp); // 血量 0-100 }参数说明乘 100 还是乘 10 取决于地图大小地图 2000 像素乘 100 是 200000超过 short 上限 32767所以要么地图控制在 327 像素内要么用 int。我一般地图设 3000 像素坐标用 int 存「像素乘 10」精度 0.1 像素4 字节比 short 稳。角度除 1.4 是因为 360/256≈1.4刚好塞进一个无符号 byte。再说可复现验证。联机游戏最怕「我这边好好的你那边炸了」。我的习惯是服务器每 tick 把frame、所有玩家输入掩码、世界状态哈希写一行日志。客户端也记录自己收到的快照哈希。出问题时对比两边的哈希序列第一个不一致的帧就是 bug 起点。这个日志平时关掉出问题用命令行参数打开不影响性能。# 启动服务器并开启帧日志 java -jar game-server.jar --frame-log ./frames.log --tick 30 # 对比客户端和服务器哈希序列找第一个分歧帧 diff (awk {print $1,$3} frames.log) (awk {print $1,$3} client.log) | head最后说一个我自己的习惯每次改同步逻辑先跑一个「回放测试」——把一段录制的输入序列喂给服务器跑 1000 帧看最终状态哈希是否和上次一致。不一致就说明改动引入了不确定性比如用了HashMap遍历顺序、System.currentTimeMillis()参与逻辑、或者多线程竞争。这套验证花不了多少时间但能挡住绝大多数「改完看着没事上线就翻车」的情况。联机游戏没有后悔药可复现性就是那颗后悔药。希望帮到你。本文还有配套的精品资源点击获取