ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java课程设计实战:从Socket多线程到文本存档的MUD多人在线游戏实现

Java课程设计实战:从Socket多线程到文本存档的MUD多人在线游戏实现 简介这是一份吉林大学软件学院Java课程设计项目内容为MUD多人在线游戏的简单模拟。资源面向正在完成课程设计的学生也适合对经典文本游戏感兴趣的开发者用于学习Java网络编程、多线程处理、控制台交互等核心技能。压缩包共有三十二个文件包含十个Java源文件、十六个编译后的class文件以及工程配置和Eclipse设置文件整体大小约五十三KB结构精简导入开发环境后即可查看运行逻辑。已有二百零四人学习下载具备一定参考热度。代码覆盖了简易MUD游戏的关键模块如场景切换、玩家输入解析、多人在线指令交互等可作为课程设计报告的核心示例也能在原有框架上继续扩展战斗、背包等玩法适合入门级Java游戏开发者快速上手和二次修改。1. 这个MUD“简单模拟”课设难的不是游戏而是并发期末周让Java课程设计“MUD多人在线游戏简单模拟”这题一照很多人才发现标题里的“简单模拟”四个字有多骗人没有图形界面没有美术资源连操作都靠逐行敲命令可“多人在线”四个字又把普通单机作业的难度抬了一档。吉林大学软件学院这份java课程设计学习价值基本不在文字冒险玩法而在对象模型怎么建、Socket线程怎么协调、存档怎么持久化。适合拿它练手的是那种想搞懂并发与网络基础又暂时不想去啃游戏服务器框架的Java学习者。这篇笔记按“对象模型→网络引擎→存档计分→避坑验证”顺序用纯Java原生技术栈给出一条能照做的落地路径。2. 把MUD拆成java对象模型Room、Item、Mob与Player怎么分配职责MUD全称Multi-User Dungeon多用户在同一个文字世界里探索。它的玩法看起来是一串命令本质却是“状态”与“状态迁移”输入look取得当前房间描述输入go north把玩家位置从一个Room实例迁到另一个Room实例输入attack修改生物血量。所以类设计得越贴近状态本身后面的命令分发、广播、存档都会越顺手。2.1 为什么先定对象模型而不是先写控制台界面命令行程序的“界面”只是System.out加BufferedReader真正决定这份java课程设计成败的是数据怎么组织。我见过不少同学一上来就写while循环读命令把房间、出口、怪物状态全塞在主函数里最后跑起来像一个只认识几条指令的玩具加一个新出口要改五处代码。项目虽小答辩老师翻代码时第一眼看的却是类图和职责划分。我把对象分成四类Room世界里的空间单元持有名称、描述、出口、物品列表。Item可以拾取、丢弃、使用的物品。Player登录后的玩家多出网络连接、位置、背包状态。Mob可战斗的NPC和Player共享血量、攻击、存活行为。这样分完地图加载、玩家登录、战斗、存档各自都有明确归属。比如经验值和等级属于Player但物品掉率属于Mob写代码时按这条线走就不会在Player里堆出“战斗、交易、任务”无关字段。2.2 玩家与NPC的通用行为接口Actor层挡住重复逻辑玩家和怪物都有血量、攻击力、能否存活把这一层抽成接口后续战斗逻辑只依赖接口而不是具体实现类public interface Actor { String getName(); int getHp(); int getMaxHp(); int getAttack(); void takeDamage(int damage); boolean isAlive(); }Player和Mob分别实现它。战斗方法就可以写成下面的样子对Mob和Player一视同仁public static void fight(Actor attacker, Actor defender) { defender.takeDamage(attacker.getAttack()); if (!defender.isAlive()) { System.out.println(defender.getName() 倒下了。); } else { attacker.takeDamage(defender.getAttack()); } }这里为什么用接口而不是让Player去继承一个生物基类因为Player有Socket连接、登录态、存档等独有字段Mob没有再嵌套一层会留下一堆空实现答辩时一问“父类哪些字段真的生效”场面很难看。接口在这里保证的是一种“能打架”的能力契约不绑定实现细节也天然符合面向对象编程java里“面向接口编程”的惯用做法。2.3 地图从文本进内存一行一个房间的配置把世界装进去写死地图是最土的方式。我一般把地图放到resources/map.txt每行一个房间格式自定roomStart|中央广场|一座广场东西南北都有路。|northroomA, eastroomB roomA|北街|一家铁匠铺门口挂着盾牌。|southroomStart用竖线分隔而不是逗号因为描述文本里可能出现逗号出口用“方向房间id”的键值对。加载逻辑public class World { private final MapString, Room rooms new LinkedHashMap(); public void loadFromFile(String path) throws IOException { try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(path), UTF-8))) { String line; while ((line reader.readLine()) ! null) { if (line.isBlank() || line.startsWith(#)) { continue; } String[] parts line.split(\\|); if (parts.length 3) { System.err.println(跳过非法行: line); continue; } Room room new Room(parts[0].trim(), parts[1].trim(), parts[2].trim()); if (parts.length 3) { for (String exitDef : parts[3].split(,)) { String[] kv exitDef.split(); if (kv.length 2) { room.setExit(Direction.valueOf(kv[0].trim().toUpperCase()), kv[1].trim()); } } } rooms.put(room.getId(), room); } } } public Room get(String roomId) { Room room rooms.get(roomId); if (room null) { throw new IllegalStateException(房间不存在: roomId); } return room; } }这段代码有几个参数值得说明读取时跳过空行和#注释地图里临时加注释或禁用房间都很方便Direction.valueOf需要传大写枚举名所以解析时先toUpperCase再转换。加载完成后rooms表里保存全量房间对象玩家进入时只要通过主键ID从World里取Room就不会出现“两个同名但不同对象”的错位。后面存档恢复也会复用这一套映射逻辑。2.4 为什么不推荐用二维数组做地图有同学会问MUD地图不就是一个网格吗用int[x][y]存房间不好吗不推荐的理由有三点。第一MUD地图经常有“直线距离很近但实际要绕一大圈”的房间二维数组为了表达这种拓扑要么留大量空位要么把路径画得很别扭。第二二维数组的“出口”概念天然绑定东南西北四个方向想做“上楼、下楼、跳进井里”这类纵向转移就得另开一套结构。第三存档时二维数组要连带保存行列尺寸而MapString, Room只需要把房间ID导出来和地图文件天然一致。这门课设的评分核心是“状态关系清晰”我用文本加ID这种更接近图论模型的方案远好过在一张方格纸上硬搓。3. 用Socket与多线程撑起多人在线服务端从连接到广播的完整链路3.1 单机模拟与多人在线的分水岭网络层与共享状态单机版MUD的服务器和玩家在同一个进程里Scanner读命令直接调对象方法多人在线版多出两件事任何玩家的命令都要经过网络传输多人同时操作同一片世界状态必须处理竞争。所谓“多人在线简单模拟”验收通常只看三个点两个及以上客户端能同时连接同房间玩家能互相看到说话一个玩家移动或战斗时世界状态不会把别人带飞。最稳的模型是服务端一个ServerSocket等待连接每来一个客户端就交给一个线程循环处理。课程设计用netty反而容易被追问底层原理我用java.net原生Socket简单、线程模型一目了然。3.2 服务端骨架连接、线程池与每客户端一个读写循环public class GameServer { private final ServerSocket server; private final ExecutorService pool Executors.newCachedThreadPool(); private final World world new World(); private final GameEngine engine; public GameServer(int port) throws IOException { server new ServerSocket(port); world.loadFromFile(resources/map.txt); engine new GameEngine(world); } public void start() { System.out.println(MUD server start, port server.getLocalPort()); while (true) { try { Socket socket server.accept(); pool.submit(() - handleClient(socket)); } catch (IOException e) { System.err.println(accept失败: e.getMessage()); } } } private void handleClient(Socket socket) { try (socket; BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), UTF-8), true)) { out.println(欢迎来到MUD世界输入help查看指令。); String line; while ((line in.readLine()) ! null) { String result engine.execute(line.trim(), socket); out.println(result); } } catch (IOException e) { System.out.println(客户端断开: socket.getRemoteSocketAddress()); } finally { engine.offline(socket); } } }这里把协议定成“一行一条命令”用readLine()按换行读命令用PrintWriter的println返回结果。这个设计天然规避了TCP粘包半包问题服务端不需要为拆包写状态机。线程池用newCachedThreadPool连接量不大时来一个接一个空闲线程60秒自动回收比手动new Thread省心。流的编码统一UTF-8整条读写链路用到两个地方InputStreamReader指定UTF-8OutputStreamWriter指定UTF-8两端一致中文就不会乱码。3.3 命令引擎与全局锁把线程安全问题锁在最外层多个客户端并发时多个线程同时改玩家位置、房间物品、怪物血量。这份课设不必把锁粒度做得很细最实用的是整条命令处理加一把全局锁。只要命令执行不包含网络I/O等待全局锁性能完全够用。public class GameEngine { private final World world; private final MapSocket, Player players new HashMap(); private final Lock lock new ReentrantLock(); public GameEngine(World world) { this.world world; } public String execute(String cmdLine, Socket socket) { String[] tokens cmdLine.split(\\s); if (tokens.length 0) { return 请输入命令。; } lock.lock(); try { Player player players.get(socket); if (player null) { if (login.equalsIgnoreCase(tokens[0])) { return login(socket, tokens[1]); } return 请先登录。格式: login 名字; } switch (tokens[0].toLowerCase()) { case look: return look(player); case go: return go(player, tokens[1].toUpperCase()); case say: return say(player, String.join( , Arrays.copyOfRange(tokens, 1, tokens.length))); case help: return helpText(); default: return 未知命令。; } } finally { lock.unlock(); } } }为什么锁要包在switch外面而不是每个case里单独加一次命令可能涉及多个对象的事务。比如go要先校验出口、再改玩家位置、再更新房间人数say要往房间聊天记录里追加文本。分开加锁会出现一个线程读到另一个线程“刚移动一半”的中间状态。一把全局锁把这些操作串行化正确性第一。Player以socket对象为key比用名字当key稳因为同名登录会互相踢下线这个设计在断线重连时也容易清理。3.4 同房间广播让“多连接”变成真正的“多人在线”广播是区分“多连接”和“多人在线”的分界线。say命令除了把消息返回给发起者还要发给同房间其他所有玩家。核心逻辑先维护一张“房间ID→该房间在线Socket列表”的映射广播时遍历private void broadcastToRoom(String roomId, String message, Socket except) { ListSocket roomSockets roomOccupants.get(roomId); if (roomSockets null) { return; } for (Socket target : roomSockets) { if (target.equals(except)) { continue; } try { PrintWriter out new PrintWriter( new OutputStreamWriter(target.getOutputStream(), UTF-8), true); out.println(message); } catch (IOException e) { System.err.println(广播失败客户端可能已断开: e.getMessage()); } } }每次广播都新建PrintWriter演示规模没问题但更好的做法是连接建立时就把每个socket对应的PrintWriter存进MapSocket, PrintWriter广播直接查表顺带把输出流复用起来。注意遍历roomOccupants时另一个线程可能正在把玩家移出房间所以广播必须放在全局锁内调用或者先对列表做一次快照副本。多人聊天刷起来后最怕的就是“一边遍历一边改”这个坑在第5章还会再讲。4. 让课设拿得出手存档固化、命令注册表与答辩加分策略单机跑通只是开始真正让java课程设计案例源码看起来像软件工程的是存档能不能重启恢复、加一条新命令要多改几个文件。这一章专门讲这三件事。4.1 文本存档 vs 对象序列化为什么课程设计更适合前者最容易想到的存档方式是ObjectOutputStream把Player对象直接写进文件几行代码完事。但它有两个坑第一Player字段一变旧存档反序列化直接抛InvalidClassException答辩前几天加一个“成就点数”字段所有旧档作废第二Player里有Socket引用Socket不可序列化要先加transient再做一层处理复杂程度立刻上来了。文本存档没有这些毛病字段用“键值”逐行写人眼能读答辩时可以直接打开文件说“这是玩家的血量、位置”排查问题也比二进制方便。课设阶段我统一用文本方案第三方依赖都不需要。4.2 一个不依赖第三方库的存档实现与三种保存时机public class PlayerStore { private final Path saveDir; public PlayerStore(String dir) { this.saveDir Paths.get(dir); } public void save(Player p) throws IOException { Files.createDirectories(saveDir); Path file saveDir.resolve(p.getName() .sav); StringBuilder sb new StringBuilder(); sb.append(name).append(p.getName()).append(\n); sb.append(hp).append(p.getHp()).append(\n); sb.append(room).append(p.getCurrentRoomId()).append(\n); sb.append(attack).append(p.getAttack()).append(\n); Files.write(file, sb.toString().getBytes(StandardCharsets.UTF_8)); } public Player load(String name, World world) { Path file saveDir.resolve(name .sav); if (!Files.exists(file)) { return null; } Player p new Player(name); for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { if (line.startsWith(hp)) { p.setHp(Integer.parseInt(line.substring(3))); } else if (line.startsWith(room)) { p.setCurrentRoomId(line.substring(5).trim()); } } p.setCurrentRoom(world.get(p.getCurrentRoomId())); return p; } }这段代码里最不该省掉的一行是最后一句p.setCurrentRoom(world.get(...))。房间ID是字符串不重新映射到World里的Room对象存档恢复后go命令全部失效。保存时机我一般选三个玩家手动输入save命令玩家下线时自动保存以及每60秒用ScheduledExecutorService定时自动存一次。定时存可以防止答辩演示到一半断电丢进度属于性价比很高的兜底。4.3 命令注册表加新玩法不再改动主流程命令少的时候switch没问题命令一大look、go、take、drop、attack、help、inventory、save、quit主流程会越写越臃肿。更稳的做法是把每条命令封装成Command接口注册到一个Map里这是很经典的开闭原则实践。public interface Command { String execute(String[] args, Player player, GameContext ctx); } public class GoCommand implements Command { Override public String execute(String[] args, Player player, GameContext ctx) { if (args.length 2) { return 用法: go 方向; } return ctx.getEngine().movePlayer(player, args[1].toUpperCase()); } }注册表本身很简单public class CommandRegistry { private final MapString, Command commands new HashMap(); public void register(String name, Command cmd) { commands.put(name, cmd); } public Command get(String name) { return commands.get(name); } }GameEngine初始化时一次性注册所有命令分发时从注册表取值private void registerCommands() { registry.register(go, new GoCommand()); registry.register(look, new LookCommand()); registry.register(say, new SayCommand()); registry.register(take, new TakeCommand()); registry.register(save, new SaveCommand()); }以后加“attack”只需要新增AttackCommand并注册主流程代码不用碰。答辩时如果说“这里我用的是策略模式新命令开一个类就接入”比“我在switch后面又加了一个case”要稳得多。4.4 答辩时会被追着问的三个点并发、持久化与扩展性有了上面的实现答辩被问到细节时不用慌。问并发就解释全局锁保住世界状态一致性并主动说出不拆锁的理由是“课程演示规模下正确性优先”问持久化打开存档文件展示键值对并说明自动保存三个时机问扩展性现场演示在注册表里加一条新命令主流程零改动。这三个问题其实是把课设代码从头串了一遍答得越具体越能证明代码是你自己写明白的。5. 避坑与排查从中文乱码到僵尸线程的五处高频翻车点以下五条都是跑MUD这类多线程网络程序时容易踩进去的坑按“现象→原因→解决”写每一条都可能决定答辩现场能不能顺利跑通。5.1 中文乱码控制台和Socket各写各的编码现象服务端启动日志中文正常玩家客户端发来中文后却收到一串乱码返回内容也乱码。原因最常见的是客户端用Scanner和System.inWindows下默认GBK服务端强制UTF-8读写两边编码不一致字节序列被decode错了。还有一种情况是PrintWriter构造时没指定字符集默认ISO-8859-1根本不支持中文。解决服务端所有InputStreamReader、OutputStreamWriter显式指定UTF-8客户端同样处理。如果控制台输出还是乱码在启动命令里加-Dfile.encodingUTF-8。每次新建流都要写成“UTF-8”不要偷懒省略省一行字乱码追半小时。5.2 命令发出去服务端毫无反应多半是忘了flush现象客户端socket.getOutputStream().write(look\n.getBytes())之后服务端一直卡在readLine()什么返回都没有。原因输出流有缓冲write只是把数据写进缓冲区还没真正发到网络。服务端readLine在等换行符但网络层面根本没收到数据。解决write之后立刻调用flush()或者直接用PrintWriter(out, true)开启自动刷新。客户端也一样println后不加flush也可能让服务端干等。这个坑之所以常见是因为很多人以前写文件没养成flush习惯换到网络编程自然就漏了。5.3 广播时抛ConcurrentModificationException现象两个玩家同时在房间聊天服务端日志突然抛ConcurrentModificationException之后某个客户端线程退出。原因广播代码遍历roomOccupants这个Map与此同时另一个线程正把玩家移入或移出房间。Java的fail-fast迭代器在遍历期间检测到集合被修改立刻抛异常。解决把广播和对房间列表的修改都放进全局锁内这是课设阶段最省事的做法。如果为了效率想缩小锁范围退一步用快照副本遍历前先new ArrayList(roomList)但是消息顺序有可能会乱。课程演示场景优先选前者。5.4 玩家断开后线程变僵尸越攒越多现象关掉一个客户端服务端日志停在原地线程池线程数却不降新连接处理越来越慢。原因网络断开时readLine()返回null而不是抛异常如果代码只写异常打印没处理nullwhile循环不会退出线程被长时间占用。缓存线程池里这种僵尸线程积累到一定数量新连接就开始排队。解决读循环写成while ((line in.readLine()) ! null) { ... }readLine返回null就自然退出循环进finally做offline清理并关闭socket。捕获IOException时也先退出循环不要只打印一行继续跑。5.5 存档加载后go命令失效Room引用断了现象重启服务端玩家登录后能看到存档里的位置描述但输入go north提示“没有这个方向的出口”。原因存档只存了房间ID字符串加载时把它解析成了currentRoomId却没有从World地图里取出对应的Room对象。玩家的currentRoom字段幸存下来是ID而不是Roomgo命令查exits自然查不到。解决load结束时执行p.setCurrentRoom(world.get(p.getCurrentRoomId()))把ID重新映射成Room对象。这里World.get如果房间不存在会抛异常反而能在日志里快速发现房间被改过名或删掉的配置错误。提示上面五条里中文乱码和flush问题最隐蔽因为它们都不报错只是行为不正常。建议在写完服务端骨架后第一时间跑一个最小客户端验证而不是等到所有功能做完再联调。6. 交作业前的自测清单一条让演示不翻车的验证路径答辩演示前我习惯按顺序过一遍自测表。每过一行就等于给之前的并发设计做了一次回归检查。检查项操作预期结果服务端启动运行GameServer看控制台无异常端口正常监听单客户端连接运行ConsoleClient连本机端口收到欢迎语登录输入login alice返回登录成功和房间描述双客户端同房再开客户端login bob双方都能看到对方进入同房sayalice发say hellobob能看到helloalice不重复收移动与重置alice go northbob原地等两人停在不同房间互不干扰存档重启alice输入save重启服务端再登录血量、房间、攻击力恢复断线重连直接关alice窗口bob继续玩服务端无异常日志线程数回降完整的双客户端演示需要一个小客户端下面是极简参考实现public class ConsoleClient { public static void main(String[] args) throws IOException { try (Socket socket new Socket(127.0.0.1, 9000); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); BufferedReader console new BufferedReader(new InputStreamReader(System.in, UTF-8)); PrintWriter out new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), UTF-8), true)) { new Thread(() - { try { String msg; while ((msg in.readLine()) ! null) { System.out.println(msg); } } catch (IOException e) { System.out.println(连接断开: e.getMessage()); } }).start(); String line; while ((line console.readLine()) ! null) { out.println(line); } } } }这个客户端的写法和服务端是对称的读线程逐行打印服务端消息主线程读控制台命令并发送。它本身也说明了一条规则只要协议是一行一条命令两边都按行读写就行不要试图一次读完所有字节否则会卡在缓冲区等一个不存在的“结束”。MUD这类课设的正反馈来得很快你写的每一把锁、每一条广播都能在另一个终端里立刻看到效果。我第一次跑通双人聊天时已经熬到凌晨屏幕上跳出几行“[alice]: hello”前面在并发排查上耗的耐心一下子有了回报。从那以后我给自己定了个规矩每完成一个模块先跑一遍上面的自测表再继续写这套习惯一直用到现在。希望帮到你等你也把这份“简单模拟”跑通会发现“多人在线游戏”背后那些原理其实一点都不简单。本文还有配套的精品资源点击获取
返回列表