资讯动态

Java课程设计MUD多人在线游戏:Socket与多线程并发实战

发布时间:2026/10/8 9:44:12 来源:尧图企业网站定制
简介一份吉林大学高分课程设计作品——MUD 多人在线游戏简单模拟项目适合 Java 课程设计、期末大作业与毕业设计参考。代码注释充分新手也能读懂项目曾获 98 分并得到导师认可功能完整、界面简洁、部署简单导入开发环境后即可运行。压缩包共 32 个文件约 55KB10 个 Java 源文件负责核心业务逻辑16 个 class 为编译后的可运行产物其余为 Eclipse 工程配置与项目描述文件方便直接打开、调试与二次修改。项目围绕房间探索、玩家指令交互等典型 MUD 场景组织代码模块划分清晰可从指令解析、移动处理等部分入手快速理解流程也便于答辩讲解与功能扩展。已有 110 人学习浏览说明具备较高参考热度。下载后可获得完整可运行工程是完成高分课设、毕设方案的实用样例。1. java课程设计MUD多人在线游戏简单模拟项目到底在做什么一个java课程设计MUD多人在线游戏简单模拟项目难点从来不在“游戏”两个字而在“多人”。想象答辩场景老师打开两个终端同时连进你的服务器两个角色在相邻房间来回走能互相看见聊天一起砍怪时怪物血量不乱扣中断端口的玩家退出后其他玩家毫无感知——这四步演示成功项目基本盘就稳了。这类课设不需要图形界面不需要Spring和数据库用原生Java的Socket、多线程和几个核心类就能跑通正好把网络编程、并发和面向对象设计这三块课程重点串起来。手里有源码的人可以照着拆结构、预判答辩追问还没动工的按这套结构从零写也来得及。2. 搭建骨架Room、Player、MudServer 三类的模块划分与纯文本命令协议2.1 课设项目里四个核心类的职责边界MUD最小闭环是服务器启动 → 玩家连接 → 进房间 → 移动 / 聊天 / 战斗 → 退出。所有逻辑绕着四类点转不带框架、不带数据库纯JDK就能跑。我一般这样分Room管地图、Player管连接与角色绑定、Monster管战斗目标、MudServer管启动与在线池。类关键字段职责答辩一句话Roomid, description, exits, monsters地图节点描述房间并记录邻接关系地图就是一张有向图Playersocket, out, name, hp, room一个连接对应一个线程角色状态跟着连接走每个玩家一个会话Monstername, hp, attackDamage房间里的战斗目标血量必须线程安全可被打死的NPCMudServerserverSocket, onlinePlayers接收连接、调度命令、持有在线玩家池游戏的总线为什么这么切课程设计答辩最看结构清晰度。一个类塞八百行和拆成四个八十行的类讲起来完全不是一种感觉。常见翻车是把命令解析、地图初始化、战斗逻辑全写在Player里最后Player变成几百行的“上帝类”被追问“这个类到底负责什么”时当场卡壳。这四类就能跑起来。以后要加背包、装备、任务就往Player和Room上加字段不会牵连网络层。保持网络与游戏逻辑分离是这份课设代码最值得模仿的一点。2.2 协议选型纯文本命令加别名Telnet 就能验收命令协议我直接选纯文本不用自定义二进制报文。原因很实在验收时用Windows自带的Telnet或者MobaXterm连上去就能玩演示成本极低自定义报文虽然看着专业但要额外写客户端评审老师没有耐心装你的客户端。命令示例行为look / llook查看当前房间描述、出口与怪物move / mmove north沿方向移动支持四方向及缩写attack / aattack wolf攻击当前房间里的怪物saysay 有人吗对同房间玩家广播helphelp列出所有命令quitquit退出并清理角色协议只有一条铁律解析时先trim再小写多空格、大小写混合都不能把程序打懵。下面是我常用的分发器写法public static void dispatch(Player p, String raw) { if (raw null || raw.isBlank()) { return; } String[] parts raw.trim().toLowerCase().split(\\s); switch (parts[0]) { case look, l - handleLook(p); case move, m - handleMove(p, parts.length 1 ? parts[1] : ); case attack, a - handleAttack(p, parts.length 1 ? parts[1] : ); case say, s - handleSay(p, raw.substring(raw.indexOf( ) 1)); case help - handleHelp(p); case quit - handleQuit(p); default - p.println(未知命令输入help查看帮助); } }逻辑说明先把整行首尾空白去掉、转小写再按空白切分这样attack wolf和Attack Wolf都能正确解析。say单独用raw.substring取参数是为了保留聊天内容里的空格不能用split后的parts拼。命令分发器只做一件事——把字符串映射到方法不直接操作房间和玩家。参数映射的规则全部前置到这里后续加命令只动这一个类不用翻网络层代码。一个容易被忽略的设计game命令如果设计成无参数那parts数组只有一个元素parts[1]会越界所以每个取参数的地方都要判断parts.length 1。这段判断虽然多写几行但它挡住了演示现场最常见的空指针崩溃。3. 用原生 Socket 把“多人”跑起来线程模型、在线池与广播3.1 每连接一个线程阻塞 IO 才是课设的最佳选型MUD服务器最核心的代码就是这段。先上Server骨架再解释为什么非阻塞IO在这里属于过度设计public class MudServer { private int port 4000; private ConcurrentHashMapString, Player onlinePlayers new ConcurrentHashMap(); public void start() throws IOException { try (ServerSocket serverSocket new ServerSocket(port)) { System.out.println(MUD server listening on port); while (true) { Socket socket serverSocket.accept(); Player player new Player(socket); new Thread(() - handlePlayer(player)).start(); } } } private void handlePlayer(Player player) { String line; try (BufferedReader in new BufferedReader( new InputStreamReader(player.getSocket().getInputStream(), StandardCharsets.UTF_8)); PrintWriter out new PrintWriter(player.getSocket().getOutputStream(), true)) { player.setOut(out); out.println(欢迎进入MUD输入help查看命令); while ((line in.readLine()) ! null) { CommandParser.dispatch(player, line.trim()); } } catch (IOException e) { // 客户端断开连接是常态打印堆栈反而吓到答辩老师 } finally { logout(player); } } }逻辑说明外层循环只做accept接到一个Socket就开一个线程跑handlePlayer。每个玩家一个线程读操作in.readLine()是阻塞的玩家不输入时线程就挂在那里不耗CPU这是阻塞式IO最适合课设的地方。try-with-resources保证连接关闭时Buffer和Socket全部释放finally里的logout负责清理在线池与房间指针。参数说明端口选4000而不是常见的8080是为了避开本机可能占用的Web服务端口同时1024以上端口启动不需要管理员权限。编码统一UTF-8否则Linux服务器上中文聊天全是乱码这个问题在课设验收时几乎必现。为什么不建议上NIO或者Netty课程设计的评分点在网络编程基础不是中间件选型。NIO的Selector、半包粘包、ByteBuf这些概念讲深了容易被追问到“你自己写一遍看看”届时反而翻车。单机几十个连接的课设场景阻塞IO加多线程就是最优解源码短、好答辩、不容易卡死。3.2 在线池用 ConcurrentHashMap广播遍历与清理顺序在线池管着当前所有玩家它同时被连接线程、命令线程、广播线程访问必须在设计上就保证线程安全。不用HashMap加synchronized是因为broadcast要遍历全体玩家锁住整张表会让进出的玩家一起等用ConcurrentHashMap的弱一致性广播少看到一两个刚进出的玩家完全无所谓。public void broadcast(String message, Player exclude) { if (message null || message.isBlank()) { return; } for (Player p : onlinePlayers.values()) { if (p ! exclude p.isConnected()) { p.println(message); } } } public void logout(Player p) { Room room p.getRoom(); if (room ! null) { room.removePlayer(p); } onlinePlayers.remove(p.getName()); broadcast(p.getName() 退出了游戏。, p); System.out.println(current online: onlinePlayers.size()); }逻辑说明broadcast遍历所有在线玩家排除掉当前角色后逐条写入。logout的清理顺序有讲究——先移出房间再从在线池移除最后广播离开消息。顺序反了的话广播过程中其他玩家发起的look命令还能看到这个“幽灵角色”。参数说明isConnected()的判断在Player里维护一个AtomicBooleansocket关闭或readLine返回null时置为false。这里不用socket.isClosed()因为TCP连接半关闭状态下isClosed()返回的false会让你误以为对方还活着一个标志位比系统API可靠得多。在线池的读写路径只有三条连接建立后注册、命令执行中查表、断开时移除。把这三条路走干净并发这块就不会有脏数据。答辩时老师问“多线程安全怎么保证”你指这三处设计就够了不用背教科书上的锁理论。4. 从村口走到森林Room 地图、移动与 30% 随机遇敌的落地4.1 Room 用 Map 描述出口怪物列表用 CopyOnWriteArrayList地图是MUD唯一的静态数据我习惯在World类里用硬编码初始化5到8个房间这样源码一眼看得懂不需要配置文件解析器。Room本身的代码很薄边界都靠数据结构保证public class Room { private final String id; private final String name; private final String description; private final MapString, Room exits new HashMap(); private final ListMonster monsters new CopyOnWriteArrayList(); private final ListPlayer players new CopyOnWriteArrayList(); public void addExit(String direction, Room target) { exits.put(direction.toLowerCase(), target); } public Room getExit(String direction) { return exits.get(direction.toLowerCase()); } public void addPlayer(Player p) { players.add(p); } public void removePlayer(Player p) { players.remove(p); } }逻辑说明用MapString, Room描述出口关系天然支持“按方向取邻居”不需要数组下标也不用手写四个方向的setter。direction统一小写存入查询也小写从源头杜绝了大写N和小写n走到不同房间的诡异问题。为什么怪物和玩家列表都用CopyOnWriteArrayList多个玩家同时移动进入同一房间时会有并发遍历与修改同一条链表。用普通ArrayList一个玩家离开时remove另一个玩家正在look遍历直接抛ConcurrentModificationException服务器线程当场死掉。写时复制的代价是写操作重但房间人数撑死几十个读多写少的场景下它是课设级别最稳的容器。World初始化就几行我一般这么写public class World { private static final MapString, Room rooms new HashMap(); public static void init() { Room start new Room(start, 村口, 你站在村口往北是森林往南是荒地。); Room forest new Room(forest, 森林, 高树遮天树影里有双眼睛盯着你。); Room wasteland new Room(wasteland, 荒地, 风卷着沙土什么也没有。); start.addExit(north, forest); start.addExit(south, wasteland); forest.addExit(south, start); wasteland.addExit(north, start); forest.getMonsters().add(new Monster(wolf, 30, 6)); rooms.put(start.getId(), start); rooms.put(forest.getId(), forest); rooms.put(wasteland.getId(), wasteland); } }逻辑说明房间与出口构成一张有向图村口 → 森林 → 村口玩家走进森林还能原路返回。给森林放一只wolf作为战斗演示素材这样答辩演示路径最短出生村口向北一步遇敌攻击解决。地图做成环形或树形都可以但第一版务必保证每个出口都有来有回否则演示时走进死房间会被说“设计不完整”。4.2 移动与遇敌先离开后进入概率自己说了算移动命令的逻辑只有四步但顺序错了就是瞬移bugprivate static void handleMove(Player p, String direction) { Room current p.getRoom(); Room target current.getExit(direction); if (target null) { p.println(那边没有路。); return; } current.removePlayer(p); target.addPlayer(p); p.setRoom(target); p.println(target.getDescription()); if (randomEncounter(p)) { p.println(一只野狼挡住了去路输入 attack wolf 战斗。); } }逻辑说明先离开旧房间、再进入新房间、最后设置Player的room引用。顺序如果颠倒比如先setRoom再调removePlayer那么remove的是新房间里的引用旧房间的残留引用永远删不掉之后look在这个房间能看到一个不存在的玩家。移动命令里没有加锁因为Room的玩家列表是CopyOnWriteArrayList并发移动都各自修改自己的旧房间和新房间不会互相覆盖。随机遇敌的概率写死一处就行private static boolean randomEncounter(Player p) { if (ThreadLocalRandom.current().nextInt(100) 30) { return true; // 30% 概率遇敌 } return false; }参数说明30是概率阈值改成90就是“三步一遇敌”适合答辩演示战斗改成5就接近安全区。用ThreadLocalRandom而不是Random是因为多线程共用同一个Random实例会有竞争ThreadLocalRandom每个线程独立种子性能更好且不会出现重复序列的玄学问题。演示前把概率调到80保证老师走到森林必触发战斗不会因为脸黑空跑三分钟。4.3 战斗与死亡判定怪物的血量交给 AtomicInteger两个玩家同时攻击同一只怪物怪物的血量如果是个普通int扣血会互相覆盖出现“打了十刀怪还活着”的灵异事件。把血量换成AtomicInteger是最小的修复成本public class Monster { private final String name; private final AtomicInteger hp; private final int attackDamage; private final int maxHp; public Monster(String name, int hp, int attackDamage) { this.name name; this.maxHp hp; this.hp new AtomicInteger(hp); this.attackDamage attackDamage; } public boolean takeDamage(int dmg) { return hp.addAndGet(-dmg) 0; } public String getName() { return name; } public int getMaxHp() { return maxHp; } }攻击命令的处理逻辑集中在handler里private static void handleAttack(Player p, String targetName) { Room room p.getRoom(); Monster target room.findMonster(targetName); if (target null) { p.println(这里没有这个目标。); return; } int dmg ThreadLocalRandom.current().nextInt(5, 11); boolean killed target.takeDamage(dmg); room.broadcast(p.getName() 对 target.getName() 造成了 dmg 点伤害, p); if (killed) { room.removeMonster(target); room.broadcast(target.getName() 被击败了, p); } }逻辑说明takeDamage用addAndGet(-dmg)一次性完成扣血与判死比先get再set再判断少了获取值和写回值的间隙两个玩家同时打到最后一刀也只有一个能拿到0的true。死亡后从房间怪物列表移除下次look就看不到这只怪了不需要额外的尸体状态。参数说明nextInt(5, 11)的伤害区间是5到10点面对30血的怪物需要3到6刀战斗节奏刚好够演示又不拖沓。如果想让答辩节奏更快把怪物血量改成15伤害改成10到15两刀结束。注意怪物被移除后另一个玩家若拿着旧名字再发attackfindMonster返回null会得到“这里没有这个目标。”——这在演示时其实是个能讲两句的亮点怪物移除即时生效状态同步正确。5. 五个高频翻车点排查从黑屏卡死到幽灵角色5.1 第二个玩家一连接整个服务器就卡住不动现象第一个玩家玩得好好的第二个Telnet窗口连进去敲回车不仅自己没反应第一个玩家也断线。原因这是最经典的坑——用了单线程的while (true) { Socket s serverSocket.accept(); handlePlayer(s); }。第一个玩家的handlePlayer是一个阻塞的readLine循环第二个玩家accept进来后根本没人处理全部卡在读取上。这个错误我在课设辅导里见过至少五次。解决把handlePlayer丢进独立线程也就是第3章骨架里的new Thread(() - handlePlayer(player)).start()。accept主循环只负责接收连接永远不阻塞在某个玩家的IO上。检验方式连两个Telnet窗口两个窗口都能输入命令且响应互不阻塞这个坑就算填了。5.2 一个玩家退出全班掉线现象某个玩家输入quit或直接关掉终端服务器控制台刷出红色的IOException堆栈然后其他玩家全部断线。原因PrintWriter写入一个已关闭的Socket时会抛异常异常如果在某个玩家的线程里没被捕获直接传到handlePlayer的catch块之外线程栈炸掉的同时如果异常发生在广播逻辑里第3章的broadcast方法整个服务器线程也可能被带崩。更隐蔽的是write到坏连接时抛出的SocketException: Connection reset它不像普通的IOException那样好发现。解决给所有写入操作包上统一的安全输出方法。Player类里维护一个AtomicBoolean connectedprintln时先判断标志位再写写失败就把标志位置false并抛出受检的退出信号广播方法遇断连玩家跳过即可。核心是输出异常永远不能往上抛它只说明一个人掉线了不代表服务器该停。5.3 两个玩家同时攻击一只怪怪被打出负血还活着现象两个终端对着同一只wolf轮流attack打了七八刀怪物还没死血量像是无限的。原因Monster的hp如果是普通int两个线程同时执行hp hp - dmg后写覆盖先写扣了30点的血只实际生效15点更糟的是判断死亡的逻辑读到旧值血量变成-5却返回false怪物死活不消失。解决hp换成AtomicInteger用addAndGet(-dmg)一次原子完成扣血死亡判据改为返回值 0。修复之后并发攻击的安全由CAS保证不需要额外加锁。这个修复同时是个答辩加分点主动说出“这里用了CAS无锁同步”比等老师问要自然得多。5.4 聊天消息和系统消息互相穿插一行被截断现象一个玩家正在say另一个玩家触发遇敌两条提示串在一起比如“玩家A对 wolf 造 一只野狼挡住了去路”读也读不通。原因多个线程同时向同一个PrintWriter写入PrintWriter内部的锁没法把两条消息组成一个完整的“行原子”字符在底层Socket缓冲区里交错。解决给每个Player的println方法加synchronized锁住当前玩家的输出流保证一条消息从第一个字符到换行符之间不会插入其他内容。注意锁的对象必须是同一个Player实例不能锁PrintWriter实例本身因为PrintWriter可能被替换。动手验证双开两个窗口A连续sayB连续触发系统提示观察屏幕上消息是否整进整出。5.5 玩家掉线后重连旧角色还在地图上站岗现象玩家断开连接后重新进游戏发现新角色创建成功但旧角色的名字还在线用旧名字无法注册新名字又看到“两个自己”同时在房间列表里。原因掉线时finally { logout(player); }里的清理没有执行要么catch块把IOException吞掉后提前return了要么logout里移除房间引用的顺序出错房间的players列表还留着旧对象。解决单测这个场景不需要写代码手动验证就够——连上第一个窗口注册角色X直接关窗口再开新窗口用角色X登录。如果提示“该昵称已在线”说明清理路径没跑通。顺手把Room.removePlayer放在onlinePlayers.remove之前让查询房间时永远看不到已经退出的角色这个坑填完。6. 从能跑通到拿高分三个加分项与一场答辩演示自检6.1 用 Telnet 写一段十步验收话术拿高分首先要让老师三分钟内看懂你在演示什么。我习惯把演示顺序固定成一段“剧本”启动服务器 → 开第一个窗口连接 → 输入name注册 → look看当前房间 → move north进森林 → 遇敌提示出现 → attack wolf反复攻击 → 击败怪物 → 开第二个窗口连接 → say一句全体可见的话 → 第一个窗口quit退出。这段剧本跑完网络层、并发层、地图与战斗四大模块全都覆盖到了而且每一步的反馈都是明文提示老师不需要猜。6.2 三个不值得跳过的加分项第一是存档让World支持序列化写入磁盘服务器启动时尝试读取存档文件课设文档里写“存档功能已实现”比只写“支持多人在线”多一个记忆点。第二是日志每次命令在控制台输出一行记录[14:23:01] playerA moved to forest答辩时翻出日志说“这是数据流转的痕迹”比空口讲几十行代码管用。第三是并发标记在在线池里维护一个真实并发数演示时用它跟老师解释“两个窗口其实代表了两个线程”这一个细节就值两分。6.3 答辩最可能被追问的三个问题老师手里拿着课设代码多半会盯住Three地方一个玩家掉线服务器会怎样、两个玩家抢同一只怪会怎样、在线池的Map并发修改会怎样。这三问恰好是第5章修复的三个坑。准备答案时直接指向代码位置掉线看finally块抢怪看AtomicInteger在线池看ConcurrentHashMap。答完再加一句“这个问题我当时实现时遇过现在的写法就是当时踩坑改出来的”比背概念可信得多。我做这类课设最大的教训是把“能跑”当成“能演示”来验收。交之前我总觉得代码没问题结果答辩现场一开两个窗口就翻车连着踩了前文第一个坑。后来每写完一个函数都顺手用Telnet连一次双击一次CtrlC模拟断线把自己当最笨的用户来折磨程序。课设代码写出来是给老师点开运行的不是给自己欣赏的能扛住现场折腾的代码才是好代码。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