简介:微信小程序开发实战资料,以经典麻将骰子应用为主线,面向小程序初学者和想补全前端交互知识的移动开发者。压缩包共17个文件,内含10张png骰子界面素材、wxml页面结构、wxss样式、js逻辑代码及json配置文件,大小仅1.32MB,结构精简,适合边看教程边对照源码理解WXML模板、事件绑定、数据绑定和动画API的用法,也可以快速定位页面、逻辑与样式的对应关系。已有2958人学习下载。配套教程从项目初始化讲起,逐步演示页面布局、随机点数生成、掷骰子交互与动态反馈,并涵盖生命周期、调试与真机预览等关键环节。开发者不仅能复刻一个可直接运行的骰子小应用,还能借此掌握微信小程序从创建、编码到部署上线的完整流程,实际体会移动端交互设计与动画实现的基本思路。
1. 微信小程序开发:一个麻将骰子案例,为什么值得照着源码做一遍
微信小程序开发里,麻将骰子是一个被严重低估的练手项目:需求一句话就能说清,却要同时处理触屏事件、3D 翻转动画、随机数逻辑、触感反馈、本地存储和分享卡片。骰子类“微信小程序源码”恰好是一线开发者用来验证动画性能与真机兼容性的标准样本——它不依赖后端,没有复杂的协议栈,但小程序端该踩的坑几乎都踩了一遍。适合刚从“会写页面”过渡到“想交付一个完整微信小程序项目实例”的人,也适合需要快速验证新机型动画表现的熟手。
2. 先搭骨架:骰子组件的小程序页面结构与 3D 翻转动画选型
2.1 页面结构:骰子到底用 view 堆还是 canvas 画
动手之前先解决选型问题:骰子的六个面,到底用六个 view 堆 3D 布局,还是用 canvas 直接画一个骰子?这两条路我都走过,结论很明确——用 view 堆,不用 canvas。
原因有三:第一,骰子的状态极其有限,一共六个面,每个面就是一个静态视图,DOM 方案只需要切换 CSS 变换,不需要维护任何绘制循环;第二,CSS transition 天然提供补间动画,而 canvas 方案要自己管理帧循环、插值和缓动函数,代码量成倍增加;第三,canvas 在部分低端安卓机上存在初始化白屏和内存抖动,而 view 方案走的是渲染层合成,表现稳定得多。canvas 的唯一优势是自由绘制点数,但骰子点数本来就是固定图形,用 CSS 画点或者直接用图片素材都能解决。
这个案例的目录结构如下:pages/index/index.wxml负责骰子骨架,index.wxss负责 3D 变换与动画,index.js负责随机逻辑与交互状态。如果你拿到的是源码包,先确认这三份文件齐全,再去看app.json是否把pages/index/index注册为首页。
<!-- pages/index/index.wxml --> <view class="dice-scene"> <view class="dice-box" id="diceBox" bindtap="onRollDice"> <view class="face face-front">1</view> <view class="face face-back">6</view> <view class="face face-left">4</view> <view class="face face-right">3</view> <view class="face face-top">2</view> <view class="face face-bottom">5</view> </view> <view class="roll-tip">点击骰子摇一摇</view> </view>这段 WXML 的逻辑是:.dice-box是骰子的 3D 容器,六个.face子视图分别对应骰子的六个面。注意点数与面的对应关系——麻将骰子讲究“相对两面之和为 7”,所以 1 和 6 相对、2 和 5 相对、3 和 4 相对。这是一个很容易被忽略的细节,如果随便摆放,懂行的用户一眼就能看出来。
2.2 骰子的数据模型与 WXSS 变换
骰子的数据模型比想象中简单:六个面各自有一个基础变换姿态,骰子整体有一个累计旋转角。关键在于 WXSS 里要把 3D 环境建立起来,否则所有子视图都只是平铺在屏幕上。
/* pages/index/index.wxss */ .dice-scene { width: 100%; height: 80vh; display: flex; align-items: center; justify-content: center; perspective: 900rpx; /* 视距,太小则变形剧烈,太大幅度感弱 */ } .dice-box { width: 200rpx; height: 200rpx; position: relative; transform-style: preserve-3d; /* 关键:允许子视图在 3D 空间定位 */ transform: rotateX(0deg) rotateY(0deg); transition: transform 900ms cubic-bezier(0.22, 0.61, 0.36, 1); } .face { position: absolute; width: 200rpx; height: 200rpx; line-height: 200rpx; text-align: center; font-size: 64rpx; background: #f5f0e8; border: 2rpx solid #c8b89a; border-radius: 12rpx; box-sizing: border-box; } .face-front { transform: translateZ(100rpx); } .face-back { transform: rotateY(180deg) translateZ(100rpx); } .face-left { transform: rotateY(-90deg) translateZ(100rpx); } .face-right { transform: rotateY(90deg) translateZ(100rpx); } .face-top { transform: rotateX(90deg) translateZ(100rpx); } .face-bottom { transform: rotateX(-90deg) translateZ(100rpx); }这里的核心参数有三个:perspective决定 3D 透视的强度,900rpx 是我在各类机型上调试后比较稳的值,太小骰子会变形得像鱼眼镜头,太大则几乎看不出立体翻转;transform-style: preserve-3d必须写在容器上,一旦漏掉,六个面永远只会平铺;transition的时长和缓动曲线决定了骰子的手感,900ms 是一个“干脆但不突兀”的区间,后面会专门说为什么不要用 600ms 以下。
有一个容易踩的细节:translateZ(100rpx)里的 100rpx 恰好是骰子边长的一半,因为立方体的六个面要围绕中心点向外推。如果骰子边长改成 240rpx,这里也要同步改成 120rpx,否则面与面之间会出现缝隙甚至交错。
2.3 视图层的六个面为什么要预先定义旋转姿态
骰子翻转的核心思路不是直接去“旋转单独的面”,而是始终旋转整个.dice-box容器。每个面的基础姿态是固定的——比如 1 号面永远朝向用户,6 号面永远背对用户——用户看到的点数,取决于容器当前旋转到了哪个角度。
// pages/index/index.js 中的姿态映射表(节选) const FACE_POSE = { 1: { x: 0, y: 0 }, // 正面朝上,无需额外旋转 2: { x: -90, y: 0 }, // 让原本朝下的面转到正面 3: { x: 0, y: 90 }, // 右侧面转到正面 4: { x: 0, y: -90 }, // 左侧面转到正面 5: { x: 90, y: 0 }, // 顶面转到正面 6: { x: 0, y: 180 } // 背面转到正面 };这个映射表是整个骰子逻辑的基石,理解它比理解后面的随机算法更重要。它的本质是:把“用户想看到几号面”翻译成“容器需要转多少度”,让目标面恰好面向屏幕。如果你拿到源码后想自定义点数布局,只需要改这个表,不用动任何视图代码。注意 x 轴和 y 轴的旋转顺序不能互换,先 rotateX 再 rotateY 和反过来得到的结果完全不同,这也是新手最常翻车的点。
3. 摇骰子逻辑:随机算法、动画衔接与触感反馈的落地代码
3.1 随机点数与“不连续重复”的观感处理
骰子的随机算法看起来就是一个Math.random()乘以 6 再取整,但直接这么写会在真实产品里挨骂——因为用户会连续三次摇出同一个点数,然后质疑代码有 bug。这不是随机算法的问题,是人类对随机序列的直觉判断与真实随机分布之间的偏差。
我一般会做一个“防连续重复”处理:在纯随机的基础上,如果本次点数与上次相同,则重新摇一次,最多重试两次。
// pages/index/index.js const MAX_RETRY = 2; function rollDiceIndex(prevIndex) { let index = Math.floor(Math.random() * 6); // 生成 0-5 的随机索引 if (index === prevIndex) { // 连续相同则重新生成,最多重试 MAX_RETRY 次 for (let i = 0; i < MAX_RETRY; i++) { index = Math.floor(Math.random() * 6); if (index !== prevIndex) break; } } return index; }这里要注意一个细节:我不是直接生成点数,而是生成“索引”0 到 5,再映射到点数。因为骰子面的点数按照前面 WXML 里的顺序正好是从 1 到 6,但实际项目中面序可能打乱,用索引做中间层,姿态映射表和面序解耦,后续维护会舒服很多。另一个隐含的边界是:首次摇骰子时prevIndex传-1,保证第一次不受“不连续”限制,任何点数都可以出现。
MAX_RETRY这个参数控制“连续相同”容忍度。设成 2,意味着在极端情况下仍然可能连续相同,但概率已经低到用户感知不到。如果你在做一个“比大小”的麻将玩法,希望连庄感更强,可以把MAX_RETRY调到 0,完全回归纯随机。这个参数值得在源码里留成配置项,而不是写死。
3.2 动画衔接:把随机结果转成容器的累计旋转角
拿到随机点数后,下一步是把点数映射成容器旋转角。这里最容易翻车的是“过渡动画走最短路径”问题——如果容器当前在 0 度,目标面需要转到 90 度,动画会顺着 90 度转过去;但下一次目标面需要转到 -90 度,动画就会反向转回来,视觉上骰子“左右摇摆”,非常假。
解决思路是累计角度:每次都在上一次的旋转角基础上,加上目标面所需角度,再加上一整圈(360 度)的倍数,让骰子始终朝同一个方向滚动。
// pages/index/index.js Page({ data: { diceStyle: '' // 内联样式字符串,直接驱动骰子旋转 }, onLoad() { this.rotationX = 0; this.rotationY = 0; this.lastIndex = -1; }, onRollDice() { if (this.isRolling) return; // 动画期间屏蔽重复点击 const faceIndex = rollDiceIndex(this.lastIndex); const pose = FACE_POSE[faceIndex + 1]; // 索引转点数,再取姿态 // 随机多转 1-3 整圈,增强翻转的视觉动感 const extraSpins = 1 + Math.floor(Math.random() * 3); // 累计旋转角:旧角度 + 目标姿态 + 整圈增量 this.rotationX += pose.x + 360 * extraSpins; this.rotationY += pose.y + 360 * extraSpins; this.lastIndex = faceIndex; this.isRolling = true; this.setData({ diceStyle: `transform: rotateX(${this.rotationX}deg) rotateY(${this.rotationY}deg);` }); } });逻辑说明:this.rotationX和this.rotationY是挂在页面实例上的累计值,不在data里,因为它们不需要驱动视图更新,只是用来计算下一次的目标角度。extraSpins让骰子每次多转 1 到 3 整圈,这样即使前后两次目标面相邻很近,视觉上也始终是“哗啦哗啦”地滚了好几圈,而不是拨动了一下。isRolling是一个互斥锁,动画期间再次点击会直接 return,避免用户狂点导致动画状态错乱。
关键参数:extraSpins的区间决定了单次动画的幅度。最大值 3 圈是我在真机上测试过的上限,再大会让动画超过 1.5 秒,用户会不耐烦。最小值 1 圈保证每次都有足够的旋转动感。
3.3 触感反馈:震动 API 的调用时机与静默兜底
摇骰子如果没有触感反馈,就像按了快门没有“咔嚓”声,总感觉少了点什么。微信小程序的震动接口是wx.vibrateShort,但它的调用时机和兼容性都有讲究。
// 在 onRollDice 中,骰子开始旋转时立即震动 wx.vibrateShort({ type: 'light', // 轻触反馈,部分基础库版本要求传入 type fail: () => { // 旧版本基础库不支持 type 参数时,降级为重震动 wx.vibrateLong({ fail: () => {} // 静默失败,不打断用户体验 }); } });注意震动必须放在动画开始前,而不是动画结束后。用户在点击瞬间就该感受到触感,如果等到 900ms 动画跑完才震,反馈就完全错位了。另外,wx.vibrateShort的type参数在基础库 2.5.0 之后才支持,所以fail回调里要做降级处理——有些安卓机型传了type反而报错。这里的静默失败很重要:震动是增强体验,不是核心功能,绝不能因为震动接口报错导致页面卡死或逻辑中断。
4. 真机适配与常见坑:3D 失效、动画回调丢失、连续点击错乱的排查记录
4.1 真机上骰子“平躺”没有立体感:preserve-3d 被父级裁掉了
现象:模拟器里骰子翻转完美,放到真机上六个面全部平铺在屏幕上,或者翻转时出现面与面交错的撕裂感。
原因:transform-style: preserve-3d不是设置了就一定生效。微信小程序的 WXSS 在部分安卓机型上,只要祖先元素设置了overflow: hidden或border-radius,3D 上下文就会被强制重置为flat。我排查过的一个案例是,开发者为了给页面加圆角背景,在.dice-scene上写了border-radius: 20rpx,结果 3D 效果在真机上直接消失。
解决:检查骰子容器的所有祖先节点,把overflow: hidden、border-radius、filter这类会破坏 3D 上下文的属性全部挪到不影响骰子的节点上。如果必须保留圆角,那就把骰子放到一个独立的、没有这些属性的子容器里。
4.2 动画播完不触发回调:transitionend 在切后台时丢失
现象:骰子动画正常播放,但点数结果迟迟不更新,或者更新时机不稳定。开发者明明监听了transitionend事件,在回调里setData更新点数,可有时候动画结束就是不回调。
原因:微信小程序的页面在切后台、来电弹窗等场景下,渲染层会被挂起,transitionend这类 CSS 动画结束事件可能直接丢失。另外,如果开发者工具里开了“自动预览”,部分模拟器版本对transitionend的派发也有兼容性问题。
解决:不依赖transitionend,改用setTimeout兜底。动画时长是 900ms,我在onRollDice里直接注册一个 950ms 的定时器,到点后无论动画是否触发回调,都执行点数更新逻辑。这样做的代价是损失了“动画提前结束”的精确衔接,但对于 900ms 的骰子动画来说,50ms 的误差用户根本感知不到。
// 在 onRollDice 中,用定时器兜底动画结束 setTimeout(() => { this.isRolling = false; this.setData({ currentPoint: faceIndex + 1 }); }, 950); // 动画时长 900ms + 50ms 裕量这里 950ms 是一个经验值,如果你改了动画时长,记得同步修改裕量。裕量太小会提前解锁导致动画被打断,裕量太大会让用户感觉自己“卡”了一下。
4.3 快速连点导致动画错乱:状态锁没有覆盖到定时器
现象:用户快速连点骰子,骰子突然转到一半停下来,或者最终停在一个错误的点数上。
原因:isRolling状态锁确实在动画期间禁用了点击,但如果在动画结束回调里复位isRolling,而回调丢失(见上一条),锁就永远不会解开,骰子“死”在那里。反过来,如果用定时器复位,但在定时器触发前用户又点了一次,两次请求交错,旋转角度的计算就全乱了。
解决:定时器回调里复位isRolling,同时每次点击时先clearTimeout清理上一个定时器,再注册新的。这样即使极端情况下连续两次点击,第二次会直接覆盖第一次的定时器,不会出现两个回调同时操作状态的问题。
onRollDice() { if (this.isRolling) return; if (this.rollTimer) clearTimeout(this.rollTimer); // 清理上个定时器 // ...生成点数、计算角度、震动... this.rollTimer = setTimeout(() => { this.isRolling = false; this.setData({ currentPoint: faceIndex + 1 }); }, 950); }4.4 骰子翻转方向忽左忽右:角度没有累加
现象:每次摇骰子,骰子总是就近翻转,前一秒顺时针、下一秒逆时针,看起来像骰子在“犹豫”。
原因:直接用了FACE_POSE里的固定角度作为diceStyle的目标值,没有在旧角度上做累加。CSS transition 默认走最短路径,目标角 90 度和 -90 度之间,会抄近路反向旋转。
解决:这就是第 3 章里累计角度方案要解决的问题。this.rotationX += pose.x + 360 * extraSpins而不是this.rotationX = pose.x,保证每次都是在当前角度基础上叠加增量,转动方向永远一致。
5. 让案例更完整:历史记录、分享卡片与组件化改造
5.1 用本地缓存保存最近 20 次结果
一个只有骰子动画的页面,做完也就是个 Demo。要让这个案例真的能放进麻将项目里,至少要加上历史记录——用户摇完一次,想知道上一把掷了几点,这是麻将场景里的真实诉求。
// 保存历史记录 function saveHistory(point) { const key = 'mahjong_dice_history'; let history = wx.getStorageSync(key) || []; history.unshift({ point, time: Date.now() }); if (history.length > 20) history = history.slice(0, 20); // 最多保留 20 条 wx.setStorageSync(key, history); }这里我用wx.getStorageSync和wx.setStorageSync同步接口,而不是异步版本,原因很简单:历史记录写入的数据量极小(最多 20 条对象),同步接口不会造成可感知的阻塞,而且代码可读性更好。保留 20 条是经过权衡的:太少用户觉得历史太短,太多在 WXML 里渲染列表时会产生不必要的节点。每条记录存point和时间戳time,后续如果要做“最近 N 次点数分布”统计,时间戳是现成的数据源。
5.2 分享卡片与页面参数:让好友点开就是结果
麻将骰子经常出现在好友间的牌局场景,所以转发分享是刚需。微信小程序的分享配置在onShareAppMessage里,这个案例里要做的不是简单转发页面,而是把当前点数带进去,让接收者打开小程序时直接看到分享者的结果。
Page({ onShareAppMessage() { const point = this.data.currentPoint || '?'; return { title: `我掷了 ${point} 点,你来试试`, path: `/pages/index/index?fromShare=1&point=${point}`, imageUrl: '/assets/dice-share.png' // 自定义分享图,建议 5:4 比例 }; }, onLoad(options) { if (options.fromShare && options.point) { // 从分享卡片进入,直接展示分享者的点数 this.setData({ currentPoint: Number(options.point) }); } } });这里的path参数设计有一点需要提前规划:分享路径带了查询参数,意味着onLoad里要能处理这些参数。fromShare=1是一个标记位,用来区分正常进入和分享进入,避免统计页面数据时把分享回流算成新用户。imageUrl建议使用自定义分享图,默认截图经常会把骰子动画的中间帧截进去,丑到没人敢点。
5.3 组件化改造:把骰子从页面里拆出来
源码包里的骰子如果和页面逻辑写在同一个Page()里,后续想复用到其他项目就非常痛苦。我会在案例的第二阶段做组件化改造,把骰子抽成一个自定义组件components/mahjong-dice,页面只负责调用。
// components/mahjong-dice/mahjong-dice.js Component({ properties: { disabled: { type: Boolean, value: false } // 外部控制骰子是否可点击 }, data: { styleText: '', currentPoint: 0 }, methods: { roll() { // 骰子内部逻辑封装,外部通过 selectComponent 调用 if (this.data.disabled) return; // ...原有随机、动画、震动逻辑... this.triggerEvent('rollend', { point: this.data.currentPoint }); } } });组件化的关键不是把代码挪个位置,而是定义好对外接口。properties.disabled让父组件可以禁用骰子——比如“比大小”玩法里轮到对方回合时,你的骰子应该不可点。triggerEvent('rollend')是骰子和外界的通信方式,父组件监听这个事件就能拿到结果,骰子内部怎么转、怎么震,外部完全不用关心。
<!-- 页面里使用组件 --> <mahjong-dice disabled="{{myTurn === false}}" bind:rollend="onDiceRollEnd" />这个改造完成之后,整个骰子案例的代码结构就变成了:页面负责业务状态(谁先手、第几轮),组件负责骰子的全部表现层逻辑。源码包里如果只有单页实现,我建议你拿到手之后第一件事就是做这个拆解,它比多写两个功能更有价值。
6. 交付前最后一步:用真机调试验证动画流畅度与随机均匀性
骰子这种动画密集型小程序,开发者工具里的表现只能参考一半,最终一定要走真机预览。我每次做这类案例,固定做三项验证:动画帧率、随机均匀性、频繁操作稳定性。
第一项,动画帧率。打开真机调试,在开发者工具的“性能”面板里观察丢帧情况。骰子 900ms 动画期间,如果出现连续丢帧超过 3 帧,优先检查骰子容器上是不是有会触发重排的属性,比如动态修改width或height。正确的做法是只改transform,让动画走合成器线程。
第二项,随机均匀性。这个小经验我用了很久,值得写出来:写一个统计脚本,在onLoad里跑 1000 次随机,统计每个点数出现的次数。单独跑Math.random()的分布可能每一面在 150 到 185 之间波动,这完全正常。但如果加上“防连续重复”逻辑后,某一面出现次数跌到 120 次以下,说明重试逻辑过度修正了随机性,要调低MAX_RETRY。
// 临时脚本:验证随机均匀性 function verifyRandomness() { const counts = [0, 0, 0, 0, 0, 0]; let prev = -1; for (let i = 0; i < 1000; i++) { const idx = rollDiceIndex(prev); counts[idx]++; prev = idx; } console.log('1000次分布:', counts.join(', ')); }第三项,频繁操作稳定性。连续快速点击骰子 20 次,确认三点:骰子不会卡在动画中途;每次结果最终都会正确显示;isRolling状态锁不会被异常解除。我见过一个很隐蔽的问题:开发者用wx.vibrateShort的success回调去解锁状态,结果在部分机型上震动失败,状态锁永远不解开,骰子直接变成“死骰子”。所以状态解锁一律放在定时器回调里,不要挂在任何可能失败的异步接口上。
回顾我做过的骰子类案例,最深刻的一条经验是:动画时长永远比想象中更难调。900ms 是我的基准值,但大屏手机和小屏手机上的感知速度差异很大——屏幕越大,同样的角速度看起来越迟钝。我的习惯是 900ms 起步,真机预览觉得拖沓就减到 800ms,觉得仓促就加到 1000ms,每次只调 50ms,不要一次改 200ms 以上,改一大截的结果往往是过了头又调回来。
希望这篇笔记能帮你把这个源码案例吃透,做出来的骰子不只是“能转”,而是“转了就像真的在麻将桌上掷出去的”。
本文还有配套的精品资源,点击获取