☰
Unity2D入门实战:用UGUI和C#实现记忆翻牌小游戏
2026/10/19 2:19:09 网站建设 项目流程

简介:一款基于Unity 2019.4.18f1c1平台的2D记忆翻牌小游戏完整源代码,面向Unity初学者及需要课程设计或毕业设计参考的开发者,项目通过C#脚本实现了翻牌配对、匹配判断与游戏流程控制,代码结构清晰,可直接运行并二次扩展。压缩包整体约62.47MB,包含运行所需的工程文件与依赖资源,导入Unity即可开始学习。已有2022人学习下载,热度可观。借助该工程可深入掌握OnMouseDown点击事件、枚举类型定义牌面状态、Sprite Renderer切换正反面、Collider 2D检测点击区域、Animator控制翻转动画、Canvas与Text搭建计分和结算界面等关键实践;同时能学习脚本间通信、全局牌局管理器的常见写法,以及如何利用Unity.Mathematics处理坐标与向量运算。对于想快速上手Unity 2D开发、理解面向对象组织游戏逻辑的读者,这是一份兼顾实例与原理的入门资料。

1. 记忆翻牌:为什么它是Unity2D入门绕不开的小项目

打开Unity Hub想新建项目却不知道先写什么,是绝大多数Unity2D入门者的第一个坎。记忆翻牌这个项目恰好是那块最软的敲门砖:玩法规则一眼看懂,却把按钮交互、协程延时、UI布局、数据持久化这些做小游戏必备的技能全串在了一起。它适合刚学完C#语法、想找一个完整可跑项目的初学者,也适合想快速搭一套UGUI交互模板、做换皮练习的开发者。整个项目不依赖物理引擎,不碰网络,跑起来全凭UGUI和十几张图片,但正因为小,每一行代码都值得拆开看一遍。

2. 项目架构拆解:UGUI场景层级与三个脚本的职责边界

先用一张图把整个项目装进脑子里。记忆翻牌只有三个C#文件,但它的分层思路跟大项目没有本质区别,只是被压缩到了极致。这一章先把架构讲清楚,后面再落到具体代码。

2.1 技术选型:为什么是Unity2D + UGUI + C#

这个玩法本身不需要物理系统,不需要寻路,也不涉及复杂的3D模型,所以Unity2D模板是最省事的起点。Unity2D模板自带的正交相机和Sorting Layer已经帮你处理好了“2D画面怎么渲染”的问题,你只需要关心Canvas上的UI怎么摆。

UI层面用UGUI而不是手动摆Sprite,是因为UGUI里的Button组件天生自带点击事件,GridLayoutGroup又能在运行时自动完成网格排布。手动算坐标不是不行,但一旦改了卡牌数量或者屏幕分辨率,坐标计算就会变成一场灾难。用UGUI的话,改一个cellSize参数,所有卡牌自动重新排列,这正是小游戏开发里最看重的那部分效率。

C#这块没什么悬念。Unity的编辑器序列化机制能让数组、Prefab引用直接拖拽绑定,比读配置文件更直观;而且C#的协程和PlayerPrefs正好对应记忆翻牌两个核心需求——延时翻回和最高分存储。说白了,这个组合对入门者就是“少踩坑、高回报”。

2.2 场景层级与资源准备:一张树状图看清全局

场景里的GameObject层级大概是这样的:

  • GameManager(空物体,挂GameManager.cs,作为全局流程控制器)
    • Canvas(UI根节点,Unity自动创建)
      • GamePanel(面板背景,承载游戏主界面)
        • CardGrid(挂GridLayoutGroup,作为所有卡牌的父节点)
        • InfoBar(显示步数和用时的Text)
      • ResultPanel(结算面板,默认隐藏,游戏结束时显示)

CardGrid是所有卡牌GameObject的父节点,GridLayoutGroup的约束模式设置为Fixed Column Count,列数根据配对数调整,比如6对卡牌就是4列3行。InfoBar里放两个Text,或者用一个Text拼字符串,显示“步数:x 用时:x.xs”,后面在GameManager代码里会讲到怎么更新这两个数据。

