算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 转盘落点:目标未知时先空转再落定 待审核 22 / 47
indeterminate loop · deferred target · 减速落定

转盘落点:目标未知时先空转再落定

抽奖转盘与老虎机是一类特殊的交互:点击那一刻目标还不存在。接口尚在路上,动画却必须立刻响应。做法是把整段运动拆成两段——先用 repeat: Infinityease: 'linear' 匀速空转占位,接口返回后再按当前角度新建一段减速,落到中奖扇区。

1 · 两段接力

空转段的长度不可预知(取决于网络),落定段却必须精确停在扇区中心。对齐靠一个取模:先算出目标角度与当前角度的差 Δ=(目标当前)mod360\Delta = (\text{目标} - \text{当前}) \bmod 360,再加上整数圈。

图 1-1 · 转盘的空转与落定,下方是整段运动的角速度剖面。可调网络延迟与落定曲线,观察平台段长度每次不同而落点始终精确。

角速度剖面把这件事画得最清楚:空转期是一条水平的平台(匀速,deg/ms 恒定),虚线标出交接点,之后曲线随落定缓动衰减到 0。平台的长度不可预知,衰减段的形状由落定曲线决定。

2 · 交接处的速度

默认的落定曲线是惯性衰减而非固定曲线,因为两者在因果上正相反:固定曲线先定时长、再塑形;惯性减速反过来,时长与滑行距离都由物理反推。

要让起速续得上空转,本页把 power 取作 τ\tau 秒。振幅 A=powerv0=τv0A = \text{power} \cdot v_0 = \tau v_0,于是吸附前的起点角速度 A/τ=v0A / \tau = v_0,正好等于空转的角速度。惯性衰减那页记过 powerτ\tau 一般并不相等、起速会是标称值的若干倍——本页是那条关系被反过来用的一个实例。

警示 · 切到 easeOut 会在交接处看到平台尾端一个向上的尖峰。原因是 easeOut 的起点斜率恒为该段平均速度的两倍,而平均速度又约等于空转速度,于是交接瞬间凭空提速一倍。match 档用 cubicBezier(13,m03,23,1)\text{cubicBezier}(\frac13, \frac{m_0}{3}, \frac23, 1) 按当前匀速反算起点斜率,尖峰随之消失——代价是圈数与时长仍是设定值,不再由物理决定。

3 · 为什么不用弹簧收尾

弹簧的目标吸引与回摆会把指针荡过中奖区再摆回来,落点在过程中不可控。decay 单调趋近落点、不过冲,天然适合「必须停在扇区」。

需要「咔哒一下到位」的手感时,backOut 是可控的折中:它也过冲,但过冲量固定、终值精确落在目标上——与弹簧的区别在于它是一条给定的曲线,越界的幅度写死在参数里。

const PER_REV = 720;

loop = new Animation({
  keyframes: [0, 1], duration: PER_REV,
  ease: 'linear', repeat: Infinity,
  onUpdate: () => render(loop.currentTime / PER_REV * 360),
});
const win = await fetchResult();

const cur = loop.currentTime / PER_REV * 360;
const v0 = 360 / PER_REV * 1000;
loop.pause();
const base = cur + mod360(360 - sectorCenter(win) - cur);

const decay = createDecay({
  from: cur, velocity: v0,
  power: tau / 1000, timeConstant: tau,
  modifyTarget: (ideal) =>
    base + Math.max(0, Math.round((ideal - base) / 360)) * 360,
});

匀速循环的 repeat 语义见播放控制backOut 的过冲来自 back 族缓动,见缓动modifyTarget 与惯性吸附见惯性衰减。带速度接管的通用做法见中断与重定向