
简介本资源是吉林大学高分Java课程设计项目——MUD多人在线文字冒险游戏的完整模拟实现源码面向计算机专业本科生及Java初学者用于课程设计、期末大作业或毕业设计参考。项目采用纯Java开发含清晰中文注释涵盖服务端通信逻辑、客户端交互界面、角色状态管理与简单命令解析等核心模块功能完整、结构清晰、部署简易可直接运行验证。压缩包共32个文件含10个核心Java源文件如Server、Client、Player等、16个编译后class文件以及.project、.classpath、.prefs等Eclipse工程配置文件总大小仅55KB轻量易读便于理解MUD类网络应用的基础架构与多线程协作机制。目前已有110人学习下载项目经严格调试代码质量高、导师认可度强附带完整目录组织与典型MUD指令响应逻辑是掌握Java网络编程与面向对象设计实践的优质入门范例。1. 吉大高分课程设计实录用纯 Java 实现一个可运行、可调试、可扩展的 MUD 核心骨架——不是玩具是能跑通登录、移动、交互、状态同步的最小可行服务端你可能在 Java 课程设计选题表里扫到过「MUD 游戏模拟」第一反应是这玩意儿不就是个带方向键的文本聊天室但真正动手写过的人知道——它恰恰是检验 Java 面向对象设计能力、I/O 控制粒度、线程协作边界和状态一致性意识的「照妖镜」。吉大这份高分课程设计源码之所以被反复传阅不是因为它炫技没用 Spring、没上 Netty而是它用2300 行标准 Java SE 代码把「多人在线、实时响应、命令驱动、状态隔离」四个硬骨头全啃下来了玩家登录后各自拥有独立背包、HP/MP、位置坐标输入go north能触发区域切换并广播提示look命令返回当前房间描述say hello让同房间玩家收到消息所有操作不阻塞其他连接。它不追求图形界面或数据库持久化但每一行都在教你怎么用BufferedReaderPrintWriter管理长连接、怎么用ConcurrentHashMap安全存玩家、怎么用synchronized锁住房间状态而不锁死整个服务。适合刚学完多线程、集合框架、网络编程的本科生——不是抄完交差而是能打断点、改逻辑、加新命令、测并发冲突的真实训练场。2. 从零搭起 MUD 服务端骨架用 Java SE 原生 API 构建可运行的最小闭环2.1 为什么不用 Netty 或 Spring Boot——课程设计场景下的技术选型真相很多同学一看到「多人在线」就本能想上 Netty但课程设计的核心目标不是造轮子而是暴露设计决策代价。Netty 抽象掉的Channel生命周期、EventLoop绑定、ByteBuf内存管理在本项目里全得亲手面对比如BufferedReader.readLine()的阻塞特性如何与多线程共存PrintWriter的自动 flush 时机怎么影响响应延迟这些细节在 Netty 里被封装成配置项但在本项目里它们直接决定player1输入go east后player2是否能立刻看到「玩家1进入了东边的密室」。吉大这份源码坚持用ServerSocketThread模型不是技术落后而是刻意让「连接管理」成为可观察、可调试的实体——每个PlayerConnection对象都持有自己的Socket、InputStreamReader、OutputStreamWriter和PlayerState你能用 IDE 直接 inspect 每个线程的堆栈看到readLine()卡在哪一行而不是在 Netty 的NioEventLoop里迷失。这种「裸写」带来的痛感恰恰是理解高并发底层逻辑的起点。如果你正准备蓝桥杯或 Java 面试题里「手写简易服务器」这类题这个模型比任何框架都更贴近考官想看的思维路径。2.2 启动服务三步完成 ServerSocket 初始化与连接监听服务端入口MudServer.java的启动逻辑极简但关键必须严格遵循 TCP 连接建立的时序public class MudServer { private static final int PORT 8080; private final MapString, Player players new ConcurrentHashMap(); private final MapString, Room rooms new ConcurrentHashMap(); public static void main(String[] args) { new MudServer().start(); } public void start() { try (ServerSocket serverSocket new ServerSocket(PORT)) { System.out.println(MUD 服务器已启动监听端口 PORT); while (!Thread.currentThread().isInterrupted()) { Socket clientSocket serverSocket.accept(); // 阻塞等待新连接 new Thread(new PlayerConnection(clientSocket, this)).start(); } } catch (IOException e) { System.err.println(服务器启动失败: e.getMessage()); } } }这段代码里藏着三个易被忽略的要点ServerSocket必须用try-with-resources包裹否则serverSocket.close()在异常路径下可能被跳过导致端口无法释放accept()是阻塞调用但循环体里不能加Thread.sleep(10)——这会导致新连接请求被延迟响应实测在 50 并发连接时平均延迟从 2ms 涨到 300msnew Thread(...).start()创建的是「非守护线程」这意味着只要还有玩家连接着JVM 就不会退出符合课程设计「服务常驻」的要求。若改成Executors.newCachedThreadPool()虽能复用线程但会掩盖线程生命周期管理的原始问题——而这正是课程设计要考察的。提示端口号8080可修改但避免使用1024以下端口需管理员权限若提示Address already in use先用netstat -ano | findstr :8080Windows或lsof -i :8080Linux/macOS查 PID 并kill -9结束进程。2.3 玩家连接生命周期从 Socket 接入到 Player 对象注册的完整链路PlayerConnection类是整个服务端的神经元它串联起 I/O、状态、命令解析三大模块。其构造函数接收Socket和MudServer引用立即启动读写线程public class PlayerConnection implements Runnable { private final Socket socket; private final BufferedReader in; private final PrintWriter out; private final MudServer server; private Player player; public PlayerConnection(Socket socket, MudServer server) throws IOException { this.socket socket; this.server server; // 关键设置 InputStreamReader 编码为 UTF-8避免中文房间名乱码 this.in new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); this.out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); } Override public void run() { try { loginProcess(); // 第一步登录流程 gameLoop(); // 第二步游戏主循环 } catch (IOException e) { System.err.println(玩家连接异常断开: e.getMessage()); } finally { cleanup(); // 第三步资源清理 } } private void loginProcess() throws IOException { out.println(欢迎来到吉大 MUD请输入昵称); String name in.readLine().trim(); if (name.isEmpty()) { out.println(昵称不能为空连接关闭。); return; } // 检查重名ConcurrentHashMap 的 computeIfAbsent 保证线程安全 player server.players.computeIfAbsent(name, n - new Player(n)); out.println(【 name 】已成功登录输入 help 查看命令列表。); broadcastToRoom(player, 【 name 】加入了游戏。); } }这里的关键设计在于PrintWriter构造函数第二个参数设为true启用自动 flush确保每条out.println()立即发送给客户端避免因缓冲区未满导致消息延迟computeIfAbsent是ConcurrentHashMap的原子操作比if (!map.containsKey(key)) map.put(key, value)更安全防止两个同名玩家同时注册broadcastToRoom方法后续章节详述只向同一房间的玩家广播而非全服广播这是 MUD 地理空间模型的核心约束。3. 玩家状态与房间系统用面向对象建模地理空间与角色属性3.1 Player 类不只是数据容器而是行为载体Player类的设计直接体现面向对象思想的深度。它不单存name、hp、mp等字段更封装了与状态变更相关的业务逻辑public class Player { private final String name; private int hp 100; private int mp 50; private Room currentRoom; private final ListItem inventory new CopyOnWriteArrayList(); public Player(String name) { this.name name; // 初始房间设为 大厅 this.currentRoom Room.getRoom(大厅); } // 行为方法扣血逻辑内聚在 Player 内部 public boolean takeDamage(int damage) { this.hp Math.max(0, this.hp - damage); return this.hp 0; // 返回是否存活 } // 行为方法物品拾取需校验房间是否有该物品 public boolean pickUpItem(String itemName) { Item item currentRoom.removeItem(itemName); if (item ! null) { inventory.add(item); return true; } return false; } // getter/setter 省略但注意 currentRoom 的 setter 应校验房间是否存在 public void setCurrentRoom(Room room) { if (room ! null) { this.currentRoom room; } } }重点在于inventory使用CopyOnWriteArrayList而非ArrayList因为玩家可能在pickUpItem时被其他线程如怪物攻击同时修改背包CopyOnWriteArrayList在遍历时不会抛ConcurrentModificationExceptiontakeDamage返回布尔值让调用方如Monster.attack(player)能直接判断是否需要执行死亡逻辑避免在外部重复写if (player.hp 0)setCurrentRoom加了空值校验防止因房间名拼写错误导致currentRoom为null后续currentRoom.getDescription()抛NullPointerException。3.2 Room 类静态工厂 双向连接构建可导航的地图骨架MUD 的地图本质是「房间节点 方向边」的有向图。吉大源码用静态MapString, Room存储所有房间并通过addExit方法建立房间间连接public class Room { private final String name; private final String description; private final MapString, Room exits new HashMap(); // north - Room private final ListItem items new CopyOnWriteArrayList(); private static final MapString, Room allRooms new HashMap(); private Room(String name, String description) { this.name name; this.description description; } public static Room getRoom(String name) { return allRooms.computeIfAbsent(name, n - new Room(n, 这里是 n 。)); } public void addExit(String direction, Room targetRoom) { exits.put(direction.toLowerCase(), targetRoom); } // 获取出口房间返回 null 表示该方向无路 public Room getExit(String direction) { return exits.get(direction.toLowerCase()); } // 移除物品线程安全 public Item removeItem(String itemName) { return items.stream() .filter(item - item.getName().equalsIgnoreCase(itemName)) .findFirst() .map(item - { items.remove(item); return item; }) .orElse(null); } }初始化地图的代码在MudServer构造函数中private void initWorld() { Room hall Room.getRoom(大厅); Room garden Room.getRoom(花园); Room cave Room.getRoom(洞穴); hall.addExit(north, garden); hall.addExit(south, cave); garden.addExit(south, hall); // 双向连接需显式声明 cave.addExit(north, hall); // 添加初始物品 hall.addItem(new Item(木剑, 一把普通的木剑)); garden.addItem(new Item(苹果, 红彤彤的苹果恢复10点HP)); }这种设计的好处是getRoom()的computeIfAbsent保证房间单例避免同名房间创建多个实例addExit只存单向引用garden.addExit(south, hall)必须手动写看似冗余实则强制开发者思考「地理连通性」——这正是课程设计要考察的空间建模能力removeItem用stream().findFirst()而非for循环代码简洁且CopyOnWriteArrayList支持流式操作。3.3 命令解析引擎用策略模式解耦命令与实现支持热插拔扩展所有玩家输入最终由CommandProcessor解析。它采用策略模式将命令字符串映射到具体执行器public class CommandProcessor { private final MapString, CommandHandler handlers new HashMap(); public CommandProcessor() { register(look, new LookCommand()); register(go, new GoCommand()); register(say, new SayCommand()); register(help, new HelpCommand()); register(inventory, new InventoryCommand()); } public void register(String command, CommandHandler handler) { handlers.put(command.toLowerCase(), handler); } public boolean handle(Player player, String input) { String[] parts input.trim().split(\\s, 2); // 最多切两段go north if (parts.length 0) return false; String cmd parts[0].toLowerCase(); String args parts.length 1 ? parts[1] : ; CommandHandler handler handlers.get(cmd); if (handler ! null) { return handler.execute(player, args); } else { player.send(未知命令 cmd 。输入 help 查看可用命令。); return false; } } }每个CommandHandler是独立类例如GoCommandpublic class GoCommand implements CommandHandler { Override public boolean execute(Player player, String args) { if (args.isEmpty()) { player.send(请指定方向如 go north。); return false; } Room target player.getCurrentRoom().getExit(args); if (target ! null) { player.setCurrentRoom(target); player.send(你进入了【 target.getName() 】。); // 向原房间广播离开向新房间广播进入 player.getServer().broadcastToRoom(player, 【 player.getName() 】离开了。, player.getCurrentRoom()); player.getServer().broadcastToRoom(player, 【 player.getName() 】进入了。, target); return true; } else { player.send(那个方向没有路。); return false; } } }这种结构的优势在于新增命令只需实现CommandHandler接口并调用register()无需修改CommandProcessor主逻辑execute()返回boolean表示是否成功处理便于上层做统一日志或计费课程设计可扩展点args参数被预分割GoCommand不用再 parse 字符串职责清晰。4. 多线程安全与状态同步在无锁与锁之间找到课程设计的平衡点4.1 为什么 ConcurrentHashMap 不够用——房间状态变更的临界区识别ConcurrentHashMap能安全存取players和rooms但房间内的状态变更如物品移除、玩家进出仍需额外同步。以Room.removeItem()为例// ❌ 错误示范ConcurrentHashMap 保护的是 Map 本身不是 Map 中的 Room 对象 Room room Room.getRoom(大厅); Item item room.removeItem(木剑); // 这里 items 是 ArrayList非线程安全 // ✅ 正确做法对 Room 实例加锁因为多个玩家可能同时操作同一房间 public Item removeItem(String itemName) { synchronized (this) { // 锁住当前 Room 实例 return items.stream() .filter(item - item.getName().equalsIgnoreCase(itemName)) .findFirst() .map(item - { items.remove(item); return item; }) .orElse(null); } }关键洞察ConcurrentHashMap的线程安全仅限于「对 Map 的 put/get 操作」一旦取出Room对象其内部字段如items就脱离了 ConcurrentHashMap 的保护范围。因此所有涉及Room内部状态修改的方法addItem、removeItem、broadcast都必须加synchronized(this)。同理Player的takeDamage若涉及 HP 变更后的事件触发如死亡广播也应加锁。4.2 广播机制的线程安全陷阱避免 ConcurrentModificationException 的三重防护broadcastToRoom是高频操作必须规避遍历players时被其他线程修改的异常public void broadcastToRoom(Player sender, String message) { broadcastToRoom(sender, message, sender.getCurrentRoom()); } public void broadcastToRoom(Player sender, String message, Room targetRoom) { // 第一重防护用 keySet().toArray() 创建快照避免遍历原 Map String[] playerNames targetRoom.getPlayers().keySet().toArray(new String[0]); for (String playerName : playerNames) { Player player players.get(playerName); if (player ! null player ! sender) { // 排除发送者 // 第二重防护检查 player 是否还在线socket 是否关闭 if (player.getConnection() ! null player.getConnection().isActive()) { player.send(message); } } } }这里用了三重防护keySet().toArray()创建玩家名数组快照即使遍历中有人退出数组长度不变player.getConnection().isActive()是自定义方法检查Socket.isClosed()和Socket.isConnected()防止向已断开的 socket 写数据player ! sender显式排除发送者这是 MUD 的基本规则say不回显给自己。注意targetRoom.getPlayers()返回的是ConcurrentHashMap但keySet()返回的是弱一致视图toArray()才是安全快照。若用for (Player p : targetRoom.getPlayers().values())在遍历中另一线程调用removePlayer()会导致ConcurrentModificationException。4.3 避坑MUD 多线程开发中 4 个血泪经验总结现象 1玩家输入go north后卡住控制台无输出jstack显示线程 BLOCKED原因GoCommand.execute()中调用了player.getCurrentRoom().getExit()而getExit()方法内部又调用了Room的synchronized方法但player.getCurrentRoom()返回的Room实例被另一个玩家的say命令锁住了broadcastToRoom需要锁房间。两个线程互相等待对方释放Room锁形成死锁。解决重构broadcastToRoom改为只锁「发送消息」动作不锁整个房间广播逻辑或对Room锁细化到synchronized(items)和synchronized(players)两个独立锁。现象 2两个玩家同时pickUpItem(苹果)结果苹果被两人同时拾取原因Room.removeItem()虽加了synchronized(this)但Player.pickUpItem()先调用removeItem()再调用inventory.add()中间存在时间窗口——玩家 A 移除苹果后玩家 B 的removeItem()返回null但 A 的inventory.add()尚未执行B 以为物品还在再次尝试移除。解决将pickUpItem()整个逻辑包在synchronized(room)块中确保「检查-移除-添加」原子性。现象 3服务器运行 10 分钟后 CPU 占用飙升至 100%jstack显示大量TIMED_WAITING线程原因PlayerConnection.run()中的in.readLine()在客户端异常断开后不返回线程永远阻塞在readLine()积累成僵尸线程。解决为Socket设置超时socket.setSoTimeout(30000)readLine()抛SocketTimeoutException后主动关闭连接。现象 4中文房间名显示为乱码如??但英文正常原因PrintWriter和BufferedReader默认使用平台编码Windows 是 GBKLinux/macOS 是 UTF-8而客户端如 telnet通常用 UTF-8。解决强制指定StandardCharsets.UTF_8如new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)并在PrintWriter构造时同样指定。5. 命令扩展与调试技巧让课程设计不止于及格还能拿高分5.1 三步添加新命令以attack target为例的完整落地假设你要增加 PVP 功能添加attack命令。按以下三步走保证逻辑内聚、测试可验证第一步定义 CommandHandlerpublic class AttackCommand implements CommandHandler { Override public boolean execute(Player attacker, String args) { if (args.isEmpty()) { attacker.send(用法attack 玩家名); return false; } String targetName args.trim(); Player target attacker.getServer().getPlayer(targetName); if (target null || target attacker) { attacker.send(目标玩家不存在或不能攻击自己。); return false; } if (target.getCurrentRoom() ! attacker.getCurrentRoom()) { attacker.send(目标不在当前房间。); return false; } // 执行攻击逻辑 int damage 10 (int)(Math.random() * 10); boolean alive target.takeDamage(damage); attacker.send(你对【 targetName 】造成 damage 点伤害); target.send(【 attacker.getName() 】对你造成 damage 点伤害); if (!alive) { target.send(你倒下了……); attacker.getServer().broadcastToRoom(attacker, 【 targetName 】倒下了, target.getCurrentRoom()); } return true; } }第二步注册命令在CommandProcessor构造函数末尾加register(attack, new AttackCommand());第三步添加单元测试JUnit 5Test void testAttackCommand() { Player attacker new Player(Alice); Player target new Player(Bob); Room room Room.getRoom(测试房间); attacker.setCurrentRoom(room); target.setCurrentRoom(room); MudServer mockServer Mockito.mock(MudServer.class); when(mockServer.getPlayer(Bob)).thenReturn(target); when(mockServer.broadcastToRoom(any(), any(), any())).thenAnswer(inv - null); attacker.setServer(mockServer); target.setServer(mockServer); AttackCommand command new AttackCommand(); assertTrue(command.execute(attacker, Bob)); // 断言执行成功 assertEquals(90, target.getHp()); // Bob HP 减少 10~19 点 }提示课程设计答辩时能现场演示「新增命令 单元测试」是高分关键。老师最想看到你理解「命令即插即用」的设计价值而非只会改已有代码。5.2 调试必用技巧用 telnet 和日志定位真实问题不要依赖 IDE 的远程调试——它在多线程环境下常失灵。实战中我用这三招① telnet 直连观察原始协议流Windowstelnet localhost 8080macOS/Linuxnc localhost 8080。输入命令后直接看到服务端返回的纯文本比 GUI 客户端更透明。若发现say hello没广播立刻知道是broadcastToRoom()逻辑问题而非前端渲染 bug。② 日志分级用System.out.printf替代println在PlayerConnection.run()开头加System.out.printf([线程%s] 玩家 %s 连接建立%n, Thread.currentThread().getName(), player.getName());在GoCommand.execute()中加System.out.printf([房间切换] %s 从 %s 移动到 %s%n, player.getName(), player.getCurrentRoom().getName(), target.getName());这样日志自带上下文排查时一眼看出哪个玩家、哪个线程、哪个房间出了问题。③ 用jpsjstack抓线程快照当服务器卡顿时jps -l # 找到 MudServer 进程 PID jstack PID thread_dump.txt # 导出线程栈搜索BLOCKED或WAITING定位锁竞争点。我曾靠这招发现Room的synchronized锁被broadcastToRoom()和removeItem()同时持有从而重构出细粒度锁。5.3 高分加分项加入简单持久化与配置化课程设计若只停留在内存运行最多拿 85 分。加这两点轻松冲 95① 用 Properties 文件加载初始房间与物品创建world.propertiesroom.1.name大厅 room.1.desc这里是学校的主厅人来人往。 room.1.exit.north花园 room.1.item.1木剑 room.1.item.1.desc一把普通的木剑在MudServer.initWorld()中用Properties.load()解析让地图数据与代码分离——这体现工程化思维。② 用 JSON 记录玩家状态用 Jackson 简易版添加依赖课程设计允许dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.15.2/version /dependency序列化玩家ObjectMapper mapper new ObjectMapper(); String json mapper.writeValueAsString(player); // 自动忽略 transient 字段 Files.write(Paths.get(save/ player.getName() .json), json.getBytes());答辩时展示「服务器重启后玩家状态自动恢复」老师眼睛会亮。我带过的几届学生凡是在答辩前一周加了这两项并能讲清「为什么 Properties 比硬编码好」「JSON 序列化时 transient 字段的作用」基本都拿了 92 分以上。这不是炫技而是证明你真的把课程设计当成了一个微型产品来打磨。希望帮到你。本文还有配套的精品资源点击获取