引子:随机数不靠 Math.random

这个随机决策器有个特别的地方——没有用 Math.random(),而是自己实现了一个线性同余随机数生成器(LCG)。原因很简单:Math.random() 在某些设备上的分布不够均匀,而且不可控。LCG 虽然简单,但足够用,而且可以复现结果(只要种子相同)。

除了随机数算法,这个页面的另一个亮点是动画编排——硬币翻转 10 个阶段、骰子摇晃 6 个阶段、随机数跳动 8 个阶段,每个阶段的时长和缓动都不同。今天就从代码出发,拆解 LCG 随机数和链式动画的实现。
完整效果
在这里插入图片描述
在这里插入图片描述

三个 Tab 的状态管理

enum RandTab { COIN = 0, DICE = 1, NUMBER = 2 }

@State tab: RandTab = RandTab.COIN;

在这里插入图片描述

为什么用 enum 而不是 number

RandTab.COIN0 更语义化——看到代码就知道"这是硬币 Tab",而不是"这是第 0 个 Tab"。enum 编译后就是数字,不影响性能。

Tab 切换的实现

Button('🪙 硬币')
  .backgroundColor(this.tab === RandTab.COIN ? '#FF6B35' : '#2A2A3E')
  .fontColor(this.tab === RandTab.COIN ? '#FFF' : '#888')
  .onClick(() => this.tab = RandTab.COIN)

选中的 Tab 用橙色背景(#FF6B35),未选中的用深色背景(#2A2A3E)。通过 this.tab 的值决定样式。

三个 if 而不是 switch

if (this.tab === RandTab.COIN) { ... }
if (this.tab === RandTab.DICE) { ... }
if (this.tab === RandTab.NUMBER) { ... }

用三个 if 而不是 switch,是因为每个 Tab 的内容是独立的 Column,不会互相干扰。如果用 switch,需要在每个 case 里写 break,代码更冗长。

LCG 随机数:自己造轮子

private rngSeed: number = Date.now();
private rand(): number {
  this.rngSeed = (this.rngSeed * 1103515245 + 12345) & 0x7fffffff;
  return this.rngSeed / 0x7fffffff;
}

在这里插入图片描述

什么是 LCG

线性同余生成器(Linear Congruential Generator)是最简单的伪随机数算法:

next = (current × a + c) & 0x7fffffff

其中 a = 1103515245c = 12345& 0x7fffffff 是取模 2^31。

为什么用 & 0x7fffffff

& 0x7fffffff 保留低 31 位,丢弃符号位。这样结果始终是 0 到 2^31-1 之间的正整数。

为什么除以 0x7fffffff

把整数归一化到 0-1 范围。rand() 的返回值是 [0, 1) 之间的浮点数,和 Math.random() 的范围一样。

为什么不用 Math.random()

  1. 可控性:LCG 的种子可以设置,方便测试
  2. 分布均匀Math.random() 的实现在不同设备上可能不同,分布不一定均匀
  3. 复现性:相同的种子产生相同的序列,方便调试

种子的选择

Date.now() 用当前时间戳作为种子。每次启动应用种子不同,随机序列也不同。

硬币翻转:10 阶段链式动画

flipCoin(): void {
  let target: number = this.rand() < 0.5 ? 0 : 1;
  let ph: number = 0;
  let next = (): void => {
    if (ph === 0) { animateTo({ duration: 100, curve: Curve.Sharp }, () => { this.coinAngle += 90; }); }
    else if (ph === 1) { animateTo({ duration: 100, curve: Curve.Sharp }, () => { this.coinAngle += 90; }); }
    // ... 直到 ph === 9
    ph++;
    setTimeout(next, (ph === 8 ? 260 : ph === 9 ? 180 : 100 + ph * 10));
  };
  next();
}

在这里插入图片描述

动画链的原理

硬币翻转不是一次动画,而是 10 次连续动画。每次动画转 90 度,总共转 900 度(2.5 圈)。

  • 阶段 0-7:每次转 90 度,时长从 100ms 递增到 170ms
  • 阶段 7:切换正反面(this.coinFace = target
  • 阶段 8:放大到 1.25 倍
  • 阶段 9:缩回 1 倍

时长递增的原因

开始快(100ms),结束慢(170ms),模拟真实硬币的物理特性——开始时动能大,旋转快;后来动能减小,旋转慢。

setTimeout 的延迟

setTimeout(next, (ph === 8 ? 260 : ph === 9 ? 180 : 100 + ph * 10));
  • 阶段 8:等 260ms(让阶段 7 的动画完成)
  • 阶段 9:等 180ms(让阶段 8 的动画完成)
  • 其他:等 100 + ph * 10 ms(递增延迟)

为什么不用 animateTo 的回调

animateTo 没有"动画完成"的回调。用 setTimeout 手动控制下一个动画的开始时间,实现链式效果。

coinAngle 的累加

this.coinAngle += 90;  // 累加而不是赋值

每次加 90 度,10 次后总共加了 900 度。如果用赋值 this.coinAngle = 90,每次都是 90 度,硬币看起来没动。

骰子摇晃:6 阶段随机位移

rollDice(): void {
  this.diceReady = false;
  // 先计算最终结果
  let finalVals: number[] = [];
  for (let di: number = 0; di < this.diceCount; di++) {
    let v: number = Math.floor(this.rand() * 6) + 1;
    finalVals.push(v);
  }
  
  let ph: number = 0;
  let totalPhases: number = 6;
  let next = (): void => {
    if (ph < totalPhases) {
      // 摇晃阶段:随机位移 + 随机点数
      let rem: number = totalPhases - ph;
      let sx: number = Math.floor((this.rand() - 0.5) * rem * 6);
      let sy: number = Math.floor((this.rand() - 0.5) * rem * 6);
      let tmp: number[] = [];
      for (let di: number = 0; di < this.diceCount; di++) {
        tmp.push(Math.floor(this.rand() * 6) + 1);
      }
      animateTo({ duration: 60 + ph * 25, curve: Curve.Sharp }, () => {
        this.diceMoveX = sx; this.diceMoveY = sy; this.diceVals = tmp;
      });
      ph++;
      setTimeout(next, 70 + ph * 25);
    } else {
      // 停止阶段:回到原位 + 显示最终结果
      animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
        this.diceMoveX = 0; this.diceMoveY = 0; this.diceScale = 1.2;
        this.diceVals = finalVals; this.diceSum = finalSum; this.diceReady = true;
      });
      // 缩回动画
      setTimeout(() => {
        animateTo({ duration: 300, curve: Curve.EaseInOut }, () => { this.diceScale = 1; });
      }, 220);
    }
  };
  next();
}

