☰
Unity2D记忆翻牌游戏开发:状态机设计与源代码实现
2026/10/15 1:45:58 网站建设 项目流程

简介:这份资源是面向Unity初学者与2D小游戏开发爱好者的记忆翻牌游戏完整源代码,基于Unity 2019.4.18f1c1版本与C#语言实现,通过鼠标点击翻牌配对来训练记忆力,适合作为入门练手项目或课堂示例。压缩包整体约62.47MB,包内以C#脚本、Unity场景与资源文件、工程配置及测试相关文件为主,分别承担游戏逻辑、牌面渲染与状态管理、项目设置与单元测试等职责,目录结构清晰便于按模块查阅。目前已有2022人学习下载,热度较为可观。代码覆盖OnMouseDown点击事件处理、枚举状态管理、Sprite Renderer翻牌渲染、Collider碰撞检测、脚本间通信与全局管理者设计,并涉及Canvas、Text、Button等UI元素、Animator翻转动画、Unity Test Runner测试框架及Plastic SCM版本管理等内容。读者可借此掌握Unity 2D开发基本流程,理解翻牌匹配的核心逻辑与常见排错思路,进而提升C#编程与引擎实操能力。

1. 记忆翻牌游戏的底层逻辑:为什么状态机比动画更重要

很多人第一次做记忆翻牌(Memory Card / Matching Pairs),注意力全在卡牌翻转的动画上,结果做到一半发现:连点两张相同的牌能加分,连点三张就乱套;快速点击时两张牌同时翻面,判定直接崩掉。这个问题的根源不在动画,而在状态管理。记忆翻牌的核心其实是一个有限状态机:空闲、翻第一张、翻第二张、比对、回退或消除,六个状态循环流转。Unity2D 里用 C# 写这套逻辑,代码量不大,但状态边界没划清楚,后面加计时、连击、关卡全都得推倒重来。这篇笔记就围绕一份可复现的记忆翻牌源代码结构,把状态机、卡牌数据、翻面判定、洗牌算法和常见翻车点讲透,适合刚接触 Unity2D 的开发者,也适合想重构旧项目的熟手。

2. 卡牌数据结构与状态机设计:先定骨架再写逻辑

2.1 用 ScriptableObject 还是纯 C# 类存卡牌数据

卡牌数据分两层:静态配置(图标、配对 ID)和运行时状态(是否已翻开、是否已匹配)。静态配置我一般用 ScriptableObject,好处是美术换图不用改代码,Inspector 里直接拖。运行时状态放在 MonoBehaviour 或普通类里,避免污染配置资产。

using UnityEngine; // 静态配置:每张卡牌的图标与配对标识 [CreateAssetMenu(fileName = "CardData", menuName = "Memory/CardData")] public class CardData : ScriptableObject { public int pairId; // 配对 ID,相同 ID 视为一对 public Sprite frontSprite; // 正面图标 public Sprite backSprite; // 背面图标,通常所有卡共用 }

这段代码定义了一个可创建的资产类型。pairId是配对判定的唯一依据,不要用 Sprite 引用去比对,因为同一张图可能被多个卡牌复用,引用比对在运行时容易出玄学问题。frontSprite和backSprite分离,是为了后面做翻面动画时能直接换 SpriteRenderer 的 sprite。

运行时状态我习惯单独写一个Card组件挂在预制体上:

