从零实现打地鼠小游戏:前端三件套核心逻辑与动画细节全解析
打地鼠游戏我前后做过不下五个版本。最早是当年自己学前端时的课堂作业后来带新人、做公开课也经常拿它当教学案例。这东西看着简单无非是 htmlcssjavascript 三个老朋友凑在一起页面摆几个洞地鼠随机冒头玩家点中就加分。可真把它拆开事件绑定、状态管理、定时器、CSS过渡动画、随机算法全在里面几乎把前端入门需要踩的坑都覆盖了。这篇文章我会完整分析这个项目从0到1的每一步讲清楚每个设计为什么这么做最后给出可以直接跑的完整代码。不管你是刚学完基础语法想找项目练手还是打算拿小游戏当面试作品都应该能从中捞到点东西。1. 项目拆解打地鼠游戏到底在练什么1.1 一个游戏闭环拆开就是四件事任何游戏都可以抽象成“规则 - 操作 - 反馈 - 结算”的闭环。打地鼠的玩法规则是游戏开始后地鼠会随机从洞里冒出来玩家要在它缩回去之前点中它点中得一分倒计时结束整局结束看总分。听起来很简单但落到代码上你就会发现背后有几个绕不开的问题地鼠多久出现一次出现在哪个洞出现后停留多长时间这些参数直接决定游戏是无聊还是崩溃。比如出现间隔固定为2秒、停留5秒玩家会闲得发呆反过来间隔200毫秒、停留100毫秒鼠标都追不上体验就很差。所以第一版开发时别急着堆UI先把这几个数值抽成变量放在一个配置对象里。除了数值还要考虑边界情况地鼠缩回瞬间被点到算不算命中这一帧的状态怎么判断游戏结束那一刻刚好有只地鼠冒出来会不会继续加分这些都是新手不容易注意到、但实际写代码必然遇到的分支。我的处理原则是把“地鼠处于上升状态up”当作唯一合法的点击目标所有结算逻辑都围绕这个状态展开。这样无论用户手速多快、事件顺序多乱只要状态不满足就直接忽略。1.2 为什么选纯三件套而不是先学框架很多人一上来就想用 React、Vue 写小游戏我觉得对打地鼠这个体量来说真没必要。首先游戏的核心状态就三个分数、剩余时间、是否运行中。用框架的状态管理反而要多写很多样板代码。其次这个项目的主要价值是帮助你理解浏览器原生能力DOM 查询、事件监听、定时器、CSS transform 过渡。这些基础如果没吃透后面学框架一定会吃亏。举一个最简单的例子grid.addEventListener(click, handler)这种事件委托写法在原生 JS 里很直观但放进 Vue 的模板里你反而不容易意识到它发生了什么。当然不是说不能用框架。如果你是为了练习某个框架或者想把游戏接到聊天机器人、积分系统里那另说。但作为项目分析我建议第一版老老实实用原生三件套。完成之后再谈重构你会发现后面无论换什么技术栈核心逻辑都能轻松搬过去。1.3 状态机让游戏逻辑不再是一团乱麻小游戏最容易写出“面条代码”的地方就是游戏状态散落得到处都是。我用三个变量贯穿全局score、time、isRunning。isRunning是唯一的运行标志所有定时器、地鼠冒头逻辑都先检查它。这样即使玩家在游戏中途反复点击按钮或者某个回调函数晚触发也不会出现“游戏已结束还在加分”的灵异事件。比布尔变量更进一步的做法是维护idle | running | ending这样的状态枚举。对这个项目来说布尔够用了但你要理解这背后的思想不是让每个事件自己判断“我能不能执行”而是让全局状态告诉它“现在该不该执行”。后面如果你想加暂停、加复活、加关卡只要扩展状态枚举就行。这也是我从这个项目里得到的最有价值的思维训练比会写几行动画重要得多。2. 页面结构设计先画HTML骨架2.1 顶部信息栏 九宫格地洞页面我分成两大块顶部是状态栏左侧显示得分中间/右侧显示剩余时间再放一个开始按钮下面是3x3排列的九个地洞。为什么选3x3而不是一行9个或4x4一是3x3的宽高比接近方形在手机上不用横屏也能看清二是9个洞对随机算法来说既不会太密导致点击误触也不会太稀让玩家视线来回跑太远。HTML结构不复杂一个header包三样东西一个div.grid包九个div.cell。每个cell里再放一个div.mole当地鼠。注意这里不要直接把文字“地鼠”写在按钮里而是用独立的元素是因为后面所有动画都要作用在mole这个节点上。如果你用背景图或伪元素画地鼠控制位置和变换就会麻烦很多。2.2 地鼠的 DOM 结构为什么不能省我见过一些实现把洞和地鼠全画在一个div的背景里靠切换背景图来模拟“出现”和“消失”。这样写虽然HTML干净但有两个问题一是无法做平滑过渡动画背景图切换是瞬时的二是想加“被击中后抖动”之类效果必须换一套背景图维护成本高。所以我的方案是.cell负责“洞”的容器和遮罩.mole是真正会出现、会被点击的地鼠元素。CSS里我用.cell::after画出洞口的深色弧形用.mole::before画出两只眼睛用.mole::after补充鼻子区域这样不用任何图片素材纯代码就能表达“这是一只地鼠”。用伪元素画图形还有一个额外好处任意缩放不会失真将来想改配色只要改几个颜色值。如果你有自己的地鼠图片替换起来也很容易只要把.mole改成一个img或者背景图动画逻辑不需要动。2.3 用事件委托统一处理点击九个地洞如果每个洞单独绑定 click代码会冗余而且动态增删洞时容易漏绑。我选择在grid父节点上绑定一个 click 监听器然后通过e.target.closest(.cell)找到被点击的洞。这就是事件委托。它不只省监听器数量还能保证后添加的洞自动拥有相同逻辑非常契合“数据驱动UI”的思路。真正判断逻辑只有两步先找到最近的.cell然后判断它是否包含.up状态。包含就执行加分和隐藏不包含直接 return。这个判断听起来简单但实际是我改版最多的地方。早期版本我直接判断“点击的是不是 mole”结果点在地鼠的缝里、点在地鼠的歪头部分、点在洞的边缘时行为不稳定。改成基于 cell 的 up 状态判断后所有命中判定的语义都统一了。3. CSS 动画细节地鼠怎么从洞里钻出来3.1 transform transition 实现出洞和回洞地鼠的“出洞”效果核心代码是一行 transition 加一组成对类.mole { transform: translateX(-50%) translateY(130%); transition: transform 0.15s ease-out; } .cell.up .mole { transform: translateX(-50%) translateY(0); }平时 mole 被往下推了自身高度130%因为.cell设置了overflow: hidden它完全藏在洞下方玩家看不到。一旦.cell加上.up类mole 就通过过渡动画回到正常位置看起来就像地鼠从洞里钻出来。缩回去也一样去掉.up类transform 回到隐藏位置。这里的关键是动画必须由transform驱动不要去改top、bottom、margin这些属性。transform不触发 layout只会走合成层动画帧率更稳。对打地鼠这种频繁冒头的场景动画性能直接影响手感。你可以试着改成margin-top实现同样效果大概率在低端手机上能看到明显卡顿。同理translateY(130%)里的百分比是相对于地鼠自身高度的不是容器高度这样设计的好处是无论地鼠尺寸怎么响应式变化藏匿比例都保持一致。3.2 击中反馈涟漪光圈扩散那个特效怎么加游戏反馈不能只靠加分数字那太干瘪。我在地鼠被击中时给洞口叠加了一个涟漪光圈扩散效果这也是网上常说的“css涟漪光圈扩散”在游戏里的一种用法。核心样式如下.cell.hit::before { content: ; position: absolute; left: 50%; top: 50%; width: 20px; height: 20px; margin: -10px 0 0 -10px; border-radius: 50%; background: rgba(255, 255, 255, 0.75); animation: ripple 0.35s ease-out forwards; z-index: 3; } keyframes ripple { from { transform: scale(0.3); opacity: 0.8; } to { transform: scale(4); opacity: 0; } }.hit类只负责触发动画。为了能让同一洞口连续击中时动画重新播放每次点击我都会先用void cell.offsetWidth强制浏览器重排再重新加上.hit。这是前端动画一个经典小技巧想重跑CSS动画就删掉类、强制重绘、再添加类。否则第二次点击同一个洞时浏览器觉得类没变化动画不会重启。这个涟漪其实是个很便宜的视觉效果只用了伪元素和 scale 动画连 transform-origin 都不用改因为圆心定位在洞口中心。如果你觉得白色太轻可以换成黄色或者半透明的橙色只要保留透明度变化视觉注意力自然会被吸引过去。3.3 布局与光标小游戏也要注意手感手感是个很玄但真实存在的东西。第一个影响是光标九个洞区域要设置cursor: pointer让玩家知道这里点得动。如果你有锤子图标可以用cursor: url(hammer.cur), pointer自定义光标没有资源就保持默认。第二个影响是点击区域大小洞的尺寸不能太小手机上手指点击区域最好不低于44px。我用aspect-ratio: 1把每个洞固定成正方形再通过grid的gap留出间距既好看又不容易误触。响应式方面外层容器max-width: 480px内层 grid 使用repeat(3, 1fr)这样在手机上自动缩小在桌面也不至于铺满全屏。另外我给开始按钮加了一个按下的位移效果利用:active配合box-shadow模拟“按钮被按下去”的反馈。这种小细节不复杂但能让整个游戏看起来像那么回事。记住一点小游戏优先保证“触控友好”再谈视觉丰富。4. JavaScript 游戏引擎定时器、随机与计分4.1 随机出鼠从 Random 到防重复出鼠逻辑的核心是showMole函数。每次执行先用randomCell()选一个洞给洞加上.up再用setTimeout在随机延迟后去掉.up。直接Math.floor(Math.random() * 9)就能随机但玩过就知道连续两次同一个洞出现会很烦玩家刚盯着左边地鼠又冒在左边而且旧地鼠还没缩回去新的又出来视觉上就是鬼畜。所以我加了一个previousCell变量下次随机时排除上次的洞。function randomCell() { if (cells.length 1) { return cells[0]; } let idx; do { idx Math.floor(Math.random() * cells.length); } while (idx previousCell); previousCell idx; return cells[idx]; }防重复只是一层体验优化真正的难度调节在于出现频率和停留时间。我第一版参数是spawnInterval: 850停留700 到 1300ms随机玩起来比较轻松。如果你想加大难度可以把出现间隔缩短到500ms停留缩短到650ms或者每过5秒动态加快一次。这类参数建议集中放在文件顶部写成CONFIG对象方便随时调。调参的时候记住一个规律玩家的反应时间大约在300毫秒左右地鼠停留时间至少要大于这个数否则游戏就变成纯运气了。4.2 计分与防连点状态比事件更可靠计分逻辑最忌讳“每次点击都加分”。地鼠出现后玩家可能快速双击如果你只判断“点击的是地鼠”就加分那同一只地鼠会被记两次甚至三次。我的做法是加分后立刻把.up去掉地鼠马上缩回第二次点击发生时它已经不具备合法状态自然加不了分。这比额外记录“这局已经点过哪只地鼠”要简单可靠得多。另外我用cellTimers这个 Map 记录了每个洞口当前隐藏定时器的句柄。点击命中时先clearTimeout清掉原定的隐藏任务再手动隐藏。这样既避免定时器晚触发把新冒头的地鼠收回也避免内存里的定时器越积越多。很多初学者忽略这一步会导致“地鼠明明点中了却像没点中一样”的假Bug其实就是旧定时器在捣鬼。4.3 定时器管理setInterval 不是启动完就不管了游戏里有两个setInterval一个负责出鼠一个负责倒计时。还有一个setTimeout负责单只地鼠的隐藏。这三类定时器如果不在结束时统一清理一定会出问题。最典型的就是连续点击“开始游戏”第一次启动的setInterval没有清掉第二次又启动一个地鼠以双倍频率往外冒倒计时也变成一秒扣两秒。解决方案很简单每次启动前先调用clearAllTimers()把旧的出鼠定时器和倒计时定时器全部清掉再开始新一轮。倒计时的写法也要留个心眼。setInterval每1000毫秒执行一次看起来没问题但实际上浏览器的定时器在标签页被切到后台时会被降频甚至暂停导致游戏结束时间比实际晚。严谨一点的做法是记录endTime Date.now() totalTime * 1000然后在倒计时里用Math.round((endTime - Date.now()) / 1000)计算剩余秒数。我在完整代码里保留了简单版但在注释里标一句如果要做排行榜或正式对战建议改成时间戳方案。4.4 结束收尾所有地鼠回洞停止所有逻辑endGame函数里除了清定时器、恢复按钮还要把所有洞的.up类去掉防止游戏结束后还有一只地鼠卡在洞口。我用一个简单的alert展示结果你也可以改成页面内的弹窗比如用一个隐藏的div.overlay显示得分和“再来一局”按钮。不过要注意alert会阻塞 JavaScript 执行在移动端体验一般正式项目我建议换成自定义弹窗。还有一个很多人容易漏掉的细节在startGame里正式启动前要先把所有cell的up和hit类都清掉。否则上一局结束时如果某个洞口还残留动画状态下一局第一次出鼠可能会带上一个多余的hit涟漪。这类“开局先做全局重置”的习惯在多轮对局中特别重要。5. 完整代码与运行方式5.1 完整代码可直接运行考虑到大多数人只是想要一个能跑的样例我把 html、css、javascript 全部合并到一个 HTML 文件里双击就能打开不需要额外依赖。代码放在文末复制后保存为mole.html即可。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title打地鼠游戏 - 项目分析完整代码/title style * { box-sizing: border-box; user-select: none; } body { margin: 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; background: linear-gradient(135deg, #1a2a3a, #243b55); font-family: Arial, Microsoft YaHei, sans-serif; } .game { width: 90%; max-width: 480px; padding: 20px 20px 24px; background: #f7edd8; border-radius: 20px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); } .game-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .info { font-size: 18px; font-weight: bold; color: #334; } #startBtn { padding: 8px 18px; font-size: 16px; font-weight: bold; border: none; border-radius: 8px; background: #ff8c42; color: #fff; cursor: pointer; box-shadow: 0 4px 0 #b3541e; } #startBtn:active { transform: translateY(2px); box-shadow: 0 2px 0 #b3541e; } #startBtn:disabled { background: #aaa; box-shadow: none; cursor: not-allowed; } .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } .cell { position: relative; aspect-ratio: 1; background: #7a5230; border-radius: 50%; overflow: hidden; cursor: pointer; box-shadow: inset 0 6px 10px rgba(0, 0, 0, 0.45); } .cell::after { content: ; position: absolute; left: 10%; right: 10%; bottom: -8%; height: 48%; background: #3e2c1c; border-radius: 50%; z-index: 2; } .mole { position: absolute; left: 50%; bottom: 12%; width: 70%; height: 55%; transform: translateX(-50%) translateY(130%); background: #d9913a; border-radius: 50% 50% 42% 42%; transition: transform 0.15s ease-out; z-index: 1; } .mole::before { content: ; position: absolute; top: 24%; left: 22%; width: 12%; height: 16%; background: #222; border-radius: 50%; box-shadow: 55px 0 0 #222; } .mole::after { content: ; position: absolute; top: 44%; left: 32%; width: 36%; height: 12%; background: #8a5a2b; border-radius: 0 0 8px 8px; } .cell.up .mole { transform: translateX(-50%) translateY(0); } .cell.hit::before { content: ; position: absolute; left: 50%; top: 50%; width: 20px; height: 20px; margin: -10px 0 0 -10px; border-radius: 50%; background: rgba(255, 255, 255, 0.75); animation: ripple 0.35s ease-out forwards; z-index: 3; } keyframes ripple { from { transform: scale(0.3); opacity: 0.8; } to { transform: scale(4); opacity: 0; } } /style /head body div classgame header classgame-header div classinfo得分span idscore0/span/div div classinfo剩余span idtime30/spans/div button idstartBtn开始游戏/button /header div classgrid idgrid/div /div script const CONFIG { totalTime: 30, spawnInterval: 850, hideMin: 700, hideMax: 1300 }; const grid document.getElementById(grid); const scoreEl document.getElementById(score); const timeEl document.getElementById(time); const startBtn document.getElementById(startBtn); let score 0; let time CONFIG.totalTime; let isRunning false; let spawnTimer null; let countdownTimer null; let previousCell -1; const cellTimers new Map(); for (let i 0; i 9; i) { const cell document.createElement(div); cell.className cell; cell.dataset.index i; const mole document.createElement(div); mole.className mole; cell.appendChild(mole); grid.appendChild(cell); } const cells Array.from(document.querySelectorAll(.cell)); function randomCell() { if (cells.length 1) { return cells[0]; } let idx; do { idx Math.floor(Math.random() * cells.length); } while (idx previousCell); previousCell idx; return cells[idx]; } function showMole() { if (!isRunning) return; const cell randomCell(); if (cellTimers.has(cell)) { clearTimeout(cellTimers.get(cell)); cellTimers.delete(cell); } cell.classList.remove(up, hit); void cell.offsetWidth; cell.classList.add(up); const hideDelay CONFIG.hideMin Math.random() * (CONFIG.hideMax - CONFIG.hideMin); const hideTimer setTimeout(() { cell.classList.remove(up); cellTimers.delete(cell); }, hideDelay); cellTimers.set(cell, hideTimer); } function startGame() { if (isRunning) return; clearAllTimers(); score 0; time CONFIG.totalTime; isRunning true; startBtn.disabled true; scoreEl.textContent 0; timeEl.textContent time; cells.forEach(cell cell.classList.remove(up, hit)); showMole(); spawnTimer setInterval(showMole, CONFIG.spawnInterval); countdownTimer setInterval(() { time - 1; timeEl.textContent time; if (time 0) { endGame(); } }, 1000); } function endGame() { clearAllTimers(); isRunning false; startBtn.disabled false; cells.forEach(cell { cell.classList.remove(up, hit); if (cellTimers.has(cell)) { clearTimeout(cellTimers.get(cell)); cellTimers.delete(cell); } }); alert(游戏结束得分 score); } function clearAllTimers() { if (spawnTimer) { clearInterval(spawnTimer); spawnTimer null; } if (countdownTimer) { clearInterval(countdownTimer); countdownTimer null; } cells.forEach(cell { if (cellTimers.has(cell)) { clearTimeout(cellTimers.get(cell)); cellTimers.delete(cell); } }); } grid.addEventListener(click, (e) { const cell e.target.closest(.cell); if (!cell) return; if (!cell.classList.contains(up)) return; if (cellTimers.has(cell)) { clearTimeout(cellTimers.get(cell)); cellTimers.delete(cell); } score 1; scoreEl.textContent score; cell.classList.remove(hit); void cell.offsetWidth; cell.classList.add(hit); cell.classList.remove(up); }); startBtn.addEventListener(click, startGame); /script /body /html5.2 运行方式与参数微调代码保存后双击mole.html就能在浏览器里跑起来。如果你在用 VS Code也可以装一个 Live Server 插件右键 Open with Live Server 使用这样可以顺带调试控制台。游戏逻辑非常简单最需要调整的是CONFIG里那四个参数参数默认值影响totalTime30一局的总时长单位秒spawnInterval850多久生成一只新地鼠单位毫秒越小越难hideMin700地鼠最少停留时长单位毫秒hideMax1300地鼠最大停留时长单位毫秒想给朋友玩简单模式就把spawnInterval调到1000hideMin调到1000。想自我挑战就调到450和500。调完刷新页面立即生效不需要改其他地方。6. 常见问题与排查技巧实录6.1 点击没反应分数不涨这个现象九成出在命中判定上。我早期 debug 的时候发现点击地鼠边缘偶尔没反应后来定位到是使用了e.target.tagName判断但地鼠的伪元素和缝隙会导致target不确定。如果你也遇到点击无效先别怀疑事件没绑定打开控制台在 click 回调里打一行console.log(e.target)看看点击时拿到的到底是什么元素。完整代码里我用closest(.cell)加classList.contains(up)两段判断基本能杜绝这类问题。6.2 地鼠还没看清就消失了多半是停留时间设得太短或者出现了定时器覆盖。前者调大hideMin和hideMax即可。后者比较隐蔽第一只地鼠的隐藏setTimeout还没到时间第二只出鼠又选到了同一个洞新地鼠刚加上up旧定时器触发把up移除了。我在完整代码里用cellTimers在生成新地鼠前主动清掉旧定时器就是为了解决这个时序问题。如果你自己写的时候跳过这个细节地鼠会出现“秒回洞”的灵异现象。6.3 多次点击开始游戏越来越乱这是定时器泄漏的典型症状。解决办法只有一条游戏进入运行态之前统一clearAllTimers()。我在完整代码里每次startGame开头都做了清理同时在endGame里也清理一次。养成这个习惯后即使之后加了“重新开始”“下一关”之类的入口也不会因为入口多了而状态失控。还有一个容易忽略的细节alert(游戏结束...)弹出后用户停留在对话框期间如果有定时器没有被清掉游戏后台还会继续跑。所以我建议结束逻辑里第一件事就清定时器再弹结果顺序不要反。6.4 动画有拖影或者卡顿打地鼠的出洞动画我坚持用transform和opacity这两个属性都能走 GPU 合成不会造成大面积重排。如果你发现动画卡顿检查一下是不是在地鼠颜色上有带阴影的复杂绘制或者.cell的overflow: hidden配合圆角在某些浏览器里触发了过重的合成层。一个简单优化是给.mole加上will-change: transform;但注意不要滥用这个项目里加上它体感提升有限却会在部分设备上占用额外内存。拖影的问题通常来自过渡时间太长。transition: transform 0.15s ease-out已经是我试下来最跟手的值低于0.1s会显得生硬高于0.25s就会有明显的“黏滞感”。如果你改了时间记得连点击手感一起评估。最后再分享一个小技巧如果你把这个项目扩展成双人模式只需要把score变成两个变量再加一个turn标志逻辑框架完全不用动。这个项目最适合的用法不是看一遍代码就过而是自己徒手重写一遍然后在跑通的基础上去改参数、加音效、换皮肤。等你改完一轮前端入门这条最难的路基本就走顺了。