用Java写一版三国杀:从类设计到事件驱动架构的实战指南
2026/9/2 18:00:19 网站建设 项目流程

简介:一套基于Java实现的三国杀标准版游戏项目源码,面向Java学习者、游戏开发爱好者以及想深入理解卡牌游戏逻辑的读者。项目通过面向对象方式建模,将25名武将、104张牌及其技能规则转化为可执行程序,适合借此练习类设计、状态流转与回合制逻辑实现。资源包共606个文件,含155个Java源文件、243个class编译产物、126个PNG与64个JPG图像素材,另有XML配置、properties属性文件等,整体约6.39MB,源码层级清晰。目前已有一千余人浏览学习。借助这些素材与代码,读者可了解游戏主循环、出牌判定、技能触发等模块的编写方式,也可在Alpha版本基础上继续完善功能、修复缺陷,是结合兴趣提升Java编程与游戏开发能力的实用练习项目。 打开任意一个Java学习交流群,隔三差五就有人问“有没有用Java写的小项目源码”。问来问去,翻来覆去就那么几个:图书管理系统、学生成绩管理、酒店预订……不是说这些项目不好,而是它们清一色都是“业务CRUD+数据库操作”的套路,写完之后除了熟练了增删改查,基本接触不到什么高级特性。

所以我一直建议想练手的Java学习者换个思路,做点带状态流转、带规则引擎、带复杂交互的东西。比如用Java写一版三国杀。

这话题真不是我临时起意。三国杀这个游戏,表面看是个卡牌游戏,但它的规则复杂度在桌游里属于相当高的层级:身份判断、回合内多阶段、装备区与判定区、延时锦囊结算、技能在特定时机的触发……把这些逻辑用Java完整实现一遍,你能充分感受到面向对象设计里“类与类如何协作”“状态如何流转”“功能如何扩展”这些课本上讲了很多遍但一直很抽象的概念。而且最关键的——做完之后,你手里会有真正能跑的源文件,可以随时翻出来改、扩展、重构,比死记硬背面试八股文有价值得多。

这篇文章我就以“Java写三国杀”为核心,梳理一下我做这个项目时的完整思路,包括类怎么拆、流程怎么走、代码怎么组织、调试时哪些坑让我一度想删库跑路,希望能给想拿这个项目练手的朋友提供一份能直接参考的实战手册。

1. 开局之前:先想清楚这个项目到底在练什么

很多人拿到“用Java写三国杀”这个需求,第一反应是打开IDE直接建类开写。我劝你先别急。这个项目真正的难点根本不在于“会不会写Java语法”,而在于“怎么设计出一套能容纳复杂规则的代码结构”。如果一上来就动手,写到一半大概率会陷入“改了一个类,崩了三个类”的泥潭。

1.1 三国杀的规则复杂度到底在哪

我们用技术语言拆一下三国杀的核心玩法。一局游戏里有多个玩家,每个玩家有一个武将,有自己的血量和手牌;每个回合分为准备阶段、判定阶段、摸牌阶段、出牌阶段、弃牌阶段、回合结束阶段;回合内玩家可以出杀、出闪、出桃,可以装备武器和防具,可以挂延时锦囊;其他玩家可以在特定时机出无懈可击来抵消锦囊效果。

这些机制翻译成技术术语就是:

  • 多阶段状态机:一个玩家回合要经历6个阶段,且不同阶段允许执行的操作完全不同。
  • 多层事件响应:出一张“杀”,要依次询问是否有“闪”、是否有“八卦阵”判定、是否有技能“倾国”替代出闪……
  • 动态效果挂载:一把“诸葛连弩”让你本回合出杀次数不受限制;装备“仁王盾”后,黑色的“杀”对你无效。这些效果不是写死在某一个方法里的,而是像插件一样动态生效的。

所以这个项目的核心难点不是“能不能写出一个类”,而是“能不能设计出一套机制,让新武将、新卡牌可以像插件一样往里加,而不用改核心代码”。想通这一点,这个项目的学习价值就体现出来了——你练的其实是框架设计能力

1.2 我最终选定的技术方案

我的版本没有用任何框架,纯Java + 控制台交互完成,JDK版本用的11。有人可能会问,都写游戏了,为什么不用Swing或者JavaFX做个图形界面?

我的答案是:第一版打死不要碰界面。

三国杀这个游戏的逻辑复杂度和界面复杂度是相互独立的。如果一开始就做图形界面,你大量的时间会花在按钮布局、事件监听、贴图上,而真正核心的“规则逻辑”反而会被挤到次要位置。控制台版本能让你把所有注意力放在逻辑本身上,等逻辑全部跑通了,再套一层JavaFX界面,那是水到渠成的事。

