简介:这是一套面向Java游戏开发初学者与网络编程学习者的多人联机飞机游戏完整源码,包含客户端与服务器端两部分,可用于理解C/S架构下的游戏逻辑分离、通信同步与界面渲染等核心问题。资源包共25个文件,约36KB,以Java源文件与编译后的class文件为主体,另含classpath路径配置、Eclipse项目配置与用户偏好设置文件,以及授权协议和说明文档,结构清晰、便于导入IDE直接阅读调试。目前已有320人学习关注。项目将用户界面、图形渲染与输入处理放在客户端,把游戏状态管理、逻辑运算与数据同步交由服务器端,读者可借此掌握Socket通信、多线程并发、AWT/Swing界面设计等关键技术的落地方式,并参考其模块划分与配置组织,快速搭建自己的联机游戏原型或课程设计框架。
1. 从「能双人同屏」到「能跨网联机」:JAVA 多人联机飞机游戏到底难在哪
单机版飞机大战,很多人一两天就能撸出来:一个JFrame,一个Timer循环,键盘监听控制飞机移动,碰撞检测用矩形相交,完事。可一旦标题里加上「多人联机」四个字,难度不是加一,而是乘十。你要面对的是:两台机器上的子弹位置怎么保持一致、谁说了算、网络延迟 200ms 时对方飞机瞬移怎么办、玩家中途掉线房间怎么收场。这些才是 JAVA 多人联机飞机游戏客户端及服务器端设计里真正值钱的部分。
这篇笔记面向的是已经会写 JAVA 基础语法、能看懂Socket和线程,但没真正做过联机游戏的开发者。我会把一套可复现的 C/S 架构拆开:服务器端用什么模型扛连接、客户端怎么把渲染和网络解耦、协议怎么定、状态怎么同步、掉线怎么兜底。读完你应该能自己搭出一个 2~8 人同房间、局域网或公网可玩的飞机对战原型,并且知道哪些参数一改就翻车。热搜里那些「客户端和服务端区别」「java 环境配置」的问题,本质都会在这套架构里被逼着回答一遍。
2. 服务器端选型:为什么我最终放弃阻塞 IO,改用 NIO + 线程池
2.1 三种服务器模型的取舍:BIO、NIO、Netty
做飞机游戏服务器,第一道坎是通信模型。最直觉的写法是ServerSocket.accept()拿到一个连接就开一个线程,这就是阻塞 IO(BIO)。10 个玩家以内它跑得好好的,代码也好懂。但飞机游戏有个特点:高频小包。玩家飞机位置、子弹坐标、敌机状态,每秒要广播 20~30 次。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 Map<SocketChannel, 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(); // 阻塞直到有事件就绪 Iterator<SelectionKey> 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,并做 0~1 的钳制,防止时间回退导致飞机倒着飞。
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}三四十字节,省一半还多。
| 字段 | 类型 | 字节数 | 说明 |
|---|---|---|---|
| type | byte | 1 | 消息类型,1=位置 2=开火 3=加入 4=离开 |
| playerId | int | 4 | 玩家唯一 ID,服务器分配 |
| x | float | 4 | 飞机横坐标 |
| y | float | 4 | 飞机纵坐标 |
| timestamp | long | 8 | 客户端发送时刻,用于延迟补偿 |
用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)); }逻辑说明:客户端发的是「意图」(按了上),不是「结果」(我在 y=95)。服务器统一按speed计算,所有人看到的 A 位置一致。clampToBounds是防作弊底线,客户端就算改了本地坐标,服务器也不认。
参数说明:speed = 5.0f是每 tick 移动量,配合服务器 tick 频率(比如 20Hz)就是 100 像素/秒。这个值要和客户端预测用的值完全一致,否则会出现「我明明没动,服务器说我动了」的拉扯感。客户端预测(client-side prediction)是进阶话题:本地先按同样规则移动,等服务器消息回来再校正,能显著降低操作延迟感。
5. 避坑与排查:那些让我熬夜到三点的联机问题
5.1 现象:玩家移动一顿一顿,像幻灯片
原因:服务器广播频率太低,或者客户端没做插值,直接按收到的离散位置渲染。20Hz 的位置更新,60Hz 的屏幕,中间两帧没数据,飞机就停在原地等下一包。
解决:客户端加插值(见 3.2),服务器广播频率提到 20~30Hz。别盲目提到 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)模拟,或者用工具做流量整形。所有同步逻辑必须在 100~200ms 延迟下验证过才算数。
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 字节左右,效果立竿见影。但差值压缩有个后悔药问题:一旦某个包丢了,客户端状态就和服务器永久不一致。所以每隔 1~2 秒必须补发一次全量快照做校正,这个「全量兜底」的间隔就是你要调的参数,太密省不了带宽,太疏状态会漂。
最后说个习惯:我做完任何联机功能,都会先在本机开两个客户端加人为延迟跑一遍,再拉一个同事跨网络实测。本地全绿不代表线上能用,网络这东西,永远比你想象的更玄学。希望帮到你。
本文还有配套的精品资源,点击获取