ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JAVA多人联机飞机游戏:NIO服务器与客户端架构设计实战

JAVA多人联机飞机游戏:NIO服务器与客户端架构设计实战 简介这是一套面向Java游戏开发初学者与网络编程学习者的多人联机飞机游戏完整源码包含客户端与服务器端两部分可用于理解C/S架构下的游戏逻辑分离、通信同步与界面渲染等核心问题。资源包共25个文件约36KB以Java源文件与编译后的class文件为主体另含classpath路径配置、Eclipse项目配置与用户偏好设置文件以及授权协议和说明文档结构清晰、便于导入IDE直接阅读调试。目前已有320人学习关注。项目将用户界面、图形渲染与输入处理放在客户端把游戏状态管理、逻辑运算与数据同步交由服务器端读者可借此掌握Socket通信、多线程并发、AWT/Swing界面设计等关键技术的落地方式并参考其模块划分与配置组织快速搭建自己的联机游戏原型或课程设计框架。1. 从「能双人同屏」到「能跨网联机」JAVA 多人联机飞机游戏到底难在哪单机版飞机大战很多人一两天就能撸出来一个JFrame一个Timer循环键盘监听控制飞机移动碰撞检测用矩形相交完事。可一旦标题里加上「多人联机」四个字难度不是加一而是乘十。你要面对的是两台机器上的子弹位置怎么保持一致、谁说了算、网络延迟 200ms 时对方飞机瞬移怎么办、玩家中途掉线房间怎么收场。这些才是 JAVA 多人联机飞机游戏客户端及服务器端设计里真正值钱的部分。这篇笔记面向的是已经会写 JAVA 基础语法、能看懂Socket和线程但没真正做过联机游戏的开发者。我会把一套可复现的 C/S 架构拆开服务器端用什么模型扛连接、客户端怎么把渲染和网络解耦、协议怎么定、状态怎么同步、掉线怎么兜底。读完你应该能自己搭出一个 28 人同房间、局域网或公网可玩的飞机对战原型并且知道哪些参数一改就翻车。热搜里那些「客户端和服务端区别」「java 环境配置」的问题本质都会在这套架构里被逼着回答一遍。2. 服务器端选型为什么我最终放弃阻塞 IO改用 NIO 线程池2.1 三种服务器模型的取舍BIO、NIO、Netty做飞机游戏服务器第一道坎是通信模型。最直觉的写法是ServerSocket.accept()拿到一个连接就开一个线程这就是阻塞 IOBIO。10 个玩家以内它跑得好好的代码也好懂。但飞机游戏有个特点高频小包。玩家飞机位置、子弹坐标、敌机状态每秒要广播 2030 次。BIO 下每个连接一个线程线程上下文切换的开销会随着人数线性上涨50 人就开始抖。第二种是 JAVA 原生 NIO用Selector做多路复用一个线程管一堆连接。它省线程但ByteBuffer的读写、半包粘包处理、SelectionKey的状态机写起来非常容易出玄学 bug。第三种是 Netty本质是 NIO 的封装帮你把粘包拆包、心跳、编解码都做好了。我的建议很明确学习目的、想搞懂底层用原生 NIO想快速出可玩版本用 Netty。下面这套代码我用原生 NIO 写因为标题强调「设计」你得看见Selector长什么样。2.2 用 NIO 搭一个能收发的服务器骨架// GameServer.java —— NIO 服务器主循环骨架 public class GameServer { private Selector selector; private ServerSocketChannel serverChannel; // 每个连接对应一个 ClientSession保存玩家状态 private MapSocketChannel, ClientSession sessions new ConcurrentHashMap(); public void start(int port) throws IOException { selector Selector.open(); serverChannel ServerSocketChannel.open(); serverChannel.configureBlocking(false); // 必须非阻塞否则 Selector 失效 serverChannel.socket().bind(new InetSocketAddress(port)); serverChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println(server listening on port); while (true) { selector.select(); // 阻塞直到有事件就绪 IteratorSelectionKey it selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); // 必须手动移除否则重复处理 if (key.isAcceptable()) handleAccept(key); else if (key.isReadable()) handleRead(key); } } } private void handleAccept(SelectionKey key) throws IOException { SocketChannel client ((ServerSocketChannel) key.channel()).accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); sessions.put(client, new ClientSession(client)); } private void handleRead(SelectionKey key) { SocketChannel client (SocketChannel) key.channel(); ByteBuffer buf ByteBuffer.allocate(1024); try { int n client.read(buf); if (n -1) { closeSession(client); return; } buf.flip(); // 交给协议解析器处理半包/粘包 ProtocolDecoder.decode(sessions.get(client), buf); } catch (IOException e) { closeSession(client); } } }逻辑说明selector.select()是核心它让一个线程同时盯着所有连接的读写事件。configureBlocking(false)是硬性前提忘了写这行register会直接抛IllegalBlockingModeException。it.remove()也是血泪经验不删的话同一个事件会被反复处理表现为「玩家移动一次服务器收到十次」。参数说明ByteBuffer.allocate(1024)是每个读事件的缓冲区大小。飞机游戏单个消息通常几十字节1024 够用但如果你把整张地图快照塞进一个包就得调大或者改成「长度前缀 分片」。port建议选 1024 以上避免权限问题。2.3 房间与广播把「谁该收到这条消息」想清楚服务器收到一个玩家位置更新后不能无脑广播给所有人。飞机游戏里同一房间的玩家才需要互相看见。所以ClientSession里要挂一个roomId广播时按房间过滤。// 按房间广播避免跨房间串消息 public void broadcast(int roomId, GameMessage msg) { byte[] data ProtocolEncoder.encode(msg); for (ClientSession s : sessions.values()) { if (s.getRoomId() roomId s.isAlive()) { s.send(data); // send 内部用 channel.write注意处理写半包 } } }这里有个容易翻车的点SocketChannel.write()在非阻塞模式下不保证一次写完。如果对方接收慢返回值会小于data.length剩下的字节必须缓存起来等OP_WRITE就绪再写。很多人第一次写 NIO 服务器测试时好好的一上压力就丢包就是栽在这。稳妥做法是给每个ClientSession配一个发送队列写不完的挂队列注册OP_WRITE事件续写。3. 客户端设计渲染循环和网络线程必须分家3.1 为什么不能在 Swing 的 EDT 里读 Socket客户端最容易犯的错是把网络读取直接写在paintComponent或者按钮回调里。Swing 的事件分发线程EDT是单线程的你在里面socket.read()阻塞一下整个界面就卡死玩家看到的是「未响应」。正确做法是网络收发放独立线程收到数据后只更新一份共享的游戏状态渲染线程按固定帧率去读这份状态。// NetworkClient.java —— 独立网络线程 public class NetworkClient implements Runnable { private Socket socket; private DataInputStream in; private volatile GameState state; // volatile 保证渲染线程能看到最新引用 Override public void run() { try { socket new Socket(127.0.0.1, 8888); in new DataInputStream(socket.getInputStream()); while (!Thread.currentThread().isInterrupted()) { int len in.readInt(); // 先读长度前缀 byte[] body new byte[len]; in.readFully(body); // 保证读满避免半包 GameMessage msg ProtocolDecoder.decode(body); state.apply(msg); // 只更新状态不碰 UI } } catch (IOException e) { // 断线处理标记状态让渲染层显示连接已断开 state.setDisconnected(true); } } }逻辑说明readInt()读长度、readFully()读满这是解决 TCP 粘包/半包最朴素也最可靠的办法——长度前缀协议。发送端先写 4 字节长度再写正文接收端先读长度再按长度读满。state.apply(msg)只改数据绝不在这里调repaint()否则线程安全问题会让你怀疑人生。参数说明volatile修饰state引用保证一个线程改了引用另一个线程立刻可见。但注意volatile不保证GameState内部字段的原子性如果多个字段要一起更新得加锁或者用不可变对象整体替换。3.2 渲染循环用固定时间步长别用「能跑多快跑多快」// GamePanel.java —— 固定 60FPS 的渲染循环 public class GamePanel extends JPanel implements ActionListener { private static final int FPS 60; private final Timer timer new Timer(1000 / FPS, this); public GamePanel(GameState state) { this.state state; timer.start(); } Override public void actionPerformed(ActionEvent e) { state.interpolate(); // 根据上次/本次快照做插值缓解网络抖动 repaint(); } Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 只读 state画飞机、子弹、敌机 for (Plane p : state.getPlanes()) { g.drawImage(p.getImage(), p.getRenderX(), p.getRenderY(), null); } } }逻辑说明Timer每 16ms 触发一次interpolate()是关键。服务器 20Hz 发位置客户端 60Hz 渲染中间那两帧怎么办用插值。保存上一帧和当前帧的位置按时间比例算中间值飞机就不会一格一格地跳。这是「看起来流畅」和「看起来卡顿」的分水岭。参数说明FPS 60是渲染帧率和服务器广播频率比如 20Hz解耦。别把两者设成一样网络一抖画面就跟着抖。interpolate()的插值系数建议用(now - lastUpdateTime) / interval并做 01 的钳制防止时间回退导致飞机倒着飞。4. 协议与状态同步定好「谁说了算」再写代码4.1 消息格式二进制还是 JSON新手喜欢用 JSON因为可读。但飞机游戏每秒几十条消息JSON 的字符串解析开销和体积都偏大。我的做法是二进制协议1 字节消息类型 若干字段。比如玩家位置消息type(1) playerId(4) x(4) y(4) timestamp(8)一共 21 字节。对比 JSON 的{t:pos,id:1,x:100,y:200}三四十字节省一半还多。字段类型字节数说明typebyte1消息类型1位置 2开火 3加入 4离开playerIdint4玩家唯一 ID服务器分配xfloat4飞机横坐标yfloat4飞机纵坐标timestamplong8客户端发送时刻用于延迟补偿用ByteBuffer读写这套结构比DataOutputStream更灵活因为可以控制字节序统一用ByteOrder.BIG_ENDIAN避免大小端不一致。4.2 权威服务器位置由服务器裁决客户端只做预测联机游戏最大的坑是「两个客户端各算各的」。A 看到自己在 (100,100)B 看到 A 在 (98,102)打起来就是互相觉得对方作弊。解决办法是服务器权威客户端只上报「我按了哪个方向键」服务器算出真实位置再广播给所有人。// 服务器端处理玩家输入 public void onPlayerInput(ClientSession s, InputMsg input) { Plane p s.getPlane(); float speed 5.0f; // 每 tick 移动像素 if (input.hasFlag(InputMsg.UP)) p.y - speed; if (input.hasFlag(InputMsg.DOWN)) p.y speed; if (input.hasFlag(InputMsg.LEFT)) p.x - speed; if (input.hasFlag(InputMsg.RIGHT)) p.x speed; p.clampToBounds(800, 600); // 服务器做边界校验防作弊 broadcast(s.getRoomId(), new PosMsg(p)); }逻辑说明客户端发的是「意图」按了上不是「结果」我在 y95。服务器统一按speed计算所有人看到的 A 位置一致。clampToBounds是防作弊底线客户端就算改了本地坐标服务器也不认。参数说明speed 5.0f是每 tick 移动量配合服务器 tick 频率比如 20Hz就是 100 像素/秒。这个值要和客户端预测用的值完全一致否则会出现「我明明没动服务器说我动了」的拉扯感。客户端预测client-side prediction是进阶话题本地先按同样规则移动等服务器消息回来再校正能显著降低操作延迟感。5. 避坑与排查那些让我熬夜到三点的联机问题5.1 现象玩家移动一顿一顿像幻灯片原因服务器广播频率太低或者客户端没做插值直接按收到的离散位置渲染。20Hz 的位置更新60Hz 的屏幕中间两帧没数据飞机就停在原地等下一包。解决客户端加插值见 3.2服务器广播频率提到 2030Hz。别盲目提到 60Hz带宽和 CPU 扛不住插值才是正解。5.2 现象两个人同时开火一方总看不到另一方的子弹原因子弹生成逻辑放在了客户端本地各自算各自的。A 的子弹在 A 屏幕上飞B 根本不知道。解决开火事件必须上报服务器由服务器生成子弹实体并广播。客户端收到BulletSpawnMsg才创建子弹对象。所有游戏实体的生命周期都由服务器管。5.3 现象玩几分钟后服务器 CPU 飙到 100%原因Selector的selectedKeys没清理或者OP_WRITE一直注册着导致空转。非阻塞 channel 只要可写select()就立刻返回形成忙等。解决只在有数据要写时才注册OP_WRITE写完立刻interestOps(OP_READ)取消。检查it.remove()有没有漏。5.4 现象玩家掉线后他的飞机还停在原地别人还能打中原因没有心跳机制服务器不知道对方已经断了。TCP 连接在正常关闭时会触发read() -1但如果是网线拔了、进程被杀服务器可能很久都感知不到。解决加心跳。客户端每 2 秒发一个HeartbeatMsg服务器记录每个 session 的lastActiveTime超过 10 秒没收到就判定掉线广播PlayerLeaveMsg并清理实体。5.5 现象本地测试完美一放到公网就各种超时原因本地127.0.0.1延迟接近 0掩盖了所有时序问题。公网 100ms 延迟下客户端预测和服务器校正打架表现为飞机来回抽搐。解决本地测试时人为加延迟。可以在网络线程里Thread.sleep(100)模拟或者用工具做流量整形。所有同步逻辑必须在 100200ms 延迟下验证过才算数。6. 进阶技巧用状态快照 差值压缩把带宽打下来当房间人数上到 8 人、实体上百个时每 tick 全量广播会迅速吃满带宽。我一般会做两件事状态快照和差值压缩。状态快照是服务器每隔 N 个 tick 发一次完整状态中间只发变化量。差值压缩更狠只发和上一帧不同的字段。比如飞机没动就不发位置只发一个「无变化」标记。// 差值编码只发变化的实体 public byte[] encodeDelta(GameState prev, GameState curr) { ByteBuffer buf ByteBuffer.allocate(4096); buf.put((byte) MSG_DELTA); int countPos buf.position(); buf.putShort((short) 0); // 先占位最后回填变化数量 short changed 0; for (Plane p : curr.getPlanes()) { Plane old prev.getPlane(p.getId()); if (old null || old.getX() ! p.getX() || old.getY() ! p.getY()) { buf.putInt(p.getId()); buf.putFloat(p.getX()); buf.putFloat(p.getY()); changed; } } buf.putShort(countPos, changed); // 回填真实数量 byte[] out new byte[buf.position()]; buf.flip(); buf.get(out); return out; }逻辑说明先占位再回填是二进制协议的常用手法因为变化数量要等遍历完才知道。客户端收到MSG_DELTA后按playerId找到本地实体只更新变化的坐标没提到的实体保持不动。参数说明ByteBuffer.allocate(4096)要按最大可能变化量估算8 人 × 12 字节 96 字节4096 绰绰有余。如果实体数量可能上千得改成分片发送或者用更激进的量化——坐标从 float 压成 short精度降到 1 像素体积直接减半。验证这套压缩有没有效果别靠感觉。在服务器端统计每 tick 发送的字节数打印出来对比全量广播。我自己的经验是8 人房间从每 tick 约 800 字节降到 150 字节左右效果立竿见影。但差值压缩有个后悔药问题一旦某个包丢了客户端状态就和服务器永久不一致。所以每隔 12 秒必须补发一次全量快照做校正这个「全量兜底」的间隔就是你要调的参数太密省不了带宽太疏状态会漂。最后说个习惯我做完任何联机功能都会先在本机开两个客户端加人为延迟跑一遍再拉一个同事跨网络实测。本地全绿不代表线上能用网络这东西永远比你想象的更玄学。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进