没想到干前端2年了还能用上高中物理运动学知识

背景

最近做一个活动需求,其中有一块是类似老虎机样式的积分抽奖的功能,抽奖的结果由后端返回,产品要求动画流畅,先慢慢变快后逐渐变慢直至目标奖项。

难点

拿到这个需求第一反应就是界面不是问题,难点是抽奖动画的逻辑。

  1. 需要实现三阶段动画效果:加速→匀速→减速
  2. 减速后需要准确不突兀的停在目标上
  3. 高频动画的性能的优化
  4. 通用性封装,适用不同奖品个数,指定速率变化等

寻求解法

最开始我想的是其他项目组记得是有这个功能的,于是找到负责人要到了项目仓库权限,打开一看有点蒙。7年前的类组件写法+amis-core低代码库等各种辅助工具,且逻辑是根据写死角度deg来判定,有点过于复杂,并不是通用的。

后面开始在稀土掘金找有没有大佬实现了分享出来的,发现并没有使用ReactHook实现的,且其他大部分都是一个非常简单的匀速转动+中奖概率的Demo,不符合我的需求。

于是我就想自己封装一个高级通用可调配参数的hook。

过程

首先向DeepSeek提问:

使用react帮我实现一个九宫格抽奖逻辑,要求如下

  1. 九宫格中心是开始按钮,8个奖品分别为1-8
  2. 抽奖动画先加速再匀速再减速,直到抽中后端返回的特定的结果
  3. 使用requestAnimationFrame优化动画
  4. 逻辑清晰,结构简单,代码备注

虽然DeepSeek给出的代码运行不起来,但起码给了我最初的思路,其中核心是通过设定指针停留在奖项上的停留时间长短的变化随时间的改变而改变(所谓的加速度,每毫秒减少x停留时间),并且记录了所转的圈数,然后使用时差固定每16ms更新一次,等速度降到最低时找到目标值再停下。

js 复制代码
 // 关键参数
 currentIndex: 当前高亮的奖品序号
 currentSpeed: 150 // 当前速度,值越小,速度越大
 minSpeed: 50, // 最快速度,每个奖品停留的最长时间
 maxSpeed: 150, // 最慢速度,每个奖品停留的最短时间
 acceleration: 0.5, // 加速度(每帧速度增加的量)
 deceleration: 0.3, // 减速度(每帧速度减少的量)
 minRounds: 3, // 最小旋转圈数
 targetIndex: 0, // 目标位置

看起来这种方式确实可以实现,但是自己实现时发现有些地方的逻辑还是比较混乱和复杂的!

首先就是指针变化的时机不好掌控,因为一个奖品停留的时间是随着时间的变化而变化的,我们需要具体算出一个奖品的停留时间就需要依靠微积分的知识了

其次比如既然我有了最大速度,是不是可以不再依赖固定的时间,而是根据实际旋转圈数和速度变化来控制动画,因为设备刷新率的不同

转换成物理模型

在我打着草稿时我突然觉得,这个场景怎么跟高中某个物理题似曾相识呢 😂

于是我大腿一拍!我可以将它转换成环形跑道的问题呀!利用高中物理的加速度公式和位移公式,把跑道等分编号,给出时间求出总路程求余得出对应编号,可行,开干!!👏👏👏

✨✨✨✨✨✨✨✨
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> V = V 0 + a t V = V₀ + at </math>V=V0+at
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> S = V 0 t + ( 1 / 2 ) a t 2 S = V₀t + (1/2)at² </math>S=V0t+(1/2)at2

✨✨✨✨✨✨✨✨

最终,我将抽奖动画问题抽象为以下物理模型:

  • 一段环形公路长n米(n为整数且n>=1),平均分成n段标号1~n,每段长度为1m
  • 物体从速度0开始绕环运动,先经过t1秒的加速运动到最大速度
  • 再经过t2秒的匀速运动,匀速时每段公路经过的时间为s1
  • 再经过t3秒的匀减速运动至一定速度后持续做匀速运动,匀速时每段公路经过的时间为s2
  • 给出总的运动时间t,求物体在哪一段路上的公式F(t)

其中s1,s2为最长/最短停留奖项的时间,这样我就可以随着时间推移实时算出当前高亮的奖品序号。

通过AI得出表达式:

高级通用抽奖动画模型的Hook封装

这里奉上我最终封装的代码(全网唯一),欢迎前端同学指出代码中的问题以及提出更好的方案哦~

