ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从打地鼠源码看HTML5小游戏开发:定时器、事件驱动与状态机

从打地鼠源码看HTML5小游戏开发:定时器、事件驱动与状态机 简介基于HTML、CSS与JavaScript实现的打地鼠小游戏完整源码面向前端初学者、网页游戏爱好者及需要课堂练手或小型项目参考的开发者提供从页面结构、界面美化到交互逻辑的完整实现链路。资源包约2.74MB共26个文件含index.html入口、style.css样式表、9个JavaScript脚本含jQuery、Zepto等库、10个PNG与1个JPG图片素材、MP3和WAV音效以及readme.txt使用说明。已有135人浏览学习。借助这套源码可直观理解HTML如何搭建游戏面板、CSS如何绘制视觉风格、JavaScript如何完成点击判定、计分计时与音效播放等核心交互逻辑。所有素材随包提供部署即可运行也便于在此基础上扩展关卡、调整难度或替换美术音效适合入门练习与二次开发。1. 从一份压缩包看 HTML5 小游戏的依赖进化收到一份打地鼠小游戏的源码压缩包解压之后你会看到index.html、style.css、js.js和一堆库文件jQuery 1.11、Zepto 1.1.6、Hammer.js 2.0.8、zepto.toch.js还有q.mp3、q.wav这类双格式音效。第一反应不是“这游戏能打”而是它把网页游戏的移动端适配、事件驱动、资源降级这些工程问题都浓缩在了 26 个文件里。这类项目特别适合拿来练手地鼠出现和消失本质上是 JavaScript 代入了一个有节奏的循环锤子敲下去是触摸事件与 DOM 状态的碰撞而那个不断上涨的分数背后是计时器、状态机和本地存储的协作。不管你是刚看完javascript基础语法的新手还是在移动端踩过坑的熟练工都能从这套源码里拆出点自己能用的东西。2. 事件驱动与游戏循环JavaScript 定时器把规则变成频率打地鼠的所有玩法几乎都围绕“频率”展开——地鼠多久探出一次头停留多少毫秒倒计时每秒扣一次。这些频率在浏览器里就是定时器回调。理解这一点之后再看源码里js.js中的逻辑会发现整个游戏其实就是一个由setInterval和clearTimeout交替驱动的状态机。2.1 setInterval 控制地鼠出现先定频率再定规则实现地鼠随机出现最直接的办法是setInterval。它按固定间隔把“让一只地鼠冒头”这个动作塞进事件队列里示例代码如下// 游戏主循环每隔 800ms 随机让一个洞产生地鼠 var holes document.querySelectorAll(.hole); var moleTimer null; var currentHole null; function showMole() { // 先清掉上一只避免两只地鼠同时冒头 clearMole(); var i Math.floor(Math.random() * holes.length); if (holes[i] currentHole) { i (i 1) % holes.length; // 避免连续出现在同一个洞 } currentHole holes[i]; currentHole.classList.add(up); playSound(q.wav); // 地鼠出现时的提示音 } function clearMole() { if (currentHole) { currentHole.classList.remove(up); currentHole null; } } function startLoop() { stopLoop(); moleTimer setInterval(showMole, 800); } function stopLoop() { if (moleTimer) { clearInterval(moleTimer); moleTimer null; } }逻辑说明setInterval返回一个整型定时器句柄必须保存在变量里之后才能通过clearInterval停住循环。classList.add(up)只负责切换 CSS 类真正的地鼠钻出动作由 CSS 过渡完成。这样写的好处是 JavaScript 不需要频繁读取style.top或style.left样式变化集中在浏览器合成层减少重排。代码里还做了防连续重复逻辑避免地鼠反复出现在同一个洞。这里有一个手感参数setInterval的间隔 800ms。低于 500ms 时玩家基本来不及反应超过 1200ms 游戏又会显得太松散。实际开发中我一般把这个值放入配置对象里方便在跑测试时快速调整。2.2 移动端的迟到事件Zepto 与 Hammer.js 在事件层做了什么源码里出现了zepto.min.v1.1.6.js、zepto.toch.js、hammer.v2.0.8.js这三个库都是为了解决移动端事件问题。Zepto 是 jQuery 的轻量替代品zepto.toch.js补充了tap事件Hammer.js 是手势库把touchstart、touchend包装成tap、swipe、press等语义化事件。常见做法是把锤子动作绑定到游戏面板上而不是给每个洞单独绑定var panel document.getElementById(game-board); var hammerManager new Hammer(panel); hammerManager.on(tap, function (ev) { // ev.center 是触点坐标可以用于计算命中哪个洞 var el document.elementFromPoint(ev.center.x, ev.center.y); var hole el ? el.closest(.hole) : null; handleHit(hole); });逻辑说明new Hammer(panel)建立了一个手势监听实例tap事件触发时携带ev.center也就是触点相对视口的坐标。document.elementFromPoint(x, y)反查该坐标上最上层的 DOM 元素再用closest(.hole)向上找到洞容器。这套写法绕开了 click 事件在移动端约 300ms 的延迟也不需要自己计算clientX与洞位置的偏移。项目里同时出现 jQuery 和 Zepto通常是为了让 PC 端和移动端共用业务脚本但实际部署时只保留一个即可。移动端还有一个常见问题手指点下去时浏览器会同时触发touchstart、touchend、click如果对同一个逻辑绑定两套事件分数可能一次加两次。我的习惯是只用 Hammer 的tap或只用 Zepto 的tap不要混用。2.3 定时器方案选型setInterval、setTimeout 还是 requestAnimationFrame游戏循环不止setInterval一种实现方式。下面是三种常见方案的对比方案触发方式适合场景注意事项setInterval(fn, ms)按固定毫秒重复地鼠出现、倒计时后台运行时会节流可能造成时间跳变setTimeout(fn, ms)递归每次执行后再次注册节奏不固定的任务容易遗忘取消造成重复启动requestAnimationFrame(fn)每次浏览器绘制前触发动画、连续位移频率接近屏幕刷新率不适合做秒级倒计时打地鼠这类游戏地鼠出现用setInterval就够了因为并不需要帧级精度如果要给锤子做残影、给地鼠做被砸扁的动画用requestAnimationFrame更平滑。需要注意的是浏览器在页面切到后台时会自动节流定时器回来时如果只是简单执行timeLeft--时间会少扣。更严谨的做法是在回调里用Date.now()计算实际流逝的毫秒再换算秒数。这套源码没有做这一步但这是初学者最容易忽略的边界条件。3. CSS 布局与点击判定伪类、位移动画和命中坐标地鼠游戏外观简单但要做到“打得准”核心在于两件事洞与地鼠的几何关系怎么表达以及点击事件如何与视觉对齐。CSS 负责前者JavaScript 的事件委托负责后者。3.1 用 CSS Grid 搭洞背景图与九宫格的对应关系源码中的bg_canvas.png和next.png说明场景不是纯色块而是背景图加独立角色。常见做法是先准备一张带洞的背景图再用九个.hole元素压在对应位置用 CSS Grid 排列成三行三列div idgame-board>#game-board { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); width: 300px; height: 300px; background: url(bg_canvas.png) center / cover no-repeat; position: relative; } .hole { position: relative; overflow: hidden; } .mole { width: 60%; height: 70%; margin: 0 auto; background: url(mouse.png) center / contain no-repeat; transform: translateY(110%); transition: transform 0.12s ease-out; } .hole.up .mole { transform: translateY(0); }逻辑说明Grid 布局保证了九个洞在棋盘上的均匀分布背景图只负责提供木桌、草地纹理。地鼠mouse.png放在.mole中默认用translateY(110%)藏进洞底部当.hole加上up类时位移归零地鼠看起来是从背景里钻出来的。.hole必须设置overflow: hidden否则地鼠会漏到洞外。使用transform而不是top或bottom是因为位移不触发整页布局合成器可以直接处理动画。这里也涉及css伪类选择器怎么用的问题。如果想在鼠标悬停时提示某个洞可以打可以写.hole:not(.up):hover但触摸设备没有 hover 概念所以移动端更多是靠 JS 临时加.hit类做反馈。3.2 锤子的视觉反馈鼠标移入事件与动画设计源码里的hammer1.png应该是一把锤子这类元素通常跟随鼠标或触点移动。PC 端要感知鼠标连续坐标必须监听mousemove因为 CSS 的:hover只能改样式拿不到动态坐标var hammerImg document.getElementById(hammer); document.addEventListener(mousemove, function (e) { hammerImg.style.left e.clientX px; hammerImg.style.top e.clientY px; });逻辑说明clientX/clientY是相对视口的坐标直接把锤子元素的left和top设为这两个值就能让锤子跟随光标。为了避免遮挡地鼠锤子要设置绝对定位、缩小尺寸并隐藏鼠标原光标。真正“砸下去”的手感来自命中瞬间的动画可以给画布临时加一个类keyframes hammer-hit { 0% { transform: rotate(-30deg) scale(1); } 50% { transform: rotate(-60deg) scale(1.15); } 100% { transform: rotate(-30deg) scale(1); } } .hit-effect { animation: hammer-hit 0.08s ease-out; }这里用的是css动画的极简写法时长 80ms 左右正好符合一次快速敲击的视觉节奏。如果需要做地鼠被打中后变成星星的效果通常也就是在点击后把.mole的图片替换成star.png再加上一个向上飘的位移动画。分数数字如果要做得更花哨可以用css 字体渐变用background渐变配合-webkit-background-clip: text实现。3.3 命中判定不靠“看见”事件委托与 closest 搜索命中判定的关键不是“鼠标坐标是否落在地鼠图片的范围内”而是“点击的 DOM 元素是否属于一个处于up状态的洞”。利用事件委托可以只绑定一次父容器var board document.getElementById(game-board); board.addEventListener(click, function (e) { var hitEl e.target; if (!hitEl) return; var hole hitEl.closest ? hitEl.closest(.hole) : null; if (!hole) return; if (hole.classList.contains(up)) { hole.classList.remove(up); hole.classList.add(hit); updateScore(1); playSound(123.mp3); } });逻辑说明e.target是实际被点击的元素可能是地鼠图片、洞的空白区域或装饰元素。closest(.hole)从该元素开始向上查找找到最近的洞容器这一步代替了手写坐标范围判断。真正决定是否得分的是classList.contains(up)因为只有地鼠探头的洞才有效。hit临时类可以让 CSS 把地鼠图片替换成dd.png或触发一个缩小动画。这种写法的另一个好处是如果后续增加特殊地鼠或道具只需要修改closest的目标类名不需要给每个洞分别绑定事件。4. 计分、倒计时与本地纪录状态机、音频恢复和存储抽象当游戏从“打中”扩展成“有时限的竞技”就需要控制完整流程。最简单的方式是引入状态机把ready、playing、over三种状态之间的迁移规则显式写出来。4.1 状态机ready、playing、over 之间的迁移状态机的作用是避免玩家在结算界面还能打到地鼠、或在未开始时倒计时就偷跑。下面是一个极简实现var state ready; var timer null; function changeState(next) { var allowed { ready: [playing], playing: [over, ready], over: [ready] }; if (allowed[state].indexOf(next) -1) { throw new Error(invalid state: state - next); } state next; } function startGame() { if (state ! ready) return; changeState(playing); resetScore(); startCountdown(); startLoop(); } function endGame() { if (state ! playing) return; changeState(over); stopLoop(); saveHighScore(); }逻辑说明allowed对象定义了合法的迁移路径比如ready只能去playingplaying只能去over或ready。如果代码里出现非法调用会直接抛出错误方便在开发阶段发现问题。状态机的好处在于把“当前状态”变成唯一事实来源所有按钮点击、定时器回调、触摸事件都先检查状态再执行动作。我把状态对应关系整理成了表方便对照状态地鼠循环倒计时点击事件界面显示ready关闭静止忽略开始按钮、规则说明playing运行中递减计分分数板、地鼠、计时器over关闭归零忽略游戏结束背景、最高分实际项目中状态值还可以扩展成paused但引入新状态时必须同步修改allowed表否则会出现“暂停了还能继续加分”的逻辑漏洞。4.2 音效播放的兼容层自动播放限制与双格式降级源码里同时出现了q.mp3和q.wav这是典型的资源降级策略。浏览器对 MP3 的支持已经非常普遍但老版本 Safari 对 WAV 更友好所以代码里通常会做两层var audioCache {}; function getAudio(name) { if (audioCache[name]) return audioCache[name]; var audio new Audio(); var sources { q: [q.mp3, q.wav], hit: [123.mp3, 123.wav] }; var list sources[name] || [name]; audio.preload auto; list.forEach(function (url) { var source document.createElement(source); source.src url; audio.appendChild(source); }); audioCache[name] audio; return audio; } function playSound(name) { var player getAudio(name); player.currentTime 0; var p player.play(); if (p p.catch) { p.catch(function () { // 浏览器阻止自动播放时静默处理 }); } }逻辑说明Audio对象可以追加多个source子元素浏览器会按顺序选择第一个能播放的格式。player.play()在移动端可能被浏览器拦截返回的 Promise 会执行 reject所以必须用catch接住。更稳妥的做法是在用户首次点击页面时主动调用一次playSound(q)相当于“解锁”音频上下文之后再播放就不会被拦。这个细节很容易被忽略如果玩家第一次点击锤子时没有先解锁音频后续所有音效都可能一直安静。源码里同时保留123.mp3和q.wav说明原作者已经考虑过格式降级而不是随意丢两个音频进目录。4.3 最高分记录localStorage 与 Ajaxdb.js 的启发源码里有一个Ajaxdb.js从名字看像是异步数据库封装。但在纯静态小游戏场景下它更像是对本地存储的抽象封装。最高分记录用localStorage就足够function loadHighScore() { var raw localStorage.getItem(whack-high-score); if (!raw) return 0; try { var data JSON.parse(raw); return data.score || 0; } catch (e) { return 0; } } function saveHighScore(newScore) { var old loadHighScore(); if (newScore old) return; var record { score: newScore, timestamp: Date.now(), version: 1 }; localStorage.setItem(whack-high-score, JSON.stringify(record)); }逻辑说明localStorage只能保存字符串所以需要把分数、时间戳和版本号打包成 JSON 字符串。读取时用JSON.parse还原这里要用try/catch容错因为玩家可能手动改过 localStorage 导致数据损坏。如果希望合并新旧字段也可以用Object.assign({}, oldRecord, record)这正是javascript合并两个对象的典型场景。至于Ajaxdb.js我倾向于把它理解成一个存储抽象层上层只调用db.save(highScore, data)下层内部决定用 localStorage、IndexedDB 还是服务端接口。这样以后如果要加排行榜不用改计分逻辑只需要替换存储实现。5. 调试与调参把地鼠速度改成适合手速的节奏这个项目要跑起来不能只是双击index.html。因为页面里引用了多个脚本和音频资源直接通过file://协议打开容易出现跨域或资源加载问题正确做法是起一个本地静态服务器。5.1 起一个本地静态服务如果本机已经安装了 Python进入源码目录后执行cd upload python -m http.server 8080然后访问http://localhost:8080。如果更习惯 PHP 环境也可以php -S 127.0.0.1:8080第一条命令会在当前目录启动静态文件服务端口可以换成任意未被占用的值。手机调试时让手机和电脑处于同一局域网再用python -m http.server 0.0.0.0:8080绑定所有网卡通过电脑局域网 IP 访问。注意目录路径不要有中文或空格否则某些静态资源路径会解析出错。5.2 游戏参数速查表打开js.js和style.css最容易调的就是下面几个参数用途参数位置推荐值地鼠出现间隔setInterval(showMole, 800)600 - 1000ms单只地鼠停留时间.up类的过渡时长400 - 800ms游戏时长timeLeft 3020 - 60 秒音效音量audio.volume 0.50.3 - 0.8锤子挥动动画animation: hammer-hit 0.08s0.06 - 0.12s注意改“地鼠出现间隔”时如果同时调整了.up类的动画时长一定要让两个数字匹配否则地鼠可能还没来得及完全弹出就被下一轮逻辑清掉。5.3 三个高频报错的排查方向第一个是控制台出现Uncaught ReferenceError: $ is not defined这说明 jQuery 或 Zepto 没有被成功加载。检查 script 标签顺序先引库文件再引js.js还要确认文件路径没有错位。第二个是Failed to load module script: expected a JavaScript module script这通常是 script 标签被误写成typemodule但引用的库本身不是 ES Module改成普通script src...即可。第三个是音频一直不响。先打开 Network 面板看q.mp3和q.wav是否返回 404再确认没有在用户首次点击之前调用播放。如果都正常最后检查是不是有多个音频对象同时播放导致叠加异常。调试手感时我习惯把地鼠出现间隔单独提取成一个配置对象例如var GAME_CONFIG { moleInterval: 800, gameDuration: 30 };这样在试玩过程中只需要改GAME_CONFIG.moleInterval不用满文件搜索散落的setInterval。这个习惯会让打地鼠这类小项目在加了新难度、新地图之后依然保持可维护性。本文还有配套的精品资源点击获取
返回列表