ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端练手项目:水果老虎机游戏开发全解析

前端练手项目:水果老虎机游戏开发全解析 简介这是一份可直接运行的网页版水果老虎机小游戏源码面向想快速搭建休闲抽奖类互动页面的前端初学者与有二次开发需求的开发者。资源以原生JavaScript实现核心逻辑配合独立样式表与HTML入口无需额外框架即可在浏览器中打开体验适合用于课程作业、活动抽奖页或小游戏练手项目。压缩包共29个文件包含25张png图片、1张jpg、1个css样式文件、1个js脚本和1个html页面整体约409KB体积轻巧便于传输与部署其中图片素材覆盖苹果、橙子、西瓜、椰子、铃铛、星星、数字77及进度条等多种图标按大小图区分方便替换或扩展图案。目前已有496人学习下载代码结构清晰、注释友好读者可借此理解转盘随机、动画滚动与结果判定等实现思路并在此基础上调整赔率、替换素材或接入自定义逻辑具备较高的实用与二次修改价值。1. 一个 zip 包里的水果老虎机为什么值得前端拿来练手拿到「js水果老虎机游戏.zip」这个标题多数人第一反应是去找现成源码跑起来看效果。但真正做过这类项目的人会告诉你水果老虎机是前端小游戏里少有的「麻雀虽小五脏俱全」的题材它同时涉及 canvas 或 DOM 渲染、随机数生成、动画时序、状态机、音效调度、本地存储甚至还要处理「伪随机但看起来公平」这种玄学问题。一个能跑通的水果老虎机几乎把前端游戏开发的核心链路都串了一遍。这个方向适合两类人一是想从前端页面开发转向互动娱乐、H5 小游戏方向的工程师需要一个体量可控但结构完整的练手项目二是需要快速交付一个可嵌入网页的轻量游戏模块的开发者水果老虎机因为规则简单、视觉反馈强、代码量小是很合适的切入点。接下来我会按「拆包看结构 → 核心逻辑实现 → 动画与交互 → 避坑 → 进阶技巧」的顺序把这个 zip 背后的技术方案讲清楚让你拿到任何一份类似源码都能看懂、改得动、跑得稳。2. 拆开 zip 先看什么水果老虎机的文件结构与技术选型2.1 一个典型水果老虎机项目的目录长什么样拿到 zip 解压后不要急着打开 index.html 就跑。先花两分钟看目录结构这决定了你后面改代码的效率。一个结构清晰的水果老虎机项目通常长这样fruit-slot/ ├── index.html # 入口页面承载 canvas 或 DOM 容器 ├── css/ │ └── style.css # 布局、按钮样式、响应式适配 ├── js/ │ ├── main.js # 入口逻辑初始化游戏、绑定事件 │ ├── slot.js # 核心转盘逻辑、随机结果生成 │ ├── animation.js # 动画控制旋转、停止、中奖闪烁 │ ├── audio.js # 音效加载与播放调度 │ └── config.js # 配置水果种类、赔率、转速参数 ├── assets/ │ ├── images/ # 水果图标、背景、按钮切图 │ └── sounds/ # 旋转音、停止音、中奖音 └── lib/ # 第三方库常见的是 jQuery 或轻量动画库看到这个结构你心里要有个判断config.js 和 slot.js 是你要重点读的两个文件。前者定义了游戏的「数值骨架」后者决定了「结果怎么出」。很多人改老虎机改了半天没效果就是因为只动了 animation.js 的动画参数没碰 slot.js 里的随机逻辑。如果解压后发现所有代码都堆在一个 index.html 里script 标签里几千行那也不用慌。这种「单文件流」在早期教程类项目里很常见处理方式是先按功能把代码块用注释标记出来再决定要不要拆。拆不拆的标准很简单如果你只是跑起来看效果不用拆如果你要改赔率、加水果种类、接后端接口那就必须拆否则改一处崩三处。2.2 渲染方案选型DOM 还是 Canvas水果老虎机的渲染方式直接决定了你后面写动画的难度。常见做法有两种方案实现方式优点缺点适用场景DOM CSS每个水果是一个 div用 CSS transform 做旋转上手快、调试方便、文字清晰水果数量多时性能下降、动画时序不好精确控制3×3 或 3×5 的小格子Canvas 2D所有水果绘制在 canvas 上用 requestAnimationFrame 驱动性能好、动画帧精确、容易做粒子特效需要自己处理点击命中检测、文字渲染麻烦5×3 以上或需要复杂特效我一般会这样判断如果项目里水果格子不超过 15 个且没有复杂的粒子或拖尾效果DOM 方案完全够用而且改起来快。如果格子多、要做「滚动模糊」或「中奖爆炸」效果那就上 Canvas。你拿到 zip 后看 index.html 里是div classreel还是canvas就能立刻知道作者选的是哪条路。选型没有绝对优劣但有一个坑要提前说DOM 方案里如果用 CSS animation 做旋转停止时机很难和 JS 逻辑精确同步容易出现「视觉停了但逻辑还没停」的错位。解决办法是用 JS 控制 animation-play-state或者干脆用 transform transition 配合 transitionend 事件。Canvas 方案则要注意 devicePixelRatio否则在高分屏上水果图标会糊。2.3 核心配置文件的参数怎么读config.js 是整台老虎机的「黑匣子」面板。一个典型配置长这样// config.js const SLOT_CONFIG { reels: 3, // 转盘列数 rows: 3, // 每列可见行数 symbols: [ // 水果种类与权重 { id: apple, weight: 30, payout: 2 }, { id: orange, weight: 25, payout: 3 }, { id: banana, weight: 20, payout: 5 }, { id: grape, weight: 15, payout: 8 }, { id: melon, weight: 8, payout: 15 }, { id: seven, weight: 2, payout: 50 } ], spinDuration: 2000, // 总旋转时长毫秒 stopInterval: 300, // 每列停止间隔 initialCoins: 100, // 初始金币 betPerSpin: 10 // 每次下注消耗 };这里每个参数都值得说清楚。weight是权重不是概率。真实概率是weight / 所有weight之和。比如 apple 的概率是 30/100 30%seven 是 2/100 2%。payout是赔率表示中奖后返还的倍数。stopInterval控制三列依次停止的节奏设太小会感觉三列同时停设太大玩家会觉得拖沓300ms 左右是比较舒服的值。改配置时最容易翻车的地方是只改了payout没改weight导致期望收益变成负数或高得离谱。你可以用这个公式快速验算期望收益 Σ(weight_i / totalWeight × payout_i)。如果结果大于 1长期看玩家是赚的游戏经济系统会崩如果远小于 1玩家会很快输光失去兴趣。一般把期望收益控制在 0.85 到 0.95 之间比较合理。3. 转盘逻辑怎么写随机结果生成与中奖判定3.1 用权重表生成一次旋转结果水果老虎机的核心不是「随机」而是「按权重随机」。直接用 Math.random() 选水果每种概率一样游戏就没有层次感。正确做法是按权重区间映射// slot.js function pickSymbol(symbols) { // 计算总权重 const totalWeight symbols.reduce((sum, s) sum s.weight, 0); // 生成 [0, totalWeight) 之间的随机数 let random Math.random() * totalWeight; // 遍历权重区间落在哪个区间就返回哪个水果 for (const symbol of symbols) { if (random symbol.weight) { return symbol; } random - symbol.weight; } // 兜底理论上不会走到这里除非权重配置有误 return symbols[symbols.length - 1]; } // 生成一整屏结果3列 × 3行 function generateResult(config) { const result []; for (let col 0; col config.reels; col) { const column []; for (let row 0; row config.rows; row) { column.push(pickSymbol(config.symbols)); } result.push(column); } return result; }这段代码的逻辑说明pickSymbol把权重想象成一条线段总长度是 totalWeight每个水果占据一段。随机点落在线段哪个位置就选哪个水果。这种「累积权重法」是游戏开发里最常用的加权随机实现时间复杂度 O(n)对于水果种类不超过 20 个的场景完全够用。参数方面weight必须是正整数如果出现小数会导致区间计算出现浮点误差。如果你需要更精细的概率控制可以把所有权重乘以 10 或 100 变成整数。另外注意Math.random()返回的是 [0, 1)不包含 1所以random symbol.weight的边界是安全的。3.2 中奖判定从「三连」到「多线赔付」生成结果之后下一步是判断哪些组合中奖。最简单的规则是「中间行三连相同即中奖」但真实老虎机通常有多条赔付线。常见做法是把赔付线定义成坐标数组// 赔付线定义每条线是一组 [列, 行] 坐标 const PAYLINES [ [[0,1], [1,1], [2,1]], // 中间横线 [[0,0], [1,0], [2,0]], // 顶部横线 [[0,2], [1,2], [2,2]], // 底部横线 [[0,0], [1,1], [2,2]], // 左上到右下对角线 [[0,2], [1,1], [2,0]] // 左下到右上对角线 ]; function checkWin(result, paylines, config) { const wins []; for (let i 0; i paylines.length; i) { const line paylines[i]; const symbols line.map(([col, row]) result[col][row]); // 判断这条线上所有水果是否相同 const first symbols[0]; const isWin symbols.every(s s.id first.id); if (isWin) { wins.push({ lineIndex: i, symbol: first, payout: first.payout * config.betPerSpin }); } } return wins; }逻辑说明PAYLINES里每条线是一组坐标checkWin遍历所有线取出对应位置的水果判断是否全部相同。如果相同就按该水果的赔率乘以本局下注额计算奖金。这里用every而不是循环比较代码更简洁但要注意every在空数组上返回 true所以line不能为空。参数上betPerSpin是每局下注额赔率乘以它才是实际返还金币。很多新手会直接把payout当奖金加给玩家结果金币膨胀得飞快。另外如果多条线同时中奖要把所有wins的 payout 累加而不是只取第一条。这个细节在改代码时很容易漏掉导致玩家中了多条线却只拿到一份奖励。3.3 把结果映射到视觉转盘停止位置的计算逻辑层算出结果后还要让转盘「看起来」停在了正确的位置。DOM 方案里通常用一个长列表模拟滚动然后通过 transform 位移到目标位置// animation.js function spinTo(reelElement, targetSymbolId, symbolHeight, symbols) { // 找到目标水果在列表中的索引 const targetIndex symbols.findIndex(s s.id targetSymbolId); // 为了看起来是「转了很多圈才停」额外增加若干整圈 const extraLoops 5; const totalOffset (targetIndex extraLoops * symbols.length) * symbolHeight; // 应用过渡动画 reelElement.style.transition transform 2s cubic-bezier(0.25, 0.1, 0.25, 1); reelElement.style.transform translateY(-${totalOffset}px); }逻辑说明targetIndex是目标水果在符号列表里的位置extraLoops是额外转的圈数让动画看起来更自然。symbolHeight是每个水果格子的高度必须和 CSS 里定义的一致否则会停偏。cubic-bezier(0.25, 0.1, 0.25, 1)是缓出曲线模拟转盘减速停止的手感。参数上extraLoops设 3 到 8 之间比较合适太少显得没转够太多玩家等得着急。transition的时长要和config.spinDuration保持一致否则逻辑层已经判定结束了视觉还在转。还有一个隐藏坑如果symbolHeight是小数比如用了 rem 或百分比translateY的像素值会出现累积误差转几圈后位置就偏了。解决办法是用getBoundingClientRect()动态获取实际高度或者干脆用整数像素布局。4. 动画、音效与交互让老虎机「转起来」的细节4.1 三列依次停止的时序控制水果老虎机的手感很大程度取决于三列停止的节奏。如果三列同时停会显得很廉价如果间隔太大玩家会不耐烦。常见做法是用setTimeout或Promise链控制// main.js async function spin() { // 扣除下注 if (state.coins config.betPerSpin) { showToast(金币不足); return; } state.coins - config.betPerSpin; updateCoinDisplay(); // 生成结果 const result generateResult(config); // 三列依次启动旋转 const spinPromises []; for (let col 0; col config.reels; col) { const promise new Promise(resolve { setTimeout(() { spinTo(reelElements[col], result[col][1].id, symbolHeight, config.symbols); // 等待该列动画结束后再判定 setTimeout(resolve, config.spinDuration); }, col * config.stopInterval); }); spinPromises.push(promise); } // 等所有列停止 await Promise.all(spinPromises); // 判定中奖 const wins checkWin(result, PAYLINES, config); if (wins.length 0) { playSound(win); highlightPaylines(wins); const totalPayout wins.reduce((sum, w) sum w.payout, 0); state.coins totalPayout; updateCoinDisplay(); } else { playSound(lose); } }逻辑说明每列的启动时间用col * config.stopInterval错开这样第一列先转、第二列稍后、第三列最后。每列动画结束后 resolve最后用Promise.all等全部停止再判定中奖。这种写法的好处是时序清晰不会出现「动画还没停就弹中奖」的尴尬。参数上stopInterval控制列与列之间的启动间隔一般 200 到 500ms。注意这里用的是「启动间隔」而不是「停止间隔」因为每列动画时长相同启动错开就意味着停止也错开。如果你想让停止间隔更精确可以改成每列动画时长不同但那样代码会复杂一些。另外spin()函数要加锁防止玩家在旋转过程中连续点击导致状态错乱。常见做法是设一个state.isSpinning标志位旋转期间禁用按钮。4.2 音效调度别让中奖音效延迟半秒音效是老虎机沉浸感的关键但也是翻车重灾区。最常见的问题是中奖了音效却延迟半秒才响或者连续中奖时音效叠在一起变成噪音。根因通常是音频文件没预加载或者每次播放都 new Audio()。正确做法是在游戏初始化时预加载所有音效并复用 Audio 实例// audio.js const AudioManager { sounds: {}, enabled: true, init(soundMap) { // soundMap: { spin: assets/sounds/spin.mp3, win: ... } Object.keys(soundMap).forEach(key { const audio new Audio(soundMap[key]); audio.preload auto; audio.volume 0.6; this.sounds[key] audio; }); }, play(key) { if (!this.enabled || !this.sounds[key]) return; const audio this.sounds[key]; // 重置播放位置支持快速连续播放 audio.currentTime 0; audio.play().catch(() { // 浏览器可能因为自动播放策略拒绝静默处理 }); } };逻辑说明init阶段把所有音频实例创建好并设置preload auto浏览器会提前缓存。play时把currentTime重置为 0这样即使上一次还没播完也能从头开始。catch是必须的因为现代浏览器在没有用户交互前会拒绝play()不处理会报 Uncaught Promise 错误。参数上volume建议设 0.5 到 0.7太大会破音太小听不见。如果音效文件是 mp3注意采样率不要太高128kbps 足够否则加载慢。还有一个细节旋转音通常是循环播放的要在旋转开始时play()停止时pause()并重置currentTime。如果直接用play()不循环转两秒音效就没了后面干转没声音玩家会以为卡了。4.3 金币与状态管理别把状态散落在全局很多老虎机源码把金币数、旋转状态、中奖记录散落在各个全局变量里改一处忘一处。我一般会用一个集中的 state 对象管理// state.js const state { coins: 100, isSpinning: false, lastResult: null, totalSpins: 0, totalWins: 0, canSpin() { return !this.isSpinning this.coins SLOT_CONFIG.betPerSpin; }, addCoins(amount) { this.coins amount; this.persist(); }, persist() { // 存到 localStorage刷新页面不丢进度 localStorage.setItem(slot_state, JSON.stringify({ coins: this.coins, totalSpins: this.totalSpins, totalWins: this.totalWins })); }, restore() { const saved localStorage.getItem(slot_state); if (saved) { const data JSON.parse(saved); this.coins data.coins ?? SLOT_CONFIG.initialCoins; this.totalSpins data.totalSpins ?? 0; this.totalWins data.totalWins ?? 0; } } };逻辑说明canSpin把「是否在旋转」和「金币是否足够」两个判断收在一起调用方不用关心细节。persist和restore用 localStorage 做持久化刷新页面金币不归零。??是空值合并运算符比||更安全因为金币为 0 时||会错误地回退到初始值。参数上localStorage 的 key 要加前缀避免和其他项目冲突。如果游戏要嵌入多个页面考虑用 sessionStorage 或干脆不持久化。另外JSON.parse要包 try-catch因为用户可能手动改了 localStorage 导致格式错误不处理会白屏。5. 避坑与排查水果老虎机开发中最容易翻车的 5 个点5.1 现象转盘停了但中奖判定没触发原因动画的transitionend事件没绑定或者setTimeout的时长和 CSS transition 时长不一致。DOM 方案里如果 CSS 写的是transition: transform 2sJS 里setTimeout写的是 1800ms就会出现视觉还在转、逻辑已经判完的情况或者反过来。解决统一时长来源。把动画时长写在 config 里CSS 用 JS 动态设置style.transitionDurationsetTimeout也用同一个值。更稳妥的做法是监听transitionend事件而不是靠setTimeout猜时间。注意transitionend会为每个属性触发一次要判断event.propertyName transform。5.2 现象连续点击旋转按钮金币扣了但只转一次原因没有做旋转状态锁。玩家手快连点spin()被调用多次每次都扣金币但后面的调用因为动画冲突被覆盖看起来只转了一次。解决在spin()开头判断state.isSpinning为 true 直接 return。进入旋转后立刻设为 truePromise.all结束后设回 false。同时把按钮的disabled属性也同步设置双保险。注意disabled按钮的点击事件不会触发但键盘回车可能触发所以 JS 层的判断不能省。5.3 现象中奖概率和配置对不上seven 出现得太频繁原因权重区间计算有误常见的是把random - symbol.weight写成了random - totalWeight或者权重数组没有按预期顺序排列。另一个隐蔽原因是Math.random()被其他代码覆盖了比如某些库会改写全局 Math。解决写一个单元测试跑 10000 次pickSymbol统计每个水果的出现次数和理论概率对比。偏差超过 5% 就说明有问题。测试代码不用引入框架直接 console.log 就行。另外检查有没有Math.random function() {...}这种覆盖有的话立刻删掉。5.4 现象音效在手机上不响或者响一次后就不响了原因移动端浏览器要求音频必须在用户手势click、touch中首次播放否则会被拦截。另外iOS 上 Audio 实例播放一次后如果不重置currentTime再次play()可能无效。解决在第一次点击「开始」按钮时播放一个极短的静音音频解锁音频上下文。每次播放前设置currentTime 0。如果用的是 Web Audio API要在用户手势中调用audioContext.resume()。测试时一定要用真机桌面浏览器的模拟器不会触发这些限制。5.5 现象刷新页面后金币归零或者金币变成 NaN原因localStorage 里存的数据格式不对或者JSON.parse失败没有兜底。常见的是存的时候是数字读的时候被当成字符串coins payout变成字符串拼接再参与运算就成 NaN。解决restore时用Number()强制转换并判断isNaN。persist时确保存的是数字类型。另外如果游戏逻辑改了金币字段名旧数据会读不到要在restore里做字段兼容比如data.coins ?? data.money ?? initialCoins。上线前清一次 localStorage 测试全新用户流程。6. 进阶技巧把水果老虎机改成可配置的「游戏引擎」6.1 用配置驱动整个游戏而不是硬编码如果你只做一个老虎机硬编码没问题。但如果你要交付给不同客户或者想快速换皮成「水果」「宝石」「生肖」多个版本就需要把游戏逻辑和具体符号解耦。核心思路是所有和「水果」相关的信息都从 config 读代码里不出现apple这种字符串。// 换皮只需要改 config不动逻辑代码 const THEME_FRUIT { name: 水果, symbols: [ { id: apple, weight: 30, payout: 2, icon: assets/fruit/apple.png }, // ... ], background: assets/fruit/bg.jpg, spinSound: assets/fruit/spin.mp3 }; const THEME_GEM { name: 宝石, symbols: [ { id: ruby, weight: 30, payout: 2, icon: assets/gem/ruby.png }, // ... ], background: assets/gem/bg.jpg, spinSound: assets/gem/spin.mp3 }; function loadTheme(theme) { config.symbols theme.symbols; document.body.style.backgroundImage url(${theme.background}); AudioManager.init({ spin: theme.spinSound, win: theme.winSound }); renderReels(); // 重新渲染转盘 }逻辑说明loadTheme把主题相关的资源一次性替换逻辑层完全不用改。renderReels根据新的symbols重新生成 DOM 或重绘 canvas。这种设计下加一个新主题只需要新增一个 config 对象和一套素材代码零改动。参数上symbols数组的长度可以不同但要注意PAYLINES的坐标是基于reels和rows的如果新主题改了行列数PAYLINES也要跟着改。建议把PAYLINES也放进 theme 配置里或者写一个根据reels和rows自动生成赔付线的函数。6.2 用「结果预生成 动画回放」保证公平性可验证如果你要把老虎机接入真实经济系统比如积分、虚拟币就需要让结果可验证。常见做法是服务端生成结果和随机种子客户端只负责动画回放。客户端拿到结果后用种子在本地重算一遍确认和服务端一致再播放动画。// 客户端验证服务端结果 function verifyResult(serverResult, seed, config) { // 用种子初始化伪随机数生成器 const rng seededRandom(seed); const localResult []; for (let col 0; col config.reels; col) { const column []; for (let row 0; row config.rows; row) { column.push(pickSymbolWithRng(config.symbols, rng)); } localResult.push(column); } // 对比本地重算结果和服务端结果 return JSON.stringify(localResult) JSON.stringify(serverResult); }逻辑说明seededRandom是一个确定性随机函数同样的种子永远产出同样的序列。服务端生成结果时记录种子客户端用同一种子重算如果一致就说明服务端没有作弊。pickSymbolWithRng和之前的pickSymbol逻辑一样只是随机数来源换成了rng()。参数上种子的长度建议 32 位以上避免被穷举。seededRandom可以用简单的线性同余法实现也可以用现成的库。注意客户端和服务端的pickSymbol实现必须完全一致包括权重顺序和边界判断否则验证永远失败。这个方案适合对公平性有要求的场景如果只是单机娱乐不需要这么复杂。6.3 性能优化水果多了怎么不卡当转盘从 3×3 扩展到 5×3 甚至 5×5DOM 方案会开始卡顿。优化手段有几个一是用will-change: transform提示浏览器提前合成图层二是把旋转中的水果用visibility: hidden隐藏只保留容器动画三是用requestAnimationFrame替代 CSS transition手动控制每一帧的位置。我一般会先测一下打开 Chrome Performance 面板录一次旋转看帧率。如果掉到 30fps 以下就上 Canvas。Canvas 方案里把水果图标预渲染到离屏 canvas每帧只做drawImage比每次都重新绘制路径快得多。另外requestAnimationFrame里不要做 DOM 查询或创建对象所有变量提前缓存。最后说一个我自己的习惯每次改完老虎机的核心逻辑我都会用console.table打印 100 次旋转的结果分布肉眼扫一眼有没有异常。这个习惯帮我抓到过好几次权重配置写错的问题。水果老虎机看着简单但细节多能把它写稳的人做其他小游戏也不会差。希望帮到你。本文还有配套的精品资源点击获取
返回列表