ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java多人联机飞机游戏:服务端权威模型与网络同步实战

Java多人联机飞机游戏:服务端权威模型与网络同步实战 简介基于JAVA语言开发的多人联机飞机游戏客户端与服务器端设计源码包面向Java游戏开发学习者和网络编程爱好者帮助理解多人实时交互游戏的客户端/服务器架构与实现流程。项目包含客户端与服务器端两部分客户端负责界面渲染、用户输入及通信服务器端负责游戏逻辑、状态同步与数据交互共25个文件包括9个Java类文件、8个Java源文件以及classpath、项目配置、用户偏好设置、LICENSE、readme等辅助文件压缩包大小约36KB结构清晰便于查阅。目前已有319人学习适合用作实战案例。通过源码可研究Socket网络通信、GUI构建、多线程同步、事件处理等关键技术在游戏中的具体运用并学习代码模块划分与项目组织方式为后续开发更复杂的多人游戏提供参考。1. 基于 Java 的多人联机飞机游戏先把服务端权威模型想清楚看到“基于JAVA语言开发的多人联机飞机游戏客户端及服务器端设计源码”这个标题很多人第一反应是它不过是个飞机大作战的 Java 版画个飞机、发射子弹就够了。真正动手写起来才发现卡住你的不是绘图和碰撞而是多人联机带来的网络同步问题两个客户端各自计算位置画面就会各说各话服务器不知道谁先开火分数就是一笔糊涂账。这类项目的核心实际是服务端权威模型客户端负责表现服务器负责裁决网络协议负责把输入和状态稳定地搬来搬去。它适合正在学 Java 网络编程和并发、想找一个能写进简历的练手项目的人。下面从协议设计一直讲到断线重连和压测照着搭能少走很多弯路。2. 客户端与服务端的消息协议用网包包头解决粘包和拆包2.1 TCP 长连还是 UDP飞机游戏该选哪种传输层协议多人联机飞机游戏常见做法是用 TCP 长连接原因很实际飞机游戏不像 FPS 那么依赖极低延迟操作频率大概是每秒 10 到 20 次TCP 的拥塞控制和重传机制能保证你发出的“开火”指令不会丢失。如果采用 UDP你得自己实现可靠传输、顺序编号、重传和拥塞控制工程量立刻大了一倍。对于课程设计或小规模联机TCP 是性价比最高的选择。什么时候换 UDP 呢如果你要做 50 人以上的同屏弹幕类游戏或者需要 60Hz 的帧同步这时候 TCP 的头部阻塞和重传延迟会直接表现为画面漂移。但那是另一个量级的项目。我们这里的场景是 2 到 8 人房间TCP 完全够用而且 Java 标准库和 Netty 对 TCP 的支持最成熟出问题好排查。2.2 自定义消息二进制格式魔数、类型、长度三件套基于 TCP 的流式传输客户端和服务端读到的数据没有边界所以协议必须自己定义消息边界。我一般会设计一个最简单的二进制包头包含魔数、消息类型、消息体长度三个字段。魔数用来快速识别非法连接长度用来拆包类型用来分发到不同的处理逻辑。public class Message { public static final int MAGIC 0x5A01; // 魔数用来识别合法消息 public static final int TYPE_HEARTBEAT 1; public static final int TYPE_MOVE 2; public static final int TYPE_FIRE 3; public static final int TYPE_STATE_SYNC 4; public int type; public byte[] body; public static byte[] encode(Message msg) { ByteBuffer buffer ByteBuffer.allocate(8 msg.body.length); buffer.putShort((short) MAGIC); // 2 字节魔数 buffer.putShort((short) msg.type); // 2 字节消息类型 buffer.putInt(msg.body.length); // 4 字节消息体长度 buffer.put(msg.body); // 消息体 return buffer.array(); } public static Message decode(byte[] data) { ByteBuffer buffer ByteBuffer.wrap(data); int magic buffer.getShort(); if (magic ! MAGIC) throw new IllegalArgumentException(bad magic); Message msg new Message(); msg.type buffer.getShort(); int length buffer.getInt(); msg.body new byte[length]; buffer.get(msg.body); return msg; } }这段代码里encode把消息转成字节数组包头固定 8 字节。decode在拿到一个完整消息时解析。注意实际网络读取时必须先把 4 字节长度读回来再根据长度读 body否则会读到半个消息。上面代码只做了单消息解析实际在 NIO 或 Netty 里要做拆包器Netty 有现成的LengthFieldBasedFrameDecoder可以直接用参数是maxFrameLength 65535lengthFieldOffset 4lengthFieldLength 4。如果自己用流读要维护一个累积缓冲区。为什么不直接传 JSONJSON 方便调试但字符串解析有额外开销同样的状态消息体积会膨胀 3 到 5 倍。20Hz 广播时 CPU 可能不吃紧但协议边界问题还是得自己解。所以很多项目从一开始就上二进制协议后面做帧同步也不用换。2.3 用 Netty 管连接还是用原生 Socket 硬写对于服务器端我一般用 Netty因为它把线程模型和拆包粘包都封装好了。客户端为了减少依赖可以用原生Socket放到单独的线程里。这个组合在中小型项目里非常稳定。服务器端有一个 ServerBootstrapChildHandler 里加入LengthFieldBasedFrameDecoder和自定义 Handler。EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup(4); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(65535, 4, 4)); ch.pipeline().addLast(new MessageDecoder()); ch.pipeline().addLast(new GameServerHandler()); } }); ChannelFuture future bootstrap.bind(8888).sync(); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); }worker 线程组大小我一般设成 CPU 核数的两倍不要开太大因为每个房间的逻辑计算是轻量的。LengthFieldBasedFrameDecoder的参数里偏移量是 4 是因为前面有 2 字节魔数加 2 字节类型如果你把魔数去掉、直接类型加长度偏移量就是 2。这里最容易出错长度字段偏移和长度字段本身的大小要和你编码时完全一致否则解析出来的 body 字节数不对后面所有消息全部错位。这属于典型的“牵一发动全身”我第一次搭的时候就栽在这里。如果你不用 Netty客户端原生 Socket 的拆包循环大概长这样。关键在于维护一个累积缓冲区并在一轮循环里处理完所有可能存在的完整消息。// 原生Socket客户端拆包积累缓冲区 byte[] readBuffer new byte[8192]; ByteArrayOutputStream accumulator new ByteArrayOutputStream(); while (true) { int n socket.getInputStream().read(readBuffer); accumulator.write(readBuffer, 0, n); byte[] data accumulator.toByteArray(); while (data.length 8) { ByteBuffer header ByteBuffer.wrap(data); int magic header.getShort(); int type header.getShort(); int bodyLen header.getInt(); if (data.length 8 bodyLen) break; // 还不够完整等下次 Message msg new Message(); msg.type type; msg.body Arrays.copyOfRange(data, 8, 8 bodyLen); dispatch(msg); data Arrays.copyOfRange(data, 8 bodyLen, data.length); accumulator.reset(); accumulator.write(data); } }这段代码的逻辑是读进来的数据先塞进累积缓冲区然后循环检查缓冲区里是否够 8 字节包头够的话再检查包头里的 body 长度是否已到。如果 body 没到说明这是半条消息直接 break 等下一次 read。处理完一个完整消息后必须把剩余字节重新放回 accumulator否则下一条消息的头会被丢掉。3. 服务器端设计房间状态同步与心跳线程怎么配合3.1 房间数据结构用 ConcurrentHashMap 管好玩家会话服务器端核心是房间Room和玩家会话PlayerSession。房间存放玩家列表、游戏状态飞机坐标、血量、子弹状态。因为 Netty 的 worker 线程可能同时处理多个连接对房间的读写要做并发控制但不要用 synchronized 锁住整个房间的 tick 循环。常见做法是每个房间一个锁或直接使用单线程处理所有房间。public class GameRoom { private final int roomId; private final MapInteger, Player players new ConcurrentHashMap(); private final GameState state new GameState(); public void addPlayer(Player p) { players.put(p.id, p); state.addPlayer(p.id); } public void removePlayer(int playerId) { players.remove(playerId); state.removePlayer(playerId); } public void updatePlayerInput(int playerId, float dx, float dy, boolean fire) { Player p players.get(playerId); if (p ! null) { p.inputBuffer.add(new InputFrame(dx, dy, fire)); } } }这里ConcurrentHashMap保证读多写少时不会锁整个表。但要注意updatePlayerInput只更新玩家的“输入指令”真正的坐标计算放到游戏循环里做这样就不会在消息到达线程里直接修改 game state避免并发竞争。客户端发送的是“我按下右方向键希望相对当前位置往右走”而不是“我的飞机现在在(100, 200)”。如果服务器直接接受客户端上报的坐标那任何一个人都可以用内存修改器把飞机瞬移到你脸上。服务器权威计算意味着所有位置变化由服务器根据输入计算客户端只能上报操作意图dx、dy 等参数还要限制在 [-1,1] 范围内防止归一化后超速。3.2 服务器端的游戏循环定时任务框架决定同步精度游戏循环是服务器端的心脏。它每隔固定时间通常 50ms也就是 20Hz读取所有玩家的输入推进飞机和子弹的位置检测碰撞然后广播新状态给房间内所有人。用 Java 定时任务框架实现最常用的是ScheduledExecutorService。public class GameLoop implements Runnable { private final int tickMs 50; private final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); public void start() { scheduler.scheduleAtFixedRate(this, 0, tickMs, TimeUnit.MILLISECONDS); } Override public void run() { long start System.nanoTime(); for (GameRoom room : roomManager.getAllRooms()) { room.tick(); } long cost (System.nanoTime() - start) / 1_000_000; if (cost tickMs) { System.out.println(tick overtime: cost ms); } } }重点参数是scheduleAtFixedRate而不是scheduleWithFixedDelay。前者是固定频率如果某次 tick 超时下次会立刻补上严格保持 20Hz后者是等待上一次执行完再等固定时间实际频率会变慢。对于游戏同步固定频率更重要。另外线程池大小设成 1因为游戏循环内部是批处理所有房间不需要并发并发反而要加锁。3.3 状态同步广播只发变化量还是全量坐标这里有个常见争论全量坐标协议简单20Hz 广播所有玩家位置每个消息体大约几十字节10 个玩家也就几百字节完全没问题。变化量协议能省带宽但要额外维护“上一次的状态”代码复杂度高。我的建议是先从全量开始除非你的玩家数量和消息频率都要翻了否则不要提前优化。public void broadcastState() { for (Player viewer : players.values()) { ByteArrayOutputStream stateBytes new ByteArrayOutputStream(); stateBytes.write(STATE.getBytes(StandardCharsets.UTF_8)); for (Player p : players.values()) { stateBytes.write(ByteBuffer.allocate(4).putInt(p.id).array()); stateBytes.write(ByteBuffer.allocate(4).putInt(p.xScaled).array()); stateBytes.write(ByteBuffer.allocate(4).putInt(p.yScaled).array()); stateBytes.write(ByteBuffer.allocate(1).put((byte) (p.alive ? 1 : 0)).array()); } viewer.channel.writeAndFlush(stateBytes.toByteArray()); } }注意这里用了ByteArrayOutputStream和ByteBuffer每次 tick 都构造新数组会有 GC 压力。在人数不多时没关系如果优化可以复用缓冲区或直接用 Netty 的ByteBuf。还有一个隐蔽问题遍历 Map 的顺序每次基本稳定但修饰了玩家列表时广播给 A 玩家的包和给 B 玩家的包可能因为顺序不同导致客户端看到的世界不一致。稳妥做法是先把玩家列表快照成数组再遍历广播避免ConcurrentModificationException。坐标字段建议用定点数而不是float。Java 的double在不同 CPU 上可能产生微小舍入差异几十次 tick 后偏差累积两台电脑看到的位置就会不一样。常见做法是把位置乘以 1000 存成 int只在渲染时除以 1000。这样既省带宽又能保证服务器计算结果一致也是状态同步里最容易被忽略的边界。4. 客户端设计渲染循环、输入采集与断线重连的配合4.1 Swing 渲染循环把网络收发放到独立线程客户端的飞机游戏 UI 常用 Java Swing 或 JavaFX。Swing 本身不是线程安全的所有 UI 改动必须在事件分发线程EDT上进行。网络线程收到服务器状态后不能直接修改 UI 组件而是把最新状态写入一个“共享快照”Swing 的 Timer 渲染循环每 16ms 读取一次快照并重绘。public class GameClient { private volatile GameStateSnapshot latestState; private final JPanel panel new JPanel(); public void start() { new Thread(this::networkLoop).start(); // 网络收发线程 Timer timer new Timer(16, e - render()); timer.start(); } private void networkLoop() { while (running) { Message msg receiveMessage(); if (msg.type Message.TYPE_STATE_SYNC) { latestState parseState(msg.body); } } } private void render() { GameStateSnapshot state latestState; if (state null) return; // 在 EDT 中绘制所有玩家飞机 panel.repaint(); } }这里volatile保证网络线程写入后渲染线程能看到最新值但如果状态对象内部是可变的还需要同步。更好的做法是用不可变对象每次网络线程解析完生成一个全新的GameStateSnapshot渲染线程只读。这样既不需要大锁也不会看到中间状态。输入采集也不能每次按键都发一条指令。Swing 键盘监听里keyPressed和keyReleased成对出现按住方向键会导致同一个事件触发多次。常见做法是维护一个按键状态集合每 50ms 检查一次当前被按住的键把聚合后的输入上下左右、开火打包成一条TYPE_MOVE消息发送。SetInteger pressedKeys new HashSet(); panel.addKeyListener(new KeyAdapter() { public void keyPressed(KeyEvent e) { pressedKeys.add(e.getKeyCode()); } public void keyReleased(KeyEvent e) { pressedKeys.remove(e.getKeyCode()); } }); public void sendInputLoop() { while (running) { float dx 0, dy 0; if (pressedKeys.contains(KeyEvent.VK_LEFT)) dx - 1; if (pressedKeys.contains(KeyEvent.VK_RIGHT)) dx 1; if (pressedKeys.contains(KeyEvent.VK_UP)) dy - 1; if (pressedKeys.contains(KeyEvent.VK_DOWN)) dy 1; boolean fire pressedKeys.contains(KeyEvent.VK_SPACE); Message msg Message.createMove(dx, dy, fire); sendMessage(msg); Thread.sleep(50); } }按键聚合可以稳定网络请求频率避免键盘重复事件导致消息暴涨。Thread.sleep(50)和服务端 tick 保持一致20Hz 发送输入服务器收到后下一轮 tick 消费。4.2 插值算法解决网络抖动导致的飞机瞬移服务器按 50ms 广播一次客户端如果直接每 50ms 把飞机画到最新坐标视觉上会出现一卡一卡的效果。所以要在两个同步状态之间做插值。常见做法是保存最近两份状态根据渲染时间戳计算 alpha插值出当前帧坐标。public class Interpolator { private StateSnapshot prev; private StateSnapshot next; private long t0; private long t1; public void pushState(StateSnapshot newState) { prev next; next newState; t0 System.currentTimeMillis(); t1 t0 50; // 与服务器 tick 周期一致 } public float getX(int playerId) { if (prev null || next null) return next.getX(playerId); float alpha (System.currentTimeMillis() - t0) / (float) (t1 - t0); alpha Math.max(0, Math.min(1, alpha)); return prev.getX(playerId) (next.getX(playerId) - prev.getX(playerId)) * alpha; } }插值参数的核心是t1 - t0这段窗口时间。如果服务端 tick 是 50ms客户端读取到两次状态的时间间隔大概率在 50ms 到 70ms 之间。把插值窗口固定为 50ms 会让游戏速度稍快一点但能保证不卡顿如果网络抖动变大会出现插值等待。更进阶的方案是把插值窗口设为预计网络延迟 tick周期。这块属于调参范围我在最后一章会讲怎么验证。4.3 断线重连会话恢复与“地址已在使用”的规避断线重连不只是重新 new Socket 连接。服务器要能识别重连玩家否则房间里的飞机被清理重连后只能重新开始。常见方案是客户端登录时拿到一个 sessionId断线后通过 sessionId 重新加入原房间。如果短时间掉线服务器端可以保留玩家状态几十秒等待重连。public void reconnect(int playerId, String sessionId) { Session session sessionManager.get(sessionId); if (session null || session.ownerId ! playerId) { throw new SecurityException(invalid session); } GameRoom room roomManager.getRoom(session.roomId); room.rejoin(playerId, session.channel); }这里最大的坑不在业务逻辑而在操作系统层面。客户端刚断开连接时旧 socket 进入TIME_WAIT或CLOSE_WAIT如果你立刻用相同的本地端口去连接服务器可能抛出“地址已在使用”。客户端Socket默认会由系统分配可用临时端口一般不会报这个错但如果你为了固定端口设置过setLocalPort就容易翻车。避坑章第一条我会专门展开。5. 避坑指南多人飞机游戏上线前必须处理的 5 个真实踩坑记录5.1 客户端重连时报地址已在使用现象客户端第一次连接正常掉线重连时Socket的connect抛出java.net.BindException: Address already in use。原因客户端的 socket 在前一次断开时进入TIME_WAIT状态如果在近距离时间内用同一个本地端口再次连接操作系统会拒绝。解决不要手动指定本地端口让系统分配临时端口对 Socket 设置SO_REUSEADDR另外确保旧的读线程已经退出否则 socket 没有真正关闭。Socket socket new Socket(); socket.setReuseAddress(true); socket.connect(new InetSocketAddress(serverAddr, serverPort), 3000);SO_REUSEADDR在客户端不一定能解决TIME_WAIT但能解决CLOSE_WAIT下的 bind 冲突。真正可靠的方案是每次重连都新建 Socket 对象而不是复用旧的。5.2 服务器端阻塞 IO 把整个房间拖慢现象房间人数超过 4 人后所有玩家都出现周期性卡顿每次卡顿时间和某个玩家网络超时时间相近。原因服务器端用了阻塞InputStream.read()每个连接一个线程某个连接慢时它的读线程卡住没问题但如果用阻塞 IO 做广播任何一个连接写不出去就会阻塞后续代码。解决不要用阻塞 IO 做广播。使用 Netty 的Channel.writeAndFlush异步写或者为非阻塞 Socket 配置超时并隔离慢连接。在小规模下可以在broadcastState里把写操作提交到单独的写线程池避免慢连接拖累 tick。5.3 浮点坐标同步导致两台机器看到的位置不一样现象两台配置不同 CPU 的电脑运行同一个客户端观察到的飞机位置在长时间运行后出现明显偏差。原因Java 的double计算在不同硬件上可能产生微小的浮点舍入差异几十次 tick 后偏差累积。解决不传浮点坐标改成传整数坐标或定点数。比如把位置乘以 1000 存成 int游戏循环里用 int 计算只在渲染时除以 1000。这样既省带宽又能保持一致。public class FixedPoint { public static final int SCALE 1000; public int xScaled; // 存储 x * SCALE }5.4 消息粘包拆包处理不干净第二个消息开始乱码现象客户端第一个消息解析成功第二个消息开始消息体少一部分或解析抛出异常。原因自己实现的拆包逻辑在循环处理accumulator时把剩余字节带到了下一次循环或者没有处理好“只有半条消息”的情况。解决使用 Netty 的LengthFieldBasedFrameDecoder是安全方案如果自己写必须用“循环 while”检查缓冲区里是否足够一个完整消息。上面第二章给的原生 Socket 拆包代码里最关键的一个细节是处理完一个完整消息后要更新剩余数据并重新判断而不是只 break 一次。5.5 定时任务框架线程池泄漏运行一天后无响应现象服务器跑一段时间后游戏循环突然停止但进程还活着也没有异常堆栈。原因在run()里如果某一次 tick 抛出未捕获异常ScheduledExecutorService会取消该定时任务而且默认不会打印异常。解决在游戏循环的run()里用 try-catch 把整个 tick 包起来并将异常输出到日志另外设置Thread.setDefaultUncaughtExceptionHandler。这样至少能发现是房间逻辑里哪个 NullPointerException 导致循环死亡。scheduler.scheduleAtFixedRate(() - { try { gameLoop.run(); } catch (Throwable t) { t.printStackTrace(); } }, 0, 50, TimeUnit.MILLISECONDS);顺手整理一份参数速查这些是经过多轮联调后比较稳的起点参数推荐值说明服务器监听端口8888避开常用端口防火墙放行Netty worker 线程数CPU 核数 x2太高会导致上下文切换游戏 tick 周期50ms20Hz延迟与带宽的平衡点客户端插值窗口50ms与 tick 周期匹配连接超时3000ms避免长时间等待黑屏心跳间隔5000ms小于服务器踢人阈值6. 验证与进阶本地双开联调、压力测试、从状态同步到帧同步本地联调很简单同一个 Java 进程可以同时启动服务器端和两个客户端但注意 Swing 组件必须在 EDT 创建。我通常写一个Main类启动服务器线程后再创建两个GameClient窗口并接入同一房间。那个“地址已在使用”的坑最容易在双开时暴露所以先跑通这个步骤再上真机。进入项目根目录后先编译再运行。服务器端要绑定0.0.0.0而不是127.0.0.1否则局域网内其他电脑连不进来。客户端连接地址写127.0.0.1端口写服务器启动端口。验证目标是两个窗口能看到彼此的飞机按方向键对方画面移动开火后对方画面出现子弹。压测方面我常用一个机器人客户端快速模拟 50 个连接每个机器人连上服务器后自动进入同一房间每秒发送 20 条移动消息持续 10 分钟。观察tick cost是否稳定在 50ms 以内观察 JVM 内存是否持续增长。机器人代码其实就是在循环里sendMessage中间Thread.sleep(50)。for (int i 0; i 50; i) { new Thread(() - { Socket socket connect(127.0.0.1, 8888); while (true) { sendMove(socket, randomDx(), randomDy(), true); Thread.sleep(50); } }).start(); }三个进阶改动值得投入。第一把状态同步改成帧同步服务器只转发玩家操作指令客户端各自跑同样的游戏逻辑能省掉大量状态广播代价是逻辑必须完全确定性浮点运算和集合迭代顺序都要固化。第二给房间加观战模式用只读状态同步给观战者设计上要注意 World 状态不能因为观战者参与而被改变。第三如果人数上去把 Netty 的NioEventLoopGroup换成EpollEventLoopGroup对 Linux 高并发连接更友好。我自己的习惯是保留一个“回放日志”把每个 tick 收到的所有输入按时间戳存成二进制文件。出了诡异问题就回放日志在代码里复现否则你面对的是一个黑匣子只能靠猜。这套做法让我少踩了至少一半的坑。希望帮到你。本文还有配套的精品资源点击获取
返回列表