尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java联机版森林冰火人:Socket长连接与服务端权威状态同步实践

Java联机版森林冰火人:Socket长连接与服务端权威状态同步实践 简介这份资源是一份基于Java实现的《森林冰火人》双人联机小游戏课程设计源码包主要面向Java编程学习者、课程设计答辩者以及游戏开发入门者。资源包内共八十五个文件包括十二个Java源码文件及其对应的class字节码还有用于工程配置的xml与properties文件视觉素材方面提供了多张jpg和png静态图片、gif动态图并附有README说明文档方便使用者了解项目结构。压缩包整体体积仅二点四六兆字节轻巧紧凑目录层次分明可直接导入常见开发工具阅读运行。目前已有三百六十九人学习下载。通过该资源读者能够获得完整可运行的联机游戏代码并从中理解双人键盘控制、碰撞检测、游戏循环以及局域网联机等核心实现思路作为课程设计或游戏开发练手项目都非常适合。1. 一个联机版森林冰火人难的不是游戏逻辑做联机版森林冰火人第一道坎从来不是角色移动、宝石碰撞这些单机逻辑而是两个 Java 进程怎么“看到同一个世界”。单机版按下方向键角色就地移动联机版如果客户端各算各的坐标Fireboy 和 Watergirl 很快会出现在互相矛盾的位置合作解谜也就没法玩了。标题里这个 Java 双人联机小游戏表面是 Swing 加 Socket 的 Demo实际要解决的是怎么让两个客户端通过 TCP 长连接共享同一份世界状态并且在一方掉线、一方迟疑时不把局面搞崩。它适合有 Java 基础、想往网络编程方向走的开发者也适合想拿一个“非管理系统”的 Java 项目做面试作品的人。整条实现路径我会按“同步模型 → Socket 线程骨架 → 参数与坑 → 演进与验收”的顺序展开代码只用 JDK 原生 API不引入第三方依赖。2. 同步模型与服务端权威联机版先定规矩再写代码2.1 TCP 还是 UDP合作解谜游戏为什么选 TCP联机游戏一谈到网络默认会先吵 TCP 和 UDP 哪个好。结论很简单这种合作解谜游戏选 TCP而且是长连接。理由是它的游戏节奏天然对延迟不敏感。格斗游戏需要 60Hz 的输入采样FPS 需要极低延迟的走位反馈但森林冰火人这类关卡制解谜角色移动速度慢操作频率低20Hz 的状态推送已经能带来很顺滑的体验。在这样的频率下TCP 的重传开销完全可接受。UDP 的优势是省掉握手和重传但代价是丢包后你需要自己处理乱序、重传和状态补偿。对一个双人小游戏来说这部分的开发成本会超过游戏逻辑本身。TCP 的字节流模型还能让你直接用BufferedReader.readLine()按行读取协议帧天然解决了大半粘包问题细节放在第 4 章讲。更关键的是解谜游戏里“火人踩到水池”这种判定不可逆一旦因为丢包漏判整个关卡就要重开TCP 的可靠投递在这里不是拖累而是保底。还有一层是调试成本。TCP 可以用nc、telnet直接连上去发文本验证协议UDP 得额外写发包工具。对学习性质的双人联机项目选 TCP 意味着把精力留给游戏状态设计而不是网络细节。2.2 服务端权威客户端只上传按键不上传坐标常见做法是这样客户端不计算自己的坐标只上传“当前哪几个方向键被按住”这种输入状态服务端统一推进游戏逻辑把所有人的坐标、宝石状态算好后广播回去。这就是服务端权威模型Server Authority。为什么不能让客户端算完坐标再发给对方因为两个客户端的网络延迟不同各自推进一帧后Fireboy 的“我到了门口”和 Watergirl 的“我也到了门口”在时间上对不齐双方看到的相对位置会出现几帧到几十帧的偏差。更隐蔽的问题是可靠性客户端 A 算出了合法坐标发给 B但 A 本地已经偷偷穿墙的话B 无法验证。服务端权威模型下客户端只是“遥控器”所有坐标由服务端计算再统一分发两个客户端拿到的状态完全同源不会分叉。这个模型也顺带解决了掉线问题。客户端从来不“拥有”角色只是声明输入。它断线后服务端可以直接判定游戏结束或让角色暂停而不需要纠结“它最后报的坐标还作不作数”。对双人合作游戏这个模型让两个人的体验严格绑定在同一份世界状态上玩家之间不用互相猜测对方位置。2.3 状态同步还是帧同步选状态同步的实际理由除了“同步谁”还要决定“怎么同步”。这里有两套主流方案状态同步和帧同步。不少教程会把帧同步包装得很高级但它对游戏逻辑的确定性要求极高不适合新手项目。对比项状态同步帧同步同步内容同步计算结果坐标、血量、宝石状态同步操作指令每帧的按键输入客户端逻辑只做表现和输入逻辑在服务端每个客户端都要完整跑一遍游戏逻辑一致性强弱服务端统一计算天然一致依赖双端逻辑完全确定浮点差异都会导致分叉断线容忍度掉线一方暂停另一方继续收状态掉线导致帧缺失全局逻辑错乱实现难度低服务端一个循环搞定高需要帧号对齐、逻辑确定性保证适用场景休闲、解谜、RPG、MOBA格斗、RTS、竞速等强实时对抗状态同步的核心是一个独立的游戏循环每 50ms 收齐两个玩家的输入推进一次物理和碰撞广播一帧状态。帧同步则是把输入广播给所有客户端每个客户端各自跑逻辑。森林冰火人没有任何需要逐帧精确判定的机制宝石收集顺序、开关触发这类交互用状态同步完全够而且你在服务端打断点就能调试比帧同步“双方逻辑必须一模一样”的约束省心得多。2.4 协议帧先定成一行JOIN、IN、STATE写网络程序先定协议再写代码这是绕着坑走的关键一步。我习惯把协议定义成“一行一个帧字段用竖线分隔”人眼能读nc能模拟出问题也好抓。对这个项目三个帧足够覆盖核心流程帧方向帧格式说明客户端 → 服务端JOIN|playerId第二人加入后双方都进入等待开局状态客户端 → 服务端IN|playerId|bits四位二进制分别表示 上/左/下/右 是否按住服务端 → 客户端STATE|frame|p1x|p1y|p2x|p2y|gemMask帧号、双人坐标、宝石状态位掩码playerId用 1 和 2 区分两个客户端服务端用连接顺序分配。bits是这章的细节把四个方向键的布尔状态压成一个字节比逐字段传uptrueleftfalse更干净也方便服务端解析。先写一个输入状态类把按键状态收集和协议解析分开public class InputState { volatile boolean up, left, down, right; // 把按键状态压成 4 位二进制例如 上左 0b0101 int toBits() { return (up ? 1 : 0) | (left ? 2 : 0) | (down ? 4 : 0) | (right ? 8 : 0); } // 从协议帧里的 bits 还原成布尔值例如 10 下 static InputState fromBits(int bits) { InputState s new InputState(); s.up (bits 1) ! 0; s.left (bits 2) ! 0; s.down (bits 4) ! 0; s.right (bits 8) ! 0; return s; } }volatile在这里比synchronized更合适客户端接收线程要频繁写入这四个布尔值游戏循环线程要频繁读取volatile保证可见性且没有锁竞争读取方永远能拿到“最近一次完整写入”的状态。toBits()把四个布尔压缩成一个int传输时只需要一个数字fromBits()是它的逆操作服务端拿到IN|1|5就知道玩家 1 按了上和左。协议里不需要ACK帧因为 TCP 本身已经保证帧的到达与顺序应用层确认在这里是多余的。2.5 线程模型接收线程只写游戏循环只读服务端建议开三类线程一个监听线程负责ServerSocket.accept()每接收一个客户端就启动一个读线程一个游戏循环线程负责每 50ms 推进世界状态主线程只负责编排不参与业务。客户端线程更少一个发送线程轮询键盘状态主线程阻塞读STATE帧并触发渲染。这里有个 Java 多线程等待的经典陷阱很多新手会想用CountDownLatch(2)等两个玩家到齐再开游戏。问题是如果第二个玩家永远不连服务端整个线程就会永久阻塞没有任何超时机制。常见做法是监听线程直接往并发集合里塞连接游戏循环每次检查clients.size() 2不满足就继续等。这样即使只有一个人连上来服务端也能正常响应不会挂死还方便你后期加“等待中”的界面。3. 用原生 Java Socket 把双人管道搭出来3.1 服务端先连上两个玩家再启动游戏循环先写服务端骨架它只做三件事接受两个客户端、独立线程跑游戏循环、断开时通知对方。这里给一个可以直接跑起来的最小服务端代码省略了具体碰撞检测但把联机管道的结构完整表现出来了public class ForestServer { private static final int PORT 8000; private static final int TICK_MS 50; // 连接顺序即玩家 ID1 为火人2 为水人 private final MapInteger, Socket clients new ConcurrentHashMap(); private final MapInteger, InputState inputs new ConcurrentHashMap(); public void start() throws IOException { // 先启动游戏循环避免连上后没人处理逻辑 new Thread(this::gameLoop, game-loop).start(); try (ServerSocket server new ServerSocket(PORT)) { System.out.println(等待两位玩家接入端口 PORT); while (clients.size() 2) { Socket socket server.accept(); int playerId clients.size() 1; clients.put(playerId, socket); inputs.put(playerId, new InputState()); System.out.println(玩家 playerId 已连接); // 每个客户端独占一个读线程互不阻塞 new Thread(new ClientReader(playerId, socket), reader- playerId).start(); } System.out.println(两位玩家已到齐游戏开始); } } private void gameLoop() { int frame 0; while (clients.size() 2) { // 读取两个玩家的输入状态推进游戏逻辑 InputState p1 inputs.get(1); InputState p2 inputs.get(2); int p1x move(p1); // 按输入计算位移真实项目里这里是碰撞检测 int p2x move(p2); // 把计算好的状态广播给两个客户端 String state STATE| frame | p1x |0| p2x |0|0; broadcast(state); try { Thread.sleep(TICK_MS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } private void broadcast(String msg) { for (Socket s : clients.values()) { try { PrintWriter out new PrintWriter(s.getOutputStream(), true); out.println(msg); } catch (IOException e) { // 发送失败说明对方掉线交给 ClientReader 处理 } } } }synchronized在这里没有出现因为并发写入只发生在ClientReader里而它只调用InputState的volatile字段写入clients和inputs两个集合用的是ConcurrentHashMap遍历和插入可以并发。游戏循环里读取输入状态不需要锁因为volatile保证读到的是最近完整写入的值。TICK_MS是体验的关键参数50ms 对应 20Hz 的同步频率。对森林冰火人这个节奏20Hz 足够往上提到 10ms 会让服务端 CPU 占用明显上升但玩家感知不到差别这个参数后续可以做成配置文件。广播方法里每次新建PrintWriter是教学简化的写法实际项目应在建立连接时就保存输出流避免每帧重复创建 IO 对象。3.2 客户端的输入与渲染分离键盘事件不能直接改坐标客户端最容易写错的地方是把键盘监听器直接改角色坐标。单机游戏可以这么干联机版不行你按下方向键那一刻服务端还不知道直接改本地坐标会让角色“先跑再被拽回来”。正确姿势是键盘监听器只维护“按键状态集合”发送线程周期性把这个集合转成IN帧推给服务端主线程读取STATE帧更新渲染坐标。public class ForestClient { public static void main(String[] args) throws Exception { // 两个参数服务端地址、玩家 ID Socket socket new Socket(args[0], 8000); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter(socket.getOutputStream(), true); // 窗口与按键状态集合 SetInteger pressedKeys ConcurrentHashMap.newKeySet(); GameFrame frame new GameFrame(); frame.addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { pressedKeys.add(e.getKeyCode()); } Override public void keyReleased(KeyEvent e) { pressedKeys.remove(e.getKeyCode()); } }); // 发送线程每 10ms 轮询一次键盘把状态帧推给服务端 new Thread(() - { while (true) { int bits 0; if (pressedKeys.contains(KeyEvent.VK_W)) bits | 1; if (pressedKeys.contains(KeyEvent.VK_A)) bits | 2; if (pressedKeys.contains(KeyEvent.VK_S)) bits | 4; if (pressedKeys.contains(KeyEvent.VK_D)) bits | 8; out.println(IN| args[1] | bits); try { Thread.sleep(10); } catch (InterruptedException e) { return; } } }, input-sender).start(); // 主线程阻塞读状态帧收到一帧刷新一帧画面 String line; while ((line in.readLine()) ! null) { if (line.startsWith(STATE|)) { frame.updateAndRepaint(line); } } } }发送线程用 10ms 轮询是比KeyEvent本身更可靠的输入采集方式。操作系统在长按方向键时会自动重复触发keyPressed拍几下会产生十几个事件轮询方式每次都先读一遍pressedKeys全集天然过滤重复事件。主线程的readLine()是阻塞的收到STATE帧才刷新画面所以渲染频率和服务端TICK_MS严格一致。帧号字段暂时没用上但一定要留着后面做插值、统计延迟都会用到它。GameFrame的updateAndRepaint把STATE帧里的四个坐标解析出来画成两个圆就能在窗口里看到两个角色同步移动。3.3 掉线检测客户端退出游戏不能卡死联机小游戏被问得最多的问题就是“对面把窗口关了怎么办”。TCP 长连接有一个特点对端正常关闭时本端readLine()会返回null但对端如果是断网或断电本端可能很长一段时间毫无感知。所以掉线检测要做两层。第一层是读线程捕捉异常。ClientReader里readLine()返回null或抛出IOException时要从clients和inputs中移除对应 ID并给另一个客户端发EXIT帧。第二层是服务端游戏循环的退出条件clients.size() 2时循环结束整个游戏停止。这里有个 Java 多线程等待的细节ClientReader和gameLoop之间不要用Thread.join()串行等待否则一个玩家掉线会把整个服务端卡住用集合状态传递信息是最简单的。客户端掉线后的表现也要设计不是直接退出而是在界面上显示“等待对方重连”。对应重连功能可以给客户端加一个重试逻辑但双人本地联机场景里关闭游戏比断线重连更常见先保证“退出一方不会拖垮另一方”就足够。4. 联机跑起来之后参数怎么调、坑怎么填4.1 粘包半包按住方向键狂发 IN为什么读到的还是完整的TCP 是字节流没有“消息边界”连续发送多个IN帧时接收方可能一次读到半条、两条甚至三条帧。第 2 章把协议定成“一行一帧”配合BufferedReader.readLine()这个坑已经被填掉一大半readLine()按\n切分只要发送端保证一帧末尾有换行符且帧内不含换行读端永远按行取出完整帧。这里需要留意的是字符集。协议帧如果包含中文比如JOIN|火人发送端和接收端必须统一用 UTF-8否则readLine()读到的是乱码甚至多字节字符被截断。常见做法是网络层一律只传 ASCII 字符需要中文说明的场景放到客户端本地映射表里。协议里的数字和竖线没有编码歧义这是它作为教学协议最大的优点。4.2 键盘状态模型为什么用 keyPressed 存状态而不是 keyTyped 发指令联机输入一定要用“状态”而非“事件”。你按下方向键的瞬间产生一个KeyEvent把它当一次性指令发给服务端服务端移动一格就停了按键还没松开TCP 重传或延迟导致下一帧没送到角色就永远卡住。换成状态模型后客户端每 10ms 都在发“我现在按着什么”服务端每 50ms 读一次延迟一帧最多让角色晚 50ms 启动永远不会出现“指令丢了角色不动”的问题。KeyListener本身还有一个坑组合键。火人和水人共用一套键盘时比如火人按D同时水人按W在同一个KeyEvent里可能只触发一个键但联机版本客户端各自独立这个问题不存在。如果未来想改回本机双人同屏就要自己管理焦点和按键状态合并。4.3 网络参数调优该设多少、改哪里都在这张表里联机效果不好九成出在参数上而不是代码逻辑上。把关键的几个参数统一成常量集中管理比散落在代码里好调得多参数建议值说明服务端TICK_MS50ms20Hz 状态同步下调到 16ms 对这类游戏无感知收益客户端输入轮询间隔10ms保证按键状态在 1 个 tick 内被服务端读到接收线程阻塞超时3000ms配合心跳检测掉线超过无数据即判定连接失效心跳包间隔1000ms空闲时发送空IN帧保活兼做心跳Socket 缓冲区默认即可这类小帧协议不需要手动调大坐标系精度int网络状态同步用double反而容易引入不必要的精度误解心跳这里有个可复用的小技巧客户端正常操作时每 10ms 就会发一个IN帧不需要额外的心跳包客户端闲置时发送“空状态”IN|1|0就行既保活又不增加逻辑负担。服务端用读取超时来兜底3 秒没收到任何帧就把该玩家标记为掉线。这个超时值不宜太小Wi-Fi 环境一次丢包重传就可能超过 1 秒设 3000ms 是保险的。5. 作品化收尾本地自测、Netty 演进和面试应答5.1 用一行命令验证联机是否真的成立双人联机最容易被质疑的是“你是不是在一台机器上跑了两个线程假装联机”。验证方法很简单同时启动两个独立的客户端 JVM 进程通过127.0.0.1连同一个服务端。两个进程不共享任何内存交互全部走 TCP这就是货真价实的网络联机。更严谨的协议验证用nc模拟第二个玩家printf JOIN|2\nIN|2|0001\n | nc 127.0.0.1 8000这条命令把玩家 2 的加入帧和前进输入直接发给服务端如果服务端日志打出“两位玩家已到齐”且玩家 1 窗口里的水人开始移动说明协议不依赖客户端程序也能工作配合抓包工具还能看到STATE帧的广播内容。如果nc不可用Windows 下可以用 PowerShell 的System.Net.Sockets.TcpClient发同样的文本。5.2 从原生 Socket 到 Netty简历上值得写的一句演进能用原生 Socket 把流程跑通说明你理解了连接管理、线程模型和协议解析。但要过面试还要能说清楚“如果玩家数变多这个架构哪里会崩”。原版 Socket 的瓶颈是每个客户端一个线程1000 个连接就要 1000 个线程线程上下文切换会拖垮服务端。Netty 的线程模型正好解决这个核心问题多路复用器用少量线程管理大量连接一个NioEventLoopGroup就能承载上万连接编解码逻辑拆成pipeline里的一个个handler粘包半包问题可以直接用LengthFieldBasedFrameDecoder解决不用自己维护readLine()。演进后的代码玩家输入帧解码成一个Pojo对象经过业务handler后由ChannelGroup.broadcast()广播出去第 3 章的手写循环在 Netty 里变成了责任链的一段。简历上写“基于 TCP 长连接 服务端权威模型实现双人状态同步原生 Socket 版本可演进为 Netty 多路复用架构”比写“用 Java 做了一个小游戏”有信息量得多。面试官顺着这个描述问线程模型、粘包拆包、心跳保活你都回答得上项目就立住了。5.3 面试必问三连锁、同步模型、扩展点面试官针对这个项目通常连问三个问题回答思路要提前备好。第一问“两个客户端同时改状态你怎么加锁”答案是不需要锁。接收线程只写volatile字段游戏循环只读集合用ConcurrentHashMap避免的是锁竞争而不是回避并发。第二问“为什么不用帧同步”答案落到确定性约束和实现成本上不用贬低帧同步。第三问“怎么扩展成 4 人联机”答案是改playerId映射、STATE帧按角色列表拼接服务端权威模型天然支持多角色。最后留一个每次演示都用得上的技巧把服务端 IP 和端口提取成-Dserver.host127.0.0.1 -Dserver.port8000启动参数Main里用System.getProperty读取下次换机器演示不用重新编译直接改启动脚本就行。本文还有配套的精品资源点击获取
返回列表