☰
Java实现三国杀核心逻辑:状态机与事件驱动设计
2026/10/13 3:34:30 网站建设 项目流程

简介:这是一份基于Java实现的三国杀桌面卡牌游戏开源项目,面向Java初学者与游戏开发爱好者,帮助理解面向对象设计、游戏逻辑建模及复杂规则系统编码实践。资源包含606个文件,主体为155个.java源文件(定义武将、卡牌、玩家、流程控制等核心类)与243个.class字节码文件,辅以126个PNG和64个JPG资源图、7个XML配置及4个properties配置文件,整体压缩包仅6.39MB,轻量易读。已有1100人学习下载,适合用于课程设计、毕业设计参考或独立游戏开发练手。项目已基本实现标准版25名武将与104张牌的全部技能逻辑,代码结构清晰,含Panel_HandCards、AbstractPlayer、P_Action等典型模块,可直接编译运行并调试核心游戏循环,是深入掌握Java事件驱动编程与状态机设计的优质实践样本。

1. 用 Java 从零搭起三国杀核心骨架:不是写个 Demo,而是跑通「身份判定→出牌响应→状态流转」闭环

你手头没有现成框架,只有一台装了 JDK 的电脑、一个空的src/目录,和一句“Java 写三国杀 源文件”的需求——这不是要复刻某款上线游戏,而是想亲手把「主公怎么赢」「反贼怎么集火」「锦囊牌为什么能打断装备」这些规则,变成可调试、可断点、可单元测试的 Java 对象与方法调用。我见过太多人卡在第一步:花三天写了 Card 类和 Player 类,结果发现“南蛮入侵”发出去后,没人知道该不该响应、谁有“闪”、响应完要不要继续轮到下家……问题不在代码能力,而在没先厘清状态机边界和事件驱动粒度。这篇笔记就是为你写的:不依赖 Spring、不套用 LibGDX、不接任何 UI 库,纯 JDK + JUnit,用 6 个核心类(GameContext,Player,Card,Phase,Action,Effect)撑起整套逻辑主干。适合刚学完集合与多态、想啃硬骨头的中级 Java 开发者,也适合带学生做课程设计的某高校导师——它不追求画面,但每行逻辑都经得起System.out.println()追踪,每个if都对应一条《三国杀》标准规则书第 X 条。


2. 从规则书里抠出 4 个不可妥协的抽象层:为什么 Card 不该继承 Player,而 Phase 必须是枚举

2.1 先扔掉“面向对象教科书式建模”的惯性:Card 和 Player 是协作关系,不是父子关系

很多初稿会写出class Shan extends Card或class ZhugeLiang extends Player,这在后期必然翻车。原因很简单:一张“闪”可以被任意玩家打出,一个玩家可以持有任意组合的牌;而“诸葛亮”这个角色只是初始手牌和技能描述不同,其行为逻辑(出牌、响应、弃牌)完全复用Player基类。真正需要继承的是技能类型(如Skill接口),而不是角色名。我们定义:

public interface Skill { boolean canTrigger(GameContext ctx, Player self, Player target); void execute(GameContext ctx, Player self, Player target); }

提示:GameContext是全局状态容器,存着当前回合玩家、剩余牌堆、弃牌堆、所有玩家列表等——它不是单例,而是每次 Action 执行时显式传入,确保逻辑可测试、无隐式状态。

2.2 Phase 必须用 enum,且每个枚举值自带 transition 方法

三国杀一局的推进不是线性流程,而是由玩家动作触发的状态跳转:例如“出牌阶段”中打出【杀】,若目标有“闪”,则进入“响应阶段”;若无“闪”,则直接结算伤害并回到“出牌阶段”或结束本阶段。用if-else链维护这种跳转会迅速失控。正确做法是让Phase枚举自己管理流转:

