Unity+FGUI列表制作滚筒抽奖动画

Unity+FGUI列表制作滚筒抽奖动画


概述

本文将深入分析一个基于Unity和FairyGUI实现的滚筒抽奖效果脚本,该脚本可适用于游戏中的词条洗炼、抽奖等系统,通过精美的视觉效果和流畅的动画体验,为玩家提供类似抽奖机的抽奖体验。

列表滚动缩放方法参考了FGUI-Unity官方案例“LoopList”

在FGUI中

首先我使用FGUI制作一个简易列表加一个按钮,如下即可

image-20251113175255706

注意要禁用自动调整项目大小和惯性

image-20251113175046367

image-20251113175102076

导出到Unity,然后开始写代码

在Unity中

一、实现列表动态缩放

想要实现滚筒效果,需要将位于列表中间的项放大,距离中心越远的项越小,营造一种透视效果,这部分在官方案例中已有实现,我们直接看代码

  1. 首先我们在Strart中进行初始化,进行必要的设置和数据获取:
GComponent _mainView;
GList _list;
void Start()
{
    //将帧率设置为60帧
    Application.targetFrameRate = 60;

    //获取UI组件
    _mainView = this.GetComponent<UIPanel>().ui;

    //获取列表,并将列表设置为虚拟循环列表
    _list = _mainView.GetChild("list").asList;
    _list.SetVirtualAndLoop();

    //为列表设置回调函数,初始化列表
    _list.itemRenderer = RenderListItem;
    _list.numItems = 5;
    //添加滚动事件触发函数
    _list.scrollPane.onScroll.Add(DoSpecialEffect);

    //初始化进行一次缩放
    DoSpecialEffect();
}
  1. 核心,实现动态缩放

思路为:计算列表各个子项距离列表中心点的距离,距离越远越小

//实现动态缩放
void DoSpecialEffect()
{
    // 计算列表视图的中心Y坐标
    float midY = _list.scrollPane.posY + _list.viewHeight / 2;

    int childCount = _list.numChildren;
    for (int i = 0; i < childCount; i++)
    {
        GObject obj = _list.GetChildAt(i);
        if (obj != null)
        {
            // 计算子项中心到列表中心的垂直距离
            float dist = Mathf.Abs(midY - obj.y - obj.height / 2);

            // 距离范围:使用一个合适的范围来计算缩放效果
            // 当距离大于等于1.5个子项高度时,缩放到最小值
            float maxDistance = obj.height * 1.5f;
            float clampedDistance = Mathf.Min(dist, maxDistance);

            // 距离中心越远,缩放比例越小
            //可以通过调节0.25f这个系数的大小调节缩放
            float scale = 1.0f - (clampedDistance / maxDistance) * 0.25f;

            //应用缩放
            obj.SetScale(scale, scale);
        }
    }
}
  1. 至此,核心逻辑已完成,你可以使用RenderListItem方法对列表内容进行初始化。参考:列表 - 编辑器教程 | FairyGUI
    将这个脚本挂载在UIPanel上,载入在FGUI中制作的列表,运行。应该可以看到效果了。

image-20251113194344435

现在你可以拖动列表,可以发现位于中间的子项永远是最大的。

如果你想完成一个类似于拨轮的控件,那么到这里就基本完成了

[!TIP]

如果你想让中间的子项能够自动吸附到列表中间,可以在FGUI中打开“滚动位置自动贴近元件”

二、实现列表自动滚动

与我们显示中的抽奖转盘不同,游戏中的抽奖逻辑,一般是点击抽奖后立刻进行计算,得出计算结果,然后播放抽奖动画,最后控制奖池停止在已经计算完成的抽检结果上。那么这一板块,我们实现点击按钮使列表快速滚动,速度逐渐变慢停止,最后位于中间的子项恰好是我们指定的项。

首先我们先实现根据索引计算目标项位置

  1. 我想要支持在索引超出列表大小时也可以滚动到正确的位置,所以先对传入的索引进行计算,得出真实索引的位置

  2. 利用真实索引计算目标项在转一圈的情况下的绝对位置

  3. 计算居中偏移,使最后目标项能够停在视口中间

    要令视口中心=目标词条中心:

    scrollPane.posY+viewHeight/2=targetItemPosition+itemHeight/2

    解得:

    scrollPane.posY=targetItemPosition-(viewHeight-itemHeight)/2

    即为以下代码19~23行。

  4. 计算滚动距离

  5. 因为只能向前滚动,所以我们要分两种情况计算(目标在当前位置之前/目标在当前位置之后)

