
简介面向初学Java与数据结构的学生这份大一下课程设计实现经典“森林冰火人”双人联机玩法覆盖GUI开发、事件响应、双人协作逻辑等知识点适合作为Java大作业或算法练手项目。压缩包共68个文件约2.42MB其中包含11个java源码、15个class编译文件、7个gif动画与31张jpg/png图片素材另有properties配置、xml工程文件和README说明文档目录结构清晰便于直接导入运行和学习。程序已经过测试可正常运行源码与资源文件齐全读者可对照代码理解界面绘制、角色控制和联机交互的实现思路也可在此基础上扩展功能完成课程设计。目前已有698人学习下载适合需要快速上手Java GUI小游戏开发的同学参考。1. 双人联机森林冰火人大一下最有“项目感”的Java练手目标班里三十个人交Java大作业二十八个是图书管理系统和计算器剩下一两个敢碰联机小游戏的基本就是高分预定。不是因为他们代码写得有多花哨而是“大一下”这个阶段能把Java基础、面向对象、集合框架、多线程、Socket网络编程、Swing界面这些东西同时串进一个能玩的项目里本身已经说明你把这些知识点学活了。森林冰火人这个选题最讨巧的地方在于它规则足够简单——火人和冰人各自行动、互相配合过关但“双人联机”这四个字又把难度从单机搬到了两台电脑之间的实时通信上。这篇笔记就按我后来带学弟学妹做这个题目的完整套路把方案选型、核心代码、踩坑记录和打包交付一次讲完。2. 联机方案怎么选Swing界面上跑TCP别想复杂了2.1 为什么大作业首选SocketNetty、RMI这些不是不香是答辩时说不清很多同学一上来就搜“Java 联机游戏 框架”看到Netty、RMI、WebSocket一脸兴奋觉得用了这些才显高级。但你要清楚大作业的验收逻辑老师看重的是你能否把代码讲清楚、能否现场演示、能否说清每一步为什么这么写。Netty的Reactor线程模型你解释起来费劲RMI的远程调用在某些网络环境下配置起来让你抓狂最后发现全部精力耗在环境上而不是游戏本身这就是典型的“技术选型脱离场景”。大一下阶段做双人联机我的建议只有一条用Java自带的ServerSocket和Socket跑TCP协议消息格式用简单的文本协议。TCP自带连接管理、可靠传输、数据有序到达你不用处理丢包重传文本协议调试时一眼能看懂抓个包就能排查问题。UDP虽然传输更快但大作业不需要那点性能优势反而要自己处理丢包、乱序、连接状态纯给自己挖坑。至于端口选一个像6000这样的高位端口避开容易被占用的8080、3306也避开了需要管理员权限才能绑定的1024以下端口。2.2 主机-客户端结构把服务端代码和客户端代码拆成两个类联机方案定为TCP之后第一个动作是决定连接拓扑。双人联机最简单可靠的形式是“一主一从”一方启动服务器作为主机另一方填IP地址和端口作为客户端加入。这个模式不需要独立的服务器机器主机既跑游戏又做转发对森林冰火人这种每秒钟只同步几次状态的慢节奏游戏来说负载完全可控。服务端的核心逻辑就是监听端口、等客户端连入、握手成功后进入游戏转发状态。我用一个单独的GameServer类来管理避免和客户端代码混在一起。最小可运行的监听代码大概是这样的// GameServer.java —— 仅保留核心监听与连接建立 import java.io.*; import java.net.*; import java.util.concurrent.CopyOnWriteArrayList; public class GameServer { private ServerSocket serverSocket; // CopyOnWriteArrayList遍历时允许并发修改安全性比ArrayList好 private static CopyOnWriteArrayListSocket clients new CopyOnWriteArrayList(); public void start(int port) throws IOException { serverSocket new ServerSocket(port); System.out.println(服务器已启动等待玩家加入...); while (true) { Socket socket serverSocket.accept(); // 阻塞等待客户端连接 clients.add(socket); System.out.println(玩家接入 socket.getInetAddress().getHostAddress()); // 每个客户端单独开一个线程处理收发防止一个客户端阻塞影响另一个 new Thread(() - handleClient(socket)).start(); } } private void handleClient(Socket socket) { try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8))) { String msg; while ((msg in.readLine()) ! null) { broadcast(msg, socket); } } catch (IOException e) { System.out.println(客户端断开 socket.getInetAddress()); } finally { clients.remove(socket); try { socket.close(); } catch (IOException ignored) {} } } private void broadcast(String message, Socket from) { for (Socket s : clients) { if (s from) continue; // 不回传给发送者由本地直接执行 try { PrintWriter out new PrintWriter( new OutputStreamWriter(s.getOutputStream(), UTF-8), true); out.println(message); } catch (IOException e) { System.out.println(转发失败 s.getInetAddress()); } } } public static void main(String[] args) throws IOException { new GameServer().start(6000); } }代码的逻辑很清楚accept()阻塞等待玩家每连上一个就存入clients列表并新建线程处理handleClient循环读取一行消息读到了就通过broadcast转发给另一个玩家。注意PrintWriter的第二个参数true表示自动刷新不写这个参数你发出去的数据会因为缓冲不到达对端这是新手最容易忽略的细节。另外我在输入输出流的构造里都明确指定了UTF-8编码后面打包运行时乱码问题就少了一半。2.3 消息协议先握手再同步状态服务端转发什么消息、按什么格式转发这决定了整个联机系统的复杂度。我的选择是“按键指令同步”客户端把玩家自己的操作实时发过去对端收到后解析、驱动对端屏幕上的角色移动。消息格式极其简单一行字符串用竖线分割类型和参数例如move|PLAYER1|left|1 jump|PLAYER1|1不需要序列化对象不需要JSON库手写解析加拼接不超过二十行。为什么不用ObjectOutputStream直接传对象因为不同JDK版本之间序列化兼容性有时候很微妙而且肉眼不可见排错难度大。文本协议打开控制台就能看到消息在跑课堂演示时说“看我这里按了D键消息就发出去了”说服力拉满。客户端的连接代码对应如下连接成功后立即进入游戏主界面// GameClient.java —— 建立连接并启动读线程 import java.io.*; import java.net.*; public class GameClient { private Socket socket; private BufferedReader in; private PrintWriter out; private GamePanel panel; // 后面第4章会写的画面类 public void connect(String serverIp, int port) throws IOException { socket new Socket(serverIp, port); in new BufferedReader(new InputStreamReader( socket.getInputStream(), UTF-8)); out new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), UTF-8), true); System.out.println(连接成功 serverIp : port); // 读线程持续接收服务器转发过来的对方操作 new Thread(() - { String msg; try { while ((msg in.readLine()) ! null) { if (panel ! null) panel.handleRemoteMessage(msg); } } catch (IOException ignored) {} }).start(); } public void sendMove(String direction) { out.println(move| direction); } }参数说明里有两个点值得注意。第一是连接超时Socket构造器如果不指定超时连接不上时会卡住十几秒甚至更久我建议connect前先调socket.setSoTimeout(3000)连不上就快速报错第二是IP地址客户端连接时填的是主机的局域网IP不是自己的IP这个错误我见过至少五次两台机器连不上先看这个。心跳消息我在这个最小实现里没有加但正式提交时建议每两秒发一行heartbeat服务端八秒没收到就判定掉线并通知另一端。3. 森林冰火人的玩法核心一个“世界”两份逻辑3.1 双角色机制是道很好的状态机题森林冰火人的核心规则提炼出来其实很清晰火人能走过火池、但不能碰冰池冰人能走过冰池、但不能碰火池有些门需要两人站到对应的两个机关上才会打开。这套机制特别适合用面向对象表达。不要在一张Map里塞一堆String把角色行为和状态封装成两个类代码可读性会提升一个档次。我一般这么组织数据结构一个GameWorld类保存整个关卡的地图二维数组每个格子的类型用枚举定义FirePlayer和IcePlayer各自持有自己的坐标和一个“当前能走的格子集合”// GameWorld.java —— 地图格类型定义与角色移动判断 public class GameWorld { // 格子类型FIRE_FLOOR 火池ICE_FLOOR 冰池 // NORMAL 普通地砖WALL 墙GATE 门SWITCH 机关 public enum TileType { FIRE_FLOOR, ICE_FLOOR, NORMAL, WALL, GATE, SWITCH } private TileType[][] map; // 行列二维数组行是y方向列是x方向 private int rows, cols; public GameWorld(TileType[][] map) { this.map map; this.rows map.length; this.cols map[0].length; } // 判断角色能否移动到目标格子 public boolean canMove(int x, int y, boolean isFirePlayer) { if (x 0 || y 0 || x cols || y rows) return false; // 越界 TileType tile map[y][x]; if (tile TileType.WALL) return false; // 属性克制判断火人过火池、冰人过冰池都是安全的 if (tile TileType.FIRE_FLOOR) return isFirePlayer; if (tile TileType.ICE_FLOOR) return !isFirePlayer; return true; } }这段代码把整个游戏的碰撞规则压缩到了一个方法里后续移动、AI寻路、联机同步都复用它。注意棋盘类游戏坐标系和屏幕坐标系很容易搞混这里x对应列、y对应行二维数组第一层是行也就是y方向。代码里我特意给参数顺序写了注释因为真的有人在传参时把map[y][x]写成map[x][y]然后角色贴图全乱套。3.2 碰撞检测用“目标格判定”而不是像素检测森林冰火人的移动逻辑走的是网格制——角色从一格走到另一格不是像素级自由移动。这极大简化了碰撞检测不需要矩形相交算法不需要考虑贴图边缘的像素重叠只需要判断“目标位置那个格子里有没有障碍”。这就是我在canMove里的做法逐格判定替换成整个碰撞系统的唯一入口。移动的执行方法写起来顺手很多以火人为例// FirePlayer.java —— 角色移动与机关触发逻辑 public class FirePlayer { private int x, y; // 当前所在格子坐标 private boolean isAlive true; public boolean move(int dx, int dy, GameWorld world) { if (!isAlive) return false; int newX x dx; int newY y dy; if (!world.canMove(newX, newY, true)) return false; // 火人传入true x newX; y newY; // 踩到机关通知世界对象更新门状态 world.onPlayerStep(x, y); return true; } public boolean isOnSwitch(GameWorld world) { return world.getTile(x, y) GameWorld.TileType.SWITCH; } }参数dx和dy取值就是{-1, 0, 1}一次只传一个方向。这个设计让上下左右四个方向的按键处理代码对称不会出现“按D往左跑”的经典bug。机关的联动需要在GameWorld里维护一个“当前激活的机关数量”计数器当两个机关都被踩住时开门有一个松开就关门。这里有个小设计如果追加“只有冰人踩的机关才算数”这类条件就把角色类型也传进onPlayerStep这对大作业拿加分很有效因为老师最喜欢问“你这个机关的触发条件能不能改”。3.3 同步的真相别人动什么你只播什么联机同步方案我选择的是“指令同步本地即时执行”这是网络游戏领域最常见的技术路径之一。每个客户端对自己控制的角色有绝对权威按键按下的一瞬间本地角色立即移动、画面立刻响应同时把移动消息通过服务器转发给另一端对方收到消息后把对应的角色移动到指定坐标但不会把这个角色的行为当作输入再回传。这种方案的好处是玩家体验极佳不用等网络回来坏处是如果两台机器状态差异变大会出现“我看到他在左边你看到他在右边”的现象。在类设计上要守住一条线本地玩家的操作直接调用自己的move方法远端玩家的操作则走GamePanel.handleRemoteMessage。我给两个角色分别命名firePlayer和icePlayer消息里带上角色标识解析消息时按标识路由到正确的实例上// GamePanel.java 中消息路由的关键片段 public void handleRemoteMessage(String msg) { String[] parts msg.split(\\|); if (parts.length 2) return; if (move.equals(parts[0])) { String direction parts[1]; int dx 0, dy 0; switch (direction) { case left: dx -1; break; case right: dx 1; break; case up: dy -1; break; case down: dy 1; break; default: return; } // 远端玩家是火人还是冰人决定调用哪个实例 if (fire.equals(parts[2]) firePlayer ! null) { firePlayer.move(dx, dy, world); } else if (ice.equals(parts[2]) icePlayer ! null) { icePlayer.move(dx, dy, world); } repaint(); } }一个关键设计点是在同步时不携带“目标坐标”只携带“移动方向”。原因是坐标依赖状态如果对端状态已经有偏差传坐标会把偏差固定下来传方向让对端同样基于自己的世界状态去计算新位置网络抖动导致的一两帧偏差会被后续操作自然纠正。这个思路在答辩时提出来非常加分它直接亮出了你对联机系统和状态一致性的理解深度。4. Swing渲染与双人输入别让你的游戏“看起来”像控制台程序4.1 Swing跑游戏主循环的三个约定EDT、Timer、双缓冲图形界面如果做得糙联机做得再好也白搭。大一下的Java课Swing是你唯一能稳稳拿分的选择——JavaFX虽然也能做但多数学校的课程里根本没教过答辩时老师一眼就能看出你是在课外补的反而容易问倒你。Swing做游戏界面的核心不是“会建窗口”而是懂三条约定。第一条约定是UI操作全部在事件分发线程EDT里执行不要在接收Socket消息的线程里直接调repaint那会触发跨线程访问Swing组件的并发问题轻则界面闪烁、重则直接抛异常。我一般做法是在GamePanel里维护一个“待处理消息队列”网络读线程往里塞消息EDT的定时器每次tick时从队列取消息并更新画面。第二条约定是游戏循环用Swing的javax.swing.Timer而不是while(true)死循环。Timer反复触发actionPerformed作为一帧默认间隙16毫秒约60帧完全够用。而真正的死循环会卡住EDT窗口拖动、按钮点击全部假死。这个坑我在第一次做贪吃蛇时踩过进程还在跑界面已经无响应了。第三条约定是双缓冲Swing的JPanel默认就是双缓冲的你只要不手贱重写update方法就不会闪。绘制时也尽量在paintComponent里全量重绘不要试图“只画变化的部分”省不了多少性能还容易画花。4.2 键盘监听KeyListener丢焦点怎么办双人联机意味着要对两个玩家的键盘输入做区分。如果是两台电脑联机这个问题很简单——每台电脑各管各的键盘本机按键控制本机角色。但如果是“一台电脑上双人玩”的本地模式你就需要给两个玩家分配不同的按键比如玩家1用WASD玩家2用方向键。这里最大的坑是KeyListener绑定在组件上一旦焦点被按钮或输入框抢走监听就没用了。我推荐的做法是给两个玩家各维护一个boolean[4]的按键状态数组然后用KeyAdapter做按下和释放的回调在有焦点问题的组件上主动补requestFocusInWindow()// GamePanel.java —— 键盘输入监听与按键状态维护 import javax.swing.*; import java.awt.event.*; public class GamePanel extends JPanel { private boolean[] p1Keys new boolean[4]; // 玩家1WASD private boolean[] p2Keys new boolean[4]; // 玩家2方向键 // 按键映射索引0上1下2左3右 private int[][] bindings { {KeyEvent.VK_W, KeyEvent.VK_S, KeyEvent.VK_A, KeyEvent.VK_D}, {KeyEvent.VK_UP, KeyEvent.VK_DOWN, KeyEvent.VK_LEFT, KeyEvent.VK_RIGHT} }; public GamePanel() { // 必须Focusable才能接收键盘事件 setFocusable(true); addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { handleKey(e.getKeyCode(), true); } Override public void keyReleased(KeyEvent e) { handleKey(e.getKeyCode(), false); } }); } private void handleKey(int keyCode, boolean pressed) { for (int player 0; player 2; player) { for (int i 0; i 4; i) { if (keyCode bindings[player][i]) { p1Keys[i] pressed; // 这里只写了p1实际应该按player分开写 } } } } }注意上面示例里我是用p1Keys数组示范按键判定的逻辑实际写代码时应该改成二维数组pressed[2][4]第一个维度是玩家序号第二个维度是方向索引。两个玩家同时按键时不会互相覆盖因为两个玩家的按键映射是分离的哪怕他们在同一台电脑上操作也不会冲突。其实在联机模式下本机只处理一个玩家的输入本地模式才用这套双玩家绑定我想强调的还是“按键状态不丢”这个核心问题——pressed和released是成对事件只处理按下不处理释放角色就会“跑起来停不住”。4.3 画面渲染顺序先地板再机关再角色绘制顺序有一点模式化的经验先把整个地图块根据类型画出来再画机关和门最后画两个角色另外角色身上标清名字区分谁是谁。这样迭代顺序清晰从底层往上叠后面的绘制内容会盖住前面的角色永远显示在地图之上。坐标换算的逻辑值得单独说明。地图是行列的抽象世界画到屏幕上需要乘上瓷砖的像素宽高。我习惯把瓷砖尺寸设成40像素一个13x11的关卡正好是520x440像素的窗口。关键参数是瓷砖尺寸必须统一在一处定义不要散落在地图类、绘制类、碰撞类里各写一遍后期调尺寸时只改一个常量public static final int TILE_SIZE 40; protected void paintComponent(Graphics g) { super.paintComponent(g); for (int y 0; y world.getRows(); y) { for (int x 0; x world.getCols(); x) { // 根据tile类型挑选颜色或贴图 java.awt.Rectangle rect new java.awt.Rectangle( x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); g.setColor(switchColor(world.getTile(x, y))); g.fillRect(rect.x, rect.y, rect.width, rect.height); } } // 最后画角色使用角色世界坐标乘以瓷砖尺寸换算屏幕位置 if (firePlayer ! null) { g.setColor(java.awt.Color.ORANGE); g.fillOval(firePlayer.getX() * TILE_SIZE, firePlayer.getY() * TILE_SIZE, TILE_SIZE, TILE_SIZE); } // icePlayer同理用蓝色绘制 }这里我用fillOval先顶替角色贴图等美术资源准备好后再换成Image绘制。逻辑不变先把框架跑起来再美化这个节奏更适合大作业这种时间紧、目标为“能交、能演示”的场景。5. 联机森林冰火人最容易翻车的5个地方5.1 现象两台电脑连不上客户端卡住不动表现是输入IP和端口点连接后界面没反应过十几秒才报超时。原因有两类一是IP地址填错明明主机的局域网IP是192.168.1.20填成192.168.1.2或者填了127.0.0.1指向本机二是主机的防火墙拦截了Java进程的入站端口Windows默认会拦。解决方法是先ping主机的IP确认网络通再关闭主机的防火墙或单独放行6000端口最后确认客户端填的确实是主机的IP而不是自己的IP。这个坑在现场演示时出现概率极高强烈建议正式答辩前用两台真实电脑完整演练一遍并且把“关闭防火墙”写进README。5.2 现象画面严重闪烁角色拖影表现是游戏窗口像老式电视在闪角色旁边拖着残影。原因是paintComponent里没有清屏或者线程模型跑飞了。很多人以为多画几笔是闪的原因其实更常见的是在非EDT线程里调了repaint反复触发重绘和Swing的计时器打架。解决方法是确保所有画面更新都走javax.swing.Timer的actionPerformed不要在Socket读线程里直接调repaint同时检查是否在paintComponent第一行调super.paintComponent(g)这一步负责把上次的画面清干净。双缓冲的事Swing默认做好了不用自己动手反而是这些习惯问题更值得注意。5.3 现象角色松开按键还在继续跑表现是角色像“有惯性”一样按下方向键松开后它还在走。原因是只处理了keyPressed没有处理keyReleased。Swing不会自动记住你的按键状态按下和抬起是两个独立事件不处理抬起事件按键状态就一直认为还是按着的。解决方法是补全KeyAdapter的keyReleased把对应方向的状态置为false。另一个隐蔽原因是焦点被切走窗口失焦后keyReleased事件没有触发所以出现“走丢了”的假象比较稳妥的做法是在窗口失焦时把所有按键状态清零。5.4 现象服务端退出后端口还占用第二次启动报BindException表现是第一次运行完关掉程序再启动时报java.net.BindException: Address already in use。原因是Socket和ServerSocket没有关干净进程虽然结束了但操作系统还在TIME_WAIT状态。解决方法是两点一是程序里保证退出时调用socket.close()和serverSocket.close()二是用try-with-resources包裹连接对象即使异常退出也会自动关闭。如果还是占着端口Windows下netstat -ano查进程号和PID再taskkill强制结束这是运维习惯不是程序问题。5.5 现象打包成JAR后中文全部变成问号表现是双击JAR运行游戏里的“开始”“设置”“得分”全部变成乱码。原因特别简单编译时的字符编码和打包时的编码不一致或者运行时JVM没使用UTF-8解析。解决方法统一从三个层面处理所有.java源文件保存为UTF-8编码编译命令javac -encoding UTF-8运行命令java -Dfile.encodingUTF-8 -jar。另外第2章代码里我特意给输入输出流都指定了UTF-8如果不写Windows默认GBKSocket跨平台传中文开关消息时就会乱。这个坑很多人要拖到答辩前一晚才遇到早点处理能省一整晚的睡眠。6. 从能玩到能交打包、验收与一点优化习惯代码写完了接下来要干的事是让老师能一键跑起来。不要默认老师会用IntelliJ IDEA打开你的代码很多老师习惯命令行跑。我建议你交付时给出两个入口一个提供可执行的JAR包另一个保留源码目录结构。打包最简单的路线是IDEA的Artifacts功能File - Project Structure - Artifacts - JAR - From modules with dependencies选择包含main方法的类建议做一个专门的GameLauncher入口类同时提供“创建主机”和“加入主机”两个按钮避免玩家需要命令行传参构建完就能得到一个独立运行的JAR。命令行下验证这套交付物是否合格只需三步# 第一步确认JDK版本满足要求 java -version # 第二步启动服务端/主机 java -Dfile.encodingUTF-8 -jar ForestAndFireBoy.jar # 第三步另一台机器连接 java -Dfile.encodingUTF-8 -jar ForestAndFireBoy.jar --join 192.168.1.20验收时给自己列一个清单逐项打勾两台电脑防火墙是否放行、主机能创建房间、客户端能加入、双人在各自屏幕都能看到两个角色同时移动、门和机关状态一致、掉线时另一端有提示不崩溃、窗口关闭后Java进程真的退出这个很好检查任务管理器里看有没有残留的java进程。这些跑通了大作业的现场演示就成功了一大半。最后说两个容易被忽视但会让老师眼前一亮的小优化。第一个是消息收敛联机同步时不要每帧发消息而是检测到按键状态变化时再发否则一秒几十条无意义消息会暴露你的性能设计短板。第二个是给游戏加一个简单的暂停逻辑双方都按空格才继续这个实现不难但体现的是对游戏体验细节的思考答辩时你能主动讲出这两条就说明你不是在“交作业”而是在“做项目”。我自己当年这个题目做砸过一个小细节主机端一切正常客户端角色移动总比主机慢半拍。查了一晚上最后发现是发送消息时每次都new了一个PrintWriter导致输出流被反复重建和关闭。后来统一改成在连接建立时创建一次、持有复用问题立刻消失。这种教训你遇过一次就会长记性所以我特别建议你在写联机模块时就先设计好流的生命周期不要等功能跑通再回头优化。做这样的项目本来就该先犯错再修复在试错里把Java基础、网络编程和调试手段一起练扎实。希望帮你避开我当年那些不必要的坑让你把时间花在真正拉开差距的玩法设计和答辩展示上。本文还有配套的精品资源点击获取