资源方面需要准备两组图片:一张背面图,以及一组正面图案。正面图案放进一个Sprite数组,数组的索引就是配对ID。比如8对卡牌,就准备8张不同的图案,数组长度是8,每张图案被实例化两次,就组成16张卡牌。自己准备素材时注意一个细节:图片的Sprite Mode要选Single。如果导入的图片默认是Multiple(多图集模式),直接拖进Sprite数组会出现引用错乱,这个问题留到第四章避坑记录里展开。

2.3 脚本职责拆分:谁管数据,谁管表现,谁管流程

项目里脚本的分工非常明确,三个文件各管一件事:

脚本文件职责关键成员生命周期
CardData.cs卡牌数据模型,记录ID、正面图、配对状态id, faceSprite, isMatched纯数据类,无生命周期
Card.cs单张卡牌的表现与点击响应Data, button, cardImageAwake注册点击,Init初始化
GameManager.cs全局流程控制:生成棋盘、判定配对、计时结算cardPrefab, faceSprites, gridRootAwake开局,Update计时,OnCardClicked判定

一开始我把配对判定逻辑直接写在Card类里,结果每张卡都要感知整个棋盘的配对进度,改了几次越写越乱,最后老老实实把流程判断全部收回GameManager。这个拆分原则可以记一下:CardData只描述“这张卡是什么”,Card只负责“这张卡长什么样、被点了怎么表现”,至于“翻开的卡能不能配对、游戏是否结束”这类全局逻辑,统一交给GameManager。这就是一个缩小版的MVC结构,数据、视图、控制器各司其职。

3. 核心玩法实现:从洗牌到配对判定的完整链路

这一章是整个项目最值得读的部分。我按“一张卡从生成到被配对”的完整链路来拆代码,每一段都是可以直接抄作业的级别。

3.1 数据模型:CardData先定义“一张卡是什么”

CardData是整个项目里最不起眼却最重要的类,它决定了卡牌的配对依据和状态记忆。

using UnityEngine; [System.Serializable] public class CardData { public int id; public Sprite faceSprite; public bool isMatched; public CardData(int id, Sprite sprite) { this.id = id; this.faceSprite = sprite; isMatched = false; } }

逻辑说明:这个类保存了两类信息,一是标识信息(id和faceSprite),二是状态信息(isMatched)。id用来判断两张卡是否为同一对,faceSprite是翻开后要显示的画面,isMatched则记录这张卡是否已经被配对成功。

参数说明:id是单纯的自增序号,不存图案名称,避免字符串比较带来的性能开销;faceSprite直接持有Sprite引用,方便Card在翻开时赋值给UI的Image组件。isMatched放在数据层而不是表现层,意味着即使Card对象被销毁重建,配对状态依然能从数据层恢复——这是这个项目里值得长期保留的一个设计习惯。

3.2 Fisher-Yates洗牌:每次开局都不重复

记忆翻牌的核心诉求是“每次牌面位置都不同”,这就要用到洗牌算法。Fisher-Yates是目前最常用的洗牌方案,一次遍历就能得到均匀分布的随机序列。

private List<int> BuildDeck(int pairCount) { List<int> deck = new List<int>(); for (int i = 0; i < pairCount; i++) { deck.Add(i); deck.Add(i); } for (int i = deck.Count - 1; i > 0; i--) { int j = Random.Range(0, i + 1); (deck[i], deck[j]) = (deck[j], deck[i]); } return deck; }

逻辑说明:先按配对数量生成一个包含两两相同数字的序列,比如4对卡牌就是[0,0,1,1,2,2,3,3]。然后从后往前遍历,每次把当前元素和前面任意一个位置的元素交换,实现原地洗牌。

参数说明:pairCount表示有几对卡牌,牌总数是它的两倍。Random.Range的第二个参数是开区间,所以写的是i+1,这样才能保证包含当前元素自身,等于没换也是合法操作。代码里用了元组交换,这是C# 7.0以后的语法,Unity 2018以上都支持;如果碰到老版本编译报错,改成临时变量交换就行。

