Java实现三国杀核心框架:面向对象建模与有限状态机实战

发布时间:2026/10/25 15:27:20
Java实现三国杀核心框架:面向对象建模与有限状态机实战 简介本资源是一个基于Java实现的三国杀桌面卡牌游戏开源项目面向Java初学者与游戏开发爱好者提供从规则建模到核心逻辑落地的完整学习范例。项目已基本实现标准版25位武将、104张卡牌的全部技能机制涵盖面向对象设计如AbstractPlayer、AbstractCard等基类、回合流程控制P_State、P_Process等状态管理类及UI组件Panel_开头的界面类适合用于理解策略类游戏的架构分层与事件驱动编程。压缩包共606个文件含155个.java源码、243个.class字节码、190张图片资源png/jpg及配置文件xml、properties等整体大小6.39MB结构清晰便于按模块研读。目前已有1098人学习下载读者可直接编译运行、调试核心逻辑、扩展新武将或重构UI是实践Java工程能力与游戏开发思维的优质练手项目。1. 用 Java 从零手写一个可运行、可调试、可扩展的三国杀核心框架不是玩具 Demo而是能跑通身份局、摸牌出牌、判定结算的最小可行源文件集合你在网上搜“java写三国杀 源文件”大概率会撞上两类东西一类是 GitHub 上挂着“Sanguosha-Java”但最后更新在 2016 年、连 JDK 8 都跑不起来的半成品另一类是某培训机构打包出售的“完整项目”解压后发现全是Card.javaPlayer.java这种命名空壳没一行逻辑连main方法都得你自己补。这不是源文件——这是源文件的幻觉。真正能称为“源文件”的必须满足三个硬指标能编译通过javac、能启动主流程main、能走完一轮标准回合摸牌→出牌→弃牌且不抛NullPointerException。本文写的就是这样一个最小但完整的三国杀 Java 源文件集合它不渲染 UI不联网对战不支持神将技能但它把“身份识别→角色行动→卡牌效果→状态变更”这条主链路用纯 Java 对象模型和有限状态机FSM彻底跑通。适合 Java 初学者练手面向对象设计也适合面试前突击“如何用基础语法实现复杂业务规则”的建模能力——毕竟能把三国杀逻辑拆成if-else和enum的人写个电商订单状态机真不算难事。2. 从一张牌开始用 Java 类建模三国杀核心实体拒绝“万能 Card 类”玄学设计三国杀最常被新手踩的第一个坑就是试图用一个Card类扛下所有功能锦囊牌要存目标玩家装备牌要存装备栏位置延时类锦囊还要存判定区……结果Card类里塞了 15 个字段其中 12 个永远为null。这不是面向对象这是面向字段堆砌。真正的做法是按行为契约而非外观形态划分类型。2.1 用枚举定义卡牌本质而不是字符串硬编码// CardType.java public enum CardType { BASIC, // 杀、闪、桃 EQUIPMENT, // 武器、防具、坐骑1/-1 POTION, // 无中生有、桃园结义等 JUDGMENT, // 乐不思蜀、兵粮寸断需判定 DELAYED // 延时类锦囊乐、兵、闪电继承自 JUDGMENT }提示这里不用String type是因为——类型判断会散落在Player.playCard()、GameEngine.resolveJudgment()、CardEffect.apply()等至少 7 个方法里。用enum能让switch (card.getType())编译期检查漏分支比if (basic.equals(type))少 3 个 NPE 风险点。2.2 分层建模基础卡牌 → 具体卡牌 → 可执行效果// Card.java - 抽象基类只存不可变属性 public abstract class Card { protected final String name; // 杀、闪、诸葛连弩 protected final CardType type; protected final int cost; // 使用所需手牌数杀1无中1桃园1 public Card(String name, CardType type, int cost) { this.name name; this.type type; this.cost cost; } public abstract void execute(GameContext context, Player caster, ListPlayer targets); } // BasicCard.java - 继承 Card专注“杀/闪/桃”三类基础行为 public class BasicCard extends Card { public enum BasicKind { KILL, DODGE, PEACH } private final BasicKind kind; public BasicCard(String name, BasicKind kind) { super(name, CardType.BASIC, 1); this.kind kind; } Override public void execute(GameContext context, Player caster, ListPlayer targets) { switch (kind) { case KILL: if (targets.size() ! 1) throw new IllegalArgumentException(杀只能指定1名目标); context.damage(caster, targets.get(0), 1); // 执行伤害 break; case DODGE: context.addDodge(caster); // 记录闪使用供后续判定 break; case PEACH: if (targets.isEmpty()) throw new IllegalArgumentException(桃必须指定目标); context.heal(targets.get(0), 1); break; } } }参数说明GameContext是全局状态容器含玩家列表、牌堆、弃牌堆、当前回合玩家避免把状态塞进Card或Player导致循环依赖caster是出牌者targets是显式指定的目标列表如“杀”选1人“桃园”选3人强制约束操作合法性execute()不做任何 UI 或输入处理——那是上层 Controller 的事这里只管“规则生效”。2.3 玩家与身份用组合而非继承避免“主公/反贼/内奸”爆炸式子类// Identity.java public enum Identity { LORD(主公, 1), // 主公初始体力4死亡即游戏结束 REBEL(反贼, 2), // 反贼初始体力3杀死主公获胜 TRAITOR(内奸, 3), // 内奸初始体力3先杀反贼再杀主公 LOYALIST(忠臣, 4); // 忠臣初始体力3保护主公获胜 private final String desc; private final int priority; // 身份优先级用于判定胜利条件 Identity(String desc, int priority) { this.desc desc; this.priority priority; } } // Player.java - 身份只是属性不是类型 public class Player { private final String name; private int hp; // 当前体力 private final int maxHp; // 最大体力主公4其余3 private final Identity identity; // 身份枚举 private final ListCard handCards new ArrayList(); // 手牌 private final ListCard equipment new ArrayList(); // 装备区 public Player(String name, Identity identity) { this.name name; this.identity identity; this.maxHp identity Identity.LORD ? 4 : 3; this.hp this.maxHp; } // 关键方法身份判定不靠 instanceof而靠枚举值比较 public boolean isEnemyOf(Player other) { return (this.identity Identity.LORD other.identity Identity.REBEL) || (this.identity Identity.REBEL other.identity Identity.LORD) || (this.identity Identity.TRAITOR other.identity ! Identity.LORD) || (this.identity Identity.LOYALIST other.identity ! Identity.LORD); } }为什么不用继承如果写class LordPlayer extends Player那GameEngine.checkWinCondition()就得写if (p instanceof LordPlayer)—— 这违反开闭原则。而用enum 方法新增身份比如“野心家”只需加枚举值和调整isEnemyOf()逻辑无需改类结构。3. 把回合制跑起来用状态机驱动游戏流程拒绝 while(true) flag 硬编码很多“三国杀 Java 实现”卡在“怎么让玩家轮流行动”这一步最终写出while(gameRunning) { player1.turn(); player2.turn(); ... }这种脆弱代码——一旦加个“跳过出牌阶段”或“闪电判定失败重入判定阶段”整个循环就崩。正确解法是显式定义游戏状态并用状态迁移驱动流程。3.1 定义核心游戏状态枚举// GameState.java public enum GameState { SETUP, // 初始化发牌、设身份、定先手 DRAW_PHASE, // 摸牌阶段摸2张 PLAY_PHASE, // 出牌阶段可出任意张合法牌 DISCARD_PHASE, // 弃牌阶段手牌数 体力值时弃至等于体力 JUDGMENT_PHASE, // 判定阶段处理延时类锦囊 END_PHASE, // 结束阶段清理标记、切换玩家 GAME_OVER // 游戏结束检查胜利条件 }3.2 状态机引擎用 Map当前状态, Map事件, 下一状态 驱动// GameEngine.java - 核心状态流转控制器 public class GameEngine { private GameState currentState GameState.SETUP; private final Player currentPlayer; private final ListPlayer allPlayers; private final GameContext context; // 状态迁移表currentState → (event → nextState) private final MapGameState, MapGameEvent, GameState stateTransitions buildTransitionTable(); private MapGameState, MapGameEvent, GameState buildTransitionTable() { MapGameState, MapGameEvent, GameState table new HashMap(); // SETUP → DRAW_PHASE初始化完成后进入摸牌 table.put(GameState.SETUP, Map.of(GameEvent.INIT_COMPLETE, GameState.DRAW_PHASE)); // DRAW_PHASE → PLAY_PHASE摸牌完成 table.put(GameState.DRAW_PHASE, Map.of(GameEvent.DRAW_COMPLETE, GameState.PLAY_PHASE)); // PLAY_PHASE → DISCARD_PHASE出牌阶段结束用户主动结束或无牌可出 table.put(GameState.PLAY_PHASE, Map.of( GameEvent.PLAY_END, GameState.DISCARD_PHASE, GameEvent.SKIP_PLAY, GameState.DISCARD_PHASE )); // DISCARD_PHASE → JUDGMENT_PHASE弃牌完成 table.put(GameState.DISCARD_PHASE, Map.of(GameEvent.DISCARD_COMPLETE, GameState.JUDGMENT_PHASE)); // JUDGMENT_PHASE → END_PHASE判定全部处理完毕 table.put(GameState.JUDGMENT_PHASE, Map.of(GameEvent.JUDGMENT_DONE, GameState.END_PHASE)); // END_PHASE → DRAW_PHASE下一位玩家或 GAME_OVER table.put(GameState.END_PHASE, Map.of( GameEvent.NEXT_PLAYER, GameState.DRAW_PHASE, GameEvent.GAME_END, GameState.GAME_OVER )); return table; } public void triggerEvent(GameEvent event) { MapGameEvent, GameState nextStates stateTransitions.get(currentState); if (nextStates null || !nextStates.containsKey(event)) { throw new IllegalStateException(非法事件 event 在状态 currentState); } GameState nextState nextStates.get(event); // 执行状态迁移前的副作用如记录日志、触发回调 onStateExit(currentState); currentState nextState; onStateEnter(currentState); } private void onStateEnter(GameState state) { switch (state) { case DRAW_PHASE: currentPlayer.drawCards(2); break; case PLAY_PHASE: // 启动玩家交互此处应调用 UI 或 CLI 输入解析 System.out.println(currentPlayer.getName() 进入出牌阶段); break; case DISCARD_PHASE: int excess currentPlayer.getHandCards().size() - currentPlayer.getHp(); if (excess 0) { System.out.println(currentPlayer.getName() 需弃掉 excess 张牌); // 实际弃牌逻辑由 Player.discard() 完成 } break; } } }关键设计点GameEvent是另一个枚举DRAW_COMPLETE,PLAY_END,JUDGMENT_DONE…所有状态变更必须通过triggerEvent()显式触发杜绝隐式状态修改onStateEnter()中只做该状态必须发生的副作用如摸牌、打印提示具体业务逻辑如“杀是否命中”仍由Card.execute()处理职责分离清晰状态迁移表stateTransitions是可配置的——未来加“南蛮入侵”导致跳过出牌阶段只需在PLAY_PHASE的映射里加一条GameEvent.NAN_MAN_INVADE → DISCARD_PHASE无需改while循环。4. 避坑Java 写三国杀必踩的 4 个血泪经验第 3 条让 90% 的初学者当场翻车写这个项目时我重写了 3 次核心循环才把下面这些坑踩全。它们不写在教科书里但每一条都足以让一个“能跑通 Hello World”的 Java 新手在Player.playCard()里 debug 一整天。4.1 坑手牌列表用ArrayListCard存却在出牌时用remove(0)删除——导致索引错乱闪永远挡不住杀现象玩家 A 手牌为[杀, 闪, 桃]A 对 B 出杀B 选择出闪但B.handCards.remove(0)删除了杀而不是闪B 依然受伤。原因remove(int index)删除的是索引位置不是对象内容。B 的手牌列表里闪在索引 1但代码误删索引 0。解决出牌时传入的是Card对象引用如player.playCard(闪)删除必须用remove(Object o)public void playCard(Card card) { if (!handCards.contains(card)) { throw new IllegalArgumentException(手牌中不存在此牌); } handCards.remove(card); // 注意这里是 remove(Object)不是 remove(int) card.execute(context, this, targets); }更安全的做法用Iterator或removeIf()避免并发修改异常。4.2 坑用static Random生成判定——多线程下所有玩家判定结果完全一致乐不思蜀永远判红现象两个玩家同时触发“乐不思蜀”判定结果都是true红牌本该独立判定却同步了。原因static Random是单例nextInt()返回值依赖内部种子多线程调用时种子被争抢导致伪随机序列坍塌。解决每个GameContext持有一个ThreadLocalRandompublic class GameContext { private static final ThreadLocalRandom RANDOM ThreadLocal.withInitial(Random::new); public boolean drawRedCard() { return RANDOM.get().nextInt(2) 0; // 简化版50% 红 } }或直接用SecureRandom虽慢但线程安全避免Random的种子竞争。4.3 坑延时类锦囊乐/兵/闪电存在判定区但用ListCard存储——导致“闪电”判定失败时无法定位到是哪张闪电炸了谁现象场上同时有 2 张“闪电”A 判定失败B 判定成功但程序不知道该对 A 造成 3 点雷电伤害。原因ListCard只存卡牌对象没存“这张闪电属于谁”、“这张闪电判定目标是谁”。判定区成了黑匣子。解决创建DelayedCard包装类绑定施放者、目标、判定结果public class DelayedCard { private final Card card; // 原始卡牌闪电 private final Player owner; // 施放者谁打出的 private final Player target; // 判定目标乐不思蜀的目标 private Boolean judgmentResult; // 判定结果null未判true成功false失败 public DelayedCard(Card card, Player owner, Player target) { this.card card; this.owner owner; this.target target; } }判定区存ListDelayedCardGameEngine.resolveJudgment()时遍历并填充judgmentResult再根据target和judgmentResult执行效果。4.4 坑用比较卡牌对象——导致两张“杀”被视为不同牌技能“杀1”永远不触发现象玩家装备“诸葛连弩”本该每轮可出 2 张杀但程序认为new BasicCard(杀, KILL)和new BasicCard(杀, KILL)是不同对象card.getName().equals(杀)才返回 true。原因比较引用地址equals()默认也是引用比较。解决在Card基类重写equals()和hashCode()基于name和typeOverride public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Card card (Card) o; return Objects.equals(name, card.name) type card.type; } Override public int hashCode() { return Objects.hash(name, type); }或更彻底用enum定义所有卡牌KILL,DODGE,PEACH,LIGHTNING…避免new实例直接用Card.KILL单例。5. 让源文件真正“可运行”用 Maven 构建最小可执行 Jar附带 CLI 交互式测试入口光有.java文件不算源文件——它得能一键编译、一键运行、一键验证逻辑。本节给出一个零依赖、纯 JDK 11 可跑的构建方案不碰 Spring、不配 Tomcat就用最原始的javacjarMain。5.1 目录结构与 Maven pom.xml 最小配置sanguosha-core/ ├── pom.xml ├── src/ │ └── main/ │ ├── java/ │ │ └── com/example/sanguosha/ │ │ ├── core/ // Card, Player, GameState... │ │ └── cli/ // CLI 交互入口 │ └── resources/ └── README.mdpom.xml关键配置仅保留必要插件project xmlnshttp://maven.apache.org/POM/4.0.0 modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdsanguosha-core/artifactId version1.0.0/version packagingjar/packaging properties maven.compiler.source11/maven.compiler.source maven.compiler.target11/maven.compiler.target /properties build plugins !-- 编译插件 -- plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.11.0/version /plugin !-- 打包成可执行 jar -- plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.4.1/version executions execution phasepackage/phase goalsgoalshade/goal/goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.example.sanguosha.cli.CliLauncher/mainClass /transformer /transformers /configuration /execution /executions /plugin /plugins /build /project5.2 CLI 交互入口用 Scanner 实现最简文本交互验证核心流程// CliLauncher.java public class CliLauncher { public static void main(String[] args) { // 1. 初始化游戏3玩家主公、反贼、忠臣 GameContext context new GameContext(); Player lord new Player(刘备, Identity.LORD); Player rebel new Player(吕布, Identity.REBEL); Player loyalist new Player(关羽, Identity.LOYALIST); context.addPlayer(lord); context.addPlayer(rebel); context.addPlayer(loyalist); // 2. 发牌简化每人发3张基础牌 lord.addCard(new BasicCard(杀, BasicCard.BasicKind.KILL)); lord.addCard(new BasicCard(桃, BasicCard.BasicKind.PEACH)); lord.addCard(new EquipmentCard(诸葛连弩, EquipmentCard.EquipType.WEAPON)); rebel.addCard(new BasicCard(杀, BasicCard.BasicKind.KILL)); rebel.addCard(new BasicCard(闪, BasicCard.BasicKind.DODGE)); rebel.addCard(new BasicCard(杀, BasicCard.BasicKind.KILL)); // 3. 启动游戏引擎 GameEngine engine new GameEngine(context, lord); Scanner scanner new Scanner(System.in); System.out.println( 三国杀 Java 核心引擎启动 ); System.out.println(输入 play kill 对当前目标出杀end 结束阶段); while (engine.getCurrentState() ! GameState.GAME_OVER) { System.out.print( ); String input scanner.nextLine().trim(); if (input.equalsIgnoreCase(end)) { engine.triggerEvent(GameEvent.PLAY_END); } else if (input.startsWith(play )) { String cardName input.substring(5).trim(); Player currentPlayer engine.getCurrentPlayer(); Card cardToPlay findCardByName(currentPlayer, cardName); if (cardToPlay ! null) { try { currentPlayer.playCard(cardToPlay); System.out.println(✓ 成功打出 cardName); } catch (Exception e) { System.out.println(✗ 执行失败: e.getMessage()); } } else { System.out.println(✗ 手牌中无此牌: cardName); } } } System.out.println(游戏结束。); } private static Card findCardByName(Player player, String name) { return player.getHandCards().stream() .filter(c - c.getName().equalsIgnoreCase(name)) .findFirst() .orElse(null); } }验证步骤终端执行# 1. 编译打包 mvn clean package # 2. 运行可执行 jar java -jar target/sanguosha-core-1.0.0.jar # 3. 交互测试 play kill ✓ 成功打出 杀 end 刘备 进入弃牌阶段 参数说明maven-shade-plugin打包时自动合并所有依赖本项目无外部依赖但留着以防后续加 log4jManifestResourceTransformer指定CliLauncher为 Main-Classjava -jar直接运行CLI 逻辑极简但覆盖了playCard()、triggerEvent()、state transition全链路是验证源文件可用性的黄金路径。6. 进阶技巧用 Java 8 Stream 重构判定逻辑3 行代码搞定“桃园结义”群体回血以及我坚持写的 2 个开发习惯当你的GameEngine.resolveJudgment()方法长达 80 行、嵌套 4 层if时是时候祭出 Stream API 了。它不只为炫技而是把“对一组玩家执行相同效果”这种模式从面条代码变成声明式表达——而且Stream 的peek()和collect()能天然捕获中间状态方便你加日志、做断点、甚至 mock 测试。6.1 用 Stream 重构“桃园结义”从 12 行 for 循环到 3 行函数式表达传统写法易错、难读、难测// 错误示范手动遍历 状态修改 public void applyPeachGarden(Player caster, ListPlayer targets) { for (Player p : targets) { if (p.getHp() p.getMaxHp()) { p.setHp(p.getHp() 1); System.out.println(p.getName() 回复 1 点体力); } else { System.out.println(p.getName() 体力已满桃园无效); } } }Stream 重构声明意图、自动空安全、可链式调试// 正确写法用 filter-map-peek-collect 表达意图 public void applyPeachGarden(Player caster, ListPlayer targets) { targets.stream() .filter(p - p.getHp() p.getMaxHp()) // 筛选可回血玩家 .peek(p - System.out.println(p.getName() 回复 1 点体力)) // 日志副作用 .forEach(p - p.heal(1)); // 执行回血Player.heal() 已封装边界检查 }为什么更可靠filter()自动跳过null元素targets若含null传统for会 NPEStream 会跳过peek()日志可随时注释掉不影响逻辑而System.out.println()在for循环里删起来总怕删错行forEach()里的p.heal(1)是 Player 的封装方法内部已做hp Math.min(hp 1, maxHp)比裸setHp()安全。6.2 用 Collector 做“伤害统计”一行代码生成战斗报告想看“这一轮谁造成了最多伤害”别再写MapPlayer, Integer手动累加。用Collectors.groupingByCollectors.summingInt// 在 GameContext 中记录每次伤害 private final ListDamageRecord damageHistory new ArrayList(); public void damage(Player from, Player to, int amount) { damageHistory.add(new DamageRecord(from, to, amount)); to.setHp(to.getHp() - amount); } // 生成伤害统计报告 public MapPlayer, Integer getDamageReport() { return damageHistory.stream() .collect(Collectors.groupingBy( DamageRecord::getAttacker, // 按攻击者分组 Collectors.summingInt(DamageRecord::getAmount) // 求伤害总和 )); }调用示例MapPlayer, Integer report context.getDamageReport(); report.forEach((player, total) - System.out.println(player.getName() 造成 total 点伤害) ); // 输出吕布 造成 3 点伤害刘备 造成 1 点伤害6.3 我坚持写的 2 个开发习惯让三国杀源文件经得起半年后回看习惯 1每个Card子类的execute()方法第一行必须写Preconditions.checkNotNull(context)理由GameContext是全局状态中枢一旦为null后续所有damage()、heal()都会 NPE。用 Guava 的Preconditions或 JDK 19 的Objects.requireNonNull提前崩溃比在context.getPlayerById()里抛NullPointerException更易定位。Override public void execute(GameContext context, Player caster, ListPlayer targets) { Objects.requireNonNull(context, GameContext must not be null); // 后续逻辑... }习惯 2所有玩家交互输入统一走InputParser接口绝不裸写scanner.nextLine()理由CLI 测试、JUnit Mock、未来加 GUI都只需替换InputParser实现。当前 CLI 版本public interface InputParser { ParsedCommand parse(String rawInput); } public class CliInputParser implements InputParser { Override public ParsedCommand parse(String rawInput) { String[] parts rawInput.trim().split(\\s, 2); return new ParsedCommand(parts[0], parts.length 1 ? parts[1] : ); } }这样CliLauncher里scanner.nextLine()只负责读字符串解析逻辑全在InputParser单元测试时直接new CliInputParser().parse(play kill)即可。写完这个项目我删掉了所有“为了设计模式而设计”的工厂、代理、装饰器——真正的工程能力不是你会多少模式而是你知道什么时候该用enum而不是if-else什么时候该用Stream而不是for什么时候该用Preconditions而不是沉默的null检查。这 12 个.java文件组成的源文件集合没有一行代码是多余的每一处Override、每一个enum、每一次Objects.requireNonNull都是从翻车现场捡回来的后悔药。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询