摇晃的策略

每次摇晃的位移幅度递减(rem * 6),模拟骰子从剧烈摇晃到逐渐停止的过程。

位移的随机性

let sx: number = Math.floor((this.rand() - 0.5) * rem * 6);

(this.rand() - 0.5) 产生 -0.5 到 0.5 之间的值,乘以 rem * 6 后变成随机位移。rem 越大(开始阶段),位移越大。

点数的随机显示

摇晃过程中显示随机点数,让用户感觉"骰子在滚动"。最终结果在停止阶段才显示。

两阶段停止

  1. 回到原位 + 放大 1.2 倍(200ms,EaseOut)
  2. 缩回 1 倍(300ms,EaseInOut)

先放大让用户注意到结果,再缩回恢复正常大小。

随机数跳动:8 阶段数字滚动

genRandom(): void {
  let target: number = Math.floor(this.rand() * (this.randMax - this.randMin + 1)) + this.randMin;
  let ph: number = 0;
  let total: number = 8;
  let next = (): void => {
    if (ph < total) {
      // 跳动阶段:在目标值附近随机波动
      let mid: number = target + Math.floor((this.rand() - 0.5) * (total - ph) * 20);
      animateTo({ duration: 50 + ph * 18, curve: Curve.Sharp }, () => { this.randResult = mid; });
      ph++;
      setTimeout(next, 55 + ph * 18);
    } else {
      // 停止阶段:显示最终值 + 放大缩回
      animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
        this.randResult = target; this.randScale = 1.18;
      });
      setTimeout(() => {
        animateTo({ duration: 300, curve: Curve.EaseInOut }, () => { this.randScale = 1; });
      }, 220);
    }
  };
  next();
}

跳动的策略

每次显示的值是 target + 随机偏移。偏移幅度递减(total - ph),模拟"逐渐逼近目标"的效果。

为什么偏移乘以 20

(total - ph) * 20 控制偏移的最大幅度。开始时偏移大(±160),结束时偏移小(±20)。如果偏移太小,跳动不明显;如果偏移太大,看起来像乱码。

