民启特种作业 · 安阳新乡特种作业考证咨询

首页 安阳报考专题 新乡报考专题 报名流程 考试批次 低压电工作业 熔化焊接与热切割 高处安装维护拆除 叉车司机 塔吊司机 新闻资讯 证书查询核验 证书复审 企业团报 在线预约 关于我们 联系我们
电话咨询 18236992212
首页新闻资讯文章详情

资讯详情

考试通知、政策法规、备考经验、行业动态,为安阳、新乡特种作业考证人员提供信息参考。

首页新闻资讯从零实现Simon游戏:状态机与音画同步实战

从零实现Simon游戏:状态机与音画同步实战

2026/10/12 11:24:14 民启特种作业 安阳 · 新乡考证资讯
从零实现Simon游戏:状态机与音画同步实战 简介这份资源是一套用原生前端技术实现经典Simon记忆游戏的完整源码面向刚接触HTML、CSS与JavaScript、希望通过小项目巩固基础并理解游戏逻辑的编程学习者。包内共3个文件包含1个html、1个css和1个js压缩包约4KB分别承担页面结构搭建、按钮与序列显示区样式美化、随机序列生成与用户输入判定等核心逻辑代码中附有详细注释便于逐行理解事件监听、状态管理与音效触发等实现思路。目前已有614人学习下载。借助这份源码读者可以快速跑通一个可交互的记忆挑战小游戏掌握DOM操作、数组比对与游戏状态维护的写法并在此基础上自行扩展计分系统、难度等级或更多交互效果适合作为前端入门阶段的练手项目与逻辑训练素材。1. Simon 游戏的制作从状态机到音画同步一个能跑通的最小闭环很多人第一次听到 Simon 游戏以为是个拼手速的记忆小游戏随手写个数组 push 几个随机数就完事了。真动手才发现坑全在细节里序列播放时按钮高亮和音效对不上、玩家连击到第 8 关突然判定失效、快速点击时状态机直接乱掉。Simon 游戏也叫“西蒙说”的核心机制其实很朴素——系统按顺序点亮四个色块并播放对应音调玩家按相同顺序复现每过一关序列加一位。但要把「制作」这件事做扎实你需要同时处理状态管理、定时调度、音频触发和输入去抖四件事。这篇笔记面向想用前端技术栈从零实现一个可玩、可扩展 Simon 游戏的开发者不管你是刚学完 DOM 操作的新手还是想找一个干净的状态机练手的老手下面的路径都能直接复现。我会用原生 JavaScript 加 Web Audio API 走一遍完整流程把每个参数为什么这么设讲清楚最后给出一个能直接跑的代码骨架。2. 先把状态机立住Simon 游戏为什么不能靠 setTimeout 硬堆2.1 四个状态与两条边理解 Simon 的最小状态模型Simon 游戏看起来是「播放」和「等待输入」交替进行但实际编码时如果只用一个布尔值isPlaying来切换很快就会遇到竞态问题。我一般会把状态拆成四个idle空闲、showing播放序列中、waiting等待玩家输入、gameover失败结算。状态之间的迁移由两条边驱动一条是「序列播放完毕」从showing到waiting另一条是「玩家输入错误」从waiting到gameover。为什么不用setTimeout直接串联因为setTimeout的返回值是定时器 ID你没法在玩家中途点击时优雅地取消后续所有待执行的播放任务。常见做法是维护一个timers数组每次进入showing前清空但这样写出来的代码会散落在各处后期加一个「暂停」功能就得翻遍全文件。更稳的方案是用一个state变量加一个sequence队列播放逻辑写成可中断的异步循环。// 状态常量避免魔法字符串 const STATE { IDLE: idle, SHOWING: showing, WAITING: waiting, GAMEOVER: gameover }; // 全局游戏状态对象 const game { state: STATE.IDLE, sequence: [], // 系统生成的序列元素为 0~3 playerIndex: 0, // 玩家当前输入到第几位 level: 0, // 当前关卡数等于 sequence.length timers: [] // 存放所有待执行的定时器 ID }; // 清理所有定时器状态切换前必调 function clearAllTimers() { game.timers.forEach(id clearTimeout(id)); game.timers []; }这段代码的关键在于game.timers数组。每次调用clearAllTimers会把之前排队的播放任务全部取消避免玩家在序列播放途中点击导致「旧序列还在闪、新序列已经生成」的玄学现象。playerIndex和level分开存是因为level在序列生成后就确定而playerIndex在玩家输入过程中递增两者生命周期不同。2.2 用 async/await 重写播放循环让序列播放可中断有了状态对象播放序列的逻辑就可以写成一个async函数。每一步播放「点亮色块 播放音调 等待间隔」然后检查状态是否被外部改成gameover或idle如果是就提前return。这样比回调地狱清晰得多。// 播放单个色块的时长毫秒 const FLASH_DURATION 400; // 两个色块之间的间隔 const FLASH_GAP 200; // 点亮某个色块并播放对应音调 function flashPad(index, duration FLASH_DURATION) { const pad document.querySelector([data-pad${index}]); pad.classList.add(active); playTone(index); return new Promise(resolve { const id setTimeout(() { pad.classList.remove(active); resolve(); }, duration); game.timers.push(id); }); } // 播放整个序列可被 clearAllTimers 中断 async function playSequence() { game.state STATE.SHOWING; clearAllTimers(); for (let i 0; i game.sequence.length; i) { // 每次播放前检查状态若已被重置则退出 if (game.state ! STATE.SHOWING) return; await flashPad(game.sequence[i]); await new Promise(r { const id setTimeout(r, FLASH_GAP); game.timers.push(id); }); } // 序列播完进入等待输入状态 game.state STATE.WAITING; game.playerIndex 0; }这里flashPad返回 Promise 是为了让await能按顺序等待。FLASH_DURATION设 400ms 是血泪经验低于 250ms 新手根本看不清高于 600ms 老玩家会觉得拖沓。FLASH_GAP设 200ms 是给视觉和听觉留出分离感否则连续两个色块会糊在一起。注意game.timers.push(id)在 Promise 内部执行这样clearAllTimers能取消掉尚未触发的定时器。如果玩家在showing阶段点击色块我们会在输入处理里直接忽略因为状态不是waiting。2.3 输入判定与序列增长三个必须同步的变量玩家点击色块时需要依次做四件事检查状态是否为waiting、比对sequence[playerIndex]与点击的索引、正确则playerIndex并判断是否通关、错误则进入gameover。这里最容易翻车的是「通关判定」的时机——必须在playerIndex自增之后、且等于sequence.length时才算通关然后追加一个新随机数并重新播放。// 处理玩家点击某个色块 function handlePadClick(index) { if (game.state ! STATE.WAITING) return; // 播放中或已结束忽略 const expected game.sequence[game.playerIndex]; if (index ! expected) { game.state STATE.GAMEOVER; clearAllTimers(); return; } // 输入正确短暂点亮反馈 flashPad(index, 150); game.playerIndex; // 判断是否完成本关 if (game.playerIndex game.sequence.length) { game.level; // 追加一个新随机色块 game.sequence.push(Math.floor(Math.random() * 4)); // 延迟一小段再播放新序列给玩家喘息 const id setTimeout(() playSequence(), 600); game.timers.push(id); } }Math.random() * 4取整得到 0~3对应四个色块。延迟 600ms 再播放新序列是给玩家一个「过关了」的心理缓冲这个值可以根据难度调整但低于 300ms 会让玩家觉得系统在催命。注意flashPad(index, 150)的第二个参数覆盖了默认时长让玩家点击的反馈更短促和系统播放的长闪区分开。game.level在这里自增但实际显示关卡数时直接用game.sequence.length更准确因为level只是辅助计数。3. 音画同步与交互反馈Web Audio API 的实战参数3.1 用 OscillatorNode 生成四个音调频率表与包络Simon 游戏的音效不需要加载任何音频文件用 Web Audio API 的OscillatorNode直接合成正弦波即可。四个色块对应四个频率常见做法是 C4、E4、G4、C5约 261.63、329.63、392.00、523.25 Hz这样听起来和谐且区分度高。但直接oscillator.start()再stop()会有「咔哒」声因为波形被突然截断。必须加一个增益节点做淡入淡出包络。// 四个色块对应的频率Hz const TONES [261.63, 329.63, 392.00, 523.25]; // 懒加载 AudioContext避免浏览器自动播放策略拦截 let audioCtx null; function getAudioContext() { if (!audioCtx) { audioCtx new (window.AudioContext || window.webkitAudioContext)(); } return audioCtx; } // 播放指定索引的音调 function playTone(index) { const ctx getAudioContext(); const osc ctx.createOscillator(); const gain ctx.createGain(); osc.type sine; osc.frequency.value TONES[index]; // 包络0 秒淡入到 0.3持续 0.2 秒再淡出到 0 const now ctx.currentTime; gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(0.3, now 0.02); gain.gain.linearRampToValueAtTime(0, now 0.25); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now 0.3); }gain.gain的包络是关键linearRampToValueAtTime(0.3, now 0.02)在 20ms 内淡入避免爆音linearRampToValueAtTime(0, now 0.25)在 250ms 内淡出和视觉上的 400ms 高亮形成「音短光长」的节奏这样玩家能清楚分辨每个色块的起止。osc.stop(now 0.3)比包络结束晚 50ms确保淡出完成后再断开。注意AudioContext必须在用户手势如点击后创建或恢复否则会被浏览器挂起这是新手最常见的翻车点。3.2 视觉高亮与 CSS 过渡用 class 切换而不是直接改样式色块的高亮不要用element.style.backgroundColor red这种硬编码因为后期换主题会改到崩溃。正确做法是给每个色块一个基础 class 和一个.activeclass用 CSS 过渡控制亮度和缩放。.pad { width: 120px; height: 120px; border-radius: 12px; opacity: 0.6; transition: opacity 0.1s ease, transform 0.1s ease; cursor: pointer; } .pad.active { opacity: 1; transform: scale(1.05); } /* 四个色块的基础色 */ .pad[data-pad0] { background: #e74c3c; } .pad[data-pad1] { background: #3498db; } .pad[data-pad2] { background: #2ecc71; } .pad[data-pad3] { background: #f1c40f; }opacity从 0.6 到 1 的变化配合transform: scale(1.05)在 100ms 内完成和 400ms 的播放时长配合起来视觉上会有「亮起再暗下」的呼吸感。transition的ease曲线让缩放不是线性的更接近物理按钮的按压反馈。注意.pad的cursor: pointer要加否则桌面端玩家不知道可以点。移动端还需要加touch-action: manipulation来消除 300ms 点击延迟这个在后面的避坑章节会展开。3.3 事件绑定与去抖为什么 click 不够用桌面端用click没问题但移动端click有延迟而且快速连点时可能丢失事件。我一般会同时监听pointerdown并阻止默认行为然后用一个isFlashing标志防止玩家在同一个色块上疯狂点击导致音效重叠。// 为四个色块绑定事件 document.querySelectorAll(.pad).forEach(pad { const index Number(pad.dataset.pad); pad.addEventListener(pointerdown, (e) { e.preventDefault(); // 阻止移动端可能的双击缩放 handlePadClick(index); }); });pointerdown同时覆盖鼠标、触摸和触控笔比分别监听mousedown和touchstart干净。e.preventDefault()在移动端能阻止长按选中和双击缩放但要注意如果页面需要滚动不能全局阻止只在这个色块上阻止。handlePadClick内部已经通过game.state做了第一层过滤所以不需要额外的去抖定时器。如果发现快速点击时音效重叠可以在playTone里加一个「当前正在播放的振荡器」引用每次先stop上一个但 Simon 游戏里玩家点击频率不会太高通常不需要。4. 避坑与排查Simon 游戏制作中最容易翻车的五个点4.1 移动端点击 300ms 延迟与双击缩放现象在手机上点击色块感觉反应慢半拍快速连点两下页面还会放大。原因移动端浏览器为了判断用户是单击还是双击缩放会在click事件上延迟约 300ms。同时viewport没有禁止缩放时双击会触发页面缩放。解决用pointerdown替代click并在 CSS 里给色块加touch-action: manipulation同时在meta nameviewport里加user-scalableno。如果不想禁用全局缩放至少给.pad加touch-action: manipulation它告诉浏览器这个元素只用于点击不需要等待双击判定。4.2 AudioContext 被浏览器挂起导致无声现象桌面端正常手机上第一次点击没声音第二次才有。原因浏览器自动播放策略要求AudioContext必须在用户手势的同步调用栈中创建或恢复。如果在页面加载时就new AudioContext()它初始状态是suspended。解决把getAudioContext()的调用放在pointerdown事件处理函数内部或者监听第一次pointerdown时执行audioCtx.resume()。我一般会在handlePadClick开头调用getAudioContext().resume()确保每次点击都尝试恢复。4.3 序列播放中途点击导致状态错乱现象系统正在播放序列玩家提前点击了色块结果序列播放到一半停了或者玩家输入被错误接受。原因handlePadClick只检查了game.state ! STATE.WAITING但playSequence是异步的如果在await期间状态被外部修改循环不会立即退出。解决在playSequence的for循环每次迭代开头加if (game.state ! STATE.SHOWING) return;并且clearAllTimers要在状态切换前调用。另外玩家点击时如果状态是showing直接return忽略不要做任何视觉反馈否则玩家会以为点击生效了。4.4 关卡递增后序列播放速度没变导致难度曲线太平现象玩到第 10 关还是和第一关一样慢玩家觉得无聊。原因FLASH_DURATION和FLASH_GAP是常量没有随game.level变化。解决把这两个值改成根据关卡动态计算比如const duration Math.max(200, 400 - game.level * 15)const gap Math.max(100, 200 - game.level * 8)。这样第 1 关是 400ms 和 200ms第 10 关降到 250ms 和 120ms第 15 关后稳定在 200ms 和 100ms。注意不要低于 150ms否则人眼和耳朵都跟不上。4.5 随机序列连续重复同一个色块导致玩家误判现象序列里出现连续三个相同色块玩家以为系统卡了。原因Math.random()可能连续生成相同索引虽然概率不高但一旦出现体验很差。解决在追加新色块时检查是否和上一个相同如果相同就重新生成最多重试 3 次。代码很简单let next; do { next Math.floor(Math.random() * 4); } while (next game.sequence[game.sequence.length - 1] Math.random() 0.1);这里加一个 10% 的概率允许重复避免完全禁止重复导致序列可预测性增加。5. 进阶技巧用 localStorage 存最高分与序列回放验证5.1 最高分持久化与关卡显示游戏结束后把game.level和localStorage里的历史最高分比较如果更高就更新。显示关卡时用game.sequence.length而不是game.level因为level在追加新色块前自增容易差一位。function updateHighScore(level) { const key simon_high_score; const prev Number(localStorage.getItem(key)) || 0; if (level prev) { localStorage.setItem(key, String(level)); return level; } return prev; } // 游戏结束时调用 function onGameOver() { const best updateHighScore(game.sequence.length); document.getElementById(score).textContent 本局${game.sequence.length} 最高${best}; }localStorage存的是字符串读取时用Number()转换避免10 9这种字符串比较的坑。key加一个前缀simon_防止和其他页面的存储冲突。5.2 用序列回放验证状态机是否健壮开发过程中我习惯加一个隐藏的「回放」按钮把game.sequence打印到控制台并重新播放一遍用来验证playSequence在多次调用后是否还能正确清理定时器。如果发现回放时旧的高亮没清除说明clearAllTimers没有覆盖到某个setTimeout。// 调试用回放当前序列 function replaySequence() { if (game.state STATE.SHOWING) return; clearAllTimers(); game.state STATE.SHOWING; playSequence(); }这个函数在控制台调用replaySequence()即可。注意它不改变game.sequence只是重新播放所以不会影响玩家进度。如果回放时出现两个色块同时亮检查flashPad里的classList.add(active)是否在clearAllTimers之前有残留——clearAllTimers只取消定时器不会移除已经添加的 class所以需要在clearAllTimers里加一行document.querySelectorAll(.pad.active).forEach(p p.classList.remove(active))。5.3 一个我踩过的坑不要用 setInterval 做序列播放早期版本我用setInterval每 600ms 播放一个色块结果玩家在序列播放中途点击时setInterval还在跑导致新旧序列混在一起。后来改成async/await加clearAllTimers才彻底解决。setInterval的问题是它没有「完成」的概念你只能clearInterval但无法知道当前播放到第几个。而async循环天然支持在每一步检查状态中断逻辑清晰得多。如果你现在还在用setInterval建议尽早重构否则加暂停、加变速、加回放都会很痛苦。最后说一个习惯每次改完状态机相关代码我都会在控制台手动调用replaySequence()三次观察是否有定时器泄漏或 class 残留。这个动作花不了十秒但能挡住大部分玄学 bug。希望帮到你。本文还有配套的精品资源点击获取
特种作业考证资讯 责任编辑:民启特种作业
FUWU BAOZHANG

看完文章,报名服务了解一下

从咨询到拿证,全程有人对接

条件先核对年龄、学历、体检先对照,能报才报
材料免费预审材料拍来先审,不齐的提前补
批次主动提醒报名截止、考试时间提前通知
费用透明费用报名前逐项列明,确认后再办

看完文章还有疑问?

报考条件、材料清单、考试批次,直接电话或在线咨询,几分钟给你明确答复。