资讯动态

Unity3D棋牌游戏发牌效果实现:协程+二次贝塞尔曲线动画详解

发布时间:2026/10/22 19:21:13 来源:尧图企业网站定制
做棋牌类游戏Demo的时候发牌效果基本上是我遇到的第一道坎。看起来不过就是把牌从牌堆飞出去、落到某个玩家手边真上手写了才发现里面全是碎问题牌飞到一半被别的物体挡住怎么办、左右两家手牌排列方向不一样怎么处理、发到一半点击重新开局会不会出Bug。这篇文章把我自己从零写Unity3d发牌效果的过程完整拆开讲不依赖第三方Tween插件纯原生协程加二次贝塞尔曲线就能跑通适合正打算做棋牌Demo、又被发牌动画卡住的开发者参考。1. 发牌效果拆到底数据、表现和交互各管什么1.1 牌对象的一生从牌堆到手牌的完整状态链我一开始写发牌效果时犯过一个典型错误一上来就调transform.position想着把牌从A点移动到B点就完事了。结果牌是飞过去了但手牌排列乱成一团发牌过程中点其他按钮还会触发点击事件。后来我才意识到发牌效果真正难的不是“移动”而是牌在移动过程中和到达目的地之后身份状态一直在变化。一张牌从生成到落位至少要经历四个阶段被创建、飞行中、落位排序、进入待命。被创建时它只是一张有花色点数的数据壳飞行中它要做弧线运动并且要保证渲染在最上层落位后它要按发牌顺序插到对应玩家手牌的正确槽位进入待命后就从“发牌动画对象”变成了“可交互手牌对象”。这四个阶段对应着不同的数据和表现职责写代码之前先把这个状态链理清楚后面每一步都好做。1.2 把发牌拆成数据的流动和表现的流动再往深一层看发牌效果其实是两条并行的流动线。第一条是数据流动服务器或逻辑层决定某一轮把哪张牌发给哪个玩家这个决策是确定的第二条是表现流动客户端拿到决策后要把这张牌用动画呈现出来。单机Demo里我们常常把两条线混在一起写比如发牌动画里直接调Random.Range决定发哪张这在真正做棋牌项目时会埋下大坑——因为网络同步要求所有玩家看到的是同一手牌序列动画只是演出来而已。所以我在这个Demo里刻意把“洗牌的结果”和“播放动画的过程”分开。DealManager先洗好一副有序的牌列表再在动画协程里按顺序一张张取出数据、创建牌对象、播放飞行。这样哪怕以后要接服务端下发的牌序只需要把deck换成服务端传来的数组就行动画逻辑完全不用动。这也是我觉得一个“简单发牌效果”最值得学习的架构思路上限。2. 动画实现路线怎么选Tween插件、Animator还是手写协程2.1 三条路线的优缺点对比画一个简单的路线对比发牌动画常见的实现方式有三类第三方Tween插件、Animator动画状态机、手写协程插值。实现路线上手难度动态目标点支持中途打断控制依赖和体积第三方Tween插件低一句代码就能移动好链式调用方便好提供Kill和Pause需引入插件项目多一个依赖Animator 关键帧中等要搭动画状态机差目标点不好动态计算一般需要设计打断状态纯引擎自带但配置繁琐手写协程 贝塞尔中等需要理解插值原理好目标点就是函数参数好协程引用在手可随时停止零依赖逻辑完全透明这里说的“动态目标点支持”很关键。Animator动画状态机适合做固定演出比如开场牌从屏幕外飞进来路径写死在动画里就行。但棋牌游戏的手牌位置受玩家数量、牌数、当前手牌数量影响每个目标点都是运行时算出来的你总不能给每个槽位都做一个动画状态。Tween插件当然能做但大多数时候我们犯不着为几行插值逻辑引入一个库。2.2 为什么我最终选择“协程 二次贝塞尔”我最终选了手写协程加二次贝塞尔曲线最重要的原因是它把“路径计算”和“动画播放”都放在明面上任何人都能看懂牌是怎么飞的。二次贝塞尔曲线的数学也不复杂给定起点P0、终点P2、一个控制点P1中间任意时刻的位置就是P(t) (1 - t)^2 * P0 2 * (1 - t) * t * P1 t^2 * P2控制点P1选什么最常用的做法是取P0和P2的中点再加一个向上的高度偏移。这样牌会先微微向上飞再落下来看起来就是一条自然弧线而不是僵硬直线平移。飞行的旋转插值用Quaternion.Slerp从牌堆的朝向平滑转成手牌容器的朝向整个发牌过程就有“仪式感”了。有人可能会说Tween插件里也有Ease可以模拟弧线但那些Ease曲线主要控制速度快慢并不会真正改变物体路径的形状。想要弧线效果还是得自己计算路径点。既然都要写路径计算了不如直接一条龙写在协程里后续调试起来更直观。2.3 什么时候你该换用Tween或Animator当然手写协程不是银弹。如果你的项目已经有Tween插件并且团队对它很熟悉用在发牌效果上也完全没问题我只是为了清晰的逻辑和零依赖选择了手写。真正让我建议用Animator的场景是发牌特效里包含大幅度的相机运动、华丽的多阶段动画甚至要播放固定的开场演出这时动画状态机的可视化编辑优势就体现出来了。简单发牌效果协程这层复杂度刚好合适。3. 可运行的Demo核心代码从洗牌到牌飞进手牌3.1 基础数据与牌面显示CardData和CardView先定义最基础的牌数据。我没有把花色的枚举写得很复杂直接用一个类保存id、花色和点数方便以后扩展成服务端下发的数据结构。[System.Serializable] public class CardData { public int id; public int suit; // 0黑桃 1红心 2梅花 3方块 public int rank; // 1A 11J 12Q 13K }然后是CardView负责牌的显示和飞行动画。Setup方法根据CardData把牌面设置为对应图片初始状态只显示牌背。FlyTo方法就是核心的飞行协程用二次贝塞尔做位置插值同时用Slerp插值旋转。using System.Collections; using UnityEngine; public class CardView : MonoBehaviour { [SerializeField] private GameObject faceObj; [SerializeField] private GameObject backObj; private int cardId; public int CardId cardId; public void Setup(CardData data) { cardId data.id; // 这里根据 data.suit 和 data.rank 从自己的图集里取 Sprite // faceImage.sprite CardSpriteLibrary.GetSprite(data.suit, data.rank); faceObj.SetActive(false); backObj.SetActive(true); } public IEnumerator FlyTo(Vector3 startPos, Vector3 endPos, Quaternion startRot, Quaternion endRot, float duration, float height) { float elapsed 0f; while (elapsed duration) { elapsed Time.deltaTime; float u Mathf.Clamp01(elapsed / duration); Vector3 control (startPos endPos) * 0.5f Vector3.up * height; Vector3 pos (1 - u) * (1 - u) * startPos 2 * (1 - u) * u * control u * u * endPos; transform.position pos; transform.rotation Quaternion.Slerp(startRot, endRot, u); yield return null; } transform.position endPos; transform.rotation endRot; } public void ShowFace() { backObj.SetActive(false); faceObj.SetActive(true); } }这里有个小细节飞行高度参数height直接加在控制点Y轴上。如果你的牌桌视角是从上往下看的Y轴向上没问题如果视角是斜45度俯视可能需要把Vector3.up换成牌的当前朝向上方向或者手动给一个垂直于桌面的方向向量。最省事的办法是发牌时从牌堆位置往屏幕外方向偏移大家按自己的场景调一下即可。3.2 发牌管理器轮流发牌的节奏控制DealManager是整个效果的大脑负责洗牌、轮流发牌、计算手牌槽位、维护层级。核心是一个发牌协程洗好的牌按顺序一张张取出来目标玩家和手牌索引由i % playerCount和i / playerCount决定。using System.Collections; using System.Collections.Generic; using UnityEngine; public class DealManager : MonoBehaviour { [Header(组件配置)] public CardView cardPrefab; public Transform deckRoot; public Transform[] playerHandRoots; [Header(发牌参数)] public float flyDuration 0.6f; public float flyHeight 2f; public float dealInterval 0.12f; [Header(手牌排列)] public Vector3 dealDirection Vector3.right; public float cardSpacing 1.2f; public int maxHandCount 13; private ListCardData deck new ListCardData(); private ListCardView activeCards new ListCardView(); private Coroutine dealRoutine; private int playerCount; private void Awake() { playerCount playerHandRoots.Length; BuildDeck(); } public void StartNewDeal() { StopCurrentDeal(); ShuffleDeck(); dealRoutine StartCoroutine(DealRoutine()); } public void StopCurrentDeal() { if (dealRoutine ! null) { StopCoroutine(dealRoutine); dealRoutine null; } foreach (var card in activeCards) { if (card ! null) Destroy(card.gameObject); } activeCards.Clear(); } private void BuildDeck() { deck.Clear(); for (int suit 0; suit 4; suit) { for (int rank 1; rank 13; rank) { deck.Add(new CardData { id deck.Count, suit suit, rank rank }); } } } private void ShuffleDeck() { for (int i deck.Count - 1; i 0; i--) { int j Random.Range(0, i 1); CardData tmp deck[i]; deck[i] deck[j]; deck[j] tmp; } } private IEnumerator DealRoutine() { for (int i 0; i deck.Count; i) { int playerIndex i % playerCount; int handIndex i / playerCount; CardView card CreateCard(deck[i]); Vector3 startPos deckRoot.position; Vector3 endPos GetHandSlot(playerIndex, handIndex); Quaternion startRot deckRoot.rotation; Quaternion endRot playerHandRoots[playerIndex].rotation; SetSortingOrder(card, 1000); yield return card.FlyTo(startPos, endPos, startRot, endRot, flyDuration, flyHeight); card.transform.SetParent(playerHandRoots[playerIndex], false); card.transform.localPosition playerHandRoots[playerIndex] .InverseTransformPoint(endPos); card.transform.localRotation Quaternion.identity; SetSortingOrder(card, handIndex); card.ShowFace(); yield return new WaitForSeconds(dealInterval); } dealRoutine null; } private CardView CreateCard(CardData data) { CardView card Instantiate(cardPrefab, deckRoot.position, deckRoot.rotation); card.Setup(data); activeCards.Add(card); return card; } private Vector3 GetHandSlot(int playerIndex, int cardIndex) { Transform root playerHandRoots[playerIndex]; float offset (cardIndex - (maxHandCount - 1) / 2f) * cardSpacing; Vector3 localPos dealDirection.normalized * offset; return root.TransformPoint(localPos); } private void SetSortingOrder(CardView card, int order) { var renderer card.GetComponentSpriteRenderer(); if (renderer ! null) renderer.sortingOrder order; } }这段代码直接可以粘进Unity里跑。需要注意的是一副牌52张、4个玩家时漏斗循环发牌的顺序是玩家0第一张、玩家1第一张、玩家2第一张、玩家3第一张再回到玩家0第二张依此类推。handIndex用整数除法计算刚好对应每个玩家当前手牌槽位的索引。3.3 手牌位置的计算逻辑手牌槽位计算看起来不起眼其实是发牌效果里非常影响观感的地方。常见做法是把每个玩家第n张牌的位置固定在某个根节点下的一个偏移量上所有牌按dealDirection方向排开中心对齐。我的GetHandSlot用maxHandCount算出中心偏移这样牌一多就会自动向两侧扩展不会全部挤在一堆。左右两个玩家的手牌方向不同下方的玩家手牌横向展开左右两边的玩家手牌需要竖向展开。在DealManager里我留了一个dealDirection字段手动给不同玩家的手牌根节点配置不同的展开方向就行。这是一个不增加代码复杂度、又能兼容多人桌更自然的做法实际项目里很实用。3.4 层级排序发牌中的牌不能被手牌挡住层级问题是发牌效果里最容易被忽略的细节。牌飞行过程中可能穿过其他玩家的手牌区域如果不处理sortingOrder飞行的牌就会从手牌下面穿过去观感很差。我的处理方式分两步飞行前把所有正在飞行的牌sortingOrder设为1000强制置顶到达手牌槽位后再把它的sortingOrder改回它的手牌索引让它自然叠放在手牌队列里。因为手牌之间按索引从小到大叠放后面的牌压在前面的牌上面视觉上才合理。4. 最容易翻车的四个细节坐标系、层级、中断和对象池4.1 UI坐标系还是3D世界坐标RectTransform的坐标换算问题Unity棋牌游戏最常见的两种形态纯3D场景或者UGUI Canvas界面里做牌。我上面的核心代码是针对3D场景的用的是Transform.position。如果你的发牌效果是在Canvas下做直接用这些代码会出现一个很迷惑的现象牌飞到一半位置对不上或者压根不显示。原因是UGUI的UI元素定位走的是RectTransform的anchoredPosition不是transfrom.position。简单做法是做一个坐标转换工具把世界坐标转成UI局部坐标先用RectTransformUtility.WorldToScreenPoint拿到屏幕坐标再用ScreenPointToLocalPointInRectangle转成Canvas内的局部坐标。如果Canvas是ScreenSpaceOverlay模式转换时相机传null即可。用这套转换方法后发牌代码的逻辑可以完全复用只是起止点换成UI坐标。4.2 发牌瞬间的遮挡顺序与点击穿透除了sortingOrder的问题还有点击穿透。飞行中的牌如果带碰撞体或者Image的RaycastTarget没关就会挡住玩家点击其他按钮。最稳妥的做法是卡片在飞行动画期间把raycastTarget设置为false落地后恢复true。在3D场景里就是碰撞体的enable先关掉到达后开启。说到这个我有个习惯牌面默认关闭任何交互组件只有在对应玩家回合、需要选牌时才打开。一个听起来和发牌无关的细节能避免大量“发着发着牌能误点”的战术Bug。4.3 重新开局时怎么优雅中断正在飞的牌中途重新开局几乎是必踩的坑。玩家等发牌等得不耐烦点了一下重新开始结果上一局的牌还在天上飞新局的牌又从牌堆出来了两张牌叠在一起乱飞。原因就是发牌协程没有被打断。我的解法是给DealManager保存dealRoutine引用新的发牌开始前先StopCoroutine(dealRoutine)并且销毁所有activeCards里记录的在途牌。这里有一个很容易犯的错误在CardView内部用StartCoroutine(FlyTo(...))启动动画这样即使外部StopCoroutine了DealRoutineCardView自己的协程还是在跑。正确做法是像我在DealRoutine里写的那样用yield return card.FlyTo(...)把子迭代器直接嵌进父协程执行这样父协程一停整条飞行链都会停。反之如果你在CardView里自己StartCoroutine中断逻辑就会失控。4.4 对象池回收容易犯的错动画协程残留后面想优化性能、避免反复Instantiate和Destroy时自然会想到对象池。对象池在发牌效果里的坑在于一张牌被放进池子之前要确保它身上没有正在运行的协程、位置旋转被重置、显示状态恢复成牌背。如果只想在Demo里跑通用Instantiate和Destroy其实也够52张牌的创建销毁在现代手机上完全没压力。真正需要上对象池的是网络棋牌项目尤其频繁重开时GC开销会被无限放大。真要池化的话要注意OnDisable里重置状态或者把飞行协程引用存在CardView里主动Stop保证回池的牌是干净状态。5. 让发牌手感更好的打磨方向节奏、音效、翻牌与网络同步5.1 发牌节奏决定牌桌气质同样的代码dealInterval设成0.05秒和0.3秒整个牌桌的气质完全不同。快速发牌适合竞技类棋牌玩家要尽快进入牌局慢速发牌适合偏休闲、偏表演的场景玩家想看到一张张牌飞向自己的过程。我个人一般把单张飞行时长控制在0.5到0.8秒间隔0.1到0.15秒不快不慢既不会让人等得焦躁又能看清弧线轨迹。还有一个经验发牌顺序也可以做节奏变化比如前几张慢一点后面逐渐加快最后一张略作停顿再落位这个小小的“脚本感”会让整个发牌过程更像真人发牌。实现也不复杂在DealRoutine里根据i动态调整dealInterval就完了。5.2 音效和落地反馈的叠加音效对发牌体验的提升比想象中大得多。一张牌还在天上飞的时候没有声音牌落到手牌槽位那一刻播放一个短促的“嗒”声整个手感立刻不一样。如果落地瞬间再配合一个极小幅度的手牌振动玩家会有“牌真到了牌桌上”的实感。落地回调可以在FlyTo结束之后做也可以在协程里让CardView暴露一个到达事件DealManager订阅它来播声音。我记得自己第一次加上音效和落地振动后整个Demo的质感提升了不止一个档次。从“技术演示”变成“有点游戏味了”这一步的性价比极高。5.3 多人桌、网络同步与手牌交互的扩展方向最后提一个真实棋牌项目里的关键点发牌的牌序应该由服务端决定客户端只负责播放服务端下发的发牌指令。也就是说洗牌和发牌逻辑都不能放在客户端本地随机否则每个玩家看到的牌序会不一样。把DealManager改造成“接收指令播放动画”很直接发牌程序不再自己随机发牌而是接收一组包含目标玩家和牌ID的指令队列DealRoutine逐条消费、逐条播放动画。手牌交互的扩展空间也很大发完牌之后玩家可以拖动手牌、出牌动画、手牌排序按钮这些功能在手牌对象进入待命状态之后都能自然接入。发牌效果只是牌桌生命周期的第一环状态链越早设清楚后面几个环节接入越顺。我在实际项目里最常用的小技巧是把FlyTo这条贝塞尔飞行曲线抽成一个静态工具方法发牌、翻牌、甚至敌人攻击特效都能复用同一个路径计算。把简单发牌这步走稳后面整个牌桌交互的底子就扎实了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