做棋牌类游戏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; // 1=A 11=J 12=Q 13=K }然后是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 List<CardData> deck = new List<CardData>(); private List<CardView> activeCards = new List<CardView>(); 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.GetComponent<SpriteRenderer>(); 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了DealRoutine,CardView自己的协程还是在跑。正确做法是像我在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这条贝塞尔飞行曲线抽成一个静态工具方法,发牌、翻牌、甚至敌人攻击特效都能复用同一个路径计算。把简单发牌这步走稳,后面整个牌桌交互的底子就扎实了。