3.3 SpawnGrid:用GridLayoutGroup免掉坐标计算

拿到洗好的ID序列后,接下来要生成实际的卡牌GameObject。这一步充分利用UGUI的布局能力,完全不需要手动计算坐标。

private void SpawnGrid(int pairCount) { List<int> deck = BuildDeck(pairCount); for (int i = 0; i < deck.Count; i++) { Card card = Instantiate(cardPrefab, gridRoot).GetComponent<Card>(); CardData data = new CardData(deck[i], faceSprites[deck[i]]); card.Init(data, this); spawnedCards.Add(card); } LayoutRebuilder.ForceRebuildLayoutImmediate(gridRoot as RectTransform); }

逻辑说明:按洗牌后的ID顺序逐个实例化卡牌Prefab,每张卡都会被塞进gridRoot这个父节点下。因为GridLayoutGroup已经设好了列数和间距,新生成的GameObject会自动排列到对应格子,这一行代码就省掉了所有手工坐标计算。

参数说明:gridRoot是CardGrid对象的Transform,必须挂在Canvas层级下且带着GridLayoutGroup组件,否则位置不会自动计算。cardPrefab上必须挂Card组件,否则GetComponent会返回null。最后调用的ForceRebuildLayoutImmediate是强制立即重算布局,避免动态生成后卡牌堆叠在左上角。

3.4 配对判定:状态锁与延时翻回

配对判定是整个项目里最容易出Bug的地方。新手写这步时最常见的翻车场景是“连点第三张卡导致逻辑错乱”,所以必须用一个状态锁来防止重入。

public void OnCardClicked(Card card) { if (isProcessing || card.Data == null) return; if (card == firstCard) return; if (card.Data.isMatched) return; card.FlipToFace(); stepCount++; stepText.text = "步数:" + stepCount; if (firstCard == null) { firstCard = card; return; } isProcessing = true; if (firstCard.Data.id == card.Data.id) { firstCard.MarkMatched(); card.MarkMatched(); firstCard = null; isProcessing = false; CheckWin(); } else { StartCoroutine(FlipBackRoutine(card, 0.8f)); } }

逻辑说明:点击事件进入后分三层拦截——isProcessing表示当前正在处理上一对配对的收尾,此时不允许翻新卡;card == firstCard防止玩家反复点同一张卡自己跟自己配对;isMatched避免已配对的卡被重复点击。通过拦截后,先翻面并把步数加一,再判断是“第一张卡”还是“第二张卡”。如果是第二张卡,就比较两者的id:相等则双双标记已配对,不相等则开启一个协程延时翻回。

private IEnumerator FlipBackRoutine(Card secondCard, float delay) { yield return new WaitForSeconds(delay); firstCard.FlipToBack(); secondCard.FlipToBack(); firstCard = null; isProcessing = false; }

逻辑说明:配对失败后不能立刻翻回,否则玩家根本没看清第二张卡长什么样,游戏就变成纯背板猜谜了。0.8秒的延时是手感比较折中的值,既给了观察时间,又不至于拖慢节奏。

参数说明:delay就是翻回前的等待秒数,和协程里的WaitForSeconds一一对应;secondCard是后翻开的第二张卡,第一张卡还是保存在firstCard里的引用。翻回后要把firstCard清空、把isProcessing解锁,流程才能继续。

3.5 胜负结算:步数、计时与最高分持久化

配对成功到一定数量后进入结算逻辑。这里要用到PlayerPrefs做最高分持久化,它是Unity自带的轻量级键值存储方案,适合存最高分这类单点数据。

private void CheckWin() { if (matchedCount < totalPairs) return; isGameOver = true; float finalTime = timer; int best = PlayerPrefs.GetInt("BestStep", 0); if (stepCount < best || best == 0) { PlayerPrefs.SetInt("BestStep", stepCount); PlayerPrefs.Save(); } resultPanel.SetActive(true); resultText.text = $"用时 {finalTime:F1}s,步数 {stepCount}"; }

