四个数学公式做一个丝滑到不行的大转盘 - 更新了canvas代码

1,768 阅读6分钟

前言

游戏世界里面充斥着大量的数学与物理知识,就连一个普通的大转盘亦是如此。下文解释的是转盘在运动过程中,时间,转动距离的换算关系,这和选择了具体什么框架没有任何关系,你可以使用dom,或者canvas,或游戏引擎实现角度到视觉的表达呈现。

可以在这个代码里面查看到交互效果,代码示例

大转盘的交互流程

首先我们设想一个大转盘的奖的动作,它具有如下的流程

  1. 当用户开始抽奖时,转盘必须立刻开始转动起来,并且是匀加速启动,最后匀速
  2. 此刻并不知道抽奖结果,在业务压力的高峰期,抽奖可能会比较耗时,需要等待一会儿才返回
  3. 因此我们在等待转盘结果的同时,转盘应该保持持续的运动
  4. 在获得到抽奖结果时,转盘最终应该缓慢的停在抽奖结果的位置上面

如上面的描述,转盘的速度曲线在整个过程中应该如下所示

20210805124625

  1. 在阶段一 (t0 - tt1 阶段),转盘做匀加速运动,此时转过的距离应该为 S1

  2. 加速到一定阶段后,装盘会沿着当前速度持续转动,在这个阶段,这个转盘速度不变,即 t1 - t2 阶段,此时的位移是 S2

  3. 在 t2 时刻,收到停止信号后,这个时候转盘会缓慢停下来,做匀减速运动,此时的位移内容是 S

    vt曲线图,面积即是位移内容,由于这里的曲线都是直线,因此很好的计算面积大小

我们不妨假定,转盘的最大速度是 0.48deg/ms , 转盘的加速阶段的时间为 2000ms, 停止信号(即t2)发生时间是不确定的,最终的位移总和目前也是不确定的

WheelSprite 类编写

根据上面的描述,我们设计的一个这样的 WheelSprite, 他拥有

class WheelSprite{
    // 启动旋转
    async function start(): void;

    // 另转盘停到指定的角度上面
    async function stop(target: number): Promise<void>;

    // 当前转动的位置
    public dist: number;

    // 转盘的三个阶段,分别是暂停状态,加速状态,还有减速状态,加速状态包括匀加速阶段和匀速阶段
    public status: 'paused' | 'acceleration' | 'decelerate';

    // 当前的角速度
    public v: number;

    // 各个时间
    public t0: number;
    public t1: number;
    public t2: number;
    public t3: number;
}

补充一些常亮=量的定义

const MAXIMUM_SPEED = 0.48; // 每一毫秒的速度
const ACCELERATION_TIME = 2000; // 加速的时间
const ACCELERATION_PER_MS = MAXIMUM_SPEED / ACCELERATION_TIME; // 每毫秒的加速度

编写方法 function run(): void; 启动大转盘

20210805124625

启动方法应该包含 t0 到 t2 阶段的所有工作,先做匀加速运动,在做匀速运动,我们记开始执行代码的时候为 t0, 那么在 t0 到 t1 阶段,这是一个三角形,则

20210805130310

vn / 0.48 = △t / 2000

即 vn = △t / 2000 * / 0.48

S = vn * △t / 2

当 △t > t1(2000ms), 后,则应该再加上匀速的距离,则

S1 = 0.48 * 2000 / 2

S = S1 + (△t - 2000) * 0.48

async function run(){

    this.status = "acceleration";
    this.t0 = new Date();

    // 除非收到 decelerate 的状态,否则这个将会一直旋转
    while (this.status != "decelerate") {
        
        await new Promise((resolve) => requestAnimationFrame(resolve));

        // 执行的时间间隔,用来计算当前的位置
        const dif_t = new Date() - this.t0;
        
        // 如果过了加速的时间段,则位移应该是加速的位移加上匀速的位移
        if (dif_t > ACCELERATION_TIME) {
            this.v = MAXIMUM_SPEED;
            let s1 = MAXIMUM_SPEED * ACCELERATION_TIME / 2;
            this.dist = s1 + MAXIMUM_SPEED * (dif_t - ACCELERATION_TIME); 
        } else {
            this.v = dif_t / ACCELERATION_TIME * MAXIMUM_SPEED; 
            this.dist = this.v * dif_t / 2; 
        }
    }
}

核心方法 - async function stop(stopAngle: number): Promise<void>; 将大转盘停到制定的位置