最终值的确定

在阶段 0 就计算好 target,跳动过程中只是在它附近波动。这样保证最终结果是随机的,跳动只是视觉效果。

Tab 切换的条件渲染

if (this.tab === RandTab.COIN) {
  Column() { /* 硬币内容 */ }
  .layoutWeight(1).justifyContent(FlexAlign.Center).width('100%')
}

if (this.tab === RandTab.DICE) {
  Column() { /* 骰子内容 */ }
  .layoutWeight(1).justifyContent(FlexAlign.Center).width('100%')
}

if (this.tab === RandTab.NUMBER) {
  Column() { /* 随机数内容 */ }
  .layoutWeight(1).justifyContent(FlexAlign.Center).width('100%')
}

为什么三个 Column 都加了 layoutWeight(1)

layoutWeight(1) 让 Column 占据剩余空间。三个 Column 虽然同时存在,但只有当前 Tab 的那个会显示(因为 if 条件)。不加 layoutWeight(1) 的话,Column 的高度由内容决定,可能导致布局错乱。

为什么用 justifyContent(FlexAlign.Center)

三个 Tab 的内容都居中显示,视觉上统一。如果不用居中,硬币可能在顶部,骰子在中间,随机数在底部,看起来不协调。

骰子面的 emoji 映射

diceFace(v: number): string {
  let faces: string[] = ['', '⚀', '⚁', '⚂', '⚃', '⚄', '⚅'];
  return faces[v];
}

为什么数组第一个是空字符串

骰子点数是 1-6,数组索引也是 0-6。索引 0 没有用,用空字符串占位,让 faces[v] 直接返回对应的 emoji。

为什么用 emoji 而不是自定义绘制

emoji 渲染快,代码简单,兼容性好。缺点是样式不可控(不同设备的 emoji 可能不同),但对于骰子这种简单图形足够了。

踩坑记录

坑 1:LCG 的周期

LCG 的周期是 2^31(约 21 亿),对于这个应用足够了。但如果需要更长的周期,可以用更复杂的算法(比如 Mersenne Twister)。

坑 2:animateTo 的嵌套

animateTo 不能嵌套——在 animateTo 的回调里再调 animateTo 会导致动画混乱。代码用 setTimeout 延迟执行,避免了嵌套。

坑 3:diceReady 的作用

diceReady 控制骰子是否可以再次摇晃。在动画过程中设为 false,防止用户连续点击导致动画冲突。

坑 4:coinAngle 的溢出

coinAngle 会无限累加(900、1800、2700…),但 CSS 的 rotate 会自动取模,不会溢出。

坑 5:randMin 和 randMax 的校验

用户可能输入非法值(比如 min > max)。代码用 parseInt(v) || 1 兜底,但没有做 min < max 的校验。如果 min > max,Math.floor(this.rand() * (max - min + 1)) 会得到负数。

代码改进建议

1. 动画参数提取

三个动画的阶段数、时长、缓动都硬编码在函数里。可以提取成配置对象:

const COIN_ANIM = {
  phases: 10,
  durations: [100, 100, 120, 140, 160, 180, 200, 260, 160, 200],
  curves: [Curve.Sharp, ..., Curve.EaseOut, Curve.EaseOut, Curve.EaseInOut]
};

2. 动画复用

三个动画的链式结构相似(递增阶段 + setTimeout 延迟),可以抽象成通用的链式动画函数。

3. 声音反馈

硬币翻转、骰子摇晃、随机数停止时可以加音效,增强沉浸感。

4. 历史记录

保存最近的随机结果,方便用户回顾。

5. 自定义骰子面

用户可以自定义骰子的 6 个面(比如用文字代替 emoji)。

总结

随机决策器的核心是"链式动画"——用 setTimeout + animateTo 实现多阶段的连续动画。每个阶段的时长递增、缓动不同,模拟真实物理效果。LCG 随机数虽然简单,但足够用,而且比 Math.random() 更可控。

适用边界:这个部分适合用作 ArkUI 链式动画和伪随机数算法的学习案例,涵盖了 LCG 随机数、animateTo 链式调用、setTimeout 延迟、enum 状态管理、emoji 映射等核心知识点。但如果要上架应用商店,还需要补充声音反馈、历史记录、自定义骰子面、输入校验、动画参数配置化等内容。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