js 复制代码
import {useEffect, useRef, useState} from 'react'

// 时间单位均为秒
interface LotteryAnimationProps {
  n?: number // 奖品数量
  t1?: number // 加速时间
  t2?: number // 最大匀速时间
  t3?: number // 减速时间
  s1?: number // 最快时的停留时间
  s2?: number // 最慢时的停留时间
}

// 默认参数配置
const defaultConfig = {
  n: 8,
  t1: 1.5,
  t2: 1.5,
  t3: 2,
  s1: 0.04,
  s2: 0.2,
}

export const useLotteryAnimation = ({
  n,
  t1,
  t2,
  t3,
  s1,
  s2,
}: LotteryAnimationProps = defaultConfig) => {
  // 状态管理
  const [currentIndex, setCurrentIndex] = useState<number>(null) // 当前标号转到处于 1~n
  const [isRunning, setIsRunning] = useState<boolean>(false) // 是否正在抽奖

  // 动画相关引用
  const animationRef = useRef<number>(null) // 动画帧
  const startTimeRef = useRef<number>(0) // 抽奖动画开始时间
  const resolveRef = useRef<(value: unknown) => void>(null) // 抽奖动画开始时间
  const resultRef = useRef<number>(null) // 抽奖结果 1~n

  // 开始抽奖
  function run(target: number) {
    return new Promise((resolve) => {
      if (isRunning) throw new Error('正在抽奖')
      if (target < 1) throw new Error('中奖目标参数错误')

      setIsRunning(true)
      resultRef.current = target
      resolveRef.current = resolve

      // 初始化时间戳
      startTimeRef.current = performance.now()

      // 开始动画
      animate()
    })
  }

  // 动画函数(使用状态机管理动画阶段)
  function animate() {
    const nowTime = performance.now()
    // 耗时
    const t = (nowTime - startTimeRef.current) / 1000

    if (t < 0) return

    // 公式计算
    if (t >= 0 && t <= t1) {
      // 加速阶段
      setCurrentIndex(Math.floor((t ** 2 / (2 * s1 * t1)) % n) + 1)
    } else if (t1 < t && t <= t1 + t2) {
      // 最大匀速阶段
      setCurrentIndex((Math.floor((t - 0.5 * t1) / s1) % n) + 1)
    } else if (t1 + t2 < t && t <= t1 + t2 + t3) {
      // 减速阶段
      const formula1 = (t - 0.5 * t1) / s1
      const formula2 = ((1 / s2 - 1 / s1) * (t - t1 - t2) ** 2) / (2 * t3)
      setCurrentIndex(Math.floor((formula1 + formula2) % n) + 1)
    } else {
      // 低速寻址阶段
      const formula1 = (0.5 * t1 + t2 + 0.5 * t3) / s1
      const formula2 = (0.5 * t3) / s2
      const formula3 = (t - t1 - t2 - t3) / s2
      const cur = Math.floor(((formula1 + formula2 + formula3) % n) + 1)
      setCurrentIndex(cur)
      // 到达目标终点
      if (cur === resultRef.current) {
        cancelAnimationFrame(animationRef.current)
        setIsRunning(false)
        resolveRef.current(cur)
        return
      }
    }

    // 继续动画
    animationRef.current = requestAnimationFrame(animate)
  }

  // 清理动画帧
  useEffect(() => {
    return () => {
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current)
      }
    }
  }, [])

  return {
    currentIndex,
    isRunning,
    run,
  }
}
相关推荐
又又呢35 分钟前
前端面试题总结——webpack篇
前端·webpack·node.js
dog shit1 小时前
web第十次课后作业--Mybatis的增删改查
android·前端·mybatis
我有一只臭臭1 小时前
el-tabs 切换时数据不更新的问题
前端·vue.js
七灵微1 小时前
【前端】工具链一本通
前端
Nueuis2 小时前
微信小程序前端面经
前端·微信小程序·小程序
_r0bin_5 小时前
前端面试准备-7
开发语言·前端·javascript·fetch·跨域·class
IT瘾君5 小时前
JavaWeb:前端工程化-Vue
前端·javascript·vue.js
potender5 小时前
前端框架Vue
前端·vue.js·前端框架
站在风口的猪11086 小时前
《前端面试题:CSS预处理器(Sass、Less等)》
前端·css·html·less·css3·sass·html5
程序员的世界你不懂6 小时前
(9)-Fiddler抓包-Fiddler如何设置捕获Https会话
前端·https·fiddler