/// <summary>
/// 计算虚拟循环列表的目标位置(支持多圈滚动,始终向前)
/// </summary>
/// <param name="targetIndex">目标索引(可超过 numItems)</param>
private (float targetPos, int actualIndex) CalculateCyclicTargetPosition(int targetIndex)
{
    int numItems = _list.numItems;
    float itemHeight = GetItemHeight();
    float viewHeight = _list.viewHeight;
    float contentHeight = numItems * itemHeight;

    // 获取当前滚动位置(标准化后的位置,范围在0到contentHeight之间)
    float currentPos = _list.scrollPane.posY;

    // 直接使用 targetIndex 计算绝对位置
    // 例如:targetIndex=20时,计算的是第20项的位置,而不是第0项
    float targetItemPosition = targetIndex * itemHeight;

    // 计算居中偏移:让目标项显示在列表视图的中心位置
    float centerOffset = (viewHeight - itemHeight) / 2f;
    float targetPositionAbsolute = targetItemPosition - centerOffset;

    // 计算实际显示的项索引(仅用于返回值)
    // 例如:targetIndex=20, numItems=5 → actualItemIndex=0(显示Item 0)
    // targetIndex=21, numItems=5 → actualItemIndex=1(显示Item 1)
    int actualItemIndex = targetIndex % numItems;
    if (actualItemIndex < 0) actualItemIndex += numItems;

    // 确保至少向前滚动(如果计算出的目标位置在当前位置之前,则加上额外圈数)
    float finalTargetPos = targetPositionAbsolute;
    if (finalTargetPos <= currentPos)
    {
        // 如果目标位置在当前位置之前,加上足够的圈数,确保向前滚动
        int additionalLoops = (int)Mathf.Ceil((currentPos - finalTargetPos) / contentHeight) + 1;
        finalTargetPos += additionalLoops * contentHeight;
    }

    return (finalTargetPos, actualItemIndex);
}

实现列表滚动

这里使用的方案是设置滚动时间固定,滚动速度自适应

  1. 使用刚刚实现的CalculateCyclicTargetPosition函数计算目标位置和实际索引
  2. 设置和记录必要参数
  3. 使用异步每帧循环执行滚动逻辑(使用OutQuart缓动函数)
  4. 待滚动结束后,将目标项标准化到视口中心,保证最终位置精确
  5. 最后执行一次缩放
/// <summary>
/// 抽奖式滚动到目标位置
/// </summary>
/// <param name="targetIndex">目标词条索引(可超过列表项数量,会自动计算循环圈数)		</param>
private async UniTask LotteryScrollToTargetAsync(int targetIndex)
{
    (float targetPos, int actualIndex) = CalculateCyclicTargetPosition(targetIndex);

    // 记录起始位置
    float startPos = m_list_entry.scrollPane.posY;
    float totalDistance = targetPos - startPos;

    //这里设置滚动事件固定为10秒
    const float fixedDuration = 10f;
    //记录开始时间
    float startTime = Time.time;

    //开始执行滚动
    while (Time.time - startTime < fixedDuration)
    {
        //计算已经过去的时间
        float elapsedTime = Time.time - startTime;
        //时间归一化
        float normalizedTime = Mathf.Clamp01(elapsedTime / fixedDuration);
        //使用缓动函数计算速度
        float easedProgress = CalculateEaseOutQuart(normalizedTime);
        //计算当前滚动位置
        float newPos = startPos + totalDistance * easedProgress;
        //应用位置,应用缩放
        m_list_entry.scrollPane.SetPosY(newPos, true);
        DoScrollScaleEffect();
        //这里是引入了UniTask,异步每帧执行
        await UniTask.Yield(PlayerLoopTiming.Update);
    }

    // 确保最终位置精确
    m_list_entry.scrollPane.SetPosY(targetPos, true);
    DoScrollScaleEffect();

    // 等待稳定
    await UniTask.Delay(100);

    // 标准化位置
    float contentHeight = m_list_entry.numItems * GetItemHeight();
    float normalizedPos = targetPos % contentHeight;
    if (normalizedPos < 0)
    {
        normalizedPos += contentHeight;
    }

    // 标准化时使用 false 立即跳转
    m_list_entry.scrollPane.SetPosY(normalizedPos, false);
    //等待一帧,确保虚拟列表重新渲染子项后再执行缩放效果
    await UniTask.Yield(PlayerLoopTiming.Update);
    DoScrollScaleEffect();
}

