☰
从单机到多人在线:Java MUD游戏课程设计全解析
2026/10/7 4:30:48 网站建设 项目流程

简介:这是一份面向高校软件工程专业学生的Java课程设计参考项目,以MUD多人在线游戏为主题,模拟经典文字交互式游戏的核心流程,适合初学Java网络编程、面向对象设计与基础GUI开发的读者作为练手与答辩素材。压缩包共32个文件,包含java源码、class编译产物、Eclipse工程配置以及资源备注文件等;源码便于逐行阅读和二次修改,class文件可直接运行体验效果,工程配置文件则有助于快速导入开发环境。包体约53KB,轻量精简,方便部署调试。目前已有204人学习下载。通过该项目可以了解多人游戏会话管理、核心玩法模块划分、简易数据保存方式等实现思路,也能为课程设计报告中的功能设计与测试环节提供直观参照,是巩固Java基础并完成课设任务的不错选择。 MUD 游戏课程设计:从单机模拟到真正能跑的多人在线协议

1. MUD 课程设计:这个标题讲的什么,不只是协议

如果你搜到“吉林大学 软件学院 java课程设计MUD多人在线游戏简单模拟.zip”,多半是要做一门 Java 课设,题目是“MUD 多人在线游戏”。MUD 是 Multi-User Dungeon 的缩写,上世纪七八十年代就有了,是文字冒险游戏和图形网游的共同祖先。放到今天的课程设计里,它最大的价值不是做游戏,而是用最少的图形代码,把 Java 网络编程、多线程、集合类、字符串解析、状态机这套东西全部串起来:客户端发来一行命令,服务器解析后改游戏世界状态,再广播给同房间的其他玩家。这个项目的“简单模拟”版本,整个交付物通常就一个 ZIP 包,里面是 Java 源码、一个 README、可能还有一个启动脚本。

很多学生看到“多人在线”就发怵,觉得要做一套类似魔兽世界的网络架构,其实课程设计的要求通常低得多。一个存档里几十个房间,支持玩家登录、移动、查看周围、聊天、战斗和物品拾取,服务器按“每客户端一个线程”来处理,就算达标。正因如此,MUD 是 Java 课设里少有的“不做界面反而更好”的题目:Swing 客户端的加分远不如稳定的命令响应和服务器端并发处理来得实在。这篇文章不讨论某个具体学校的要求,而是从“一个能跑通的 MUD 需要什么”出发,把整个实现路径拆开讲清楚。

2. 游戏循环、线程模型与消息分发:先把技术地基立住

2.1 基于线程的并发模型还是事件驱动的 NIO

MUD 服务器的核心不是游戏逻辑,而是网络层怎么支撑“很多人同时打字”。常见的并发模型有三种轮换思路。最初 AC 风格是“来一个客户就 new 一个 Thread”,写法直观,Java 自带的阻塞 IO 能完美配合。另一个方向是 Java NIO 引入的 Reactor 模式,用一个 Selector 管理所有 Socket,免去线程开销,但写命令解析要处理粘包、半包、回写缓冲,代码量直接翻两倍。第三种介于两者之间,用线程池 + 阻塞队列,接受连接的主线程把 Socket 丢给 ExecutorService,达到异步效果。

课程设计基本选第一种最合适,原因有两个。一是逻辑年级低,阻塞 IO 的 Socket 数据读取天然同步,读完一行再解析,不用处理缓冲区边界。二是调试方便,一个客户端一个线程,崩溃时根据线程名就能定位。用 NIO 去处理一个房间不到五十人的场景,属于自己给自己挖坑,等收尾时把“并发量高所以用 NIO”写进报告,连评审老师都无法反驳,但那是过度设计。我通常的做法是维护一个静态的 ConcurrentHashMap<String, Player>,每个客户端线程独立跑,读到的行命令放入一个命令队列,由 独立的游戏线程统一消费修改世界状态,这样网络线程和逻辑线程分离,后续锁问题会少很多。

2.2 同步锁策略:防止两个玩家同时捡走地上的同一件武器

