简介:这份资源是一套用原生前端技术实现经典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-pad="0"] { background: #e74c3c; } .pad[data-pad="1"] { background: #3498db; } .pad[data-pad="2"] { background: #2ecc71; } .pad[data-pad="3"] { 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 name="viewport">里加user-scalable=no。如果不想禁用全局缩放,至少给.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。希望帮到你。
本文还有配套的精品资源,点击获取