尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

JavaScript老虎机抽奖动画实现:CSS动画、概率算法与性能优化

JavaScript老虎机抽奖动画实现:CSS动画、概率算法与性能优化 1. 项目概述从零到一打造一个会“转”的老虎机最近在整理一些前端小项目发现“老虎机抽奖”这个需求还挺有意思的。它不像普通的点击抽奖而是通过模拟机械转盘的滚动、减速和停止来营造一种紧张刺激的视觉和交互体验。无论是用在营销活动、游戏内抽奖还是个人网站的趣味互动模块一个制作精良的老虎机都能瞬间提升页面的趣味性和专业感。这个项目的核心就是用纯JavaScript配合HTML和CSS来模拟老虎机的完整行为逻辑。听起来简单但要做好里面有不少门道如何让三个“滚轮”独立、流畅地滚动如何模拟真实的物理减速和随机停止效果如何设计一个既公平又有趣的抽奖算法以及如何让整个交互过程在视觉上足够炫酷在代码上又足够清晰、易于维护我自己在实现过程中也踩过不少坑比如直接修改DOM元素的style属性导致动画卡顿或者随机算法过于简单让用户觉得“有黑幕”。接下来我就把自己从构思到实现的完整过程包括核心思路、代码细节、性能优化点以及那些“教科书上不会写”的避坑经验系统地分享出来。无论你是想快速实现一个功能还是想深入学习JavaScript的动画、异步编程和状态管理这个项目都是一个不错的练手选择。2. 整体设计与核心思路拆解在动手写代码之前我们先得把老虎机的“骨架”和“灵魂”想清楚。一个老虎机页面从用户视角看就是点击按钮三个或多个窗口里的图案开始飞速滚动然后依次减速、停止最终显示结果。但从开发者视角我们需要拆解成数据、视图、逻辑和控制四个层面。2.1 核心组件与数据模型设计首先我们需要定义老虎机里“转”的是什么。通常是一系列图案比如水果、、、数字7、BAR或者星星。我们可以用一个数组来代表一个完整的“滚轮”或“卷轴”上所有可能的图案序列。// 定义一个滚轮的符号序列长度要足够长以保证滚动时有足够多的内容可显示 const reelSymbols [, , , , ⭐, 7, BAR, ]; // 为了滚动效果更自然我们通常会把这个序列复制多份连接起来形成一个“虚拟长卷轴” const virtualReel [...reelSymbols, ...reelSymbols, ...reelSymbols];为什么需要虚拟长卷轴想象一下真实的物理滚轮它是一个闭环。在网页上为了模拟无限滚动或者至少是足够长的滚动的视觉效果我们需要一个足够长的数据源。当视觉上的一个“窗口”在这个长序列上移动时我们只显示窗口范围内的几个符号。这样无论滚动多久我们都有数据可以显示。接下来是状态管理。老虎机在任意时刻都处于几种明确的状态之一idle待机等待开始、spinning滚动中、stopping减速停止中。清晰的状态划分是控制复杂动画流程的关键。class SlotMachine { constructor() { this.state idle; // idle | spinning | stopping this.reels []; // 存储每个滚轮当前的数据和状态 this.results []; // 存储最终停止时每个窗口显示的结果 } }2.2 动画与视觉呈现方案选型如何让图案“动”起来这里有几种主流方案CSS Transition/Animation JavaScript 控制这是最直观、性能也相对较好的方案。我们将每个滚轮的图案列表垂直排列通过改变这个列表容器的transform: translateY属性来实现滚动。通过CSS定义transition-timing-function: cubic-bezier(...)可以非常精细地控制减速曲线模拟物理效果。JavaScript负责在合适的时机触发CSS类的添加与移除从而启动和停止动画。纯JavaScript动画requestAnimationFrame通过requestAnimationFrame在每一帧手动计算并更新元素的位置。这种方式控制力最强可以实现任何复杂的运动曲线但代码量也更大对性能优化要求更高。Canvas/WebGL绘制如果追求极致的视觉效果和复杂的粒子特效如光影、模糊这是终极方案。但实现成本最高不适合快速开发。对于我们的老虎机项目方案一CSS Transition JS控制在实现难度、性能和效果之间取得了最佳平衡。它充分利用了浏览器的硬件加速动画流畅且代码易于理解和维护。我们将采用这个方案。2.3 抽奖逻辑与随机性设计这是老虎机的“灵魂”也是最容易让用户产生疑虑的地方。一个糟糕的随机算法会让用户觉得程序被操纵了。核心原则结果可控过程随机。这意味着最终显示给用户的三个图案组合即中奖结果是预先根据中奖概率计算好的但每个滚轮在停止前滚动了多少距离、中间经历了哪些图案是随机的。这样既能保证庄家运营方的胜率可控又能让用户感受到过程的不可预测性。一种简单的实现方法是预先定义好所有可能的中奖组合及其概率例如“777”是头奖概率0.1%“三个相同水果”是二等奖概率1%等。在用户点击抽奖时先根据概率随机决定本次的结果是什么例如这次抽中了“”。然后让每个滚轮滚动起来。在停止时通过计算和控制确保它们最终停在预设的结果图案上。而滚动过程中经过的图案则是从虚拟长卷轴中随机截取的一段。这样从用户角度看滚轮飞速转动后随机停止显示了一个结果。从程序角度看我们只是用一场华丽的动画展示了一个早已确定的结果。这是一种非常经典且实用的设计模式。3. 核心细节解析与实操要点理解了整体框架我们来深入每个环节的细节。这里面的每一个选择都直接影响到最终效果的好坏和代码的健壮性。3.1 DOM结构与CSS布局的精巧设计HTML结构要清晰便于CSS定位和JavaScript操作。div classslot-machine div classreels-container !-- 滚轮1 -- div classreel idreel1 div classreel-strip !-- 这里将由JS动态插入很多个 .symbol -- div classsymbol/div div classsymbol/div !-- ...更多符号 -- /div div classreel-window/div !-- 这个遮罩层用来制造“窗口”效果 -- /div !-- 滚轮2、滚轮3结构相同 -- /div button classspin-button开始旋转/button div classresult-display/div /div关键CSS技巧.reel设置为position: relative; overflow: hidden; height: 100px;。它定义了每个滚轮的视窗大小并隐藏超出部分。.reel-strip包含所有符号的容器。它的高度会远远大于.reel的高度。我们通过改变它的transform: translateY来制造滚动效果。初始位置可以设为负值让第一个符号显示在窗口中央。.reel-window一个用绝对定位覆盖在.reel上的元素可以用CSS边框、阴影或半透明遮罩来强化“窗口”的视觉效果与滚动的背景形成对比。.symbol每个图案单元。需要设置固定的高度如100px并完美居中确保在滚动时每个图案都能对齐窗口。注意性能陷阱。如果通过JS动态插入几百个.symboldivDOM节点数量会暴增影响性能。一个优化技巧是只创建略多于窗口可见数量的符号比如窗口能显示3个我们创建10个在滚动时通过监听transitionend事件动态更新离开视口的符号内容实现“无限滚动”的假象。这类似于前端列表虚拟化的思想。3.2 动画曲线与物理减速模拟让滚动看起来真实的关键在于减速过程。我们使用CSS的transition属性并精心设计cubic-bezier缓动函数。.reel-strip { transition: transform 0s linear; /* 初始状态无动画 */ } .reel-strip.spinning { transition: transform 3s cubic-bezier(0.1, 0.7, 0.1, 1); /* 滚动和减速 */ }transition: transform 3s表示对transform属性应用3秒的过渡动画。cubic-bezier(0.1, 0.7, 0.1, 1)是这个项目的精髓。这个贝塞尔曲线定义了动画的速度变化。起始点(0.1, 0.7)动画一开始就有一个较高的速度y值大模拟猛力拉下拉杆后滚轮高速启动。结束点(0.1, 1)在动画时间的90%左右速度依然保持较快最后10%的时间迅速减速到0。这模拟了滚轮因摩擦力逐渐停止的过程而不是匀速然后突然卡住。你可以通过 cubic-bezier.com 这样的工具可视化调整这四个参数直到找到最符合你心中“老虎机手感”的曲线。通常结束点的X值小于1如0.9能制造出更明显的“回弹”或“精准定位”的停顿感。3.3 概率算法与公平性实现前面提到了“结果可控过程随机”。我们来具体实现这个概率系统。class PrizePool { constructor() { // 定义奖项图案组合 - 概率0-1之间 this.prizes [ { combination: [7, 7, 7], probability: 0.001, name: 头奖 }, // 0.1% { combination: [BAR, BAR, BAR], probability: 0.005, name: 二等奖 }, // 0.5% { combination: [, , ], probability: 0.01, name: 三等奖 }, // 1% // ... 更多奖项 { combination: null, probability: 0.984, name: 未中奖 } // 剩余概率为“未中奖” ]; // 验证概率总和为1 const totalProb this.prizes.reduce((sum, prize) sum prize.probability, 0); if (Math.abs(totalProb - 1) 0.0001) { console.error(概率总和必须为1); } } draw() { const rand Math.random(); // 生成一个0-1的随机数 let cumulativeProbability 0; for (const prize of this.prizes) { cumulativeProbability prize.probability; if (rand cumulativeProbability) { return prize; // 返回抽中的奖项对象 } } // 理论上不会走到这里兜底返回未中奖 return this.prizes.find(p p.name 未中奖); } }在spin函数中我们首先调用prizePool.draw()决定本次结果。如果抽中了具体组合比如[, , ]那么三个滚轮的最终停止位置就必须分别对齐到樱桃图案。如果抽中的是“未中奖”我们可以随机生成三个不构成大奖组合的图案作为结果。实操心得让随机更“人性化”。完全数学意义上的随机有时会让用户觉得“不真实”比如连续十次不中奖。可以引入一些软性调节比如“伪随机”Pseudo Random。记录用户连续未中奖的次数当次数超过某个阈值时临时微调中奖概率让用户下一次中个小奖提升正面反馈。这在游戏设计中很常见但需要谨慎处理并明确告知用户如果法律有要求。4. 完整实现步骤与代码剖析现在我们把所有部分组合起来看看一个完整的老虎机类如何构建和运作。4.1 初始化构建数据与视图class SlotMachine { constructor(containerId, symbols, prizePool) { this.container document.getElementById(containerId); this.symbols symbols; // 所有可用符号数组 this.prizePool prizePool; this.state idle; this.reelCount 3; this.reels []; this.currentPrize null; this.initDOM(); this.initReelsData(); } initDOM() { // 清空容器并构建基础HTML结构 this.container.innerHTML div classreels-container styledisplay: flex; gap: 20px; margin-bottom: 30px;/div button classspin-btn stylepadding: 15px 30px; font-size: 1.2em; 开始旋转/button div classresult stylemargin-top: 20px; font-size: 1.5em; min-height: 2em;/div ; this.reelsContainer this.container.querySelector(.reels-container); this.spinBtn this.container.querySelector(.spin-btn); this.resultDisplay this.container.querySelector(.result); // 创建滚轮DOM元素 for (let i 0; i this.reelCount; i) { const reelEl document.createElement(div); reelEl.className reel; reelEl.style.cssText position: relative; width: 100px; height: 150px; overflow: hidden; border: 3px solid #333; border-radius: 10px; background: #f8f8f8;; const stripEl document.createElement(div); stripEl.className reel-strip; stripEl.style.cssText position: absolute; width: 100%; transition: transform 0s linear;; // 初始无动画 reelEl.stripEl stripEl; // 将strip元素挂载到reel元素上方便后续访问 const windowEl document.createElement(div); windowEl.className reel-window; windowEl.style.cssText position: absolute; top: 0; left: 0; width: 100%; height: 100%; box-shadow: inset 0 0 15px rgba(0,0,0,0.5); pointer-events: none;; reelEl.appendChild(stripEl); reelEl.appendChild(windowEl); this.reelsContainer.appendChild(reelEl); this.reels.push({ element: reelEl, strip: stripEl, currentPosition: 0, // 当前strip的translateY值 targetPosition: 0, // 目标停止位置 isSpinning: false }); } // 绑定按钮事件 this.spinBtn.addEventListener(click, () this.startSpin()); } initReelsData() { // 为每个滚轮的strip填充符号优化版只填充足够数量的符号 const symbolsPerReel 20; // 每个strip初始创建20个符号远多于可视区域 this.reels.forEach(reel { reel.strip.innerHTML ; // 清空 for (let i 0; i symbolsPerReel; i) { const symbolEl document.createElement(div); symbolEl.className symbol; symbolEl.style.cssText height: 150px; display: flex; align-items: center; justify-content: center; font-size: 3em;; // 初始随机分配一个符号 const randomSymbol this.symbols[Math.floor(Math.random() * this.symbols.length)]; symbolEl.textContent randomSymbol; symbolEl.dataset.symbol randomSymbol; // 用data-*属性记录符号类型 reel.strip.appendChild(symbolEl); } // 初始化strip位置让第一个符号位于窗口中央 reel.strip.style.transform translateY(0px); }); } }4.2 旋转控制启动、减速与停止这是最核心的动画控制逻辑涉及异步编程和状态管理。async startSpin() { // 1. 防止重复点击 if (this.state ! idle) return; this.state spinning; this.spinBtn.disabled true; this.resultDisplay.textContent 旋转中...; // 2. 抽取本次结果 this.currentPrize this.prizePool.draw(); console.log(抽中结果, this.currentPrize); // 3. 为每个滚轮计算最终要显示的符号和停止位置 const finalSymbols this.calculateFinalSymbols(this.currentPrize); this.setReelTargets(finalSymbols); // 4. 启动所有滚轮快速滚动阶段 const spinPromises this.reels.map((reel, index) this.spinSingleReel(reel, index)); // 等待所有滚轮完成快速滚动阶段实际是等待一个最短时间然后进入减速 await Promise.all(spinPromises); // 5. 依次停止滚轮增加戏剧效果 this.state stopping; for (let i 0; i this.reels.length; i) { await this.stopSingleReel(this.reels[i], i); // 等待第一个停止后再停止第二个 // 可以在这里加入音效或震动反馈 } // 6. 全部停止展示结果 this.state idle; this.spinBtn.disabled false; this.displayResult(); } calculateFinalSymbols(prize) { if (prize.combination) { // 如果抽中了具体组合直接返回该组合 return prize.combination; } else { // 如果是“未中奖”随机生成三个不构成大奖的符号 // 这里简化处理完全随机有可能随机到大奖但概率很低符合“未中奖”设定 return Array.from({ length: this.reelCount }, () this.symbols[Math.floor(Math.random() * this.symbols.length)] ); } } setReelTargets(finalSymbols) { this.reels.forEach((reel, index) { const targetSymbol finalSymbols[index]; // 找到当前strip中第一个出现的targetSymbol的位置索引 const symbolElements reel.strip.querySelectorAll(.symbol); let targetIndex -1; for (let i 0; i symbolElements.length; i) { if (symbolElements[i].dataset.symbol targetSymbol) { targetIndex i; break; } } // 如果没找到就选第一个理论上不会发生因为strip里有所有符号 if (targetIndex -1) targetIndex 0; // 计算需要滚动到的translateY值。 // 每个symbol高150px我们要让第targetIndex个symbol滚动到窗口中央。 // 初始时第0个symbol在窗口中央translateY0。 // 所以第targetIndex个symbol到中央需要向上移动 targetIndex * 150px。 // 因为translateY向上为负所以是 -targetIndex * 150。 // 但我们希望它停止时targetSymbol在窗口中央所以目标位置就是 -targetIndex * 150。 reel.targetPosition -targetIndex * 150; // 但为了制造滚动了很多圈的假象我们会在目标位置前加上很多圈的距离。 // 一圈的距离是 strip的总高度symbol数量 * 150。 const totalHeight symbolElements.length * 150; const extraSpins 5 Math.floor(Math.random() * 3); // 随机多滚5-7圈 reel.targetPosition - extraSpins * totalHeight; }); } spinSingleReel(reel, index) { return new Promise((resolve) { reel.isSpinning true; // 先快速滚动一段随机距离制造启动效果 const quickSpinDistance - (2000 Math.random() * 1000); // 快速向下滚动2000-3000px reel.strip.style.transition transform 0.5s linear; // 快速滚动线性运动 reel.strip.style.transform translateY(${quickSpinDistance}px); // 快速滚动结束后切换到慢速减速动画滚向最终目标 setTimeout(() { // 计算从当前快速滚动结束位置到目标位置的动画 // 这里我们直接设置到目标位置并应用一个长时间的、带缓动函数的过渡 const spinDuration 2 index * 0.3; // 每个滚轮停止时间依次递增更有层次感 reel.strip.style.transition transform ${spinDuration}s cubic-bezier(0.1, 0.7, 0.1, 1); reel.strip.style.transform translateY(${reel.targetPosition}px); // 监听动画结束事件 const onTransitionEnd () { reel.strip.removeEventListener(transitionend, onTransitionEnd); reel.isSpinning false; resolve(); }; reel.strip.addEventListener(transitionend, onTransitionEnd); }, 500); // 快速滚动持续0.5秒 }); } stopSingleReel(reel, index) { // 这个方法在startSpin中顺序调用实际上每个reel的停止动画已经在spinSingleReel中开始了。 // 这里我们只需要返回一个Promise等待该reel的动画真正结束。 // 由于我们在spinSingleReel中已经监听了transitionend并resolve了Promise // 所以实际上spinSingleReel返回的Promise完成就代表这个reel停止了。 // 但为了增加“依次停止”的等待我们这里可以简单延时。 return new Promise(resolve { // 模拟一个短暂的延迟让停止有先后顺序 setTimeout(resolve, 300 * index); }); } displayResult() { // 从当前停止的位置获取每个窗口中央显示的符号 const finalSymbols this.reels.map(reel { // 这是一个简化方法实际项目中需要根据strip的translateY值精确计算当前窗口中央对应哪个symbol。 // 这里我们假设动画精确停止直接使用我们预设的目标符号。 // 更严谨的做法是计算translateY然后除以符号高度取模再映射回symbols数组。 const stripEl reel.strip; const computedStyle window.getComputedStyle(stripEl); const matrix new DOMMatrixReadOnly(computedStyle.transform); const currentY matrix.m42; // 获取translateY值 const symbolHeight 150; // 找到当前位于窗口中央即translateY的偏移补偿后为0的位置的符号索引 // 因为初始0位置是第一个符号在中央所以 (0 - currentY) / symbolHeight 就是中央位置的索引 const centerIndex Math.round(-currentY / symbolHeight); const symbolElements stripEl.querySelectorAll(.symbol); const actualIndex ((centerIndex % symbolElements.length) symbolElements.length) % symbolElements.length; // 处理负数取模 return symbolElements[actualIndex].dataset.symbol; }); this.resultDisplay.textContent 结果${finalSymbols.join( | )}; if (this.currentPrize this.currentPrize.name ! 未中奖) { this.resultDisplay.innerHTML brstrong 恭喜${this.currentPrize.name}/strong; } }4.3 最终整合与启动最后我们创建实例并启动老虎机。// 定义符号和奖池 const mySymbols [, , , , ⭐, 7, BAR, ]; const myPrizePool new PrizePool(); // 使用前面定义的PrizePool类 // 页面加载完成后初始化 document.addEventListener(DOMContentLoaded, () { const mySlotMachine new SlotMachine(slotMachineContainer, mySymbols, myPrizePool); }); // HTML中需要有一个id为“slotMachineContainer”的容器 // div idslotMachineContainer/div5. 常见问题、优化与避坑指南在实际开发中你肯定会遇到一些预期之外的情况。下面是我在多次实现和优化过程中总结出来的典型问题和解决方案。5.1 动画卡顿与性能优化问题当滚轮快速滚动特别是DOM元素很多时动画可能会掉帧、卡顿。排查与解决启用GPU加速确保对transform和opacity属性做动画浏览器会尝试使用GPU合成层性能远优于改变top/left或margin。正确transform: translateY(...)避免top: ...px减少DOM数量这是最关键的一点。不要真的为无限滚动创建成千上万个.symbol节点。采用“虚拟列表”思想。优化方案只创建约20-30个符号节点。在transitionend或animationiteration事件中判断哪些节点已经完全移出视口将它们移动到 strip 的末尾并更新其符号内容。这样用户看到的是无限滚动的假象但DOM节点数恒定。// 在strip的transitionend事件中 reel.strip.addEventListener(transitionend, (e) { if (e.propertyName transform) { this.recycleSymbols(reel); } }); recycleSymbols(reel) { const stripEl reel.strip; const symbolEls Array.from(stripEl.querySelectorAll(.symbol)); const reelRect reel.element.getBoundingClientRect(); const stripRect stripEl.getBoundingClientRect(); symbolEls.forEach(symbolEl { const symbolRect symbolEl.getBoundingClientRect(); // 如果符号已经完全滚动到视口上方即底部位置 容器顶部位置 if (symbolRect.bottom reelRect.top) { // 将这个符号移动到strip的末尾 stripEl.appendChild(symbolEl); // 并给它赋予一个新的随机符号继续参与滚动 const newSymbol this.symbols[Math.floor(Math.random() * this.symbols.length)]; symbolEl.textContent newSymbol; symbolEl.dataset.symbol newSymbol; } }); }使用will-change谨慎对于持续动画的元素可以添加will-change: transform;提示浏览器提前优化。但不要滥用过多使用会消耗内存。5.2 随机性被质疑与算法透明度问题用户觉得“中奖率太低”或“程序有鬼”。解决策略客户端与服务器端验证对于涉及真实奖励的线上活动抽奖的核心随机算法必须在服务器端进行。客户端只负责展示动画。客户端在旋转前向服务器请求本次抽奖的“结果令牌”旋转结束后再用这个令牌向服务器验证并领取奖励。这样杜绝了客户端篡改。使用可验证的随机数即使是前端演示也可以使用更“严肃”的随机数生成器如window.crypto.getRandomValues()并在控制台或结果页面展示本次抽奖的随机种子Seed增加透明度。明确概率公示在页面醒目位置公示各奖项的中奖概率符合相关法律法规要求。5.3 视觉对齐与浏览器兼容性问题在不同浏览器或不同缩放比例下符号对不齐停止时图案显示不完整或偏移。解决使用box-sizing: border-box确保所有元素的宽度、高度计算方式一致避免因边框和内边距导致错位。精确的像素计算在计算translateY时确保符号高度是整数像素并且滚轮容器的高度是符号高度的整数倍。避免出现半像素渲染导致的模糊或错位。.reel { height: 150px; } /* 符号高度也是150px */ .symbol { height: 150px; line-height: 150px; } /* 确保垂直居中 */测试transform的兼容性虽然现代浏览器支持很好但如果需要支持非常老的浏览器注意transition和transform的兼容性前缀。可以使用 Autoprefixer 等工具自动处理。5.4 交互体验细节打磨按钮防重复点击在spinning和stopping状态时务必禁用开始按钮并在状态恢复后启用。防止动画逻辑混乱。音效与震动反馈在滚轮开始、停止、中奖时添加适当的音效能极大提升沉浸感。对于移动端可以配合navigator.vibrate()提供短震动反馈需用户授权。网络加载状态如果符号图片是网络资源确保在图片加载完成后再开始游戏或者使用字体图标如FontAwesome来保证即时显示。响应式设计通过CSS媒体查询让老虎机容器和符号大小能适应不同屏幕宽度。实现一个老虎机抽奖页面远不止是让几个div动起来那么简单。它涉及前端动画、状态机、概率算法、性能优化和用户体验等多个方面的知识。从最初的原型到最终流畅、可信、有趣的版本是一个不断迭代和打磨的过程。希望这篇详细的拆解能帮你避开我踩过的那些坑更快地做出让自己和用户都满意的作品。最重要的是在动手的过程中去理解每一个技术选择背后的“为什么”这才是成长的关键。
返回列表