Unity卡牌状态机设计:状态驱动移动与工程化洗牌实现

发布时间:2026/9/16 2:16:54
Unity卡牌状态机设计:状态驱动移动与工程化洗牌实现 简介本资源是一个面向编程初学者与游戏开发入门者的纸牌游戏核心机制实践项目聚焦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接口如何解耦渲染与逻辑需要知道CardPile的StackCard和ObservableListCard两种实现分别在什么场景下更稳也需要亲手调试DragHandler在高 DPI 屏幕上因CanvasScaler导致的偏移误差。这不是教你怎么赢牌而是教你把“牌会动、能洗、有归属”这件事写成别人能继承、能测、能换皮的代码。2. 用 CardState 和 CardMover 实现可预测的纸牌移动从鼠标拖拽到动画路径的三层控制流纸牌移动表面是视觉反馈底层是状态驱动的行为调度。RummyGame_.zip 的核心不在CardView的OnMouseDown而在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; } }注意SmoothDamp的dragVelocity必须是每个CardView的实例字段不能共用。否则多张牌同时拖拽时会相互干扰速度缓冲。2.2 移动终点判定用 Collider2D LayerMask 实现精准落点识别RummyGame_.zip 的TargetZone并非简单OnDrop而是基于Collider2D的持续检测。TargetZone继承MonoBehaviour并挂载BoxCollider2D其Layer设为TargetZone需在 ProjectSettings Tags and Layers 中预设。CardMover在FixedUpdate中执行射线检测// 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; // 强制归位防浮点误差 }提示control1和control2的长度决定曲线弯曲度。实践中control1 start Vector3.up * 1.2fcontrol2 end Vector3.up * 0.8f可生成自然抛物线比直线移动更具“纸牌被抛起”的观感。3. 洗牌机制的工程化封装Fisher-Yates 权重控制 可回滚的 ShuffleHistory洗牌常被简化为ListT.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 ShuffleT(IListT list) { for (int i list.Count - 1; i 0; i--) { int j rng.Next(0, i 1); // 注意上限是 i1非 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 WeightedDeckT { 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是一个栈存储每次洗牌前的ListCard快照深拷贝public class ShuffleHistory { private StackListCard history; private const int MAX_HISTORY 10; // 防内存泄漏 public void RecordBeforeShuffle(ListCard originalDeck) { var snapshot new ListCard(); 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 ListCard UndoLastShuffle() { return history.Count 0 ? history.Pop() : null; } }Shuffler在Shuffle()前调用history.RecordBeforeShuffle(deck)UI 按钮绑定shuffler.UndoLastShuffle()即可还原。注意此处Card必须是struct或不可变class否则快照会随原对象修改而失效。4. Assets 目录结构与资源加载策略为什么 RummyGame_.zip 的 Resources 文件夹里只有 prefabRummyGame_.zip 的Assets/Resources下仅存CardPrefab.prefab和TargetZone.prefab没有一张 PNG 或音频文件——这并非疏漏而是刻意采用Addressable Assets System的轻量替代方案所有 Sprite 通过SpriteAtlas打包运行时按需加载。这种结构规避了Resources.Load()的反射开销与内存泄漏风险也解释了为何你在项目中搜不到../assets/grenade (1024x128)[frames8].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) { GetComponentSpriteRenderer().sprite sprite; } else { Debug.LogError($Sprite {key} not found in atlas); } } }提示SpriteAtlas的Include in Build必须勾选且Packing Tag统一设为CardSprites否则GetSprite()返回 null。图集构建日志可在Window Rendering Sprite Atlas查看。4.2 Prefab Variants 优化手牌布局避免为每种牌型建 prefabAssets/Prefabs/CardVariants/下有CardBack.prefab和CardFront_Red.prefab但无CardFront_Blue.prefab。RummyGame_.zip 使用 Prefab VariantsCardFront_Red作为基础 prefabCardFront_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.LoadAssetSprite(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 ListCard(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洗牌动画过快/过慢需匹配CardView的ScaleTransition时间Physics2D.Raycast距离CardMover.CheckDropTargets()0.5f卡牌无法落入目标区单位为世界坐标需匹配TargetZoneCollider 大小SpriteAtlasPacking TagInspector SpriteAtlasCardSpritesGetSprite()返回 nullTag 名必须与Sprite的Packing Tag一致ShuffleHistory.MAX_HISTORYShuffleHistory.cs10内存占用异常高每次快照约 2KB10 次约 20KB足够调试当你在ProjectSettings中发现Graphics选项卡的Default Sprite Packer Policy被设为Always而CardAtlas的Packing Tag为空——这就是GetSprite()失败的根源。修正后CardView.SetCardSuitAndRank()将稳定返回 Sprite不再触发failed to resolve import ../assets/...类错误。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询