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

资讯详情

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

Java课设必备:MUD多人在线游戏源码,讲透网络编程、多线程与集合框架

Java课设必备:MUD多人在线游戏源码,讲透网络编程、多线程与集合框架 简介来自吉林大学Java程序设计课程98分高分项目的MUD多用户虚拟游戏模拟系统源码面向需要完成Java课程设计、期末大作业或毕业设计的同学也适合对多人在线交互机制感兴趣的编程初学者。压缩包体积仅68KB共包含40个文件其中以10个java源文件、16个class编译文件、1个project工程文件为主另有配置文件与备份副本代码注释细致src与bin目录分明便于对照理解编译与运行机制。目前已有42人学习下载。系统完整实现了多用户网络游戏的关键技术包括用户会话管理、实时数据交互、模块化功能组件等界面直观、操作逻辑清晰部署流程简便运行稳定且组件间耦合度低便于后续扩展与维护。整套源码经过实际高分验证能帮助读者快速掌握Java网络编程与面向对象设计核心方法是课程实践与课题复现的可靠参照。1. Java课设没思路这套MUD多人在线游戏源码把网络编程、多线程、集合框架一次讲透你以为课程设计交个图书管理系统就万事大吉了真正能拉开分数差距的反而是MUDMulti-User Dungeon多人地下城这种听起来很老派的文字游戏。MUD是最早的多人在线游戏形态一个服务器同时承载几十个玩家玩家用命令行走、战斗、聊天所有状态实时同步。这套源码正好是一个完整的 Java MUD 项目——从网络层到玩法层一样不少覆盖了课设和面试都会问到的 ServerSocket 编程、多线程并发、集合框架、I/O 与 JSON 持久化。目标人群很明确正在准备 Java 期末大作业、想找一个能讲清楚原理而不仅仅能跑的项目、以及想借课程设计把网络编程整个过一遍的人。你拿到的不是一个玩具 Demo而是一个可以编译运行、可以二次开发、答辩时站得住的完整系统。2. 项目骨架与网络通信层从 ServerSocket 到连接生命周期先别急着看玩法MUD 这种项目最吃紧的是网络层。一个玩家敲命令、服务器回结果这条链路不通后面所有设计都是白搭。这套源码的网络层走的是最朴素的 BIO 模型ServerSocket 接受连接、每个客户端一条独立线程、行协议收发文本。BIO 在几十人规模的课设里完全够用而且代码直观答辩时两三句话就能讲清楚阻塞发生在哪、谁负责读、谁负责写。2.1 包结构与类职责拿到源码先看包结构我习惯把类拆成“网络、玩家、玩法、持久化”四块这套项目也是这么分的每个类只干一件事后期改需求不牵连。类名所在包职责ServerMainmud.server服务器入口启动 ServerSocket接受连接ClientHandlermud.server每个客户端对应一条线程负责收发消息PlayerManagermud.server在线玩家注册表登录、登出、防重复登录Playermud.server玩家实体属性、背包、位置、经验CommandDispatchermud.server解析玩家输入分发到具体动作Room / MapBuildermud.server房间实体与地图静态初始化Monster / BattleSystemmud.server怪物实体与回合制战斗逻辑ItemSystemmud.server背包查询与物品使用DataStoremud.server玩家存档 JSON 序列化与反序列化SmokeTestmud.test模拟多客户端连接的自动化冒烟测试注意我把入口和业务逻辑分开了ServerMain 里不写任何玩法代码只负责监听和创建线程。答辩时老师问“服务器怎么启动的”你只需要指这一个类讲起来非常省事。2.2 服务器启动端口监听与连接接入服务器入口就是一个标准的 ServerSocket 循环代码很短但每一步都是考点。package mud.server; import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; /** * MUD 服务器入口 * 端口默认 4000可通过启动参数覆盖 */ public class ServerMain { private static final int DEFAULT_PORT 4000; public static void main(String[] args) throws IOException { int port DEFAULT_PORT; if (args.length 0) { port Integer.parseInt(args[0]); // 支持 java ServerMain 5000 自定义端口 } ServerSocket serverSocket new ServerSocket(port); System.out.println([MUD] 服务器已启动监听端口 port); while (true) { Socket socket serverSocket.accept(); System.out.println([MUD] 新连接 socket.getRemoteSocketAddress()); new Thread(new ClientHandler(socket)).start(); } } }这段的逻辑不复杂accept()是阻塞方法有客户端连上来才返回一个 Socket拿到 Socket 后立刻丢给新线程处理主线程继续等下一个连接。端口我默认定在 4000避开 8080、3306 这些容易跟本地其他服务冲突的号。如果你机器上 4000 也被占了启动时直接传参换端口不用改代码。为什么不在这里做连接数限制因为这是课设不是生产环境。真要限制可以在循环里加一个计数器超过 50 个连接直接socket.close()拒绝掉源码里没写但答辩时被问到“并发上限怎么控制”你可以现场补这一段。2.3 通信协议为什么用换行分隔文本MUD 的通信协议不需要搞二进制封包一条命令就是一行文本以\n结尾。客户端发look服务器回一长串房间描述也是以换行结尾。这叫行协议line-based protocol优点是实现简单、调试直观你甚至可以不用写客户端直接用 telnet 连上去玩。C: look S: 【村庄广场】 你站在村庄的广场上北面是森林入口南面是旅店。 出口north south 怪物野狼 C: go north S: 你走进了迷雾森林……行协议的代价是消息正文里不能出现换行符否则会把一条消息拆成两条。对文字游戏来说这不是问题因为命令和房间描述本身就不需要换行。我在 ClientHandler 里用BufferedReader.readLine()读指令天然按行切分服务端用PrintWriter.println()回消息每次调用自动补上换行符两端完全对齐。2.4 连接关闭与资源回收网络编程的隐藏考点不在建连而在断连。玩家点掉窗口、网线被拔、服务器主动踢人这些情况都要保证不泄漏线程、不丢存档。这套源码里 ClientHandler 把资源回收收在 finally 里主循环退出只有两种可能客户端断开导致readLine()返回 null或者抛出 IOException。package mud.server; import java.io.*; import java.net.Socket; public class ClientHandler implements Runnable { private final Socket socket; private BufferedReader in; private PrintWriter out; private Player player; public ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try { in new BufferedReader(new InputStreamReader( socket.getInputStream(), UTF-8)); out new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), UTF-8), true); out.println(欢迎来到 MUD请输入角色名); String name in.readLine().trim(); player PlayerManager.login(name); player.setClient(this); out.println(你好 name 输入 help 查看命令。); String line; while ((line in.readLine()) ! null) { player.touch(); // 更新最后活跃时间供心跳检测使用 String response CommandDispatcher.dispatch(player, line.trim()); out.println(response); } } catch (Exception e) { // 单个连接异常不拖垮服务器 } finally { PlayerManager.logout(player); try { socket.close(); } catch (IOException ignored) { } } } public void send(String msg) { out.println(msg); } }这里有两个容易被忽视的细节。第一PrintWriter构造器第二个参数传了true这是自动刷新开关每次println()都会把缓冲区内容立即发出去否则消息可能会憋在缓冲区里半天出不来表现就是客户端“假死”。第二finally 里先执行PlayerManager.logout(player)再关 Socket顺序不能反——logout 会触发存档保存如果先关连接再存档万一存档逻辑里需要发消息给客户端就报错了。我见过不少课设代码把顺序写反结果玩家下线时档案经常没存上。3. 多线程玩家管理并发注册表、命令分发与心跳踢人网络层通了之后第二个坎就是并发。MUD 场景下几十个玩家同时在线每个人都往服务器发命令玩家的上下线随时在发生如果用一个普通 HashMap 存在线玩家遍历和修改撞在一起轻则ConcurrentModificationException重则死循环。这套源码在第 3 层做了三件事用并发集合管玩家、用单一线程模型收发消息、用独立线程做心跳检测。3.1 在线玩家注册表从 HashMap 到 ConcurrentHashMap玩家管理的核心是一个全局在线表登录时 put登出时 remove。看起来简单线程安全是坑。package mud.server; import java.util.concurrent.ConcurrentHashMap; public class PlayerManager { private static final ConcurrentHashMapString, Player ONLINE new ConcurrentHashMap(); public static Player login(String name) { Player exists ONLINE.get(name); if (exists ! null) { // 同一账号被顶号踢掉旧连接 exists.getClient().send(你的账号在别处登录你被挤下线了。); exists.getClient().forceClose(); } Player player DataStore.load(name); // 没有存档则创建新角色 ONLINE.put(name, player); return player; } public static void logout(Player p) { if (p ! null) { ONLINE.remove(p.getName()); DataStore.save(p); // 下线即存档 } } public static Player get(String name) { return ONLINE.get(name); } public static ConcurrentHashMapString, Player all() { return ONLINE; } }选ConcurrentHashMap而不是Collections.synchronizedMap的原因前者读操作无锁写操作按桶加锁MUD 这种“读多写少”的场景并发性能好得多后者所有读写都串行化玩家一多就成瓶颈。另一个细节是login()里顶号的处理——同一角色名不能有两份在线状态否则会出现两个客户端同时操作一个 Player 对象的诡异局面经验值互相覆盖。顶号时把旧连接的 Socket 强制关掉旧线程的readLine()会立刻抛异常退出自然走完 finally 里的登出逻辑。3.2 每连接一线程ClientHandler 的主循环BIO 模型的核心思路是“每连接一线程”前面 2.4 节的 ClientHandler 就是这条线程的执行体。为什么这么设计因为readLine()是阻塞的一个连接配一个线程读命令、处理命令、回结果都在同一个线程里串行执行天然不需要额外加锁。这套模型面对 20~50 个并发玩家完全够用线程栈默认 1MB50 个线程也就 50MB 内存课设机器的内存负担不大。真要优化可以改用线程池比如Executors.newFixedThreadPool(30)但被池化的线程不能持有玩家状态否则玩家下线后线程还在得额外做状态清理复杂度反而上去了。对课程设计来说裸 Thread 足够而且更好讲。3.3 命令分发把字符串解析成动作命令分发是玩法的入口所有玩家输入都经过这一层。我用的方案是“前缀匹配 静态函数表”代码不多扩展命令时只改一个地方。package mud.server; import java.util.HashMap; import java.util.Map; import java.util.function.Function; public class CommandDispatcher { private static final MapString, FunctionPlayer, String SIMPLE new HashMap(); static { SIMPLE.put(help, p - 可用命令look、go 方向、fight、use 物品、bag、save、quit); SIMPLE.put(look, p - MapBuilder.roomOf(p.getRoomId()).getFullDesc()); SIMPLE.put(bag, p - p.getInventory().toString()); SIMPLE.put(save, p - { DataStore.save(p); return 存档完成。; }); } public static String dispatch(Player p, String raw) { String[] parts raw.split(\\s); String cmd parts[0].toLowerCase(); FunctionPlayer, String simpleAction SIMPLE.get(cmd); if (simpleAction ! null) { return simpleAction.apply(p); } if (go.equals(cmd) parts.length 2) { return Movement.go(p, parts[1]); } if (fight.equals(cmd)) { return BattleSystem.fight(p); } if (use.equals(cmd) parts.length 2) { return ItemSystem.use(p, parts[1]); } return 未知命令输入 help 查看帮助。; } }split(\\s)把一行命令按空白拆成数组go north会拆成[go, north]方向参数取parts[1]。SIMPLE这个静态函数表专门放无参数命令收到look直接查表执行避免了冗长的 if-else 链。新增命令时如果带参数就在dispatch里加一段 if 分支不带参数就往SIMPLE里塞一行改动非常局部。注意所有命令都返回 String由 ClientHandler 统一println回客户端命令执行层不直接碰 Socket——这样设计的好处是以后想接 WebSocket 或做单元测试只需要替换这层返回值逻辑完全不用动。3.4 心跳检测与异常下线处理玩家拔网线是常态服务端readLine()不会立刻感知到断开可能要好几分钟才抛异常。这段真空期里玩家对象一直挂在在线表里角色卡在地图上别的玩家还能看到它很尴尬。解决方案是心跳检测每次玩家发命令就更新lastActiveTime另有守护线程周期扫描超时直接断开。package mud.server; public class HeartbeatMonitor implements Runnable { private static final long TIMEOUT_MS 5 * 60 * 1000; // 5 分钟 private static final long SCAN_INTERVAL_MS 30 * 1000; // 30 秒扫一次 Override public void run() { while (true) { long now System.currentTimeMillis(); for (Player p : PlayerManager.all().values()) { if (now - p.getLastActive() TIMEOUT_MS) { p.getClient().send(长时间无操作服务器已将你断开。); p.getClient().forceClose(); } } try { Thread.sleep(SCAN_INTERVAL_MS); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); return; } } } }这里是拿System.currentTimeMillis()做超时判断不用System.nanoTime()因为我要比的是“是否超过 5 分钟”而非高精度时间差毫秒级足够。forceClose()里调用的是socket.close()这会打断阻塞中的readLine()让 ClientHandler 线程安全退出并触发存档。扫描线程要设成守护线程serverSocket的主线程退出后它跟着结束不会拖着 JVM 不退出。启动手段是在 ServerMain 的 main 里加一行new Thread(new HeartbeatMonitor()).start()但记得先设setDaemon(true)。4. 玩法核心房间地图、回合制战斗与背包系统的实现网络层和玩家管理立住之后剩下的问题在玩法侧玩家在哪个房间、房间里有谁、打怪怎么算伤害、打完什么时候升级。这套源码的玩法部分并不堆复杂系统而是用最经典的一套 MUD 设计把地图、战斗、物品三条链路串起来任何一条链路都能在答辩时单独讲清楚。4.1 房间建模与地图初始化MUD 的地图本质是“房间 有向出口”的图结构。每个房间有 id、名字、描述以及一个方向到目标房间 id 的映射。go north本质上就是把玩家的roomId改成exits.get(north)。package mud.server; import java.util.List; import java.util.Map; import java.util.HashMap; import java.util.concurrent.CopyOnWriteArrayList; public class Room { private final int id; private final String name; private final String desc; private final MapString, Integer exits new HashMap(); private final ListMonster monsters new CopyOnWriteArrayList(); public Room(int id, String name, String desc) { this.id id; this.name name; this.desc desc; } public void addExit(String dir, int targetRoomId) { exits.put(dir, targetRoomId); } public void addMonster(Monster m) { monsters.add(m); } public int getExit(String dir) { return exits.getOrDefault(dir, -1); } public Monster findAliveMonster() { for (Monster m : monsters) { if (m.isAlive()) { return m; } } return null; } public String getFullDesc() { StringBuilder sb new StringBuilder(); sb.append(【).append(name).append(】\n); sb.append(desc).append(\n); sb.append(出口); for (String dir : exits.keySet()) { sb.append(dir).append( ); } sb.append(\n); if (!monsters.isEmpty()) { sb.append(怪物); for (Monster m : monsters) { if (m.isAlive()) { sb.append(m.getName()).append( ); } } sb.append(\n); } return sb.toString(); } }CopyOnWriteArrayList是我刻意选的战斗线程要修改怪物血量随时可能有新玩家进来触发findAliveMonster()的遍历如果这里用普通 ArrayList遍历同时写入会直接在look命令里抛并发修改异常。这个容器的写操作会复制底层数组适合“读多写极少”的场景而这个房间的怪物列表每场战斗才写一两次代价完全可接受。地图初始化全部放在 MapBuilder 的静态块里课设规模不需要引入 XML 或 JSON 配置文件代码里直接建房间、连出口反而更直白。package mud.server; import java.util.HashMap; import java.util.Map; public class MapBuilder { private static final MapInteger, Room ROOMS new HashMap(); static { Room plaza new Room(1, 村庄广场, 你站在村庄的广场上北面是迷雾森林南面是旅店。); plaza.addExit(north, 2); plaza.addExit(south, 3); plaza.addMonster(new Monster(野狼, 30, 6, 2, 20)); ROOMS.put(1, plaza); Room forest new Room(2, 迷雾森林, 森林里雾气弥漫看不清远方东边有一条小径。); forest.addExit(south, 1); forest.addExit(east, 4); forest.addMonster(new Monster(黑熊, 50, 8, 4, 40)); ROOMS.put(2, forest); Room inn new Room(3, 旅店, 旅店老板热情地招呼你这里看起来安全。); inn.addExit(north, 1); ROOMS.put(3, inn); Room cave new Room(4, 幽暗洞穴, 洞穴深处传来低沉的咆哮声。); cave.addExit(west, 2); cave.addMonster(new Monster(洞穴蜘蛛, 45, 10, 2, 35)); ROOMS.put(4, cave); } public static Room roomOf(int id) { return ROOMS.get(id); } }地图做成静态块初始化的一个隐患在“怪物重生”场景战斗把怪物打死后再重生需要把新 Monster 实例放回房间的 monsters 列表。因为 Room 实例在静态块里只创建了一次后续对列表的修改都会作用在同一份实例上所以玩家重进房间看到的是重生后的怪没有任何状态残留问题。4.2 回合制战斗伤害公式与经验成长战斗是我建议答辩时重点讲的模块因为里面同时出现了循环、条件分支、对象状态修改和经验数值计算等一连串考点。伤害公式是经典减法攻击方攻击力减去防守方防御力下限强制为 1防止攻击力低于防御力时出现零伤害死循环。package mud.server; public class BattleSystem { public static String fight(Player p) { Room room MapBuilder.roomOf(p.getRoomId()); Monster m room.findAliveMonster(); if (m null) { return 这里没有可战斗的怪物。; } StringBuilder sb new StringBuilder(); while (p.isAlive() m.isAlive()) { // 玩家先手 int dmg Math.max(1, p.getAttack() - m.getDefense()); m.takeDamage(dmg); sb.append(你攻击).append(m.getName()).append(造成).append(dmg).append(点伤害) .append(m.getName()).append(剩余HP ).append(m.getHp()).append(\n); if (!m.isAlive()) { sb.append(你击败了).append(m.getName()).append(获得经验 ).append(m.getExp()).append(\n); p.gainExp(m.getExp()); break; } // 怪物反击 int mdmg Math.max(1, m.getAttack() - p.getDefense()); p.takeDamage(mdmg); sb.append(m.getName()).append(反击造成).append(mdmg).append(点伤害) .append(你的HP ).append(p.getHp()).append(\n); if (!p.isAlive()) { p.revive(); sb.append(你倒下了……回到村庄HP 已恢复。\n); break; } } return sb.toString(); } }几个设计点回合是玩家先手每回合双方各动一次循环终止条件是某一方 HP 归零玩家死亡不删档调用revive()回村满血这是课设里比较友好的设定也省去了复杂的死亡惩罚逻辑。经验成长走的是线性曲线升级经验 当前等级 × 100升级后攻击 2、最大 HP 10。这个公式写在 Player 里gainExp()内部循环检查是否升级最多跳 3 级防止后期一次战斗连升好几级导致数值崩坏。4.3 背包与物品使用走通一条完整链路背包系统本身不复杂但它把“命令分发 → 背包查找 → 玩家状态修改 → 消息回显”整条链路串了起来非常适合展示模块间协作。package mud.server; public class ItemSystem { public static String use(Player p, String name) { Item item p.getInventory().find(name); if (item null) { return 背包里没有 name; } if (!item.isUsable()) { return item.getName() 不能使用; } if (小药瓶.equals(item.getName())) { p.heal(30); p.getInventory().remove(item); return 你喝下小药瓶恢复 30 点 HP当前 HP p.getHp(); } if (铁剑.equals(item.getName())) { p.setAttack(p.getAttack() 5); p.getInventory().remove(item); return 你装备了铁剑攻击力 5当前攻击 p.getAttack(); } return 这个物品不知道怎么用。; } }find(name)做的是包含匹配玩家输入use 药瓶也能匹配到小药瓶因为内部用name.contains(keyword)。这在文字游戏里很实用玩家不用记住完整物品名。注意每次使用物品后都执行remove同时玩家属性同步变化这样玩家故意刷物品的漏洞被堵死了——当然前提是怪物掉落逻辑里严格控制物品发放频率。4.4 存档读档把玩家状态写成 JSON存档模块决定了玩家关掉客户端之后下次还能不能找回自己的角色。这套源码用 Gson 把 Player 对象直接序列化成 JSON 文件每个角色名对应一个saves/{name}.json。package mud.server; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import java.io.*; public class DataStore { private static final String SAVE_DIR saves; private static final Gson GSON new GsonBuilder().setPrettyPrinting().create(); public static void save(Player p) { File dir new File(SAVE_DIR); if (!dir.exists()) { dir.mkdirs(); } File file new File(dir, p.getName() .json); try (Writer writer new FileWriter(file)) { GSON.toJson(p, writer); } catch (IOException e) { e.printStackTrace(); } } public static Player load(String name) { File file new File(SAVE_DIR, name .json); if (!file.exists()) { return new Player(name); // 新角色 } try (Reader reader new FileReader(file)) { return GSON.fromJson(reader, Player.class); } catch (IOException e) { e.printStackTrace(); return new Player(name); } } }玩家每下线一次就存一次档quit命令也会走logout()触发存档所以正常情况下很少有丢档问题。这里藏着一个大坑Player 类里有ClientHandler client字段如果直接序列化整个 Socket 和流对象都会被 Gson 递归写进 JSON 里文件体积瞬间爆炸。解决办法是在对应字段上加transient修饰细节放到下一章的避坑清单细说。5. 避坑与排查从跑不起来到稳定运行的五个血泪记录我把这套源码复现过程中最容易踩的五个坑按“现象 → 原因 → 解决”记下来。前三个是环境性的跑起来就能遇见后两个是设计性的要玩十几分钟才会炸但炸一次就是大修。5.1 端口占用第二次启动必现现象在 IDE 里第一次运行ServerMain正常改了点代码再点 Run控制台直接抛java.net.BindException: Address already in use。原因上一次运行的服务器进程没有被终止。IDE 的 Stop 按钮有时候只停掉主线程而accept()阻塞住的 ServerSocket 线程还在后台挂着端口就被它占着不放。解决Windows 下执行netstat -ano | findstr 4000看最后一列 PID再到任务管理器结束对应进程。Linux 上则是lsof -i:4000查到 PID 后kill -9。我自己的习惯是启动参数里支持换端口开发时用默认 4000占用了就java ServerMain 5000不跟端口死磕。5.2 中文乱码全成问号或“锟斤拷”现象客户端窗口收到的中文全是乱码英文和数字正常。原因Windows 命令行默认 GBK 编码服务端用 UTF-8 输出两端编码不一致。这是 MUD 这种纯文字游戏最折磨人的问题因为满屏都是中文描述乱码基本等于游戏没法玩。解决在客户端窗口先执行chcp 65001切到 UTF-8 代码页再启动客户端程序。服务端代码里读写流时都显式指定UTF-8不用平台默认编码否则换个系统跑又乱了。另外一个备选方案是统一用 GBK 编码——但那只在 Windows 上跑没问题跨平台就翻车不推荐。5.3 客户端假死输入命令没任何响应现象客户端能连上服务器能收到欢迎语但敲任何命令都像石沉大海服务器日志里也没有异常。原因PrintWriter构造时没传自动刷新参数。很多人写new PrintWriter(outputStream)就完了println()只把数据写进缓冲区缓冲区没满就不会真正发到网络上表现就是“假死”。同理服务器readLine()如果客户端没发换行符也会一直阻塞等着。解决一律写成new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true)第二个参数true就是 autoFlush。如果不想用自动刷新就得每次println()后手动flush()漏一次就故障一次我建议直接用自动刷新少一个心智负担。5.4 存档文件爆炸Gson 把连接对象写进 JSON现象某个玩家下线后saves/xxx.json文件有几十 MB甚至直接抛StackOverflowError。原因Player类里持有ClientHandler client字段而ClientHandler又持有Socket、BufferedReader、PrintWriter。Gson 默认序列化所有非transient字段会把整个 Socket 内部状态对象图一层层递归展开变成 JSON大小失控是必然的。解决给Player里的客户端连接字段加transient修饰Gson 直接跳过它。同时建议把存档数据改成只保留业务字段的方案——Player只有名字、等级、经验、HP、攻击、防御、背包列表、当前房间 ID 这 8 个东西需要落盘其他都是运行期状态。我后来在DataStore里加了一个toSnapshot()方法只导出游戏状态彻底杜绝这种问题。5.5 怪物重生线程 CPU 打满现象服务器进程 CPU 占用 100%但在线玩家只有两三个日志也没有报错。用jstack查线程栈发现某个线程停在while (true)里死循环空转。原因早期实现怪物重生时我写过一个定时刷新线程循环体里直接执行重生检查忘了放Thread.sleep()。这个循环在单核上每秒跑几十万次CPU 直接被打满而且因为一直占用时间片其他玩家线程的响应也跟着变慢。解决循环里至少加Thread.sleep(500)把扫描频率降到每秒两次CPU 占用瞬间回落到几乎为零。更彻底的做法是惰性重生——玩家进入房间时检查怪物列表如果怪死了就现场生成一个新的完全去掉独立的定时线程从根上消灭这个坑。6. 交付前最后一关用自动化冒烟测试给服务器验身课设项目最怕答辩现场翻车。服务器当场起不来、命令打错没反应这种事故一次就足以让分数掉档。我的习惯是写一个自动化冒烟测试每次改动后跑一遍再提交完全不用手工敲命令验证。6.1 模拟多客户端登录与命令链路冒烟测试的思路很简单用真客户端 Socket 连上服务器走一遍“登录 → 查看 → 移动 → 攻击 → 退出”的完整命令链路并对服务器的每个响应做关键词断言。package mud.test; import java.io.*; import java.net.Socket; public class SmokeTest { public static void main(String[] args) throws Exception { for (int i 0; i 3; i) { testSingleClient(tester_ i); } System.out.println(SmokeTest PASS); } private static void testSingleClient(String name) throws Exception { try (Socket socket new Socket(127.0.0.1, 4000); BufferedReader in new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true)) { expectContains(in.readLine(), 请输入角色名); out.println(name); expectContains(in.readLine(), 你好); out.println(look); expectContains(in.readLine(), 出口); out.println(go north); expectContains(in.readLine(), 森林); out.println(fight); String fightResult in.readLine(); if (!fightResult.contains(攻击) !fightResult.contains(没有可战斗)) { throw new IllegalStateException(战斗响应异常 fightResult); } out.println(quit); } } private static void expectContains(String actual, String keyword) { if (actual null || !actual.contains(keyword)) { throw new IllegalStateException(期望响应包含「 keyword 」实际是 actual); } } }这段代码要注意两点。第一try-with-resources保证连接必然关闭测试结束后服务器端readLine()收到 EOF自动触发登出和存档正好同时验证了下线链路。第二断言的失败信息要带上实际响应否则报错时你不知道服务器到底回了什么排查效率会低很多。跑完这个测试服务器“能连、能看、能走、能打、能存”五个维度就都有底了。6.2 参数调整与验证清单下面这几个参数是我提交前必查一遍的参数默认值调整建议监听端口4000本地冲突时用启动参数覆盖心跳超时5 分钟演示时建议调成 60 秒方便快速演示踢人地图房间数4想拉长演示流程就加到 8 个房间怪物经验20~40演示升级可以调高倍率答辩前别让数值太平淡出生攻击力15调太高会导致战斗一回合结束调太低两个怪就打不动每次改完任意一个数值我都会把SmokeTest完整跑一遍再提交。从那以后我每次交 Java 课程设计都会强制先过一遍冒烟测试确认通过的瞬间心里那根弦才放松下来因为我知道它能覆盖住基础输入输出链路答辩时最怕的“答非所问”就不太可能出现了。这套流程也推荐你拿去用在自己的项目上希望帮到你。本文还有配套的精品资源点击获取
返回列表