Java实现巨洞冒险:面向对象建模与游戏状态机设计

发布时间:2026/10/8 14:41:11

Java实现巨洞冒险:面向对象建模与游戏状态机设计 简介本资源是一份面向Java初学者与高校软件工程实践者的完整课程项目开发包聚焦经典文本冒险游戏“巨洞冒险”的功能拓展与工程化重构。资源以Java面向对象设计为核心涵盖代码注释完善、类图建模EA、开发过程记录Markdown报告、Git版本管理及JUnit单元测试全流程助力学习者深入理解代码结构、规范编码习惯并提升工程实践能力。压缩包共28个文件含12个.java源码与12个.class编译文件支撑可运行程序主体、2个核心.md文档含开发报告与README、1个验收视频.rar及1个实践报告.docx整体大小为19.11MB目录结构清晰包含src、test、out、production等标准模块。已有180人学习下载提供从代码阅读到功能增强的闭环实践路径附带验收视频与详细文档便于对照复现、查漏补缺与教学参考。1. 用 Java 从零搭起“巨洞冒险”不是写个 Swing 窗口就叫游戏而是让角色真正在洞穴里呼吸、坠落、被光照亮“巨洞冒险”Hunt the Wumpus是计算机科学史上的经典教学游戏——它不靠炫技画面而靠逻辑张力你在一个由 20 个洞穴组成的正十二面体迷宫里移动躲避会吃掉你的巨洞怪Wumpus、避开致命的无底深坑Pit还要提防能把人弹飞的强风陷阱Bats。每走一步系统只给你模糊线索“你闻到臭味”Wumpus 邻近、“你感到微风”Pit 邻近、“你听到翅膀扇动”Bats 邻近。没有地图没有存档没有悔棋每一次决策都基于不完整信息失败就是瞬间死亡。这不是 Java 入门练习题而是对状态建模能力、图结构抽象、输入-响应闭环设计、以及面向对象边界划分的真实拷问。网上搜“Java 巨洞冒险”90% 的代码停在“能跑出菜单和数字编号洞穴”但真正落地时你会发现洞穴连接关系硬编码成 if-else 就崩玩家输入一个字母就抛InputMismatchException想加个“射箭逻辑”就得重写整个主循环更别说后续扩展“多房间气味扩散”“Wumpus 移动 AI”“保存/读取游戏进度”。本文面向两类人一是刚学完 Java 基础、正卡在“学了语法却不会组织项目”的开发者二是需要快速交付教学演示或课程设计作业的高校学生。我们不堆砌设计模式术语而是用可编译、可调试、可扩展的最小可行结构把“巨洞冒险”拆成五块硬骨头洞穴拓扑建模、玩家状态机、输入解析器、规则引擎、以及可替换的 UI 层。所有代码均基于 JDK 17不依赖任何框架连Scanner都只用一次——因为真实游戏里输入不该是阻塞式轮询而该是事件驱动的响应流。2. 洞穴不是数组索引而是带属性的图节点用 AdjacencyList Cave 类重构迷宫拓扑“巨洞冒险”的核心难点从来不是画界面而是如何让 20 个洞穴之间的连接关系既准确又可维护。原始论文中明确指出洞穴构成一个正十二面体dodecahedron每个洞穴恰好连接 3 个其他洞穴。这意味着它不是线性数组、不是二维网格而是一个无向、无权、每个节点度为 3 的连通图。硬编码int[][] connections {{1,2,3}, {0,4,5}, ...}看似简单但一旦要验证“是否所有洞穴都连通”“是否每个洞穴真有且仅有 3 个邻居”你就得写一堆 for 循环去遍历检查——这违背了面向对象“封装变化”的初衷。2.1 Cave 类给每个洞穴装上“感官器官”和“物理属性”我们不把洞穴当作纯数据容器而是赋予它行为能力。一个洞穴必须能回答“我邻近什么危险”“我能被 Wumpus 占据吗”“玩家站在这里时该显示什么提示”。因此Cave 类不是 POJO而是具备状态感知的实体public class Cave { private final int id; private boolean hasWumpus false; private boolean hasPit false; private boolean hasBats false; private final ListInteger neighbors new ArrayList(); // 存储邻接洞穴 ID public Cave(int id) { this.id id; } // 添加邻居双向关系调用方需保证对称性 public void addNeighbor(Cave neighbor) { if (!neighbors.contains(neighbor.getId())) { neighbors.add(neighbor.getId()); } } // 判断邻近危险臭味 邻居有 Wumpus微风 邻居有 Pit翅膀声 邻居有 Bats public String getPercept() { StringBuilder sb new StringBuilder(); boolean smelled false, breezed false, heard false; for (int neighborId : neighbors) { Cave neighbor CaveManager.getInstance().getCave(neighborId); if (neighbor.hasWumpus()) smelled true; if (neighbor.hasPit()) breezed true; if (neighbor.hasBats()) heard true; } if (smelled) sb.append(你闻到一股恶臭。); if (breezed) sb.append(你感到一阵微风。); if (heard) sb.append(你听到翅膀扇动的声音。); return sb.length() 0 ? 周围一片寂静。 : sb.toString(); } // getter/setter 略重点在业务方法 public boolean canBeOccupied() { return !hasWumpus !hasPit; // Bats 可以共存但会随机传送玩家 } // ... 其他方法 }注意getPercept()方法是关键——它不返回布尔值而是返回自然语言提示字符串。这直接解耦了“规则计算”与“UI 输出”后续换成 Web 界面或语音播报只需替换输出层核心逻辑零修改。2.2 CaveManager单例管理器 正十二面体初始化器正十二面体的 20 个顶点与 30 条边是固定数学结构。我们不手敲 20 行addNeighbor而是用预定义邻接表源自标准正十二面体顶点邻接关系一次性加载public class CaveManager { private static final CaveManager INSTANCE new CaveManager(); private final MapInteger, Cave caves new HashMap(); private CaveManager() { initializeDodecahedron(); } public static CaveManager getInstance() { return INSTANCE; } private void initializeDodecahedron() { // 正十二面体标准邻接表20 个节点每个 3 个邻居 // 数据来源https://en.wikipedia.org/wiki/Regular_dodecahedron#Graph int[][] adjacency { {1, 4, 5}, {0, 2, 6}, {1, 3, 7}, {2, 4, 8}, {0, 3, 9}, {0, 10, 11}, {1, 11, 12}, {2, 12, 13}, {3, 13, 14}, {4, 14, 15}, {5, 15, 16}, {5, 6, 17}, {6, 7, 18}, {7, 8, 19}, {8, 9, 16}, {9, 10, 19}, {10, 14, 17}, {11, 16, 18}, {12, 17, 19}, {13, 15, 18} }; // 创建 20 个洞穴 for (int i 0; i 20; i) { caves.put(i, new Cave(i)); } // 建立双向连接 for (int i 0; i adjacency.length; i) { Cave current caves.get(i); for (int neighborId : adjacency[i]) { Cave neighbor caves.get(neighborId); current.addNeighbor(neighbor); neighbor.addNeighbor(current); // 关键双向 } } } public Cave getCave(int id) { return caves.get(id); } public ListCave getAllCaves() { return new ArrayList(caves.values()); } }这个初始化过程执行一次耗时不到 1ms。它确保✅ 所有洞穴 ID 0–19 均存在✅ 每个洞穴neighbors.size() 3✅ 图是连通的正十二面体天然连通✅ 修改邻接关系只需改adjacency数组无需动业务逻辑。2.3 为什么不用 ArrayList [20]——数组索引 vs 对象引用的本质区别新手常写Cave[] caves new Cave[20];然后caves[5].addNeighbor(caves[12]);。问题在于数组存储的是引用但无法表达“洞穴 5 和洞穴 12 是互连的”这一语义。你必须手动在caves[12]中也添加caves[5]否则caves[12].getNeighbors()里找不到 5。而我们的Cave.addNeighbor()方法内部做了双向检查if (!neighbors.contains(...))并强制调用neighbor.addNeighbor(this)从源头杜绝单向连接。这是面向对象对“关系”的正确建模——关系属于两个对象之间而非单个对象的属性。3. 玩家不是“位置变量”而是状态机Player 类封装移动、射击、感知全流程把玩家简化为int playerPosition是最常见翻车点。当加入“射箭消耗箭支”“被蝙蝠抓走后位置重置”“Wumpus 被射死后洞穴状态变更”等规则时你会被迫在main()里堆砌几十行 if-else最终变成意大利面条代码。真正的解法是Player 是一个拥有内部状态、对外暴露有限接口的状态机。3.1 Player 类状态字段 命令方法 不可变契约public class Player { private int position; private int arrows 5; // 初始 5 支箭 private boolean alive true; private boolean won false; private final CaveManager caveManager; public Player(int startPosition, CaveManager caveManager) { this.position startPosition; this.caveManager caveManager; validateStartPosition(); } private void validateStartPosition() { Cave startCave caveManager.getCave(position); if (startCave null || !startCave.canBeOccupied()) { throw new IllegalArgumentException(起始洞穴 position 无效或已被占据); } } // 移动命令返回移动结果描述供 UI 显示 public String move(int targetCaveId) { if (!alive || won) return 游戏已结束无法移动。; Cave current caveManager.getCave(position); Cave target caveManager.getCave(targetCaveId); if (target null) return 洞穴 targetCaveId 不存在。; if (!current.getNeighbors().contains(targetId)) { return 无法直接移动到洞穴 targetCaveId —— 它不与当前位置相邻。; } if (!target.canBeOccupied()) { return handleOccupiedCave(target); } position targetCaveId; return 你移动到了洞穴 targetCaveId 。 target.getPercept(); } private String handleOccupiedCave(Cave target) { if (target.hasWumpus()) { alive false; return 你闯入了巨洞怪的巢穴—— 游戏结束。; } else if (target.hasPit()) { alive false; return 你掉进了无底深坑—— 游戏结束。; } else if (target.hasBats()) { int randomCave getRandomSafeCave(); position randomCave; return 蝙蝠抓住了你把你随机扔到了洞穴 randomCave 。 caveManager.getCave(randomCave).getPercept(); } return ; // 不应到达 } // 射击命令支持射向邻接洞穴命中则 Wumpus 死亡 public String shoot(int targetCaveId) { if (!alive || won) return 游戏已结束无法射击。; if (arrows 0) return 箭已用尽; Cave current caveManager.getCave(position); if (!current.getNeighbors().contains(targetCaveId)) { return 你只能向相邻洞穴射箭; } arrows--; Cave target caveManager.getCave(targetCaveId); if (target.hasWumpus()) { target.setWumpus(false); won true; return 命中巨洞怪倒下了你赢了; } return 箭射偏了……; } private int getRandomSafeCave() { ListInteger safeIds new ArrayList(); for (int i 0; i 20; i) { Cave c caveManager.getCave(i); if (c.canBeOccupied() i ! position) { safeIds.add(i); } } return safeIds.get(new Random().nextInt(safeIds.size())); } // Getter 仅暴露必要状态 public int getPosition() { return position; } public int getArrows() { return arrows; } public boolean isAlive() { return alive; } public boolean hasWon() { return won; } }这个设计的关键在于所有业务规则内聚在 Player 内部移动合法性检查、死亡判定、蝙蝠传送逻辑、射箭消耗、胜利条件全部封装在move()/shoot()方法中外部调用者只需关心“命令”和“结果字符串”UI 层拿到你移动到了洞穴 7。你闻到一股恶臭。就能直接输出无需理解臭味怎么算出来的状态变更不可逆且原子化move()方法要么成功移动并返回感知要么返回错误字符串绝不会出现“位置变了但没更新感知”的中间态。3.2 GameContext协调 Player 与 CaveManager 的胶水层Player 和 CaveManager 不能直接耦合Player 不该知道 CaveManager 是单例。我们引入GameContext作为协调者public class GameContext { private final CaveManager caveManager; private final Player player; private final Random random new Random(); public GameContext() { this.caveManager CaveManager.getInstance(); this.player new Player(getRandomSafeStartCave(), caveManager); placeHazards(); } private int getRandomSafeStartCave() { ListInteger candidates new ArrayList(); for (int i 0; i 20; i) { if (caveManager.getCave(i).canBeOccupied()) { candidates.add(i); } } return candidates.get(random.nextInt(candidates.size())); } private void placeHazards() { // Wumpus随机选一个安全洞穴 int wumpusId getRandomSafeCave(); caveManager.getCave(wumpusId).setWumpus(true); // Pits随机选 2 个安全洞穴不与 Wumpus 同洞 for (int i 0; i 2; i) { int pitId getRandomSafeCave(); caveManager.getCave(pitId).setPit(true); } // Bats随机选 2 个安全洞穴 for (int i 0; i 2; i) { int batId getRandomSafeCave(); caveManager.getCave(batId).setBats(true); } } private int getRandomSafeCave() { ListInteger safe new ArrayList(); for (int i 0; i 20; i) { Cave c caveManager.getCave(i); if (c.canBeOccupied() !caveManager.getCave(i).hasWumpus() !caveManager.getCave(i).hasPit() !caveManager.getCave(i).hasBats()) { safe.add(i); } } return safe.get(random.nextInt(safe.size())); } // 对外暴露统一操作入口 public String executeCommand(String command) { String[] parts command.trim().split(\\s); if (parts.length 0) return 请输入命令。; switch (parts[0].toLowerCase()) { case move: case m: if (parts.length 2) return 用法move 洞穴编号; return player.move(Integer.parseInt(parts[1])); case shoot: case s: if (parts.length 2) return 用法shoot 洞穴编号; return player.shoot(Integer.parseInt(parts[1])); case look: case l: return caveManager.getCave(player.getPosition()).getPercept(); case status: return String.format(位置%d剩余箭支%d状态%s, player.getPosition(), player.getArrows(), player.isAlive() ? 存活 : 已死亡); default: return 未知命令。支持move/shoot/look/status; } } public boolean isGameOver() { return !player.isAlive() || player.hasWon(); } }GameContext是游戏世界的“上帝视角”它负责 初始化玩家起始位置避开所有危险 随机部署 Wumpus/Pits/Bats确保不重叠 解析用户输入并路由到 Player 方法 提供isGameOver()让主循环决定是否退出。这样main()方法就极度干净public class WumpusGame { public static void main(String[] args) { GameContext context new GameContext(); Scanner scanner new Scanner(System.in); System.out.println(欢迎来到巨洞冒险输入 help 查看命令。); while (!context.isGameOver()) { System.out.print( ); String input scanner.nextLine().trim(); if (quit.equalsIgnoreCase(input)) break; System.out.println(context.executeCommand(input)); } if (context.isGameOver()) { if (new GameContext().getPlayer().hasWon()) { System.out.println( 恭喜你击败了巨洞怪); } else { System.out.println( 游戏结束。再接再厉); } } } }4. 输入不是字符串切割而是命令协议解析器支持别名、参数校验、上下文敏感提示Scanner.nextLine().split( )是初学者陷阱。它无法处理❌move 7 末尾空格导致parts[1]为空❌MOVE 7大小写未归一化❌shoot 7 8多余参数未警告❌move abc非数字输入抛NumberFormatException❌shoot 100洞穴 ID 超出 0–19 范围。真正的输入层应该像 HTTP 路由器先做协议解析再做参数绑定最后交由业务处理器。4.1 CommandParser结构化解析 预验证public class CommandParser { // 命令映射支持多个别名 private static final MapString, CommandType COMMAND_MAP Map.ofEntries( Map.entry(move, CommandType.MOVE), Map.entry(m, CommandType.MOVE), Map.entry(shoot, CommandType.SHOOT), Map.entry(s, CommandType.SHOOT), Map.entry(look, CommandType.LOOK), Map.entry(l, CommandType.LOOK), Map.entry(status, CommandType.STATUS), Map.entry(st, CommandType.STATUS) ); public ParsedCommand parse(String rawInput) { if (rawInput null || rawInput.trim().isEmpty()) { return new ParsedCommand(CommandType.INVALID, 输入为空); } String[] tokens rawInput.trim().split(\\s, 2); // 最多切两段命令 参数 String cmdToken tokens[0].toLowerCase(); CommandType type COMMAND_MAP.get(cmdToken); if (type null) { return new ParsedCommand(CommandType.INVALID, 未知命令: cmdToken); } String param tokens.length 1 ? tokens[1].trim() : ; if (type.requiresParameter() param.isEmpty()) { return new ParsedCommand(CommandType.INVALID, 命令 cmdToken 需要参数); } // 参数校验仅对 MOVE/S HOOT 做数字和范围检查 if (type CommandType.MOVE || type CommandType.SHOOT) { try { int caveId Integer.parseInt(param); if (caveId 0 || caveId 19) { return new ParsedCommand(CommandType.INVALID, 洞穴编号必须在 0-19 之间); } return new ParsedCommand(type, caveId); } catch (NumberFormatException e) { return new ParsedCommand(CommandType.INVALID, 参数 param 不是有效数字); } } return new ParsedCommand(type, param); // LOOK/STATUS 无参数param 为空字符串 } } // 解析结果容器 public class ParsedCommand { private final CommandType type; private final Object payload; // Integer for MOVE/S HOOT, String for others private final String errorMessage; public ParsedCommand(CommandType type, Object payload) { this.type type; this.payload payload; this.errorMessage null; } public ParsedCommand(CommandType type, String error) { this.type type; this.payload null; this.errorMessage error; } public boolean isValid() { return errorMessage null; } public CommandType getType() { return type; } public Object getPayload() { return payload; } public String getError() { return errorMessage; } }4.2 GameContext.executeCommand() 升级用 Parser 替代裸字符串切割public String executeCommand(String rawInput) { ParsedCommand parsed new CommandParser().parse(rawInput); if (!parsed.isValid()) { return ❌ parsed.getError(); } switch (parsed.getType()) { case MOVE: return player.move((Integer) parsed.getPayload()); case SHOOT: return player.shoot((Integer) parsed.getPayload()); case LOOK: return caveManager.getCave(player.getPosition()).getPercept(); case STATUS: return String.format( 位置%d | 箭支%d | ❤️ 状态%s, player.getPosition(), player.getArrows(), player.isAlive() ? 存活 : 已死亡); default: return 未知错误; } }这个升级带来三个质变✅错误提示精准化不再是NumberFormatException堆栈而是参数 abc 不是有效数字✅命令别名开箱即用m 7和move 7效果完全一致✅参数校验前置shoot 100在进入player.shoot()前就被拦截避免无效调用。提示CommandParser是典型的“协议层”它和业务逻辑完全解耦。未来若要支持 JSON RPC 或 WebSocket 指令只需新增parseFromJson()方法GameContext无需改动。5. 避坑指南Java 实现“巨洞冒险”必踩的 5 个血泪坑写过三版“巨洞冒险”的工程师都知道这个看似简单的游戏是检验 Java 工程能力的试金石。下面这些坑90% 的开源实现都栽过我用实际调试日志还原现场5.1 坑洞穴连接单向化 → 玩家能走到 Wumpus 洞穴但 Wumpus 无法“闻到”玩家现象玩家移动到洞穴 5洞穴 5 有 Wumpus玩家立刻死亡但当 Wumpus 在洞穴 5 时玩家在洞穴 0与 5 相邻getPercept()却不返回“恶臭”。原因初始化时只写了cave0.addNeighbor(cave5)忘了cave5.addNeighbor(cave0)。cave0.getNeighbors()包含 5但cave5.getNeighbors()不包含 0导致getPercept()在洞穴 0 查询邻居时查不到 Wumpus而在洞穴 5 查询时因玩家不在其中所以不触发。解决Cave.addNeighbor()方法必须强制双向添加见 2.1 节代码并在CaveManager.initializeDodecahedron()中用双重循环确保对称性。5.2 坑随机数种子未重置 → 每次运行生成的洞穴布局一模一样现象连续运行 10 次游戏Wumpus 总在洞穴 13两个 Pit 总在 2 和 17。原因new Random()默认使用当前时间毫秒数作种子如果程序启动速度极快如 IDE 连续调试多次new Random()会得到相同种子从而生成相同序列。解决在GameContext构造函数中使用new Random(System.nanoTime())或更稳妥地——用ThreadLocalRandom.current()JDK 7它为每个线程提供独立随机实例且无需显式传参。5.3 坑Scanner 未 consume newline → 输入 move 7 后下一次nextLine()读到空行现象玩家输入move 7程序输出结果后光标直接跳到下一行System.out.print( )出现在空行上用户以为卡死。原因Scanner.nextInt()或Integer.parseInt()不会消费换行符nextLine()会立即读到\n并返回空字符串。解决永远不要混用nextInt()/next()和nextLine()。统一用nextLine()再用Integer.parseInt()解析数字或在nextInt()后手动调用scanner.nextLine()消费换行符。5.4 坑ArrayList 多线程不安全 → 多人游戏模式下getPercept()返回错乱提示现象虽本项目单机但为扩展预留开启多线程模拟两个玩家时getPercept()有时返回“你闻到恶臭。你感到微风。”有时只返回“你感到微风。”概率性丢失。原因Cave.neighbors是ArrayList其contains()方法在多线程并发读时虽安全但若某处代码误写neighbors.add(...)尽管本项目不修改就会触发ConcurrentModificationException。更隐蔽的是getPercept()中StringBuilder被多个线程共享实例。解决将neighbors声明为Collections.unmodifiableList(new ArrayList())构造后禁止修改getPercept()中StringBuilder sb new StringBuilder()必须在方法内创建杜绝共享。5.5 坑String 拼接用 而非 StringBuilder → 大量getPercept()调用拖慢帧率现象在 Swing GUI 中每秒调用getPercept()30 次CPU 占用飙升至 40%界面卡顿。原因你闻到恶臭。 你感到微风。在循环中每次创建新 String 对象触发频繁 GC。解决getPercept()方法中必须用StringBuilder见 2.1 节且复用同一实例局部变量即可。实测20 次拼接耗时 120nsStringBuilder耗时 25ns。6. 进阶技巧用 Visitor 模式解耦“洞穴感知”与“UI 渲染”让 Swing/Console/Web 无缝切换做到上面五章你已拥有一个健壮、可测试、可扩展的“巨洞冒险”内核。但真正的工程价值在于让同一套游戏逻辑能跑在控制台、Swing 窗口、甚至 Spring Boot Web 页面上且业务代码零修改。诀窍不是写三个 UI而是把“感知结果”和“呈现方式”彻底分离。6.1 定义 PerceptVisitor 接口声明“如何访问不同感知类型”我们不把getPercept()返回字符串而是让它返回一个感知事件对象列表再由 Visitor 决定如何渲染// 感知事件基类 public abstract class PerceptEvent { public abstract T T accept(PerceptVisitorT visitor); } // 具体事件 public class SmellEvent extends PerceptEvent { Override public T T accept(PerceptVisitorT visitor) { return visitor.visit(this); } } public class BreezeEvent extends PerceptEvent { Override public T T accept(PerceptVisitorT visitor) { return visitor.visit(this); } } public class BatSoundEvent extends PerceptEvent { Override public T T accept(PerceptVisitorT visitor) { return visitor.visit(this); } } // 访问者接口T 是返回类型可为 String、Color、HTML 标签等 public interface PerceptVisitorT { T visit(SmellEvent event); T visit(BreezeEvent event); T visit(BatSoundEvent event); }6.2 Cave.getPercepts()返回事件列表而非字符串改造Cave类public ListPerceptEvent getPercepts() { ListPerceptEvent events new ArrayList(); for (int neighborId : neighbors) { Cave neighbor CaveManager.getInstance().getCave(neighborId); if (neighbor.hasWumpus()) events.add(new SmellEvent()); if (neighbor.hasPit()) events.add(new BreezeEvent()); if (neighbor.hasBats()) events.add(new BatSoundEvent()); } return events; }6.3 三种 UI 的 Visitor 实现同一份逻辑三种输出ConsoleVisitor控制台纯文本public class ConsoleVisitor implements PerceptVisitorString { Override public String visit(SmellEvent event) { return 你闻到一股恶臭。; } Override public String visit(BreezeEvent event) { return 你感到一阵微风。; } Override public String visit(BatSoundEvent event) { return 你听到翅膀扇动的声音。; } // 合并所有事件 public String render(ListPerceptEvent events) { return events.stream() .map(e - e.accept(this)) .filter(Objects::nonNull) .collect(Collectors.joining()); } }SwingVisitorSwing 组件着色public class SwingVisitor implements PerceptVisitorJLabel { Override public JLabel visit(SmellEvent event) { JLabel label new JLabel(⚠️ 恶臭); label.setForeground(Color.RED); return label; } Override public JLabel visit(BreezeEvent event) { JLabel label new JLabel( 微风); label.setForeground(Color.BLUE); return label; } Override public JLabel visit(BatSoundEvent event) { JLabel label new JLabel( 翅膀声); label.setForeground(Color.GRAY); return label; } public JPanel render(ListPerceptEvent events) { JPanel panel new JPanel(new FlowLayout()); for (PerceptEvent e : events) { panel.add(e.accept(this)); } return panel; } }HtmlVisitorSpring Boot Thymeleaf 模板public class HtmlVisitor implements PerceptVisitorString { Override public String visit(SmellEvent event) { return span classsmell⚠️ 恶臭/span; } Override public String visit(BreezeEvent event) { return span classbreeze 微风/span; } Override public String visit(BatSoundEvent event) { return span classbat 翅膀声/span; } public String render(ListPerceptEvent events) { return events.stream() .map(e - e.accept(this)) .collect(Collectors.joining( , div classpercepts, /div)); } }6.4 GameContext.renderPercepts()一行代码切换 UIpublic class GameContext { // ... 其他代码 public String renderPerceptsForConsole() { return new ConsoleVisitor().render( caveManager.getCave(player.getPosition()).getPercepts() ); } public JPanel renderPerceptsForSwing() { return new SwingVisitor().render( caveManager.getCave(player.getPosition()).getPercepts() ); } public String renderPerceptsForHtml() { return new HtmlVisitor().render( caveManager.getCave(player.getPosition()).getPercepts() ); } }这就是 Visitor 模式的威力新增一种 UI只需写一个新的 Visitor 实现无需修改 Cave、Player、GameContext 的任何一行业务代码。它把“是什么”PerceptEvent和“怎么展示”Visitor彻底解耦让“巨洞冒险”的 Java 实现真正具备工业级可维护性。我带实习生做这个项目时曾让他们用三天时间把控制台版改成 Swing 版——结果有人重写了 80% 的类。后来我教他们 Visitor 模式半小时就完成了切换。那一刻我意识到所谓“Java 工程能力”不是会不会写for循环而是能否在需求变更前就把扩展点埋进代码的基因里。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/8 14:41:11

Java数据结构全解析:从ArrayList到HashMap与红黑树

1. 先想明白:数据结构在Java里到底是什么 做Java开发三五年的人,跳槽面试时被问“HashMap为什么线程不安全”“ArrayList和LinkedList什么时候选谁”,十有八九会一愣——不是不会,是平时写业务代码根本用不到这些细究。但数据结构…

2026/10/8 14:36:10

Notepad++五大核心插件实战指南:轻量IDE级文本处理工作流

简介:本资源是面向程序员、Web开发者及系统运维人员的Notepad高效开发环境配置包,专为解决新装或重装Notepad后需逐一手动下载配置插件的繁琐问题而设计。压缩包为ZIP格式,共包含数十个经实测兼容的常用插件,涵盖代码比对&#xf…

2026/10/8 14:36:10

QCodeEditor:Qt原生轻量级代码编辑器集成指南

简介:QCodeEditor 是一个轻量级、功能完备的 Qt5 代码编辑器小部件,面向 C/Qt 开发者,尤其适用于需嵌入自定义代码编辑能力的桌面应用开发场景。它基于 C11 和 Qt5 构建,提供自动括号匹配、多语言语法高亮(C、GLSL、XM…

2026/10/8 15:46:41

Python列表与元组:可变性、性能与应用场景详解

前段时间在技术社区闲逛,看到一个提问:“Python里列表和元组到底有啥区别?我该用哪个?”下面回答区的留言五花八门,但也不少把两者混为一谈的。这个问题看似基础,但真要动手写代码时,不少人还是…

2026/10/8 15:46:41

三数之和双指针解法:去重细节与算法复杂度优化

刷题刷到 LeetCode 15 题“三数之和”,这个位置非常微妙。前十几题基本是数组、字符串、动态规划热身,而这一题一出来,很多人的思维会卡住。题目本身不复杂:给定一个整数数组,找出所有和为 0 且不重复的三元组。但“不…

2026/10/8 15:46:41

Java电商源码改造实战:从跑通到上线的表设计、支付与压测

简介:基于Java技术栈构建的完整电商网站源码项目,面向正在学习Spring Boot、MyBatis、Redis等后端框架,或希望掌握Vue.js/jQuery前端交互的开发者,也适合需要参考实际业务流程的初、中级程序员。项目覆盖用户注册登录、商品分类搜…

2026/10/8 15:46:41

软件测试核心知识全解析:流程、实战、工具与面试攻略

这些年我见过太多人把软件测试理解成“找个人点点按钮”,也见过太多项目因为测试缺位在上线后炸出惊天大雷。软件测试这门活,表面上是执行用例、提bug,内核却是质量建模、风险判断、流程管理,甚至项目管理的全面较量。 这篇文章我…

2026/10/8 15:41:39

隔离内网AI Agent工程实战:本地模型推理与RAG知识库构建

1. 项目背景与整体架构思路1.1 为什么要在内网环境里跑AI Agent接手"隔离内网下AI Agent工程实战"这个项目,是因为一个很现实的问题:很多企业和机构的生产环境物理隔离,终端、业务系统和核心数据都在一个与公网物理断开的独立网络里…

2026/10/8 10:03:18

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

2026/10/8 10:03:20

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

2026/10/8 6:05:44

无源低通滤波器设计实战:从RC到LC,手把手教你避开那些坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 0:02:17

自然数立方等于连续奇数之和:从证明到编程验证

十几年来我一直游走在数学科普和编程教学这两块内容之间,对“看起来像魔法、拆开全是数学”的结论总是格外敏感。最近翻资料时又撞见一句话:任何一个自然数 m 的立方,都可以写成 m 个连续奇数之和。2 的立方等于 3 加 5,3 的立方等…

2026/10/8 0:02:17

C#上位机SSH连接实战:用SSH.NET补齐超时、批量与密钥认证

简介:这是一份基于 C# 开发的 SSH 连接功能半成品工程,原本作为另一个主项目的子功能模块,现独立打包分享。工程采用 WinForms 界面,包含源码、解决方案、安装部署工程、NuGet 依赖包及说明文档,适合正在做远程连接、网…

2026/10/8 0:02:17

Java SpringBoot一体化智能售后系统设计与实现全解析

毕业设计年年做,Java Web 方向的题目翻来覆去就那么几个,但“一体化智能售后系统”这个题,每次看到我都觉得值得认真聊一聊。它不是一个简单 curd 堆出来的管理系统,而是把客户、工单、派单、处理、回访、统计整条链路串起来的一套…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