数据存储上我一开始也没用数据库,全部是内存对象。游戏结束时直接把结果打印在控制台。原因很直接:规则类游戏的状态都在内存里动态流转,强行走数据库反而会让对象关系的管理变得很别扭。真要做排名、做战绩存储,那是后话,不影响核心逻辑。

2. 一版能跑通的三国杀,类结构该怎么拆

这是整个项目最关键的一步。我见过太多人写这种项目,最后代码变成一个大几百行的Game类,里面全是if else判断——“如果是杀,执行A;如果是闪,执行B”。这样写确实能跑,但每加一个新功能都要去动这个类,代码会越来越难维护。

我采用的拆法遵循一个核心原则:把游戏里的每个概念都映射为一个类,类与类之间只通过接口通信,不直接依赖具体实现。

2.1 核心类一览

我的源文件目录下,主要包含了这么几个核心类:

src/com/sanguosha/ ├── core/ │ ├── GameEngine.java # 游戏主循环、回合调度 │ ├── Player.java # 玩家抽象 │ ├── Card.java # 卡牌基类 │ ├── Skill.java # 技能接口 │ └── EventBus.java # 事件分发器(关键组件) ├── card/ │ ├── SlashCard.java # 杀 │ ├── DodgeCard.java # 闪 │ ├── PeachCard.java # 桃 │ └── ... ├── character/ │ ├── Hero.java # 武将基类 │ ├── CaoCao.java # 曹操 │ ├── LiuBei.java # 刘备 │ └── ... └── ui/ └── ConsoleUI.java # 控制台交互

这套结构里,GameEngine只负责游戏主流程的推进,它不知道具体某张牌怎么结算,具体技能怎么触发——它只管发信号。卡牌怎么结算、技能怎么生效,由各个具体的卡牌类和技能类自己决定。

2.2 卡牌类的设计思路

很多人会把卡牌设计成一个Card类,然后加一个type字段区分是杀还是闪,再用switch判断走不同逻辑。这种设计确实简单,但它把“数据”和“行为”混在了一起。

我更推荐的是让Card作为抽象基类,每种卡牌单独建一个类:

public abstract class Card { protected String name; protected String description; public abstract boolean canUse(Player source, GameEngine engine); public abstract void execute(Player source, GameEngine engine); } public class SlashCard extends Card { public SlashCard() { this.name = "杀"; this.description = "出牌阶段,对攻击范围内的一名角色造成1点伤害"; } @Override public boolean canUse(Player source, GameEngine engine) { // 判断距离、是否处于出牌阶段等 return source.isInPlayPhase() && engine.getTargetsInRange(source).size() > 0; } @Override public void execute(Player source, GameEngine engine) { // 询问目标是否出闪、判定八卦阵、触发技能等 Player target = engine.chooseTarget(source, engine.getTargetsInRange(source)); if (target.askForDodge()) { engine.log(source.getName() + "的杀被" + target.getName() + "闪避了"); } else { target.takeDamage(1); } } }

这样设计之后,每种牌有自己的canUseexecute方法。游戏引擎想用哪张牌,直接拿到实例调用即可。后续扩展新卡牌,只需要新建一个类继承Card,实现这两个方法,然后注册到卡牌池里,完全不用动引擎代码。

2.3 武将技与继承体系

武将的设计也遵循同样的思路。每个武将是一个类,继承自Hero抽象类,技能则是Skill接口的实现。

public interface Skill { String getName(); String getDescription(); void onTrigger(TriggerType type, EventContext context); }

一个武将可以持有多个技能。关键在于onTrigger方法里的TriggerType——这是告诉技能“你在什么时机被触发”,比如DAMAGE_DEALT(造成伤害后)、DAMAGE_TAKEN(受到伤害后)、PHASE_START(回合开始时)等。

举个例子:曹操的技能“奸雄”是“当你受到伤害后,你可以获得对你造成伤害的牌”。实现就是:

public class CaoCao extends Hero { public CaoCao() { this.name = "曹操"; this.maxHp = 4; this.skills.add(new Skill() { @Override public void onTrigger(TriggerType type, EventContext context) { if (type == TriggerType.DAMAGE_TAKEN) { Player owner = context.getOwner(); Card damageCard = context.getDamageCard(); if (damageCard != null) { owner.getHandCards().add(damageCard); } } } }); } }

这种写法的好处是:新加一个武将时,只需要新建一个类,定义好属性和技能逻辑,其他代码一概不用动。到后期你会发现,写扩展比写核心逻辑还轻松——这正是框架设计的价值所在。

3. 回合循环与结算机制:先把主流程跑通

类结构设计好后,下一步就是把游戏主流程搭起来。三国杀的一回合流程是这样的:回合开始 → 准备阶段 → 判定阶段 → 摸牌阶段 → 出牌阶段 → 弃牌阶段 → 回合结束。这个流程用代码实现起来不算复杂,但要注意的点很多。我建议用事件驱动的方式来实现,而不是把所有逻辑硬编码在循环里。

3.1 主循环的实现骨架

我的GameEngine核心逻辑大致是这样的:

public void startGame() { initPlayers(); while (!isGameOver()) { Player current = getNextAlivePlayer(); processRound(current); } announceWinner(); } private void processRound(Player current) { // 回合开始 current.setInPlay(true); // 准备阶段 current.onPhaseStart(PhaseType.PREPARE); // 判定阶段 if (current.hasPendingJudge()) { processJudge(current); } // 摸牌阶段 current.drawCards(2); // 出牌阶段 while (current.hasUsableCard()) { // 这里交给玩家的交互逻辑,让玩家选择出哪张牌或者结束出牌 boolean continuePlaying = ui.playPhase(current); if (!continuePlaying) break; } // 弃牌阶段 int discardCount = current.getHandCards().size() - current.getMaxHandCards(); if (discardCount > 0) { ui.askDiscard(current, discardCount); } // 回合结束 current.setInPlay(false); }

这段代码看起来简单,但里面藏着一个很关键的设计决策:出牌阶段我没有写死“该出哪张牌”,而是把它交给ui.playPhase(current)来处理。控制台版里,这个方法是让用户输入指令选择手牌;将来换成图形界面版,这个方法就是弹窗按钮。

这背后的思路叫做关注点分离:引擎只负责流程控制,不负责用户体验。交互怎么呈现,完全由UI层决定。

3.2 事件分发器:让技能和牌效果解耦

接下来是这套系统里我认为最值得讲的部分:EventBus事件分发器

三国杀的复杂性很大程度来自“时机”。一张杀出去,可能触发武将技能、可能触发装备效果、可能触发其他玩家的技能响应。如果这些逻辑都用函数调用来写,类与类之间会形成高度耦合。

我的解决办法是引入一个轻量级的事件总线。核心代码如下:

public class EventBus { private Map<TriggerType, List<Skill>> listeners = new HashMap<>(); public void register(TriggerType type, Skill skill) { listeners.computeIfAbsent(type, k -> new ArrayList<>()).add(skill); } public void fire(TriggerType type, EventContext context) { List<Skill> skills = listeners.get(type); if (skills == null) return; for (Skill skill : skills) { skill.onTrigger(type, context); } } }

游戏引擎里需要触发某个时机时,只需要调用eventBus.fire(TriggerType.DAMAGE_TAKEN, context),所有注册了这个时机的技能都会自动响应。

这样做最大的好处是:引擎完全不需要知道有哪些技能、技能是谁的。武将需要在自己受到伤害时触发技能,只要在初始化时把自己注册到EventBus里即可。新加一个武将、新加一个技能,完全不用改动引擎代码。这不是什么高深的概念,本质就是观察者模式的实践应用。

3.3 代码示例:伤害结算流程

把EventBus引入之后,一张杀从出手到结算的流程就变成了这样:

// SlashCard.execute 里的关键逻辑 public void execute(Player source, GameEngine engine) { Player target = engine.chooseTarget(source); // 触发“成为杀的目标时”的技能,比如小乔的天香 EventContext context = new EventContext(); context.setSource(source); context.setTarget(target); context.setCard(this); engine.fireEvent(TriggerType.BECOME_TARGET, context); // 如果目标已被技能转移,target可能已经改变 if (target.isDead()) return; // 询问闪避 boolean dodged = target.askForDodge(); if (!dodged) { // 造成伤害 target.takeDamage(1, source, this); // 触发“造成伤害后”的技能,比如夏侯惇的刚烈 engine.fireEvent(TriggerType.DAMAGE_DEALT, context); } }

我在实际编码过程中发现,把整个流程拆解成不同时机的触发链,虽然前期写起来稍慢,但后期调试和扩展时的幸福感是无法言喻的。遇到“为什么这个技能没触发”的问题,只需要看EventBus里有没有注册对应时机的技能,一目了然。

4. 写代码时最容易踩的坑:卡牌驱动下的状态机设计

这个项目我前后写了三个版本,前两版都推倒重写了。第三版也就是我上面分享的这套架构,才终于让我看到了“能稳定跑完一整局”的曙光。这期间踩的坑不少,有些教训值得好好聊聊。

4.1 循环依赖:最容易掉进去的深渊

第一个版本里,我让Player类直接持有GameEngine的引用,因为玩家需要调用引擎里的方法。后来写Card时,卡牌也需要调用引擎的方法,于是也持有了引擎引用。结果就是:引擎依赖玩家,玩家依赖引擎,形成一个双向依赖。

表面看代码能跑,但随着功能增多,这种双向依赖会让类的内聚性越来越差。改一行引擎代码,可能会连带三个类出问题。

后来我彻底重构了这个问题:所有组件只依赖EventBusGameEngine这两个门面类,PlayerCard之间不直接互相调用。玩家需要出牌时,通过事件机制通知引擎,再由引擎协调其他组件。这样虽然多了一层间接性,但整个系统的依赖方向变得非常清晰。写这种规则复杂的项目,一定要时刻关注依赖方向,不然扩展几个武将后代码就成一团乱麻了。

4.2 武将技能触发的“时机冲突”

第二个大坑是武将技能的触发时机。三国杀里有很多技能是在“某个动作发生时”响应的,但动作本身又可能嵌套其他动作。比如“造成伤害”这个动作可能触发“伤害转移”的效果,而转移后又会产生新的伤害事件。如果不做处理,很容易出现递归调用层级过深,甚至死循环。

我的解决方案是在EventBus里加了一个简单的事件栈深度控制

public class EventBus { private int depth = 0; private static final int MAX_DEPTH = 20; public void fire(TriggerType type, EventContext context) { if (depth >= MAX_DEPTH) { throw new IllegalStateException("事件触发层级过深,疑似死循环"); } depth++; try { // 原有触发逻辑 } finally { depth--; } } }

这个保护机制帮了我大忙。之前在开发过程中经常出现程序卡住不动,之后查日志发现是某个武将技能和装备效果形成了无限循环触发。有了深度控制,程序会直接报错并打印调用栈,定位问题快了很多。

4.3 玩家交互与流程控制的解耦

还有一个很实际的问题:控制台版本的输入输出是阻塞式的——你要等待用户输入。如果游戏逻辑直接调用Scanner.nextLine(),那么在等待输入的时候,整个程序被卡住,任何异步事件都处理不了。

好在三国杀的流程本质上是回合制,玩家出牌阶段的等待不会阻塞其他逻辑的触发。但我也依然建议,把所有输入输出统一封装在ConsoleUI类里,PlayerCard不直接接触Scanner。这样做的好处不仅是代码整洁,更重要的是后期如果要接GUI,只需要把这个UI类替换掉即可,核心逻辑一行不用改。

5. 从能用源码到能看懂源码:文件组织与调试技巧

玩家交互、卡牌结算、技能触发这些主逻辑跑通后,项目其实已经完成度很高了。但这还不够——如果你希望这个项目能成为简历上的亮点,或者作为面试时能讲清楚的项目,你还需要在代码组织和调试体验上做更多工作。

5.1 源文件的包结构到底该怎么分

我在前面展示了核心类的包结构,这里再展开说几个细节决策。

core包只放引擎、事件总线、玩家抽象这些最稳定的部分。这一层是整个系统的地基,轻易不要改动。card包放卡牌的具体实现,每张牌一个类,独立开发、独立测试。character包放武将,每个武将一个类,技能用匿名内部类或Lambda实现。

我强烈建议在包结构上花点心思,因为Java项目最直观的可维护性体现就在于包结构。一个能让人“一眼就知道去哪找代码”的包结构,比写一百行注释都有用。

另外,我习惯把游戏配置和常量单独放在一个类里。比如初始手牌数、最大手牌数、距离计算规则这些可以调整的参数,统一抽出来:

public class GameConfig { public static final int INITIAL_HAND_CARDS = 4; public static final int MAX_HAND_CARDS = 20; public static final int ATTACK_RANGE_BASE = 1; }

这样后续要调整平衡性,改Config类里的常量就行,不会误改到业务逻辑。

5.2 可控的日志输出:调试的三国杀

做这种复杂项目,最怕的就是程序跑起来之后你不知道内部发生了什么。控制台版游戏里,用户的每一步操作都被打印出来作为交互提示,但如果你混用“系统日志”和“交互提示”,调试时就会非常痛苦。

我的做法是封装了一个简单的LogUtil类,专门输出系统日志:

public class LogUtil { public static void debug(String message) { System.out.println("[DEBUG] " + message); } public static void info(String message) { System.out.println("[INFO] " + message); } }

每个关键动作(摸牌、出牌、结算伤害、技能触发)都在代码里加上LogUtil.info()输出。开发阶段用debug级别追踪,跑通后把debug开关关掉。这样你在测试新功能时,可以清晰地看到整个游戏的状态流转过程。我花在日志上的这部分时间,在后续排错时至少帮我节省了十倍的时间。

5.3 自动对战模式:测试的利器

手动在控制台打一局完整的游戏,可能要花十几分钟。如果你每改一个技能都要手动打一局,那效率实在太低了。我后来加了一个自动测试模式:让所有玩家都由简单的AI控制——每回合优先出杀,没杀就出桃,然后结束出牌阶段。

这个改动极其关键。有了自动模式之后,我可以让电脑自己跑几十局游戏,跑完了直接看日志,检查有没有异常、有没有死循环、有没有不合理的状态。这不只是提升了测试效率,更是让你理解“自己写的规则系统”到底能不能自洽的试金石。很多规则漏洞,都是我在自动对战模式里跑了几百局之后才发现的。

6. 项目扩展:从控制台到完整游戏的后续思路

走到这一步,你的Java版三国杀已经有了完整可运行的核心逻辑源文件。接下来项目还能怎么发展?我分享几个我实际考虑过并且在做的方向,供你参考。

6.1 面向对象练习的进一步深化:用设计模式重构

这个项目写完之后,你可以回头审视一下自己代码里的设计模式运用。比如我用到的观察者模式就是EventBus的核心思想;卡牌的canUseexecute设计又用到了模板方法模式的影子;武将技能的挂载方式可以再提炼成策略模式

面试的时候聊起这个项目,能主动说出“我在这个项目里用观察者模式解耦了技能触发,用策略模式设计卡牌行为”,比背十道八股文都有说服力。因为这说明你真的能理解设计模式在什么场景下用、用在哪儿、解决了什么问题。

6.2 图形界面是可以补的

前面我特意说第一版不要做GUI,但逻辑全部稳定之后,套上一套JavaFX界面是完全值得考虑的。到这一步,你要做的核心工作只是写一个UI适配层,把原来的ConsoleUI替换成JavaFX的窗口交互类,核心引擎一行都不用改。这相当于又实践了一次依赖倒置原则——高层模块不依赖低层模块,两者都依赖抽象接口。

6.3 网络对战是另一个大工程

真正让这个项目上难度的是网络对战。设想一下,如果两个玩家分别在同一局域网的不同电脑上玩,那么整个游戏引擎就要考虑状态同步、出牌超时、断线重连等问题。这一块涉及的知识已经超出了Java基础范畴,更接近网络编程和分布式系统。如果你把单机版完全吃透了,再往这个方向走,我会觉得你有相当强的自学能力。

最后分享几个我实操后的心得体会

整个项目从构思到稳定运行,我大概花了三周左右的业余时间。这个过程里我感受最深的一点是:写业务系统时,你写的是流程;写游戏引擎时,你写的是规则。规则系统的难点在于它天然是网状结构,一个动作会触发多个条件,多个条件之间又可能互相影响。想把这种网状逻辑写清楚,靠的不是记忆力,而是合理的抽象和分层。

如果你也想拿这个项目来练手,我会给出几个很具体的建议。最开始不要追求一把武将的所有技能都实现,先从最简单的四血将(没有技能)开始,把基本的出牌、伤害、死亡流程跑通。跑通之后再加一个有主动技能的武将,比如刘备的“仁德”,体验一把什么叫出牌阶段额外触发的技能。适应之后再加被动响应技能,比如曹操的“奸雄”,这时候你才会真正理解EventBus的价值在哪儿。这样分步走,每个阶段都有明确的完成感和可测试的代码,不会一上来就被复杂度劝退。

代码碰到问题的时候,多看看日志,也多给自己写点日志。这个项目里“信息可见性”就是生产力,谁掌握的信息更完整、更及时,谁就能更快定位到问题的根源。

做这样的一个项目,你可能不会像刷LeetCode那样每天兴奋于那道题终于AC了,但当你把最后一个武将的技能写完、让电脑自动对战跑完一整局没有任何报错时,那种“这个系统是我一颗颗螺丝拧起来的”的踏实感,是任何一个现成源码包的下载都替代不了的。

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

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

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

立即咨询