Unity记忆翻牌游戏核心实现:状态机、协程与数据分离 简介一款基于Unity 2019.4开发的2D记忆翻牌小游戏完整源代码目标用户是Unity初学者和有C#基础的游戏爱好者用于学习翻牌配对的经典交互逻辑。资源包大小为62.47MB目前未标注具体文件数量与明细核心内容是C#脚本、场景和UI工程文件导入Unity即可运行体验。已有2022人浏览学习适合作为Unity2D入门练手项目。项目通过OnMouseDown实现点击翻牌用枚举管理牌面状态用Sprite切换表现正反面并结合Collider检测点击、全局脚本判断配对结果还包含Canvas得分与结束提示等UI模块。阅读和改造这份代码可以掌握Unity2D从场景搭建、组件交互到逻辑落地的完整流程同时提升C#面向对象设计和脚本通信能力是一份不错的实战参考。1. 记忆翻牌这个小项目真正复杂的不是配对是状态管理我拆过不少 Unity 入门项目记忆翻牌是那种“看着简单、写起来翻车率奇高”的类型。这套 C# 源代码覆盖了从生成 4×4 网格、洗牌、点击翻开、配对判断、合拢到重置的完整闭环功能不花哨但恰好把 Unity 小游戏最需要的基本功都压在里面数据与显示分离、点击状态机、UI 布局与资源开销。它适合三类人想用第一个完整小游戏验证 Unity 掌握度的新手需要课程设计但不想从零憋逻辑的以及想在面试作品里放一个“不炫技但逻辑完整”的 Demo。你真正要啃的不是 Sprite 切换而是三个容易在小项目里被忽略的点卡牌身份与图片解耦、连点状态锁、协程时序。这三件事理顺了这套代码才能真正成为你自己的东西。2. 数据与显示先拆开4×4 网格、Fisher-Yates 洗牌与配对顺序很多初版记忆翻牌最容易烂在“用 Sprite 直接比较配对”。把卡牌数据与显示层拆开是整套代码里最值得先做的一步。2.1 不要把 Sprite 当数据先用 CardData 定义“身份”我一般会在项目里先建一个极简的 CardData 类只存身份信息不碰任何 UI 类型[Serializable] public class CardData { public int cardId; // 配对标识同一对卡牌共用同一个 id public string symbolName; // 记录图案名调试时用 public Sprite faceSprite; // 正面显示图属于“表现层” }逻辑说明对比时只比cardId不比 Sprite 引用。两个 4×4 牌组共有 8 对cardId 取值 0 到 7。faceSprite虽然在同一个类里但它只是给 UI 赋值的资源不能当作配对键。如果直接把 Sprite 引用拿来相等比较一旦后续做图集重贴、动态换肤或资源卸载配对会莫名失效而且问题极难查。参数说明cardId必须在生成牌组时成对分配一次赋值后不再改变symbolName可以不参与逻辑纯粹为了在 Inspector 里看数据时能认出是哪张牌。2.2 生成卡组成对插入 Fisher-Yates 洗牌洗牌这一步有三个常见方案反复随机去重、OrderBy(Guid.NewGuid())、Fisher-Yates。前两种在小样本下能用但会带来“相邻重复”或“概率不均匀”的观感问题。我现在固定用 Fisher-Yatesprivate ListCardData CreateCardDeck(int rows, int cols) { int pairCount (rows * cols) / 2; ListCardData deck new ListCardData(); for (int i 0; i pairCount; i) { deck.Add(new CardData { cardId i }); deck.Add(new CardData { cardId i }); } // Fisher-Yates 洗牌从尾部开始逐个与随机位置交换 for (int i deck.Count - 1; i 0; i--) { int j Random.Range(0, i 1); var temp deck[i]; deck[i] deck[j]; deck[j] temp; } return deck; }逻辑说明先保证每张牌有且只有一个配对副本再用 Fisher-Yates 打乱顺序。Random.Range(0, i 1)的左闭右开区间保证了j能取到i自身这是标准算法里最容易写错的地方。很多初版代码把Random.Range(0, i)写进去导致洗出来永远是偏的。参数说明rows * cols必须为偶数4×4 就是 16 张。生成牌组时我习惯把Sprites的赋值放在 UI 绑定阶段而不是塞进 CardData 构造函数这样换皮肤、换图集都不用重新洗牌。2.3 GridLayoutGroup 布 4×4 网格参数给全别写死坐标4×4 网格不需要手算坐标用 UnityEngine.UI 的 GridLayoutGroup 最省事。新建一个空物体挂 GridLayoutGroup再动态生成 16 个子按钮参数取值如下。参数推荐值说明Cell Size90 × 90在 1920×1080 参考分辨率下4 列 90 宽加上间距刚好居中Spacing8卡间距横竖统一 8Child AlignmentMiddle Center让 4×4 整体居于容器正中ConstraintFixed Column Count, 4固定 4 列行数由子物体数量决定生成子物体时推荐用代码从预制体 Instantiate而不是在场景里手摆 16 张卡。绑定数据时按顺序来for (int i 0; i deck.Count; i) { GameObject cardObj Instantiate(cardPrefab, gridContent); CardButton card cardObj.GetComponentCardButton(); card.SetCardData(deck[i]); Button btn cardObj.GetComponentButton(); btn.onClick.AddListener(() OnCardClicked(card)); }逻辑说明cardPrefab的结构建议是“根节点挂 CardButton Button下面两个子节点分别放正面 Image 和背面 Image”。不要在根节点上直接挂 Image 显示正面否则翻转动画和射线检测会打架。参数说明gridContent是挂 GridLayoutGroup 的 RectTransform子物体 Instantiate 后会自动排布。GridLayoutGroup 只负责布局不负责点击逻辑按钮响应靠onClick.AddListener闭包里捕获的是局部变量card不会出现 for 循环闭包共享问题。2.4 配对判定放在表现层之前记录的是 cardId不是 GameObject配对逻辑要形成“先判身份、后做表现”的习惯。点击第二张卡后先拿两个 CardData 的 cardId 比较再决定播放“配对成功”还是“合拢”动画private IEnumerator EvaluatePair(CardButton first, CardButton second) { bool isMatch first.CardId second.CardId; // 先留一点时间让第二张牌翻开再做判定 yield return new WaitForSecondsRealtime(0.35f); if (isMatch) { first.LockMatched(); second.LockMatched(); } else { first.SetFace(false); second.SetFace(false); } }逻辑说明只有CardId参与配对比较。LockMatched会把按钮的interactable置为 false并把卡状态改为已匹配防止后续再点到。判定完成前不管结果是什么都要先把状态机切到“忙”状态这是下一章的重点。参数说明0.35 秒是人眼看清第二张牌的最短反馈间隔。数值太小会让人觉得游戏没有反馈太大又拖节奏。如果卡牌图案本身比较复杂可以调到 0.4 到 0.5 秒。3. 点击状态机与协程从“if 套 if”到最小可维护实现记忆翻牌最容易失控的就是点击节奏。没有状态锁时玩家快速乱点两卡未判定完就点第三张整个流程直接崩成状态错乱。这种问题靠加bool临时变量往往越补越乱正确做法是引入一个显式状态机。3.1 两个卡位就够为什么用全局状态而不是计数变量先定义一个枚举把游戏当前所处阶段说清楚public enum FlipGameState { Idle, // 等待玩家翻第一张 WaitingSecond, // 第一张已翻开等待第二张 CheckingPair, // 两张都在场上正在判定 FlippingBack, // 配对失败正在合拢 GameEnd // 全部套牌配完 }逻辑说明很多初版用一个int _clickCount做分支第一张置 1、第二张置 2、然后归零。表面能用但只要有一次异常点击计数器就漂移了。枚举状态把“允许点击”和“正在处理”明确分开Idle允许第一张WaitingSecond允许第二张其余状态一律禁止入场。参数说明FlippingBack状态不该只被 UI 动画使用协程里也应显式切换。这样哪怕合拢动画中间被打断后续逻辑也能根据状态判断该不该放行。3.2 点击入口第一行就拦截非 Idle 状态核心点击回调写成一个入口所有防御都收在开头public void OnCardClicked(CardButton card) { if (_state ! FlipGameState.Idle _state ! FlipGameState.WaitingSecond) { return; } if (card null || card.IsFlipped || card.IsMatched) { return; } card.SetFace(true); if (_firstCard null) { _firstCard card; _state FlipGameState.WaitingSecond; } else { _secondCard card; _state FlipGameState.CheckingPair; StartCoroutine(EvaluatePair(_firstCard, _secondCard)); } }逻辑说明第一层拦截把“判定中”“合拢中”“游戏结束”全部挡在外面从源头杜绝第三张牌被翻开。第二层拦截防的是同一张牌连点两次IsFlipped在 SetFace(true) 之后立即置 true所以同一张卡不可能成为自己的第二张。参数说明IsMatched是配对成功后由 LockMatched 设置的状态。这时按钮虽然还能收到点击事件但事件在入口直接 return不会进逻辑。比单纯把按钮gameObject.SetActive(false)安全。3.3 CheckPair 协程延时、配对、合拢与复位判定流程我用一个协程承载因为它有自然的延时点private IEnumerator EvaluatePair(CardButton first, CardButton second) { // 让第二张卡完全翻开后再判定 yield return new WaitForSecondsRealtime(0.35f); if (first.CardId second.CardId) { first.LockMatched(); second.LockMatched(); _matchedCount; if (_matchedCount 8) { _state FlipGameState.GameEnd; } } else { _state FlipGameState.FlippingBack; yield return new WaitForSecondsRealtime(0.4f); first.SetFace(false); second.SetFace(false); } _firstCard null; _secondCard null; _state FlipGameState.Idle; }逻辑说明配对成功时不等待额外时间直接标记匹配并检查游戏是否结束配对失败时先停在 FlippingBack 状态让玩家看清两张卡再翻回背面。最后统一清理两个卡位引用并把状态恢复 Idle下一轮点击自然放行。参数说明失败后的 0.4 秒是“反悔缓冲”。如果这里直接合拢玩家会觉得卡还没看清就消失超过 0.6 则拖慢节奏。注意这里用的是 WaitForSecondsRealtime 而不是 WaitForSeconds因为有些项目会在暂停菜单把 Time.timeScale 设成 0WaitForSeconds 会跟着停住导致协程卡在合拢阶段。3.4 卡片组件分离与重新开始时的协程清理状态机不应散落在单个 MonoBehaviour 里。我一般拆两个类CardButton 管单卡表现GameManager 管全局状态。public class CardButton : MonoBehaviour { public int CardId { get; private set; } public bool IsFlipped { get; private set; } public bool IsMatched { get; private set; } private Image _faceImage; private Image _backImage; public void SetCardData(CardData data) { CardId data.cardId; _faceImage.sprite data.faceSprite; SetFace(false); // 初始全部背面朝上 } public void SetFace(bool showFace) { IsFlipped showFace; _faceImage.enabled showFace; _backImage.enabled !showFace; } public void LockMatched() { IsMatched true; GetComponentButton().interactable false; } }逻辑说明CardId用private set保证外部只能赋值一次SetFace用Image.enabled切换而不是 SetActive避免隐藏 GameObject 后按钮失联。LockMatched直接关掉按钮的可交互性比用 bool 记录更硬。重新开始时最容易漏的是协程清理private void ResetGame() { StopAllCoroutines(); _state FlipGameState.Idle; _firstCard null; _secondCard null; // 逐张重置卡片数据与外观 }逻辑说明如果上一局有一段合拢协程还没跑完用户就点了重置旧协程会在新牌局里继续执行把新卡翻回去看起来就像“残影”一样。StopAllCoroutines 必须在重新生成卡组之前调用。4. 翻转动画、图集与 Canvas Scaler把 UI 层级和内存一并理顺这套项目的翻转动画没有依赖第三方插件用一行 Scale 就能做出来但换面时机和层级设计有几个容易踩的坑。4.1 翻转动画Scale.x 从 1 到 0 再展开换面时机是命门我用一个简单的 ScaleX 协程模拟竖轴翻转第一阶段把 x 从 1 缩到 0第二阶段把 x 从 0 展开到 1。中间切面。private IEnumerator ScaleX(RectTransform rt, float from, float to, float duration) { float t 0f; while (t duration) { t Time.deltaTime; float p Mathf.Clamp01(t / duration); p Mathf.SmoothStep(0f, 1f, p); Vector3 scale rt.localScale; scale.x Mathf.Lerp(from, to, p); rt.localScale scale; yield return null; } } private IEnumerator FlipCard(CardButton card) { RectTransform root card.transform as RectTransform; // 第一阶段缩到 0 yield return StartCoroutine(ScaleX(root, 1f, 0f, 0.12f)); // 中线换面 card.SetFace(true); // 第二阶段展开到 1 yield return StartCoroutine(ScaleX(root, 0f, 1f, 0.12f)); }逻辑说明SetFace用Image.enabled切换正反面而不是把 GameObject 删除。换面发生在 x 为 0 的瞬间理论上两张图都不会被看到。如果换面过早正面会在收缩阶段露出一条边过晚则背面已经在展开阶段出现闪烁。参数说明0.12 秒半程、总时长 0.24 秒这个速度配合 0.35 秒判定延时比较自然。Mathf.SmoothStep让动画不是线性而是两头缓、中间快视觉上更像真实翻转。注意 pivot 必须保持在中心也就是 (0.5, 0.5)如果预制体根节点 pivot 设成左下角缩放到 0 时卡片会向锚点方向偏移。4.2 图集和内存16 张单独 Sprite 不如一张大图很多初学者会拖 16 张独立 PNG 进工程每一张都生成独立纹理。这类做法能跑但资源管理上不划算。更推荐把 8 对图案放进一张 512×512 图集用 Sprite Editor 切成 4×4。方案纹理数量内存估算备注16 张独立 PNG16约 16 × 128 × 128 × 4 字节 ≈ 1 MB每张都要单独加载、单独进 GPU容易造成 Draw Call 翻倍一张 512×512 图集1512 × 512 × 4 字节 ≈ 1 MB一次加载四个精灵共用同一纹理合批更简单一张 1024×1024 图集1约 4 MB适合后续扩展更多牌面不建议一上来就开 2048逻辑说明表里的内存估算是 RGBA32 不开启 mipmap 的粗略值。独立贴图方案的内存总量并没有大多但会让图集合批失效、加载次数变多。对 4×4 记忆翻牌来说512 图集最合适因为刚好能放下 16 张 128×128 的格子还留了少量边距。参数说明切成 4×4 后每个 Sprite 的 PPU 保持默认只要在 CardButton.SetCardData 里直接赋给_faceImage.sprite就行。不要把图集的 Sprite 再单独导出为多张 PNG那样又绕回独立贴图方案。4.3 Canvas Scaler 参考分辨率与动态 cellSize这套项目不写死坐标而是交给 CanvasScaler。新建 UI Canvas 时的参数如下。Canvas Scaler 参数推荐值说明UI Scale ModeScale With Screen Size让 UI 按屏幕比例统一缩放Reference Resolution1920 × 1080横屏项目竖屏改 1080 × 1920Screen Match ModeMatch Width Or Height避免宽高比变化时 UI 被拉得过分Match0.5横竖权重取中间4×4 卡面在绝大多数屏幕都居中仅靠 CanvasScaler 还不行因为宽高比不同比如 iPad 4:3网格要保持“卡片不会溢出屏幕”就必须动态算尺寸。我在生成网格前跑一段适配RectTransform canvasRect canvas.GetComponentRectTransform().rect; float spacing 8f; float cellSize (canvasRect.width - (cols - 1) * spacing) / cols; gridContent.cellSize new Vector2(cellSize, cellSize); gridContent.spacing new Vector2(spacing, spacing);逻辑说明canvasRect.width取的是 CanvasScaler 计算后的参考宽度不是物理屏幕像素所以这套代码在横竖屏切换后都能保持四列填满可用宽度不会出现“横屏居中、竖屏挤到一边”的问题。参数说明如果觉得卡片太大可以把canvasRect.width改成canvasRect.width * 0.9f。这里算出来的是宽度方向的最大值高度方向最好也做个限制取两者中较小的值作为最终尺寸避免在非常扁的屏幕上卡片竖向溢出。5. 常见问题排查连点开三张、叠影闪现、残留协程成谜5.1 快速连点翻开第三张现象第一张翻开后玩家不等第二张动画播完快速连点另一张卡片第三张也被翻开随后两张合拢状态乱掉。原因点击入口只在_firstCard null时做拦截没有检查全局状态第二张判定协程运行期间_state仍停留在 Idle 或 WaitingSecond新点击自然放行。解决点击函数第一行强制判断状态必须等于 Idle 或 WaitingSecond。更稳妥的做法是把整个 UI 面板的 Button 做成一个“可点击区间”if (_state FlipGameState.CheckingPair || _state FlipGameState.FlippingBack || _state FlipGameState.GameEnd) { return; }5.2 翻转中途正反面叠影现象卡牌翻转缩放过程中正面和背面同时可见出现半透明叠影或边缘闪烁。原因换面时机发生在 scale.x 还比较大的时候比如缩放到 0.3 时就切换正反两个 Image 在剩余帧内同时在渲染。解决换面严格放在 scale.x 接近 0 的节点也就是第一阶段协程完全结束之后。如果仍然闪烁检查两张 Image 是否都挂在根节点下正确结构应该是根节点做缩放正面和背面都作为根节点的子物体且初始状态只有一个启用另一个禁用。5.3 匹配成功后卡牌还能被点击现象两张卡配对成功随后玩家点击已匹配的卡片它竟然还能翻开甚至会影响下一轮配对。原因匹配成功只改了IsMatched标记但点击入口没有读取它按钮的 interactable 也没有关闭。解决LockMatched()里同时做两件事置IsMatched true并把 Button 的interactable设为 false。写入后OnClick 事件不会再触发即使外部用代码调用OnCardClicked入口处的card.IsMatched也能兜住。5.4 重新开始后上一局动画接着跑现象点“重新开始”后新牌局刚生成某些旧卡突然自动翻面或合拢状态显示混乱。原因ResetGame 只重新生成了牌组没有停掉正在运行的协程。旧协程持有上一局的 CardButton 引用在新局中继续 yield 后执行SetFace。解决ResetGame 第一行调用StopAllCoroutines()然后再清空_firstCard、_secondCard和_state。如果项目后续有多个管理器各自开协程建议给 GameManager 单独开一个专用协程对象优先停那个对象而不是全局停止。5.5 横屏改竖屏后卡片溢出或间距不均现象1920×1080 横屏一切正常切到 1080×1920 竖屏或 iPad 4:3 后网格整体偏左、卡片溢出屏幕或间距明显不一样。原因GridLayoutGroup 的 cellSize 写死成了 90×90间距 8。CanvasScaler 只能等比缩放整个 UI无法重新计算列宽与高宽比差异。解决启动时按 4.3 里的公式动态计算 cellSize。记得在横竖屏切换事件里重新执行一次适配函数否则切屏后旧的 cellSize 还是会在新分辨率下使用。6. 再进一步把翻牌逻辑扩成关卡化小框架这套记忆翻牌玩到能稳定运行之后下一步不是加音效而是把“死参数”变成配置。做法是把行列数、卡组类型、翻牌延时、允许步数抽到一个 ScriptableObject 关卡配置里[CreateAssetMenu(fileName LevelConfig, menuName MemoryGame/LevelConfig)] public class LevelConfig : ScriptableObject { public int rows 4; public int cols 4; public int moveLimit 40; public float revealDelay 0.35f; public float flipDuration 0.24f; public Sprite[] facePatterns; }GameManager 在 Start 时读这份配置用rows和cols生成卡组用flipDuration驱动动画用moveLimit做失败判定。这样同一个项目里只需要拖出多个 LevelConfig 资产就能做 4×4、6×6 甚至更高难度的关卡不用改一行逻辑代码。我把扩展点主要放在三处一是设置动画时长参数而不是写死 0.12 秒二是把卡牌图标从代码里解耦放进配置资产三是用 GameEnd 状态统一结算而不是在匹配计数器达到阈值时直接闪现结果。配合上一章的状态机这套结构后续接菜单、结算面板、存档都不会推翻重来。我有一次在调竖屏适配时为了省事把 cellSize 直接写死成 90结果在宽屏设备上一切正常、一到竖屏就溢出屏幕。从那以后我拿到这类小项目都会先检查两件事数据是否和 Sprite 分离、点击入口是否锁了全局状态。只要这两个点不塌后面加关卡、加倒计时、加音效都只是体力活。希望帮到你。本文还有配套的精品资源点击获取