简介:一款Simon游戏制作(详细注释)代码资源,面向前端初学者与编程练习者,以经典记忆力挑战游戏为线索,帮助读者掌握HTML搭建页面框架、CSS美化交互界面、JavaScript实现随机序列与用户输入判定的完整流程。包内共3个文件,结构清晰:HTML文件负责游戏面板、提示序列区等核心元素;CSS文件统一控制四色按钮的尺寸、圆角、悬停反馈与界面排版;JS文件承担随机颜色生成、音效触发、点击匹配和游戏状态管理,注释覆盖关键逻辑,便于逐行通读。资源仅4KB,无需额外依赖,下载解压后即可在浏览器中直接运行,适合课设选题、入门练手或教学演示。目前已有615人浏览学习,读者不仅可复现经典玩法,还可在此基础上添加计分、难度分级、动画过渡等扩展,体验从基础语法到完整游戏项目的进阶路径。
1. 为什么人人都该自己写一遍Simon游戏
一个看似简单的记忆游戏,做完能让你对前端异步时序的理解提升一个台阶。Simon游戏的核心不是“四个彩色按钮”,而是一套严格的状态切换:电脑演示、等待输入、校验结果、失败重来。很多初级开发者一上来就写点击事件,结果游戏在“玩家乱点时”瞬间崩溃。这个项目用原生 HTML/CSS/JavaScript 实现完整 Simon 游戏,每一段代码都标注了详细注释,从状态机到 Web Audio 音效,覆盖了前端开发中最容易翻车的异步流程。适合刚学完基础的前端新手复盘真实项目,也适合想快速搭一个可扩展游戏模板的熟手。
2. 项目定位与整体结构:从一个按钮到四个象限
2.1 技术选型:为什么用原生三件套
拿到“Simon游戏”这个需求,我第一反应是可以直接套 Vue 或 React,但仔细想了一下,这个项目最值得学习的恰恰是原生实现。因为游戏核心在于“状态控制”和“时序管理”,框架能帮你省掉 DOM 操作,但也会把事件流和异步逻辑包成黑匣子。用原生 JavaScript 写,每一步的高亮、响音、判定都是自己控制的,遇到“按钮闪得不对”“连点两下就提前结束”这种问题,靠调试器一眼就能定位。
项目中只用到了三个文件:HTML 负责界面骨架,CSS 负责四个色块和动效,JavaScript 负责全部游戏逻辑。没有引入任何第三方依赖,意味着只要浏览器支持 ES6 和 Web Audio API,就能直接跑起来。这个选型对初学者尤其友好,因为你不需要先理解虚拟 DOM 或响应式原理,就能把整条游戏链路读完。
如果换成 Vue 或 React,大概率会把按钮状态做成 data 或 state,然后通过 watch 去监听序列变化。但这样会让“演示中点击无效”这种规则变得隐蔽。原生写法里,状态是显式的,每个函数入口都先检查当前处于什么状态,不在合法状态就直接 return。这也是我坚持用原生三件套的原因:让规则看得见。
2.2 目录结构与文件职责
拿到资源后,解压出来的目录很干净,适合作为前端小项目的范本。
simon-game/ ├── index.html # 页面入口,四个色块 + 控制按钮 ├── style.css # 色块布局、亮起动画、响应式 ├── script.js # 游戏逻辑,带详细注释 └── README.md # 项目说明与操作指南index.html 里最核心的是四个色块容器,每一个都绑定了><div class="simon-pad">// 当前回合的完整序列,例如 ['red', 'blue', 'green', 'yellow'] const simonSequence = []; // 玩家本轮已输入的颜色 const playerSequence = []; // 当前轮数,从 1 开始 let round = 0; // 进入下一回合:在序列末尾追加一个随机颜色 function nextRound() { const colors = ['red', 'blue', 'green', 'yellow']; // 随机取 0~3,Math.random() 返回 [0,1) const randomIndex = Math.floor(Math.random() * colors.length); const newColor = colors[randomIndex]; // 追加后,旧序列保持不变 simonSequence.push(newColor); // 清空玩家序列,准备接收新输入 playerSequence.length = 0; round++; // 更新界面上的轮数/比分 updateScore(round); // 开始演示完整序列 playSequence(); }
逻辑说明:simonSequence.push(newColor)保证了上一轮的序列不会变,玩家每次只需要对比指针移动一位。playerSequence.length = 0比重新赋空数组性能好,也更直观。round++在下一回合一开始就增加,这样即使演示失败,分数也不会虚高。参数说明:colors数组顺序不重要,但必须和 HTML 里的>let audioCtx = null; // 延迟创建 AudioContext,并在首次点击时恢复,避免被浏览器自动播放策略拦截 function getAudioCtx() { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtx.state === 'suspended') { audioCtx.resume(); } return audioCtx; } // 颜色对应的音高,频率单位 Hz const toneMap = { red: 261.63, // C4 blue: 329.63, // E4 green: 392.00, // G4 yellow: 523.25 // C5 }; // 播放某个颜色的音效,duration 是持续秒数 function playTone(color, duration = 0.3) { const ctx = getAudioCtx(); // 创建振荡器节点,指定波形为正弦波 const oscillator = ctx.createOscillator(); // 创建增益节点,用来控制音量包络 const gainNode = ctx.createGain(); // 把频率设成对应音高的频率 oscillator.frequency.setValueAtTime(toneMap[color], ctx.currentTime); // 音量从最大快速衰减,模拟敲击感 gainNode.gain.setValueAtTime(0.3, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration); // 连接节点:振荡器 -> 增益 -> 输出 oscillator.connect(gainNode); gainNode.connect(ctx.destination); // 从当前时间播放,持续 duration 秒后停止 oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + duration); }
逻辑说明:setValueAtTime把振荡器频率设置成目标音高,exponentialRampToValueAtTime让音量指数衰减,避免结束时有“啪”的爆音。oscillator.start和stop的时间都基于audioCtx.currentTime,所以多个音效叠加时不会互相干扰。
参数说明:duration默认 0.3 秒,可以在演示序列时传 0.2 让节奏更快。gainNode.gain的值最大 0.3 是为了防止两个音同时播放时过载。实战里如果发现声音发闷,多半是频率值取太低;如果爆音,多半是没用增益包络。
视觉反馈和音效是配套出现的。当一个色块被点亮时,代码会给这个色块的 DOM 节点临时加一个active类,CSS 里.active { filter: brightness(200%); transform: scale(1.05); },同时调用playTone(color)。颜色亮起的节奏完全由序列播放控制,声音和视觉的同步误差控制在几十毫秒内,人耳几乎察觉不到。
3.3 序列播放与状态切换
演示序列是异步逻辑中最容易翻车的一段。这个项目把它抽成了一个独立函数playSequence,里面用 setTimeout 构建一条时间线。
function playSequence() { // 进入演示状态,此时玩家点击无效 currentState = 'SHOWING'; let delay = 0; // 按顺序播放完整序列,每个色块间隔 800ms simonSequence.forEach((color, index) => { setTimeout(() => { lightUp(color); playTone(color); // 300ms 后熄灭当前色块 setTimeout(() => { lightDown(color); }, 300); }, delay); delay += 800; }); // 最后一个色块播完后再等待 300ms 切换到等待输入 setTimeout(() => { currentState = 'WAITING'; }, delay + 300); }逻辑说明:delay变量在 forEach 循环中不断累加,让第一个色块在 0ms 播放,第二个在 800ms,第三个在 1600ms,依此类推。每个色块亮起后又用内层 setTimeout 在 300ms 后熄灭,形成“亮 300ms、灭 500ms”的节奏。参数说明:800ms 是间隔,300ms 是亮起时长,两者相加就是单步节奏;想加快游戏节奏,把 800 改成 600,把 300 改成 200。
这里有一个关键点:currentState = 'SHOWING'放在playSequence最前面,而不是nextRound里。这样无论谁调用演示,都能保证状态先切过去。如果状态切换和演示动作分散在不同函数,调试时就要反复核对调用链。
3.4 用户输入的触发与校验逻辑
当演示结束,状态切到 WAITING,玩家开始点击色块。每次点击,代码会把点击颜色记录下来,并和序列中对应位置的值比较。
// 绑定四个色块的点击事件 document.querySelectorAll('.simon-pad').forEach(pad => { pad.addEventListener('click', function () { // 游戏还在演示序列时,点击无效 if (currentState !== 'WAITING') return; const color = this.dataset.color; // 点亮并按音效,给玩家即时反馈 lightUp(color); playTone(color); // 把玩家输入的颜色追加到数组中 playerSequence.push(color); // 当前要核对的位置,注意数组下标从 0 开始 const currentIndex = playerSequence.length - 1; // 如果这个位置的颜色和序列不一致,游戏结束 if (playerSequence[currentIndex] !== simonSequence[currentIndex]) { endGame(); return; } // 如果本次输入已经等于完整序列长度,说明这一轮全对了 if (playerSequence.length === simonSequence.length) { // 延迟 500ms 进入下一轮,给玩家看清反馈的时间 setTimeout(() => { nextRound(); }, 500); } }); });逻辑说明:currentState !== 'WAITING'这一行是整个游戏的保险丝。演示中点击会被忽略,游戏结束后点击也会被忽略。currentIndex用playerSequence.length - 1,因为每次点击先 push 再取下标,保证比较的是最后一次输入的项。
参数说明:setTimeout的 500ms 不是随手写的。如果太短,玩家还没看清最后一个色块亮起就进入下一轮;如果太长,等待会让人烦躁。实际调参时可以放在 400~600ms 之间。还有一个细节:playerSequence与simonSequence的比较是按位同步的,只要有一个位置不匹配就立刻失败,这正是记忆类游戏的规则。
3.5 游戏结束与重新开始
失败后的处理同样有讲究,不能只是弹一个 alert 就结束。这个项目把失败状态做成一个“可恢复”的页面状态。
// 游戏结束:状态切到 GAMEOVER,播放失败音,显示最终轮数 function endGame() { currentState = 'GAMEOVER'; // 播放一个低促的失败音,与普通色块音区分 playTone('red', 0.2); setTimeout(() => playTone('yellow', 0.2), 200); // 把最高分记录到 localStorage,下次打开还在 const best = Number(localStorage.getItem('simonBest') || 0); if (round > best) { localStorage.setItem('simonBest', String(round)); } showResult(`本轮坚持到第 ${round} 轮`); } // 重置到初始状态,不清除最高分 function resetGame() { currentState = 'INIT'; simonSequence.length = 0; playerSequence.length = 0; round = 0; hideResult(); // 界面色块全部恢复到未点亮状态 document.querySelectorAll('.simon-pad').forEach(pad => { pad.classList.remove('active'); }); }逻辑说明:endGame里播放两段音效,中间用setTimeout制造短暂停顿,听起来像一个“错了”的旋律。localStorage用来记录最高轮数,值刷新或关闭浏览器后依然存在,这是很多初级版本里没做到的小细节。
参数说明:round此时是已经加过 1 的轮数,因此显示的是玩家实际坚持到的轮数。resetGame强调“不清除最高分”,因为最高分属于整个用户历程,重开一局不该被重置。如果你希望“最高分”只存在于单次会话,可以把localStorage换成内存变量,但大多数情况下,存下来是更合理的行为。
4. 运行与常见问题排查:本地跑通与踩坑记录
4.1 三种方式启动项目
这个项目不需要安装依赖,理论上直接双击index.html就能跑。但在实际开发中,用file://协议打开偶尔会遇到 ES6 模块或音频上下文初始化的问题,所以我一般推荐用本地静态服务器。
# 方式一:Python 自带 HTTP 服务,在项目目录下执行 python3 -m http.server 8080 # 方式二:使用 Node.js 的 npx 工具 npx serve . # 方式三:直接用 VS Code 的 Live Server 插件 # 右键 index.html -> Open with Live Server启动后在浏览器访问http://localhost:8080,点击开始按钮,先看到四个色块依次亮起,然后轮到你按顺序点击。如果一切正常,每一轮色块数量会递增,失败后会出现重来按钮。
需要说明的是,方式一要求本机装了 Python,方式二要求装了 Node.js。如果你两种都没有,直接双击index.html也可以,只是要注意后续提到的几个坑。项目里的 README 也写了相同的启动命令,按哪个都行。如果 8080 端口被占用,可以把命令改成python3 -m http.server 8081,浏览器访问新端口即可;npx serve默认会自动选空闲端口,所以很少遇到冲突。
4.2 坑一:双击打开后游戏声音失效
现象:直接双击index.html打开,点击色块没有任何声音,但控制台也没报错。
原因:浏览器对 Web Audio API 有自动播放策略,音频上下文在用户首次点击页面之前不能启动,而且file://协议下部分接口会被限制。代码里虽然创建了AudioContext,但如果没有在点击事件里 resume,上下文会一直处于 suspended 状态。
解决:在开始按钮的点击事件里,先调用getAudioCtx(),再执行nextRound()。这个项目里playTone内部会判断ctx.state === 'suspended'并自动 resume,所以正常情况下不会遇到这个问题。如果你在自己实现时遇到,优先检查是否把new AudioContext()写在了页面加载的顶层函数里,而不是用户手势回调里。
提示:如果你在控制台看到
The AudioContext was not allowed to start,基本就是这个原因,第一时间检查有没有在点击回调里 resume。
4.3 坑二:演示期间玩家点击导致序列错乱
现象:电脑演示序列时,快速点击色块,等到自己输入时,明明按对了却判定失败,或者轮数明显不对。
原因:演示期间currentState是 SHOWING,但事件绑定没有移除,所以点击会进入处理函数。如果处理函数没有状态判断,玩家的输入会被记录到playerSequence,等演示结束,数组里多了一个“预输入”,后面的对比全部位移。
解决:在点击处理函数第一行加if (currentState !== 'WAITING') return;。这是整个项目最核心的一行保护代码。如果你是在自己改代码时遇到这个问题,先检查所有事件处理函数是否都做了状态过滤,不要只在视觉上禁用按钮,因为 CSS 的pointer-events在移动端某些场景下会有延迟。
4.4 坑三:色块亮起和声音不同步
现象:演示序列时,色块已经亮了,声音却慢了半拍,或者有时候声音先响,视觉还没跟上。
原因:常见原因是把playTone放在了classList.add之后,又用setTimeout控制时长,但 setTimeout 的延迟和 AudioContext 的时间线并不是同一个时钟。另一个原因是 CSS 过渡时间太长,亮起动画有 300ms 延迟,听感上就像“脱节”。
解决:把视觉变化和声音触发放在同一个同步代码块里,先 add class,再立即playTone;CSS 过渡时间控制在 0.1~0.15s。如果需要精确同步,使用audioCtx.currentTime作为统一时间基准。这个项目里默认是同步执行的,实测基本没有可感知偏差。
4.5 坑四:游戏结束后无法重新开始
现象:玩到失败后,点“再来一局”没反应,或者重新开始后上一局的色块还亮着。
原因:失败时状态变成 GAMEOVER,但重来按钮的点击事件没有把状态重置为 INIT,或者没有清空simonSequence。旧序列残留会导致下一局第一轮就对比多一个颜色。
解决:在重来按钮的事件处理函数里显式调用resetGame(),并且resetGame要清空两个数组、重置轮数、移除所有 active 类。粗心的写法是只调用simonSequence.length = 0,忘记playerSequence,这样重新开始时玩家输入会从旧长度开始,出现“按一下就直接过关”的灵异现象。
4.6 排查技巧:把状态和序列打到控制台
遇到“不知道哪里出现问题”的情况,我最常做的是在关键函数里打日志。你可以临时在代码里加一个调试函数:
function debugStatus() { console.log('当前状态:', currentState); console.log('电脑序列:', simonSequence.join(' -> ')); console.log('玩家序列:', playerSequence.join(' -> ')); console.log('轮数:', round); } // 按 F9 时输出当前状态 document.addEventListener('keydown', e => { if (e.key === 'F9') { debugStatus(); } });逻辑说明:simonSequence.join(' -> ')把数组转成可读字符串,方便一眼看出序列长度。round用来核对轮数是否和界面显示一致。如果你在自己项目里排查,可以在nextRound、endGame、点击事件三个位置各打印一次,对比哪一步开始数据不对,就能快速锁定是演示问题还是校验问题。这个技巧不依赖任何调试器,纯 JavaScript 就能做,对新手特别友好。
5. 二次开发:让Simon游戏长出你自己的玩法
5.1 增加“连续错误三次”的难度机制
原版的规则是错一次就结束,很多玩家会觉得太硬核。你想做成“三次机会”,可以给endGame加一个failCount参数,每次输入错误时只减少一次机会,而不是立刻 game over。这个改动只需要在endGame前后加判断,不需要改变状态机结构。
let failCount = 3; if (playerSequence[currentIndex] !== simonSequence[currentIndex]) { failCount--; updateFailUI(failCount); if (failCount <= 0) { endGame(); } else { // 错误后重新演示当前序列,让玩家再试一次 playerSequence.length = 0; setTimeout(playSequence, 500); } return; }逻辑说明:失败时不立刻退到 GAMEOVER,而是清空玩家序列并重新演示,相当于“重温一遍再挑战”。failCount初始值放在游戏启动时重置,避免上一局消耗的机会带到下一局。参数说明:500ms 的等待是让玩家看清自己按错的是哪个颜色,属于容错设计。
5.2 把最高分独立成模块
如果你想更进一步,可以新增一个statistics.js,单独负责 localStorage 的读取和写入,游戏主逻辑里的endGame只负责传round。这样最高分逻辑与游戏状态机解耦,后面想加“最近五局成绩”或者统计胜率,都不用动主流程。
// statistics.js const SIMON_STORAGE_KEY = 'simonStats'; function saveRecord(round) { const stats = JSON.parse(localStorage.getItem(SIMON_STORAGE_KEY) || '{}'); stats.best = Math.max(stats.best || 0, round); stats.playCount = (stats.playCount || 0) + 1; localStorage.setItem(SIMON_STORAGE_KEY, JSON.stringify(stats)); }这个改动虽然简单,但它体现了“一个模块只做一件事”的分层习惯。原版把所有逻辑塞进一个 script.js,对三五百行的项目没问题,一旦玩法和统计变多,很容易变成一团乱麻。
在开始改之前,我习惯先跑一遍原来的完整流程,记下每一轮的状态切换顺序。改完一个功能,再手动走一遍“演示 → 输入 → 过关 → 失败”验证。从那以后,每次我改这种带异步流程的项目,都会强制自己在改动前把状态机图重画一遍,改完再对着图验证一次。这个习惯看起来笨,但真的能避免很多不知道从哪冒出来的坑。希望帮到你。
本文还有配套的精品资源,点击获取