逻辑说明:每次配对成功后matchedCount加一,当它等于总配对数时说明所有卡牌都已翻开,游戏结束。结算时把当前步数和存储的历史最高步数做比较,若破纪录则写入新的值并立即Save。PlayerPrefs的数据会跨会话保存,重启游戏后依然存在。

参数说明:best的默认值为0,所以“best == 0”的判断是给第一次游戏的玩家开绿灯;Save方法是把数据刷到磁盘,不在调用时写会丢失最后的更新。resultPanel是结算面板的GameObject引用,resultText里用F1格式化符号把浮点时间截到一位小数。

计时器的实现放在GameManager的Update里:

private void Update() { if (isGameOver) return; timer += Time.deltaTime; timeText.text = $"用时 {timer:F1}s"; }

逻辑说明:游戏未结束时每秒累加Time.deltaTime,并把当前用时刷新到界面文本上。Time.deltaTime是上一帧到这一帧的秒数,用它累加是Unity中最常见的计时写法。

4. 跑通与避坑:导入Unity的配置步骤和5条实战记录

代码看懂了不算数,能在编辑器里跑起来才是真的掌握。这一章从环境准备到配置挂载,再到实战中容易踩的坑,全部走一遍。

4.1 版本要求与导入方式

项目基于Unity 2020.3 LTS及以上版本开发,建议直接使用LTS版本打开,稳定性最好。旧版本也不是完全不能跑,但代码里用到了元组交换和字符串插值,两者都需要C# 7.0以上——Unity 2018.3以上基本都满足,再早就需要先手动降级代码。

拿到源码包后有两种导入方式:如果包里是完整Unity项目结构(包含Assets、ProjectSettings等目录),直接用Unity Hub的“添加项目”按钮选择这个文件夹;如果包里只有Assets目录,那就新建一个Unity项目,然后把Assets目录直接覆盖到你新项目的Assets目录里。第二种方式要注意,不要拖进其他目录,否则Unity不会自动识别。

导入完成后的第一件事不是急着点Play,而是先打开场景文件。完整项目结构下,找到Assets/Scenes目录下的主场景并双击打开,确认场景里存在Canvas和GameManager,再点击运行。

4.2 场景配置与引用挂载

场景里的引用关系是手动拖拽绑定的。核心配置项如下表:

GameManager字段类型拖拽目标
cardPrefabCard(Prefab)Card.prefab
faceSpritesSprite[]正面图案数组,按配对ID顺序排列
gridRootTransformCardGrid对象
stepTextTextInfoBar下的步骤Text
timeTextTextInfoBar下的计时Text
resultPanelGameObjectResultPanel对象

还需要确认场景里存在EventSystem。UGUI的Button点击完全依赖EventSystem,缺少它会出现“按钮怎么点都没反应”的诡异现象。如果场景里没有,就在Hierarchy面板右键选择UI下的Event System创建。

卡牌Prefab上的Card组件需要绑定两个引用:cardImage指向卡牌身上的Image组件,backSprite指向背面图案。这两个引用建议在Prefab编辑模式下预先拖好,而不是在运行后动态查找。

4.3 联调验证:怎么确认逻辑真的跑通了

配置完成后,点击Play进入运行模式,按下面的顺序验证:

  • 观察CardGrid下一共生成了多少子物体,数量和配对数呈两倍关系
  • 点击任意一张卡,确认正面图案正常显示,步数+1
  • 点击第二张卡,如果配对成功,两张卡同时变为不可点击状态
  • 如果配对失败,两张卡在0.8秒后自动翻回背面
  • 全部配对完成后,ResultPanel弹出并显示正确的步数和用时
  • 关掉游戏重开,检查历史最高步数是否被保留

如果某一步卡住了,先看Console有没有报错。经常出现的问题是faceSprites数组长度为0或者cardPrefab没有挂Card组件,这类问题基本在Console里都会有明确的NullReferenceException提示。

4.4 避坑记录

