1. 项目概述与核心思路做老虎机Slot Machine游戏转动效果是灵魂。一个流畅、有张力、符合玩家心理预期的转盘动画能直接提升游戏的爽快感和沉浸感。最近在项目中重构了一个老虎机模块核心需求就是实现两种主流的转动效果一种是匀速减速停止另一种是弹性回弹停止。别看只是“转动”里面涉及到动画曲线控制、物理模拟、随机数生成与结果预定的协同以及性能优化等多个技术点。这篇文章我就结合这次实战把这两种效果的实现原理、代码细节、踩过的坑和优化心得掰开揉碎了讲清楚。无论你是刚接触Unity的开发者还是想优化现有老虎机逻辑的程序相信都能找到可以直接“抄作业”的干货。简单来说我们要实现一个由多列通常是3列或5列图标组成的转盘。每一列独立转动最终停止时需要显示出服务器或逻辑层预先确定好的结果图案。难点在于如何让这个“停止”的过程看起来自然、有冲击力而不是生硬地瞬间定格。第一种“匀速减速”模拟了现实轮盘因摩擦力逐渐停下的感觉第二种“弹性回弹”则更夸张像是有根弹簧在拉扯停下的瞬间还会轻微晃动几下视觉反馈更强。2. 核心效果设计与数学原理在动手写代码之前我们必须把两种效果背后的运动模型想明白。这决定了我们是用Update里手动计算还是用AnimationCurve配合协程或者是用物理引擎。为了保持高性能和完全可控我选择了纯数学计算配合协程的方案完全掌控每一帧的位移。2.1 匀速减速模型这种效果追求的是线性减速到零的平滑感。假设一列有N个图标Item我们通常让它们在一个垂直方向上循环滚动。转动速度是一个从初始值startSpeed线性递减到0的过程。其核心是经典的运动学公式。在每一帧Time.deltaTime里我们更新当前速度currentSpeed和累计位移totalDelta速度更新currentSpeed Mathf.MoveTowards(currentSpeed, 0f, deceleration * Time.deltaTime)。这里deceleration是减速度决定了停下来需要的时间。位移计算float delta currentSpeed * Time.deltaTimetotalDelta delta。图标位置更新根据totalDelta对图标进行排序和位置重置实现循环滚动的视觉效果。关键在于“停止点”的控制。我们不能让转盘停在任意位置必须精确停在预设结果图标对齐的位置。假设每个图标的高度是itemHeight那么最终totalDelta必须是itemHeight的整数倍。我们需要在减速过程中实时计算当预测到下一帧将越过目标点时就强制将位置修正到精确的目标点并立即将速度设为0。注意不要试图在每一帧都精确计算并修正位置到目标点这会导致动画卡顿。正确做法是只在“即将到达”的最后一两帧进行微调。通常判断条件是(targetPos - currentPos) nextFrameDelta即剩余距离小于下一帧将要移动的距离时进行跳跃修正。2.2 弹性回弹模型这种效果更复杂也更出彩。它模拟了弹簧阻尼振动系统。我们期望转盘快速减速到目标点附近但不是直接停下而是冲过目标点一小段距离再被“拉”回来如此往复几次振幅越来越小最终停在目标点。最直观的实现方式是使用Damping Harmonic Oscillator阻尼谐振子模型或者用AnimationCurve绘制一个带过冲的减速曲线。我选择了后者因为更直观美术也能参与调整。曲线设计在Unity编辑器中创建一个AnimationCurveX轴是归一化的时间0到1Y轴是归一化的位移进度0到1。曲线的形状应该是开始陡峭快速运动在接近终点Y1时轻微超过1例如到1.05然后回落并最终稳定在1。这个“超过1”的部分就是过冲Overshoot回落后的小幅波动就是回弹。数学映射在协程中我们记录总动画时长duration和已进行时间elapsedTime。计算归一化时间t elapsedTime / duration。然后从曲线取样sample curve.Evaluate(t)。最终当前位移进度currentProgress startPos (targetPos - startPos) * sample。这里的sample可能大于1就对应了过冲的位置。停止判断当t 1.0f时动画结束必须将位置精确设置为targetPos避免浮点数误差导致的微小错位。实操心得弹性曲线的绘制需要反复调试。过冲量Overshoot太大会显得不自然太小则回弹感不足。一个经验值是过冲到1.05至1.15之间并确保曲线末端是平滑地、水平地接近Y1这样停止时才不会有突兀的抖动。可以将这个曲线暴露为public AnimationCurve reboundCurve方便策划和美术在Inspector窗口中实时调整效果。3. 核心模块实现与代码详解理论清楚了我们开始搭建核心的SlotColumn类。每一列都是一个独立的SlotColumn它管理着一组图标Item Prefab的渲染和动画。3.1 数据结构与初始化首先定义必要的变量和初始化图标队列。using System.Collections; using System.Collections.Generic; using UnityEngine; public class SlotColumn : MonoBehaviour { [Header(References)] [SerializeField] private Transform itemsContainer; // 存放所有图标的父节点 [SerializeField] private GameObject itemPrefab; // 单个图标预制体 [SerializeField] private int visibleItemCount 5; // 可视区域内的图标数量通常为奇数 [Header(Spin Settings)] [SerializeField] private float itemHeight 100f; // 每个图标的高度像素或单位 [SerializeField] private float baseSpinSpeed 2000f; // 初始转动速度单位/秒 [SerializeField] private AnimationCurve decelerateCurve; // 用于匀速减速的曲线 [SerializeField] private AnimationCurve reboundCurve; // 用于弹性回弹的曲线 private ListSlotItem slotItems new ListSlotItem(); // 所有图标实例的列表 private Queueint symbolQueue new Queueint(); // 待显示符号ID的队列 private float currentScrollOffset 0f; // 当前滚动偏移量 private Coroutine spinCoroutine null; private void Start() { InitializeItems(); } // 初始化并创建足够多的图标实例形成循环列表 private void InitializeItems() { // 清除旧实例 foreach (Transform child in itemsContainer) { Destroy(child.gameObject); } slotItems.Clear(); // 我们需要创建 (visibleItemCount 2) 个实例确保滚动时无缝衔接 int totalItemsToCreate visibleItemCount 2; for (int i 0; i totalItemsToCreate; i) { GameObject itemObj Instantiate(itemPrefab, itemsContainer); SlotItem item itemObj.GetComponentSlotItem(); // 初始位置设置i - 1是为了让中间那个图标索引为 visibleItemCount/2初始在视口中心 float posY (i - 1) * itemHeight; itemObj.transform.localPosition new Vector3(0, posY, 0); slotItems.Add(item); } // 初始设置一次图标显示内容 UpdateItemContents(); } }这里的关键是创建了visibleItemCount 2个图标实例。多出来的两个一个放在可视区域上方预备进入一个放在下方预备进入这样在循环滚动时当某个图标完全移出可视区域我们可以立即将其重置到队列的另一端并更新其显示内容从而实现无限滚动的假象且不会有任何瞬跳。3.2 循环滚动与图标内容更新滚动效果的本质是不断改变所有图标实例的本地Y坐标并维护一个逻辑上的“符号队列”。// 根据当前滚动偏移量更新所有图标的位置和显示内容 private void UpdateItemPositions() { // 1. 应用偏移量移动所有图标 for (int i 0; i slotItems.Count; i) { SlotItem item slotItems[i]; float basePosY (i - 1) * itemHeight; // 初始基准位置 float finalPosY basePosY - currentScrollOffset; // 应用滚动偏移 item.transform.localPosition new Vector3(0, finalPosY, 0); } // 2. 检查并重置完全移出视口的图标实现循环 // 视口上边界假设中心为0 float viewportTop (visibleItemCount / 2) * itemHeight; // 视口下边界 float viewportBottom -viewportTop; for (int i slotItems.Count - 1; i 0; i--) { SlotItem item slotItems[i]; if (item.transform.localPosition.y viewportTop itemHeight) { // 图标已完全从上方移出视口将其移动到最下方并更新内容 float newPosY item.transform.localPosition.y - (slotItems.Count * itemHeight); item.transform.localPosition new Vector3(0, newPosY, 0); // 移动到列表末尾保持顺序 slotItems.RemoveAt(i); slotItems.Add(item); // 更新这个“新”图标显示的内容 UpdateSingleItemContent(item); } else if (item.transform.localPosition.y viewportBottom - itemHeight) { // 图标已完全从下方移出视口将其移动到最上方并更新内容 float newPosY item.transform.localPosition.y (slotItems.Count * itemHeight); item.transform.localPosition new Vector3(0, newPosY, 0); // 移动到列表开头 slotItems.RemoveAt(i); slotItems.Insert(0, item); UpdateSingleItemContent(item); } } } // 更新单个图标显示的内容从符号队列中获取 private void UpdateSingleItemContent(SlotItem item) { if (symbolQueue.Count 0) { int symbolId symbolQueue.Dequeue(); item.SetSymbol(symbolId); // 队列耗尽前需要补充新的符号ID这里简化处理可以随机生成或从预设列表获取 // 在实际项目中这个队列应由游戏逻辑驱动确保最终停止时中心图标是预定结果 symbolQueue.Enqueue(GetRandomSymbolId()); } }UpdateItemPositions方法是核心中的核心。它做了两件事一是根据currentScrollOffset更新所有图标位置二是检查是否有图标完全移出了可视区域如果有就将其“传送”到另一端并更新其显示内容同时调整slotItems列表顺序以维持空间逻辑。这种“循环缓冲区”式的管理是高效实现无限滚动动画的关键。3.3 两种转动效果的协程实现接下来是重头戏启动旋转和停止动画的协程。我们提供两个公共方法分别触发两种效果。public void StartSpinning() { if (spinCoroutine ! null) { StopCoroutine(spinCoroutine); } spinCoroutine StartCoroutine(SpinRoutine()); } // 传入最终需要停止在中心的符号ID以及停止效果类型 public void StopAtSymbol(int targetSymbolId, StopEffectType effectType) { if (spinCoroutine ! null) { StopCoroutine(spinCoroutine); } // 在停止前需要根据targetSymbolId计算出需要滚动的精确距离targetOffset // 这涉及到当前符号队列的映射是一个逻辑计算过程此处省略... float targetOffset CalculateTargetOffset(targetSymbolId); switch (effectType) { case StopEffectType.Decelerate: spinCoroutine StartCoroutine(StopWithDecelerateRoutine(targetOffset)); break; case StopEffectType.Rebound: spinCoroutine StartCoroutine(StopWithReboundRoutine(targetOffset)); break; } } private IEnumerator SpinRoutine() { // 纯匀速旋转阶段通常持续随机时间或等待服务器信号 float spinSpeed baseSpinSpeed; while (true) { currentScrollOffset spinSpeed * Time.deltaTime; // 归一化防止偏移量无限增大 currentScrollOffset % (slotItems.Count * itemHeight); UpdateItemPositions(); yield return null; } }匀速减速停止协程private IEnumerator StopWithDecelerateRoutine(float targetOffset) { float startOffset currentScrollOffset; // 计算需要滚动的总距离考虑循环取最短路径 float distance Mathf.DeltaAngle(startOffset, targetOffset) * itemHeight / 360f; // 这里用DeltaAngle处理循环取模问题是个技巧 // 更通用的方法是 float diff targetOffset - startOffset; // 因为偏移量是循环的所以调整diff到 [-cycle/2, cycle/2] 区间保证是最短转动方向 float cycle slotItems.Count * itemHeight; if (Mathf.Abs(diff) cycle / 2) { diff diff 0 ? diff - cycle : diff cycle; } float distance diff; float duration Mathf.Abs(distance) / (baseSpinSpeed * 0.5f); // 减速阶段持续时间可根据曲线调整 float elapsedTime 0f; while (elapsedTime duration) { elapsedTime Time.deltaTime; float t Mathf.Clamp01(elapsedTime / duration); // 使用减速曲线从1到0来插值速度或进度 float curveValue decelerateCurve.Evaluate(t); // 计算当前进度startOffset distance * (1 - curveValue) 的变体 // 更直观的是直接用曲线值做线性插值但曲线需要是递减的 // 这里我们采用直接插值位移的方式 currentScrollOffset Mathf.Lerp(startOffset, targetOffset, t); // 但为了有减速感我们对t应用曲线。实际上我们可以用曲线来重映射时间t float remappedT decelerateCurve.Evaluate(t); // 假设曲线是从1到0 currentScrollOffset startOffset distance * (1 - remappedT); UpdateItemPositions(); yield return null; } // 确保最终位置精确 currentScrollOffset targetOffset; UpdateItemPositions(); spinCoroutine null; }弹性回弹停止协程private IEnumerator StopWithReboundRoutine(float targetOffset) { float startOffset currentScrollOffset; float distance targetOffset - startOffset; // 同样处理循环取模确保最短路径 float cycle slotItems.Count * itemHeight; if (Mathf.Abs(distance) cycle / 2) { distance distance 0 ? distance - cycle : distance cycle; } float duration 1.5f; // 弹性动画总时长可配置 float elapsedTime 0f; while (elapsedTime duration) { elapsedTime Time.deltaTime; float t Mathf.Clamp01(elapsedTime / duration); // 从弹性曲线取样曲线Y值范围可能超过[0,1] float curveValue reboundCurve.Evaluate(t); // 应用曲线值到位移 currentScrollOffset startOffset distance * curveValue; // 处理循环偏移确保currentScrollOffset在[0, cycle)范围内 currentScrollOffset Mathf.Repeat(currentScrollOffset, cycle); UpdateItemPositions(); yield return null; } // 最终修正确保停在精确位置 currentScrollOffset targetOffset; UpdateItemPositions(); spinCoroutine null; }在弹性回弹协程中reboundCurve的绘制至关重要。你需要在AnimationCurve编辑器中将第一个关键帧(0,0)的切线拉高制造快速启动在中间偏后位置(0.7, 1.05)设置一个关键帧制造过冲在最后(1,1)设置关键帧并将切线设为水平以平滑停止。通过调整中间关键帧的数值和切线手柄可以创造出各种不同的弹性感觉。4. 结果预定与逻辑同步老虎机游戏不能是纯客户端随机的结果必须由服务器或确定的逻辑决定。这就要求我们的转动动画是“表演”最终显示的结果在开始停止动画时就已经确定了。我们引入一个SymbolSequence的概念。每一列在初始化时就生成一个足够长的符号ID序列比如1000个这个序列是确定的。当服务器下发停止指令和结果例如第一列停在“樱桃”第二列停在“7”第三列停在“BAR”时客户端需要做的是将每列的“目标符号”映射到其SymbolSequence中的一个位置。计算出从当前位置滚动到那个位置所需要的targetOffset。启动停止动画并确保动画结束时中心图标或判定线上的图标正好是目标符号。这涉及到符号队列的管理和偏移量计算。通常我们会维护一个逻辑上的“虚拟长列表”currentScrollOffset是这个虚拟列表的滚动偏移。targetOffset就是目标符号在这个虚拟列表中的位置。计算diff时要选择最短的滚动方向顺时针或逆时针以提供最好的视觉体验。踩坑实录最初我直接使用targetOffset - currentScrollOffset计算距离当需要跨过循环零点时转盘会朝反方向绕一大圈非常奇怪。后来改用Mathf.DeltaAngle的思路处理循环值或者手动将差值调整到[-cycle/2, cycle/2]区间问题才得以解决。记住对于循环滚动的数值做差值运算前一定要先归一化到同一个周期内并考虑最短路径。5. 性能优化与视觉增强当老虎机列数增多或者图标非常精致时性能可能成为瓶颈。以下是几个立竿见影的优化点图标池与复用不要频繁Instantiate和Destroy图标。在InitializeItems中创建好一个图标池在循环滚动重置图标位置时只更新其显示内容Sprite、Color等而不是改变其在池中的引用。这能极大减少GC垃圾回收压力。避免每帧查找组件在Start或Awake中将SlotItem组件引用缓存到列表里在UpdateItemPositions中直接使用缓存后的引用避免GetComponent调用。使用Canvas合批如果图标是UI元素Image确保它们在同一个Canvas下并且材质相同以促进Unity UI的合批减少Draw Call。动画更新频率如果游戏帧率很高如120Hz但老虎机动画不需要如此精细可以考虑在协程中使用WaitForSeconds(0.016f)约60Hz来限制更新频率或者使用Time.deltaTime的累积量进行判断减少不必要的UpdateItemPositions调用和Transform更新。视觉增强技巧运动模糊在高速旋转时可以给图标父节点添加一个简单的运动模糊Shader或者叠加一个拖尾粒子效果增强速度感。分层滚动可以将一列中的图标分为前景层和背景层以略微不同的速度滚动营造出景深效果。停止特效当某一列停止时可以触发该列的中心图标高亮、放大、粒子爆发等特效强化反馈。音效同步转动音效应由慢到快停止时配合“咔哒”声和回弹音效音画同步至关重要。6. 常见问题与调试技巧在实际开发中你肯定会遇到一些诡异的问题。这里记录几个我踩过的坑和解决方法。问题1图标在停止时轻微抖动或错位。排查这几乎总是浮点数精度问题。在停止协程的最后你必须将currentScrollOffset强制设置为计算好的targetOffset并调用UpdateItemPositions。检查你的targetOffset计算逻辑确保它恰好是itemHeight的整数倍。解决在最终修正位置时使用Mathf.Round(currentScrollOffset / itemHeight) * itemHeight进行四舍五入到最近的整数倍高度。或者在UpdateItemPositions中计算finalPosY后也可以做一个微小的舍入。问题2弹性回弹结束时图标位置偶尔对不齐中心线。排查检查你的reboundCurve。确保曲线最后一个关键帧的Y值精确等于1并且其左右切线都是水平的切线权重为0。如果曲线在末尾还有微小的斜率即使时间t1计算出的位置也可能有极小偏差。解决在Unity中编辑曲线选中最后一个关键帧在Inspector中将其Out Tangent设置为Constant常量模式或者手动将Out Weight设为0确保曲线在结尾是绝对平坦的。问题3多列同时停止时感觉不同步。排查虽然每列独立运行协程但由于帧率波动协程的yield return null时机可能有细微差别。此外如果各列的duration动画时长或曲线形状有差异也会导致不同步。解决对于需要严格同步的“同时停止”效果不要各自启动协程。应该由一个总控管理器如SlotMachineManager在一个协程里统一计算和设置所有列的currentScrollOffset。这样它们就在同一帧更新绝对同步。问题4转动过程中图标显示的内容出现乱序或重复。排查问题出在UpdateSingleItemContent和符号队列symbolQueue的维护上。当图标被重置到另一端时你从队列中取出了一个符号ID来显示但你是否及时补充了新的ID到队列末尾队列的生成逻辑是否保证了结果的确定性解决实现一个可靠的SymbolSequenceGenerator。它可以是一个很大的预生成列表也可以是一个伪随机数生成器PRNG配合固定的种子。当需要为重置的图标获取下一个符号时不是从Queue.Dequeue()而是从生成器获取下一个值并同时将这个值推入队列以备后续使用。确保整个序列在单次旋转中是确定且连续的。调试技巧绘制调试线在OnDrawGizmos中画出可视区域的上下边界viewportTop,viewportBottom以及每个图标的位置可以非常直观地看到循环重置是否正常工作。时间缩放在Unity编辑器中调整Time.timeScale慢放动画仔细观察停止瞬间的位置修正和曲线变化过程。日志输出在停止协程的关键节点如计算出的distance、targetOffset、每一帧的currentScrollOffset输出日志与预期值进行比对。实现一个手感出色的老虎机转动效果三分靠算法七分靠调试。尤其是弹性曲线的调整需要反复在真机上测试感受那种“恰到好处”的顿挫感和回弹力。把文中的代码框架搭起来然后花时间细细打磨那些参数和曲线你的老虎机就能从“能转”变成“好玩”。最后别忘了加上那些炫酷的灯光和音效那才是让玩家忍不住再来一把的魔法。