Unity卡牌状态机设计:状态驱动移动与工程化洗牌实现
2026/9/16 2:16:48 网站建设 项目流程

简介:本资源是一个面向编程初学者与游戏开发入门者的纸牌游戏核心机制实践项目,聚焦Rummy类卡牌游戏中最关键的牌移动逻辑与洗牌算法实现,帮助学习者掌握数据结构设计、随机算法应用及游戏状态管理等基础能力。压缩包共70个文件,含18个Unity资源文件(asset)、15张界面/图标PNG图、26个元数据(meta)及3个核心C#脚本(cs),完整呈现Deck、Card、Player、Game四大模块的代码组织与交互逻辑;整体仅105KB,轻量易读,适合逐行分析源码。已有135人学习下载,项目虽为命令行风格简易实现,但代码结构清晰、注释充分,涵盖Fisher-Yates洗牌实现、合法移动判定、异常操作反馈等关键细节,并附LICENSE、README.md与Unity工程配置文件,便于快速导入与二次拓展。

1. 这不是一个“纸牌游戏Demo”,而是一套可复用的卡牌状态机骨架:从RummyGame_.zip看Unity中牌面移动、洗牌与手牌管理的底层建模逻辑

你解压 RummyGame_.zip 后看到的不是一堆乱序的 Sprite 和空脚本,而是一个刻意精简但边界清晰的卡牌交互契约:每张牌有明确的State(闲置/选中/拖拽中/目标区/弃牌堆),移动不是靠Transform.position硬拖,而是通过CardMover统一调度路径点;洗牌不调用System.Random.Shuffle()就完事,而是封装成Shuffler类,支持 Fisher-Yates + 自定义权重(比如让 Joker 更难被抽到);手牌区域不是 UI Panel 堆叠,而是HandLayout组件按CardSlot网格动态分配位置并响应缩放。这套机制不绑定 Rummy 规则——换掉RummyRuleValidator,它就能跑 Gin Rummy、Canasta 甚至自定义的教育类配对游戏。适合刚脱离“拖一个 Button 改 Text”阶段的 Unity 中级开发者:你需要理解IStatefulCard接口如何解耦渲染与逻辑,需要知道CardPileStack<Card>ObservableList<Card>两种实现分别在什么场景下更稳,也需要亲手调试DragHandler在高 DPI 屏幕上因CanvasScaler导致的偏移误差。这不是教你怎么赢牌,而是教你把“牌会动、能洗、有归属”这件事,写成别人能继承、能测、能换皮的代码。

2. 用 CardState 和 CardMover 实现可预测的纸牌移动:从鼠标拖拽到动画路径的三层控制流

纸牌移动表面是视觉反馈,底层是状态驱动的行为调度。RummyGame_.zip 的核心不在CardViewOnMouseDown,而在CardState枚举与CardMover的协同设计。CardState定义了六种合法状态:Idle(未激活)、Selected(已点选但未拖)、Dragging(鼠标按下中)、MovingToTarget(正在飞向目标区)、InTargetZone(已落位)、Discarded(进入弃牌堆)。关键在于:状态变更必须由CardMover统一触发,而非直接赋值。这避免了CardView自行切换状态导致的 UI 与逻辑不同步。

2.1 CardMover 如何接管移动全流程:从输入捕获到路径插值

CardMover是单例服务,注册所有可交互卡牌。当CardView.OnBeginDrag被调用时,它不直接修改transform.position,而是向CardMover提交一个MoveRequest

// CardView.cs public void OnBeginDrag(PointerEventData eventData) { // 不直接操作 transform! CardMover.Instance.RequestMove(this, MoveType.DragStart, eventData); }

CardMover.Update()每帧检查请求队列,对Dragging状态的牌执行物理模拟:

// CardMover.cs private void UpdateDraggingCards() { foreach (var card in draggingCards) { Vector3 targetPos = Camera.main.ScreenToWorldPoint(Input.mousePosition); targetPos.z = card.transform.position.z; // 保持 Z 层级 // 使用平滑阻尼而非 Lerp,避免急停 Vector3 smoothPos = Vector3.SmoothDamp( card.transform.position, targetPos, ref card.dragVelocity, 0.15f // 阻尼时间,越小越灵敏,越大越跟手 ); card.transform.position = smoothPos; } }