/// <summary>
/// OutQuart 缓动函数(更平滑的减速)
/// </summary>
/// <param name="t">归一化时间 [0, 1]</param>
/// <returns>缓动后的进度值 [0, 1]</returns>
private float CalculateEaseOutQuart(float t)
{
    if (t >= 1f) return 1f;

    // OutQuart缓动公式:1 - (1 - t)^4
    float p = 1f - t;
    return 1f - p * p * p * p;
}

[!CAUTION]

实现列表自动滚动要取消注册滚动事件,将_list.scrollPane.onScroll.Add(DoSpecialEffect);在Start中注释掉,我们将实现滚动动态缩放改为在每帧滚动结束后手动触发。

如果依赖滚动事件在高频更新下会可能造成事件队列延迟与帧同步冲突,结果就是缩放计算会滞后,导致视觉上不连贯或是其他问题

完整代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using FairyGUI;
using System.Threading.Tasks;
using System.Linq;

public class Test : MonoBehaviour
{
    GComponent _mainView;
    GList _list;
    GButton _btn;
    private bool _isLotteryScrolling = false;
    void Start()
    {
        Application.targetFrameRate = 60;

        _mainView = this.GetComponent<UIPanel>().ui;

        _list = _mainView.GetChild("list").asList;
        _btn = _mainView.GetChild("btn").asButton;

        _btn.onClick.Set(OnBtn);
        _list.SetVirtualAndLoop();

        _list.itemRenderer = RenderListItem;
        _list.numItems = 5;
        _list.touchable = false;

        DoSpecialEffect();
    }

    void DoSpecialEffect()
    {
        // 计算列表视图的中心Y坐标
        float midY = _list.scrollPane.posY + _list.viewHeight / 2;

        int childCount = _list.numChildren;

        for (int i = 0; i < childCount; i++)
        {
            GObject obj = _list.GetChildAt(i);
            if (obj != null)
            {
                // 计算子项中心到列表中心的垂直距离
                float dist = Mathf.Abs(midY - obj.y - obj.height / 2);

                // 距离范围:使用一个合适的范围来计算缩放效果
                // 当距离大于等于1.5个子项高度时,缩放到最小值
                float maxDistance = obj.height * 1.5f;
                float clampedDistance = Mathf.Min(dist, maxDistance);

                // 距离中心越远,缩放比例越小
                float scale = 1.0f - (clampedDistance / maxDistance) * 0.25f;

                obj.SetScale(scale, scale);
            }
        }
    }

    void RenderListItem(int index, GObject obj)
    {
        GLabel item = (GLabel)obj;
        item.SetPivot(0.5f, 0.5f);

        item.title = "Item " + index;
    }

    /// <summary>
    /// 抽奖式滚动到目标位置
    /// </summary>
    /// <param name="targetIndex">目标词条索引(可超过列表项数量,会自动计算循环圈数)</param>
    private async Task LotteryScrollToTargetAsync(int targetIndex)
    {
        (float targetPos, int actualIndex) = CalculateCyclicTargetPosition(targetIndex);

        // 记录起始位置
        float startPos = _list.scrollPane.posY;
        float totalDistance = targetPos - startPos;

        const float fixedDuration = 10f;
        float startTime = Time.time;


        while (Time.time - startTime < fixedDuration)
        {
            float elapsedTime = Time.time - startTime;
            float normalizedTime = Mathf.Clamp01(elapsedTime / fixedDuration);
            float easedProgress = CalculateEaseOutQuart(normalizedTime);

            float newPos = startPos + totalDistance * easedProgress;

            _list.scrollPane.SetPosY(newPos, true);
            DoSpecialEffect();

            await Task.Yield();
        }

        // 确保最终位置精确
        _list.scrollPane.SetPosY(targetPos, true);
        DoSpecialEffect();

        // 等待稳定
        await Task.Delay(100);

        // 标准化位置
        float contentHeight = _list.numItems * GetItemHeight();
        float normalizedPos = targetPos % contentHeight;
        if (normalizedPos < 0)
        {
            normalizedPos += contentHeight;
        }

        // 标准化时使用 false 立即跳转
        _list.scrollPane.SetPosY(normalizedPos, false);
        //等待一帧,确保虚拟列表重新渲染子项后再执行缩放效果
        await Task.Yield();
        DoSpecialEffect();
    }

