十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity老虎机抽奖效果实现:自定义动画与循环滚动技术详解

Unity老虎机抽奖效果实现:自定义动画与循环滚动技术详解 1. 项目概述从老虎机到数字抽奖的平滑过渡最近在做一个休闲游戏项目里面需要一个类似老虎机或者大转盘的抽奖模块。甲方爸爸的原话是“要那种滚动的有惯性最后‘咔哒’一下停住感觉特别爽的抽奖效果。” 这不就是经典的老虎机Slot Machine滚动效果嘛。虽然市面上有很多插件但自己实现一遍对理解Unity的动画系统、协程和时间控制有奇效。这个效果的核心远不止让几个图片上下滚动那么简单它涉及到视觉欺骗、运动曲线模拟、概率控制与表现分离等一系列游戏开发中常见的技巧。无论你是想做一个纯视觉的展示界面还是一个需要后端概率验证的真实抽奖系统这套思路都能给你提供一个扎实的起点。接下来我就把自己从零搭建这个效果的过程包括思路、踩过的坑和优化技巧详细拆解一遍。2. 核心设计思路与方案选型2.1 效果拆解不只是“滚动”乍一看老虎机效果就是几个物品图标在窗口里垂直滚动。但仔细琢磨一个体验良好的效果包含多个层次初始加速启动时不是匀速而是有一个短暂的加速过程模拟现实老虎机拉杆后的启动。匀速滚动加速结束后进入稳定的高速滚动状态此时图标模糊或连成一片看不清具体内容。减速制动收到停止指令后开始减速。这里的减速不是匀减速而是类似“先快后慢”的非线性过程。精准停靠与回弹最终需要精确地停在某个预设的图标位置。为了增强手感在完全停稳前可以加入微小的 overshoot过冲和回弹就像齿轮卡入卡槽的“咔哒”感。概率与表现分离这是关键。滚动动画是“表现”最终停在哪一个是“结果”。结果应由抽奖逻辑如服务器下发、本地概率表提前决定滚动动画只是戏剧化地展示这个结果。2.2 实现方案对比实现滚动主要有三种思路UGUI ScrollRect 惯性利用UGUI自带的ScrollRect组件开启惯性通过修改速度模拟滚动。优点是简单能快速实现列表滚动。缺点对滚动过程的控制粒度较粗难以实现“先加速后减速再精准停靠”的复杂曲线且对“无限循环滚动”的视觉模拟支持不佳。DoTween/LeanTween 动画插件使用这些强大的补间动画插件可以非常方便地定义复杂的运动曲线。优点代码简洁曲线控制直观。缺点对于需要根据运行时确定的“停止位置”来动态计算滚动距离和时间的场景逻辑会稍显绕且插件黑盒化不利于理解底层原理。自定义动画 协程自己用Mathf.Lerp、Mathf.SmoothStep等插值函数配合协程Coroutine或Update驱动动画。优点控制力最强每一帧的状态都掌握在自己手中可以轻松实现任何自定义的运动曲线和逻辑如分阶段加速、减速、回弹。性能透明便于优化。缺点需要自己处理更多细节。考虑到我们需要高度的控制力和清晰的理解我选择了方案三自定义动画协程。这不仅是为了实现效果更是一个深入理解游戏动画时序的绝佳练习。2.3 整体架构设计我的设计分为三个核心层数据层负责管理所有可抽奖的项Item列表以及处理抽奖逻辑计算最终结果索引。这一层是纯逻辑与显示无关。表现层核心是一个SlotRoller类它管理一列一个卷轴的视觉表现。它持有对数据层结果索引的引用并驱动图标滚动、减速、停靠的完整动画。视图层由多个SlotRoller实例组成的控制器例如SlotMachineController负责同时启动和协调多个卷轴的动画并处理用户交互如点击抽奖按钮。这种分离确保了抽奖结果的公平性逻辑决定和表现的灵活性动画展示。3. 核心模块实现详解3.1 数据与配置准备首先我们定义抽奖项和单个卷轴的数据结构。// 抽奖单项数据 [System.Serializable] public class SlotItem { public Sprite icon; // 图标 public string name; // 名称 public int weight; // 权重用于概率计算 // 其他奖励数据... } // 单个卷轴老虎机的一列的配置与状态 public class SlotRoller : MonoBehaviour { public ListSlotItem itemList; // 本卷轴对应的物品列表 public RectTransform content; // 滚动内容的父节点UGUI public float itemHeight; // 每个物品UI单元的高度 public int visibleItemCount 3; // 可视区域内显示的项目数量通常为奇数如3 private int _targetItemIndex; // 抽奖逻辑决定的目标停止索引 private float _currentPosition; // 当前滚动位置基于像素或单位 private bool _isRolling false; // ... 其他状态变量 }在场景中你需要一个垂直的ScrollRect或者直接是一个RectTransform作为content其下排列着足够多的SlotItem的UI克隆体例如用itemList生成并为了循环滚动需要上下额外多复制一份。itemHeight可以通过代码计算获得。注意为了实现“无限滚动”的视觉效果我们通常采用“对象池位置重置”的策略。即content下的实际UI对象数量是visibleItemCount 2或更多当某个UI对象滚动出可视区域时立即将其位置调整到即将进入区域的另一端并更新其显示的数据。这是性能优化的关键避免创建大量UI对象。3.2 滚动动画引擎协程与曲线这是最核心的部分。我们使用一个协程来驱动整个动画过程并将其分为几个阶段。public IEnumerator RollToTargetIndex(int targetIndex, float totalDuration) { if (_isRolling || itemList null || itemList.Count 0) yield break; _isRolling true; _targetItemIndex targetIndex; // 阶段1计算需要滚动的总距离。 // 注意由于是循环列表需要计算最短路径。例如从索引9滚动到索引1正向滚动很长但反向经过0滚动很短。 int currentVisualIndex GetCurrentVisualIndex(); // 根据当前位置推算当前视觉上的“主索引” int steps CalculateShortestSteps(currentVisualIndex, targetIndex, itemList.Count); float totalDistance steps * itemHeight; // 总滚动距离 // 阶段2加速阶段 (占总时长的10%) float accelerateTime totalDuration * 0.1f; float timer 0f; while (timer accelerateTime) { timer Time.deltaTime; float t timer / accelerateTime; // 使用二次缓动Ease Out Quad实现加速速度从0快速增加 float speedCurve t * t; _currentPosition (speedCurve * maxSpeed * Time.deltaTime); UpdateVisualPosition(); yield return null; } // 阶段3匀速阶段 (占总时长的60%) float cruiseDistance totalDistance * 0.7f; // 匀速阶段滚动大部分距离 float cruiseSpeed maxSpeed; float distanceCovered 0f; while (distanceCovered cruiseDistance) { distanceCovered cruiseSpeed * Time.deltaTime; _currentPosition cruiseSpeed * Time.deltaTime; UpdateVisualPosition(); yield return null; } // 阶段4减速阶段 (占总时长的30%) - 使用SmoothStep实现平滑减速 float decelerateDistance totalDistance - distanceCovered; float decelerateStartPos _currentPosition; timer 0f; float decelerateTime totalDuration * 0.3f; while (timer decelerateTime) { timer Time.deltaTime; float t timer / decelerateTime; // Mathf.SmoothStep(0,1,t) 会产生平滑的缓入缓出曲线这里用于计算进度 float progress Mathf.SmoothStep(0f, 1f, t); _currentPosition decelerateStartPos progress * decelerateDistance; UpdateVisualPosition(); yield return null; } // 阶段5精准停靠与微回弹增强手感 float exactPosition targetIndex * itemHeight; float overshoot itemHeight * 0.1f; // 过冲10%的高度 // 先轻微过冲 _currentPosition exactPosition overshoot; UpdateVisualPosition(); yield return new WaitForSeconds(0.05f); // 再弹回正确位置 _currentPosition exactPosition; UpdateVisualPosition(); _isRolling false; OnRollFinished?.Invoke(this); // 触发完成事件 } private void UpdateVisualPosition() { // 1. 更新content的anchoredPosition.y content.anchoredPosition new Vector2(content.anchoredPosition.x, _currentPosition); // 2. 关键实现循环滚动逻辑 // 当_currentPosition超出一定范围例如一个列表长度时将其复位同时重置所有UI元素的位置和数据。 float listHeight itemList.Count * itemHeight; if (_currentPosition listHeight) { _currentPosition - listHeight; RepositionItems(); } else if (_currentPosition 0) { _currentPosition listHeight; RepositionItems(); } } private void RepositionItems() { // 遍历content下所有UI子对象根据_currentPosition重新计算它们的世界位置和显示的数据索引。 // 这是实现“无限滚动”视觉魔术的核心。 for (int i 0; i content.childCount; i) { RectTransform child content.GetChild(i) as RectTransform; // 计算该子对象应该对应的数据索引 int dataIndex Mathf.FloorToInt((_currentPosition i * itemHeight) / itemHeight) % itemList.Count; // 防止负索引 dataIndex (dataIndex itemList.Count) % itemList.Count; child.anchoredPosition new Vector2(0, -i * itemHeight); // 更新child上显示的图标和文本为itemList[dataIndex]的数据 UpdateItemUI(child, itemList[dataIndex]); } }这段代码的要点解析分阶段控制将动画明确分为加速、匀速、减速、停靠四个阶段每个阶段有独立的时间和运动曲线控制感极强。Mathf.SmoothStep的妙用在减速阶段SmoothStep能产生非常自然的缓动效果比简单的线性插值手感好得多。循环滚动逻辑UpdateVisualPosition和RepositionItems是实现“无限滚动”视觉的核心。它通过动态调整UI对象的位置和数据显示用有限的几个对象模拟出无限滚动的假象。这是性能关键务必理解透彻。精准停靠最终位置是通过targetIndex * itemHeight精确计算出来的。之前的滚动距离和时间都是为了“演”出恰好到达这个位置的过程。回弹效果最后微小的过冲和弹回虽然只有0.1个item高度和0.05秒却能极大地增强操作的确定感和品质感成本极低收益很高。3.3 概率与表现的桥梁抽奖逻辑应该在动画开始前就执行完毕。例如在SlotMachineController中public void StartLottery() { // 1. 执行抽奖逻辑确定每个卷轴的目标索引 int[] targetIndices new int[rollerCount]; for (int i 0; i rollerCount; i) { targetIndices[i] CalculateResultIndex(i); // 根据概率权重计算 } // 2. 可以加入一点随机延迟让卷轴依次停止增加戏剧性 for (int i 0; i rollerCount; i) { float delay i * 0.2f; // 每个卷轴间隔0.2秒停止 StartCoroutine(StartRollerWithDelay(i, targetIndices[i], delay)); } } private IEnumerator StartRollerWithDelay(int rollerId, int targetIndex, float delay) { yield return new WaitForSeconds(delay); _rollers[rollerId].StartRoll(targetIndex, 2.0f); // 每个卷轴滚动总时长2秒 } private int CalculateResultIndex(int rollerId) { // 示例根据权重随机 ListSlotItem items _itemDatabase.GetItemsForRoller(rollerId); int totalWeight items.Sum(item item.weight); int randomValue Random.Range(0, totalWeight); int accumulatedWeight 0; for (int i 0; i items.Count; i) { accumulatedWeight items[i].weight; if (randomValue accumulatedWeight) { return i; } } return items.Count - 1; }这里的关键是“表现服从结果”动画时间、速度曲线都是为了“演”出到达预定结果的过程。绝不能根据动画停止时的视觉位置来反推结果那会引入不确定性。4. 性能优化与视觉增强技巧4.1 性能优化要点UI对象池如前所述content下的子物体数量应固定只创建可见数缓冲个通过RepositionItems循环利用。这是最大的性能收益点。避免每帧查找在Awake或Start中缓存content的所有子RectTransform到数组或列表中避免在UpdateVisualPosition中频繁调用GetChild。减少Canvas重建如果图标或文本在滚动中不变确保它们的材质和字体图集是静态的。频繁改变Image.sprite或Text.text可能触发Canvas的批处理破坏和重建。我们的UpdateItemUI方法应尽量高效。使用RectTransform.anchoredPosition直接操作anchoredPosition来移动UI比修改localPosition更符合UGUI的坐标系且效率无差异。4.2 视觉增强手法运动模糊在高速滚动阶段可以给content添加一个简单的水平运动模糊Shader或者叠加一个半透明的“拖影”UI图像并随着速度改变其透明度能极大增强速度感。音效设计启动音拉杆或点击按钮时。滚动音持续循环的、音调可能逐渐升高的机械滚动声。减速音滚动速度变化时音调或音量随之变化。停靠音每个卷轴停止时清脆的“咔哒”声。胜利音所有卷轴停止且中奖时播放。 音效是提升沉浸感成本最低、效果最显著的方式。粒子特效在卷轴开始滚动、停止、以及中奖时可以触发粒子特效如星光、闪光。镜头震动当所有卷轴停止并显示大奖时一个轻微的屏幕震动能有效放大玩家的喜悦感。5. 实战调试与常见问题排查在实际开发中你肯定会遇到一些预期之外的情况。下面是我踩过的一些坑和解决方案。5.1 滚动卡顿或跳帧问题描述滚动动画不流畅有卡顿感。排查步骤检查性能分析器打开Unity的Profiler观察CPU的Canvas.SendWillRenderCanvases耗时。如果耗时很高说明Canvas重建频繁。检查对象池确认是否在滚动过程中动态实例化或销毁了UI对象。检查协程确保驱动动画的协程只在Update之后运行默认的yield return null就是避免在FixedUpdate或LateUpdate中做密集的UI位置计算。解决方案确保使用对象池且UI元素属性如图片、文本变更频率最低。如果RepositionItems中的循环计算量大可以考虑每2-3帧执行一次位置重置而不是每帧都执行因为高速滚动时玩家看不清细节。考虑将content及其子物体放在一个独立的、层级较低的Canvas中减少其他UI元素变化对其的影响。5.2 停止位置不精确问题描述动画结束后最终停靠的位置有1-2个像素的偏差或者正好卡在两个图标中间。排查步骤检查itemHeight计算确保itemHeight计算准确是UI元素的实际高度包括间距。最好在运行时通过RectTransform.rect.height动态获取。检查浮点数精度在最终停靠阶段直接使用_currentPosition targetIndex * itemHeight;赋值避免使用Lerp等插值因为插值的终点可能因浮点精度有极小误差。检查动画阶段划分确保加速、匀速、减速三个阶段走过的总距离精确等于steps * itemHeight。由于浮点数运算可能存在累计误差。可以在减速阶段开始时重新计算剩余距离decelerateDistance exactPosition - _currentPosition而不是用之前的总距离减去已走距离。解决方案// 在减速阶段开始前精确计算剩余距离 float exactPosition targetIndex * itemHeight; float decelerateDistance exactPosition - _currentPosition; // 然后根据decelerateDistance和剩余时间计算减速曲线5.3 循环滚动出现断层或闪烁问题描述在RepositionItems时图标瞬间跳变出现视觉断层。排查步骤检查索引计算RepositionItems中根据_currentPosition计算dataIndex的公式是否正确。特别注意取模运算% itemList.Count对负数的处理在C#中-1 % 5结果是-1。这就是为什么代码中需要(dataIndex itemList.Count) % itemList.Count)进行修正。检查缓冲数量content下的子物体数量是否足够至少需要visibleItemCount 2。当滚动速度极快时可能需要3或4来提供足够的缓冲避免一个物体刚被重置到队尾下一帧就又需要它出现在队首。解决方案仔细推导dataIndex的计算公式并用极端情况如_position为负、很大进行测试。增加缓冲UI对象的数量。在重置物体位置时可以考虑使用CanvasGroup短暂如一帧将其Alpha设为0然后下一帧再设为1可以避免跳变瞬间被看到但需谨慎使用可能造成闪烁。5.4 多卷轴停止顺序不自然问题描述多个卷轴同时开始滚动同时停止显得很呆板。解决方案随机延迟停止如之前代码所示为每个卷轴的停止加入一个随机的小延迟例如0.1~0.5秒。差异化滚动速度每个卷轴可以有不同的maxSpeed和totalDuration让它们启动和停止的节奏略有不同。关联停止可以设计成第一个卷轴先停停稳后触发第二个卷轴开始减速以此类推营造紧张感。这需要更精细的协程间通信如事件、回调。实现一个高品质的老虎机滚动效果是一个融合了数学曲线计算、编程状态管理、协程和视觉设计动画、特效的综合性任务。它没有用到特别高深的Unity API但对基础知识的理解和运用要求很高。自己动手实现一遍你会对“如何用代码创造手感”有更深的理解。当看到自己实现的效果流畅地滚动、精准地停下并伴随着恰到好处的音效和特效时那种成就感是直接用插件无法比拟的。最重要的是这套自定义动画框架可以轻松移植到任何需要类似“滚动选择”、“动态列表”效果的项目中成为你工具箱里的一件利器。
返回列表