简介基于HTML、CSS与JavaScript实现的打地鼠小游戏源码包适合前端初学者和游戏开发爱好者作为休闲娱乐练习项目也可用于课堂演示或作品集素材。压缩包共26个文件约2.74MB含1个HTML主页面、1个CSS样式文件、9个JavaScript脚本含jQuery、Zepto等常用库、10张PNG与1张JPG图片以及MP3和WAV音效文件从页面结构、视觉风格到点击交互、计分和时间控制均完整实现。已有135人学习下载。项目目录结构清晰readme.txt附带使用说明打开index.html即可直接运行。通过阅读源码可快速理解原生前端三件套如何协同完成一个小型游戏包括事件监听、DOM操作、碰撞判定与音频播放适合用来巩固基础并在此基础上扩展功能。1. 基于原生 JavaScript 的打地鼠游戏为什么值得自己写一遍很多人觉得打地鼠这种小游戏太简单直接找一份源码改改就能交差。但真到面试聊项目、或者想把它塞进自己的作品集时才发现问题不少用setInterval控制地鼠弹出结果页面切后台再切回来地鼠直接“瞬移”点击事件绑在图片上地鼠消失的瞬间点击丢失想调难度只能在代码里到处改魔法数字。这些坑恰恰是“基于 JavaScript 的 HTMLCSS 打地鼠小游戏设计源码”这个标题真正值钱的地方——它不是一个玩具而是一套完整的 DOM 操作、事件机制和动画调度的练手项目。这篇博文我会带你从零搭一个打地鼠游戏不使用任何框架纯 HTML CSS JavaScript 实现。你可以直接把它当源码用也可以按文中的思路重构。文章会覆盖井字格布局、地鼠随机弹出逻辑、点击判定原理、分数与倒计时状态管理以及 HBuilderX 和浏览器的调试技巧。适合刚学完 JavaScript 基础、想做一个拿得出手的小项目的开发者也适合在教前端入门、需要一份结构清晰参考代码的人。读完你应该能回答三个问题为什么地鼠弹出要用“定时器 随机数 延迟”而不是简单循环明明点了地鼠却没加分问题出在哪难度参数到底应该调哪些值。2. 先用 HTML CSS 把场地搭起来井字格与地鼠的三种状态2.1 HTML 骨架列表比div更适合做格子打地鼠游戏的地图通常是 3×3 的井字格。很多人下意识用九个div平铺这没问题但从语义和样式控制上我会推荐用ul/li结构。这样写的好处有两个一是ul天然表示一组同类元素屏幕阅读器和爬虫都能识别二是用flex或grid布局时li的宽度可以用flex: 0 0 33.33%直接控制不需要额外计算边距。一个最小可用的结构长这样!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title打地鼠小游戏/title link relstylesheet hrefstyle.css /head body div classgame-wrapper h1打地鼠/h1 div classscore-board span得分strong idscore0/strong/span span时间strong idtime30/strongs/span button idstartBtn开始游戏/button /div ul classgame-grid idgrid !-- 9个li每个li包含一个洞和一只地鼠 -- /ul /div script srcgame.js/script /body /html这里的div.game-wrapper是整体容器用来控制游戏区域的宽度div.score-board是记分板后续所有状态更新都通过这个区域里的元素来实现。ul#grid是核心容器9 个格子由 JavaScript 动态生成而不是写死在 HTML 里——这一步很重要因为后面你要调整行列数比如改成 4×4 或 5×5时只改game.js里的一个配置项就行。2.2 为什么li要包含“洞”和“地鼠”两个子元素每个li内部需要两层结构才能实现“地鼠从洞里钻出来”的视觉效果li classcell div classhole/div div classmole>.cell { width: 120px; height: 120px; position: relative; overflow: hidden; list-style: none; } .hole { position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 80px; height: 40px; background: #3a2e1e; border-radius: 50%; z-index: 1; } .mole { position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%) translateY(100%); width: 60px; height: 60px; background: #a67c52; border-radius: 50% 50% 30% 30%; cursor: pointer; transition: transform 0.15s ease; z-index: 2; } .mole.active { transform: translateX(-50%) translateY(0); }提示overflow: hidden是必需的否则地鼠缩回时身体会溢出到格子外面视觉上就露馅了。3. JavaScript 核心逻辑随机弹出、事件绑定与定时器的坑3.1 游戏状态机用对象管理数据而不是散落的全局变量写这次打地鼠游戏以前我也见过有人用var score 0; var time 30;这种散落全局变量写完整套逻辑。代码少时没问题但只要想加“暂停”“继续”“最高分记录”任一功能全局变量的维护成本就立刻失控。更好做法是先定义一个游戏对象const game { score: 0, timeLeft: 30, isPlaying: false, moleTimer: null, countdownTimer: null, hitCount: 0, // 命中次数 totalShots: 0, // 总点击次数 cells: [], config: { gridSize: 3, appearInterval: [600, 1200], // 地鼠出现间隔 ms 范围 activeDuration: 800, // 地鼠停留时间 ms totalTime: 30 // 游戏时长 s } };为什么把appearInterval设计成数组[600, 1200]因为地鼠不应该以固定频率弹出——固定频率玩家很容易找到节奏游戏就失去了挑战性。正确做法是每次弹出地鼠后在 600 到 1200 毫秒之间随机取一个值作为下一次弹出的等待时间。这个随机区间是整个游戏难度的核心调节手段后面第 5 章会专门讲怎么调它来改变难度曲线。hitCount和totalShots是为命中率统计预留的字段。虽然在基础版里可以不展示但加上这两个字段后续你想做“连击奖励”或“精准度评价”时就不需要重构代码。3.2 生成格子DOM 操作与事件委托的取舍初始化格子的代码很直观function createGrid() { const grid document.getElementById(grid); grid.innerHTML ; game.cells []; for (let i 0; i game.config.gridSize * game.config.gridSize; i) { const li document.createElement(li); li.className cell; const hole document.createElement(div); hole.className hole; const mole document.createElement(div); mole.className mole; mole.dataset.id i; li.appendChild(hole); li.appendChild(mole); grid.appendChild(li); game.cells.push(mole); } }这里有两个选择值得解释第一为什么用document.createElement而不是innerHTML 因为innerHTML每次赋值都会触发浏览器的 HTML 解析器9 个格子在性能上虽然看不出差距但这个习惯在更大规模的渲染任务里会很吃亏。而且createElement的方式可以直接拿到 DOM 对象引用放进game.cells数组里后续操作元素时不需要再querySelector。第二点击事件绑在哪我给.mole直接绑了监听器而不是用事件委托。事件委托的写法是给ul#grid绑监听器然后通过event.target判断点到的是不是.mole。这两种方案在小游戏里都能工作但直接绑在.mole上逻辑更清晰——因为每只地鼠有自己的dataset.id命中后直接通过event.target找到对象并移除样式就行。事件委托更适合动态生成且数量不明的元素比如无限加载列表。我们这个游戏格子数量固定直接绑定即可。3.3 地鼠弹出机制setTimeout链比setInterval更优雅这是整个游戏的核心。很多新手会写function showMole() { // 随机选一个格子加上 active } setInterval(showMole, 1000);这段代码最大的问题是如果地鼠停留在界面上的时间比如 800ms和setInterval的间隔1000ms不同步会出现在一只地鼠还没缩回去时就弹出另一只的情况。处理这个问题的常规做法是用setTimeout链——每次地鼠缩回后才计算下一次弹出的时间。function showRandomMole() { if (!game.isPlaying) return; // 从所有格子里随机选一个 const randomIndex Math.floor(Math.random() * game.cells.length); const mole game.cells[randomIndex]; mole.classList.add(active); // 停留 activeDuration 毫秒后缩回 setTimeout(() { mole.classList.remove(active); // 缩回之后等待一小段随机时间再弹出下一只 const [min, max] game.config.appearInterval; const delay min Math.random() * (max - min); game.moleTimer setTimeout(showRandomMole, delay); }, game.config.activeDuration); }这里的时间顺序是点击开始游戏 → 调用showRandomMole()→ 地鼠加上.active类 → 保持 800ms → 移除.active→ 地鼠缩回动画持续 150ms → 等待 600~1200ms 随机延迟 → 调用下一次showRandomMole()。这个链条里面有一个容易被忽略的细节mole.classList.remove(active)之后地鼠的缩回动画还在播放150ms 的transition而这时我们已经开始计算下一次弹出了。如果上一次缩回动画还没结束下一次弹出又加上了.active那么过渡会被强行打断地鼠可能看起来在中间位置跳一下。官方小程序里一般不会遇到这个问题因为动画时间很短150ms而最小间隔是 600ms基本不会冲突。但如果你把activeDuration调到 1000ms 以上或者把appearInterval的下界调低到 200ms就会看到这个 bug。解决办法是记录上一次地鼠缩回的完成回调在下一次弹出前检查状态或者干脆让地鼠缩回后再等一个固定动画时长150ms才开始计时。3.4 点击判定为什么“点到了却没加分”点击判定的代码比想象中要复杂一点因为涉及“地鼠正在消失”“重复点击同一只地鼠”“点到洞而不是地鼠”三种情况function handleMoleClick(event) { if (!game.isPlaying) return; const mole event.target; // 如果点击的是地鼠且地鼠处于 active 状态 if (mole.classList.contains(active)) { game.score; game.hitCount; document.getElementById(score).textContent game.score; // 加分后立即缩回给玩家视觉反馈 mole.classList.remove(active); mole.classList.add(hit); // hit 动画结束后移除类名 setTimeout(() { mole.classList.remove(hit); }, 300); } game.totalShots; }注意event.target并不一定是你绑定监听器的那个元素。如果地鼠内部有子元素比如眼睛、帽子这些装饰性div点击时event.target是子元素而不是.mole。所以要么保证.mole内没有子元素要么用closest()const mole event.target.closest(.mole); if (mole) { ... }closest方法会从当前元素向上遍历直到找到匹配选择器的祖先元素。这是处理嵌套结构中点击事件的通用解法。还有一个隐藏比较深的 bug玩家在极短时间内连续点击同一只地鼠会加两次分。因为第一次点击后地鼠可能还没被移除.active类第二次点击就进来了。我的处理方式是在加分后立刻把分数更新为game.score但这不能防止同一帧内的第二次回调。严谨点的方案是给每只地鼠加一个isHit属性点击后立即置为true避免重复触发if (mole.classList.contains(active) !mole.dataset.hit) { mole.dataset.hit true; // ...加分逻辑 setTimeout(() { delete mole.dataset.hit; }, 400); }3.5 倒计时与游戏结束清定时器是重点倒计时的标准写法是setInterval每秒减一秒但这有一个坑浏览器标签页在后台时定时器会大幅降频甚至暂停玩家切回页面时会发现时间漏了一大块。这个问题在 PC 端表现明显在移动端锁屏后更严重。一个更可靠的做法是用时间戳计算真实流逝时间function startCountdown() { const endTime Date.now() game.config.totalTime * 1000; game.countdownTimer setInterval(() { const remaining Math.round((endTime - Date.now()) / 1000); if (remaining 0) { clearInterval(game.countdownTimer); gameOver(); return; } game.timeLeft remaining; document.getElementById(time).textContent remaining; }, 200); // 每 200ms 检查一次而不是每秒 }把setInterval的间隔设为 200ms配合Date.now()计算剩余秒数即使定时器被浏览器节流到 1 秒一次页面回到前台时也会立即校准不会出现时间“漏走”的情况。这是移动端 H5 游戏里非常常见的时间处理方案。游戏结束时要清理所有定时器否则会出现“游戏已经结束但地鼠还在弹、倒计时还在走”的鬼畜现象function gameOver() { game.isPlaying false; clearTimeout(game.moleTimer); clearTimeout(game.moleHideTimer); clearInterval(game.countdownTimer); // 把所有地鼠缩回去 game.cells.forEach(mole mole.classList.remove(active)); alert(游戏结束得分 game.score); }4. 完整流程整合开始游戏、重置状态与 HBuilderX 本地调试4.1 按钮事件与状态初始化把上面所有片段整合进一个主函数并绑定到“开始游戏”按钮上function init() { createGrid(); document.getElementById(startBtn).addEventListener(click, () { if (game.isPlaying) return; // 防止重复点击 startGame(); }); // 给每个格子的地鼠绑定点击事件 game.cells.forEach(mole { mole.addEventListener(click, handleMoleClick); }); } function startGame() { // 重置状态 game.score 0; game.timeLeft game.config.totalTime; game.hitCount 0; game.totalShots 0; game.isPlaying true; document.getElementById(score).textContent 0; document.getElementById(time).textContent game.config.totalTime; document.getElementById(startBtn).disabled true; startCountdown(); showRandomMole(); // 第一个地鼠立即弹出 } document.addEventListener(DOMContentLoaded, init);这个startGame()里的顺序很重要先重置数据和 UI再启动定时器最后弹出第一只地鼠。如果先把地鼠弹出来再重置状态可能出现玩家在毫秒级差距内点击地鼠触发加分逻辑但此时game.score还没归零——虽然概率极低但这是“偶现 bug”最常见的来源。4.2 用 HBuilderX 跑起来预览与内置服务器HBuilderX 对 HTMLCSSJavaScript 这类静态项目的支持非常成熟。它的内置预览功能不通过文件协议file://打开页面而是起了一个本地 HTTP 服务这意味着你可以绕过浏览器的本地文件安全限制正常加载外部资源。操作方法是右键项目 → “运行” → “运行到浏览器”或者用快捷键CtrlF5直接打开。这里有一个区分点值得说明直接用浏览器打开本地 HTML 文件file:///...和通过本地服务器http://localhost:xxxx打开对包含fetch、XMLHttpRequest、ES6 Module的页面来说行为完全不一样。我们的打地鼠游戏只用到了 DOM API 和原生 JavaScript理论上双击文件也能工作但做后续扩展比如把最高分存到localStorage或请求一个排行榜接口时HBuilderX 内置服务器会更接近生产环境。调试时我会同时开启浏览器的开发者工具重点观察三个面板Console看 JavaScript 报错、Sources打断点调试game.js、Performance录制一段游戏过程看有没有掉帧。初学者最容易犯的错误是 Console 面板亮了红色报错却不看然后整个页面动不起来。常见的报错有三个报错信息原因解决方法Cannot set property innerHTML of nullJavaScript 执行时 DOM 还没加载完成把script放在/body前或用DOMContentLoaded包裹mole is undefinedcreateGrid()没有先于事件绑定执行确认init()中createGrid()在最前面Unexpected token语法错误通常是中文标点混入了 JavaScript检查引号、分号、括号是否为英文半角4.3 键盘调试与命中反馈给游戏加一个隐藏的调试快捷键能大幅提升测试效率。点击地鼠需要真实的鼠标操作这在反复调试时很累。我习惯加一个最小化的内置调试接口按D键直接加 10 分按T键把倒计时跳到 5 秒方便快速验证游戏结束逻辑。document.addEventListener(keydown, (event) { if (event.key d game.isPlaying) { game.score 10; document.getElementById(score).textContent game.score; } if (event.key t game.isPlaying) { game.timeLeft 5; document.getElementById(time).textContent 5; } });这个代码不是给最终用户用的但值得写。它对应着一个非常关键的工程经验留一个可控的后门来让测试路径变短。游戏逻辑越复杂这个后门越值钱。5. 让游戏手感更好命中率、随机分布和移动端适配5.1 地鼠弹出策略改“完全随机”为“就近不重复”纯粹用Math.random()选格子会导致一种情况同一个格子在连续几次弹出中反复被选中另一些格子则长时间完全空闲。这在玩家眼里会显得很“假”——真实游戏应该尽量让地鼠在九个格子间比较均匀地出现。改进方案是记录上一次弹出的格子索引本次随机时排除它let lastIndex -1; function showRandomMole() { // 从排除 lastIndex 的剩余格子里随机选 let randomIndex; do { randomIndex Math.floor(Math.random() * game.cells.length); } while (randomIndex lastIndex); lastIndex randomIndex; // ...后续逻辑 }do...while在这里比while合适因为我们要保证至少执行一次随机选择。9 个格子中排除 1 个冲突概率约 11%循环最多多执行一次不会有效率问题。从概率分布的角度看这个改动让相邻两次的地鼠不出现在同一格玩家体验提升明显代价几乎为零。进阶版本还可以再加一条规则不允许出现在上一只的相邻格子上下左右这样地鼠的移动轨迹看起来更随机但代码就要引入格子坐标换算一般 3×3 的案例不需要做这一步。5.2 难度曲线把参数抽到配置对象里游戏难度不应该靠“到某个时间点硬切换”更平滑的方式是让参数随时间变化。比如游戏总时长 30 秒前 10 秒地鼠停留 900ms中 10 秒 700ms后 10 秒 500ms。实现方式不需要引入复杂的状态机在每一次弹出时根据剩余时间动态计算即可function getActiveDuration() { const elapsed game.config.totalTime - game.timeLeft; if (elapsed 10) return 900; if (elapsed 20) return 700; return 500; }然后showRandomMole()里把game.config.activeDuration换成getActiveDuration()。同时appearInterval也可以做相同处理后段让地鼠出现得更频繁。我这里把参数分成“硬编码”和“配置化”两层gridSize、totalTime属于结构参数一般开局就固定activeDuration、appearInterval属于手感参数应该支持游戏过程中动态变化。两层分离的好处是测试时可以快速调手感而不影响整体架构。5.3 移动端适配响应式、触摸和 300ms 延迟手机浏览器上唯一拦路的问题是点击延迟。现代的移动浏览器已经移除了 300ms 延迟但为了兼容性click事件在触摸设备上仍可能有一点点延迟。打地鼠这种需要快速反应的游戏建议同时监听touchstartmole.addEventListener(touchstart, (e) { e.preventDefault(); // 防止触发点击事件造成双重计数 handleMoleClick(e); });注意e.preventDefault()会阻止同元素的click事件触发避免了“点一次计两次分”的问题。桌面端继续用click移动端用touchstart两者互不干扰。CSS 侧给html加一行既能解决双击缩放又能解决触摸高亮html { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }touch-action: manipulation允许浏览器忽略双击放大的手势同时保留其他手势操作是移动端游戏页面最实用的属性之一。这比以往用user-scalableno禁缩放要温和得多也不会触发浏览器的可访问性警告。5.4 视觉反馈打中后的“打击感”从哪里来源码里很多人不做打击反馈导致玩家点中了地鼠除了分数变了 1 之外没有任何感觉。“打击感”本质上是三件事的组合位置偏移、缩小和短暂的颜色变化。我用的方案是给打中的地鼠加一个.hit类.mole.hit { transform: translateX(-50%) translateY(0) scale(0.7); background: #d4845c; filter: brightness(0.8); transition: transform 0.05s ease; }transition缩短到 0.05s 是关键——被打中时要快速缩放而不是缓慢缩小快速才有“被敲打”的冲击感。原来的 0.15s 是给“地鼠钻洞”用的两者场景不同不应共用同一个值。类名在 300ms 后移除地鼠恢复到正常状态下次弹出时不会残留缩小效果。6. 验证源码能不能跑三个快速检查与一个隐藏技巧写完源码之后先别急着加功能用下面三个方法验证它是否健壮。第一打开浏览器控制台在游戏运行中手动调用game.isPlaying false;观察所有定时器是否被清理。一个健壮的游戏应该在外部强制停止时也能安静退出不会在控制台报错。如果此时还有地鼠在弹说明gameOver()里的clearTimeout没有覆盖到所有句柄。第二用谷歌浏览器开发者工具的 Performance 面板录一段 10 秒的游戏过程。观察有没有超过 50ms 的长任务——打地鼠游戏的 JavaScript 逻辑只涉及定时器和 DOM 类切换如果出现长任务多半是你不小心在handleMoleClick里做了复杂计算比如调用了JSON.stringify存 localStorage。第三切到移动端模拟器CtrlShiftM验证触摸事件不会导致计分两次。快速连点一只地鼠如果分数一次涨 2 或更多说明dataset.hit防重复机制没有生效检查是否在第一次点击后立即设置了标记。最后分享一个我习惯藏在源码注释里的技巧把游戏的状态打印到页面标题上。在handleMoleClick的加分逻辑后加一行document.title 打地鼠 ${game.score} 分 | ${game.timeLeft}s;这样当你在多个标签页之间切换时不需要逐个点开看标签页标题上就显示当前分数和剩余时间。对调试阶段的源码来说这个做法的价值在于零额外开销就能持续观察游戏状态。读者拿到源码后也可以保留这一行它不影响任何功能反而让页面在任务栏里也像一个真正的游戏。本文还有配套的精品资源点击获取