资讯动态

Java实现三国杀控制台版:从卡牌建模到回合状态机的毕业设计实战

发布时间:2026/10/27 22:42:16 来源:尧图企业网站定制
简介一份基于Java的控制台版三国杀游戏完整项目面向上机练习、课程设计与毕业设计场景重点展示Java语法、面向对象建模、多线程、输入输出及异常处理在完整游戏逻辑中的综合运用适合希望通过实战巩固Java基础的读者。压缩包共69个文件核心包含10个Java源代码、32个class编译产物、20个bak备份文件以及cdk配置、工程文件和操作演示视频整体约19.9MB目录将源码、编译输出与配置分离便于检索。已有266人学习下载。项目附带演示录屏和可直接运行的class对照源码可快速理清角色、卡牌、技能触发、回合流程等核心机制的数据结构与算法实现控制台交互设计了清晰的菜单提示、输入校验与错误恢复测试与排错思路贯穿各模块能作为毕业设计或课程项目的完整方案参考。1. 一个控制台能装下三国杀吗从毕业设计选题到玩法边界毕业设计选题最怕的不是题目偏而是题目大。选“基于Java的三国杀游戏系统”光武将牌原画就要找三十张素材处理占了一半工作量核心规则反而没写几行。控制台版本把这个问题直接绕开——砍掉图形界面把全部精力压在玩法规则、状态流转和命令交互上正好对上课程设计的验收逻辑类结构清晰、对局能跑通、答辩能讲明白。而且Java控制台程序天然适合当作学习路线里的中期项目用来检验集合、枚举、接口、多线程这些基础知识的掌握程度。这篇文章就顺着“Java三国杀游戏控制台版本 | 毕业设计”这条主线从卡牌建模、回合状态机再到AI托管和存档复盘把完整做法拆开讲。适合正在选毕业设计题目、或者想用Java基础写一个能演示项目的在校生参考。2. 把卡牌和武将翻译成Java类三大核心模型与选型理由在控制台里做三国杀第一个决定不是先写游戏循环而是先定义“一张牌是什么”。很多同学上手就建一个Card大类和一堆字段名称、类型、效果描述、使用对象……结果杀和桃变成同一个类的两个实例锦囊牌的效果全靠switch分支后期每加一张牌要动三处地方。我一般会把卡牌设计成接口加具体实现类效果逻辑跟着牌走这样再补“火攻”“铁索连环”时只需要新增一个类已有代码完全不动。这个选择本质上是面向对象编程里的开闭原则直接影响后续所有模块的扩展成本值得在最开始定下来。2.1 卡牌体系接口加实现类效果跟着牌走先定义牌的类型枚举。控制台项目里通常保留三大类就可以覆盖大部分规则public enum CardType { BASIC, // 基本牌杀、闪、桃、酒 TACTIC, // 锦囊牌无中生有、过河拆桥、南蛮入侵 EQUIP // 装备牌武器、防具、坐骑 }然后定义Card接口。我把它压到最短只留四个方法public interface Card { String name(); // 牌名控制台显示用 CardType type(); // 牌类型初始化牌堆时按比例分配 boolean canUse(Player user, GameContext ctx); // 当前状态下能否合法使用 void execute(Player user, Player target, GameContext ctx); // 效果结算 }把“能不能用”和“用了之后产生什么效果”拆成两个方法目的是让回合流程、命令解析、AI决策共用同一套合法性校验。玩家输入“出杀 3”时Parser拿到指令Engine调用手牌中那张“杀”的canUse方法返回false就直接提示“不能出杀距离不够或次数用完”不会走到结算逻辑里。具体实现类以杀牌为例public class SlashCard implements Card { Override public String name() { return 杀; } Override public CardType type() { return CardType.BASIC; } Override public boolean canUse(Player user, GameContext ctx) { // 无武器时每回合至多出一次杀诸葛连弩可突破 if (user.getSlashCountThisTurn() 1 !user.hasEquip(诸葛连弩)) { return false; } // 攻击范围从装备计算默认1 return ctx.calcDistance(user, ctx.getTarget()) user.getAttackRange(); } Override public void execute(Player user, Player target, GameContext ctx) { target.takeDamage(1, user, ctx); user.increaseSlashCount(1); } }这段代码把“出杀次数限制”和“距离限制”集中在一起。答辩时老师问“杀为什么不能无限出”你直接指canUse里的第一行判断就行逻辑一目了然。takeDamage里传user作为伤害来源是为了让“麒麟弓”“反馈”这类技能能追溯伤害来源控制台版本早期可以不传但保留来源参数可以省掉后面一次重构。用接口还是用枚举加switch我的建议是牌库十几张时全枚举够用但三国杀光标准包就有五十多张游戏牌接口加实现类是规模上来之后最省心的扩展方式。代价是类数量膨胀一张牌一个文件所以分包要提前规划card.basic、card.tactic、card.equip三个包分开放别全堆在默认包。如果不想每张牌一个文件还有一个折中做法用单Card类加CardEffect函数式接口每张牌构造时传入一个BiConsumer。这种方式类少但效果逻辑全堆在初始化代码里后期复杂度转移到了配置区。做毕业设计我更推荐接口加实现类因为注释、文件、类名本身就是答辩时的讲解地图。2.2 玩家与武将的状态建模血条、手牌、身份和距离玩家类是整局游戏的“国家档案”承载了几乎所有运行时数据。字段建议精简但别省因为后面每个模块都要用到public class Player { private int seat; // 座位号环形排布 private int hp; // 当前体力 private int maxHp; // 体力上限受武将影响 private ListCard handCards new ArrayList(); private Role role; // 主公、忠臣、反贼、内奸 private boolean alive true; private int slashCountThisTurn 0; // 本回合出杀次数 private EquipSlots equip new EquipSlots(); // 武器、防具、1马、-1马 private ListCard judgeArea new ArrayList(); // 延时锦囊区 private boolean chained false; // 铁索连环状态 }手牌用ArrayList就够了但有三条使用纪律增删牌都通过方法访问而不是直接操作listremove时用对象引用而不是下标排序展示时按类型和名称排序。同一个“杀”在List里可能有多个实例按下标remove会误删玩家手里另一张杀这是新手debug半天找不到原因的高发问题。身份和武将拆开来建模的好处是同一套玩家状态可以复用到不同武将。武将牌单独一个类public class HeroCard { private String heroName; // 武将名 private int maxHp; // 体力上限 private ListSkill skills; // 技能列表 }Skill接口这样定义public interface Skill { String name(); boolean onTrigger(GameEvent event, Player owner, GameContext ctx); void resolve(GameEvent event, Player owner, GameContext ctx); }GameEvent是一个事件对象字段包含事件类型摸牌、出杀、受伤、濒死、时间点之前/之后、涉及玩家。用事件驱动实现技能的好处是加武将不需要改Player类注册一个新Skill实现类即可。不过事件系统写起来比直接调用复杂毕业设计如果时间紧技能先用if判断硬编码在Player里也能过等基础版本跑通再重构。两害相权一个能按期验收的硬编码方案好过永远在重构的事件系统。武将技能这里有一个跑得通的简化做法不做真实的三国杀全技能只做几个标志性技能。主公技“激将”和“护驾”涉及请求其他玩家出杀/出闪需要额外的响应机制建议放到扩展阶段。先实现刘备“仁德”给牌回血、关羽“武圣”红牌当杀这类效果独立、不需要全局协作的技能演示效果已经足够。2.3 牌堆和弃牌堆洗牌算法与随机性的验证牌堆初始化是数据侧的“配置中心”。标准包的配比我给一个可直接用的参考牌类别牌名数量基本牌杀30基本牌闪15基本牌桃8锦囊牌无中生有4锦囊牌过河拆桥6锦囊牌顺手牵羊5锦囊牌南蛮入侵3锦囊牌万箭齐发1锦囊牌桃园结义1锦囊牌乐不思蜀3数量不必严格对齐实体卡控制台版重在流程验证。填牌时用循环Add进ArrayList然后洗牌。洗牌直接用Collections.shuffle但有一个测试友好性的细节容易被忽略Random无参构造使用当前时间做种子两个在同一毫秒内创建的Random可能产生完全相同的序列而且更麻烦的是每次重开牌序都不同出问题时没法稳定复现。我改成了允许外部传种子public class CardPoolFactory { public static ArrayDequeCard createDrawPile(long seed) { ListCard cards new ArrayList(); fillBasicCards(cards); fillTacticCards(cards); // 用固定种子复现同一局传System.currentTimeMillis()则每次不同 Collections.shuffle(cards, new Random(seed)); return new ArrayDeque(cards); } }参数说明seed传当前时间戳是正常对局传一个固定值如20240601则每次启动摸牌顺序完全一致。这个特性在debug时等于后悔药某个回合摸到异常牌可以用固定seed重跑一步步复现出问题路径。弃牌堆用一个List 装着牌堆耗尽时把弃牌堆重新shuffle然后新建ArrayDeque这就是一局游戏循环里唯一需要反复使用的数据流转。一条关于系统输出编码的提醒放在这里所有打印牌名的println统一经过一个ConsoleOutput工具类封装在main入口把System.out编码强制指定成UTF-8否则Windows默认GBK代码页下“杀”字可能变乱码。具体方案在下一章控制台交互里给出。3. 控制台交互层命令解析与回合状态机的不翻车实现控制台项目在体验上的差距往往不是算法复杂度而是玩家“下一步该做什么”有没有被清晰提示。有的作品启动后刷一屏文字玩家不知道该输入什么有的作品每个阶段都提示“0-杀 1-闪 2-桃请输入序号”体验完全不同。要实现后者靠两个模块一个把字符串输入变成命令对象一个把游戏流程拆成有限状态。控制台是黑盒输入输出走的是命令字面量所以这两个模块的质量直接决定作品作为毕业设计答辩演示时是不是流畅。3.1 命令解析器把“出杀 3”变成一次合法操作命令格式我设计成三段动作 目标编号 附加参数。“出杀 3”表示对3号玩家出杀“使用 无中生有”表示使用手牌中的无中生有“结束”表示结束行动。中文动词的好处是不用说明书答辩老师上手就能试。解析器如下public class CommandParser { public Command parse(String raw) throws IllegalCommandException { if (raw null || raw.trim().isEmpty()) { throw new IllegalCommandException(输入不能为空); } String[] parts raw.trim().split(\\s); String action parts[0]; int target -1; if (parts.length 2) { try { target Integer.parseInt(parts[1]); } catch (NumberFormatException e) { throw new IllegalCommandException(目标编号必须是数字例如出杀 3); } } return new Command(action, target, parts.length 2 ? parts[2] : null); } }Command对象只做数据承载。GameEngine拿到Command后根据当前状态决定动作是否合法合法就执行非法就返回提示信息。这样输入层与逻辑层隔离以后哪怕把控制台替换成网络Socket输入或者做一个简单的Web前端Engine不需要改只要在输入边界把字符串转成Command对象。参数说明split(\s)支持多个连续空格防止玩家“出杀 3”这种输入被拆出空串。target默认-1表示无目标再加一个简单的映射方法把“一、二、三”转成数字成本很低但演示观感会好很多。解析失败抛异常而不是返回null因为静默忽略会让玩家以为指令生效了实际没有任何反应体验很差。异常在Engine层统一catch再输出“输入格式不正确”。中文错别字容忍是另一个值得做的点。玩家可能输“出沙”而非“出杀”。常见做法是在Parser里维护一个别名词典把常见错字映射到标准动作。毕业设计到这里看精力有就加没有不影响主体功能。一个必须提的Scanner翻车点nextInt和nextLine混用。如果你开局用nextInt读人数后面再用nextLine读命令第一个nextLine会返回空串因为nextInt不会消费数字后面的换行符。这事我见很多同学踩过表现为“游戏第一次读命令什么都没有就结束了”。解决方法是统一用nextLine读整行需要数字时用Integer.parseInt转换这样输入路线只有一条不会出现这种灵异事件。3.2 回合状态机准备、判定、摸牌、出牌、弃牌五阶段三国杀的一个标准回合可以分为六个相位回合开始、判定阶段、摸牌阶段、出牌阶段、弃牌阶段、回合结束。用枚举定义相位public enum GamePhase { TURN_START, // 回合开始清理状态 JUDGE, // 判定乐不思蜀、闪电 DRAW, // 摸牌 PLAY, // 出牌自由操作区间 DISCARD, // 手牌超过体力值则弃牌 TURN_END // 移交给下一个存活玩家 }核心的回合循环在GameEngine里public void startGame() { while (!isGameOver()) { Player current players.get(currentSeat); runTurn(current); currentSeat findNextAliveSeat(currentSeat); } printWinner(); } private void runTurn(Player current) { phase GamePhase.TURN_START; current.resetSlashCount(); phase GamePhase.JUDGE; resolveJudgeArea(current); phase GamePhase.DRAW; drawCards(current, 2); phase GamePhase.PLAY; while (!isGameOver()) { Command cmd ui.readCommand(); if (processPlayCommand(current, cmd)) { break; // “结束”命令返回true离开出牌循环 } } phase GamePhase.DISCARD; discardToHpLimit(current); phase GamePhase.TURN_END; }这套流程的关键在于“跳过出牌阶段”这类情况用标志位处理而不改变状态机流转顺序。比如乐不思蜀判定成功给current设置skipPlay标志PLAY阶段while循环直接跳过兵粮寸断则设置skipDraw标志影响DRAW阶段。这样每个状态只在代码里写一次后续增加“铁索连环重置”“翻面”等机制时只需在对应阶段入口检查标志位不会出现状态分支嵌套到怀疑人生。每个状态切换时向控制台输出阶段名。PLAY阶段的提示信息尤其要完整当前体力、装备、手牌编号列表、可以操作的目标列表。一个健壮的控制台游戏必须做到“玩家只看屏幕就知道下一步能输什么词”这比花哨的输出更显工程素养。3.3 中文乱码与输出同步控制台程序的门面工程控制台程序不是不用UI就不关心编码。用过Windows中文系统的都知道System.out默认编码跟终端代码页不一致时杀字直接变问号。在main开头统一处理public static void main(String[] args) throws UnsupportedEncodingException { PrintStream out new PrintStream(System.out, true, UTF-8); System.setOut(out); ConsoleUI ui new ConsoleUI(); new GameEngine(ui).run(); }PrintStream的布尔参数表示每次写入都flush第三个参数是编码名。指定UTF-8之后如果Windows终端还乱码多半是控制台代码页还是GBK在运行程序前执行chcp 65001切换到UTF-8代码页即可。IntelliJ IDEA里不要勾选“Use Console UTF-8”这种双保险选项否则反而可能叠出奇怪结果。输出同步是另一个容易忽略的细节。System.out在控制台程序里默认有缓冲程序异常退出时最后几行日志可能丢失。所以在每次读取玩家输入前显式flush一次保证提示信息完整显示。另一个建议是把“结算结果输出”和“当前状态输出”拆成两个方法比如伤害结算输出在受伤侧回合状态重绘在玩家侧不然一屏信息混合在一起玩家会看不清自己手里的牌到底有几张。到这里控制台交互层已经能够支撑一个人完整打完一局。下一步是把各模块拼成完整对局涉及身份分配、胜负判定这些压轴逻辑。4. 完整对局流程从身份分配到胜负判定的代码骨架有了牌、玩家、Parser和状态机现在可以把它们拼成一个能完整体验的对局。这一章从一局游戏的开局到结束串起来四个环节身份分配、回合运行、距离结算、胜负判定。前三章已经解决了零散的零件这章解决的是把它们装配起来时必然遇到的两个易错点。4.1 身份分配与胜负判定不同人数局的可配置实现身份分配是整个游戏立起来的前提。控制台版建议支持3到8人局每种局面身份配比不同3人局是一主一忠一反5人局是一主一忠两反一内8人局是一主两忠四反一内。配置表可以写死在一个枚举里public enum GameSizeConfig { THREE(3, 1, 1, 1, 0), FIVE(5, 1, 1, 2, 1), EIGHT(8, 1, 2, 4, 1); final int players; final int lords; final int loyalists; final int rebels; final int defectors; GameSizeConfig(int players, int lords, int loyalists, int rebels, int defectors) { this.players players; this.lords lords; this.loyalists loyalists; this.rebels rebels; this.defectors defectors; } }初始化时先构建一个Role列表shuffle后按座位顺序分配。主公需要注意传统规则要求主公第一回合亮明身份其他角色隐藏。控制台版本我做了一个取舍——所有身份开局公开。理由很实际控制台没有表情和弹幕来玩心理博弈藏身份只能让局面变得拖沓失去演示价值。公开身份后玩家的策略重心变成“该出杀先杀谁”代码里的胜负判定也能被快速验证。胜负判定是整个程序里逻辑最绕的部分。三国杀基础规则中共有四套胜负条件主公与忠臣需要消灭所有反贼和内奸反贼需要杀死主公内奸需要先消灭所有反贼和忠臣最后再单挑杀死主公另外主公亲手杀死忠臣要弃所有手牌这个惩罚毕业设计可以不实现。核心判定可以压缩成优先级链public GameResult judgeResult(GameContext ctx) { // 条件1主公阵亡 if (!ctx.isAlive(Role.LORD)) { // 场上还有反贼存活反贼胜 if (ctx.aliveCount(Role.REBEL) 0) return GameResult.REBEL_WIN; // 没有反贼且内奸是唯一存活的非主角色内奸胜 if (ctx.aliveCount(Role.DEFECTOR) 1 ctx.aliveCount(Role.LOYALIST) 0 ctx.aliveCount(Role.REBEL) 0) { return GameResult.DEFECTOR_WIN; } // 其他情况归为反贼胜兜底 return GameResult.REBEL_WIN; } // 条件2主公存活且所有反贼、内奸阵亡主忠胜 if (ctx.aliveCount(Role.REBEL) 0 ctx.aliveCount(Role.DEFECTOR) 0) { return GameResult.LORD_WIN; } return null; // 游戏继续 }注意这里有个常见误判主公死了且内奸和忠臣都活着按规则是反贼胜因为反贼的目标已经达成。上面代码先判断REBEL0再判断DEFECTOR单挑顺序对先反贼后内奸。我见过不少实现把内奸胜利条件写在前面导致主公死亡但反贼还活着时错误判内奸赢。每回合结束检查judgeResult返回非null就跳出循环然后显示获胜阵营。这一段的测试建议单独写demo手工构造几个人物死活状态直接调用judgeResult验证比开着完整游戏反复打一局省时间。4.2 目标选择与距离计算最容易写错的环形结构距离计算是新手bug高发区。三国杀的距离定义是两名存活玩家之间按座位环数最少经过几条边每经过一个存活玩家距离1不连续跳过空位。比如5人局里0号玩家到2号玩家可以正向0-1-2距离2反向0-4-3-2距离3取最小为2。死亡玩家坐过的位置在计算时不计数这一点特别容易踩坑。public int calcDistance(Player from, Player to, GameContext ctx) { ListPlayer alivePlayers ctx.getAlivePlayersSortedBySeat(); int n alivePlayers.size(); if (n 2) return 1; // 单挑时距离固定为1 int start alivePlayers.indexOf(from); int end alivePlayers.indexOf(to); if (start 0 || end 0) throw new IllegalStateException(玩家不在存活列表); // 在存活者环形链上取最短路径 int forward (end - start n) % n; int backward (start - end n) % n; int dist Math.min(forward, backward); // 坐骑修正-1马缩短自己到目标的距离1马拉长目标被攻击的距离 dist - from.getMinusHorseValue(); dist - to.getPlusHorseValue(); return Math.max(1, dist); }关键点计算基础距离必须在“存活玩家列表”上做而不是原始座位数组。直接拿seat相减会遇到三个情况死玩家的空位被计为距离、首尾跳变变成n-1、活人之间距离被死人撑大。用alivePlayers重新索引后环形下单挑距离自然变成1后面的坐骑修正只是收尾。坐在位置上的判断也要注意玩家死亡后座位保留但标记alivefalse遍历时跳过。UI层显示座位表时可以把死亡玩家的位置打印成“空”这样复盘时能看到死亡顺序对答辩讲清“为什么这个目标不可选”很有帮助。4.3 出牌阶段完整骨架从命令到结算的闭环出牌阶段是玩家与游戏交互最密集的部分。把Parser产生的Command接进引擎private boolean executePlayCommand(Player current, Command cmd, GameContext ctx) { switch (cmd.getAction()) { case 出杀: // 目标不存在时Parser阶段已经拦截这里再校验一次 Player target ctx.getAlivePlayer(cmd.getTarget()); Card slash current.findCardByName(杀); if (slash null || !slash.canUse(current, ctx)) { ui.show(不能出杀没有杀、距离不够或出杀次数已满); return false; } ctx.handleCardPlay(current, target, slash); return false; case 使用: Card card current.findCardByName(cmd.getExtra()); if (card null) { ui.show(手里没有这张牌); return false; } if (!card.canUse(current, ctx)) { ui.show(当前不能使用这张牌); return false; } ctx.handleCardPlay(current, ctx.getAlivePlayer(cmd.getTarget()), card); return false; case 结束: return true; // 外层while检测到true就跳出PLAY阶段 default: ui.show(未知指令可用命令出杀 [目标] / 使用 [牌名] [目标] / 结束); return false; } }这层处理很薄真正的逻辑已经下沉到Card的execute里。好处是Engine不关心具体牌只关心动作类别。target.getAlivePlayer在目标已死亡时会返回null因此在使用前需要判空。你也可以在Parser阶段用当前GameContext校验目标合法性但Engine里再校验一次不是冗余而是防止未来接入网络输入时产生非法的Command。手牌查找用findCardByName会返回第一张匹配牌。如果玩家手里有两张“杀”而只想出特定的那张控制台版做不到精确选择通常也不会被要求。但如果以后要支持“弃置指定花色牌”就要把手牌查找扩展成按牌名加花色过滤的方法。到这里一个基本完整的控制台版三国杀已经能跑起来支持可配置人数、身份分配、回合流转、手牌管理、距离计算、伤害与死亡判定、胜负判断。剩下的章节把最常见翻车点汇总成排查清单再给出两个让演示效果立刻上台阶的增强方案。5. 毕业设计答辩前必查控制台版三国杀的5个典型坑与排查控制台项目好不好用往往不取决于功能多不多而是细节是否可靠。下面这五个问题是这个题目的高发踩坑点我每个都按“现象→原因→解决”写成排查条目建议在答辩前对照过一遍。5.1 中文牌名在终端里显示成问号或方块现象游戏能跑但控制台打印“杀”显示成“”或者乱码。 原因JDK默认输出编码与操作系统终端代码页不一致。Windows下Java旧版本默认编码往往是GBK而新版本在部分环境里变成UTF-8当终端代码页是GBK时UTF-8字节流被错误解码中文自然乱码。 解决在main第一行用PrintStream(System.out, true, UTF-8)包一层并setOut同时提示用户在Windows的cmd或PowerShell里先执行chcp 65001切换代码页。注意新版Windows Terminal里代码页切换和旧版cmd不是一回事如果是在IntelliJ IDEA里运行把Run Configuration的VM options加上-Dfile.encodingUTF-8更直接。5.2 第一个游戏命令神秘消失游戏像在自动跳过现象启动游戏后问你“请输入指令”你输入“出杀 3”程序却立即像收到了“结束”一样进入下一阶段。 原因典型的Scanner的nextInt与nextLine混用。开局读玩家人数和玩家名用了nextInt数字后面的换行符还残留在缓冲区紧接着的nextLine把换行符当成了空命令读走于是你真正输入的命令是第二次nextLine读到的。 解决整个程序统一用nextLine读取读数字也先readLine再Integer.parseInt或者每次nextLine之前手动调用一次nextLine来消费残留换行。后者容易漏所以推荐前者。这个坑在外表上非常像“状态机逻辑错误”排查时要先检查输入读取再检查状态机。5.3 同一局游戏每次重启牌序都不同bug没法复现现象发现一个摸牌阶段的异常但重启游戏后牌序变了同样的操作再也复现不出来调试变成玄学。 原因Collections.shuffle默认使用无参Random种子取自系统时间每次启动产生的随机序列都不一样。 解决把洗牌入口改成可传入seed的工厂方法例如CardPoolFactory.createDrawPile(long seed)。日常用System.currentTimeMillis()做随机种子需要复现时用固定种子例如把上一局的seed记录到存档再重放。顺手在启动参数里支持--seedxxxx这样复现路径和操作记录绑定能直接写出“用seed 20240601复现第7回合摸牌异常”的测试报告。5.4 玩家死后剩下的活人之间距离计算越来越奇怪现象一个5人对局死了三个人剩下两个活人本来是同桌近邻计算出来的距离却可能很大某些武器够不到。 原因用原始座位数组算距离把死亡玩家的座位也当成有效节点。而规则里距离只在存活玩家之间计算死人的位置不占距离。 解决先收集alivePlayers按座位排序再在存活者列表上用(末尾-开头n)%n算环形距离。第4章的距离方法可以直接拿来用。排查时在打印距离的日志里同时输出当前存活座位列表一眼就能看出是不是把空位算进去了。5.5 一回合能出无数次杀或者第一回合出不了杀现象同一个玩家在出牌阶段连着用四五张杀游戏不拦但另一个对局里出一次杀就被拦。 原因出杀次数限制用的是玩家对象上的计数器但计数器清零的时机和地点不对。最常见的是只在摸牌阶段清零或者搞混了“当前回合玩家”和“上一个行动玩家”带诸葛连弩时又忘了跳过次数限制。 解决在runTurn的TURN_START阶段第一行调用current.resetSlashCount()让清零跟着回合走而不是跟着出牌动作走。canUse里的限制写成“有诸葛连弩则无次数限制否则slashesThisTurn 1拒绝”。测试时可以构造一个出过杀再出杀的手牌场景用不装备武器和装备诸葛连弩两种状态各跑一遍。把上面五条过一遍主体流程基本就稳了。剩下的问题多是规则细节比如“乐不思蜀判定是怎么执行判定的”——这类规则我在代码里都用简单标准按“判定牌亮出→生效/失效”流程做避免过度设计。6. 让作品拿得出手AI托管与存档复盘的落地技巧控制台版做到这里已经能玩了但演示时还有一个尴尬场景你是5人对局除了你其余四个玩家都要有人手动操作答辩现场四个人一起输入基本不可控。最常见的解法是给NPC加一个简单AI然后用存档机制保证对局的可复现性。这两个增强不复杂但能让作品从“能跑”变成“能演示”。6.1 给闲置角色塞一个AI规则简单就够用AI不需要懂得策略只需要按固定优先级决策。定义一个AIController实现和HumanController相同的PlayerController接口游戏每回合询问当前玩家的控制器来决定动作public interface PlayerController { Command decide(Player self, GameContext ctx); }人类控制器的decide从控制台读一行AI控制器的decide按下面的优先级返回Command体力不满且有桃返回“使用 桃”有杀且存在距离为1的敌人返回“出杀 目标编号”否则返回“结束”。这个AI连“闪”都不会主动用只有在被攻击结算时才由引擎询问手中是否有闪。逻辑简单但对手感影响明显对局节奏快、回合不卡壳足以应付答辩演示。AI的决策方法里所有合法性判断复用canUse不会做出“没杀却出杀”这种非法动作这是接口设计带来的直接收益。6.2 存档与复盘用固定种子加命令日志回放整局线下调试时最怕什么怕打到第6回合出现了bug却不知道这6回合是怎么一步步走过来的。存档机制做两层即可第一层是记录开局seed第二层是记录每个玩家的每个Command动作。重新启动游戏时传入同一seed加载同一牌序再顺序回放Command日志就能稳定再现那一局。public class ReplayRecorder { private final ListString log new ArrayList(); private final long seed; public ReplayRecorder(long seed) { this.seed seed; } public void record(Command cmd, String result) { log.add(cmd.getAction() cmd.getTarget() - result); } public void save(String filePath) throws IOException { Files.write(Paths.get(filePath), log, StandardCharsets.UTF_8); } }保存路径建议放在项目根目录的replay文件夹下文件名带上时间戳。复盘时读日志让引擎处于“回放模式”而不是“等待输入模式”每读取一条日志调用执行方法。实现半小时之内能完成但对答辩的价值很大当老师问“这个bug当时怎么排查的”你可以现场用固定seed把bug复现一遍比嘴上解释一百句都有说服力。我做这类控制台项目有一个习惯所有面向用户的输出都走同一个UI类从不直接println。这样不管是输出编码、日志记录还是回放模式下的静默打印都只需要改一个地方。这个习惯不是一开始就有是吃了两次乱码和一次日志丢失的亏才养成的。希望这篇笔记能帮你少走这几步弯路把时间省下来做真正重要的规则打磨。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