老胡机的实现方式探讨
背景
最近做的需求里面有个老胡机的效果,反复调整了一两天才做得差不多,在这里记一下过程备忘,以后遇到相似的需求可以不用那么麻烦。
这个抽奖的逻辑,实际上后端只需要直接随机得到结果和奖励,一包把数据直接发给客户端,前端做个假的老胡机转动的效果。
参考现实中的老胡机效果,它会在一开始转得非常快,快到根本看不清图案,然后有的靠摩擦力慢慢停下来,有的是有一个急刹一样的效果。
设计
整个老胡机作为一个SlotMachine类;
老胡机的每条轨道作为一个Slot类, SlotMachine包含N个Slot对象;
每个Slot对象包含一个SlotItem的列表,这个列表就是轨道上的图案列表;
SlotItem基本上没啥逻辑, 所以这个类没有必要抽象出来,它指代Slot轨道上的单个图案;
由于数据层只能给出SlotItem图案对应的逻辑ID(如果直接用物品图标作为Slot上的图案,则这里可能会直接用ItemID; 这个ID也是后端通知老胡机抽奖图案结果用的), 因此需要做一个逻辑ID与图案的对应表。
这里要求每个SlotItem的图案高度是相等的,并把这个高度记为1;后续做旋转等功能时都用这个标准单位来计算。
SlotMachine的关键接口有:
StartRoll() //老胡机开始旋转
SetRollResult(List<ID>) //设置老胡机结果
Slot的关键接口有:
SetIDList(List<ID>) //设置逻辑ID的列表,它要根据这个列表刷新自己的图案显示
RollInFullSpeed() //以最高速度开始旋转
StopRoll(StopedID) //模拟老胡机减速停止效果,并保证停止在指定的ID处
Slot的内部变量和函数有:
常量FULL_SPEED //全速旋转时的速度
CurrentPosition //以单位长度描述的当前位置
Length //实际上就是它的逻辑ID列表的长度
Speed //每秒旋转速度,同样用单位长度作为单位
Update(deltaTime) //每帧执行的函数 它根据Speed调整CurrentPosition的值;如果在减速到停止的阶段还需要调整Speed本身。
RefreshByPosition() //根据CurrentPosition调整图案元素的位置。每次CurrentPosition变化时自动调用
难点
其他都是常规的逻辑,这里面唯一的难点就是:如何设计减速效果,保证效果看起来比较真实,同时还要使停止转动时刚好指向给定的结果ID。
一开始我自己鼓捣出的表现需求是:
所有Slot全速旋转n秒;
依次间隔m秒,对每个Slot调用StopRoll函数;
StopRoll函数计算出给定的ID对应的Position值,在给定的时长后停止在给定的Position上。
Update函数调用ModifySpeed函数,此函数逐渐降低Speed的值。
由于收到StopRoll的时候Slot正处在全速转动的状态,此时Slot.CurrentPosition是随机的,因此停止转动的整个过程需要移动多长距离也是随机的,具体的移动距离在减速开始时才能确定,换句话说,一次完整的减速过程中移动距离是确定的。
整个减速过程的时长确定,运动的距离确定,那就只能是减速函数不确定了。
如果将每帧时长deltaTime视为一个趋近于0的极小值,做一条Speed/Time的曲线,显然这个曲线在Time=0时Speed=FULL_SPEED, 在Time为减速时长时为0, 曲线与纵横轴包围的区域面积就是运动的距离。
看起来,根据所需的运行距离来动态调整Speed减速曲线的斜率,也是可以实现需求的。遗憾的是这有两个问题:
首先,deltaTime并不是一个无限趋近于0的值,甚至也不是一个定值,帧数浮动是很正常的,导致每帧时长也跟着浮动变化,甚至都不能估计它的取值范围;
其次,虽然不想承认,要设计这样一个很棒的曲线函数,我稀碎的高数知识是不允许的……
既然如此,那么就退而求其次,由于停止时的位置是硬性要求,就只能放弃给定时长这个要求了。
既然要求在指定位置时刚好停下,那我就在收到停止指令时计算得出停止在指定ID应该移动的距离targetDistance,targetDistance可以额外增加n*Length以修正具体表现。
然后每帧根据剩余的距离调整Speed的值,只要保证movedDistance == targetDistance时Speed==0即可。
实际上这里如果采用(targetDistance - movedDistance)与Speed线性相关即可,这样移动得约多Speed约慢,实际的Speed与Time的函数关系应该是一个逐渐放缓的曲线,看起来效果也就差不多了。
不过我这里当时轴住了,误以为这种线性递减的方式会比较生硬,试图让距离与速度的关系按 speed = n^(distance*m), 这里n为 (0,1),m是一个反复尝试得出的较大的倍数。这个函数本身就是一个斜率逐渐平缓的曲线,那么Speed与Time的函数就只会更加平缓得厉害,导致停止下来消耗的时间急剧增长……
用这个算法来做,n的值不能大,大了接近于1的话,速度降低的效果不明显,很久才能减速到0;也不能太小,例如小于0.8, 速度会急剧降低,导致离目标距离还隔很远速度已经降低到不能看了,后面要抵达目标距离只能慢慢地走,花费大量时间……
我在写完这个计算方法后,反复调整各种参数,发现它停止的时候始终很拖沓。
后来我突然想明白,其实在全速旋转时,根本看不清图案,我完全犯不着那么实诚地根据开始停止的实际位置计算要运动的距离,完全可以直接把CurrentPosition跳跃到距目标位置确定距离的地方,然后经过确定的距离而停止。这样就根本不用调整什么函数斜率了。
同时需求方表示,他们想要的是一个急促停止的效果,没必要这么软绵绵地逐渐减速,因此我偷懒继续采用上述的算法,但是在停止时跳跃到目标距离前x单位的位置,然后speed = n^(distance*m)这里的n=0.97,m则非常大,最后发现表现其实还凑合,就这样交差了。