简介:这份资源是一份基于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是这章的细节:把四个方向键的布尔状态压成一个字节,比逐字段传up=true&left=false更干净,也方便服务端解析。先写一个输入状态类,把按键状态收集和协议解析分开:
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; // 连接顺序即玩家 ID:1 为火人,2 为水人 private final Map<Integer, Socket> clients = new ConcurrentHashMap<>(); private final Map<Integer, 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); // 窗口与按键状态集合 Set<Integer> 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_MS | 50ms | 20Hz 状态同步;下调到 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.host=127.0.0.1 -Dserver.port=8000启动参数,Main里用System.getProperty读取,下次换机器演示不用重新编译,直接改启动脚本就行。
本文还有配套的精品资源,点击获取