逻辑层最常见的坑就是共享房间状态。地图上的一个物品,如果 A 和 B 同时发送 pickup 命令,两个线程都读到一个“物品还在”然后都往背包里加,数据就错了。同步锁的三层策略是:第一层锁整个命令队列保证一个时刻只处理一条全局命令;第二层锁当前房间对象,任何修改房间内物品和玩家的操作都 synchronized(room);第三层锁玩家背包,只在物品转移时使用。这里不追求高性能,追求“写完不出并发 bug”。

命令分发可以用一个简单的 Map<String, CommandHandler>,命令字映射到处理类,比 switch case 好维护,也便于报告里写“策略模式”。但要注意 Map.get 得到的处理器是共享的,处理器实现里别放成员变量,全部用局部状态。这样即使后续想换线程模型,游戏逻辑代码不用动,只需要替换网络层和分发入口。

2.3 理解 MUD 的“简单模拟”边界

课程设计的标题是“简单模拟”,这个定语很重要。真正的 MUD 有战斗回合制、技能冷却、NPC 自动漫游、持久化存档。模拟版让每个功能点到为止即可:战斗只做攻击力和生命值两个数值,NPC 不做 AI,房间只设计三十个以内,存档文件每五分钟全量写一次。磁盘开销不是考量,关键是不能让自身陷入复杂的功能膨胀,否则期末周你会一边复习一边改 bug,而那种体验跟你写这个游戏时收获到的正反馈完全不成比例。

还有一点容易被忽略:提交的 ZIP 包,命名格式通常是“学号_姓名_题目.zip”,里面要有 README 说明如何启动服务器和客户端、默认端口、演示账号。这个 README 的分量,有时候比代码本身的印象分还大。第一步先把一个能运行的骨架立起来,比追求功能全面有用得多。

3. Java 代码落地:服务器、客户端与命令解析的具体实现

3.1 服务器端核心:接受连接与每个会话的读写循环

服务器端的切入点就是阻塞 IO 加每客户端一线程。下面这段代码是这个骨架的锚点,支持新连接进入后即刻交给独立线程,每个线程从客户端输入流中按行读取命令。

