news 2026/10/27 6:34:31

基于Java控制台版三国杀毕业设计:面向对象、状态机与事件队列实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Java控制台版三国杀毕业设计:面向对象、状态机与事件队列实战

简介:这是一份以Java三国杀游戏控制台版本为核心的毕业设计资源,适合正在学习Java编程、准备课程设计或毕业设计的开发者。项目完整演示了如何在命令行界面下实现三国杀的核心玩法,涵盖角色、技能、卡牌等要素的数据建模与流程控制,可帮助读者理解面向对象设计、多线程以及控制台交互等关键知识点。压缩包共69个文件,大小约19.9MB,主要内容包括10个java源文件、32个class编译产物、20个bak备份文件、3个cdk卡牌数据文件,以及一个avi格式的操作演示视频,便于对照源码查看运行效果。目前已有266人学习下载。借助源码、备份与演示,读者可快速梳理项目结构,掌握从游戏规则分析、数据表达到控制台输入验证的完整开发思路,也能为自己完成同类卡牌游戏项目提供可复用的参考。

1. Java毕业设计选控制台版三国杀:为什么它比Web增删改查更能打

很多同学看到“控制台版本”四个字,第一反应是“这也算毕业设计?”——没有图形界面、没有MySQL、没有Spring Boot那身行头,看起来不够炫。但我拆过几届人做的三国杀项目,最耐问的恰恰是这种没有界面遮羞的版本:身份怎么隐藏判断、回合怎么轮转、杀和闪的结算顺序是什么,全裸在键盘输入之上,答辩老师一问一个准,回答不上来基本没救。

这份资源适合两类人:一类是java基础刚走完集合和面向对象这一程、需要拿一个完整可运行的Java项目当课设或毕设的学生;另一类是准备java开发工程师面试、不想用图书管理系统凑数的人。控制台三国杀的代码量不大,但武将、手牌、牌堆、回合状态机和AI出牌全都能覆盖,信息密度比普通Web项目的增删改查高出一截。这篇笔记按我拆项目的习惯,把结构、关键实现、可复现步骤和踩坑点一次说清。

2. 对象建模先行:身份、武将、卡牌的结构设计与集合选型

2.1 为什么先定数据结构,而不是先写菜单

很多人拿到这个题目就开写,先做一个while(true)菜单,switch打印“1.开始游戏 2.读取存档 3.退出”,然后一路堆if。写到出牌阶段会发现自己根本管不住:杀要判断距离,桃要判断血量,锦囊要选目标,代码全都挤在一个方法里,改一个规则就要翻一大片。我一般建议反过来,先把“游戏里有哪几类对象,对象之间怎么发生关系”定死,再考虑交互。

控制台版三国杀核心就三样东西:身份、武将、卡牌。身份决定胜利条件和阵营关系;武将决定体力上限和一个技能;卡牌决定每个回合能做什么动作。这三件事不牵扯界面,是天然的面向对象建模素材,也是java基础里最常见的考点。对象建模梳理清楚了,后面的状态机和AI才站得住。

2.2 身份枚举与武将类:一版可以直接抄的落地代码

身份用枚举而不是int常量,这是我强烈建议的。用0、1、2、3代表主公忠臣反贼内奸,是远古写法,读代码时每次都要回头查注释;枚举自带类型安全,switch时分支清晰,IDE还能帮你检查漏了哪种身份。贴我常用的底座代码:

