☰
Java控制台三国杀:一个能体现面向对象基本功的毕业设计
2026/10/27 6:33:45 网站建设 项目流程

简介:一份基于Java开发的三国杀控制台游戏完整项目,定位为毕业设计参考,适合Java课程设计、游戏逻辑学习及控制台交互开发的读者。项目涵盖角色选择、出牌流程、技能触发等核心玩法,将三国杀规则以命令行形式实现,有助于理解面向对象建模、异常处理与游戏流程控制。压缩包共69个文件,包含10个Java源码、32个编译后的class文件、20个备份文件以及演示视频(avi)、工程配置类文件,整体19.9MB;src源码、bin编译产物与演示内容分层存放,便于对照运行、调试与二次开发。目前已有266人学习/下载。通过该资源可获得可运行工程、源码备份、演示视频及清晰的目录结构,既能用于毕业设计的功能展示与代码讲解,也能支持课程报告中的设计思路阐述。

1. 控制台版三国杀毕业设计:为什么它比SSM商城更能体现Java基本功

现在Java毕业设计里,十有八九是「XX管理系统」,注册登录加增删改查,答辩时老师问一句「你哪里用了多态」,很多人就卡壳。Java三国杀游戏控制台版本这个题目,恰恰站在反面:它没有Web框架,没有数据库,却逼着你把Java基础、面向对象设计、集合与排序、异常处理、IO流全调动起来。控制台版本意味着所有规则都得靠类和接口承载,摸牌、出牌、距离计算、胜负判定,每一项都比写CRUD更能检验一个学生是不是真懂Java。适合两类人:一是想避开同质化选题的毕业生;二是打算借项目把面向对象编程Java重新吃透的求职者。

2. 先搭游戏骨架:角色、牌堆与回合状态机的类设计

2.1 从「人、牌、回合」三个维度拆对象模型

三国杀控制台版本最怕的就是一上来写循环。我刚拿到这个题目时,第一反应是「不就是让玩家轮流输指令吗」,结果写到第三天,主方法已经堆了三百行,后续每加一个功能都像在拆地雷。拆完之后才发现它本质上是个状态机:游戏是一轮一轮的回合,每个回合又拆成开始、判定、摸牌、出牌、弃牌、结束六个阶段,玩家在每个阶段能做的动作被严格限制。把这三个维度拆开,代码才可能活下来。

第一个维度是「人」。Player类不需要管技能,它只需要关心自己有多少血、多少手牌、当前坐在第几个位置。距离计算是玩家关系的核心——杀能否指定目标,取决于你和目标之间隔着多少人,三国杀里的默认距离不是数组下标差,而是环形座位上的最短路径。你可以把players列表当成环,计算两个人的顺时针距离和逆时针距离,取小值。这个计算方法直接影响后面的「杀」,也天然能考察你的Java基础。

第二个维度是「牌」。控制台版本最简做法是把牌建模成一张带名称和类型的对象,而不是用字符串传来传去。每张牌需要知道自己能不能被使用、使用时怎么结算。第三个维度是「回合」。每个玩家在自己的回合内按顺序经过六个阶段,阶段之间可以被打断,但主循环必须稳定推进。下面这段枚举是回合状态机的最简实现:

public enum Phase { START, JUDGE, DRAW, PLAY, DISCARD, END; public Phase next() { Phase[] values = values(); return values[(ordinal() + 1) % values.length]; } }

这段代码的逻辑很容易理解:每个枚举值自带顺序,next()返回下一个阶段,最后一个回到START。用枚举当状态机的好处是,编译器帮你挡住非法状态,你不可能凭空造出一个「摸牌阶段后面的阶段」。它的代价是太僵硬——如果你的武将技能要跳过摸牌阶段,这个next()就不够用了,需要改成状态模式,把每个阶段封装成对象。毕业设计阶段先用枚举把骨架跑通,完全够用。

玩家之间的距离计算是一个算法细节,我一般会这么写:

public class Player { private String name; private int hp; private int maxHp; private boolean alive = true; private List<Card> handCards = new ArrayList<>(); public int distanceTo(Player target, List<Player> players) { int n = players.size(); int thisIdx = players.indexOf(this); int targetIdx = players.indexOf(target); int diff = Math.abs(thisIdx - targetIdx); return Math.min(diff, n - diff); } }

这里的参数说明:players是存活的玩家列表,this和target都在里面。indexOf依赖equals方法,所以Player必须重写equals和hashCode,否则indexOf会按对象地址查找,就算两个玩家名字和位置完全一样,也会被当成不同的人。这是新手常踩的坑,后面避坑章节会再展开。另一个细节是这个返回值在目标是自己时是0,使用杀的时候要额外排除目标不能是自己,否则就会唱一出「我杀我自己」。

2.2 用枚举和接口把规则从业务逻辑里剥出来

三国杀的身份判断特别适合枚举。主公、忠臣、反贼、内奸的敌我关系如果用1、2、3、4表示,出牌阶段就要写一堆魔法数判断,看得人头疼。把身份做进枚举之后,判断敌对关系就是一行代码:

public enum Role { LORD("主公"), LOYALIST("忠臣"), REBEL("反贼"), TRAITOR("内奸"); private final String displayName; Role(String displayName) { this.displayName = displayName; } public boolean isEnemy(Role other) { if (this == LORD || this == LOYALIST) { return other == REBEL || other == TRAITOR; } if (this == REBEL) { return other == LORD || other == LOYALIST || other == TRAITOR; } return true; // 内奸与所有人敌对 } @Override public String toString() { return displayName; } }

我故意把内奸的isEnemy简化成永远返回true,因为内奸的胜利条件很特殊——场上只剩自己和主公时才算赢。真实规则里,内奸在只剩两方时不是「与所有人为敌」,而是要保护一下主公,这里先留一个口子,后续用单独逻辑覆盖。枚举的优势是定义稳定、可读性高,在出牌阶段判断「能不能杀他」时,直接调用role.isEnemy(target.role)就够了,不用再散落各种身份常量。

2.3 最小可运行骨架:主循环与命令分发

状态机拆好之后,主循环其实就是一个不断「推进阶段-处理阶段-切换玩家」的过程。我建议第一步先不实现任何卡牌效果,只把回合轮转跑通,确认玩家能依次进入出牌阶段。这样可以避免逻辑和UI纠缠在一起,出了Bug都不知道是循环挂了还是牌效果写错了。

public class Game { private final List<Player> players = new ArrayList<>(); private final PhaseMachine phaseMachine = new PhaseMachine(); private int currentPlayerIndex = 0; private boolean running = true; public void loop() { while (running) { Player current = players.get(currentPlayerIndex); Phase phase = phaseMachine.current(); processPhase(current, phase); currentPlayerIndex = nextAliveIndex(currentPlayerIndex); } } private int nextAliveIndex(int index) { int next = (index + 1) % players.size(); while (!players.get(next).isAlive()) { next = (next + 1) % players.size(); } return next; } }

这里的processPhase方法承担实际动作,比如摸牌阶段给当前玩家发两张牌、出牌阶段等待控制台输入。phaseMachine里维护Phase枚举的当前值,每处理完一个阶段就调用next()。nextAliveIndex的逻辑很关键:如果一个人的下一家已经阵亡,必须往后继续跳,直到找到下一个存活玩家,否则游戏会在死人身上空转。这个循环要记得加保护,正常情况下总会有一个存活玩家,但如果你在测试时把人都杀了,它就会死循环——所以我在开场时先把running判好。

出牌阶段是这个骨架里唯一需要等待外部输入的地方,也是最容易失控的地方。后面第4章会专门讲输入解析,这里先只留一个空接口,让读者理解整个循环的走向。控制台版本考验的不是单线程交互,而是「事件循环」的思维:游戏主线程不断从Scanner读行,每读一行就当成一个命令去驱动状态机,而不是用一长串if去挨个比较玩家输入。

3. 把杀闪桃和无懈可击实现成可扩展的卡牌体系:策略模式实战

3.1 为什么不能用一层又一层if-else写卡牌效果

我见过不少控制台版三国杀,关键代码长这样:出牌阶段来一个命令,然后用if判断这张牌叫什么名字,再挨个处理。前三天很爽,等把杀、闪、桃、无懈可击都加上,这段代码已经有两三百行,而且每一次加新牌都要在主循环里再塞一个if。更痛苦的是,不同卡牌需要的参数完全不同:杀需要目标,桃需要自己当前血量,无懈可击需要目标出的那张锦囊牌。硬塞进同一个if块里,就只能靠传null和一堆前提前置判断。

职业做法是策略模式。把每种牌实现成独立类,类与类之间不互相依赖,游戏主流程只认识Card接口。这样当你加一张「南蛮入侵」时,只需要新建一个类,不改Game、不改Player、不改原有的牌,完全符合开闭原则。而且在答辩时,老师问「如果让你加一张新卡牌,需要改哪些类」,你可以自信回答「只需要加一个类」。这是实打实的加分项。

3.2 定义Card接口与EffectContext参数对象

卡牌效果的执行需要访问游戏环境:玩家列表、当前玩家、牌堆、随机数、控制台输入。把这些全部塞到Card接口的每个方法参数里会显得很长,而且有些牌用不上某些参数。我一般会做一个EffectContext包装对象,把环境字段打包在一起,这样将来无论是扩展装备牌还是锦囊牌,签名都不用变。

public interface Card { String getName(); CardType getType(); boolean canUse(Player user, Player target, EffectContext ctx); void execute(Player user, Player target, EffectContext ctx); } public class EffectContext { private final List<Player> players; private final Player current; private final Random rng; private final Scanner scanner; public EffectContext(List<Player> players, Player current, Random rng, Scanner scanner) { this.players = players; this.current = current; this.rng = rng; this.scanner = scanner; } public List<Player> getPlayers() { return players; } public Player getCurrent() { return current; } public Random getRng() { return rng; } public Scanner getScanner() { return scanner; } }

这段代码里的CardType可以先做成一个只读枚举,比如BASIC("基本牌"), TRICK("锦囊牌"), EQUIP("装备牌")。canUse和execute放在一起的好处是,出牌阶段渲染手牌时,可以先遍历调用canUse,能用的牌正常显示,不能用的置灰。玩家敲「杀 3」,Game再查一次canUse确认合法性,防止伪造指令。控制台版本虽然不存在安全问题,但这样分层的思路跟将来做网络版是一致的。

3.3 实现杀、闪、桃、无懈的响应链

现在看杀和闪。杀不是一拍脑袋就扣血,目标有机会打出闪。所以我把杀.execute写成:先询问目标是否使用闪,再决定是否扣血。

public class SlashCard implements Card { @Override public boolean canUse(Player user, Player target, EffectContext ctx) { return !user.equals(target) && user.getAttackRange() >= user.distanceTo(target, ctx.getPlayers()); } @Override public void execute(Player user, Player target, EffectContext ctx) { System.out.print(target.getName() + " 是否打出【闪】闪避?(y/n): "); String response = ctx.getScanner().nextLine().trim(); if ("y".equalsIgnoreCase(response) && target.hasCard("闪")) { target.consumeCard("闪"); System.out.println(target.getName() + " 打出了【闪】,杀被躲过"); } else { target.setHp(target.getHp() - 1); System.out.println(target.getName() + " 受到1点伤害"); } } }

这里有一个设计取舍。把Scanner放进execute里,对一个纯控制台版本来说书写方便,但它把「交互」和「结算」耦合在一起,以后如果要接AI托管或网络传输,这段代码就很难复用。我后面在第6章会讲怎么把交互拆出去,这里先用比较直白的实现让读者快速跑起来。还有一个细节是target.hasCard("闪")和consumeCard之间不是原子的,如果目标手上只有一张闪,hasCard返回true之后,consumeCard再删,中间没有并发问题。但如果以后要做多线程,记得加同步。

桃的实现更简单,因为我把它设计成只能对自己使用:

public class PeachCard implements Card { @Override public boolean canUse(Player user, Player target, EffectContext ctx) { return target == user && user.getHp() < user.getMaxHp(); } @Override public void execute(Player user, Player target, EffectContext ctx) { user.setHp(user.getHp() + 1); System.out.println(user.getName() + " 使用【桃】,回复1点体力"); } }

这里的canUse限定了target必须是使用者和hp不满,所以出牌阶段我不会让「桃」出现在满血玩家的可操作列表里。注意setHp同时处理了上限和死亡,如果你在后面把setHp改成返回boolean,还能用于判断「桃」是否生效。这是很多卡牌游戏的做法:回复动作返回实际生效量。

无懈可击则引入一个新接口,专门给锦囊牌拦截用。控制台版能遇到的最常见场景是「决斗」和「南蛮入侵」这类伤害型锦囊被无懈,而「无懈可击」本身也是一张锦囊。我建议不要把无懈逻辑写死在每张锦囊牌里,而是抽一个静态方法询问一圈:

public static boolean askWuxie(EffectContext ctx, Player questioner) { for (Player p : ctx.getPlayers()) { if (!p.isAlive() || !p.hasCard("无懈可击")) { continue; } System.out.print(p.getName() + " 是否打出【无懈可击】?(y/n): "); if ("y".equalsIgnoreCase(ctx.getScanner().nextLine().trim())) { p.consumeCard("无懈可击"); System.out.println(p.getName() + " 打出了【无懈可击】,锦囊无效"); return true; } } return false; }

参数说明:这里按players列表的物理顺序询问,不是标准规则里的「从当前行动者开始按座次询问」,但对控制台版本演示足够。之所以抽成静态方法,是为了让决斗、过河拆桥等所有锦囊牌共用同一个询问逻辑,避免每张牌都写一遍。如果你以后要支持连环无懈,需要把返回值从boolean改成递归调用,这也是一个很好的扩展点。

手牌显示时的排序也是一个Java基础考点。我一般用Collections.sort配合Comparator:

List<Card> view = new ArrayList<>(player.getHandCards()); Collections.sort(view, Comparator.comparing(Card::getType).thenComparing(Card::getName)); view.forEach(card -> System.out.print(card.getName() + " "));

这里要解释一下:先按类型排序,同类型再按名字排序,手牌显示就很整齐。Collections.sort是原地排序,所以先复制一份view,避免直接排序玩家手牌列表影响后续逻辑。Comparator.comparing是Java 8的键提取写法,比匿名内部类简洁得多。这就是Java排序在具体业务里最常用的形态,比死背冒泡排序有价值得多。

4. 控制台交互与输入解析:让命令行变成可玩的游戏

4.1 用Scanner还是BufferedReader?先解决控制台代码页GBK的乱码问题

控制台版本最大的交互难题不是键盘输入,而是中文乱码。如果你在Windows命令行里运行一个UTF-8编码的Java程序,println("杀")大概率输出成锟斤拷或者一串问号,因为Windows默认控制台代码页是GBK(936),而你的源码和运行环境可能按UTF-8来。反过来,如果代码用GBK写,传到Linux或macOS上又乱。这是控制台版三国杀最典型的翻车现场。

我的习惯是统一走UTF-8。运行时在命令行里先把控制台代码页切到65001,再启动JVM时显式指定文件编码:

chcp 65001 >nul java -Dfile.encoding=UTF-8 -cp out Main

这样在Windows、Linux、macOS三套环境下行为一致。如果你用的是JDK 18及以上,默认字符集已经是UTF-8,但chcp 65001仍然不能省,因为System.out在Windows上还是由控制台代码页决定的。另一个办法是不要用System.out直接打印中文,而是包一层PrintStream指定编码:

PrintStream out = new PrintStream(new FileOutputStream(FileDescriptor.out), true, "UTF-8");

但这样依然要在chcp 65001的基础上才能正常显示,所以最省心的还是把启动命令固定下来。IDE里跑时会自动继承IDE的控制台编码,所以很多同学在IntelliJ里好好的,一到老师命令行演示就崩,这是血泪经验。如果这时候javac或java本身都跑不起来,先回头检查JAVA_HOME环境变量配置,别让环境问题卡在最前面。

4.2 指令格式设计:从「杀 3」到「用 1 3」的自然语言解析

交互设计上,我不建议让玩家输入「使用杀攻击3号玩家」这种长句,解析起来全是if判断。控制台命令要短、要一致。我设计了一套两到三个字段的命令:动作 卡牌编号/目标编号。例如「杀 3」表示对3号玩家使用杀,「桃」表示自己使用桃,「过」表示结束出牌阶段。为了更直观,也可以采用「用1 3」:第一条手牌对3号目标使用。两者各有优缺点,短命令好敲,编号命令好解析,毕业设计通常用后者。

public class CommandParser { public static Command parse(String line) { String[] parts = line.trim().split("\\s+"); if (parts.length == 0) return Command.invalid("空指令"); try { switch (parts[0]) { case "用": if (parts.length != 3) return Command.invalid("用法: 用 手牌编号 目标编号"); int cardIdx = Integer.parseInt(parts[1]); int targetIdx = Integer.parseInt(parts[2]); return new UseCardCommand(cardIdx, targetIdx); case "过": return new PassCommand(); case "help": return new HelpCommand(); default: return Command.invalid("未知指令: " + parts[0]); } } catch (NumberFormatException e) { return Command.invalid("编号必须是数字"); } } }

解释一下这里面几个关键点。split("\s+")是按一个或多个空白符切分,所以玩家输「用1 3」和「用 1 3」都能被正确解析,实现上更宽容。Integer.parseInt放在try里,玩家输「用 abc 3」时不会让程序崩溃,而是返回一条invalid命令,Game拿到后打印提示并重新等待输入。switch在Java 17及以上支持字符串和枚举,读起来比一长串if-else清晰。

这里也要强调,Command类本身是一个简单的数据携带对象,可以做成抽象类,下面挂UseCardCommand、PassCommand等。UseCardCommand里持有一个手牌编号和目标编号,Game执行它之前必须再次校验手牌编号是否越界、目标是否存活、canUse是否满足,不能轻信解析结果。控制台版本没有作弊风险,但这种「解析器只做语法翻译、游戏逻辑才做语义校验」的分工,会大大减少以后接入Web时的安全问题。

4.3 玩家状态显示:如何用格式化输出避免刷屏

一场对局打下来,屏幕上会混着技能提示、卡牌结算、身份翻牌,如果每次出牌都重新打印一遍所有玩家的完整信息,信息会跟洪水一样。我一般把「当前玩家手牌」和「全场状态」拆成两种视图:平时只显示一句话状态,出牌阶段才展开打印手牌列表。

public static String renderStatus(List<Player> players, Player current) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < players.size(); i++) { Player p = players.get(i); String currentMark = p.equals(current) ? " ←" : ""; sb.append(String.format("%d. %s %d/%d血 手牌%d张 距离%d%s%n", i, p.getName(), p.getHp(), p.getMaxHp(), p.getHandCards().size(), p.distanceTo(current, players), currentMark)); } return sb.toString(); }

