Unity斗地主游戏开发实战:MVC架构、状态机与AI算法详解

发布时间:2026/7/20 21:28:38
Unity斗地主游戏开发实战:MVC架构、状态机与AI算法详解 1. 项目概述与价值解析最近在整理硬盘里的老项目翻出来一个几年前用Unity做的单机版斗地主游戏源码。当时做这个项目主要是为了练手Unity的UI系统、状态机管理和简单的AI逻辑没想到后来断断续续优化了几次完成度还挺高。今天把它分享出来一方面是给Unity初学者一个完整、可运行、结构清晰的参考项目另一方面也是想聊聊在Unity里做棋牌类游戏时那些教科书里不会写的“坑”和“技巧”。这个源码是完全免费的你可以直接拿去学习、修改甚至作为你个人作品集的一部分。这个项目麻雀虽小但五脏俱全。它实现了一个标准的三人斗地主玩法包括完整的发牌、叫地主、出牌、跟牌、胜负判定流程。游戏逻辑完全在本地运行不依赖网络AI对手的智商也经过了多次调校打起来还有点挑战性。对于想入门Unity游戏开发特别是对棋牌、回合制游戏逻辑感兴趣的朋友来说这个项目就像一份“活”的教案。你能看到UI如何与数据绑定游戏状态如何流转以及一个看似简单的“出牌逻辑”背后有多少细节需要处理。接下来我会把这个项目的核心设计思路、关键代码实现以及我踩过的那些“坑”毫无保留地拆解给你看。2. 核心架构与设计思路拆解2.1 为什么选择MVC模式拿到一个棋牌游戏项目首先要解决的是代码结构问题。早期版本我尝试过把逻辑全写在GameObject的脚本里结果很快代码就变成了一团乱麻UI改动一点逻辑就要跟着大改维护起来简直是噩梦。所以在这个斗地主项目中我果断采用了变种的MVCModel-View-Controller架构这对于UI和逻辑交互频繁的游戏来说是条清晰的路。Model数据模型这是游戏的核心大脑只关心数据。我定义了Player类存储手牌、身份、分数、Card类存储花色、点数、权值、GameRound类存储当前回合状态、地主牌、当前出牌者等。这些类都是纯粹的C#类不继承MonoBehaviour对Unity引擎零依赖。这样做的好处是数据逻辑可以单独进行单元测试非常干净。View视图这就是你在Unity编辑器里看到的Canvas、Image、Text、Button。每个UI控件都挂载了对应的View脚本比如PlayerHandView负责渲染玩家手牌DeskCardView负责显示桌面已出的牌。View的唯一职责就是“显示”它从Model获取数据然后更新UI元素的位置、图片和文字。它不应该包含任何游戏规则判断。Controller控制器这是连接Model和View的桥梁也是游戏流程的驱动器。我创建了GameManager作为总控制器它内部持有Model的实例并监听玩家的输入如点击出牌按钮。当Controller接收到输入后它会调用Model的方法去改变数据例如从玩家手牌列表中移除选中的牌然后Model的数据变化会通过事件C#的event或UnityEvent通知给所有注册的ViewView再自动更新显示。注意这里我并没有使用严格的观察者模式而是用了更简单的Action或UnityEvent。对于中小项目这完全够用且更直观。关键在于一定要保证数据流动是单向的用户输入 - Controller - Model - (事件通知) - View更新。杜绝View直接去修改Model的数据。2.2 游戏状态机让流程井然有序斗地主是一个流程明确的游戏准备 - 发牌 - 叫地主 - 出牌 - 结算。如果把这些状态混在一起判断代码里会充满大量的if-else。我引入了简单的状态机GameStateMachine来管理。我定义了一个枚举GameStatePreparing,Dealing,Bidding,Playing,Settlement。GameManager中有一个当前状态变量。每个状态都有对应的“进入”、“更新”、“退出”方法。例如进入Dealing状态时触发发牌动画进入Bidding状态时激活叫地主按钮并启动AI叫地主计时器。状态机的优势在于逻辑隔离每个状态的代码独立不会互相干扰。你在写出牌逻辑时完全不用考虑发牌的事。易于扩展如果想增加一个“明牌”环节只需要新增一个状态并在状态转换图中插入它即可。调试方便任何时候打印一下当前状态就对游戏流程一目了然。在实现上我使用了一个DictionaryGameState, Action来存储每个状态对应的主逻辑函数然后在GameManager的Update方法里根据当前状态调用对应的函数。这是一种轻量级的实现对于这个项目足够了。2.3 卡牌数据的核心如何高效比较大小斗地主的核心玩法就是“管上”即后出的牌型必须大于先出的牌型。这里面的逻辑复杂度是项目的一个重点。我的设计如下1. 卡牌表示 每个Card对象有两个核心属性Suit花色黑桃、红桃、梅花、方块和Rank点数3, 4, ... K, A, 2, Joker。为了方便比较我定义了一个Weight权值属性。权值是一个整数点数越大权值越高3最小大王最大。这样比较单张牌的大小就变成了比较两个整数非常高效。public class Card { public SuitType Suit { get; private set; } public RankType Rank { get; private set; } public int Weight { get; private set; } // 根据Rank计算得出 // 构造函数根据Rank和Suit计算Weight public Card(SuitType suit, RankType rank) { this.Suit suit; this.Rank rank; this.Weight CalculateWeight(rank, suit); // 例如3-1, 4-2, ... 2-15, 小王-16, 大王-17 } }2. 牌型识别 这是算法部分的核心。我写了一个静态类CardLogic里面最重要的方法就是ParseCardType(ListCard cards)。它的作用是传入一个有序的卡牌列表返回一个CardType对象里面包含牌型单张、对子、顺子、连对、飞机、炸弹等和该牌型的“比较权值”。识别逻辑简述首先按Weight排序。统计每种点数的牌的张数。根据张数的分布模式来匹配牌型。例如所有牌点数都相同 - 单张、对子、三张、炸弹。点数连续且张数都为1 - 顺子。点数连续且张数都为2 - 连对。包含两个或以上连续的三张并可能带翅膀 - 飞机。识别出牌型后计算一个“类型权值”。这个值由牌型基础分 关键牌点数构成。例如炸弹的基础分很高确保能管上非炸弹牌型同为顺子则比较顺子中最大的那张牌的权值。3. 牌型比较 有了CardType比较两副牌谁大就简单了先比较牌型是否匹配能否管上。例如除了炸弹其他牌型必须同类型才能比较。如果牌型相同则直接比较两者的“类型权值”大小。炸弹可以管上任何其他非炸弹牌型火箭王炸最大。这个识别和比较算法我反复优化了很多次重点在于处理边界情况比如A-2-3算不算顺子不算四张牌是炸弹还是三带一优先识别为炸弹这些规则都必须和标准斗地主完全一致。3. 关键模块实现细节与避坑指南3.1 UI系统如何优雅地管理手牌和桌面牌UI是玩家最直观的感受也是最容易写出性能问题的地方。我采用了基于UGUI的解决方案。手牌管理PlayerHandView数据驱动PlayerHandView监听玩家Model的HandCardsChanged事件。一旦手牌数据变化出牌、被发牌它就重新渲染。对象池这是关键优化点不要每次渲染都Instantiate和Destroy卡牌GameObject。我创建了一个卡牌预制体池。需要显示新牌时从池中取出一个闲置的卡牌GameObject设置其对应的Card数据更新卡面图片、点数花色显示然后排列好位置。当牌被移除时不是销毁它而是将其放回池中并隐藏。这能极大减少GC垃圾回收压力。动态布局手牌数量会变如何自动排列我计算了总宽度和每张牌的间隔根据索引动态设置每张牌的RectTransform的anchoredPosition。为了让牌看起来有层次感我还对选中状态的牌做了Y轴偏移和放大效果。桌面出牌区DeskCardView这里相对简单也是用对象池管理打出的牌。重点是出牌时的动画效果。我使用了DOTween插件免费版即可来实现卡牌从手牌区飞到桌面区的平滑移动和缩放动画。没有DOTween的话用Unity自带的Coroutine配合Lerp也可以实现但代码会繁琐一些。实操心得UI元素的锚点Anchor和轴心Pivot设置是UGUI的易错点。对于卡牌这种需要精确定位的元素我通常将它们的锚点预设为Bottom-Left然后通过代码计算绝对或相对位置来设置anchoredPosition。避免使用Center锚点然后去算偏移那样很容易在屏幕适配时出问题。3.2 AI逻辑设计让电脑对手“像个人”单机游戏AI的体验至关重要。一个愚蠢的AI会让游戏索然无味。我的AI设计目标是在规则正确的基础上加入一定的“性格”和“策略”。AI决策流程状态判断现在是叫地主阶段还是出牌阶段叫地主策略我设计了一个简单的概率模型。AI会根据手牌质量计算一个“叫分权值”。权值基于是否有大王或2是否有炸弹手牌的整体连贯性能否组成顺子、连对。然后引入一个随机因子和“侵略性”参数让不同的AI玩家有不同的叫分风格激进、保守。出牌策略这是核心。回合判断如果是首出桌面无牌AI会从所有合法牌型中选择一个打出。策略是优先出能一次出很多张的牌型如长顺子或者小牌多的牌型如对3、对4以达到快速走牌的目的。跟牌策略当需要管上家的牌时AI会遍历自己所有手牌找出所有能管上的牌型组合。策略一最小代价从所有能管上的组合中选择所用牌权值总和最小的那个。这是最保守的打法。策略二控制节奏如果自己是地主下家且队友另一个农民牌可能很多会选择用稍大的牌管上但不用最大的把更大的牌留给队友或用于控制局面。策略三炸弹使用为AI设置了“忍耐阈值”。当对手出的牌型很大或者游戏进入尾声对手手牌很少时AI才更倾向于使用炸弹。牌型组合生成这是AI算法中最耗时的部分。如何从一手牌里快速找出所有可能的单张、对子、顺子等组合我用了递归搜索剪枝的方法。预先定义好所有牌型的“模板”如顺子需要5张连续单牌然后在手牌中搜索匹配该模板的组合。搜索时优先用权值小的牌去匹配并做好去重。让AI更“人性化”的窍门反应延迟AI做出决策后不要立即出牌。用一个Random.Range(0.5f, 1.5f)的延迟模拟玩家思考的时间体验会好很多。错误模拟可以低概率让AI“犯错”比如有炸弹却没出或者出了不合理的牌。这能增加游戏的不确定性和趣味性但调试时要能关闭这个功能。3.3 资源管理与配置化一个可维护的项目必须把硬编码的数据抽离出来。卡牌图集我使用了一张Sprite AtlasUnity的图集功能来存放所有54张卡牌的正面图片和卡背图片。这样做的好处是Draw Call合并渲染效率高。在代码中我通过卡牌的Suit和Rank计算出一个唯一的Sprite名称然后动态加载。string spriteName $card_{suit}_{rank}; // 例如 card_spade_A Sprite cardSprite Resources.LoadSprite($CardSprites/{spriteName}); // 或者如果放进了AssetBundle或Addressables就用对应的加载方式游戏配置创建了一个GameConfig的ScriptableObject文件。在这里面配置游戏规则参数初始分数、叫分阶梯、每局基础分、炸弹翻倍数。AI参数不同AI角色的侵略性、出错概率、思考时间范围。音效、背景音乐引用。使用ScriptableObject的好处是策划或测试人员可以在Unity编辑器里直接调整数值无需修改代码调整效果立竿见影。4. 完整开发流程与核心代码剖析4.1 项目初始化与场景搭建创建项目使用Unity Hub创建新的3D项目2D项目也可但UGUI在3D项目里一样用。我选择URP通用渲染管线模板因为它对UI渲染友好且性能不错。场景结构Main Camera调整为正交投影Orthographic用于渲染UI。Canvas渲染模式设为Screen Space - Overlay。创建几个主要的子物体Background背景图。PlayerArea(Bottom)本地玩家手牌区。LeftPlayerArea/RightPlayerArea两个AI玩家的手牌区和信息区通常只显示牌的数量。DeskArea桌面出牌区。BiddingPanel叫地主面板。ControlPanel出牌、提示、不要等按钮面板。InfoText显示游戏状态信息的文本。预制体制作CardPrefab一个Image组件显示牌面下面可以挂Text组件显示点数花色可选因为牌面图已包含信息。挂载CardView脚本用于接收Card数据并更新显示。PlayerInfoPrefab包含头像、分数、身份标识的UI预制体。4.2 核心代码流程详解让我们跟踪一局游戏的典型流程看看代码是如何协作的1. 游戏启动 (GameManager.Start)void Start() { // 1. 初始化Model playerModel new Player(You, PlayerType.Local); leftAIModel new Player(AI_Left, PlayerType.AI); rightAIModel new Player(AI_Right, PlayerType.AI); deck new Deck(); // 初始化一副54张的牌 // 2. 初始化View并将View注册到Model的事件 playerHandView.Initialize(playerModel); playerModel.HandCardsChanged playerHandView.OnHandCardsChanged; // 3. 进入准备状态 currentState GameState.Preparing; EnterState(currentState); }2. 发牌状态 (EnterState(GameState.Dealing))void EnterDealingState() { // 洗牌 deck.Shuffle(); // 开始发牌动画协程 StartCoroutine(DealCardsCoroutine()); } IEnumerator DealCardsCoroutine() { // 每人17张留3张地主牌 for(int i 0; i 17; i) { playerModel.AddCard(deck.DrawCard()); leftAIModel.AddCard(deck.DrawCard()); rightAIModel.AddCard(deck.DrawCard()); // 每发一张牌等待0.1秒并播放音效实现动画效果 yield return new WaitForSeconds(0.1f); } // 发完牌自动转换到叫地主状态 ChangeState(GameState.Bidding); }注意这里AddCard方法会触发HandCardsChanged事件从而通知PlayerHandView更新UI。协程的使用让顺序执行的任务有了时间间隔是Unity中实现简单动画的常用手段。3. 出牌逻辑 (GameManager.OnPlayCardsButtonClicked) 这是本地玩家点击“出牌”按钮后的处理。public void OnPlayCardsButtonClicked() { // 1. 从View中获取玩家选中的牌ListCard selectedCards ListCard cardsToPlay playerHandView.GetSelectedCards(); // 2. 验证牌型合法性 CardType cardType CardLogic.ParseCardType(cardsToPlay); if(cardType CardType.Invalid) { ShowToast(无效的牌型); return; } // 3. 与桌面牌比较如果不是首出 if(deskCards.Count 0) { CardType deskType CardLogic.ParseCardType(deskCards); if(!CardLogic.CanBeat(deskType, cardType)) { ShowToast(您的牌不能管上对方的牌); return; } } // 4. 合法则更新Model playerModel.RemoveCards(cardsToPlay); deskCards cardsToPlay; // 更新桌面牌 currentPlayer GetNextPlayer(); // 轮到下一位 // 5. 检查游戏是否结束玩家手牌为空 if(playerModel.HandCardCount 0) { ChangeState(GameState.Settlement); return; } // 6. 如果是AI的回合启动AI决策协程 if(currentPlayer.Type PlayerType.AI) { StartCoroutine(AITakeTurnCoroutine()); } }4.3 性能优化与内存管理要点即使是一个简单的2D游戏不注意性能也会在低端设备上卡顿。对象池是必须的前面已经强调过。卡牌、特效、甚至UI提示文本都应该使用对象池。避免在Update中做复杂计算AI的搜索算法、牌型识别算法都比较耗时。确保这些计算只在需要时触发如AI回合开始、玩家选牌后验证时而不是每帧都执行。使用Sprite Atlas将UI和卡牌的所有小图打包成图集这是降低Draw Call最有效的方法之一。谨慎使用Find和GetComponent在Start或Awake中缓存常用组件的引用不要在Update里频繁调用。音效管理创建一个AudioManager单例来统一播放音效和音乐。使用AudioSource池来播放短音效避免频繁创建和销毁AudioSource组件。5. 常见问题、调试技巧与项目扩展5.1 开发中遇到的典型问题与解决方案问题1卡牌拖拽或点击选中不灵敏。原因UGUI的点击事件被层级更高的UI元素如透明的背景Panel拦截了。解决检查Canvas下所有UI元素的Raycast Target属性。对于不需要接收点击事件的背景元素取消勾选此选项。确保卡牌预制体上的Image组件勾选了Raycast Target。问题2AI出牌逻辑在某些极端牌型下卡死或出错。原因牌型组合生成算法的递归没有正确的终止条件或剪枝不充分导致组合爆炸。解决日志调试法在AI决策函数入口和递归函数入口打印当前手牌和搜索深度。当出现问题时查看日志就能定位到是哪手牌导致了问题。单元测试为CardLogic识别函数和AI组合生成函数编写单元测试。准备一些特殊的牌型如多个炸弹、超长顺子确保函数返回正确结果。Unity可以使用Unity Test Framework。设置超时在AI决策协程中设置一个最大思考时间如2秒。如果超时则强制选择一个默认的合法出牌如出最小的单张。这能防止游戏完全卡死。问题3游戏打包后卡牌图片丢失或变成粉色。原因Sprite的引用丢失或者图集没有被打包进构建。解决检查CardPrefab上Image组件的Sprite引用是否是动态通过代码赋值的。如果是确保代码中加载资源的路径在打包后依然有效使用Resources文件夹或Addressables。确保Sprite Atlas在Build Settings的Graphics设置中被包含。如果是通过Resources.Load请确认图片资源放在了项目的Resources文件夹内。问题4在部分安卓设备上运行缓慢。原因可能是每帧的GC垃圾回收分配过多。解决使用Unity Profiler分析器连接真机进行性能分析。重点关注GC Alloc垃圾回收分配列。最常见的凶手是字符串拼接、LINQ查询在Update中、以及之前提到的频繁Instantiate/Destroy。用StringBuilder替代字符串拼接缓存LINQ结果使用对象池。5.2 项目扩展方向这个单机版源码是一个很好的起点你可以基于它进行很多有趣的扩展联网功能这是最直接的扩展。你需要设计网络协议可以用Unity自带的Netcode或第三方如Mirror、Photon将GameManager中的本地逻辑判断迁移到服务器权威服务器架构或是在客户端进行预测和校验P2P架构。这涉及到状态同步、断线重连、反作弊等一整套复杂的知识。更多玩法增加“癞子”玩法指定某张牌为万能牌、“明牌”玩法、或者“二人斗地主”变种。这主要考验你对CardLogic牌型识别模块的扩展能力。美术升级替换卡牌和UI资源加入更炫酷的出牌动画、特效使用Particle System或动画系统让游戏品质上一个台阶。数据持久化加入玩家等级、积分、成就系统。使用PlayerPrefs存储本地数据或者连接一个简单的后端服务器。跨平台Unity的优势在于一键打包。你可以尝试将它发布到WebGL网页、iOS或Android平台体验完整的发布流程。5.3 给新手的建议如果你刚拿到这份源码我建议按以下步骤学习先跑起来用Unity打开项目直接点击运行。熟悉一下游戏的基本操作和流程。从数据流跟踪在GameManager中OnPlayCardsButtonClicked方法里打一个断点。然后点击出牌一步步跟踪代码看数据是如何从View到Controller再到Model最后又回到View的。这是理解整个架构最快的方式。修改简单规则尝试修改GameConfig里的初始分数或者改一下CardLogic里关于“火箭最大”的规则比如让炸弹能管火箭看看游戏行为如何变化。动手实现一个小功能比如在UI上增加一个“提示”按钮点击后高亮显示AI建议你出的牌。这需要你理解AI的出牌建议逻辑并把它和UI联动起来。最后游戏开发最大的乐趣在于创造和解决问题。这份源码里肯定还有可以优化的地方也许我的AI策略在你看来很笨也许你有更好的UI设计方案。这正是开源和分享的意义所在——它不是一份标准答案而是一块引玉的砖。希望你在研究和修改它的过程中能获得和我当初开发它时一样的乐趣与成就感。如果在实践中遇到任何具体问题欢迎随时来交流讨论很多时候一个特定的报错信息比泛泛的问题描述更能快速定位症结所在。