enum Identity { LORD(0), LOYALIST(1), REBEL(2), TRAITOR(3); private final int code; Identity(int code) { this.code = code; } public int getCode() { return code; } }
public class Hero { private String name; // 武将名,如曹操/刘备/关羽 private int maxHp; // 体力上限 private int hp; // 当前体力 private List<Card> handCards = new ArrayList<>(); public Hero(String name, int maxHp) { this.name = name; this.maxHp = maxHp; this.hp = maxHp; } public void takeDamage(int dmg) { this.hp = Math.max(0, this.hp - dmg); } public void recover(int heal) { this.hp = Math.min(maxHp, this.hp + heal); } }

代码逻辑不复杂,但有两个地方值得注意。第一,takeDamage里用Math.max(0, ...)把血量钳在0,而不是直接hp -= dmg,能省掉很多“死亡判定前先检查负数”的脏处理。第二,recover用Math.min(maxHp, ...)保证不超上限,这两个钳制函数是控制台游戏里最常用的边界保护,比到处写if判断简洁得多。

武将技能在控制台版里不需要做成策略接口那么重,我一般直接在Hero里加一个String skillName字段,结算时用switch判断武将名触发。这样扩展曹操奸雄、刘备仁德时改动集中在一处,答辩时也讲得清。

2.3 卡牌的类层次:abstract class 与 interface 的选择

卡牌是三国杀变化最密集的地方。杀、闪、桃是基本牌;万箭齐发、南蛮入侵、决斗是锦囊;诸葛连弩、八卦阵是装备。我写卡牌时习惯用abstract class做基类,而不是interface。原因很直接:杀闪桃有大量共性——名称、花色、点数、类型,interface里放字段很别扭;abstract class可以先把公共字段和构造函数写好,子类只负责实现canUse和resolve两个行为。

public abstract class Card { protected String name; protected String suit; // 花色:spade/heart/club/diamond protected int point; // 点数:1~13 protected String type; // 类型:basic/trick/equipment public Card(String name, String suit, int point, String type) { this.name = name; this.suit = suit; this.point = point; this.type = type; } // 能不能打出去:杀要看距离,桃要看血量,锦囊要看目标 public abstract boolean canUse(GameContext ctx, Player self); // 打出去之后产生什么效果,进入结算队列 public abstract void resolve(GameContext ctx, Player self, Player target); }
public class SlashCard extends Card { public SlashCard(String suit, int point) { super("杀", suit, point, "basic"); } @Override public boolean canUse(GameContext ctx, Player self) { // 简化版距离判断:只看自己到目标的座位距离 return ctx.getDistance(self, ctx.getSelectedTarget()) <= 1; } @Override public void resolve(GameContext ctx, Player self, Player target) { ctx.damage(self, target, this); // 进伤害结算,而不是直接扣血 } }

设计成这样之后,加一张新牌只需要新建一个类,写清楚canUse和resolve,不用改主循环。这套做法就是典型的面向对象编程java风格——扩展靠新增类,不靠改旧代码。控制台项目规模小,但这种习惯放到后续Spring项目里一样值钱。

2.4 集合选型:手牌、摸牌堆、弃牌堆的Java容器选择

三种牌的存放场景不同,容器选择也要分开说。手牌要按编号随机操作,偶尔在中间删除,用ArrayList<Card>是最稳的——get(下标)是常数时间,显示给玩家时按序号遍历也方便。摸牌堆只需要头尾操作:摸牌是removeFirst,洗牌是把所有牌重新shuffle,用ArrayDeque<Card>合适。弃牌堆只追加、很少查询,用ArrayList<Card>即可,等需要重新洗牌时把弃牌堆倒进摸牌堆再shuffle。

容器推荐场景理由注意点
ArrayList玩家手牌随机访问快、遍历简单删除时用Iterator或倒序遍历
ArrayDeque摸牌堆头尾进出高效牌用完要合并弃牌堆再shuffle
ArrayList弃牌堆只追加、偶尔清空洗牌前要清空

这里有一个java八股文必考的点:for (int i = 0; i < list.size(); i++) list.remove(i)是错的,因为remove后元素整体左移,会跳过相邻元素、最后还可能越界。我在这类项目里统一用Iterator.remove()或者倒序遍历。这个坑细节放在第5章的避坑段落里集中说。

3. 回合状态机与结算队列:让乱序出牌有秩序

3.1 六个阶段的状态转换:准备、判定、摸牌、出牌、弃牌、结束

三国杀的核心节奏不是“轮到你就出牌”,而是一条完整回合链:准备阶段、判定阶段、摸牌阶段、出牌阶段、弃牌阶段、结束阶段。控制台版可以简化,但我不建议砍到只剩摸牌-出牌-弃牌三段,因为判定阶段是以后扩展乐不思蜀、闪电这些延时锦囊的唯一入口。现在砍了,后期加功能就是重构而非加代码。

实现上最好的方式是用枚举定义阶段,再写一个executePhase方法负责推进。别用一个大while套一串boolean标志位,那个状态多起来自己都绕晕。

enum Phase { START, JUDGE, DRAW, PLAY, DISCARD, END }
public Phase executePhase(Phase phase, Player cur, GameContext ctx) { switch (phase) { case START: return Phase.JUDGE; case JUDGE: // 回合开始时有判定区的延时锦囊才需要结算 // 控制台版初期没有乐不思蜀,可以直接跳过 return Phase.DRAW; case DRAW: ctx.draw(cur, 2); // 摸两张牌 return Phase.PLAY; case PLAY: ctx.playCards(cur); // 玩家循环输入use/discard/end return Phase.DISCARD; case DISCARD: ctx.discardToLimit(cur, cur.getHp()); // 弃到体力上限 return Phase.END; default: return Phase.END; } }

这个方法写得对称、短小,每个阶段干什么一眼能看清。要特别注意我特意写成经典switch而不是java高版本的switch表达式,因为毕业设计机房的JDK版本大概率还是8,写->箭头语法在旧环境直接编译失败。这种环境兼容问题我在第5章避坑里还会提一次。

3.2 伤害与濒死:用事件队列接住链式反应

三国杀最容易写崩的不是回合流程,而是伤害结算。杀打出去,对方可能出闪;没闪掉血,血量归零进入濒死;濒死要求桃,有人出了桃救回来;救回来之后如果受伤的是曹操还有“奸雄”技能要拿伤害牌。这一串连锁反应如果写成方法套方法,比如damage()里调checkDying(),checkDying()里调askForPeach(),askForPeach()里又可能调damage(),深度一深就会栈溢出。

我吃过这个亏。第一次把所有结算写成递归调用,测到曹操和反贼对砍时直接StackOverflowError,控制台打出一长串红色堆栈,那叫一个难看。后来老老实实改成事件队列:所有结算都变成事件放进ArrayDeque,程序循环从队列头部取事件、执行、再取下一个。链式反应只会往队列尾部追加新事件,不会加深调用栈。

public class GameContext { private Deque<GameEvent> settleQueue = new ArrayDeque<>(); // 对外只提供入口,内部循环消费队列 public void damage(Player from, Player to, Card card) { settleQueue.offer(new DamageEvent(from, to, card)); while (!settleQueue.isEmpty()) { GameEvent evt = settleQueue.poll(); evt.resolve(this); } } }
public class DamageEvent implements GameEvent { private Player from; private Player to; private Card source; @Override public void resolve(GameContext ctx) { to.takeDamage(source.getDamagePoint()); // 血量归零进入濒死,濒死也是事件,继续往队列丢 if (to.getHp() <= 0) { ctx.settleQueue.offer(new DyingEvent(to, source)); return; } // 没死但有技能的,比如曹操受到伤害后触发奸雄 if (source != null && "曹操".equals(to.getSkillName())) { to.getHandCards().add(source); } } }

这段代码的要点是:damage()方法本身不做伤害逻辑,只负责往队列里放事件然后启动消费循环。DyingEvent处理濒死求桃,求桃失败就触发死亡判定,死亡判定又可能触发奖惩摸牌,全都以事件形式排队,不会出现A调B、B调A的死循环。

3.3 事件顺序排错:为什么“先入队”比“先执行”安全

事件队列有个容易被忽略的细节:offer是入队尾,poll是出队头,天然先进先出,这正好符合三国杀的结算直觉——先造成的伤害先生效,濒死事件排在下一个处理。如果你用push往栈顶塞,就会变成后进先出,先求桃后结算伤害,顺序完全反了。

我一般给事件实现一个getPriority()也做过,但控制台版本根本用不上那么复杂的优先级,直接在队列头尾操作就够。记住一条规则:所有会造成连锁反应的操作,比如扣血、弃牌、摸牌、翻面,都走settleQueue,别直接调对应方法。这样哪怕后续加再多的技能,主流程代码都不用动。

4. 控制台交互协议与AI出牌:机器对手怎么撑住一局

4.1 一套能说清的输入协议:show / use / discard / end

控制台交互的关键是“程序要能明确读懂玩家每一步动作”。我定义了一套最简单的指令协议:show查看当前局面、use 手牌编号 目标编号出牌、discard 手牌编号弃牌、end结束出牌阶段、help查看帮助。所有输入统一按行读取,再按空格分割,绝不混用nextInt和nextLine,原因是nextInt不消费换行符,残留的回车会被下一个nextLine直接吃掉,导致输入“跳读”——这个坑第5章细说。

public void runConsole(Game game) { Scanner sc = new Scanner(System.in); while (game.isRunning()) { Player cur = game.getCurrentPlayer(); System.out.print(cur.getName() + " > "); String line = sc.nextLine().trim(); if (line.isEmpty()) { continue; } String[] parts = line.split("\\s+"); try { switch (parts[0]) { case "show": game.printTable(cur); break; case "use": int cardIdx = Integer.parseInt(parts[1]); int targetIdx = parts.length > 2 ? Integer.parseInt(parts[2]) : -1; game.useCard(cur, cardIdx, targetIdx); break; case "discard": game.discardCard(cur, Integer.parseInt(parts[1])); break; case "end": return; default: System.out.println("未知指令,输入 help 查看说明"); } } catch (NumberFormatException e) { System.out.println("指令格式:use 手牌编号 目标编号 或 end"); } } }

注意我这里用了try-catch包住Integer.parseInt,而不是让数字转换异常直接打断游戏。控制台版本没有图形界面,玩家输错一个字符就崩,体验非常糟糕。包一层异常捕获,至少保证下一条指令还能继续录入,这在答辩演示时能救你一命。

4.2 让AI撑住对局:一个简单但不犯傻的贪心出牌器

很多人以为控制台版三国杀不需要AI,热座模式两个人轮流输入就行。但毕业设计要演示一整套流程,你总得有个机器对手,不然只能自己在答辩现场左手打右手。控制台版的AI不需要神经网络,也不需要蒙特卡洛,一个贪心策略就够了——有杀先出杀、血少先吃桃、顺手牵羊拆装备。

public class SimpleAI { // 返回手牌下标,返回-1表示没有想出的牌 public int decide(Game game, Player self) { List<Card> hand = self.getHandCards(); for (int i = 0; i < hand.size(); i++) { Card c = hand.get(i); if (c instanceof SlashCard && game.hasTargetInRange(self)) { return i; // 距离内有敌人,先杀 } if (c instanceof PeachCard && self.getHp() < self.getMaxHp()) { return i; // 血量不满,吃桃续命 } if (c instanceof DodgeCard && game.isUnderAttack(self)) { return i; // 被决斗或万箭威胁时留着闪 } } return -1; // 没有值得出的牌,跳过出牌阶段 } }

这个AI的判断顺序是按“威胁程度”排的,杀>桃>闪,符合直觉。核心设计是decide只返回手牌下标,不直接调用出牌接口,这样AI的逻辑和玩家输入走同一条game.useCard()路径,不会出现“AI出的牌违反规则”这种翻车场面。如果你想加点难度,就把“保留锦囊牌”的权重调高,让AI宁可不出杀也要憋着决斗,但控制台版没必要做到那一步。

4.3 热座模式与单人调试的切换

热座模式实现极简单:同一进程里,每回合结束后playerIndex = (playerIndex + 1) % playerCount,轮流从同一个Scanner读输入。不需要网络、不需要多线程,这个方案在毕业设计里完全够用。

调试时我习惯给每个玩家加一个isAI字段,手动把1号玩家设为AI,自己控制0号玩家,这样不用开虚拟机也能把武将技能挨个测一遍。配合第6章的固定随机种子,AI出牌可以被精确复现,这是后话。

5. 避坑/常见问题:四个 java 控制台项目绕不开的翻车点

5.1 控制台中文乱码:方框还是问号

现象:程序编译通过,启动后所有“杀”“闪”“桃”都显示成方框乱码或者??,log里打印武将名全是问号。

原因:绝大多数情况是源码文件是UTF-8编码,而Windows控制台默认GBK编码,System.out输出时按平台默认编码转码,UTF-8字节流被当GBK解读,中文直接乱套。这和java环境变量配置没关系,是JVM运行时file.encoding在作怪。

解决:第一步,IDE里把项目编码统一成UTF-8;第二步,运行时显式指定编码,java -Dfile.encoding=UTF-8 -jar sanguosha.jar;第三步,如果还是在cmd窗口乱码,先执行chcp 65001切到UTF-8代码页再启动程序。三条按顺序来,九成乱码能解。我在多个JDK版本下试过,JDK8到JDK17都能用这套解决。

5.2 Scanner输入跳读:回车键被“吃掉”

现象:菜单第一轮输入正常,第二轮程序完全不等输入,直接往下跑;或者输入use 1 2之后,下一条指令还没打就自动执行了。

原因:这就是nextInt()和nextLine()混用的经典翻车。nextInt()只读取数字,不消费后面的换行符\n,这个字符留在缓冲区里;下一行代码调用nextLine()时读到的就是空字符串,于是程序认为你什么都没输入,直接跳过。跟网络无关、跟JDK版本无关,这是java基础里最容易忽略的Scanner细节。

解决:整个项目统一用nextLine()读取,再用Integer.parseInt()把字符串转数字,如第4章的runConsole代码。如果实在要用nextInt(),必须在它后面补一句sc.nextLine()把残留换行吃掉。另外切记全程只用一个Scanner实例,不要在AI线程或结算线程里再new一个Scanner读同一个System.in,两个Scanner抢同一个输入流会导致输入行为完全随机,这种玄学问题极难排查。

5.3 手牌删除丢牌或越界:ArrayList删除的连锁反应

现象:手牌显示10张,弃一张后下一张消失;或者输入discard 5报IndexOutOfBoundsException,但列表里明明有好几张牌。

原因:正向for循环删除元素时,ArrayList内部会移动后续元素,i++之后位置已经错位,跳过了原本相邻的那张牌。还有一种常见情况是“显示编号”和“集合下标”没对齐——你给玩家显示的是从1开始的编号,程序里直接用list.get(编号),编号比下标大1,取到最后一张时必然越界。

解决:第一,给玩家看的编号统一i + 1,内部换算时记得cardIdx - 1;第二,删除元素用Iterator.remove()或者倒序遍历;第三,每次操作前基于当前list.size()重新生成显示编号,不要缓存一份旧的手牌快照。这三条记住,手牌相关的bug能少一大半。

5.4 伤害结算递归爆栈:StackOverflowError在中期爆发

现象:游戏前几轮正常,打到中盘武将残血时,控制台突然刷出一屏at com.xxx.DamageEvent.resolve(...),程序直接退出。

原因:第一版如果把伤害、濒死、技能结算写成方法互相调用,比如damage()里调checkDying(),求桃里又调damage(),一旦两个武将技能互相触发,调用链无限加深,JVM栈空间很快耗尽。这不是运气问题,是设计问题。

解决:所有结算进事件队列,用循环消费,禁止在resolve()里直接调用另一个resolve()。第3章的settleQueue写法就是为这条准备的。另外排查这类问题有快速手段:在启动参数加-Xss512k人为缩小栈,能更快复现递归过深,但根治还是要靠队列。

6. 调试与验证:固定牌堆种子和指令录像让你每局都能复盘

6.1 固定随机种子:让同一局牌可以无限重放

控制台版三国杀的所有不确定性来自牌堆洗牌。Collections.shuffle()不传随机源时用的种子不可控,每次运行摸到的牌都不一样,这就导致调试时无法复现上一局的“运气好差”。解决办法是给shuffle显式传一个固定种子:

List<Card> pile = new ArrayList<>(cardList); Collections.shuffle(pile, new Random(20240607L));

用固定种子初始化的Random对象,只要洗牌前牌堆内容一致,洗出来的顺序就完全一致。答辩演示前,你可以在真机上跑几局找出一个“适合表演”的种子号,比如某个种子下主公前两轮就能摸到连弩,演示效果会稳定很多。这个技巧是调试游戏的后悔药,比反复重开找牌运强得多。

6.2 指令录像与debug开关:把每局牌变成可回看的日志实验

我习惯在输入循环外加一层“录像器”:每收到一行玩家输入,同时写入一个record.txt文件。回放时用new Scanner(new File("record.txt"))替代System.in,就能原样重放上一局。配合deBug开关,在GameContext里加一个静态boolean字段,为true时每个事件resolve前打印一行[结算] 伤害事件 from=曹操 to=反贼,这样哪里算错一眼定位。

身份胜利条件验证断言
主公反贼和内奸全部阵亡assert winner == Identity.LORD
忠臣主公获胜即胜利assert winner == Identity.LORD && alive(player)
反贼主公阵亡assert game.isLordDead()
内奸主公阵亡且全场只剩自己assert aliveCount == 1 && lordDead

这套验证表是答辩时最好用的东西。四类身份各打一遍录像,把断言贴在答辩文档里,比空口说“我测过了”有力得多。从那以后我每次写完一个模块,都强制自己走一遍固定种子录像回放,再也不敢靠肉眼盯一场牌局。这个习惯在Web后端项目里同样适用——把线上请求录下来回放,就是最朴素的分支回归测试。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 6:34:19

ipmitool实战指南:物理服务器带外管理核心技能

1. 为什么一个二十年老工具还在服务器运维一线高频出现&#xff1f;你可能在某次深夜处理一台宕机的物理服务器时&#xff0c;被值班同事甩来一句&#xff1a;“快&#xff0c;用 ipmitool 看下 BMC 日志&#xff01;”——那一刻&#xff0c;你大概率会愣一下&#xff1a;BMC …

作者头像 李华
网站建设 2026/10/27 6:32:44

MCP协议实战:TypeScript构建大模型工具调用标准化链路

1. 从一根线缆的痛点到 MCP 的诞生逻辑如果你最近在折腾大模型应用开发&#xff0c;大概率已经被一个词反复刷屏&#xff1a;MCP。全称 Model Context Protocol&#xff0c;翻译过来叫“模型上下文协议”。名字听着挺唬人&#xff0c;但你可以先把它理解成一根“USB-C 线”——…

作者头像 李华
网站建设 2026/10/27 6:32:15

PyTorch实战:LSTM情感分析模型构建与应用

1. 项目概述&#xff1a;基于PyTorch的LSTM情感分析实战情感分析作为自然语言处理&#xff08;NLP&#xff09;领域的经典任务&#xff0c;在电商评论、社交媒体监测等场景中具有广泛应用价值。本项目将使用PyTorch框架构建一个LSTM&#xff08;长短期记忆网络&#xff09;模型…

作者头像 李华
网站建设 2026/10/27 6:32:12

PyTorch与LSTM情感分析实战:从数据到部署全流程

1. 项目概述&#xff1a;PyTorch与LSTM情感分析实战作为一名长期使用PyTorch进行NLP项目开发的从业者&#xff0c;我发现情感分析是检验深度学习入门者是否掌握核心技能的绝佳试金石。这个项目将带你从零开始构建一个基于LSTM的情感分析模型&#xff0c;不同于市面上大多数教程…

作者头像 李华
网站建设 2026/10/27 6:31:43

多智能体系统探活与生命周期管理实战指南

1. “不报错的故障”才是多智能体系统最危险的生产隐患你有没有遇到过这样的情况&#xff1a;整套多智能体系统在监控面板上绿得发亮——CPU、内存、网络延迟全在阈值内&#xff0c;健康检查接口返回200&#xff0c;日志里没有ERROR&#xff0c;甚至没有WARN&#xff1b;但业务…

作者头像 李华
网站建设 2026/10/27 6:31:33

拓数派云原生数据库与AI驱动的数据资产化实践

1. 项目概述&#xff1a;拓数派入选"金种子"企业的行业意义2023年初春&#xff0c;一则来自上海的消息在科技创投圈引发广泛关注——大数据与人工智能企业拓数派&#xff08;TopMath&#xff09;成功入选第九届世界浙商上海论坛"金种子"企业名单。这份由上…

作者头像 李华