注意SmoothDampdragVelocity必须是每个CardView的实例字段,不能共用。否则多张牌同时拖拽时会相互干扰速度缓冲。

2.2 移动终点判定:用 Collider2D + LayerMask 实现精准落点识别

RummyGame_.zip 的TargetZone并非简单OnDrop,而是基于Collider2D的持续检测。TargetZone继承MonoBehaviour并挂载BoxCollider2D,其Layer设为TargetZone(需在 ProjectSettings > Tags and Layers 中预设)。CardMoverFixedUpdate中执行射线检测:

// CardMover.cs private void CheckDropTargets() { foreach (var card in movingToTargetCards) { // 从卡牌中心向下发射 2D 射线,只检测 TargetZone 层 RaycastHit2D hit = Physics2D.Raycast( card.transform.position, Vector2.down, 0.5f, LayerMask.GetMask("TargetZone") ); if (hit.collider != null && hit.collider.CompareTag("TargetZone")) { // 触发落点逻辑,而非立即设置 position card.OnLandInTargetZone(hit.collider.gameObject); } } }

CardView.OnLandInTargetZone()内部调用CardMover.Instance.RequestMove(this, MoveType.Land, targetZone),由CardMover统一执行最终位移和状态切换。这种设计使落点逻辑可测试(MockPhysics2D.Raycast返回值)、可扩展(添加SnapToGrid选项只需改MoveType.Land处理逻辑)。

2.3 动画路径生成:Bezier 曲线让洗牌和发牌有“抛物线感”

RummyGame_.zip 的Shuffler类在洗牌后调用CardMover.MoveCardsAlongPath(),传入一组CardPath对象。CardPath封装三次贝塞尔曲线参数:

