
1. 项目概述从“杀戮尖塔”到你的卡牌游戏如果你玩过《杀戮尖塔》或者《月圆之夜》这类卡牌游戏一定会对它们那优雅的扇形手牌布局印象深刻。鼠标悬停时卡牌会微微抬起并放大点击后又能精准选中整个交互过程流畅且富有反馈感。这种设计不仅美观更重要的是极大地提升了玩家的操作体验和策略思考的清晰度。今天我们就来深入拆解如何在Unity中从零开始实现一套类似《杀戮尖塔》风格的扇形手牌布局与交互系统。这不仅仅是把几张图片摆成弧形那么简单。它背后涉及到UI布局算法、动态插值动画、输入事件处理、渲染层级管理等一系列核心知识点。我们将一步步构建一个完整的解决方案涵盖从数据驱动的手牌管理到视觉上的平滑过渡再到细腻的交互反馈。无论你是想制作自己的DBG牌库构筑游戏还是仅仅想为你的UI系统增添一个炫酷的组件这篇实战指南都将提供可直接“抄作业”的代码和设计思路。2. 核心思路与架构设计在动手写代码之前我们先厘清整个系统的设计思路。一个健壮的扇形手牌系统应该将数据、表现、逻辑三者分离。2.1 数据驱动手牌管理的基石首先我们需要一个中心化的管理者我习惯称之为HandController或HandManager。它的核心职责是维护一个当前手牌的列表ListCardData。这里的CardData是一个纯粹的数据类ScriptableObject 是个好选择它只定义一张卡牌的本质属性ID、名称、费用、效果描述、目标类型等不包含任何Unity的GameObject或组件引用。为什么要数据与表现分离想象一下当玩家打出一张牌、抽一张新牌、或者使用了一张“复制”卡牌时我们首先操作的是这个数据列表。增加、删除或重新排序数据。然后由表现层即屏幕上那些卡牌物体来响应数据的变化更新自己的位置、状态和显示内容。这种设计让逻辑无比清晰也便于做网络同步或状态保存。2.2 表现层卡牌物体的双重身份屏幕上每一张可视的卡牌都是一个CardDisplay预制体。这个预制体上会挂载一个CardDisplay脚本。这个脚本扮演着“数据渲染器”和“交互接收器”的双重角色。渲染器它持有一个CardData的引用并根据这个数据来更新卡牌UI上的文本、图像、费用图标等。当HandController通知它数据更新时它负责刷新显示。接收器它需要处理玩家的鼠标或触摸事件悬停OnPointerEnter、移出OnPointerExit、点击OnPointerClick、开始拖拽OnBeginDrag等。但它不直接决定整手牌的布局它只负责报告自身的交互状态例如“我被悬停了”。2.3 布局控制器扇形的魔法师这是整个系统的核心——FanLayoutController。它不关心卡牌的具体内容只关心两件事有多少张牌和每张牌的交互状态。它的工作流程是监听数据从HandController获取当前手牌数据列表。关联表现为列表中的每个CardData找到或创建一个对应的CardDisplay物体。计算布局根据卡牌数量、预设的扇形参数半径、角度范围实时计算出每一张牌应该处于的目标位置Position、目标旋转Rotation和目标缩放Scale。应用动画驱动每个CardDisplay物体通过插值Lerp平滑地移动到其目标状态形成动画。处理交互偏移当某个CardDisplay报告自己被悬停时FanLayoutController会重新计算该卡牌的目标状态通常是位置抬高、缩放增大、旋转归零并优先应用从而产生“突出显示”的效果。这种架构下HandController管“有什么牌”CardDisplay管“这张牌长什么样、被点了没”FanLayoutController管“所有牌应该怎么摆”。职责分明耦合度低非常易于维护和扩展。3. 扇形布局算法的数学原理与实现让卡牌沿着圆弧排列是视觉效果的关键。这里我们采用一种基于角度等分的算法它计算简单效果自然。3.1 核心参数定义首先我们在FanLayoutController中定义几个可调节的公共变量方便在Unity编辑器中实时调试效果public class FanLayoutController : MonoBehaviour { [Header(布局参数)] public float arcRadius 300f; // 扇形的半径以像素或世界单位计 public float totalArcAngle 60f; // 扇形覆盖的总角度例如60度 public Vector2 basePosition new Vector2(0, -400); // 扇形圆心的屏幕位置通常是屏幕底部中央 public float baseRotation 0f; // 扇形整体的旋转偏移可用于倾斜 [Header(卡牌参数)] public float hoverRaiseHeight 50f; // 悬停时抬升的高度 public float hoverScale 1.2f; // 悬停时的缩放倍数 public float cardWidth 120f; // 卡牌的参考宽度用于计算间距 private ListCardDisplay activeDisplays new ListCardDisplay(); // ... 其他代码 }3.2 位置、旋转与缩放的计算核心算法在CalculateFanLayout这类函数中实现。假设我们有cardCount张牌当前正在计算第i张索引从0开始。1. 计算标准位置未悬停时// 计算从扇形最左侧到最右侧每张牌应占据的角度间隔 // 如果只有一张牌就放在正中间角度间隔为0 float angleStep (cardCount 1) ? totalArcAngle / (cardCount - 1) : 0f; // 计算当前牌相对于扇形中心的角度从左侧开始 // -totalArcAngle / 2 使得索引为0的牌在最左端 float currentAngle -totalArcAngle / 2 i * angleStep; // 将角度转换为弧度因为Mathf.Sin和Cos使用弧度 float angleInRadians currentAngle * Mathf.Deg2Rad; // 根据圆的参数方程计算位置x 圆心x 半径 * sin(角度), y 圆心y 半径 * cos(角度) // 注意这里使用Cos计算Y轴是因为我们希望0度角指向正上方。你可以根据你的坐标系调整。 Vector2 targetPos basePosition; targetPos.x arcRadius * Mathf.Sin(angleInRadians); targetPos.y arcRadius * Mathf.Cos(angleInRadians); // 卡牌自身的旋转应该与其在圆弧上的切线方向垂直简单处理可以让旋转等于角度的负值 float targetRotation -currentAngle; // 这样卡牌就会“面向”圆心看起来更自然。 // 缩放通常根据角度有所变化让边缘的牌稍小增强透视感。一个简单的实现 // 计算当前角度占最大角度的比例取绝对值比例越大缩放越小。 float t Mathf.Abs(currentAngle) / (totalArcAngle / 2); float targetScale Mathf.Lerp(1.0f, 0.85f, t); // 从中心的1.0缩放到边缘的0.852. 处理悬停状态当某张牌被悬停时我们需要覆盖它的标准计算值。if (cardDisplay.IsHovered) { // 位置在标准位置的基础上向屏幕上方或圆心反方向移动一段距离 Vector2 toCenterDir (basePosition - (Vector2)cardDisplay.transform.position).normalized; // 或者更简单直接向上抬升 targetPos.y hoverRaiseHeight; // 旋转通常悬停时让卡牌摆正便于阅读 targetRotation 0f; // 缩放放大 targetScale hoverScale; // 非常重要悬停的牌应该渲染在最上层避免被其他牌遮挡 cardDisplay.SetAsLastSibling(); // 如果使用UGUI这个方法可以改变其在父节点下的顺序 }注意这里的basePosition是扇形的圆心。计算标准位置时我们让卡牌分布在以basePosition为圆心、arcRadius为半径的圆弧上。而悬停抬升的方向可以根据设计需求选择朝向屏幕上方或者远离圆心的方向。上述代码提供了两种思路。3.3 平滑动画与性能考量计算出“目标状态”后我们不能直接设置transform.position和rotation那样会显得非常生硬。我们需要平滑的动画。使用插值Lerp在CardDisplay脚本或FanLayoutController的更新循环中使用Mathf.Lerp或Vector3.Lerp进行平滑过渡。// 在CardDisplay的Update中 void Update() { if (!isAnimating) return; float moveSpeed 10f; // 调整这个值控制动画快慢 transform.position Vector3.Lerp(transform.position, targetWorldPosition, Time.deltaTime * moveSpeed); transform.rotation Quaternion.Lerp(transform.lerpRotation, targetQuaternion, Time.deltaTime * moveSpeed); transform.localScale Vector3.Lerp(transform.localScale, targetScaleVector, Time.deltaTime * moveSpeed); // 当非常接近目标时可以停止动画以节省性能 if (Vector3.Distance(transform.position, targetWorldPosition) 0.1f) { isAnimating false; } }性能优化提示避免每帧为所有卡牌重算布局只有当手牌数量变化、或某张牌的交互状态变化时才需要触发一次完整的布局计算。计算完成后将目标状态分配给各个CardDisplay由它们自己负责动画。使用对象池频繁创建和销毁CardDisplay预制体会产生GC垃圾回收压力。在HandController或FanLayoutController中实现一个简单的对象池来复用卡牌物体。区分渲染层级悬停的卡牌应具有最高的渲染优先级。在UGUI中可以通过设置Transform.SetAsLastSibling()来实现。在SpriteRenderer或自定义渲染中需要管理sortingOrder。4. 交互系统的详细实现从悬停到出牌有了漂亮的布局接下来就要让玩家能与卡牌互动。交互的核心是响应输入事件和提供视觉反馈。4.1 事件处理UGUI EventSystem 与 RaycastUnity的UGUI系统提供了完善的EventSystem我们可以通过实现IPointerEnterHandler,IPointerExitHandler,IPointerClickHandler,IBeginDragHandler等接口来捕获事件。在CardDisplay脚本中public class CardDisplay : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerClickHandler, IBeginDragHandler, IEndDragHandler, IDragHandler { private bool isHovered false; public bool IsHovered isHovered; // 供布局控制器查询 // 鼠标进入 public void OnPointerEnter(PointerEventData eventData) { if (!IsPlayable()) return; // 例如检查法力值是否足够、是否被沉默等 isHovered true; // 通知布局控制器“我被悬停了” FanLayoutController.Instance.OnCardHovered(this); // 可以播放一个音效或粒子效果 PlayHoverSound(); } // 鼠标离开 public void OnPointerExit(PointerEventData eventData) { isHovered false; FanLayoutController.Instance.OnCardUnhovered(this); } // 鼠标点击用于快速出牌例如指向性技能 public void OnPointerClick(PointerEventData eventData) { if (!IsPlayable()) return; // 如果是需要选择目标的牌点击后可能进入“等待选择目标”状态 if (cardData.RequiresTarget) { GameManager.Instance.EnterTargetingMode(this); } else // 如果是无需目标的牌直接使用 { TryPlayCard(null); // null表示无特定目标 } } // 开始拖拽用于拖拽出牌更符合直觉 public void OnBeginDrag(PointerEventData eventData) { if (!IsPlayable()) return; // 开始拖拽时可以将卡牌“吸附”在鼠标指针上并隐藏原手牌中的它或降低其透明度 isBeingDragged true; originalParent transform.parent; transform.SetParent(GetDragCanvas().transform); // 拖拽到顶层Canvas // 同样通知布局控制器这张牌正在被拖拽可能需要特殊处理 FanLayoutController.Instance.OnCardDragStarted(this); } // 拖拽中 public void OnDrag(PointerEventData eventData) { if (!isBeingDragged) return; // 让卡牌跟随鼠标移动 RectTransformUtility.ScreenPointToWorldPointInRectangle( (RectTransform)transform.parent, eventData.position, eventData.pressEventCamera, out Vector3 worldPoint); transform.position worldPoint; // 可以在这里做高亮潜在目标的操作 HighlightPotentialTargets(); } // 结束拖拽 public void OnEndDrag(PointerEventData eventData) { if (!isBeingDragged) return; isBeingDragged false; // 检查释放位置是否有有效目标 GameObject dropTarget GetDropTarget(eventData); if (dropTarget ! null IsValidTarget(dropTarget)) { // 在有效目标上释放使用卡牌 TryPlayCard(dropTarget.GetComponentITargetable()); } else { // 无效区域释放卡牌返回手牌 ReturnToHand(); } FanLayoutController.Instance.OnCardDragEnded(this); } // ... 其他方法 }4.2 视觉反馈超越简单的放大除了布局控制器处理的“抬升”和“放大”我们还可以在CardDisplay内部添加更丰富的反馈光泽效果在卡牌被悬停时激活一个沿着卡牌表面移动的Shader光泽Highligh效果。描边/发光通过修改材质或添加一个外发光UI效果让可使用的卡牌带有微弱的蓝色光晕不可使用的卡牌呈灰色并带红色边框。费用高亮悬停时卡牌的费用数字可以跳动或放大。动态描述文本有些游戏在悬停时会展开更详细的卡牌描述或当前条件下的效果预览。这些效果可以通过在OnPointerEnter/Exit中激活/禁用特定的子物体如一个高亮Image或通过Animator触发一个动画状态机来实现。4.3 出牌逻辑与手牌更新当一张牌被成功使用点击或拖拽到有效目标CardDisplay会调用TryPlayCard方法。这个方法应该验证再次检查费用、条件等。执行效果调用CardData中定义的效果逻辑对目标造成伤害、施加状态等。通知通知HandController“这张牌被打出去了”。清理HandController从手牌数据列表中移除该CardData。回收将对应的CardDisplay物体归还给对象池或设置为不可用状态。刷新布局HandController数据变化后触发FanLayoutController重新计算布局。由于少了一张牌剩余的牌会平滑地移动重新形成一个紧凑的扇形。这个过程确保了数据层和表现层的同步更新。5. 高级技巧与实战避坑指南实现基础功能后一些细节处理决定了系统的最终品质。以下是我在多个项目中总结的经验和常见问题的解决方案。5.1 渲染层级Sorting Order的深度管理问题在复杂的UI中卡牌、特效、场景元素可能互相重叠。简单的SetAsLastSibling可能不足以管理所有情况尤其是当卡牌被拖拽出Hand区域时。解决方案实现一个全局的SortingOrderManager。为UI划分不同的“层”Layer例如背景层0、手牌层100、拖拽层1000、弹出框层2000。每个CardDisplay初始化时被分配到手牌层并获取一个基础Order如100。在FanLayoutController计算布局时根据卡牌在扇形中的位置通常是X坐标动态分配Order。最左边的卡牌Order最小最右边最大这样就能确保右侧的卡牌总能盖住左侧的符合视觉逻辑。当卡牌被拖拽时OnBeginDrag中将其Order设置为拖拽层的值1000确保它在所有其他UI之上。当卡牌被使用后返回对象池时重置其Order。// 在FanLayoutController中分配层级 for (int i 0; i activeDisplays.Count; i) { int sortOrder handLayerBaseOrder i; // 让索引越大的牌通常越靠右层级越高 activeDisplays[i].SetSortOrder(sortOrder); }5.2 动画曲线与手感调优问题使用固定的Lerp速度动画可能显得机械。杀戮尖塔的卡牌移动带有一种轻微的弹性或缓动效果手感更佳。解决方案使用动画曲线AnimationCurve或第三方缓动库如DOTween。在FanLayoutController中暴露几个AnimationCurve变量PositionEaseCurve,RotationEaseCurve,ScaleEaseCurve。计算动画时根据从开始到当前经过的时间比例elapsedTime / duration从曲线中取样值作为插值的参数。// 在CardDisplay的动画协程中 float timer 0f; Vector3 startPos transform.position; while (timer animationDuration) { timer Time.deltaTime; float t timer / animationDuration; float easedT positionEaseCurve.Evaluate(t); // 使用曲线变换t值 transform.position Vector3.Lerp(startPos, targetPos, easedT); yield return null; }你可以将曲线调成开始时加速、结束时减速Ease-Out或者带一点 overshoot超出再回弹的效果这能极大提升操作的愉悦感。5.3 移动端触摸屏适配问题PC端的鼠标悬停Hover在移动端不存在。如何实现类似体验解决方案采用“点击选择”或“长按预览”模式。点击选择模式点击一张牌它被“选中”并持续保持悬停状态抬升、放大直到点击另一张牌或点击空白处取消。此时IsHovered状态由点击事件触发和清除。长按预览模式这是更常见的方案。手指长按一张卡牌触发“悬停”效果松开手指后效果消失。这需要用到EventTrigger组件监听PointerDown和PointerUp事件并用一个计时器来判断是“点击”还是“长按”。拖拽出牌在移动端拖拽是更自然的手势。OnBeginDrag的触发阈值需要调高一些避免与滚动视图冲突。同时要考虑手指可能遮挡卡牌的问题可以在拖拽开始时将卡牌复制一个放大版的“预览图”跟随手指原卡牌留在手牌中半透明显示。5.4 性能优化与对象池实践问题战斗中频繁抽牌、弃牌、生成临时卡牌如果不停Instantiate和Destroy会引起卡顿。解决方案实现一个针对CardDisplay的简单对象池。public class CardDisplayPool : MonoBehaviour { public CardDisplay cardPrefab; public Transform poolContainer; // 一个隐藏的父物体用于存放未使用的卡牌 private QueueCardDisplay pool new QueueCardDisplay(); public CardDisplay GetCardDisplay(CardData data) { CardDisplay display; if (pool.Count 0) { display pool.Dequeue(); display.gameObject.SetActive(true); } else { display Instantiate(cardPrefab, transform); // 初始父节点可设为Hand区域 } display.transform.SetParent(null); // 先从池容器取出 display.Initialize(data); // 用新数据初始化显示 return display; } public void ReturnCardDisplay(CardDisplay display) { display.gameObject.SetActive(false); display.transform.SetParent(poolContainer); // 放回池容器 display.transform.localPosition Vector3.zero; pool.Enqueue(display); } }在HandController中持有这个池子的引用。需要显示新牌时调用GetCardDisplay牌被使用或丢弃时调用ReturnCardDisplay。5.5 常见问题排查速查表问题现象可能原因排查步骤与解决方案卡牌重叠不呈扇形1. 布局计算逻辑错误所有卡牌获得了相同的位置。2.arcRadius或totalArcAngle设置过小。3. 卡牌的锚点Pivot不在中心导致旋转轴不对。1. 在CalculateFanLayout中打印每张牌计算出的currentAngle和targetPos检查是否不同。2. 在Scene视图中用Gizmos绘制出计算出的扇形和每个目标点直观检查。3. 确保卡牌Image的RectTransform的Pivot设置为(0.5, 0.5)。悬停时卡牌闪烁或跳动1. 悬停状态 (isHovered) 在单帧内被反复设置和清除。2. 布局计算和动画更新在不同帧率下不同步。3. Raycast目标冲突鼠标下有多层Collider。1. 检查OnPointerEnter/Exit逻辑确保没有其他代码意外修改了isHovered。可以在状态改变时加Debug.Log。2. 确保布局计算在FanLayoutController的LateUpdate中在卡牌自身动画更新之前完成。3. 检查卡牌上的UI组件如Button、Image是否都正确设置了Raycast Target避免父子物体同时响应。拖拽卡牌时原位置卡牌消失或错乱1. 拖拽开始时没有正确通知布局控制器这张牌“暂时离队”。2. 拖拽物体的父节点改变影响了布局计算中的坐标转换。1. 在OnBeginDrag中除了设置isBeingDragged还应调用FanLayoutController.OnCardDragStarted(this)让布局器在计算时忽略此牌。2. 拖拽时使用一个独立的、位于屏幕最顶层的Canvas作为父节点。计算返回位置时需要将世界坐标转换回手牌区域的本地坐标。移动端触摸不灵敏或误触1. 卡牌UI元素太小触摸区域不足。2. 没有处理多指触摸。3. 与屏幕边缘手势冲突。1. 为卡牌添加一个比视觉区域稍大的透明Image作为点击区域。2. 在OnBeginDrag等事件中检查eventData.pointerId确保只处理第一根手指的操作。3. 如果游戏有边缘滑动菜单需要精细调整EventSystem的拖拽阈值或使用InputSystem的增强功能。大量卡牌时10张布局拥挤或超出屏幕布局参数是固定的没有根据卡牌数量动态调整。在CalculateFanLayout中根据cardCount动态调整totalArcAngle或arcRadius。例如float dynamicAngle Mathf.Min(totalArcAngle, cardCount * anglePerCard)或者让半径随数量增加而增大。实现一个《杀戮尖塔》级别的扇形手牌系统是一个融合了数学、动画、交互和架构设计的综合练习。它没有使用任何黑魔法每一个效果背后都是对基础组件的巧妙运用。从确定数据驱动架构到实现核心的圆弧布局算法再到打磨细腻的交互反馈每一步都需要耐心调试。最重要的是不要满足于“能用”要反复体验手感调整那些动画曲线和参数直到它感觉“爽快”。当你看到自己手牌随着操作如扇子般优雅开合时那种成就感就是对我们开发者最好的奖励。