接下来我们编写 stop 方法,为了防止过快返回让整个动画过程不完善(比如从开始转动到收到结果,可能只需要500ms,此时还处于加速阶段),我们通过增加延时,来保证 stop 的执行至少要在 t0 3秒之后

t = new Date() - this.t0 表示当前的过去的时间, 当 3000 - t > 0 时,说明至少还要等待 3000 - t 毫秒才能继续逻辑,反之则直接继续

async function stop(target){

    if(this.status != 'acceleration'){
        throw new Error('转盘状态错误');
    }

    // 保证从t0到当前时刻至少执行了3000
    // 如果 new Date() - this.t0 > 3000 则说明从启动到当前已经过去3000ms
    // settimeout 在处理负数的数值时会立刻放行
    await new Promise(resolve=> setTimeout(resolve, 3000 - (new Date() - this.t0)))

    // 记录这个时间
    this.t2 = new Date();

    while(1){
        await new Promise(resolve=> setTimeout(requestAnimationFrame));
        // 此处应该处理匀减速流程
        // todo
    }
}

写到这里,基本的雏形写完了,我们接下来处理最核心的匀减速问题, 在匀减速场景,我们知道如下内容

  1. 当前的速度 v
  2. 当前的转动过得距离 dist
  3. 以及外部传进来的希望停下来的角度 target
  4. 开始进行匀减速的时间 t3

20210805133257

如图所示,target 是绿色的三角形部分 S3 , 这部分的面积即是即将产生的额外距离,那么这部分的面积大小,则应该满足

target + 360 * n = S1 + S2 + S3

此时,S1, S2 是确定的, 记为Sn可以是任意的正整数, 由于一圈的赌术是360度,我们对 S 360取余并不会影响视觉角度,我们假定 n = 2, 则

target + 360 * 2 - S % 360 = S3

在位移公示,则 t3 - t2 = △t 为,

target + 360 * 2 - S % 360 = MAXIMUM_SPEED / 2 * △t

任意时刻,其实这是一个梯形,则 tn 时刻的位移为

// t2Tot3 表示 t2 到 t3所需要的时间
// t 表示当前时刻
vn = MAXIMUM_SPEED - MAXIMUM_SPEED / t2Tot3 * (t - t2)

s = S + (vn + MAXIMUM_SPEED) * (t - t2) / 2 // 梯形公式

根据如上结果,目前未知的变量只有两个,一个是 n 圈数(决定停下的距离), 一个是 △t 剩余转动的时间,由于 n 有整数限制,我们不妨假定 n 为一个固定值,则我们 stop 代码新增


this.t2 = new Date();
this.status = "decelerate";

// 标记匀减速前所有的路径总和
let S = this.dist;

// target + 360 * 2 - S % 360 = MAXIMUM_SPEED / 2 * △t
let t2Tot3 = (target + 360 * 2 - S % 360) / ( MAXIMUM_SPEED / 2);
this.t3 = this.t2 + t2Tot3;

while(1){
    await new Promise((resolve) => requestAnimationFrame(resolve));
    const dif_t = new Date() - this.t2;

    // 考虑到当时间超出t3后,vn会变成负数,此处做一下限制
    let vn = Math.max(0, MAXIMUM_SPEED - MAXIMUM_SPEED / t2Tot3 * dif_t);
    this.dist = S + (vn + MAXIMUM_SPEED) * dif_t / 2; //梯形公式
    
    // 当到达终点的时候, vn = 0
    if (vn == 0) {
        this.dist = target;
        break;
    }
}

// 转盘结束

那么为什么我们要做设计 n = 2 呢,下面这张图可能会有答案

20210805135119

  1. 对于转盘来说,多一个或者多个整数倍的360并不会影响视觉的结果
  2. 由于距离等同于坐标图的面积,当n越大,剩余转动的距离越远,则所需要的面积越大,则减速曲线就会越平滑,减速时间就会越长,反之亦然
  3. n->无穷 的时候,减速就会越不明显,我们令 n = 2,只是一种特例解,用来反向推出减速曲线,如果 n = 2 时,转盘减速痕迹太过明显,则我们应该加大 2,保证更充足的减速时间,如果减速痕迹过于缓慢,则我们可能要考虑一下减少n

除了 n 会影响减速的效果, 其实最大速度也会影响,这些参数都是可以调的,找到合适的即可

代码示例: codesandbox.io/s/youthful-…