public enum Phase { DRAW { @Override public Phase next(GameContext ctx) { ctx.drawCards(2); // 主公摸 2,其余摸 1?此处按标准规则分支 return PLAY; } }, PLAY { @Override public Phase next(GameContext ctx) { // 检查是否还有合法出牌动作 if (ctx.getCurrentPlayer().hasPlayableCard(ctx)) { return PLAY; // 可继续出牌 } else { return DISCARD; } } }, DISCARD { @Override public Phase next(GameContext ctx) { ctx.discardToHandLimit(); // 弃至手牌上限 return END; } }, END { @Override public Phase next(GameContext ctx) { ctx.nextPlayer(); return DRAW; } }; public abstract Phase next(GameContext ctx); }

这样,GameContext#proceedToNextPhase()只需调用currentPhase.next(this),无需关心具体条件——条件已封装进枚举内部。后续加“觉醒技触发阶段”或“主公技额外阶段”,只需新增枚举值并重写next(),不碰原有逻辑。

2.3 Effect 不是“效果类”,而是“副作用执行器”:解耦结算与触发

“桃”回血、“杀”掉血、“乐不思蜀”判定失败扣血……这些不是 Card 的一部分,而是Effect实例。Card 只负责声明“我打出后会触发什么 Effect”,Effect 负责执行且必须可逆(为悔棋、AI 回溯、网络同步预留接口):

public abstract class Effect { protected final Player source; // 出牌者 protected final Player target; // 目标(可能为 null) public Effect(Player source, Player target) { this.source = source; this.target = target; } public abstract void apply(GameContext ctx); // 正向执行 public abstract void rollback(GameContext ctx); // 逆向回滚 }

比如DamageEffect的apply()会调用target.takeDamage(1),而rollback()则调用target.restoreHp(1)。注意:takeDamage()内部需检查是否死亡并触发“死亡结算”,但死亡结算本身是另一个 Effect,不能塞进DamageEffect里——这是避免逻辑耦合的关键分层。

2.4 Action 是玩家意图的最小单位:一切交互必须包装成 Action

玩家点击“出杀”、选择目标、点击“使用桃”,这些 UI 操作最终都应转化为Action子类实例。Action是命令模式的落地,它持有完整上下文(谁、对谁、用什么、何时),且必须校验合法性:

public class UseCardAction implements Action { private final Player player; private final Card card; private final Player target; public UseCardAction(Player player, Card card, Player target) { this.player = player; this.card = card; this.target = target; } @Override public boolean isValid(GameContext ctx) { return player == ctx.getCurrentPlayer() && player.hasCard(card) && card.isUsableInPhase(ctx.getCurrentPhase()) && (target == null || ctx.getPlayers().contains(target)); } @Override public void execute(GameContext ctx) { player.removeCard(card); card.use(ctx, player, target); } }

注意:isValid()必须在execute()前被调用,且 UI 层应根据isValid()结果禁用/启用按钮。这是防止“点了才报错”的体验底线。


3. 用 3 个真实规则场景验证模型:为什么“南蛮入侵”必须拆成 2 个 Effect,“乐不思蜀”判定失败要进弃牌堆

3.1 场景一:“南蛮入侵”——一张牌引发的链式响应,必须支持中断与恢复

规则:打出【南蛮入侵】后,按逆时针顺序,每位其他角色需打出一张【杀】响应;若有人未打出,则其受到 1 点伤害;响应结束后,【南蛮入侵】进入弃牌堆。
错误建模:把整个过程写成NanManInvasionCard#use()里一个 for 循环。后果是:当 C 玩家没【杀】时,B 玩家的响应状态丢失,无法回退;且 D 玩家根本没机会响应(因为循环已 break)。

正确解法:将“南蛮入侵”拆为两个 Effect:

  • NanManInvasionTriggerEffect:仅触发响应流程,记录待响应玩家队列;
  • NanManInvasionResolveEffect:按队列逐个处理,每个玩家响应独立封装为子 Effect(ResponseEffect),支持单独 rollback。
// NanManInvasionTriggerEffect.java public void apply(GameContext ctx) { List<Player> responders = ctx.getOtherPlayersInOrder(ctx.getCurrentPlayer()); ctx.setPendingResponses(new ResponseChain(responders, this)); // 不执行任何结算,只挂起状态 } // ResponseChain.java —— 可暂停、可回退的响应队列 public class ResponseChain { private final List<Player> queue; private int currentIndex = 0; private final NanManInvasionTriggerEffect parent; public void proceedToNext() { if (currentIndex < queue.size()) { Player current = queue.get(currentIndex++); // 推送“请响应”事件给 UI,等待用户 Action } } public void rollbackLast() { if (currentIndex > 0) currentIndex--; } }

这样,当 B 玩家响应后,C 玩家没【杀】,NanManInvasionResolveEffect对 C 执行DamageEffect,然后proceedToNext()继续到 D——整个链路可控、可测、可 debug。

3.2 场景二:“乐不思蜀”判定失败——判定牌去哪?失败效果谁触发?

规则:贴在某玩家面前的【乐不思蜀】,在其出牌阶段开始时进行判定:若判定牌为黑桃,则【乐不思蜀】生效(该玩家跳过出牌阶段),并将【乐不思蜀】移入弃牌堆;否则,【乐不思蜀】失效,同样进入弃牌堆。
常见错误:在LeBuSiShuCard#use()里直接写if (isSpade()) { skipPlayPhase(); }。问题在于:

  • 判定时机是“出牌阶段开始时”,不是“贴上时”;
  • 失效时【乐不思蜀】也要进弃牌堆,但此时它已不在手牌,而在“判定区”;
  • 若玩家有“无懈可击”,可在判定前抵消,这又引入新响应层。

正确结构:

  • LeBuSiShuCard是DelayedEffectCard子类,use()只做两件事:① 将自身放入目标玩家的judgmentArea(判定区);② 注册一个JudgmentTrigger到GameContext的判定事件总线。
  • JudgmentTrigger在Phase.PLAY开始时被GameContext自动触发,生成JudgmentAction。
  • JudgmentAction执行判定逻辑,并根据结果生成ApplyJudgmentEffect或DiscardJudgmentEffect。

关键点:判定区是独立容器,不是玩家手牌或装备区。我们定义:

public class Player { private final List<Card> judgmentArea = new ArrayList<>(); // 仅存延时类锦囊 private final List<Card> equipmentArea = new ArrayList<>(); // ... }

这样,“乐不思蜀”无论生效与否,DiscardJudgmentEffect都能精准从judgmentArea移除它,而非大海捞针找位置。

3.3 场景三:主公技“护驾”——跨玩家权限控制,必须用 Role 抽象而非 if 判断

规则:主公可令一名其他角色替其响应【杀】或【决斗】。
新手常写:if (player.getRole() == Role.LORD && target.hasSkill("HuJia")) { ... }。这导致:

  • 新增角色(如“内奸”)需改遍所有 if;
  • “护驾”逻辑散落在ShaCard、JueDouCard等多个类中;
  • 无法统一管控“谁可以护驾”“护驾次数限制”。

正解:将“护驾”定义为Skill,由主公主动触发,且Skill的canTrigger()明确检查目标是否具备“可护驾”资质(如非反贼、未濒死):

public class HuJiaSkill implements Skill { @Override public boolean canTrigger(GameContext ctx, Player self, Player target) { return self.getRole() == Role.LORD && ctx.getCurrentPhase() == Phase.PLAY && target.getRole() != Role.REBEL // 反贼不能护驾主公 && !target.isDying(); } @Override public void execute(GameContext ctx, Player self, Player target) { // 将 target 设为当前“受护驾者”,修改后续响应逻辑 ctx.setProtectedTarget(target); // 后续 ShaCard 的响应逻辑会自动转向 target } }

注意:setProtectedTarget()不是修改target状态,而是修改GameContext中的临时标记。ShaCard#canRespond()会检查此标记并返回true,从而让target获得响应权。权限控制收口在 Skill,行为控制收口在 Card,职责清晰。


4. 避坑:5 个让 90% 初学者卡住超过 2 天的真实问题与解法

4.1 现象:打出【杀】后,目标玩家“闪”了,但游戏直接跳到下家,没走“伤害结算”

原因:ShaCard#use()中只调用了target.respondWithShan(),但没检查响应结果就结束了。respondWithShan()返回true/false表示是否成功响应,而你的代码忽略了这个返回值,直接return。
解决:所有Card#use()必须根据响应结果分支。正确结构:

public void use(GameContext ctx, Player source, Player target) { if (target.respondWithShan()) { // 响应成功,不结算伤害 System.out.println(target.getName() + " 闪避成功"); } else { // 响应失败,执行伤害 Effect new DamageEffect(source, target).apply(ctx); } }

提示:respondWithShan()内部应检查目标是否有【闪】、是否在响应窗口期,并消耗一张【闪】。返回false不代表“没闪”,而是“不能闪”或“没闪”。

4.2 现象:手牌数显示正确,但弃牌阶段提示“需弃 3 张”,实际只弃了 1 张

原因:discardToHandLimit()方法遍历手牌列表并调用remove(),但在 for 循环中remove()会导致后续元素索引前移,造成漏弃。例如[A,B,C,D](手牌上限 2),循环 i=0 删除 A 后,B 移到索引 0,但 i 已变成 1,于是跳过 B。
解决:用迭代器删除,或倒序遍历:

// ✅ 正确:倒序遍历 for (int i = hand.size() - 1; i >= handLimit && !hand.isEmpty(); i--) { Card discarded = hand.remove(i); ctx.getDiscardPile().add(discarded); } // ✅ 正确:迭代器 Iterator<Card> it = hand.iterator(); while (it.hasNext() && hand.size() > handLimit) { it.next(); it.remove(); }

4.3 现象:启动程序后,GameContext初始化玩家顺序总是主公在最后,和规则书要求的“主公首位”不符

原因:玩家列表用ArrayList添加,但添加顺序没按身份排序。你可能先add(playerA),再add(playerB),最后add(lord),却期望lord是get(0)。
解决:初始化时强制按身份排序,或更稳妥地——在GameContext构造时明确指定主公索引:

public GameContext(List<Player> players, int lordIndex) { this.players = new ArrayList<>(players); this.lord = players.get(lordIndex); // 确保主公在列表首位(便于轮转计算) Collections.swap(this.players, 0, lordIndex); }

血泪经验:不要依赖“添加顺序”,所有依赖顺序的逻辑,必须有显式索引参数或排序步骤。

4.4 现象:JUnit 测试testShaKillsTarget通过,但集成到游戏循环后,目标玩家 HP 变成负数,没触发死亡结算

原因:takeDamage(1)方法里只写了hp -= damage,没检查hp <= 0并调用die()。而die()方法负责通知主公/反贼胜利条件、清空手牌、触发“遗计”等技能——漏掉这一步,整个胜利判定就瘫痪了。
解决:takeDamage()必须是原子操作,包含伤害+死亡检查:

public void takeDamage(int damage) { this.hp = Math.max(0, this.hp - damage); if (this.hp == 0) { this.die(); // 关键!必须在此处触发 } }

4.5 现象:添加“无懈可击”后,【南蛮入侵】判定被抵消,但【南蛮入侵】卡在判定区不消失

原因:“无懈可击”的Effect只做了cancelResponse(),但没通知NanManInvasionTriggerEffect“我已被抵消”,导致后者仍等待响应完成。
解决:引入CancellationToken机制。所有可被抵消的 Effect,在创建时生成 token,并注册到GameContext的取消总线:

public class NanManInvasionTriggerEffect implements Effect { private final CancellationKey cancellationKey; public NanManInvasionTriggerEffect() { this.cancellationKey = new CancellationKey(); ctx.registerCancellation(cancellationKey, this::onCancelled); } private void onCancelled() { // 清理 pendingResponses,将南蛮入侵移入弃牌堆 ctx.getDiscardPile().add(this.card); ctx.clearPendingResponses(); } }

当“无懈可击”打出时,WuXieKeJiEffect调用ctx.cancel(cancellationKey),自动触发onCancelled()。这是解耦“抵消”与“被抵消方”的标准做法。


5. 让源文件真正可交付:用 Gradle 打包、JUnit 覆盖核心规则、生成 UML 类图的三步实操

5.1 用 Gradle 构建可运行 JAR,附带内嵌规则文档

别再手动javac *.java。新建build.gradle,声明 Java 版本、主类、资源拷贝:

plugins { id 'java' id 'application' } java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } application { mainClass = 'com.sanguosha.GameLauncher' } // 将 rules.md 和 cards.json 打包进 JAR processResources { from('src/main/resources') { include '**/*.md', '**/*.json' } }

GameLauncher.java中读取内嵌规则:

public class GameLauncher { public static void main(String[] args) throws IOException { // 从 classpath 加载规则文档 InputStream is = GameLauncher.class.getResourceAsStream("/rules.md"); String rules = new String(is.readAllBytes(), StandardCharsets.UTF_8); System.out.println("加载规则摘要:" + rules.substring(0, 200) + "..."); GameContext game = new GameContext(createPlayers()); game.start(); } }

执行./gradlew build,生成build/libs/sanguosha-1.0.jar,双击或java -jar即可运行。这才是真正的“源文件”交付形态——不是一堆 .java,而是一个可执行、带文档、版本可控的制品。

5.2 JUnit 5 覆盖 3 类核心规则,每个测试即一条可执行的规则书条目

不要写“测试 Player 类”,要写“测试【杀】的响应规则第 3 条”。例如:

@Test @DisplayName("【杀】:目标有【闪】时,响应成功且不结算伤害") void sha_with_shan_should_not_damage() { // 给目标玩家一张【闪】 target.addCard(new ShanCard()); // 主公打出【杀】 GameContext ctx = new GameContext(List.of(lord, target), 0); lord.addCard(new ShaCard()); // 执行【杀】 ShaCard sha = (ShaCard) lord.getHand().get(0); sha.use(ctx, lord, target); // 断言:目标 HP 未变,且【闪】已被消耗 assertEquals(4, target.getHp()); // 初始 HP=4 assertEquals(0, target.getHand().size()); // 【闪】被移除 }

提示:每个@Test方法名必须是中文规则描述,@DisplayName用自然语言。这样,测试报告本身就是规则验证清单。我一般会建src/test/resources/rules/目录,放sha_rules.md、judgment_rules.md,测试名与文件中的条款编号一一对应。

5.3 用 PlantUML 自动生成类图,让协作开发者 30 秒看懂架构

在build.gradle中加入 PlantUML 插件,扫描src/main/java/com/sanguosha/下的类,生成docs/class-diagram.puml:

tasks.register('generateClassDiagram', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'net.sourceforge.plantuml.PipeStream' args = ['-pipe', '-tsvg', 'src/main/java/com/sanguosha/'] standardOutput = new FileOutputStream('docs/class-diagram.svg') }

生成的 SVG 图里,你会清晰看到:

  • GameContext作为中心节点,聚合List<Player>、Deck、DiscardPile;
  • Player关联List<Card>(手牌)、List<Card>(判定区)、List<Card>(装备区);
  • Card是接口,ShaCard、ShanCard、LeBuSiShuCard实现它;
  • Effect是接口,DamageEffect、JudgmentEffect等实现它。

这张图不是装饰,而是新人加入时的第一份地图。我坚持每季度./gradlew generateClassDiagram一次,把新生成的 SVG 放进 Confluence,标题就叫“当前主干分支架构快照”。比写文档省力,比口头解释靠谱。


6. 我的私藏技巧:用 JSON 配置卡牌数据,让“改一张牌”不用编译,且天然支持多语言

你肯定遇到过:策划说“把【桃】回血量从 1 改成 2”,你改完PeachCard.java,重新编译、打包、重启——结果发现忘了改PeachEffect.java里的healAmount,或者cards.json里描述还是“回复 1 点体力”。根源在于:卡牌的元数据(名称、描述、效果数值)和行为逻辑(Java 类)混在一起了。

我的解法:彻底分离。所有卡牌数据存在src/main/resources/cards/下的 JSON 文件中,Java 类只负责解析和执行:

// src/main/resources/cards/peach.json { "id": "peach", "name": "桃", "description": "出牌阶段,对一名角色使用。该角色回复 1 点体力。", "type": "basic", "effect": { "class": "com.sanguosha.effect.HealEffect", "params": { "amount": 1, "target": "self_or_other" } } }

对应的CardFactory根据 JSON 创建实例:

public class CardFactory { public static Card createFromJson(String jsonPath) throws IOException { InputStream is = CardFactory.class.getResourceAsStream(jsonPath); JsonObject cardDef = JsonParser.parseReader(new InputStreamReader(is)) .getAsJsonObject(); String effectClass = cardDef.getAsJsonObject("effect").get("class").getAsString(); JsonObject params = cardDef.getAsJsonObject("effect").getAsJsonObject("params"); try { Class<?> clazz = Class.forName(effectClass); Effect effect = (Effect) clazz.getDeclaredConstructor(JsonObject.class) .newInstance(params); return new GenericCard( cardDef.get("id").getAsString(), cardDef.get("name").getAsString(), cardDef.get("description").getAsString(), effect ); } catch (Exception e) { throw new RuntimeException("Failed to load card: " + jsonPath, e); } } }

现在,“改【桃】回血为 2”只需:

  1. 编辑peach.json,把"amount": 1改成"amount": 2;
  2. 重启程序(或热重载,见下文);
  3. 完事。

进阶技巧:JSON 中加"i18n": {"zh-CN": "...", "en-US": "..."}字段,CardFactory根据Locale.getDefault()选描述。一套配置,中英双语自动切换。

热重载小技巧:开发时,监听resources/cards/目录变化,用FileWatcher检测 JSON 修改,自动调用CardFactory.reloadAll()。这样改完保存,游戏内立刻生效,连重启都不用。我把它封装成DevModeHelper,只在main()开头if (isDevMode()) enableHotReload();——生产环境完全无感知。

这套 JSON 驱动方案,让我在模拟项目X 中把卡牌迭代周期从“天级”压到“分钟级”。策划改完发个 JSON,我刷新页面就能测,再也不用听“你那个 Card 类是不是没 recompile?”的质疑。

希望帮到你。

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

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

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

立即咨询