public class MudServer { public static final int PORT = 4321; private final ServerSocket serverSocket; private final GameWorld world = new GameWorld(); public MudServer(int port) throws IOException { serverSocket = new ServerSocket(port); System.out.println("MUD server started at port " + port); } public void start() { while (!serverSocket.isClosed()) { try { Socket socket = serverSocket.accept(); // 每个连接一个线程,阻塞式读取客户端发送来的行命令 new Thread(new ClientHandler(socket, world)).start(); } catch (IOException e) { e.printStackTrace(); } } } } class ClientHandler implements Runnable { private final Socket socket; private final GameWorld world; private Player player; private BufferedReader in; private PrintWriter out; public ClientHandler(Socket socket, GameWorld world) { this.socket = socket; this.world = world; } @Override public void run() { try { in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8")); out = new PrintWriter(socket.getOutputStream(), true); // 第一步先要求玩家输入昵称,以此充当登录动作 out.println("请输入昵称进入游戏:"); String name = in.readLine(); if (name == null || name.trim().isEmpty()) { socket.close(); return; } player = world.loginPlayer(name.trim(), socket, out); String line; while ((line = in.readLine()) != null) { CommandDispatcher.dispatch(player, line.strip()); } } catch (IOException e) { // 客户端强行断开时走到这里,正常下线处理 world.logoutPlayer(player); } finally { try { socket.close(); } catch (IOException ignored) {} } } }

这段代码里有两个参数需要自己把握。一是端口号,别用 8080 和 3306,很容易跟本机其他软件冲突,我一般用 8000 以上的随机段,比如 4321。二是编码格式,统一 UTF-8,否则中文命令和中文聊天内容会变成乱码。line.strip()是用于清理首尾空格的写入方式,如果玩家按了几个空格再回车,CommandDispatcher收到的不该是四条带空格命令,而是一条空命令。

GameWorld.loginPlayer里面会处理昵称冲突:如果已有同名玩家在线,则拒绝并让客户端重新输入。这个功能是在 ClientHandler 里实现还是挪进 world,取决于你有没有计划以后支持断线重连。模拟版放 world 里统一管理就行。真正需要现在补齐的不在这段代码里,而在下一节CommandDispatcher和GameWorld的内部结构。

3.2 命令解析器:从原始输入到游戏动作的分发

命令分发要考虑三种输入形态:look这种无参数命令,go north这种带方向参数命令,以及聊天用的say hello world这种命令字加可变内容。用第一个空格切分命令字作键,把剩余字符串原样传给对应处理器,是这个方案最稳的做法。

public class CommandDispatcher { private static final Map<String, CommandHandler> HANDLERS = new HashMap<>(); static { // 注册指令到处理器:命令字 -> 处理对象 HANDLERS.put("look", new LookHandler()); HANDLERS.put("go", new GoHandler()); HANDLERS.put("say", new SayHandler()); HANDLERS.put("pickup", new PickupHandler()); HANDLERS.put("help", new HelpHandler()); HANDLERS.put("quit", new QuitHandler()); } public static void dispatch(Player player, String input) { if (input == null || input.isEmpty()) { player.sendMessage("输入为空。输入 help 查看帮助。"); return; } String[] tokens = input.split(" ", 2); // 参数最多只切一次,保留剩余内容 String cmd = tokens[0].toLowerCase(); String arg = tokens.length > 1 ? tokens[1] : ""; CommandHandler handler = HANDLERS.get(cmd); if (handler == null) { player.sendMessage("未知指令: " + cmd + ",输入 help 查看帮助。"); return; } // 同步派发,保证同一时刻只有一个处理器在改世界状态 handler.execute(player, arg); } }

这里的同步位置是锅底最后的防线。如果你在每个 handler 内部做加锁,那第一次写pickup时可能忘记锁,第二次写drop又重复加锁造成死锁。统一在这里用 synchronized 包住派发动作,能让所有命令串行执行,对课程设计的规模来说是性价比最高的做法。性能方面,单个服务器撑上百个在线玩家时,这种全局锁会有压力,但模拟场景完全够用。你要是在报告里说这里做了锁粒度优化,就是把简单方案复杂化,容易在答辩时被问住。

split(" ", 2)是细节且重要的地方。玩家输入say hello world,如果你用split(" "),会得到三个数组元素,拼接聊天内容时得再用空格 join 回去,而且在命令字后多敲两个空格时会解析出一个空字符串命令,当事人当场以为游戏崩溃。限制切割次数为 2 以后,arg 始终保留除命令字外的全部原文,聊天内容里的空格一个不会丢。

3.3 客户端:连上服务器并让用户输入变成一行行命令

客户端的代码比服务器少得多,就是一个连接 Socket、一个读取服务器消息的线程、一个主循环发送用户输入。这里我只给出关键部分,因为它实质上就是套在输入输出流上的壳。

public class MudClient { public static void main(String[] args) throws IOException { String host = args.length > 0 ? args[0] : "127.0.0.1"; int port = args.length > 1 ? Integer.parseInt(args[1]) : 4321; Socket socket = new Socket(host, port); BufferedReader fromServer = new BufferedReader( new InputStreamReader(socket.getInputStream(), "UTF-8")); PrintWriter toServer = new PrintWriter(socket.getOutputStream(), true); BufferedReader console = new BufferedReader(new InputStreamReader(System.in, "UTF-8")); // 单独线程持续打印服务器推送的消息 Thread reader = new Thread(() -> { try { String msg; while ((msg = fromServer.readLine()) != null) { System.out.println(msg); } } catch (IOException e) { System.out.println("与服务器断开连接。"); } }); reader.setDaemon(true); reader.start(); String line; while ((line = console.readLine()) != null) { toServer.println(line); if (line.trim().equalsIgnoreCase("quit")) { break; } } socket.close(); } }

这段代码没有做复杂处理,但它有一个必要细节:服务器输出用readLine()读取,所以服务器端每次发送消息结尾必须以\n结束,否则客户端会一直阻塞等待。初写者的常见翻车现场就是用了print而不是println,或者消息里无意中带了多余换行,导致客户端收到消息不断行。客户端主循环里 break 之前,最好也发送一次quit让服务器清理玩家对象,直接关 socket 虽然会让服务器走异常分支清理,但那个路径容易有漏网之鱼,比如玩家身上物品还在房间里。

还可以顺手做一个 Ctrl+C 退出时的 hook,在 finally 里发送一行 quit。不做也行,临时按 Ctrl+C 模拟断线对服务器来说属于正常容错场景。

3.4 房间与物品:完成 go 和 pickup 两条路径的数据支撑

前面讲的是网络层,游戏世界最容易被“简单模拟”带偏误的是房间逻辑。我先给出房间结构和一条go命令的实现,用它解释方向映射。

public class Room { private final String name; private final String description; // 方向字符串 -> 房间对象引用,初始只支持四个主方向 private final Map<String, Room> exits = new HashMap<>(); private final List<Item> items = new ArrayList<>(); public Room(String name, String description) { this.name = name; this.description = description; } public void addExit(String direction, Room room) { exits.put(direction, room); } public Room getExit(String direction) { return exits.get(direction); } public synchronized void addItem(Item item) { items.add(item); } public synchronized Item removeItem(String itemName) { for (int i = 0; i < items.size(); i++) { if (items.get(i).getName().equalsIgnoreCase(itemName)) { return items.remove(i); } } return null; } // 生成进入房间时看到的完整描述 public synchronized String getFullDescription() { StringBuilder sb = new StringBuilder(); sb.append(name).append("\n").append(description).append("\n"); sb.append("可走方向: ").append(String.join(", ", exits.keySet())).append("\n"); if (items.isEmpty()) { sb.append("这里空无一物。\n"); } else { sb.append("地上有: "); for (Item item : items) { sb.append(item.getName()).append(" "); } sb.append("\n"); } return sb.toString(); } }

getFullDescription里的加锁是整个房间描述同步的一次性快照,客户端一次 look 能看到房间、出口、物品三点信息。如果描述和物品状态分开取,可能会打到中间状态,比如物品刚好被捡走、描述却刷新不及时。对于课程设计来说这次快照可以缓解多半的同步问题。

go north的处理器逻辑非常直白:拿到方向参数,查room.getExit(arg),若不存在则提示“此路不通”,存在则把玩家从旧房间的玩家列表移除,加到新房间,再打印新房间描述。这个过程同样是 synchronized 包住的,避免双脚同时跨过两个方向的竞态。打印描述应当放在玩家已经进入新房间之后,否则客户端收到的是旧房间的景色。

地图初始化放在GameWorld构造器里,文件资源加载对 MUD 来说可有可无。真正值得做的是手写几个相邻房间构成一个环形地图,这边设计时留一个RoomGraphFactory这种单独类,将来想扩展地图数量不用改主服务器类。

4. 排查与避坑:当前线程模型下最容易翻车的七个现象

4.1 并发修改异常:消息广播时房间列表被改动

现象:服务器向同房间其他玩家广播“某某离开了游戏”时,直接遍历玩家列表,抛ConcurrentModificationException。

原因:遍历的同一个列表在别的玩家线程里被增删。教室场景写 MUD,这个异常是并发问题最常见的一个入口。列表是 ArrayList 或 HashMap,println 在遍历的同时被另一个线程调用导致冲突。

解决:广播时统一从一个房间的对象拿玩家列表。获取引用后加锁,或者直接遍历playersInRoom.values().toArray()先做一个快照副本。课程设计规模小,用副本遍历就足够,费不了多少内存。我实际写的时候会用第一种锁方案,因为后续报告里好解释,逻辑上也顺。

4.2 中文乱码:Windows 命令行控制台输出完全错误

现象:服务端和客户端都在本地启动,输入中文命令后,客户端显示的是问号或乱码。

原因:默认控制台的编码跟 UTF-8 对不上。Windows 的 cmd 用 GBK,你代码里设置了 UTF-8,双方编码不一致。Java 17 以后没有-Dfile.encoding的操作,很多新手第一次跑项目时一头雾水。

解决:明确在服务器启动时设置字符集,在 main 里先执行一遍System.setProperty("file.encoding", "UTF-8");如果你的 Java 版本不支持运行期修改,就在启动命令里加-Dfile.encoding=UTF-8。同时确保编译时javac -encoding UTF-8,不然源码里高高兴兴写的中文房间名,编译出来的是乱码字节。Eclipse 和 IDEA 里项目文件属性里选 UTF-8 也会少很多麻烦。

4.3 端口被占用:启动服务器时报 Address already in use

现象:本机跑过一次服务器进程没有关干净,再次启动时报java.net.BindException: Address already in use。

原因:调试时按了 IDE 的 stop,但线程没有真正退出,或者旧的 cmd 窗口还开着另一个实例。同一个端口不能同时被两个进程监听。

解决:确认没有残留进程后,Windows 下用netstat -ano | findstr 4321找到进程 ID,再taskkill /F /PID 进程号。如果是 IDE 的残留进程,直接到任务管理器关掉 java 进程。为了避免这种问题,项目里可以加一个“非标准端口检测失败时自动换端口”的逻辑,但课程设计别加,端口不固定会给后续联调带来麻烦。

4.4 客户端输入空行导致服务器疯狂发提示

现象:Windows 命令行下玩家按回车不留神多按了一次,服务器瞬间收到一堆空行指令,日志被迫滚动刷新,像系统被攻击。

原因:客户端主循环是while ((line = console.readLine()) != null),换行回车等同于一个空字符串送到服务器。服务器在CommandDispatcher.dispatch里没有对空白字符串做剔除,每一条都创建一条空提示。

解决:客户端发给服务器的前先检查,if (line.isBlank()) continue;从源头掐掉。服务器端侧也要防一手,对input.isBlank()直接返回,这样即使客户端程序被第三方改写,也不会造成大量无意义回包。实际开发时我会把校验同时放在两端,互为保险。

4.5 客户端断开后服务器线程未退出:僵尸线程把内存吃光

现象:开了几十个客户端窗口,部分直接点 X 关闭,服务器端ps查看线程数越来越多,玩家离线了但他所在房间还显示在线。

原因:客户端的quit命令没有被认真处理,或者服务器只在读取到流结束(IOException)时才清理。如果客户端网络断开得干脆利落,服务器在readLine()调用时会返回 null 或抛出异常,这路处理没问题;但玩家的 Java Swing 界面只关闭了窗口没送quit,然后主线程还在等待输入时就会留下僵尸线程。

解决:ClientHandler.run的 finally 块始终调用world.logoutPlayer(player),无论正常退出还是异常断开一律走这里。并在GameWorld.logoutPlayer里把玩家从所在房间的玩家列表中删除,再广播系统消息。这样清理逻辑只有一条路径,不会因为异常出现多副本。

4.6 全局锁导致广播延迟:房间有二十人时出现明显卡顿

现象:房间内所有人同时发送命令,消息响应突然出现一到两秒的延迟,局域网内也不像局域网。

原因:CommandDispatcher.dispatch用 synchronized 做全局锁,所有命令串行执行。玩家输入文本命令有天然间隔,但一次性大量输入(比如粘贴一段话)时,服务器逐个处理大量消息造成排队。

解决:课程设计规模下,这么做没问题。非要改善的话,可以缩小锁范围到房间级别,同一个房间的玩家命令才串行,不同房间各自并行。但随之而来的是广播消息时可能要被多次加锁,代码量涨不少。我建议在答辩时把“全局锁 vs 房间锁”作为可优化方向提出来,比直接把代码改成房间锁更能体现设计思考。

4.7 服务器端报“拒绝连接”的排查顺序

现象:客户端启动后一秒内就报java.net.ConnectException: Connection refused。

原因:服务端根本没启动、主机地址写错、防火墙拦了端口,三种可能并存。排查顺序先从最简的来。

解决:先在本机用telnet 127.0.0.1 4321测试端口通不通,不通就查服务端启动日志;通就查客户端填的 host 是否写成远程服务器 IP 才能用,127.0.0.1 连局域网另一台机器当然拒绝;最后确认 Windows 防火墙是不是把 JDK 进程拦截了,进“允许应用通过防火墙”里勾选 javaw.exe。这一步操作很多人踩进坑里,因为 IDEA 弹窗点“允许”时没勾选专用网络。如果在校做课程设计还涉及实验室电脑的还原卡,一次重启后防火墙配置就还原了。

5. checklist 式验收与三个值得加强的进阶点

5.1 用一份手工验收清单代替单测

课程设计的 UI 是命令行的图形界面,自动测试写起来成本高、收益低,不如手工验收。但手工验收要有清单。按下面这个顺序跑一遍,每一步都是可复现的。

先起服务器,开两个客户端登录进去,在 A 里执行look,A 能看到房间描述且 B 不受影响。接着 B 输入go north到隔壁房间,再让 A 发送痛苦的一条聊天say 大家周末有空吗,A 自己能看到这条消息。此时 B 不在同房间会是什么表现。按照这个预期去核对逻辑,你会发现很多“正常”的地方其实隐含了多房间广播的设计,而你之前只把 broadcast 写在了本房间内。

战斗功能的最低验收条件:玩家攻击怪物后,怪物的血量减少,而其他玩家的客户端能看到战斗过程的广播。如果战斗是私有的,其他玩家只会看到玩家对着空气挥拳头。

存档功能的最低验收条件:杀掉一个怪物捡到一件武器,输入 save 命令后重启服务器,恢复后玩家身上的物品还要在背包里,地图上被捡走的武器不会重新刷出来。这个功能如果做不出来,报告里就不该提,宁可删功能也不要半成品交上去。

5.2 状态机的价值:把“命令是否合法”收敛成丁点

MUD 的初始阶段通常有个登录状态:玩家输入昵称之前不接受任何游戏指令。这个状态做出来以后,命令分发器会很自然地升级成state字段。待登录态只接受name指令,游戏态才接受 7 个命令,死亡态只接受quit。这个状态机设计不复杂,逻辑却能让代码的鲁棒性上一个台阶,答辩时也能对上“软件工程”的课程目标。

做法是在ClientHandler里用一个 volatile 状态字段,dispatch之前先检查当前状态允许否。别把这个状态放在玩家对象里,因为不同客户端的输入是不需要跨线程同步的,留在 handler 里更干净。

5.3 让聊天支持 emoji 之外的纯文本着色

最后一个加分项完全免费:在PrintWriter输出时按 ANSI 转义序列给玩家 message 上色。比如系统消息用 36 的青色,玩家在说话时用 37 的白色,物品提示用 33 的黄色。Windows 10 以后的 cmd 和 Windows Terminal 默认支持 ANSI 转义,IDEA 的控制台也支持。酒店的播放场景特别符合 MUD 那种文字氛围。

下边给一个简化的输出工具类,统一封装:

public class ColorUtil { public static final String RESET = "\u001B[0m"; public static final String CYAN = "\u001B[36m"; public static final String YELLOW = "\u001B[33m"; public static final String GREEN = "\u001B[32m"; public static String cyan(String s) { return CYAN + s + RESET; } public static String yellow(String s) { return YELLOW + s + RESET; } public static String green(String s) { return GREEN + s + RESET; } }

消息发送时统一走player.sendMessage(ColorUtil.cyan(...))之类的方法,而不是到处拼字符串。这是整个项目里最容易做出“高级感”也最不容易出 bug 的地方,因为纯输出转换不涉及状态修改。

5.4 最后的习惯:提交前把每个类文件拷到新目录编译一次

这个学期项目里我最想强调的习惯,是提交前把源码复制到一个全新目录里,只靠命令行手动 javac 编译。IDE 自动管理 classpath 会掩盖很多真实问题:你用的依赖要么没写进 README,要么漏了某行 import,对方拿到 zip 一开就报找不到类。我亲身经历过一次在实验室电脑上给同学看项目,他的代码在自己机器上跑得好好的,一换机器就开始崩溃,后来发现是缺了一个json.jar却在 IDE 里引用了。

用命令行编译能立刻发现这些问题。写一个start-server.bat和start-client.bat,在脚本里写清楚javac -encoding UTF-8 -d out src/**/*.java和java -Dfile.encoding=UTF-8 -cp out com.mud.server.MudServer。不要让评审老师去翻代码找启动方式,这一步在评分不高的功能里可能决定潜在地毯。我做这个项目时的习惯是最后十分钟只干这件事:删掉 out 目录,跑脚本,重新登录两个号对打一遍,确认无异常后直接编译生成 zip。这一步做完后,你心里对所谓“交付”会有底得多,至少不会在演示的当口被命令行的编码问题当场击倒。

希望这份整理能让你把课程设计既做成一个能跑的交付物,也对 Java 的网络编程与并发模型形成一套自己的体感。给 IDE 会自动帮你补完的语法打好底子,真正在服务器与多客户端之间调度状态的时候,那些你以为熟的 synchronized 和 Map,才第一次有了穿透纸面的意义——希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询