public struct CardPath { public Vector3 start; public Vector3 end; public Vector3 control1; // 起点切线方向 public Vector3 control2; // 终点切线方向 public float duration; // 总耗时(秒) }

CardMover使用Mathf.SmoothStep计算贝塞尔插值:

// CardMover.cs public IEnumerator MoveAlongPath(CardView card, CardPath path) { float elapsed = 0f; while (elapsed < path.duration) { float t = elapsed / path.duration; float u = 1f - t; Vector3 pos = u*u*u * path.start + 3f * u*u * t * path.control1 + 3f * u * t*t * path.control2 + t*t*t * path.end; card.transform.position = pos; elapsed += Time.deltaTime; yield return null; } card.transform.position = path.end; // 强制归位防浮点误差 }

提示control1control2的长度决定曲线弯曲度。实践中control1 = start + Vector3.up * 1.2fcontrol2 = end + Vector3.up * 0.8f可生成自然抛物线,比直线移动更具“纸牌被抛起”的观感。

3. 洗牌机制的工程化封装:Fisher-Yates + 权重控制 + 可回滚的 ShuffleHistory

洗牌常被简化为List<T>.Shuffle(),但 RummyGame_.zip 的Shuffler类暴露了三个生产级需求:可复现性(调试时需固定随机种子)、权重干预(某些牌型需降低出现概率)、操作回退(玩家点击“重洗”需还原上一次状态)。Shuffler通过ShuffleHistory栈和WeightedDeck结构满足这些。

3.1 Fisher-Yates 实现与种子控制:确保每次洗牌可复现

Shuffler不依赖System.Random默认实例,而是持有一个Random实例,并允许外部注入种子:

public class Shuffler { private Random rng; public Shuffler(int? seed = null) { rng = seed.HasValue ? new Random(seed.Value) : new Random(); } public void Shuffle<T>(IList<T> list) { for (int i = list.Count - 1; i > 0; i--) { int j = rng.Next(0, i + 1); // 注意:上限是 i+1,非 i if (i != j) { T temp = list[i]; list[i] = list[j]; list[j] = temp; } } } }

关键细节rng.Next(0, i + 1)的上限必须是i + 1,否则第 0 个元素永远无法被交换到末尾。这是 Fisher-Yates 标准实现的易错点。

3.2 权重洗牌:用 WeightedDeck 支持 Joker 低频出现

WeightedDeck不是对牌直接排序,而是构建“权重池”。假设一副牌含 4 张 Joker,希望其出现概率降为 1/10(原为 4/52≈7.7%):

public class WeightedDeck<T> { private List<(T item, float weight)> weightedItems; private float totalWeight; public WeightedDeck(IEnumerable<(T item, float weight)> items) { weightedItems = new List<(T item, float weight)>(items); totalWeight = weightedItems.Sum(x => x.weight); } public T Draw(Random rng) { float pick = rng.NextDouble() * totalWeight; float cumulative = 0f; foreach (var (item, weight) in weightedItems) { cumulative += weight; if (pick <= cumulative) return item; } return weightedItems.Last().item; // fallback } }

Shuffler中,先用WeightedDeck抽出新顺序,再用 Fisher-Yates 微调相邻牌位置,兼顾概率控制与随机性。

3.3 ShuffleHistory:记录洗牌前状态,支持一键撤销

ShuffleHistory是一个栈,存储每次洗牌前的List<Card>快照(深拷贝):

public class ShuffleHistory { private Stack<List<Card>> history; private const int MAX_HISTORY = 10; // 防内存泄漏 public void RecordBeforeShuffle(List<Card> originalDeck) { var snapshot = new List<Card>(); foreach (var card in originalDeck) { snapshot.Add(new Card(card.Suit, card.Rank, card.IsFaceUp)); // 浅拷贝值类型字段 } history.Push(snapshot); if (history.Count > MAX_HISTORY) history.Pop(); } public List<Card> UndoLastShuffle() { return history.Count > 0 ? history.Pop() : null; } }

ShufflerShuffle()前调用history.RecordBeforeShuffle(deck),UI 按钮绑定shuffler.UndoLastShuffle()即可还原。注意:此处Card必须是struct或不可变class,否则快照会随原对象修改而失效。

4. Assets 目录结构与资源加载策略:为什么 RummyGame_.zip 的 Resources 文件夹里只有 prefab?

RummyGame_.zip 的Assets/Resources下仅存CardPrefab.prefabTargetZone.prefab,没有一张 PNG 或音频文件——这并非疏漏,而是刻意采用Addressable Assets System的轻量替代方案:所有 Sprite 通过SpriteAtlas打包,运行时按需加载。这种结构规避了Resources.Load()的反射开销与内存泄漏风险,也解释了为何你在项目中搜不到"../assets/grenade (1024x128)[frames=8].png"这类路径错误——它根本不存在于该工程。

4.1 SpriteAtlas 构建与引用:用图集替代散图加载

Assets/Sprites/CardAtlas.spriteatlas包含全部 52 张牌 + Joker 的 Sprite。CardView通过SpriteRenderer.sprite直接引用图集内 Sprite:

// CardView.cs public class CardView : MonoBehaviour { [SerializeField] private SpriteAtlas cardAtlas; // Inspector 中拖入 Atlas [SerializeField] private string spriteName; // 如 "hearts_ace" public void SetCardSuitAndRank(Suit suit, Rank rank) { string key = $"{suit.ToString().ToLower()}_{rank.ToString().ToLower()}"; Sprite sprite = cardAtlas.GetSprite(key); if (sprite != null) { GetComponent<SpriteRenderer>().sprite = sprite; } else { Debug.LogError($"Sprite '{key}' not found in atlas"); } } }

提示SpriteAtlasInclude in Build必须勾选,且Packing Tag统一设为CardSprites,否则GetSprite()返回 null。图集构建日志可在Window > Rendering > Sprite Atlas查看。

4.2 Prefab Variants 优化手牌布局:避免为每种牌型建 prefab

Assets/Prefabs/CardVariants/下有CardBack.prefabCardFront_Red.prefab,但无CardFront_Blue.prefab。RummyGame_.zip 使用 Prefab Variants:CardFront_Red作为基础 prefab,CardFront_Blue继承它并仅覆盖SpriteRenderer.color。这样新增花色只需创建 variant,无需复制整个 prefab。

4.3 Addressables 替代方案:用 AssetBundle 加载动态资源

若需热更牌面皮肤,RummyGame_.zip 预留了Assets/AssetBundles/CardSkins/目录。构建脚本BuildCardSkinsBundle.cs将皮肤打包为card_skins.bundle

// BuildCardSkinsBundle.cs [MenuItem("Assets/Build Card Skins Bundle")] public static void BuildBundle() { string assetPath = "Assets/AssetBundles/CardSkins/"; string outputPath = "Assets/StreamingAssets/card_skins.bundle"; BuildPipeline.BuildAssetBundles( outputPath, BuildAssetBundleOptions.ChunkBasedCompression, BuildTarget.StandaloneWindows64 ); }

运行时加载:

// CardSkinLoader.cs public IEnumerator LoadSkinBundle(string bundleName) { string path = Path.Combine(Application.streamingAssetsPath, bundleName); using (var request = AssetBundle.LoadFromFileAsync(path)) { yield return request; AssetBundle bundle = request.assetBundle; Sprite skinSprite = bundle.LoadAsset<Sprite>("blue_back"); // 应用到 CardView... bundle.Unload(false); // false 表示保留已加载资源 } }

5. 调试与验证:用 Editor Tool 快速校验洗牌均匀性与移动轨迹精度

RummyGame_.zip 的Assets/Editor/CardDebugTool.cs提供两个关键验证能力:洗牌分布统计移动路径录制回放。它们不依赖 Play Mode,可在编辑器中秒级验证逻辑正确性,避免反复运行游戏测试。

5.1 洗牌均匀性分析:直方图可视化 1000 次洗牌结果

CardDebugTool添加菜单项Tools/Card/Analyze Shuffle Distribution,执行 1000 次 Fisher-Yates 洗牌并统计每张牌在各位置的出现频次:

[MenuItem("Tools/Card/Analyze Shuffle Distribution")] public static void AnalyzeShuffle() { var deck = CreateStandardDeck(); // 52 张牌 int[,] positionCount = new int[52, 52]; // [牌ID, 位置索引] var shuffler = new Shuffler(12345); // 固定种子保重现 for (int i = 0; i < 1000; i++) { var shuffled = new List<Card>(deck); shuffler.Shuffle(shuffled); for (int pos = 0; pos < shuffled.Count; pos++) { int cardId = GetCardId(shuffled[pos]); positionCount[cardId, pos]++; } } // 输出 CSV 到 Console,可用 Excel 画热力图 Debug.Log("CardID,Pos0,Pos1,...,Pos51"); for (int id = 0; id < 52; id++) { string row = $"{id},"; for (int pos = 0; pos < 52; pos++) { row += $"{positionCount[id, pos]},"; } Debug.Log(row); } }

理想情况下,每行数据应接近均值1000/52 ≈ 19.23。若某张牌在位置 0 出现 35 次,则说明Shuffler存在偏差(如rng.Next(0, i)错误)。

5.2 移动轨迹录制:用 Gizmos 绘制实际运动路径

CardDebugTool在 Scene View 中绘制CardMover的实时路径:

[DrawGizmos] public static void DrawCardPaths() { if (CardMover.Instance == null) return; foreach (var card in CardMover.Instance.GetTrackedCards()) { if (card.PathHistory.Count > 1) { Color pathColor = card.State == CardState.MovingToTarget ? Color.cyan : Color.yellow; for (int i = 0; i < card.PathHistory.Count - 1; i++) { Gizmos.color = pathColor; Gizmos.DrawLine(card.PathHistory[i], card.PathHistory[i + 1]); } } } }

启用Gizmos后,拖拽卡牌时可见青色折线——若折线出现突兀直角,说明SmoothDamp参数过小;若折线严重偏离预期贝塞尔曲线,则CardPath.control1/control2设置不当。

5.3 关键参数速查表:调试时优先检查的 5 个数值

参数名位置推荐值调试现象说明
dragVelocity阻尼时间CardMover.UpdateDraggingCards()0.15f拖拽卡牌“粘滞”或“漂移”小于 0.1 易抖动,大于 0.3 跟手性差
CardPath.durationShuffler.CreateShufflePath()0.8f洗牌动画过快/过慢需匹配CardViewScaleTransition时间
Physics2D.Raycast距离CardMover.CheckDropTargets()0.5f卡牌无法落入目标区单位为世界坐标,需匹配TargetZoneCollider 大小
SpriteAtlasPacking TagInspector > SpriteAtlasCardSpritesGetSprite()返回 nullTag 名必须与SpritePacking Tag一致
ShuffleHistory.MAX_HISTORYShuffleHistory.cs10内存占用异常高每次快照约 2KB,10 次约 20KB,足够调试

当你在ProjectSettings中发现Graphics选项卡的Default Sprite Packer Policy被设为Always,而CardAtlasPacking Tag为空——这就是GetSprite()失败的根源。修正后,CardView.SetCardSuitAndRank()将稳定返回 Sprite,不再触发"failed to resolve import ../assets/..."类错误。

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

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

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

立即咨询