
引子随机数不靠 Math.random这个随机决策器有个特别的地方——没有用Math.random()而是自己实现了一个线性同余随机数生成器LCG。原因很简单Math.random()在某些设备上的分布不够均匀而且不可控。LCG 虽然简单但足够用而且可以复现结果只要种子相同。除了随机数算法这个页面的另一个亮点是动画编排——硬币翻转 10 个阶段、骰子摇晃 6 个阶段、随机数跳动 8 个阶段每个阶段的时长和缓动都不同。今天就从代码出发拆解 LCG 随机数和链式动画的实现。完整效果三个 Tab 的状态管理enumRandTab{COIN0,DICE1,NUMBER2}Statetab:RandTabRandTab.COIN;为什么用 enum 而不是 number用RandTab.COIN比0更语义化——看到代码就知道这是硬币 Tab而不是这是第 0 个 Tab。enum 编译后就是数字不影响性能。Tab 切换的实现Button( 硬币).backgroundColor(this.tabRandTab.COIN?#FF6B35:#2A2A3E).fontColor(this.tabRandTab.COIN?#FFF:#888).onClick(()this.tabRandTab.COIN)选中的 Tab 用橙色背景#FF6B35未选中的用深色背景#2A2A3E。通过this.tab的值决定样式。三个 if 而不是 switchif(this.tabRandTab.COIN){...}if(this.tabRandTab.DICE){...}if(this.tabRandTab.NUMBER){...}用三个if而不是switch是因为每个 Tab 的内容是独立的Column不会互相干扰。如果用switch需要在每个case里写break代码更冗长。LCG 随机数自己造轮子privaterngSeed:numberDate.now();privaterand():number{this.rngSeed(this.rngSeed*110351524512345)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()可控性LCG 的种子可以设置方便测试分布均匀Math.random()的实现在不同设备上可能不同分布不一定均匀复现性相同的种子产生相同的序列方便调试种子的选择Date.now()用当前时间戳作为种子。每次启动应用种子不同随机序列也不同。硬币翻转10 阶段链式动画flipCoin():void{lettarget:numberthis.rand()0.5?0:1;letph:number0;letnext():void{if(ph0){animateTo({duration:100,curve:Curve.Sharp},(){this.coinAngle90;});}elseif(ph1){animateTo({duration:100,curve:Curve.Sharp},(){this.coinAngle90;});}// ... 直到 ph 9ph;setTimeout(next,(ph8?260:ph9?180:100ph*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,(ph8?260:ph9?180:100ph*10));阶段 8等 260ms让阶段 7 的动画完成阶段 9等 180ms让阶段 8 的动画完成其他等100 ph * 10ms递增延迟为什么不用 animateTo 的回调animateTo没有动画完成的回调。用setTimeout手动控制下一个动画的开始时间实现链式效果。coinAngle 的累加this.coinAngle90;// 累加而不是赋值每次加 90 度10 次后总共加了 900 度。如果用赋值this.coinAngle 90每次都是 90 度硬币看起来没动。骰子摇晃6 阶段随机位移rollDice():void{this.diceReadyfalse;// 先计算最终结果letfinalVals:number[][];for(letdi:number0;dithis.diceCount;di){letv:numberMath.floor(this.rand()*6)1;finalVals.push(v);}letph:number0;lettotalPhases:number6;letnext():void{if(phtotalPhases){// 摇晃阶段随机位移 随机点数letrem:numbertotalPhases-ph;letsx:numberMath.floor((this.rand()-0.5)*rem*6);letsy:numberMath.floor((this.rand()-0.5)*rem*6);lettmp:number[][];for(letdi:number0;dithis.diceCount;di){tmp.push(Math.floor(this.rand()*6)1);}animateTo({duration:60ph*25,curve:Curve.Sharp},(){this.diceMoveXsx;this.diceMoveYsy;this.diceValstmp;});ph;setTimeout(next,70ph*25);}else{// 停止阶段回到原位 显示最终结果animateTo({duration:200,curve:Curve.EaseOut},(){this.diceMoveX0;this.diceMoveY0;this.diceScale1.2;this.diceValsfinalVals;this.diceSumfinalSum;this.diceReadytrue;});// 缩回动画setTimeout((){animateTo({duration:300,curve:Curve.EaseInOut},(){this.diceScale1;});},220);}};next();}摇晃的策略每次摇晃的位移幅度递减rem * 6模拟骰子从剧烈摇晃到逐渐停止的过程。位移的随机性letsx:numberMath.floor((this.rand()-0.5)*rem*6);(this.rand() - 0.5)产生 -0.5 到 0.5 之间的值乘以rem * 6后变成随机位移。rem越大开始阶段位移越大。点数的随机显示摇晃过程中显示随机点数让用户感觉骰子在滚动。最终结果在停止阶段才显示。两阶段停止回到原位 放大 1.2 倍200msEaseOut缩回 1 倍300msEaseInOut先放大让用户注意到结果再缩回恢复正常大小。随机数跳动8 阶段数字滚动genRandom():void{lettarget:numberMath.floor(this.rand()*(this.randMax-this.randMin1))this.randMin;letph:number0;lettotal:number8;letnext():void{if(phtotal){// 跳动阶段在目标值附近随机波动letmid:numbertargetMath.floor((this.rand()-0.5)*(total-ph)*20);animateTo({duration:50ph*18,curve:Curve.Sharp},(){this.randResultmid;});ph;setTimeout(next,55ph*18);}else{// 停止阶段显示最终值 放大缩回animateTo({duration:200,curve:Curve.EaseOut},(){this.randResulttarget;this.randScale1.18;});setTimeout((){animateTo({duration:300,curve:Curve.EaseInOut},(){this.randScale1;});},220);}};next();}跳动的策略每次显示的值是target 随机偏移。偏移幅度递减total - ph模拟逐渐逼近目标的效果。为什么偏移乘以 20(total - ph) * 20控制偏移的最大幅度。开始时偏移大±160结束时偏移小±20。如果偏移太小跳动不明显如果偏移太大看起来像乱码。最终值的确定在阶段 0 就计算好target跳动过程中只是在它附近波动。这样保证最终结果是随机的跳动只是视觉效果。Tab 切换的条件渲染if(this.tabRandTab.COIN){Column(){/* 硬币内容 */}.layoutWeight(1).justifyContent(FlexAlign.Center).width(100%)}if(this.tabRandTab.DICE){Column(){/* 骰子内容 */}.layoutWeight(1).justifyContent(FlexAlign.Center).width(100%)}if(this.tabRandTab.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 可能不同但对于骰子这种简单图形足够了。踩坑记录坑 1LCG 的周期LCG 的周期是 2^31约 21 亿对于这个应用足够了。但如果需要更长的周期可以用更复杂的算法比如 Mersenne Twister。坑 2animateTo 的嵌套animateTo不能嵌套——在animateTo的回调里再调animateTo会导致动画混乱。代码用setTimeout延迟执行避免了嵌套。坑 3diceReady 的作用diceReady控制骰子是否可以再次摇晃。在动画过程中设为false防止用户连续点击导致动画冲突。坑 4coinAngle 的溢出coinAngle会无限累加900、1800、2700…但 CSS 的rotate会自动取模不会溢出。坑 5randMin 和 randMax 的校验用户可能输入非法值比如 min max。代码用parseInt(v) || 1兜底但没有做 min max 的校验。如果 min maxMath.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。总结随机决策器的核心是链式动画——用setTimeoutanimateTo实现多阶段的连续动画。每个阶段的时长递增、缓动不同模拟真实物理效果。LCG 随机数虽然简单但足够用而且比Math.random()更可控。适用边界这个部分适合用作 ArkUI 链式动画和伪随机数算法的学习案例涵盖了 LCG 随机数、animateTo 链式调用、setTimeout 延迟、enum 状态管理、emoji 映射等核心知识点。但如果要上架应用商店还需要补充声音反馈、历史记录、自定义骰子面、输入校验、动画参数配置化等内容。