记录5个我实际踩过的坑,每个都是“现象→原因→解决”三段式,给后面拿到这个项目的人指条近路。

  • 现象:点第一张卡后再点第二张卡,第二张刚翻面就被翻回去了。
    原因:点击逻辑里没有isProcessing状态锁,第二张卡翻面后,它的点击事件再次被触发,把“第二张”当成了“第三张卡”,触发了失败翻回逻辑。
    解决:在OnCardClicked开头加状态锁,配对判定进行中时直接返回,不处理任何新点击。

  • 现象:玩家点了一下卡牌,这张卡自己和自己做配对,直接显示配对成功。
    原因:Button组件在快速双击时会连续触发两次onClick,第二次点击又传入了同一张卡的引用,而代码没拦截这种情况。
    解决:加card == firstCard的判断,自己不能和自己配对。

  • 现象:运行后所有卡牌都堆在CardGrid的左上角,没有按网格排布。
    原因:动态实例化后没有触发重新布局,GridLayoutGroup的布局计算没跟上新生成的子物体。
    解决:在生成完所有卡牌后调用LayoutRebuilder.ForceRebuildLayoutImmediate强制重算布局。

  • 现象:配对失败的卡牌迟迟不翻回,其他卡牌也点不动了。
    原因:FlipBackRoutine协程挂在Card组件上,而Card所在的GameObject在场景切换或被SetActive(false)时协程被中断,isProcessing永远卡在true。
    解决:把协程放在GameManager上执行,它是常驻对象,不随单张卡牌失活而中断。

  • 现象:自己替换正面素材后,所有卡牌翻开都是同一张图片。
    原因:图片导入时被识别为Multiple模式,拖进数组后只引用了Texture,每个Card拿到的都是图集的第一帧。
    解决:把素材的Sprite Mode改为Single后重新导入,或者在Sprite数组里逐个展开指定对应的子Sprite。

5. 二次开发:难度参数化、动画替换与回归验证

源码跑通只是起点,真正的乐趣在于改成自己顺手的样子。最后这一章给三条最实用的改动思路,顺带说一下怎么验证改动没把原有逻辑改坏。

5.1 难度选择:把固定配对数变成枚举

把GameManager里的pairCount从写死改成从枚举读取,是最快体验“改一处、全盘生效”的改动:

public enum Difficulty { Easy = 4, Normal = 6, Hard = 8 } public Difficulty currentDifficulty = Difficulty.Normal;

开局时用(int)currentDifficulty作为pairCount传入,卡牌数量就跟随难度变化。如果Hard难度下卡牌数量变多,记得同步调整GridLayoutGroup的Constraint Count列数,否则网格会变得很长。

5.2 翻转动画:从秒切到DOTween中间帧

原版卡牌翻面是直接切换Sprite,视觉效果有些生硬。如果装了DOTween插件,可以改成带中间帧的翻转动画:

public void FlipToFace() { transform.DORotate(new Vector3(0, 90, 0), 0.15f) .OnComplete(() => { cardImage.sprite = Data.faceSprite; transform.DORotate(new Vector3(0, 0, 0), 0.15f); }); }

逻辑说明:第一段旋转让卡牌转到侧面,这时用OnComplete回调把正面图片换上去,再转回到正面角度。如果不做中间帧直接旋转,会出现背面图片和正面图片在翻转时穿帮的问题。如果不引入DOTween,也可以用协程在0.15秒内逐渐修改Image的color来做渐显渐隐,效果稍弱但零依赖。

5.3 回归验证清单

改动代码后,每次都要强制走一遍下面的回归流程:

  • 切换Easy/Normal/Hard三个难度,确认开局卡牌数分别是8/12/16张
  • 连点场景:快速点开两张卡后立刻点第三张,确认第三张不参与判定
  • 配对成功与失败各测10次,确认状态锁不会卡死
  • 结算后重启游戏,确认最高步数被正确持久化

我从一开始写完就喜欢直接跑完一把游戏就算完事,后来有一次改难度参数时把isProcessing置位逻辑动错了,玩到一半棋盘卡死,玩家彻底失去操作权。从那以后,我每次改完这种带状态锁的逻辑,都会强制自己先当一把“手速很疯的熊孩子”,对着棋盘连点五分钟再提交。希望帮到你。

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

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

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

立即咨询