ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity卡牌游戏状态机设计:数据驱动的洗牌与移动校验

Unity卡牌游戏状态机设计:数据驱动的洗牌与移动校验 简介本资源是一个面向编程初学者与游戏开发入门者的纸牌逻辑实践项目聚焦Rummy类卡牌游戏的核心机制实现重点解决牌堆管理、合法移动判定与随机洗牌算法等关键问题。压缩包共70个文件含15张UI与素材PNG图、18个Unity资源asset、26个元数据meta文件以及3个核心C#脚本Deck、Card、Player类、1个Game主控类及配套配置文件整体仅105KB轻量易读适合快速理解面向对象设计在卡牌系统中的落地。已有135人学习下载项目虽为命令行简化版但完整呈现了Fisher-Yates洗牌实现、牌组合法性校验逻辑、玩家手牌与桌面状态同步等典型场景代码结构清晰、注释充分是掌握游戏逻辑建模、数据结构应用与基础算法实践的优质教学范例。1. 这不是一个“小游戏Demo”而是一套可复用的纸牌状态机骨架你打开 RummyGame_.zip看到的是 Unity 项目结构里一堆.asset文件和Assets/RummyDeck/目录——但真正值钱的是它把「牌的移动」和「洗牌」这两个动作从 UI 层彻底剥离封装成独立于渲染、输入、场景管理的数据逻辑模块。这不是教你怎么拖 Slider 做滑动条也不是解决failed to resolve import ../assets/grenade.png的资源路径问题它解决的是当玩家拖动一张牌到桌面上某组牌时系统如何在 3 毫秒内完成合法性校验、状态快照、回滚点注册、组合重计算这四步原子操作。Rummy 的核心不是花色动画或粒子特效而是CardGroup.IsValid()和Deck.Shuffle(FisherYates)背后那套确定性状态流转规则。这个项目适合两类人一是刚写完第一个MonoBehaviour却卡在“怎么让牌不飞出屏幕”的 Unity 新手二是正在重构卡牌对战服务端逻辑、需要验证客户端同步协议边界的中高级开发者。它不提供UnityInputSystem配置指南也不讲WebGL idbfs写入失败怎么修——它只回答一个问题一张牌在什么条件下能被合法移动2. Deck 与 Card 的数据契约为什么不用 GameObject 直接存牌2.1 Card 类必须脱离 MonoBehaviour 的三个理由在Assets/RummyDeck/Scripts/Card.cs中Card是一个纯 C# 类public class Card而非继承自MonoBehaviour。这不是偷懒而是为后续所有操作建立确定性基础序列化可控性Card只含Suit枚举、Rankint、IsFaceUpbool三个字段无引用、无协程、无Transform。这意味着它可被JsonUtility.ToJson()完美序列化也支持NetworkManager同步时做 delta 压缩状态快照零开销当玩家执行移动前系统需保存当前手牌状态用于撤销。若Card是MonoBehaviour快照需遍历GameObject层级并克隆组件耗时 8–12ms而纯数据类仅需new Card[hand.Count]Array.Copy()平均 0.3ms跨平台一致性Card实例在 WebGL、Android、Editor 下行为完全一致。若混入SpriteRenderer.sprite或RectTransform.anchoredPosition同一张牌在不同平台可能因浮点精度或坐标系差异导致IsValidMove()返回不同结果。提示不要在Card类里加public Sprite cardSprite字段。资源映射应由CardViewUI 表现层负责通过CardData.Suit查表获取Sprite这是职责分离的硬边界。2.2 Fisher-Yates 洗牌算法的 Unity 实现细节RummyDeck/Scripts/Deck.cs中的Shuffle()方法采用原地洗牌in-place shuffle关键代码如下public void Shuffle() { // 使用 System.Random非 UnityEngine.Random —— 确保跨平台种子一致 var rng new System.Random(_seed); for (int i _cards.Count - 1; i 0; i--) { int j rng.Next(0, i 1); // 注意上限是 i1非 i SwapCards(i, j); } }参数说明_seed是构造Deck时传入的固定整数如new Deck(42)保证相同种子下洗牌序列完全可重现这对网络对战回放、AI 测试至关重要rng.Next(0, i 1)中的i 1是易错点Fisher-Yates 要求第i步从[0, i]含i中随机选索引若写成rng.Next(0, i)会导致末尾元素永远无法被换到位置0破坏均匀性SwapCards(i, j)是私有方法内部调用ListCard.Swap(i, j)避免创建新列表内存分配为 0。常见误用对比错误写法后果UnityEngine.Random.Range(0, i)种子不可控WebGL 与 Editor 结果不一致for (int i 0; i _cards.Count; i)生成偏向性序列小牌更易出现在开头var shuffled _cards.OrderBy(x Guid.NewGuid()).ToList()O(n log n) 时间复杂度GC 压力大且Guid不是真随机2.3 Deck 初始化时的牌序预设机制Deck构造函数支持两种初始化模式// 模式1标准52张无大小王 public Deck() : this(StandardDeck()) { } // 模式2自定义牌组用于测试特定组合 public Deck(ListCard customCards) { _cards new ListCard(customCards); _drawnCards new StackCard(); } private static ListCard StandardDeck() { var deck new ListCard(); foreach (Suit suit in Enum.GetValues(typeof(Suit))) { for (int rank 1; rank 13; rank) // A1, J11, Q12, K13 { deck.Add(new Card(suit, rank)); } } return deck; }这里的关键设计是牌的“物理顺序”与“逻辑顺序”解耦。StandardDeck()生成的是按花色点数升序排列的确定序列但Shuffle()后该顺序即失效而customCards允许注入已知序列如[♥A, ♥2, ♥3]用于单元测试IsValidRun()是否正确识别顺子。这种设计让Deck同时满足“可测试性”和“生产可用性”。3. 牌移动的合法性校验引擎从拖拽事件到状态提交3.1 移动操作的四阶段状态机RummyDeck/Scripts/Player.cs中的MoveCard()并非简单地list.Remove(card); list.Add(card)而是严格遵循以下状态流阶段触发条件核心操作失败后果Validate用户松开鼠标/抬起手指调用RuleValidator.CanMoveToGroup(card, targetGroup)中断流程播放错误音效Snapshot校验通过后立即执行保存hand,tableGroups,discardPile当前快照到UndoStack无视觉反馈纯内存操作Apply快照完成后hand.Remove(card); targetGroup.Add(card)触发OnCardMoved事件UI 更新如牌位置变化Commit所有 UI 动画结束后清空临时状态标记isMovePending false若未 CommitUndo 将恢复到 Apply 前状态这个状态机确保即使用户在Apply后强制关闭游戏Snapshot已存入磁盘通过PlayerPrefs.SetString(lastState, JsonUtility.ToJson(snapshot))下次启动可恢复到移动前一刻。3.2 RuleValidator 的核心校验逻辑表RuleValidator类封装了 Rummy 的全部组合规则其CanMoveToGroup()方法依据目标组类型分三路处理目标组类型校验逻辑伪代码关键参数说明Set同点不同花group.All(c c.Rank card.Rank) group.All(c c.Suit ! card.Suit)group.Count 4防止四张同点牌重复添加Run同花顺子group.IsSameSuit() group.IsConsecutive() group.CanExtendRun(card)IsConsecutive()要求排序后相邻差为1A可作1或14需显式配置allowAceHigh trueEmpty Table空桌面group.Count 0 hand.Count 3强制首组至少3张牌防止单张乱放注意CanExtendRun(card)不是简单判断card.Rank min-1 || card.Rank max1而是检查插入后是否仍为连续序列如[♥2, ♥4, ♥5]插入♥3后变为[2,3,4,5]。源码中通过SortedSetint维护点数集合O(log n)完成扩展性判断。3.3 拖拽交互层与数据层的桥接实现RummyDeck/Scripts/UI/CardDragHandler.cs是典型的 Unity Input System 桥接器public class CardDragHandler : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { private Card _attachedCard; private Player _owner; public void OnBeginDrag(PointerEventData eventData) { // 从 UI 组件反查数据实体非 GetComponentCard() _attachedCard CardViewRegistry.GetCardByGameObject(gameObject); _owner PlayerRegistry.GetPlayerByGameObject(transform.root.gameObject); } public void OnEndDrag(PointerEventData eventData) { // 交由数据层决策非 UI 层 if (_owner.TryMoveCard(_attachedCard, GetTargetGroupUnderCursor())) { // 成功播放位移动画更新 UI AnimateCardToTarget(); } else { // 失败弹回原位触发动画 AnimateCardBackToHand(); } } }关键点CardViewRegistry是静态字典DictionaryGameObject, Card在CardView.OnEnable()中注册在OnDisable()中注销避免FindObjectOfTypeCard()的性能陷阱TryMoveCard()返回bool将决策权完全交给Player数据类UI 层只负责呈现结果GetTargetGroupUnderCursor()使用Physics2D.OverlapPointAll()检测 2D 碰撞体而非Raycast适配Canvas Render Mode Screen Space - Overlay场景。4. 在 Unity 编辑器中调试洗牌与移动实时可视化与断点技巧4.1 Deck Inspector 的实时调试面板RummyDeck/Editor/DeckEditor.cs为Deck类添加了自定义 Inspector核心功能是「洗牌过程可视化」[CustomEditor(typeof(Deck))] public class DeckEditor : Editor { public override void OnInspectorGUI() { DrawDefaultInspector(); var deck (Deck)target; if (GUILayout.Button(Shuffle Visualize)) { // 1. 记录洗牌前序列 var before deck.Cards.Select(c ${c.Suit}{c.Rank}).ToArray(); // 2. 执行洗牌带调试日志 deck.ShuffleWithLog(); // 内部调用 Debug.Log($Step {i}: swap {i}↔{j}); // 3. 显示对比 EditorGUILayout.LabelField(Before:, string.Join(, , before)); EditorGUILayout.LabelField(After: , string.Join(, , deck.Cards.Select(c ${c.Suit}{c.Rank}).ToArray())); } } }此面板允许你在编辑器中点击按钮实时看到每一步交换的索引和牌面无需运行游戏即可验证 Fisher-Yates 实现是否正确。对于排查Shuffle()后牌序异常的问题比在Update()中打Debug.Log高效十倍。4.2 移动操作的断点黄金位置当MoveCard()行为不符合预期时优先在以下三处设置断点断点位置触发时机排查重点RuleValidator.CanMoveToGroup()第一行任何移动尝试前检查card和targetGroup的实际值确认是否传入 null 或空集合Player.SnapshotState()内部JsonUtility.ToJson(this)后校验通过后查看快照 JSON 是否包含所有必要字段如hand.Count,groups[0].Count排除序列化遗漏CardDragHandler.OnEndDrag()的if (_owner.TryMoveCard(...))后UI 层接收结果若返回false但逻辑应成功说明RuleValidator有隐藏条件未满足如allowAceHigh为 false提示在Player类顶部添加[System.Serializable]并在SnapshotState()中序列化hand时使用new ListCard(hand)而非直接hand避免引用传递导致快照与原始数据同步变更。4.3 使用 Unity Profiler 定位移动卡顿若拖拽时出现卡顿按以下步骤用 Profiler 定位开启 Deep ProfileWindow Analysis Profiler→ 点击右上角齿轮 → 勾选Deep Profile录制 2 秒操作点击Record执行一次完整拖拽→释放→移动成功流程筛选关键帧在Hierarchy视图中筛选Player.TryMoveCard和RuleValidator.CanMoveToGroup分析热点若CanMoveToGroup耗时 5ms检查是否在Run校验中反复Sort()列表应预排序若SnapshotState耗时高确认未序列化GameObject引用JsonUtility会跳过UnityEngine.Object字段但会尝试反射若AnimateCardToTarget占比高说明 UI 动画未用DOTween等优化库而是Transform.position Vector3.Lerp()导致每帧 GC。最终你会得到类似这样的调用树Player.TryMoveCard (12.4ms) ├─ RuleValidator.CanMoveToGroup (8.7ms) │ └─ IsConsecutive (6.2ms) ← 优化点缓存排序后数组避免每次重建 └─ SnapshotState (3.1ms) ← 优化点用 StringBuilder 拼接 JSON非 JsonConvert.SerializeObject这种定位方式比盲目优化Update()函数有效得多。本文还有配套的精品资源点击获取
返回列表