恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
打地鼠游戏实战:拆解HTML、CSS与JavaScript的协作原理
首页
资讯中心
/
打地鼠游戏实战:拆解HTML、CSS与JavaScript的协作原理
打地鼠游戏实战:拆解HTML、CSS与JavaScript的协作原理
发布时间:2026/10/8 10:06:38
带过几轮前端新人之后我越来越确定一件事不管市面上冒出多少框架和工具想弄明白 HTML、CSS、JavaScript 三者到底怎么配合亲手做一个打地鼠游戏比啃三本教程都管用。这个项目麻雀虽小五脏俱全——结构上有计分板、九宫格洞口、一只会冒头的地鼠交互上有倒计时、随机出现、点击计分代码上还牵扯定时器、事件委托、状态锁这些实战里天天见的东西。这篇文章就把整个项目从需求拆解到完整代码再到我实际踩过的坑一次讲完适合刚学完三门基础语法、想动手做点东西的人也适合要给学生或新人讲项目的人参考。1. 为什么打地鼠游戏是拆解HTML/CSS/JS协作的最佳样例先把这个项目的需求说清楚非常简单页面上有3x3一共9个洞一只地鼠会随机从某个洞里冒出来停留一会儿又缩回去。玩家要在停留期间点击它每打中一次得1分游戏限时30秒时间一到弹出结算面板可以重新开始。就这么点需求三门语言的分工几乎是天然清晰的。我画过一张表给新人看看完基本就明白前端三件套是怎么回事层面负责的事在打地鼠里的具体体现HTML定义页面结构计分板、游戏区域、9个洞、洞里的地鼠CSS负责长相和动画洞口立体感、地鼠的五官造型、冒头过渡、被打中的反馈JavaScript负责行为和规则倒计时、随机出鼠、点击判定、得分结算、重新开始这个分工其实是前端开发的通用范式只是很多资料里讲得太抽象。打地鼠游戏的妙处在于它把结构、表现、行为三个词变成了看得见摸得着的东西。你不需要理解什么是框架、什么是组件化只需要看着这只地鼠从洞里冒出来就知道CSS管的是怎么冒得自然JS管的是什么时候冒、冒在哪。更关键的是这个项目几乎把基础阶段最重要的知识点全部串起来了。写HTML时候你会用到语义化标签、嵌套结构、id和class的选择器定位写CSS时候你会用到flex布局、grid网格、圆角、渐变、伪元素、绝对定位、overflow裁剪、transition过渡、animation关键帧动画写JavaScript时候你会用到DOM操作、事件监听、事件委托、定时器、数组方法、函数封装。我经常跟新人说一句话这些东西挨个学的时候都懂一做项目就全忘那是因为没有一条线把它们串起来。打地鼠就是那条线。你只需要亲手写完这200多行代码再跑通、再踩几个坑前面学过的语法碎片会自动组装成一个整体。这也是我把这个项目反复拿来当教学案例的原因。2. 游戏循环的骨架倒计时、地鼠生成、地鼠消失三条时间线很多人第一次写打地鼠最容易在时间这个环节翻车。直觉做法是开一个setInterval每次执行都随机让一个地鼠出现另一个setInterval负责倒计时结果写到最后发现地鼠出现和消失的逻辑搅在一起游戏结束之后地鼠还在冒头。我的建议是先把游戏拆成三条独立的时间线理清楚再动手。2.1 游戏本身就是一台状态机开始写代码之前心里要有一台状态机。这个项目只有三个状态等待开始、游戏中、已结束。用一个布尔值isPlaying就能完成最主要的控制。为什么一定要有这个锁因为开始游戏按钮是可以反复点击的。如果每次点击都启动一套新的倒计时和出鼠逻辑第二次点击之后就会出现两个倒计时在跑、地鼠冒得比隔壁工地还勤。isPlaying这个标志位做的事情很简单游戏进行中时再次点击开始按钮直接return不让任何重复逻辑进去。游戏结束时再把isPlaying翻回false让按钮恢复可用。这三个状态对应的界面表现也不一样。等待开始的时候地鼠全部缩在洞里计时器显示满时长游戏中地鼠冒头、分数变动、按钮置灰结束后所有地鼠立刻收回弹出结算面板。状态一清后面所有函数的边界条件都变得好写。2.2 为什么滚动时间线用的是setInterval和setTimeout而不是while初学者最容易问的问题是我能不能写一个while循环加sleep答案是绝对不能。JavaScript在浏览器里是单线程的同一个时刻只能干一件事。如果用一个死循环去硬等时间的流逝主线程会被占死页面连渲染都不会再执行用户看到的就是一张卡死的白屏鼠标点哪里都没反应。setInterval和setTimeout的本质是把回调函数放进任务队列等当前代码执行完后由事件循环按时间顺序调度执行。用生活话来说while循环像是你站在餐厅门口一直等着接客人别的桌全晾着setInterval是订好闹钟闹钟响的时候你去处理一下其余时间正常服务其他客人。在游戏里倒计时这条时间线用setInterval最合适因为每一秒做一件固定的事数字减一。地鼠出现就不能再用setInterval了因为随着游戏推进难度在变化间隔需要动态调整。这里我用的是递归setTimeout每次生成完一只地鼠之后根据当前难度算出下一次生成要等多久再设置一个新的setTimeout。这样每次的间隔都不一样而且整个生成循环只需要保存一个定时器ID结束游戏时一个clearTimeout就能彻底停掉。2.3 每条时间线结束时要各自收尾三条时间线分别是主倒计时、地鼠生成循环、每只地鼠的消失倒计时。它们的生命周期不一样主倒计时从开始跑到结束地鼠生成也是从头跑到尾但每一只地鼠的消失时间都是独立的从它冒头那一刻开始到它缩回去那一刻结束。所以代码里要把这三类定时器分开管理。倒计时用一个countdownTimer变量保存ID地鼠生成用一个spawnTimer每一只地鼠的消失定时器则放进一个moleHideTimers数组。游戏结束时clearInterval(countdownTimer)、clearTimeout(spawnTimer)再遍历数组把还没执行的消失定时器全部clear掉。这一步不做干净就会出现著名的幽灵地鼠结算面板都弹出来了某个洞里还有一只地鼠突然冒出来。因为那个地鼠的setTimeout还在队列里躺着到点了一样会执行。清理定时器是写交互页面时最容易漏、也最需要养成习惯的一步。2.4 难度曲线怎么设计才合理难度曲线是这个游戏最好玩的调节参数。我的做法是引入一个当前进度量function getDifficulty() { const progress 1 - timeLeft / GAME_TIME; return { spawnInterval: Math.max(450, 900 - progress * 450), stayTime: Math.max(380, 800 - progress * 420) }; }progress从0逐渐变成1表示游戏快结束了。spawnInterval是地鼠生成的间隔从开局时的900毫秒逐渐缩短到450毫秒stayTime是地鼠在洞外停留的时间从800毫秒逐渐缩短到380毫秒。试玩的时候手感很直观开局地鼠慢悠悠你能从容瞄准玩到最后一秒地鼠几乎是一闪而过考验的是反应速度。游戏阶段地鼠生成间隔地鼠停留时间手感开局剩余30秒900ms800ms慢速适合上手中期剩余15秒675ms590ms适中有压迫感尾声最后一秒450ms380ms高速纯反应测试我建议做这类游戏时把难度阈值打印到控制台观察一轮再根据实际体验微调。数值这个东西理论算得再漂亮也要贴合真实手速。3. 结构与样式九个洞的地基、纯CSS画地鼠与冒头动画结构是一切的基础这一层写好了后面所有动画和交互才有地方安放。3.1 DOM结构为什么每个洞必须把地鼠包在里面核心结构是这样的层级关系div classboard div classscore-box得分 span idscore0/span/div div classtime-box剩余时间 span idtime30/span秒/div button idstartBtn开始游戏/button /div div classfield idfield div classhole div classmole div classmole-inner/div span classeye left/span span classeye right/span span classnose/span span classtooth/span /div /div /div这里最关键的设计是地鼠是洞的子元素。别看这个选择很普通它直接决定了冒头动画怎么写。地鼠要藏在洞里只需要给洞设置overflow:hidden然后把地鼠绝对定位到洞口下方视觉上就被剪裁得不见踪影。当地鼠需要出现时把它的位置从下方移动到洞口内由于洞的外框是圆形裁剪区域看起来就像是从洞里钻出来了。如果地鼠不是洞的子元素想实现这个从洞里钻出来的效果就需要额外处理遮挡关系麻烦得多。嵌套结构不需要多高深的技巧只需要想清楚谁该是谁的父容器。九个洞我用JavaScript循环生成而不是手写九个div。这样做的理由很实际代码短以后想改成4x4、5x5的地图只需要改一个数字而且动态生成的节点完全可以配合事件委托来绑定点击后面会细说。3.2 纯CSS画地鼠渐变、伪元素和子元素五官我不想用任何外部图片所以地鼠的长相完全靠CSS画。画完之后发现这反而是个特别好的技能训练。洞本身用radial-gradient加inset阴影来做土坑的立体感.hole { position: relative; aspect-ratio: 1 / 1; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at 50% 42%, #8a5a2e 0%, #4f2d12 72%); box-shadow: inset 0 10px 14px rgba(0,0,0,.5), 0 6px 10px rgba(0,0,0,.3); }地鼠的头部是一个圆上面用radial-gradient做高光内阴影压出立体感。耳朵用::before和::after伪元素各画一个圆眼睛和鼻子用真实的span标签定位门牙是一个白色小圆角矩形。五官用绝对定位放在头部相应位置层次用z-index压好。这里有个布局习惯可以分享地鼠大小我用百分比而不是固定像素。因为洞口大小随屏幕变化地鼠跟着比例走整体才不会出现手机上地鼠太大压住洞口的尴尬。3.3 冒头动画translateY加transition别用left/top做位移冒头效果的核心是状态切换加过渡动画.mole { position: absolute; left: 50%; bottom: 10px; width: 72%; height: 72%; transform: translateX(-50%) translateY(132%); transition: transform .18s ease-out; } .hole.active .mole { transform: translateX(-50%) translateY(0); }地鼠默认藏在洞下方translateY(132%)一旦父元素hole被加上active类地鼠的transform变成translateY(0)transition会让它在一瞬间平滑位移上来看起来就是一只活蹦乱跳的地鼠冒头了。这里专门说一下为什么位移用transform而不用直接改left或bottom。left、top、bottom这些属性一旦变化浏览器要重新计算布局、重排再重绘开销比较大transform只触发合成层操作性能好得多。打地鼠是个高频交互游戏玩家要快速点击页面如果掉帧那种砸在棉花上的迟钝感会直接毁掉手感。养成用transform做动画的习惯不只是为了炫技是为了真实性能。状态类挂在hole上而不是mole上也是故意的。因为hole同时承担冒头和被打中两个逻辑状态把它们统一写在父容器上CSS选择器可以写成.hole.active .mole和.hole.hit .mole互不干扰。3.4 击中反馈让玩家一眼就看出我打到了光加分还不够玩家需要视觉反馈。我给被击中的地鼠加了一组动画.hole.hit .mole { animation: dizzy .3s ease; } keyframes dizzy { 0% { transform: translateX(-50%) translateY(0) scale(1); filter: brightness(1); } 40% { transform: translateX(-50%) translateY(0) scale(.88); filter: brightness(1.8); } 100% { transform: translateX(-50%) translateY(0) scale(1); filter: brightness(1); } }原理很简单被打中的一瞬间地鼠猛地缩小一下同时亮度飙到1.8模拟被锤子敲中的感觉然后回弹到正常。如果还想加更炫的效果可以在点击位置插入一个span让它从点击点向外扩散并淡出也就是常见的涟漪光圈扩散效果用CSS的animation就能实现。打地鼠游戏里加上这个手感会整整上一个档次。4. JavaScript控制器事件委托、随机抽洞与定时器收尾结构层和表现层准备完毕接下来是所有逻辑的中枢JavaScript控制器。游戏能不能正常跑起来完全看这一层设计得清不清楚。4.1 事件委托只监听一次别给每个洞都绑监听器九只地鼠如果每个洞都去addEventListener绑一个click代码又长又笨。更麻烦的是洞口是动态生成的每生成一个就得绑一次。正确做法是事件委托只给它们的父容器field绑定一个click事件利用事件冒泡机制点击任意子元素时事件都会冒泡到这个父容器上统一处理。field.addEventListener(click, (e) { if (!isPlaying) return; const mole e.target.closest(.mole); if (!mole) return; const hole mole.parentElement; if (!hole.classList.contains(active) || hole.classList.contains(hit)) return; hole.classList.add(hit); score; updateScore(); setTimeout(() hole.classList.remove(active, hit), 250); });这里e.target.closest(.mole)是点睛之笔。closest方法会从当前点击的元素开始一层一层向上找匹配的选择器。哪怕玩家精确点击到了地鼠的眼睛、鼻子、门牙closest都能帮你找到整只地鼠。这样就不用管点击到底落在了哪个具体子元素上代码简单且健壮。判断逻辑上做了双保险先找到地鼠再确认它所在的洞确实处于active状态。地鼠还缩在洞里时它的hole没有active类点击不加分地鼠在同一局里已经被打过一次hole带hit类也不会重复加分。这两个判断缺一不可。4.2 随机抽洞先把活化状态的洞排除掉地鼠生成函数的核心逻辑是随机选洞但随机不等于乱选。如果直接从9个洞里随机选一个完全可能出现原地鼠还没缩回去同一个洞又被选中冒出来。一鼠连冒的体验很怪玩家刚打中地鼠消失还没一秒又原地复活观感像bug。所以我在随机之前先做一次过滤function randomMole() { const inactive holes.filter(h !h.classList.contains(active) !h.classList.contains(hit)); if (inactive.length 0) return; const hole inactive[Math.floor(Math.random() * inactive.length)]; hole.classList.add(active); const diff getDifficulty(); const hideTimer setTimeout(() hole.classList.remove(active, hit), diff.stayTime); moleHideTimers.push(hideTimer); }先筛出既没在冒头、也没处于击中状态的洞只在剩下这一批里随机。这样同一时间最多只有一只地鼠露头而且不会连续抽中同一个洞。如果你想做成双鼠同框、提高难度只需要把每次抽洞的数量从1改成2其他逻辑都不用动。4.3 startGame函数的执行顺序就是游戏的全部流程我把启动流程固定为五步复位、上锁、启动倒计时、立即出鼠、排下一次出鼠。function startGame() { if (isPlaying) return; resetGame(); overlay.classList.remove(show); startBtn.disabled true; isPlaying true; updateScore(); updateTime(); countdownTimer setInterval(() { timeLeft--; updateTime(); if (timeLeft 0) endGame(); }, 1000); randomMole(); scheduleNextMole(); }resetGame是第一步这一步把上一局残留的分数、时间、定时器、地鼠状态全部清干净。如果没有这一步第二次游戏开始时会带着上一局的残留状态bug会非常隐蔽且难查。随机出鼠我特意在启动时先调用一次randomMole让第一只地鼠立刻出现而不是等第一个生成间隔跑完。玩家点下开始按钮后第一只地鼠几乎同时冒出来节奏就对了。4.4 游戏结束的清理顺序先停生成再清消失最后清界面游戏结束的处理比看上去要讲究。我的结束函数长这样function endGame() { isPlaying false; clearInterval(countdownTimer); clearTimeout(spawnTimer); moleHideTimers.forEach(id clearTimeout(id)); moleHideTimers.length 0; holes.forEach(h h.classList.remove(active, hit)); startBtn.disabled false; finalScoreEl.textContent score; overlay.classList.add(show); }顺序其实是有讲究的先把isPlaying翻成false这样就算有个漏网定时器在排队执行到一半发现游戏已经不在进行状态也会自己放弃然后停掉主循环避免再生成新的地鼠然后把所有还排着队的地鼠消失定时器清掉最后才是清理界面上的active和hit类、恢复按钮、弹出结算面板。如果用alert弹出结算框会阻塞JavaScript执行画面卡在那非常难看。我选择自定义一个overlay遮罩层display:none默认隐藏游戏结束时加show类变成弹窗上面显示最终得分和再来一局按钮。这样流程干净样式还能自由控制。点击再来一局本质就是重新执行startGame。因为isPlaying此时已经是falseresetGame又能把上一局清干净所以完全可以复用同一个入口不需要为再来一局单独写一个函数。这一点设计到位的话代码量会少很多。5. 完整可运行代码说了这么多直接上完整代码。新建一个后缀为html的文件把下面内容整份粘进去浏览器打开就能玩。所有CSS和JavaScript都在单个文件里内嵌是因为教学演示方便复制即用。想拆开维护也完全没问题样式单独存成style.css用 / 标签引入脚本单独存成game.js用 引入。那三种样式引入方式——行内式、内嵌式、外链式——这个文件里用的是内嵌式正式项目更推荐外链式代码边界清楚浏览器还能缓存。!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title打地鼠游戏/title style * { box-sizing: border-box; } body { min-height: 100vh; margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, PingFang SC, Microsoft YaHei, sans-serif; background: radial-gradient(circle at 20% 10%, rgba(255,255,255,.15), transparent 40%), linear-gradient(180deg, #7db466 0%, #3f8d3f 80%); user-select: none; } .game { max-width: 720px; margin: 0 auto; padding: 24px 16px; } .board { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; max-width: 640px; margin: 0 auto 24px; padding: 14px 24px; border-radius: 16px; background: rgba(255,255,255,.2); box-shadow: 0 4px 12px rgba(0,0,0,.25); color: #fff; font-size: 20px; } .score-box, .time-box { display: flex; align-items: center; gap: 8px; } #score, #time { min-width: 46px; font-weight: bold; text-align: center; } #startBtn { background: linear-gradient(145deg, #ff9d00, #e06b00); border: none; color: #fff; font-size: 18px; padding: 10px 24px; border-radius: 12px; cursor: pointer; box-shadow: 0 4px 0 #b35200; transition: transform .1s, box-shadow .1s; } #startBtn:active { transform: translateY(4px); box-shadow: 0 0 0 #b35200; } #startBtn:disabled { opacity: .5; cursor: not-allowed; } .field { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; max-width: 640px; margin: 0 auto; } .hole { position: relative; aspect-ratio: 1 / 1; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at 50% 42%, #8a5a2e 0%, #4f2d12 72%); box-shadow: inset 0 10px 14px rgba(0,0,0,.5), 0 6px 10px rgba(0,0,0,.3); } .mole { position: absolute; left: 50%; bottom: 10px; width: 72%; height: 72%; transform: translateX(-50%) translateY(132%); transition: transform .18s ease-out; z-index: 1; } .hole.active .mole { transform: translateX(-50%) translateY(0); } .mole::before, .mole::after { content: ; position: absolute; top: -4%; width: 32%; height: 32%; border-radius: 50%; background: radial-gradient(circle at 40% 40%, #e6b567, #a9743e 70%); z-index: 0; } .mole::before { left: 8%; } .mole::after { right: 8%; } .mole-inner { position: absolute; inset: 0; border-radius: 50% 50% 46% 46% / 55% 55% 45% 45%; background: radial-gradient(circle at 36% 26%, #ecc37a, #b37a3e 70%); box-shadow: inset 0 -10px 14px rgba(0,0,0,.15), inset 0 8px 10px rgba(255,255,255,.2); z-index: 1; } .eye { position: absolute; top: 40%; width: 13%; height: 17%; background: #2b2b2b; border-radius: 50%; z-index: 2; } .eye.left { left: 22%; } .eye.right { right: 22%; } .eye::after { content: ; position: absolute; top: 22%; left: 22%; width: 40%; height: 40%; background: #fff; border-radius: 50%; } .nose { position: absolute; top: 60%; left: 50%; transform: translateX(-50%); width: 16%; height: 11%; background: #e8837f; border-radius: 50%; z-index: 3; } .tooth { position: absolute; top: 71%; left: 50%; transform: translateX(-50%); width: 13%; height: 9%; background: #fff; border-radius: 0 0 4px 4px; z-index: 3; box-shadow: 0 2px 2px rgba(0,0,0,.1); } .hole.hit .mole { animation: dizzy .3s ease; } keyframes dizzy { 0% { transform: translateX(-50%) translateY(0) scale(1); filter: brightness(1); } 40% { transform: translateX(-50%) translateY(0) scale(.88); filter: brightness(1.8); } 100% { transform: translateX(-50%) translateY(0) scale(1); filter: brightness(1); } } .overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); justify-content: center; align-items: center; z-index: 10; } .overlay.show { display: flex; } .result-panel { background: linear-gradient(145deg, #ffd97a, #f8a942); padding: 32px 48px; border-radius: 20px; text-align: center; box-shadow: 0 12px 30px rgba(0,0,0,.35); } .result-title { font-size: 28px; font-weight: bold; margin: 0 0 10px; color: #5a3418; } .result-score { font-size: 22px; color: #5a3418; margin: 0 0 20px; } #restartBtn { background: linear-gradient(145deg, #ff9d00, #e06b00); border: none; color: #fff; font-size: 18px; padding: 10px 30px; border-radius: 12px; cursor: pointer; box-shadow: 0 4px 0 #b35200; } /style /head body div classgame div classboard div classscore-box得分 span idscore0/span/div div classtime-box剩余时间 span idtime30/span秒/div button idstartBtn开始游戏/button /div div classfield idfield/div /div div classoverlay idoverlay div classresult-panel p classresult-title时间到/p p classresult-score得分 span idfinalScore0/span/p button idrestartBtn再来一局/button /div /div script const field document.getElementById(field); const scoreEl document.getElementById(score); const timeEl document.getElementById(time); const startBtn document.getElementById(startBtn); const overlay document.getElementById(overlay); const finalScoreEl document.getElementById(finalScore); const restartBtn document.getElementById(restartBtn); const GAME_TIME 30; const HOLE_COUNT 9; let holes []; let score 0; let timeLeft GAME_TIME; let isPlaying false; let countdownTimer null; let spawnTimer null; let moleHideTimers []; function buildField() { holes []; field.innerHTML ; for (let i 0; i HOLE_COUNT; i) { const hole document.createElement(div); hole.className hole; hole.innerHTML div classmole div classmole-inner/div span classeye left/span span classeye right/span span classnose/span span classtooth/span /div ; field.appendChild(hole); holes.push(hole); } } function updateScore() { scoreEl.textContent score; } function updateTime() { timeEl.textContent timeLeft; } function getDifficulty() { const progress 1 - timeLeft / GAME_TIME; return { spawnInterval: Math.max(450, 900 - progress * 450), stayTime: Math.max(380, 800 - progress * 420) }; } function randomMole() { const inactive holes.filter(h !h.classList.contains(active) !h.classList.contains(hit)); if (inactive.length 0) return; const hole inactive[Math.floor(Math.random() * inactive.length)]; hole.classList.add(active); const diff getDifficulty(); const hideTimer setTimeout(() hole.classList.remove(active, hit), diff.stayTime); moleHideTimers.push(hideTimer); } function scheduleNextMole() { if (!isPlaying) return; const diff getDifficulty(); spawnTimer setTimeout(() { if (!isPlaying) return; randomMole(); scheduleNextMole(); }, diff.spawnInterval); } function resetGame() { clearInterval(countdownTimer); clearTimeout(spawnTimer); moleHideTimers.forEach(id clearTimeout(id)); moleHideTimers.length 0; countdownTimer null; spawnTimer null; score 0; timeLeft GAME_TIME; holes.forEach(h h.classList.remove(active, hit)); updateScore(); updateTime(); } function startGame() { if (isPlaying) return; resetGame(); overlay.classList.remove(show); startBtn.disabled true; isPlaying true; countdownTimer setInterval(() { timeLeft--; updateTime(); if (timeLeft 0) endGame(); }, 1000); randomMole(); scheduleNextMole(); } function endGame() { isPlaying false; clearInterval(countdownTimer); clearTimeout(spawnTimer); moleHideTimers.forEach(id clearTimeout(id)); moleHideTimers.length 0; holes.forEach(h h.classList.remove(active, hit)); startBtn.disabled false; finalScoreEl.textContent score; overlay.classList.add(show); } field.addEventListener(click, (e) { if (!isPlaying) return; const mole e.target.closest(.mole); if (!mole) return; const hole mole.parentElement; if (!hole.classList.contains(active) || hole.classList.contains(hit)) return; hole.classList.add(hit); score; updateScore(); setTimeout(() hole.classList.remove(active, hit), 250); }); startBtn.addEventListener(click, startGame); restartBtn.addEventListener(click, startGame); buildField(); /script /body /html6. 跑起来之后我踩过的坑和想加的功能代码能跑只是第一步。这个项目真正的价值藏在把游戏玩出毛病的过程里。这里把最典型的三个坑和对应的修法整理出来。第一个坑是重复点击开始按钮。第一次写完代码我把按钮连点了三下结果页面直接失控——倒计时在飞地鼠像开了加速器。后来加了一个isPlaying状态位游戏进行中直接return问题立刻消失。类似的问题在所有带开始按钮的小游戏里都存在写之前就先想好状态锁能省掉很多排查时间。第二个坑是同一直地鼠被连点刷分。玩家手速快的话可以在地鼠消失前点十几下每一下都加分分数变成天文数字游戏失去意义。解决办法是点击后立刻给洞加上hit类这个类当天起到计分锁的作用同一个洞只能加一次分。配合视觉动画250毫秒后一起移除active和hit地鼠这才缩回去。第三个坑是页面切到后台再回来计时器全线失控。浏览器为了省资源切后台后会把定时器节流甚至暂停回来后定时器的执行时间对不齐倒计时明显变慢。简单处理思路是监听document的visibilitychange事件页面隐藏时记录当时的真实时间戳恢复可见时用真实流逝的时间重新计算剩余秒数。这个方案在正式项目里很实用光靠倒计时变量做累积迟早会出问题。跑完代码我还给自己留了一个扩展清单。想要刺激一点可以支持多个地鼠同时冒头只要把randomMole里一次抽1个洞改成抽2到3个想要耐玩一点可以加本地最高分用localStorage存一下下次打开页面还在想要手感更好可以在点击位置加涟漪光圈扩散特效再配合Web Audio API合成一个简单的敲击音效反馈会更完整。这些扩展全都基于现在这套结构改起来不伤筋动骨。我把这个项目带去带了三轮新人每轮都要求不照着抄、改一个参数或者加一个小功能。事实证明只要理解了状态锁、三条时间线、事件委托这三件事后面不管加什么功能都不会慌。打地鼠看起来是个小玩具但它练的不只是三门语言的语法更是你在浏览器里面对时间和交互的一整套思维方式。