using UnityEngine; public class Card : MonoBehaviour { public CardData data; // 指向静态配置 public bool isMatched; // 是否已配对成功 public bool isFaceUp; // 当前是否正面朝上 private SpriteRenderer sr; void Awake() { sr = GetComponent<SpriteRenderer>(); } // 设置正面朝上,仅改视觉,不改逻辑状态 public void SetFaceUp(bool up) { isFaceUp = up; sr.sprite = up ? data.frontSprite : data.backSprite; } }

SetFaceUp只负责视觉切换,逻辑状态由外部状态机控制。这样拆分的好处是:动画可以慢慢播,逻辑判定可以立刻执行,两者不互相阻塞。很多新手把动画播放和状态切换写在一个协程里,结果快速点击时协程叠加,卡牌状态就乱了。

2.2 状态机用枚举还是状态类

记忆翻牌的状态不多,用枚举足够。我见过有人上来就上状态模式(State Pattern),写五六个类,最后发现状态之间跳转条件就那几条,纯属过度设计。枚举加 switch 在这个场景下更直观,也更好调试。

public enum GameState { Idle, // 空闲,等待翻牌 FirstCardFlipped, // 已翻第一张 SecondCardFlipped, // 已翻第二张,等待比对 Comparing, // 比对中,锁定输入 Resolving // 回退或消除动画中 }

状态流转规则:Idle收到点击进入FirstCardFlipped;再收到点击进入SecondCardFlipped;立刻进入Comparing做判定;判定后进入Resolving播动画;动画结束回到Idle。关键点是Comparing和Resolving期间必须锁输入,否则第三张牌会被翻出来。

2.3 输入锁与点击穿透的处理

输入锁不是简单加个 bool,因为动画有时长,协程有延迟。我一般用一个canClick标志配合状态判断:

private bool CanInteract() { return currentState == GameState.Idle || currentState == GameState.FirstCardFlipped; }

只有这两个状态允许点击。SecondCardFlipped之后立刻切到Comparing,点击自然被挡掉。这里有个坑:如果比对逻辑写在 Update 里每帧检测,状态切换会有延迟,快速点击仍可能穿透。所以比对要放在点击回调里同步执行,不要等下一帧。

2.4 洗牌算法:Fisher-Yates 在 Unity 里的落地

洗牌不能用Random.Range随便换,那样分布不均匀。标准做法是 Fisher-Yates:

using System.Collections.Generic; public static class ShuffleUtil { public static void Shuffle<T>(IList<T> list) { for (int i = list.Count - 1; i > 0; i--) { int j = Random.Range(0, i + 1); T temp = list[i]; list[i] = list[j]; list[j] = temp; } } }

参数说明:i从末尾往前遍历,j取[0, i]闭区间,保证每个位置被选中的概率相等。注意Random.Range(int, int)是左闭右开,所以写i + 1。如果误写成Random.Range(0, i),最后一张牌永远不会被换到前面,洗牌结果有偏。

生成牌堆时,每对牌生成两张,塞进列表后洗一次:

List<CardData> deck = new List<CardData>(); foreach (var pair in allPairs) { deck.Add(pair); deck.Add(pair); } ShuffleUtil.Shuffle(deck);

allPairs是关卡配置里选定的配对列表。洗完后按顺序实例化预制体,把data赋值进去。这样牌堆顺序随机,但配对关系不变。

3. 翻牌判定与动画协同:把比对逻辑写对

3.1 点击回调里同步完成比对

比对逻辑必须同步,不能等动画。流程是:点击卡牌 → 翻面 → 判断当前状态 → 如果是第一张,记录并切状态;如果是第二张,立刻比对pairId,然后切到Resolving播动画。

private Card firstCard; private Card secondCard; public void OnCardClicked(Card card) { if (!CanInteract()) return; if (card.isFaceUp || card.isMatched) return; card.SetFaceUp(true); if (currentState == GameState.Idle) { firstCard = card; currentState = GameState.FirstCardFlipped; } else if (currentState == GameState.FirstCardFlipped) { secondCard = card; currentState = GameState.Comparing; StartCoroutine(ResolvePair()); } }

OnCardClicked里先做三重过滤:状态不允许、已翻开、已匹配,全部挡掉。然后翻面,再根据状态决定是记录第一张还是进入比对。注意secondCard赋值后立刻切Comparing,此时CanInteract返回 false,后续点击被挡。

3.2 比对协程与动画时长参数

private IEnumerator ResolvePair() { yield return new WaitForSeconds(0.3f); // 让第二张牌翻面动画播完 if (firstCard.data.pairId == secondCard.data.pairId) { firstCard.isMatched = true; secondCard.isMatched = true; // 播放消除动画,这里用缩放模拟 yield return StartCoroutine(PlayMatchAnimation(firstCard)); yield return StartCoroutine(PlayMatchAnimation(secondCard)); } else { yield return new WaitForSeconds(0.5f); // 停留展示 firstCard.SetFaceUp(false); secondCard.SetFaceUp(false); } firstCard = null; secondCard = null; currentState = GameState.Idle; }

参数说明:第一个0.3f是翻面动画时长,根据实际动画调整,太短会看不到第二张牌翻面,太长会拖节奏。第二个0.5f是错误配对的停留时间,一般 0.4 到 0.6 秒比较舒服。PlayMatchAnimation里做缩放或淡出,动画结束再置isMatched,避免动画没播完就被再次点击。

3.3 用协程还是 DOTween 做翻面

翻面动画有两种做法:协程里手动插值,或者用 DOTween 这类补间库。手动插值可控性高,不依赖第三方库:

private IEnumerator FlipAnimation(Card card, bool faceUp) { float duration = 0.2f; float half = duration / 2f; Vector3 scale = card.transform.localScale; // 第一阶段:压扁到 0 for (float t = 0; t < half; t += Time.deltaTime) { float k = t / half; card.transform.localScale = new Vector3(Mathf.Lerp(scale.x, 0, k), scale.y, scale.z); yield return null; } card.SetFaceUp(faceUp); // 第二阶段:展开回原尺寸 for (float t = 0; t < half; t += Time.deltaTime) { float k = t / half; card.transform.localScale = new Vector3(Mathf.Lerp(0, scale.x, k), scale.y, scale.z); yield return null; } card.transform.localScale = scale; }

duration设 0.2 秒,压扁和展开各占一半。SetFaceUp在压扁到 0 的瞬间调用,视觉上就是牌翻过去。这个写法不依赖任何插件,适合小项目。如果项目里已经用了 DOTween,直接transform.DOScaleX更省事,但要注意 DOTween 的协程等待用WaitForCompletion。

3.4 配对成功后的计分与连击

计分逻辑放在ResolvePair里,配对成功加分,连续成功加连击倍率:

private int comboCount = 0; private int score = 0; private void AddScore(bool matched) { if (matched) { comboCount++; int baseScore = 100; int comboBonus = Mathf.Min(comboCount - 1, 5) * 20; // 连击加成上限 5 层 score += baseScore + comboBonus; } else { comboCount = 0; } }

comboBonus用Mathf.Min封顶,防止连击无限叠加导致分数爆炸。comboCount在配对失败时清零,这是连击系统的标准做法。

4. 避坑与排查:记忆翻牌最常见的五个翻车点

4.1 快速点击导致三张牌同时翻开

现象:手速快的时候,第三张牌在第二张还没比对完就被翻开了。原因:比对逻辑放在协程里,状态切换有延迟,CanInteract在协程 yield 期间仍返回 true。解决:在OnCardClicked里进入Comparing后立刻把状态锁死,协程里不要提前改状态。另外可以在点击回调最前面加一个if (isResolving) return;的双保险。

4.2 相同 Sprite 被误判为不同配对

现象:两张图看起来一样,但判定为不匹配。原因:用Sprite引用或GetInstanceID比对,不同卡牌实例引用了不同的 Sprite 副本。解决:统一用pairId比对,pairId在配置资产里手动指定,不依赖运行时引用。

4.3 洗牌后牌堆顺序固定不变

现象:每次重开游戏,牌的位置一模一样。原因:Random.Range没有播种,或者洗牌在Awake里只执行一次,重开时没重新洗。解决:重开时重新生成牌堆并调用Shuffle,不要复用上次的列表。如果发现随机性仍然差,检查是不是在Start里缓存了顺序。

4.4 动画未播完就允许再次点击

现象:配对失败的牌还没翻回去,玩家已经点了下一张,导致状态错乱。原因:Resolving状态在动画开始时就切回Idle。解决:状态切换放在协程最后一行,动画全部结束后再切。如果动画是 DOTween,用OnComplete回调切状态。

4.5 卡牌预制体上的 SpriteRenderer 排序错乱

现象:翻面时牌面被背景遮住,或者两张牌重叠时顺序不对。原因:2D 项目里 SpriteRenderer 的sortingOrder没统一管理。解决:给卡牌设一个基础sortingOrder,翻面时动态加 1,翻回去减 1。或者用sortingLayer分层,卡牌层永远高于背景层。

5. 进阶技巧:用事件驱动解耦计分与音效

5.1 把配对结果抛成事件

当项目变大,计分、音效、粒子、成就系统都要监听配对结果,直接在ResolvePair里挨个调用会越来越臃肿。我一般定义一个静态事件:

using System; public static class GameEvents { public static event Action<Card, Card, bool> OnPairResolved; public static void RaisePairResolved(Card a, Card b, bool matched) { OnPairResolved?.Invoke(a, b, matched); } }

ResolvePair里只调GameEvents.RaisePairResolved(firstCard, secondCard, isMatch),计分脚本、音效脚本各自订阅。这样加新功能不用改核心逻辑,符合开闭原则。

5.2 音效与粒子的订阅写法

void OnEnable() { GameEvents.OnPairResolved += HandlePairResolved; } void OnDisable() { GameEvents.OnPairResolved -= HandlePairResolved; } private void HandlePairResolved(Card a, Card b, bool matched) { if (matched) { audioSource.PlayOneShot(matchClip); Instantiate(matchParticle, a.transform.position, Quaternion.identity); } else { audioSource.PlayOneShot(mismatchClip); } }

注意OnEnable和OnDisable成对订阅和退订,静态事件不解绑会导致场景切换后空引用。这是血泪经验,我见过太多项目因为静态事件没退订,切场景就报MissingReferenceException。

5.3 用对象池管理卡牌实例

如果关卡牌数多,频繁Instantiate和Destroy会产生 GC 峰值。用对象池复用卡牌:

public class CardPool : MonoBehaviour { public GameObject cardPrefab; private Queue<GameObject> pool = new Queue<GameObject>(); public GameObject Get() { if (pool.Count > 0) { var go = pool.Dequeue(); go.SetActive(true); return go; } return Instantiate(cardPrefab); } public void Return(GameObject go) { go.SetActive(false); pool.Enqueue(go); } }

Get时优先从队列取,取不到才实例化。Return时置灰入队。重开关卡时把所有卡牌Return,而不是Destroy。这个改动在牌数超过 20 对时效果明显,帧率更稳。

5.4 一个验证洗牌均匀性的小技巧

想确认洗牌算法没写错,可以跑一千次统计每张牌出现在第一个位置的概率:

int[] count = new int[deckSize]; for (int t = 0; t < 1000; t++) { var testDeck = new List<int>(original); ShuffleUtil.Shuffle(testDeck); count[testDeck[0]]++; } // 每个位置的概率应接近 1000 / deckSize

如果某个位置的概率明显偏高,说明Random.Range的边界写错了。这个验证方法我每次换洗牌实现都会跑一遍,比肉眼观察靠谱。

最后说个习惯:我写记忆翻牌这类状态机驱动的玩法,永远先把状态流转图画在纸上,标清楚每个状态允许的输入和出口,再动手写代码。状态没理清就写逻辑,后面加功能一定翻车。希望帮到你。

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

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

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

立即咨询