
简介这是吉林大学软件学院的一份Java课程设计源码以经典MUDMulti-User Dungeon多人在线文字冒险游戏为原型面向Java学习者、在校本科生以及需要完成课程设计的开发者。项目围绕多玩家交互场景集中示范了面向对象设计、集合与IO、多线程同步、基于Socket的网络通信等核心知识点适合作为Java进阶练习的参考范例。压缩包共32个文件包含10个可读的Java源文件、16个编译后的class文件以及Eclipse项目配置.classpath、.project、.settings和newfile.acd/ucd/cld等辅助文件整体仅53KB结构精简可导入Eclipse直接查看运行。目前已有204人学习下载。通过源码可读懂MUD游戏中的房间管理、玩家移动、命令解析、多客户端连接处理等模块并学习如何从零搭建一个支持多人交互的文本游戏框架对后续开发更复杂项目有启发意义。1. 从这份课程设计开始理解 MUD多人在线游戏的鼻祖到底该怎么写如果你在课程设计选题时刷到过 MUDMulti-User Dungeon这个关键词——多人在线文字游戏的鼻祖那这份吉林大学软件学院的 Java 课程设计源码算是一个能直接跑起来的参考样本。它的核心不是画面而是文字交互玩家通过 Telnet 连上服务器在一个由房间、出口、NPC 组成的世界里探索、对话、战斗。这份源码的含金量在于它把 Java 网络编程里最常考的三块拼图——Socket 通信、多线程并发、命令解析——全部串进了一个可玩的项目里。适合正在找课程设计参考的在校生也适合想搞清楚“多个玩家同时在线时服务器究竟在做什么”的 Java 入门者。下面从架构开始一层层把它拆开。2. 核心架构Socket 选型、线程模型与命令分发2.1 为什么课程设计要用 Socket 而不是 HTTP拿到源码先别急着编译先问一个更底层的问题MUD 这类文字游戏为什么用 Socket 而不是 HTTP这个问题的答案就是你在答辩时第一个能讲清楚的点。HTTP 是应用层协议每次请求都要经历“建立连接 → 发送请求 → 接收响应 → 断开连接”的完整生命周期。放进 MUD 场景里会非常别扭玩家每走一步、每说一句话服务器都要重建一次上下文而服务器要主动向玩家推送消息比如另一个玩家走进了你的房间时HTTP 的长轮询或 WebSocket 方案在教学项目里显得过于复杂。Socket 走的是 TCP 传输层协议一旦连接建立双方可以随时双向读写字节流天然适合“玩家持续在线、命令实时往返”的场景。再就是多线程。Socket 默认是阻塞式的一个客户端连上来之后服务器调用readLine()就会卡在那里等输入。如果服务器是单线程那第一个玩家连上后第二个玩家的accept()永远轮不到。所以标准的课程设计做法是“每客户端一线程”主线程只负责接受连接接进来的每个 Socket 都丢给一个独立线程去处理。这个模型虽然在高并发的生产环境里会被线程池、NIO 替代但作为教学演示它把“并发”这个概念具象成了你能肉眼看到的线程行为。2.2 服务器主循环端口监听和连接接纳源码入口是 Server 类。它的main方法做的事情非常集中创建ServerSocket监听固定端口然后在一个死循环里反复调用accept()每等到一个新连接就创建一个ClientHandler实例并启动一个新的线程。public class Server { // 服务端口课程设计一般选 8888/9999 这类高位端口避开系统保留端口 private static final int PORT 8888; // 在线玩家表ConcurrentHashMap 保证多线程下的读写安全 public static ConcurrentHashMapString, Player onlinePlayers new ConcurrentHashMap(); public static void main(String[] args) { try (ServerSocket serverSocket new ServerSocket(PORT)) { System.out.println(MUD 服务器已启动监听端口: PORT); while (true) { // accept() 阻塞等待客户端连接 Socket socket serverSocket.accept(); System.out.println(新连接来自: socket.getInetAddress()); // 每个连接交给独立线程处理主线程立刻回到 accept() ClientHandler handler new ClientHandler(socket); new Thread(handler).start(); } } catch (IOException e) { System.err.println(服务器启动失败: e.getMessage()); } } }逻辑说明accept()是阻塞调用会一直等到有新的 TCP 连接进来才返回一个已连接的Socket。从这一刻起主线程和这个玩家再无直接关系处理这个玩家输入输出的是新建的那个ClientHandler线程。主线程的唯一使命就是继续accept()下一个玩家。这个主循环和子线程的协作方式是整个多人在线系统的地基。参数说明PORT选 8888 是经验值——太小的端口如 80、443需要管理员权限且容易被占用太偏僻的端口如 60000不好记。如果你本机跑着其他服务占了 8888改成一个随机的高位端口即可。ConcurrentHashMap在这里作为在线玩家表相比HashMap的差异在于它允许一个线程在写入的同时另一个线程读取而不抛ConcurrentModificationException代价是性能略低但对课程设计规模的玩家数量毫无压力。2.3 ClientHandler一人一线程的背后逻辑ClientHandler是处理单个玩家全部生命周期的类。它实现Runnable持有连接专属的Socket、输入流和输出流以及一个代表玩家身份的Player对象。整个类的核心是run()方法里面是一个“读一行、解析一行、回一行”的循环。public class ClientHandler implements Runnable { private Socket socket; private BufferedReader in; private PrintWriter out; private Player player; private boolean running true; public ClientHandler(Socket socket) { this.socket socket; try { // 字符集必须统一这里和客户端都约定 UTF-8 in new BufferedReader(new InputStreamReader( socket.getInputStream(), UTF-8)); out new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), UTF-8), true); } catch (IOException e) { e.printStackTrace(); } } Override public void run() { try { out.println(欢迎来到 MUD 文字世界请输入你的角色名); // 玩家连上后第一行输入就是角色名 String nameLine in.readLine(); if (nameLine null || nameLine.trim().isEmpty()) { return; // 什么都没输就断开 } player PlayerManager.login(nameLine.trim(), this); if (player null) { out.println(角色名已被占用连接即将关闭。); return; } // 进入主循环读取玩家输入交给命令解析器处理 String input; while (running (input in.readLine()) ! null) { String response CommandParser.handle(input, this); if (response ! null) { out.println(response); } if (quit.equalsIgnoreCase(input.trim())) { running false; } } } catch (IOException e) { System.out.println(玩家连接异常: e.getMessage()); } finally { // 断开时的清理移出在线列表、关闭 Socket if (player ! null) { PlayerManager.logout(player); } try { socket.close(); } catch (IOException e) { /* ignore */ } } } public void sendMessage(String msg) { out.println(msg); } }逻辑说明这段代码有两个容易被忽略但极关键的点。第一in.readLine()是阻塞的线程会挂起等待客户端发来一行以换行符结尾的文本。只有当 Telnet 或客户端按下回车这里才会返回。第二quit命令被处理完、循环条件再次判断时才会退出所以finally里的清理逻辑只会执行一次。清理的顺序是先下线玩家从在线列表移除再关 Socket保证不会出现连接关闭了但玩家数据还在内存里悬空的情况。参数说明PrintWriter构造器的第二个参数true表示自动刷新。如果写falseprintln()的内容会积在缓冲区里玩家看到的现象就是“打字没反应、服务器也不报错”。这是最隐蔽的坑之一。new Thread(handler).start()每次创建一个新线程虽然没有复用线程资源但代码量最少也最容易在报告里用图说清楚。2.4 命令解析器字符串到游戏行为的转换中枢命令解析器的本质是一张路由表拿到玩家输入的字符串拆出命令动词和参数再派发给对应的处理方法。课程设计的 MUD 命令不需要多look、go、say、help、quit五个就能撑起完整玩法。public class CommandParser { public static String handle(String input, ClientHandler handler) { // 用空白字符切分命令和参数连续空格也能正确拆分 String[] parts input.trim().split(\\s); if (parts.length 0) { return 请输入命令。输入 help 查看帮助。; } String cmd parts[0].toLowerCase(); Player player handler.getPlayer(); switch (cmd) { case look: return player.getCurrentRoom().describe(); case go: if (parts.length 2) { return 用法: go north|south|east|west; } return player.moveTo(parts[1].toLowerCase()); case say: // 必须先确认空格后面还有内容否则 substring 会取到命令本身 int spacePos input.indexOf( ); if (spacePos 0 || input.substring(spacePos 1).trim().isEmpty()) { return 用法: say 内容; } // 台词可能含空格用 substring 而不是 parts[1] String words input.substring(spacePos 1); return player.say(words); case help: return 可用命令: look / go 方向 / say 内容 / quit; case quit: return 感谢游玩再见; default: return 未知命令: cmd 。输入 help 查看帮助。; } } }逻辑说明split(\\s)用的是正则表达式\s表示空白字符表示一个或多个。这样玩家输入 go north开头和中间有多个空格也能正确拆出go和north不会因为多余空格报错。say命令这里特别先检查了空格是否存在再用substring取第一个空格之后的所有内容因为台词本身就可能包含空格用parts[1]只能取到第一个单词。参数说明cmd.toLowerCase()把命令转成小写这样玩家输入GO、Go、go都能被识别。moveTo里的方向参数也做了同样处理避免“方向大小写不一致导致出口查询失败”这种低级错误。这个handle方法返回的是字符串每个case分支只负责组装要回复玩家的文本具体业务逻辑移动、说话都下沉到Player类的方法里命令解析器不直接操作房间和地图——这种分层在课程设计报告里很好解释解析器管路由业务对象管逻辑。3. 房间与地图一个文字世界的空间建模3.1 地图存储为什么用文本文件而不是硬编码MUD 的地图结构是有向图房间是节点出口是连接节点的边。课程设计里地图有两种实现方式第一种是在代码里new Room(...)硬编码所有房间简单粗暴但改地图意味着改代码、重新编译第二种是把地图写成独立文本文件服务器启动时解析加载改地图只需改文件工程上更合理。这份源码选的是第二种map.txt的格式设计得相当直白用空行分隔房间每个房间用键值行描述属性。一个典型的文件片段是这样# 以 # 开头的行为注释加载时跳过 room_id: 1 name: 中央广场 desc: 你站在一个宽阔的广场中央石板地面被岁月磨得发亮。 exits: north2, south3, east4, west5 room_id: 2 name: 北街 desc: 一条安静的街道两旁的梧桐树投下浓密的阴影。 exits: south1这个格式最大的优点是人在读的时候也很舒服不需要看代码就能理解地图结构。换个角度说课程设计报告里直接贴这个文件内容评委一眼就能看懂你的地图设计比贴一堆Room构造代码直观得多。3.2 地图加载器解析文本到对象光有文件格式还不够需要一段代码把它变成Room对象图。GameMap类是地图管理器的入口它的loadFromFile方法逐行扫描文件按空行分组用“当前房间”指针来区分每条属性属于哪个房间。public class GameMap { private final MapInteger, Room rooms new HashMap(); private static GameMap instance; // 单例全服务器共享一份地图 public static GameMap getInstance() { if (instance null) { instance new GameMap(); } return instance; } public void loadFromFile(String path) throws IOException { ListString lines Files.readAllLines(Paths.get(path), StandardCharsets.UTF_8); Room current null; for (String line : lines) { if (line.isBlank() || line.startsWith(#)) { // 空行表示上一个房间已经读完重置当前指针 current null; continue; } int colon line.indexOf(:); if (colon 0) continue; String key line.substring(0, colon).trim(); String value line.substring(colon 1).trim(); switch (key) { case room_id - { current new Room(Integer.parseInt(value)); rooms.put(current.getId(), current); } case name - current.setName(value); case desc - current.setDescription(value); case exits - parseExits(current, value); default - { /* 未知字段忽略保持向后兼容 */ } } } } private void parseExits(Room room, String exitStr) { // exitStr 形如 north2, south3用逗号拆分再按等号拆分 for (String pair : exitStr.split(,)) { String[] kv pair.split(); if (kv.length 2) { room.addExit(kv[0].trim(), Integer.parseInt(kv[1].trim())); } } } public Room getRoom(int id) { return rooms.get(id); } }逻辑说明loadFromFile用current变量作“房间上下文”。读到room_id行时创建新Room并放入rooms表同时把current指向它接下来的name、desc、exits都往current上填。遇到空行或注释行把current置空防止下一条属性串到上一个房间。这套“增量填充”的思路非常通用你在解析任何结构化文本时都能复用。参数说明这里我用的是 Java 12 的switch箭头语法case room_id -如果你的课程设计环境是 JDK 8得把每个箭头分支改成冒号写法加break。大多数学校课程设计要求 JDK 8 或 JDK 17写之前先确认环境版本否则编译报错第一个就是这里。line.isBlank()是 JDK 11 引入的JDK 8 要用line.trim().isEmpty()。3.3 Room 类房间的自我描述与人员管理Room不应该只是一个“带描述的房间号”——它还负责管理当前在房间里的玩家和 NPC并在describe()方法里把房间的完整状态拼成文本。public class Room { private final int id; private String name; private String description; private final MapString, Integer exits new HashMap(); private final ListPlayer players new ArrayList(); private final ListNPC npcs new ArrayList(); public Room(int id) { this.id id; } public void addExit(String direction, int roomId) { exits.put(direction, roomId); } public MapString, Integer getExits() { return exits; } public void enter(Player p) { players.add(p); } public void leave(Player p) { players.remove(p); } public String describe() { StringBuilder sb new StringBuilder(); sb.append(【).append(name).append(】\n); sb.append(description).append(\n); if (!exits.isEmpty()) { sb.append(出口: ).append(String.join(, , exits.keySet())).append(\n); } if (!players.isEmpty()) { sb.append(这里的玩家: ); for (Player p : players) { if (p.isOnline()) { sb.append([).append(p.getName()).append(] ); } } sb.append(\n); } if (!npcs.isEmpty()) { sb.append(这里的 NPC: ); for (NPC npc : npcs) { sb.append([).append(npc.getName()).append(] ); } sb.append(\n); } return sb.toString(); } }逻辑说明describe()是玩家look命令的最终输出源它先拼房间名和描述再列出出口、玩家、NPC。这里有个细腻的业务设计玩家离开房间时调用leave()、进入时调用enter()配合Player类里的currentRoom引用保证“房间里的玩家列表”和“玩家当前所在房间”这两个方向的数据始终一致。如果你改了其中一个忘记改另一个就会出现上一章 5.3 提到的幽灵玩家。参数说明String.join(, , exits.keySet())把出口集合拼成一行可读文本。这个 API 比手工循环拼接StringBuilder来得干净JDK 8 起可用。players列表没有用并发容器是因为这个Room对象的方法只在玩家线程里被调用同一时刻进入同一个房间的并发场景在课程设计里几乎不会触发冲突但严格来说在describe()遍历时另一个线程正在add还是有可能抛出ConcurrentModificationException——我把players声明为CopyOnWriteArrayList会更安全但这会增加课程设计源码的复杂度通常不要求。3.4 移动与状态同步从房间 A 到房间 B 的四步移动是 MUD 最核心的交互。Player类的moveTo方法负责完整的移动流程查出口、验方向、离开旧房间、进入新房间、返回新房间描述。public String moveTo(String direction) { Room current this.currentRoom; Integer targetId current.getExits().get(direction); if (targetId null) { return 那边没有路。; } Room target GameMap.getInstance().getRoom(targetId); if (target null) { return 目标房间不存在请检查地图配置。; } // 顺序很重要先离开旧房间再进入新房间最后切换引用 current.leave(this); target.enter(this); this.currentRoom target; return 你向 direction 走去。\n target.describe(); }逻辑说明四个步骤缺一不可。current.leave(this)把玩家从旧房间的人员列表里移除target.enter(this)加入新房间最后更新this.currentRoom。如果顺序写反——先进入新房间再离开旧房间——玩家会短暂地同时存在于两个房间的列表里如果漏了最后一步更新引用玩家永远都在原地打转。参数说明direction在CommandParser里已经做过toLowerCase()这里查HashMap时大小写是严格匹配的。地图文件里如果写了North2而不是north2direction是north就会出现“那边没有路”——这类问题排查时首先检查 map.txt 里方向大小写是否和代码一致。3.5 玩家注册与登录身份与状态的绑定MUD 的身份体系在基础版本里只需一个角色名。玩家连上服务器后第一行输入即角色名。服务器要处理两件事检查名字是否已在线、为玩家创建对象并放到初始房间。public class PlayerManager { // 注册检查重名并创建玩家 public static Player login(String name, ClientHandler handler) { // putIfAbsent 是原子操作两个玩家同时抢同一个名字时只有一个能成功 Player newPlayer new Player(name, GameMap.getInstance().getRoom(1)); Player old Server.onlinePlayers.putIfAbsent(name, newPlayer); if (old ! null) { return null; // 名字已被占用 } Room startRoom newPlayer.getCurrentRoom(); startRoom.enter(newPlayer); handler.sendMessage(欢迎 name 你出生在【 startRoom.getName() 】。输入 help 查看帮助。); return newPlayer; } // 下线移出在线列表并让玩家离开当前房间 public static void logout(Player p) { Server.onlinePlayers.remove(p.getName()); p.getCurrentRoom().leave(p); p.setOnline(false); } }逻辑说明putIfAbsent是ConcurrentHashMap提供的原子方法如果 key 不存在就放入并返回 null如果已存在则返回旧值。这比“先containsKey再put”安全得多因为后者的检查和插入之间另一个线程可能抢先占用了这个名字造成两个玩家同名同时在线的并发 bug。这也是课程设计里少有的几个值得在答辩时主动提的“线程安全设计”。参数说明getRoom(1)把新玩家固定放在 1 号房间这是约定好的出生点。如果你想让地图更灵活可以在 map.txt 里给每个房间加一个is_start: true的标记但会引入额外的解析逻辑。我个人建议课程设计阶段就用固定房间号把精力花在核心玩法上。4. 编译、运行与联调从源码到可玩的完整链路4.1 先看项目结构把文件归位再动手拿到源码第一步不是急着打开 IDE先把目录结构看明白。规范的 MUD 课程设计项目长这样MUD/ ├── src/ │ ├── Server.java # 服务器入口端口监听与连接接纳 │ ├── ClientHandler.java # 单玩家连接线程 │ ├── CommandParser.java # 命令路由 │ ├── GameMap.java # 地图加载与房间索引 │ ├── Room.java # 房间模型 │ ├── Player.java # 玩家实体 │ ├── PlayerManager.java # 玩家注册与下线管理 │ └── NPC.java # 简单 NPC 实体 ├── map/ │ └── map.txt # 地图配置文件 └── README.md # 运行说明文件职责对照表如下文件职责关键方法Server.java服务器入口端口监听main(), accept 循环ClientHandler.java单玩家连接处理run(), sendMessage()CommandParser.java命令路由handle()GameMap.java地图加载与房间索引loadFromFile(), getRoom()Room.java房间模型describe(), enter(), leave()Player.java玩家数据模型moveTo(), say(), getCurrentRoom()PlayerManager.java玩家身份管理login(), logout()拿到手先做两件事第一确认src目录下所有.java文件的包名是否一致package语句不一致会导致javac报找不到符号第二确认 map.txt 的路径引用。很多课程设计源码里GameMap.loadFromFile(map/map.txt)是相对于项目根目录的路径如果你直接用java -cp out Server启动工作目录变了文件就加载不到了。最稳妥的做法是把map目录复制到out下让路径变成相对于classes输出的目录。4.2 编译和启动一套命令两个终端在项目根目录下打开命令行按顺序执行# 1. 编译所有源码到 out 目录-encoding 显式指定 UTF-8 防止中文乱码 javac -encoding UTF-8 -d out src/*.java # 2. 把地图资源复制到 out 目录保证运行时能找到 map/map.txt cp -r map out/ # 3. 启动服务器 java -cp out Server逻辑说明-d out告诉javac把生成的.class文件输出到out目录。不要在src目录里直接编译否则.java和.class混在一起后面复制资源文件会非常混乱。cp -r map out/的作用是把地图文本原封不动地搬进运行时目录——Java 程序启动后工作目录是out它访问map/map.txt时实际查找的是out/map/map.txt如果没有这一步程序会在加载地图时抛出IOException。参数说明如果你在 Windows 上用的是 CMD第二步的命令要写成xcopy map out\map /E /I或直接手动在文件管理器里复制。cp是 Linux/macOS 的语法Windows 下没有。另一个容易踩的点是如果源码是 JDK 8 语法而你的机器装的是 JDK 17编译基本没问题但如果是 JDK 17 语法比如switch箭头、var拿 JDK 8 去编译就会报语法错误。启动前先java -version看一眼环境如果提示找不到命令说明JAVA_HOME和PATH环境变量没配好先去把 JDK 的 bin 目录加进 PATH 再回来这是 Java 环境配置里最基础的一步。注意如果源码里的GameMap.loadFromFile写的是相对路径务必确认启动时的工作目录和 map.txt 的位置一致否则程序会抛NoSuchFileException。4.3 连接验证用 Telnet 当第一个玩家服务器启动后打开另一个终端或另一台电脑执行 Telnet 命令telnet localhost 8888连上后你会看到服务器发送的欢迎文本输入角色名并按回车就进入了 MUD 世界。按顺序验证三个核心功能输入look应该看到出生房间的描述、出口列表和房间里的玩家如果只有你一个人玩家列表可能是空的。输入go north如果 map.txt 里 1 号房间的north出口配置指向了 2 号房间你会看到 2 号房间的描述再输入go south可以回到 1 号房间。开第三个终端再用 Telnet 连一次输入不同的角色名然后两个角色在同一个房间里互相输入say 你好对方应该能看到你的消息。如果在第一步look就卡住或返回空文本优先检查三件事字符集是否 UTF-8、map.txt 是否加载成功服务器控制台有没有异常、当前房间对象是否为空。Telnet 在某些 Windows 版本上默认关闭需要在“控制面板 → 程序 → 启用或关闭 Windows 功能”里勾选 Telnet 客户端。4.4 自己写一个 Java 客户端摆脱对 Telnet 的依赖Telnet 只能验证基本通信如果想让演示更完整可以写一个几十行的 Java 客户端。它做两件事一个线程专门读服务器推过来的文本并打印主线程读本地键盘输入并发送给服务器。public class Client { public static void main(String[] args) throws Exception { // 连接本地服务器端口与 Server.java 保持一致 Socket socket new Socket(localhost, 8888); BufferedReader in new BufferedReader(new InputStreamReader( socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), UTF-8), true); BufferedReader console new BufferedReader(new InputStreamReader(System.in)); // 接收线程服务器推送的任何消息都打印到本机控制台 new Thread(() - { String line; try { while ((line in.readLine()) ! null) { System.out.println(line); } } catch (IOException e) { // 连接断开时 readLine 会返回 null循环自然退出 } }).start(); // 主线程读键盘输入并转发到服务器 String input; while ((input console.readLine()) ! null) { out.println(input); if (quit.equalsIgnoreCase(input.trim())) break; } socket.close(); } }逻辑说明为什么要两个线程因为in.readLine()会阻塞。如果只用一个线程你在键盘上打字的同时服务器推送的消息比如别人的say永远无法显示到控制台。接收线程解决“服务器主动推消息”的展示问题主线程解决“本地输入不阻塞”的问题两者通过同一把Socket连接读写互不干扰。参数说明localhost可以换成服务器的 IP 地址这就能跨机器联机。要注意 Windows 防火墙默认会拦截未授权的入站连接跑服务端的机器需要放行 8888 端口否则客户端连接会长时间卡在connect超时。in.readLine()返回 null 意味着对方关闭了连接这是接收线程唯一正常的退出路径所以IOException那里即使空处理也没关系。5. 避坑与常见问题排查课程设计答辩前的最后一道关5.1 端口被占用Address already in use现象启动 Server 时控制台打印java.net.BindException: Address already in use进程立即退出。原因8888 端口被上一次没关干净的 Java 进程占用或者被系统里的其他服务抢占。Windows 上最容易出现的是你在 IDEA 里点了停止但弹窗里的进程并没有被真正杀掉还驻留在后台。解决先用命令查占用端口的进程再强制结束。# Windows netstat -ano | findstr 8888 taskkill /PID 进程号 /F # Linux / macOS lsof -i:8888 kill -9 进程号更省事的做法是给端口加一个启动参数比如java -cp out Server 8890服务器里用args[0]读取端口这样即使默认端口被占也不用杀进程换个端口直接跑。5.2 中文乱码黑方块和问号占领整个屏幕现象look出来的房间描述全是??????或者类似锟斤拷的乱码而英文命令和输出正常。原因两端字符集不一致。源码、编译、运行时如果有一个环节用的不是同一种编码中文就会在转换过程中损坏。最常见的组合是源码是 UTF-8javac用了 GBK 编码编译运行输出又按 UTF-8 解码整个过程彻底错位。解决全链路统一 UTF-8三个点缺一不可。第一编译命令带-encoding UTF-8第二InputStreamReader和OutputStreamWriter显式传入UTF-8第三Telnet 客户端设置 UTF-8 字符集Windows 自带 Telnet 不支持换 MobaXterm 或 PuTTY 并在会话设置里选 UTF-8。如果你坚持用 Windows Telnet 演示也可以把服务器输出编码统一改成GBK但这条路更拧巴我一般建议直接换终端工具。5.3 玩家退出后房间里的幽灵现象一个玩家断开连接后另一个玩家look时房间玩家列表里还显示着那个人的名字但他明明已经掉线了。原因ClientHandler的finally块只做了socket.close()没有把Player从当前Room的players列表移除也没有从Server.onlinePlayers表里移除。连接虽然断了但内存里的对象引用还在。解决在finally块里补两行finally { if (player ! null) { player.getCurrentRoom().leave(player); PlayerManager.logout(player); } try { socket.close(); } catch (IOException e) { } }清理顺序是从“局部”到“全局”先离开房间的players列表再移出在线玩家表最后关 Socket。这里如果把logout和leave写反了也不至于崩溃只是逻辑上绕一圈。关键是必须两个都做缺一个就会出现幽灵玩家或在线列表残留。5.4 打字没反应我发了消息服务器就是不回现象Telnet 连上后输入命令按回车屏幕上什么都没有服务器控制台也没有任何日志。这是我当年联调时翻过的最气人的一个车。原因PrintWriter没有开启自动刷新。println()只把数据写进了缓冲区没有真正 flush 到 Socket 的 TCP 发送队列数据积在内存里出不去。这类问题最隐蔽——程序不报错、不崩溃、连接也正常就是没响应。解决把PrintWriter的构造改成new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true)或者在每次println()后手动out.flush()。如果整个ClientHandler里有多个println调用手动 flush 很容易漏所以强烈建议用构造器第二个参数的true一步到位。5.5 编译报“非法字符”编码的锅不是语法的锅现象javac编译时提示错误: 编码GBK的不可映射字符或非法字符: \u3010这类错误但代码在 IDEA 里看起来完全正常。原因源码文件以 UTF-8 保存但javac在 Windows 上默认用 GBK 解码源码遇到中文字符串和注释时就崩了。解决编译命令统一带-encoding UTF-8。更彻底的办法是写一个compile.sh或compile.bat脚本把编译、复制资源、启动三条命令固定下来答辩现场直接跑脚本不手敲命令。#!/bin/bash # 一键编译启动脚本编码、输出目录、资源复制全在这里 javac -encoding UTF-8 -d out src/*.java \ cp -r map out/ \ java -cp out Server脚本化之后每次编译都是同一套参数从根上消灭“忘加编码参数”这种人肉失误。另外提醒一句用 IDEA 的同学要检查右下角的文件编码是不是 UTF-8IDEA 默认是 UTF-8但老版本存在单文件被改成 GBK 保存的可能。6. 让 MUD 更像一个“世界”三种低成本扩展6.1 存档玩家退出后世界不遗忘基础版 MUD 的软肋在于“离开即失忆”。加一个存档文件在quit时写盘登录时读档玩家的等级和位置就不会丢。// 存档格式角色名|等级|HP|当前房间ID public static void save(Player p) throws IOException { Files.write(Paths.get(saves/ p.getName() .txt), (p.getName() | p.getLevel() | p.getHp() | p.getCurrentRoom().getId()).getBytes(StandardCharsets.UTF_8)); }这段代码在答辩时可以明确讲“我做的存档是把对象状态序列化到了一行文本里”比引入 JSON 库更稳因为不需要给评委环境额外装依赖。6.2 NPC 对话加战斗让房间有“人气”给Room挂一个ListNPC在describe()里显示 NPC 身影CommandParser里加talk和attack两个分支。NPC 的血量归零后从列表中移除这能让玩家看到自己的行为对世界产生了影响。战斗逻辑用最简单的回合制随机数控制命中5 行代码就能撑起来。6.3 图形客户端给文字世界加一个窗口Swing 客户端本质上就是把 Telnet 的收发拆成JTextArea和JTextField。关键点是接收线程把服务器消息append到文本区后要调用setCaretPosition让滚动条自动滑到底部否则新消息被旧文本挡住给玩家的感觉是“卡了”。服务器端不用改任何协议因为 Swing 客户端和 Telnet 在 TCP 层做的事完全一样——都是收发文本行。我拿这份源码重读时第一件事不是看代码而是数map.txt里的房间数量。如果地图只有两三个房间演示起来就像在走廊里来回踱步毫无探索感有十二个房间、每个房间两三个出口玩家在答辩演示时就能走出一条有故事感的路线。从那以后我每拿到一份课程设计源码都会先翻资源配置文件——地图怎么组织、存档怎么设计、编码是否统一。这三个问题能答上来项目质量基本就有底了。这份 MUD 源码的价值不在于画面而在于它把 Java 网络编程里最核心的三个知识点串成了一个闭环。把它跑起来、改几行、加一个属于自己的房间TCP 连接、多线程、状态同步这三块知识就彻底焊死在记忆里了。希望帮到你。本文还有配套的精品资源点击获取