HarmonyOS ArkTS 随机决策器的动画编排与 LCG 随机数
2026/7/22 21:21:49 网站建设 项目流程

引子:随机数不靠 Math.random

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

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

三个 Tab 的状态管理

enumRandTab{COIN=0,DICE=1,NUMBER=2}@Statetab: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 随机数:自己造轮子

privaterngSeed:number=Date.now();privaterand():number{this.rngSeed=(this.rngSeed*1103515245+12345)&0x7fffffff;returnthis.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{lettarget:number=this.rand()<0.5?0:1;letph:number=0;letnext=():void=>{if(ph===0){animateTo({duration:100,curve:Curve.Sharp},()=>{this.coinAngle+=90;});}elseif(ph===1){animateTo({duration:100,curve:Curve.Sharp},()=>{this.coinAngle+=90;});}// ... 直到 ph === 9ph++;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 * 10ms(递增延迟)

为什么不用 animateTo 的回调

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

coinAngle 的累加

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

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

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

rollDice():void{this.diceReady=false;// 先计算最终结果letfinalVals:number[]=[];for(letdi:number=0;di<this.diceCount;di++){letv:number=Math.floor(this.rand()*6)+1;finalVals.push(v);}letph:number=0;lettotalPhases:number=6;letnext=():void=>{if(ph<totalPhases){// 摇晃阶段:随机位移 + 随机点数letrem:number=totalPhases-ph;letsx:number=Math.floor((this.rand()-0.5)*rem*6);letsy:number=Math.floor((this.rand()-0.5)*rem*6);lettmp:number[]=[];for(letdi: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),模拟骰子从剧烈摇晃到逐渐停止的过程。

位移的随机性

letsx: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{lettarget:number=Math.floor(this.rand()*(this.randMax-this.randMin+1))+this.randMin;letph:number=0;lettotal:number=8;letnext=():void=>{if(ph<total){// 跳动阶段:在目标值附近随机波动letmid: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{letfaces:string[]=['','⚀','⚁','⚂','⚃','⚄','⚅'];returnfaces[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. 动画参数提取

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

constCOIN_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 映射等核心知识点。但如果要上架应用商店,还需要补充声音反馈、历史记录、自定义骰子面、输入校验、动画参数配置化等内容。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询