String.format里的%d会自动补空格,让各列对齐,可比手动拼字符串稳定。距离列显示每个玩家与当前玩家的距离,能帮玩家判断杀是否够得着,这是控制台版本独有的贴心设计。%n比\n更跨平台,在Windows和Linux下的换行行为一致。

清屏方面,我一直用最朴素的方案:打印一整页空行。ANSI的\033[H\033[2J在很多终端模拟器里有效,但Windows cmd老版本不认,会输出奇怪的字符。输出80个空行的方案虽然看起来粗暴,但至少不会出错,而且对答辩演示来说,换页比滚动更好看。

private static void clearConsole() { for (int i = 0; i < 80; i++) { System.out.println(); } }

这里有个取舍:如果每次都清屏,玩家会看不到历史结算信息,容易搞不清上一轮发生了什么。我在「出牌阶段」和「响应阶段」之间不清屏,只在轮到新玩家时才清一次。这样既保持界面清爽,又不丢失关键日志。你可以用一套全局配置控制开关,调试时关掉清屏,答辩时打开。

5. 避坑指南:控制台三国杀最常见的5个翻车现场

三国杀控制台版本踩坑点很多,我从自己写过的版本里挑了五个最容易让毕业设计当场翻车的。

5.1 遍历手牌时删除牌,ConcurrentModificationException

现象:出牌阶段玩家打出一张手牌,代码在增强for里直接handCards.remove(card),运行到一半抛ConcurrentModificationException。 原因:增强for隐式用了Iterator,遍历过程中集合结构被修改,触发快速失败机制。 解决:用显式Iterator,在next()之后通过iterator.remove()删除,或者先记下要删的牌,循环结束再删。

Iterator<Card> it = player.getHandCards().iterator(); while (it.hasNext()) { Card card = it.next(); if ("闪".equals(card.getName())) { it.remove(); break; } }

这里注意,如果你非要写player.getHandCards().remove(card),而Card类没重写equals,remove会按地址比较,大概率删除失败也不报错。好一点的做法是让Card重写equals和hashCode,把所有牌对象都当成值和类型都相等的对象,这样remove才可靠。

5.2 玩家死亡后,游戏还继续让他行动和结算

现象:被杀的玩家hp变成0,但没有置alive=false,主循环下一轮仍把当前玩家索引指到他,继续摸牌出牌,甚至还能被指定为「杀」的目标。 原因:血量字段和存活状态没有联动,很多人只在扣血那里写hp--,忘了检查死亡。 解决:把hp修改收敛到一个setHp方法中,所有伤害和回复都走这个方法,当hp降到0时置alive=false并把玩家标记为阵亡。

public void setHp(int newHp) { this.hp = Math.min(Math.max(newHp, 0), maxHp); if (this.hp == 0 && alive) { alive = false; System.out.println(name + " 阵亡,身份为" + role); } }

这里还有个更隐蔽的点:伤害触发「濒死求桃」,如果没人出桃就死亡。所以setHp里不能只在hp==0时直接宣布死亡,应该先触发濒死结算。控制台版本可以简化成一个统一的handleHpChange方法,内部先扣血,扣到0就调用askPeach询问当前玩家是否出桃,成功则恢复,失败再死亡。不然就会出现「玩家死了还能被桃救活」这种逻辑矛盾。

5.3 洗牌用默认Random,调试像开盲盒

现象:每次启动游戏发牌顺序都不同,玩家想复现上一局某个Bug,怎么都复现不出来。 原因:Collections.shuffle(list)内部用默认随机源,没有种子,运行结果不可复现。 解决:自己封装洗牌算法,传入固定种子。这个方案既方便调试,也是答辩时最好的「可复现演示」卖点。

public class ShuffleBox { private final Random rng; public ShuffleBox(long seed) { this.rng = new Random(seed); } public void shuffle(List<Card> cards) { for (int i = cards.size() - 1; i > 0; i--) { int j = rng.nextInt(i + 1); Collections.swap(cards, i, j); } } }

这段Fisher-Yates洗牌算法每次把当前位置与随机位置交换,时间复杂度O(n),是Java里很经典的手写排序算法。固定种子之后,只要牌堆初始顺序和玩家操作相同,每一局都能原样回放,后面第6章的对局日志也依赖这个种子。注意不要在每次抽牌时新建Random,那样种子会漂移,应该让整个游戏共用同一个ShuffleBox实例。

提示:如果你追求连续多局都不可预测,可以在每次新开局时用System.nanoTime()作为种子,正常情况下不固定的种子就是「运气」。调试和演示时才固定种子。

5.4 中文输入乱码被我识别为「未知指令」

现象:玩家在控制台输入「杀」,解析器却提示未知指令,调试发现读进来的字符串变成了乱码或者带了不可见前缀。 原因:stdin和stdout的编码不一致。程序内部用UTF-8,Windows控制台用GBK,输入中文被转义成怪字符。 解决:除了上一章说的chcp 65001和-Dfile.encoding=UTF-8,还可以在程序启动时主动设置默认编码。JDK 17之前比较麻烦,JDK 18之后可以直接用-Dfile.encoding=UTF-8。如果你用的是Scanner读取,还可以指定读取的字符集:

Scanner scanner = new Scanner(System.in, "UTF-8");

但Windows控制台的代码页没切过来的话,这个设置依然不根治。所以我一般在启动脚本里同时写chcp和java命令,并把脚本命名为sgs.bat,放在项目根目录,答辩时老师双击就能跑起来,不用自己敲命令。这是很接地气的做法,能避免现场演示翻车。

5.5 加一张新牌要改Game类,最后连自己都不想维护

现象:游戏中期想加一张「过河拆桥」,发现需要改出牌阶段的指令解析、修改效果结算、还要在Help命令里补一行。 原因:卡牌效果没有抽象,所有逻辑散落在Game里,导致每加一种牌都像在拆地雷。 解决:回到第3章的策略模式,把每种牌做进独立Card类。这样加一张新牌时,只需要新建类并注册到卡牌工厂里。

public class CardFactory { public static Card create(String name) { switch (name) { case "杀": return new SlashCard(); case "闪": return new DodgeCard(); case "桃": return new PeachCard(); case "无懈可击": return new WuxieCard(); default: throw new IllegalArgumentException("未知卡牌: " + name); } } }

这里的CardFactory是唯一需要修改的地方,而且很可能连这个都不需要改——更好的做法是把卡牌名到创建函数的映射放到Map中。答辩时老师如果问扩展性,你指一指CardFactory就够了。坦诚说,这个坑不是靠某个算法能解决的,是靠一开始就克制住用if-else图快的冲动。

6. 毕业设计答辩加分技巧:可复现的对局回放与最小化AI托管

6.1 固定随机种子 + 日志重放

答辩演示最怕「上一把还挺顺利,这一把摸牌全废」,演示卡壳比代码Bug还要命。我的做法是把所有随机事件收口到一个注入的Random实例上,同时把每个玩家的动作和牌局事件写进日志。这样只要种子固定,生成的就是一模一样的一局。

public class ReplayLogger { private final StringBuilder log = new StringBuilder(); public void log(String format, Object... args) { log.append(String.format(format, args)).append('\n'); } public String dump() { return log.toString(); } }

在Game构造时传进seed和ReplayLogger,Game所有决策点都用同一个random;玩家输指令时logger.log("%s 使用了 %s", playerName, cardName)。答辩前先跑一遍,把日志文件留下;现场再跑一次固定种子,必然复现同一过程。这份日志还可以当作懒人版回放——如果你愿意,甚至可以写一个简单的命令行播放器,按时间间隔去刷日志。

6.2 最小化AI托管,别让AI硬碰硬

多玩家模式如果没有AI托管,演示时就只能一个人开两个窗口自己跟自己玩,观感很乱。其实不用做复杂的策略AI,只要让AI在出牌阶段做一轮「可用牌遍历」,然后随机选一张使用就够了。这样代码量小,但已经能撑起4人局的完整性。

public void autoPlay(Game game, Player ai) { List<Card> usable = ai.getHandCards().stream() .filter(card -> card.canUse(ai, chooseTarget(card), game.getContext())) .collect(Collectors.toList()); if (usable.isEmpty()) return; Card pick = usable.get(random.nextInt(usable.size())); game.executeCommand(pick, chooseTarget(pick)); }

chooseTarget方法里,AI会把距离最近的敌人设为首选目标,距离超了再选自己人。文档里一定要诚实写清「AI仅做随机决策,不含身份记忆和策略推理」,老师问到就说这是为了把复杂度控制在合理范围,后续可以用决策树或蒙特卡洛替换。这一句话比藏着掖着强得多。

最后说一个我自己的教训:第一次做这个题目时,我花的调试时间有一半是花在「复现上一个Bug」上。后来才意识到,早早在牌堆和日志里埋随机种子,等于给调试上了一道保险。你不需要做完整的AI,也不需要做漂亮的GUI,控制台版本本身就是一次不错的Java基础工程实践。希望帮到你。

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

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

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

立即咨询