Unity游戏开发:基于贝塞尔曲线与对象池的卡牌扇形展开动画实现 1. 项目概述与核心思路最近在复刻《杀戮尖塔》的战斗体验其中一个让我着迷的细节就是它的出牌动画。当玩家点击手牌时卡牌并非简单地消失或平移到目标位置而是会以一种非常优雅的扇形轨迹展开然后飞向敌人。这个看似简单的效果实际上融合了动画、物理和视觉反馈极大地提升了操作的“手感”和策略的“仪式感”。今天我们就来深度拆解如何用Unity实现这个“扇形开牌”效果的第一部分。这个效果的核心目标很明确模拟一叠卡牌在手中被“扇形展开”的视觉过程为后续的飞牌、命中反馈做铺垫。它不适合用简单的位移动画Transform.Translate或线性插值Lerp粗暴完成那样会显得生硬。我们需要的是一个有弧度的、带有一点随机性和物理感的展开动画。经过几轮尝试和优化我最终确定了一套结合贝塞尔曲线、对象池和动画状态机的方案在保证效果惊艳的同时也兼顾了性能即使在移动设备上同时展开十几张牌也毫无压力。2. 核心模块设计与原理拆解实现扇形开牌我们可以将其分解为几个核心的子问题如何计算每张牌的终点位置形成扇形如何让牌沿着一条优美的曲线运动如何管理大量卡牌对象以避免性能开销以及如何让整个动画过程可控且可交互2.1 扇形布局算法从一叠牌到一个弧面首先我们需要一个算法根据手牌数量计算出每张牌在屏幕上的最终展开位置。这个位置集合应该构成一个圆弧。一个直观的方法是使用极坐标。我们可以定义一个“展开中心点”通常是手牌区域底部中央一个“展开半径”以及一个“总展开角度”。每张牌根据其索引分配一个角度然后通过极坐标转直角坐标的公式计算出其世界坐标位置。核心公式如下假设总牌数为cardCount当前牌索引为i从0开始。 总展开角度为totalAngle例如60度那么每张牌之间的角度间隔为angleStep totalAngle / (cardCount - 1)如果只有一张牌则间隔为0。 当前牌的角度为currentAngle -totalAngle / 2 i * angleStep。这样计算可以保证牌组以中心对称的方式展开。 最后世界坐标targetPosition centerPoint Quaternion.Euler(0, 0, currentAngle) * Vector3.right * radius。这里有一个关键细节radius半径不宜是固定值。为了让展开效果更自然我们可以让半径随着牌的数量增加而略微增大模拟手牌较多时展开幅度更大的物理感觉。同时totalAngle也可以根据牌的数量动态调整。实操心得直接计算出的目标位置是“牌的中心点”的位置。在实际摆放时你需要根据卡牌的锚点Pivot进行调整。通常卡牌Sprite的Pivot在中心0.5, 0.5这样计算出来的位置就是牌的中心点。如果你希望牌的下边缘对齐某个基线则需要额外计算偏移。2.2 运动轨迹贝塞尔曲线的魅力确定了起点手牌堆叠处和终点扇形位置后我们需要一条路径让卡牌“飞”过去。直线运动太死板而贝塞尔曲线尤其是二次贝塞尔曲线提供了完美的解决方案。它只需要一个起点P0、一个终点P2和一个控制点P1就能定义一条平滑的弧线。控制点P1的设定是精髓所在。它决定了曲线的弯曲程度和方向。对于扇形展开我们希望牌有一个向上抛起的弧线感。因此控制点可以设置在起点和终点连线的中垂线方向上的某个点。一个简单的策略是P1 (P0 P2) / 2 Vector3.up * curveHeight。 这里的curveHeight是一个可调节的参数控制弧线的“高度”。你可以为每张牌赋予一个微小的随机高度偏移让展开动画更有层次感避免过于机械。运动过程就是对t时间比例从0到1进行插值。二次贝塞尔曲线的公式是B(t) (1-t)^2 * P0 2 * (1-t) * t * P1 t^2 * P2。 在Update函数中我们根据动画的已进行时间与总时间的比例得到t然后计算当前位置并赋值给卡牌的Transform。2.3 性能基石对象池管理在战斗中出牌是高频操作。如果每次展开都实例化Instantiate新的卡牌对象动画结束后又销毁Destroy很快就会产生大量的GC垃圾回收开销导致游戏卡顿。对象池Object Pool是解决这个问题的标准答案。其思想是预先创建或懒加载一定数量的卡牌游戏对象放入一个“池子”如ListGameObject或QueueGameObject中。当需要展示一张牌时从池子里取一个闲置的对象将其激活SetActive(true)并设置到起始位置。当动画结束牌被使用或收起时不是销毁它而是将其失活SetActive(false)并放回池子。我们需要为对象池设计一个简单的管理器它负责初始化池子预加载若干卡牌Prefab。提供GetCard()方法返回一个可用的卡牌对象。提供ReturnCard(GameObject card)方法回收卡牌对象。处理池子容量不足时的动态扩容。避坑指南回收卡牌时务必重置其状态这包括位置、旋转、缩放、以及任何可能影响下次使用的脚本变量例如动画进度t、运动速度等。一个常见的做法是在卡牌对象上挂载一个CardAnimationController脚本该脚本提供一个Reset()方法在回收前调用。忘记重置状态是对象池使用中最常见的Bug来源之一。2.4 动画控制与状态管理每张卡牌的展开动画是一个有生命周期的过程等待 - 开始移动 - 移动中 - 到达终点 - 等待点击。我们需要一个清晰的状态机来管理它。我通常使用一个枚举Enum来定义卡牌动画的状态public enum CardAnimationState { Idle, // 闲置在手中未展开 MovingToFan, // 正在向扇形位置运动 InFan, // 已到达扇形位置等待交互 MovingToTarget, // 被点击后飞向敌人这是下一篇的内容 Discarded // 已被使用等待回收 }在卡牌的控制脚本中有一个CurrentState变量。在Update()函数里根据不同的状态执行不同的逻辑。例如在MovingToFan状态就计算贝塞尔曲线路径并更新位置当位置非常接近目标点Vector3.Distance 0.01f时就将状态切换为InFan。这种状态机模式使得逻辑非常清晰易于扩展比如以后增加“被选中高亮”、“抖动警告”等状态也便于调试。3. 完整实现步骤与代码解析下面我将把上述模块组合起来给出一个可运行的、相对完整的实现方案。我们将创建几个核心脚本CardObjectPool、CardFanLayoutManager和CardController。3.1 第一步创建对象池CardObjectPool这个脚本可以挂在一个空的GameObject上比如叫“CardPoolManager”。using System.Collections.Generic; using UnityEngine; public class CardObjectPool : MonoBehaviour { public static CardObjectPool Instance; // 单例模式方便全局访问 public GameObject cardPrefab; // 卡牌的Prefab public int initialPoolSize 10; // 初始池大小 private QueueGameObject pool new QueueGameObject(); void Awake() { if (Instance null) Instance this; else Destroy(gameObject); InitializePool(); } // 初始化对象池 void InitializePool() { for (int i 0; i initialPoolSize; i) { CreateNewCard(); } } // 创建一张新卡牌并放入池中 private GameObject CreateNewCard() { GameObject card Instantiate(cardPrefab, transform); // 作为池管理器的子物体 card.SetActive(false); pool.Enqueue(card); return card; } // 从池中获取一张卡牌 public GameObject GetCard() { if (pool.Count 0) { // 池子空了动态扩容 CreateNewCard(); } GameObject card pool.Dequeue(); card.SetActive(true); // 获取卡牌控制器重置状态 CardController controller card.GetComponentCardController(); if (controller ! null) controller.ResetCard(); return card; } // 将卡牌归还池中 public void ReturnCard(GameObject card) { if (card null) return; card.SetActive(false); // 重置父物体方便管理 card.transform.SetParent(transform); pool.Enqueue(card); } }3.2 第二步扇形布局管理器CardFanLayoutManager这个脚本负责计算所有卡牌的扇形目标位置。它可以挂在场景中代表“手牌区域”的GameObject上。using System.Collections.Generic; using UnityEngine; public class CardFanLayoutManager : MonoBehaviour { public Transform fanCenter; // 扇形展开的中心点世界坐标 public float baseRadius 2.0f; // 基础半径 public float radiusIncrementPerCard 0.1f; // 每多一张牌增加的半径 public float baseTotalAngle 60f; // 基础总角度度 public float angleIncrementPerCard 5f; // 每多一张牌增加的总角度 // 计算指定索引卡牌的目标位置 public Vector3 CalculateFanPosition(int cardIndex, int totalCards) { if (totalCards 0) return fanCenter.position; if (totalCards 1) return fanCenter.position Vector3.right * baseRadius; // 动态计算半径和总角度 float dynamicRadius baseRadius (totalCards - 1) * radiusIncrementPerCard; float dynamicTotalAngle baseTotalAngle (totalCards - 1) * angleIncrementPerCard; // 限制最大角度避免过度展开 dynamicTotalAngle Mathf.Min(dynamicTotalAngle, 120f); float angleStep dynamicTotalAngle / (totalCards - 1); // 计算当前牌的角度从最左侧开始 float currentAngle -dynamicTotalAngle / 2 cardIndex * angleStep; // 将角度转换为弧度并计算位置 float rad currentAngle * Mathf.Deg2Rad; Vector3 offset new Vector3(Mathf.Cos(rad), Mathf.Sin(rad), 0) * dynamicRadius; return fanCenter.position offset; } // 一个便捷方法为一批卡牌计算所有位置 public ListVector3 CalculateAllFanPositions(int totalCards) { ListVector3 positions new ListVector3(); for (int i 0; i totalCards; i) { positions.Add(CalculateFanPosition(i, totalCards)); } return positions; } }3.3 第三步卡牌控制器CardController这是最重要的脚本挂载在每一张卡牌的Prefab上。它控制动画、状态和交互。using UnityEngine; using UnityEngine.EventSystems; public class CardController : MonoBehaviour, IPointerClickHandler { // 状态 private CardAnimationState currentState CardAnimationState.Idle; // 动画参数 private Vector3 startPosition; private Vector3 controlPoint; private Vector3 targetPosition; private float animationDuration 0.3f; // 展开动画时长 private float elapsedTime 0f; private float curveHeight 1.5f; // 贝塞尔曲线控制点高度 // 组件引用 private RectTransform rectTransform; // 假设是UI卡牌 void Awake() { rectTransform GetComponentRectTransform(); } void Update() { switch (currentState) { case CardAnimationState.MovingToFan: UpdateFanMovement(); break; // 其他状态的Update逻辑... } } // 外部调用开始扇形展开动画 public void StartFanAnimation(Vector3 fanTargetPos) { if (currentState ! CardAnimationState.Idle) return; currentState CardAnimationState.MovingToFan; startPosition rectTransform.anchoredPosition3D; // 如果是UI // startPosition transform.position; // 如果是3D物体 targetPosition fanTargetPos; // 计算控制点起点和终点的中点向上偏移 Vector3 midPoint (startPosition targetPosition) / 2; controlPoint midPoint Vector3.up * curveHeight; elapsedTime 0f; } // 更新扇形移动动画 private void UpdateFanMovement() { elapsedTime Time.deltaTime; float t Mathf.Clamp01(elapsedTime / animationDuration); // 使用二次贝塞尔曲线公式计算当前位置 Vector3 position CalculateBezierPoint(t, startPosition, controlPoint, targetPosition); rectTransform.anchoredPosition3D position; // UI // transform.position position; // 3D // 可选添加一点旋转效果让运动更生动 float rotationAngle Mathf.Lerp(0f, 5f, t); // 轻微旋转 rectTransform.localEulerAngles new Vector3(0, 0, rotationAngle); // 动画结束判断 if (t 1f) { currentState CardAnimationState.InFan; // 到达后可以加一个轻微的弹动效果使用DOTween或自己写 // OnReachedFanPosition(); } } // 二次贝塞尔曲线计算 private Vector3 CalculateBezierPoint(float t, Vector3 p0, Vector3 p1, Vector3 p2) { float u 1 - t; float tt t * t; float uu u * u; return (uu * p0) (2 * u * t * p1) (tt * p2); } // 点击事件需要EventTrigger或实现IPointerClickHandler public void OnPointerClick(PointerEventData eventData) { if (currentState CardAnimationState.InFan) { Debug.Log($Card {name} clicked! Ready to play.); // 触发后续的出牌逻辑飞向敌人 // PlayCard(); } } // 重置卡牌状态供对象池调用 public void ResetCard() { currentState CardAnimationState.Idle; elapsedTime 0f; rectTransform.localEulerAngles Vector3.zero; // 重置其他可能修改的属性如缩放、颜色等 } // 获取当前状态 public CardAnimationState GetCurrentState() { return currentState; } }3.4 第四步整合与调用最后我们需要一个“出牌管理器”或“手牌管理器”来驱动整个过程。这个脚本可能在玩家回合开始时或者在抽牌后调用。using System.Collections.Generic; using UnityEngine; public class HandManager : MonoBehaviour { public CardFanLayoutManager layoutManager; private ListGameObject currentHandCards new ListGameObject(); private ListVector3 fanPositions; // 模拟抽牌并扇形展开 public void DrawAndFanCards(int cardCount) { // 1. 清空当前手牌模拟回合开始 ClearHand(); // 2. 从对象池获取卡牌 for (int i 0; i cardCount; i) { GameObject card CardObjectPool.Instance.GetCard(); card.transform.SetParent(transform); // 设为手牌管理器的子物体 // 设置卡的起始位置例如屏幕外下方 card.GetComponentRectTransform().anchoredPosition new Vector2(0, -500); currentHandCards.Add(card); } // 3. 计算所有卡牌的扇形目标位置 fanPositions layoutManager.CalculateAllFanPositions(cardCount); // 4. 为每张卡牌启动扇形展开动画可以错开时间更有层次感 for (int i 0; i currentHandCards.Count; i) { CardController controller currentHandCards[i].GetComponentCardController(); if (controller ! null) { // 延迟启动让牌一张张展开 float delay i * 0.05f; // 每张牌间隔0.05秒 StartCoroutine(StartFanWithDelay(controller, fanPositions[i], delay)); } } } private System.Collections.IEnumerator StartFanWithDelay(CardController controller, Vector3 targetPos, float delay) { yield return new WaitForSeconds(delay); controller.StartFanAnimation(targetPos); } // 清空手牌将牌归还对象池 private void ClearHand() { foreach (var card in currentHandCards) { CardObjectPool.Instance.ReturnCard(card); } currentHandCards.Clear(); } }4. 效果优化与进阶技巧基础功能实现后我们可以追求更极致的视觉效果和手感。4.1 视觉增强加入缓动与弹性直接使用线性变化的t来计算贝塞尔曲线运动看起来还是不够生动。我们可以对t应用一个缓动函数Easing Function。例如使用Mathf.SmoothStep或者更流行的OutBack、OutElastic等函数可以让卡牌在动画末尾有一个轻微的“过冲”和“回弹”模拟真实的物理惯性。Unity的DOTween插件是处理这类动画的利器它内置了大量缓动函数。如果不想引入插件也可以自己实现几个简单的函数。例如一个简单的OutBack缓动可以这样写float EaseOutBack(float t) { float c1 1.70158f; float c3 c1 1; return 1 c3 * Mathf.Pow(t - 1, 3) c1 * Mathf.Pow(t - 1, 2); }在UpdateFanMovement中计算位置时使用缓动后的t_easedfloat t Mathf.Clamp01(elapsedTime / animationDuration); float t_eased EaseOutBack(t); // 应用缓动 Vector3 position CalculateBezierPoint(t_eased, startPosition, controlPoint, targetPosition);4.2 性能优化避免每帧计算布局如果你的手牌数量在动画过程中是固定的那么CalculateAllFanPositions只需要在开始展开时计算一次然后将位置列表缓存起来。避免在每一帧都为每张牌重新计算扇形位置尤其是在卡牌数量较多时。此外CardController的Update函数在MovingToFan状态时才会执行计算一旦进入InFan状态就应该停止不必要的计算。这通过状态机已经自然实现了。4.3 交互反馈悬停与选中高亮当卡牌处于InFan状态时我们可以为其添加悬停Hover效果。这可以通过实现IPointerEnterHandler和IPointerExitHandler接口来完成。public class CardController : MonoBehaviour, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler { // ... 其他代码 ... public void OnPointerEnter(PointerEventData eventData) { if (currentState CardAnimationState.InFan) { // 放大、上浮、改变颜色等 rectTransform.localScale Vector3.one * 1.1f; // 可以播放一个轻微的向上移动的补间动画 } } public void OnPointerExit(PointerEventData eventData) { if (currentState CardAnimationState.InFan) { // 恢复原状 rectTransform.localScale Vector3.one; } } }为了效果更佳建议使用DOTween来完成这些缩放和位移动画而不是直接设置值这样会有平滑的过渡。4.4 常见问题与调试技巧问题1卡牌展开后位置不对或者重叠在一起。排查首先检查CardFanLayoutManager中的fanCenter是否正确赋值。其次在CalculateFanPosition方法中打印出计算出的dynamicRadius、dynamicTotalAngle和最终的offset值看是否符合预期。确保卡牌的锚点Pivot和RectTransform的坐标空间anchoredPositionvsposition与你计算时使用的坐标系一致。UI和3D世界坐标不能混用。问题2贝塞尔曲线运动不流畅有卡顿感。排查确保动画在Update中执行并使用Time.deltaTime来累积时间。检查animationDuration是否过短。如果使用了缓动函数尝试换一个平缓的函数如InOutSine测试。在性能分析器Profiler中查看是否在动画期间有GC Alloc垃圾分配对象池的GetCard和ReturnCard方法应避免在每帧产生新的GameObject。问题3点击卡牌没有反应。排查确认卡牌GameObject上是否有Canvas Renderer和Graphic组件如Image并且Raycast Target属性为true。检查EventSystem是否存在于场景中。在OnPointerClick方法中打一个日志确认方法是否被触发。确认卡牌的当前状态是否为InFan。问题4对象池回收后再次取出的卡牌状态异常。排查这是最经典的问题。务必在CardController.ResetCard()方法中将所有可能被修改的变量重置为初始值。特别是currentState、elapsedTime、以及Transform的位置、旋转、缩放。一个良好的实践是在卡牌Prefab上设置好一个“默认状态”ResetCard方法就是将其恢复到那个状态。实现这个扇形开牌效果的过程就像在打磨一个精致的机械装置。每一个参数半径、角度、曲线高度、动画时长都需要反复调试直到找到最符合你游戏风格的那组“黄金数值”。当看到卡牌按照你设想的弧线优雅地展开并伴随着流畅的缓动和清晰的交互反馈时那种成就感是巨大的。这不仅仅是实现了一个功能更是为你的游戏注入了灵魂。在下一篇中我们将继续探索卡牌被点击后如何飞向敌人并产生命中效果。