news 2026/10/22 19:20:57

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity3D棋牌游戏发牌效果实现:协程+二次贝塞尔曲线动画详解

做棋牌类游戏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这条贝塞尔飞行曲线抽成一个静态工具方法,发牌、翻牌、甚至敌人攻击特效都能复用同一个路径计算。把简单发牌这步走稳,后面整个牌桌交互的底子就扎实了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 19:20:56

开源智能体工作台搭建实战:六组件打造多智能体协作系统

最近我把手头几个智能体相关的开源项目串成了一个完整的工作台&#xff0c;核心组合是 Eigent、Alma、BionicGPT、LobeChat、Aizen、LobeHub 这六个东西。折腾完之后最大的感受是&#xff1a;单聊一个模型、单跑一个聊天框&#xff0c;早就不够用了。真正要落地一个能干活、能检…

作者头像 李华
网站建设 2026/10/22 19:20:48

Unity物体跟随的矩阵方案:从相对位姿到坐标空间换算

1. 这需求为什么值得用矩阵&#xff1a;先聊聊“跟随”的本质 做 Unity 开发久了&#xff0c;你会发现“物体跟随”这四个字能延伸出一大堆玩法&#xff1a;镜头跟着角色、炮塔跟着目标、挂件跟着武器、NPC 跟着巡逻路径点。最直觉的做法无非两种&#xff1a;把跟随者直接拖成目…

作者头像 李华
网站建设 2026/10/22 19:20:29

CARLA分布式自动驾驶仿真平台源码拆解与多机同步实战

简介&#xff1a;本资源为基于CARLA的高性能分布式自动驾驶仿真平台毕业设计完整源码包&#xff0c;面向计算机、人工智能、自动化、通信工程等专业的学生与科研人员&#xff0c;可用于毕业设计、课程设计、作业提交或项目初期演示。包内共17个文件&#xff0c;以13个Python脚本…

作者头像 李华
网站建设 2026/10/22 19:18:39

Unity 2D图片溶解扩散Shader:噪声图、边缘光与扩散控制

做2D游戏的朋友应该都有过这种需求&#xff1a;角色死亡时来一段消散动画、怪物被打败后碎成几块消失、UI面板切换时不想干巴巴直接淡出。网上搜一圈&#xff0c;能找到的溶解Shader基本都是3D版&#xff0c;主角是模型&#xff0c;拉到2D Sprite上要么锯齿严重&#xff0c;要么…

作者头像 李华
网站建设 2026/10/22 19:17:47

GGUF与llama.cpp实战:CPU本地部署大模型全指南

很多人一听到“本地部署大模型”&#xff0c;第一反应就是“我显卡不行”&#xff0c;然后直接放弃。这个反应我太熟悉了&#xff0c;因为我之前也一样。直到我真正动手试了一遍 GGUF llama.cpp 的 CPU 推理方案&#xff0c;才意识到&#xff1a;在资源有限的机器上&#xff0…

作者头像 李华
网站建设 2026/10/22 19:17:47

Win10重装系统全指南:从制作启动U盘到初始化配置

电脑用久了变卡&#xff0c;很多人第一反应是加内存条或者换固态硬盘&#xff0c;但实际上有一大半的“变卡”问题&#xff0c;根源是系统里积攒了太多垃圾——全家桶软件、残留驱动、注册表碎片&#xff0c;光靠清理工具根本治不了本。最近我帮家里那台老台式机做了一次系统盘…

作者头像 李华