首页 / 资讯中心 / 文章详情

Unity2D记忆翻牌源码拆解:状态机与协程实战

Unity2D记忆翻牌源码拆解:状态机与协程实战 ★ FEATURED ARTICLE
简介一款基于Unity 2019.4.18f1c1开发的2D记忆翻牌小游戏完整源码以C#为主要编程语言实现鼠标点击翻牌、配对判定与记忆训练玩法。项目覆盖Unity2D开发中的典型环节OnMouseDown点击事件、枚举状态管理、Sprite Renderer换图、Collider 2D命中检测、全局脚本跟踪匹配进度、Canvas UI展示得分与结束提示以及Animator翻转动画从场景搭建到逻辑闭环都有清晰体现既适合初学者跟随源码理解小游戏开发流程也适合开发者在此基础上扩展关卡、计分或音效。源码在状态切换、对象通信和事件回调上的处理思路较为直观配合Unity 2019.4稳定版本可直接导入工程查看脚本关联与组件配置帮助理解一个可运行小游戏从零组织的过程。压缩包以zip格式分发整体约62.47MB便于本地保存与二次修改。目前已有2022人学习下载对入门Unity2D和C#游戏逻辑很有参考价值值得逐模块拆解与复用。1. 为什么一个“翻牌”小游戏值得拆状态机才是核心难点光靠网络搜“Unity2D 记忆翻牌”能找到一大堆代码但大多照着抄进真实项目里跑的时候会当场翻车卡片翻开动画还没播完再次点击就穿透到了底层逻辑或者连续双击第三张卡导致队列混乱、配对判定直接错位。这份“【Unity2D】记忆翻牌源代码(C#)”虽然定位是入门小游戏开发但它把“并发点击”和“状态锁”这两个新手最头疼的点封装得非常干净。整套源码解决的问题恰恰是 Unity2D 项目中 UI 事件穿透、协程时序、对象生命周期这三个暗坑。对刚学完 C# 基础想找个完整练手项目的人来说它是绝佳的入门样本对想二次开发卡牌战斗、抽卡模拟器的熟手来说它的状态机骨架能直接抄——毕竟翻牌的逻辑本质就是“限时保存两个状态然后做对比”。这篇笔记就把它的架构、核心代码和踩坑经历完整拆给你看。2. 项目骨架与架构设计一张卡片背后的组件分工2.1 场景层级结构与 GameObject 职责划分这份资源导入后场景层级非常清爽完全走的是“管理类 数据类”的分层套路。根节点是“GameManager”和“Board”GameManager 挂主控脚本处理游戏流程与计分Board 挂布局脚本动态生成卡片网格。卡片预制体Card.prefab的层级是本地开发里最常见的组合一个空节点“Card”挂 C# 脚本 Card.cs底下挂一个子物体“Front”显示卡面图案再挂一个子物体“Back”显示卡背。这种父子拆分好处很直接——翻转动画只做父节点 Card 的 Scale.x 从 1 到 0 再回到 1 的补间中间切换子物体的 Sprite 显示就完成了翻面效果完全不需要用 Animator 做复杂状态机性能开销几乎为零。如果是用射线检测的同学我一般会建议直接把 Collider 放在 Front 和 Back 上这样点击判定能精确到当前显示的那一面避免误触。2.2 脚本目录划分与核心类职责表这份资源的脚本放在 Assets/Scripts 下命名和职责都很直白没有那种把逻辑全塞在一个 Mgr 里的坏味道。我把它拆成一张清单方便你在 IDE 里对照着看脚本文件核心职责关键成员或方法GameManager.cs控制游戏流程洗牌、回合计数、胜负判定StartGame()、EndGame()、int moveCountBoard.cs根据关卡配置实例化卡片网格管理所有卡片的引用CreateGrid(int rows, int cols)、ListCard allCardsCard.cs单张卡片的翻转、点击响应、配对锁定FlipToFront()、isMatched、Collider.enabledAudioManager.cs播放翻开、配对成功、失败音效PlayFlip(),PlayMatch()UI 绑定脚本更新剩余配对数和步数 Text直接引用 TMP_Text 组件这里有一个容易忽略的设计点Board.cs 在生成卡片时会把卡片实例存入一个Dictionaryint, Cardkey 是卡片编号value 是实例然后通过System.Linq里的OrderBy(Guid.NewGuid())对列表做洗牌。这个做法虽然比不上 Fisher-Yates 洗牌算法那么严谨但对于记忆翻牌这种固定数量小规模场景完全够用。如果你往后要扩展成万张卡牌的卡池系统我建议再改写成 Fisher-Yates不过当前项目里这段代码的可读性非常高适合拿来教学。2.3 预设体与资源打包规范资源方面卡面图案直接放在 Sprite 文件夹里Card.cs 通过[SerializeField] private Sprite[] faces;在 Inspector 面板拖拽赋值。这里有个细节值得注意faces数组的下标必须和卡片编号一一对应Board 生成卡片时用card.Init(id, faces[id])传参。这种设计意味着如果你要换卡面只需替换 Sprite 文件夹里的图片资源完全不用改代码逻辑。在资源打包上项目虽然没有按图集Sprite Atlas方式打包但对这个体量的小游戏来说不是问题。不过如果你想移植到手机端我强烈建议把卡面图片放进 Sprite Atlas否则几百张小图会导致 Draw Call 暴涨帧数直接从 60 掉到 20。这个属于项目上线前的常规性能优化步骤后面“避坑”章节我会专门提一句。3. 核心模块拆解翻转动画、配对判定与协程时序3.1 卡片翻转实现Scale 补间与 Sprite 换装打开 Card.cs最核心的翻转逻辑是用协程 帧循环实现的没有用任何 Tween 插件。这段代码是整份项目中最值得抄作业的部分因为它把“动画播放和逻辑切换”的时序控制做到了极致的简单直观// Card.cs 核心翻转逻辑 public void FlipToFront() { if (_isLocked || _isFlipping) return; // 正在翻转或已配对的卡禁止重复触发 StartCoroutine(DoFlip(true)); } private IEnumerator DoFlip(bool faceUp) { _isFlipping true; float duration 0.2f; // 单次翻转总时长 float elapsed 0f; Vector3 scaleFrom transform.localScale; Vector3 scaleTo new Vector3(faceUp ? 1 : 0, 1, 1); // X轴缩放到0 while (elapsed duration) { float t Mathf.Lerp(scaleFrom.x, scaleTo.x, elapsed / duration); transform.localScale new Vector3(t, 1, 1); // 当卡面宽度接近0时切换Sprite视觉上就是翻页效果 if (Mathf.Abs(t) 0.1f) { _frontImage.sprite faceUp ? _face : _backSprite; } elapsed Time.deltaTime; yield return null; // 等待下一帧 } transform.localScale scaleTo; _isFlipping false; _isFaceUp faceUp; }逻辑说明这段代码把“翻转”拆成“缩-放”两个阶段。先在 0 到 0.1 秒内把 X 轴 Scale 缩到接近 0此时卡片的宽度几乎消失趁这个视觉盲区切换 Sprite然后再把 X 轴从 0 放大回 1。整个过程模拟了真实 3D 翻牌的效果但只有 2D 组件参与没有 Z 轴旋转所以不会产生透视畸变。参数说明duration控制翻转速度0.2 秒比较跟手。如果调成 0.5 秒会显得拖沓如果调成 0.05 秒则几乎看不清翻面过程你自己在 Inspector 面板改一下就能感觉到差异。这里我用的是Mathf.Lerp线性插值如果你想要更丝滑的首尾缓动可以把elapsed / duration替换成1 - Mathf.Pow(1 - t, 3)这种三次缓动公式常见做法是在额外写一个EaseOutCubic(float t)静态方法。3.2 配对判定双缓存与状态锁的经典写法Board.cs 里面隐藏着整套项目最值钱的设计——它用一个长度为 2 的“手牌缓存”配合状态锁规避了玩家疯狂连点造成的配对错乱。只要看过这段代码你就能理解为什么说记忆翻牌是状态机入门最好的项目没有之一。// Board.cs 配对核心逻辑 public void OnCardClicked(Card clicked) { // 状态锁1当前正在执行配对检查不允许新点击 if (_isChecking) return; // 状态锁2已配对的卡或正在翻转的卡直接忽略 if (clicked.isMatched || clicked._isFlipping) return; if (_firstCard null) { _firstCard clicked; clicked.FlipToFront(); return; } if (_secondCard null) { _secondCard clicked; clicked.FlipToFront(); // 状态锁3锁定整块棋盘防止检查期间再有第三张卡入队 _isChecking true; StartCoroutine(CheckMatch()); } } private IEnumerator CheckMatch() { // 等待0.4秒给玩家留出看清第二张卡的时间 yield return new WaitForSeconds(0.4f); if (_firstCard.Id _secondCard.Id) { // 配对成功两卡锁定永久显示正面 _firstCard.SetMatched(); _secondCard.SetMatched(); _matchCount; AudioManager.Instance.PlayMatch(); if (_matchCount _totalPairs) { GameManager.Instance.GameWin(); } } else { // 配对失败两张一起翻回去 _firstCard.FlipToBack(); _secondCard.FlipToBack(); AudioManager.Instance.PlayMiss(); } // 检查完毕释放状态锁 _firstCard null; _secondCard null; _isChecking false; }逻辑说明这里的核心机制是“双缓存不变量”——永远只存刚点的第一张和第二张卡。点击第三张卡时因为_isChecking为 true事件被直接挡掉。这个锁的作用至关重要如果没有它玩家快速三连点时第三张卡会覆盖_secondCard的引用导致第一张卡和第二张卡永远配不上对整个游戏流程就卡死了。参数说明WaitForSeconds(0.4f)是留给玩家的“记忆时间”这个 0.4 秒是血泪经验换来的。太短0.1 秒玩家来不及看清牌面挫败感强太长1 秒则游戏节奏拖沓玩起来想睡觉。如果你是做成休闲益智向建议在代码里加一个public float compareDelay暴露给 Inspector让策划自己调。3.3 游戏状态管理从开局洗牌到胜负结算GameManager.cs 负责更上层的状态切换。它维护着一个简单的枚举GameStateReady,Playing,Paused,GameOver。这个枚举在键盘上按Esc时切到 Paused暂停所有协程在配对完成时切到 GameOver 并弹出结算面板。这份资源里 GameManager 用的是单例模式public static GameManager InstanceAwake()里给Instance赋值。这在 Unity 开发中属于常见做法但对这个项目来说其实可以砍掉单例改成让 Board 直接持有 GameManager 的引用。单例虽然写起来方便但会在后续扩展时埋下隐性地雷一旦你做了关卡切换的持久化单例的静态生命期就会成为罪魁祸首。如果是我自己在写这个功能大概率会直接用事件驱动让Board发一个GameOverEventGameManager监听即可但这套源码的做法更朴素更容易让初学状态机的人看明白。洗牌逻辑放在StartGame()里核心就一行_allCards _allCards.OrderBy(g Guid.NewGuid()).ToList()。因为 Guid 在生成时是全局唯一且近乎随机的所以用OrderBy能得到一套还算均匀的乱序排列。如果你想做到“每次进入游戏排列都不一样”记得在StartGame()末尾调用Camera.main.orthographic的重新聚焦否则不同尺寸的网格可能出现穿帮。4. 环境搭建与避坑指南从导入源码到修掉三个常见致命 BUG4.1 从零把源码跑起来的操作步骤拿到这个 Unity2D 记忆翻牌源代码包之后本地运行不需要额外配置任何第三方的 SDK 或插件纯原生 Unity 环境即可。这里列出完整的导入和验证步骤每一步我都做了参数验证保证你可以顺滑地跑起来# 步骤1用 Unity Hub 新建一个 2D 项目建议选择 Unity 2021 LTS 或更高版本 # 步骤2把解压后的 Assets 文件夹直接拖拽到新建项目的 Assets 根目录覆盖合并 # 步骤3场景替换找到 Assets/Scenes/Main.unity双击打开 # 步骤4点击 Play 按钮查看 Game 窗口视角操作说明这里最关键的一步是场景替换因为新建项目自带一个 Untitled 空场景。如果你不打开 Main.unity 而是直接在自带的 Default 场景里点 Play控制台会报一堆NullReferenceException因为场景里没有 GameManager 和 Board 这两个对象。还有一点容易忽略的是音频源AudioManager 场景里的 GameObject 必须挂着 AudioSource 组件且 Play On Awake 要取消勾选否则音频会在场景加载时抢跑播放一个空 clip。跑起来之后你会看到 Board 自动生成 4x4 共 16 张卡片在 Board.cs 的rows和cols字段里改。点击卡片时有音效反馈配对成功会卡住不动配对失败会在 0.4 秒后翻回去画面右上角实时更新的是步数每一次翻开两张算一步和配对进度。如果走到这一步完全顺畅说明环境 100% 正常。4.2 翻车身故一UI 穿透事件导致“点 A 卡翻 B 卡”现象还原在编辑器和真机上测试时点击屏幕左侧的一张卡结果右侧的另一张卡被翻开了点击响应和视觉位置完全对不上。原因分析这是 Unity UI 事件穿透里最经典的玄学问题之一。因为卡片的 Sprite 默认是带 Collider2D 的而场景里又恰好存在一个 EventSystem 自带的 Physics Raycaster。当 Physics Raycaster 和 Graphic Raycaster挂在 Canvas 上同时存在时射线检测会同时穿透 UI 层和物理层导致点击事件被两个层级瓜分。解决办法选中场景里的 EventSystem 对象将 Physics Raycaster 组件禁用只保留 Graphic Raycaster如果卡片用的是 UI Button 的话。或者反过来把卡片当纯世界物体移除 Canvas 下的 Graphic Raycaster用 Physics2DRaycaster 做检测。这份资源默认使用的是世界物体方案所以记住一个口诀世界物体挂 Collider就去掉 Canvas 上的 RaycasterUI 对象挂 Image就别给卡片加 Collider。4.3 翻车身故二协程等待期间对象被销毁导致空引用异常现象还原游戏结束时点击“重新开始”控制台疯狂报错MissingReferenceException画面卡死在结算界面。原因分析在CheckMatch协程中yield return new WaitForSeconds(0.4f)之后访问了_firstCard。如果此刻玩家刚好在配对失败的 0.4 秒延迟窗口期内点击了“重新开始”按钮GameManager 执行StartGame()清空了整个 Board 并重新生成卡片_firstCard指向的是被Destroy()的旧卡片访问它的Id时直接抛出异常。解决办法在协程恢复执行后的第一行加一个空引用保护判断。更严谨做法是在StartGame()里调用StopAllCoroutines()强制中断 Board 上所有未完成的协程。这就是我在代码里加入“软删除”的原因如果世界上有后悔药那一定是StopAllCoroutines()后重新Init。4.4 翻车身故三Screen.width 分辨率适配失效卡片位置飞出屏幕现象还原在 PC 上 1920x1080 全屏运行时一切正常把窗口拉成竖屏或者低分辨率窗口后卡片间距变得异常网格整个跑出屏幕边界。原因分析Board.cs 生成卡片时用的是Screen.width和Screen.height计算间距并用世界坐标固定位置。而 Screen 坐标系在运行时受窗口分辨率变化影响不是均匀缩放导致最外层卡片在极端比例下无法显示。解决办法修改布局逻辑使用正交相机的最小视口尺寸作为基准。一个常见做法是加载时计算Camera.main.orthographicSize乘以宽高比取其中较小值来适配麻将的横向和纵向边界。注意 Unity 默认的 2D 相机orthographicSize是半高值如果你需要适配低端安卓机建议额外加上 Canvas Scaler 的方案但这在纯世界物体静态网格模式下不适用必须换算法。5. 二次开发实战换皮不如换逻辑给游戏加上 AI 记忆体这套源码直接向下兼容的改动是换卡面但那样根本学不到东西。既然核心逻辑已经拆透了我想在这里演示一个真正有价值的改造点不给玩家加 AI 对手而是给游戏加一个“自动演示模式”——让 AI 在玩家犹豫时自动翻出上次匹配过的牌对用来做新手引导或放置挂机观赏。这个功能的需求本质是“让 AI 拥有一套独立于玩家操作的状态机”正好能检验你对这套源码的消化程度。AI 模块其实不需要动 Card.cs只需要在 Board.cs 里增加一个AIPlayer内部类。// Board.cs 中新增的 AI 演示逻辑 public class AIPlayer { private Dictionaryint, Card _memory new Dictionaryint, Card(); private Board _board; public void Remember(Card card) { if (_memory.ContainsKey(card.Id)) { _memory[card.Id] card; // 保留第一次记住的引用 } else { _memory.Add(card.Id, card); } } public IEnumerator MakeMove() { // 第一阶段优先翻出记忆里成对的牌 var pair _memory.FirstOrDefault(kvp kvp.Value ! null !kvp.Value.isMatched); if (pair.Value ! null) { yield return new WaitForSeconds(0.5f); _board.OnCardClicked(pair.Value); } else { // 第二阶段随机翻两张没翻过的 var unknown _board.allCards.Where(c !c.isFaceUp).ToList(); if (unknown.Count 2) { yield return new WaitForSeconds(0.5f); _board.OnCardClicked(unknown[0]); yield return new WaitForSeconds(0.5f); _board.OnCardClicked(unknown[1]); } } } }逻辑说明AI 利用Dictionary存记忆每轮玩家翻牌后Board 把翻开的两张卡传给AIPlayer.Remember()。AI 回合时优先查找_memory中还没配对的卡找到就直接输出点击。找不到就落在 Second 阶段的随机策略。这里我用的是FirstOrDefault的线性查找对 8 对卡片级别可以算是“大炮打蚊子”但如果你扩展成 20 对请换HashSetint存 ID 以维持常数级查找。参数说明WaitForSeconds(0.5f)是 AI 的“思考时间”模拟人类决策的节奏。你可以把这个值暴露成public float aiDelay塞进 Inspector方便做难度分级。如果想要更高级的 AI可以把_memory里存的不只是 ID而是“翻开次数 时间戳”用遗忘曲线决定 AI 是否会出错这块逻辑就值得你后续去慢慢打磨了。这个改造案例证明一件事判断你是否真的理解这套源码不在于你背没背下CheckMatch里的双缓存写法而在于你能否在不动核心机制的情况下横向加出一个独立于开关的状态机。从那以后我每次写完交互逻辑都会强制走一遍“连点第三张、快速点击不同卡片、协程延迟窗口内重置”这三条命令从源头上消除并发事件对状态机的破坏。记忆翻牌这是一个极佳的入口你需要它的双缓存、协程控制和对应锁尽管把它下载下来跑一遍、拆一遍再按自己的思路改一遍希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站