    /// <summary>
    /// OutQuart 缓动函数(更平滑的减速)
    /// </summary>
    /// <param name="t">归一化时间 [0, 1]</param>
    /// <returns>缓动后的进度值 [0, 1]</returns>
    private float CalculateEaseOutQuart(float t)
    {
        if (t >= 1f) return 1f;

        // OutQuart缓动公式:1 - (1 - t)^4
        float p = 1f - t;
        return 1f - p * p * p * p;
    }

    /// <summary>
    /// 计算虚拟循环列表的目标位置(支持多圈滚动,始终向前)
    /// </summary>
    /// <param name="targetIndex">目标索引(可超过 numItems)</param>
    private (float targetPos, int actualIndex) CalculateCyclicTargetPosition(int targetIndex)
    {
        int numItems = _list.numItems;
        float itemHeight = GetItemHeight();
        float viewHeight = _list.viewHeight;
        float contentHeight = numItems * itemHeight;

        // 获取当前滚动位置(标准化后的位置,范围在0到contentHeight之间)
        float currentPos = _list.scrollPane.posY;

        // 直接使用 targetIndex 计算绝对位置(不取模)
        // 例如:targetIndex=20时,计算的是第20项的位置,而不是第0项
        float targetItemPosition = targetIndex * itemHeight;

        // 计算居中偏移:让目标项显示在列表视图的中心位置
        float centerOffset = (viewHeight - itemHeight) / 2f;
        float targetPositionAbsolute = targetItemPosition - centerOffset;

        // 计算实际显示的项索引(仅用于返回值)
        // 例如:targetIndex=20, numItems=5 → actualItemIndex=0(显示Item 0)
        // targetIndex=21, numItems=5 → actualItemIndex=1(显示Item 1)
        int actualItemIndex = targetIndex % numItems;
        if (actualItemIndex < 0) actualItemIndex += numItems;

        // 确保至少向前滚动(如果计算出的目标位置在当前位置之前,则加上额外圈数)
        float finalTargetPos = targetPositionAbsolute;
        if (finalTargetPos <= currentPos)
        {
            // 如果目标位置在当前位置之前,加上足够的圈数,确保向前滚动
            int additionalLoops = (int)Mathf.Ceil((currentPos - finalTargetPos) / contentHeight) + 1;
            finalTargetPos += additionalLoops * contentHeight;
        }

        return (finalTargetPos, actualItemIndex);
    }

    /// <summary>
    /// 获取列表项高度
    /// </summary>
    /// <returns>列表项高度(像素)</returns>
    private float GetItemHeight()
    {
        if (_list.numChildren > 0)
        {
            GObject firstItem = _list.GetChildAt(0);
            return firstItem.height;
        }

        // 如果没有子项,使用估算值
        return 100f; // 默认高度
    }

    async void OnBtn()
    {
        // 如果正在滚动中,不重复触发
        if (_isLotteryScrolling)
            return;

        // 开始洗炼流程
        await RefineProcessAsync();
    }

    private async Task RefineProcessAsync()
    {
        _isLotteryScrolling = true;

        // 修改:随机最终停止的项
        int finalTargetItem = Random.Range(0, _list.numItems);

        // 再加上固定的圈数,确保滚动效果
        int minLoops = 4;
        int resultId = finalTargetItem + (minLoops * _list.numItems);

        Debug.Log($"Refine Result ID: {resultId}, Final Item: {finalTargetItem}");

        // 开始抽奖式滚动
        await LotteryScrollToTargetAsync(resultId);

        _isLotteryScrolling = false;
    }
}

运行后点击按钮列表开始滚动,控制台输出了目标子项索引,最终列表停止,目标子项位于列表视口中央

结尾

最终我们完整实现了一个基于 Unity + FairyGUI 的滚筒抽奖动画效果。从 FGUI 列表的基础配置,到动态缩放的核心算法,再到支持多圈滚动的抽奖逻辑,每一步都进行了详细分析与代码实现。你可以在此基础上进一步扩展,如加入音效、粒子特效、多列表联动等,打造更丰富的抽奖体验。希望本文能为你开发类似系统提供参考,如有疑问或建议,欢迎交流讨论。

posted @ 2025-11-14 12:05  CloverJoyi  阅读(41)  评论(0)    收藏  举报