
简介这是一份基于JavaScript开发的找茬小游戏完整源码适合前端初学者、网页游戏爱好者以及想通过实际项目练习JS逻辑的开发者。项目展示了如何用HTML、CSS和JavaScript配合构建一个可玩的找茬小游戏页面负责布局与展示样式表控制界面观感多段JS脚本分别承担图片加载、差异判定、点击反馈、计时计分和状态重置等核心功能。资源共28个文件包含html入口、css样式、js逻辑脚本、jpg背景与找茬图片、png按钮与时间框素材以及2个PSD源文件压缩包整体仅2.68MB目录层次清晰便于直接运行和二次开发。目前已有771人学习下载。通过这套代码可以读懂找茬游戏从初始化到胜负判定的完整流程学会监听用户点击并判断命中位置、记录得分并在时间耗尽后结束游戏还能借助PSD源文件替换或调整图片素材自定义游戏难度与视觉表现是巩固DOM操作和JavaScript基础语法的实用练习项目。 上周帮一个做运营的朋友赶活动页需求很简单要一个能在手机浏览器里直接玩的找茬小游戏用户点出两张图的不同之处就算过关。第一反应是找个现成插件改改但搜了一圈不是依赖太重就是风格不匹配后来干脆用原生 JavaScript 写了一个。差不多半小时搞定核心逻辑再花一小时调完移动端适配和反馈动画整体效果还挺像那么回事。这个项目算不上复杂但里面涉及坐标换算、Canvas 重绘、状态管理这些前端基础点非常适合想练手原生 JS 的人也能直接作为活动 H5 或教学 Demo 来用。找茬游戏的核心逻辑不复杂加载图片、判断点击是否落在预设差异区域、给用户正确的视觉反馈。难点不在“判断”本身而在于坐标换算和交互细节。下面把我这次的实现思路、核心代码、踩过的坑完整记录一遍。1. 项目概述与需求拆解1.1 找茬游戏到底在干什么说白了找茬游戏就是一个“点击命中判定 反馈标记”的循环。用户看到两张几乎一样的图凭眼力寻找差异并点击。系统在后台提前记录了所有差异点的坐标当用户的点击位置与某个尚未被找到的差异点距离足够近就判定命中在图上画个圈标记并把“未找到数量”减一。听起来简单但真正实现时有两个关键点一是“差异点坐标”从哪里来二是“怎么判断点击命中”。坐标这一点简单项目里我建议直接写死在关卡配置里比如手动用图片处理工具量出五个差异位置的坐标形成数组。如果做可扩展版本也可以做一个后台标注工具点一下图片生成坐标数据但这个属于额外工作后面细说。1.2 技术选型为什么是原生 JS Canvas这个项目我没有用框架也没引第三方库全程原生 JavaScript Canvas。有人会问找茬游戏不是 DOM 操作就能搞定吗为什么非得上 Canvas直说结论Canvas 在“画反馈标记”这件事上有天然优势。找茬过程中需要在图片上叠加圆圈、红叉、闪光特效如果全用 DOM 去做每个标记都要创建 div、调位置、销毁标记一多 DOM 节点就爆炸滚动穿透和点击事件也会互相干扰。Canvas 只需要维护一张画布状态每次重绘即可逻辑干净性能也更稳定。当然如果用 Vue 或 React组件状态管理会更舒服但你要清楚一点这种交互密集的小游戏性能瓶颈通常在浏览器绘制和事件处理上框架带来的收益不大反而会增加首屏加载成本。自己做项目想要一个低依赖、可复制的文件原生 JS 是最省事的选择。2. 核心实现从图片加载到点击判定2.1 数据结构的定义差异点配置所有找茬点必须预先定义好。这里我设计了一个简单的数据结构每个差异点包含中心坐标和判定半径。// 以原图尺寸为基准定义差异点 // x, y 是差异点在原图上的中心坐标r 是命中判定半径 const levelConfig { image: ./assets/level1.jpg, differences: [ { x: 212, y: 388, r: 24 }, { x: 536, y: 121, r: 20 }, { x: 803, y: 267, r: 28 }, { x: 314, y: 612, r: 22 }, { x: 642, y: 740, r: 25 } ], timeLimit: 60 };为什么不直接用“矩形区域”而用“圆形判定”原因很简单用户手指点击是一个点圆形判定计算距离即可矩形判定还要判断横向和纵向范围代码更啰嗦而且差异点边缘本来就是不规则的圆形在视觉上更贴近“差不多在这里”的判断直觉。r 的值建议设为 20 到 30 像素太小区分不出来太大又会出现隔空点中的尴尬。2.2 加载图片与画布绘制初始化时最重要的一件事是让 Canvas 尺寸跟着图片实际尺寸走。这里有一个新手最容易踩的坑Canvas 有“绘图缓冲区尺寸”和“CSS 展示尺寸”两个概念设置width和height属性是在定义缓冲区而通过 CSS 定义的宽度高度是展示尺寸。两者不一致时画出来的内容会被拉伸而且点击坐标也会对不上。我的做法是先加载图片等onload触发后按照图片的原始尺寸设置 Canvas 的宽高再在 CSS 里允许它等比缩放展示。const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const img new Image(); img.src levelConfig.image; img.onload () { canvas.width img.naturalWidth; canvas.height img.naturalHeight; drawBase(); }; function drawBase() { ctx.drawImage(img, 0, 0, canvas.width, canvas.height); drawFoundMarks(); }这里多说一句如果 Canvas 的 CSS 宽度是百分比布局而绘图缓冲区用的是图片原始像素那么用户肉眼看到的画布可能只有实际缓冲区的一半大小点击时坐标必须做换算。这一步我放在后面的交互逻辑里处理。2.3 坐标换算与命中判定点击坐标换算是整个项目里最容易被搞崩的地方。用户点击的是屏幕上的物理位置而 Canvas 内部使用的是绘图坐标系两者必须统一。标准的换算公式如下canvas.addEventListener(click, function (e) { const rect canvas.getBoundingClientRect(); // 计算点击点在 canvas 缓冲区坐标系中的位置 const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; const x (e.clientX - rect.left) * scaleX; const y (e.clientY - rect.top) * scaleY; handleClick(x, y); });getBoundingClientRect()拿到的就是 Canvas 在页面中实际的展示区域rect.width是视觉宽度canvas.width是内部缓冲区宽度二者相除得到缩放比例。点击位置减去画布左上角坐标再乘上缩放比例就得到真实的绘图坐标。这个方法同时兼容桌面端鼠标点击和移动端点击移动端要监听touchstart或click注意防止 300 毫秒延迟不过现代浏览器普遍已经解决。命中判定就简单了遍历尚未找到的差异点算距离function handleClick(x, y) { for (let i 0; i levelConfig.differences.length; i) { const diff levelConfig.differences[i]; if (state.foundIndices.includes(i)) continue; const dist Math.hypot(x - diff.x, y - diff.y); if (dist diff.r 8) { state.foundIndices.push(i); score; drawFoundMarks(); checkWin(); return; } } // 没点中任何差异点播放错误反馈 drawWrongMark(x, y); }注意Math.hypot这个方法计算平方和后开方手感上比手写Math.sqrt更清晰而且对超大数值更稳定。判定半径额外加 8 像素是为了给手指点击留一点容错空间。这样在手机上用手指点的时候不用精确到像素级体验会好很多。3. 交互细节与状态管理3.1 状态管理给游戏一个清晰的心跳一个游戏如果全靠一堆全局变量到处赋值很快就会乱成一锅粥。这次我定义了一个简单的全局状态对象专门管理游戏运行中的可变数据const state { foundIndices: [], score: 0, remaining: levelConfig.differences.length, gameOver: false, timeLeft: levelConfig.timeLimit, timerId: null };所有逻辑只通过修改这个对象来改变游戏状态界面刷新统一由“绘制”函数负责。这样设计的好处是任何时候你都能知道当前游戏进度还剩哪些点没找到、倒计时还剩多少、是否已经结束。以后加暂停、重新开始、下一关功能都只需操作这个状态对象不用到处找变量。3.2 点击正确和错误反馈找到差异点时我在图片上画了一个绿色圆圈。圆圈以差异点坐标为圆心半径比判定半径稍微大一点这样标记比较醒目。function drawFoundMarks() { drawBase(); state.foundIndices.forEach(idx { const diff levelConfig.differences[idx]; ctx.beginPath(); ctx.arc(diff.x, diff.y, diff.r 12, 0, Math.PI * 2); ctx.strokeStyle #2ecc71; ctx.lineWidth 4; ctx.stroke(); }); }这里的策略是每次重绘都先drawBase()把原图重新画一边再画所有标记。如果直接在现有画布上叠加标记一旦需要清除某个标记就会很麻烦。用“全量重绘”的思路状态永远只依赖数据画布只是数据的投影代码会好维护很多。点击错误的时候我在用户点击的位置画了一个短暂存在的红叉1 秒后自动清除。这个功能用了setTimeout触发区域重绘。function drawWrongMark(x, y) { ctx.save(); ctx.beginPath(); ctx.moveTo(x - 10, y - 10); ctx.lineTo(x 10, y 10); ctx.moveTo(x 10, y - 10); ctx.lineTo(x - 10, y 10); ctx.strokeStyle #e74c3c; ctx.lineWidth 4; ctx.stroke(); ctx.restore(); setTimeout(() { drawBase(); drawFoundMarks(); }, 600); }有一点要注意错误反馈的setTimeout如果和后续的点击事件冲突会出现红叉被新画的圆圈覆盖或者残留的情况。稳妥的办法是在drawFoundMarks里维护一个wrongMarkQueue数组每次重绘时只绘制未过期的错误标记。简单版项目里直接全量重绘也可行但你需要接受可能出现轻微闪烁。3.3 计时与输赢逻辑倒计时我用setInterval实现每秒减一到零则游戏结束。计时器必须在游戏重新开始时清理否则会叠加触发。function startTimer() { if (state.timerId) clearInterval(state.timerId); state.timerId setInterval(() { state.timeLeft--; updateTimerUI(); if (state.timeLeft 0) { clearInterval(state.timerId); state.gameOver true; showResult(timeout); } }, 1000); } function checkWin() { state.remaining--; updateScoreUI(); if (state.remaining 0) { clearInterval(state.timerId); state.gameOver true; showResult(win); } }这里有一个细节剩余差异数量我直接由foundIndices.length推导但为了方便 UI 更新我单独维护了remaining。两处数据要保持同步容易出错。后来我重构了一版干脆不存remaining前端直接计算levelConfig.differences.length - state.foundIndices.length。能少维护一个变量就少维护一个这是我在多次改动中总结出来的教训。4. 实操过程中踩过的坑与排查技巧4.1 Canvas 坐标偏移CSS 尺寸、DPR、边框是重灾区第一次写完测试时点正确的差异点总是提示错误偏差还很均匀一查基本就是坐标换算没做。常见的偏移来源有三个第一Canvas 的width属性和 CSS 宽度不一致。我一开始为了让背景图完整铺满手机屏幕给 Canvas 设了width: 100%但绘图缓冲区的width还是图片原始像素结果点击位置整体偏差了将近一半。这时候必须用rect.width做缩放换算。第二CSS 中给 Canvas 加了边框或圆角。getBoundingClientRect()拿到的left和top是元素整个盒模型的位置如果 Canvas 本身有边框点击坐标会有几个像素的偏差。我的解决方法是把 Canvas 放进一个容器里边框和圆角加在容器上Canvas 本身不加这样坐标计算就不会受到边框干扰。第三高分屏 DPR 缩放。如果页面设置了devicePixelRatio相关的适配Canvas 缓冲区的像素数可能是 CSS 像素的两倍。此时scaleX会自动按canvas.width / rect.width算出 2 倍关系换算公式天然适配不需要额外处理。怕的是有些代码手动乘以 DPR又乘了一遍 координат就会翻倍。记住统一用canvas.width / rect.width不要去 main手动碰 DPR。表格整理一下现象原因处理方式点击位置整体偏移偏移量与视觉尺寸相关Canvas 缓冲区尺寸和 CSS 尺寸不一致使用getBoundingClientRect换算坐标点击位置有 1-2 像素微小偏移Canvas 有边框、阴影等影响盒模型边框移到外层容器高分屏下图片模糊缓冲区尺寸小于物理像素稳妥做法是设置宽高为 CSS 尺寸的两倍配合缩放图片模糊但点击还准没考虑 DPR画布绘制分辨率不够重新设置canvas.width为物理像素尺寸重新算坐标4.2 图片加载时序、重复点击与性能优化图片还没加载完成就给用户交互会带来两个问题一是drawImage绘制了一张空白图用户看到黑屏二是canvas.width为 0坐标换算全部失效。这类问题最明显的特征是控制台报canvas width is 0之类的错误。我习惯在图片onload触发后才绑定点击事件加载期间显示一个占位加载文案防止用户提前操作。重复点击问题也要防。比如用户连续点同一个差异点两次由于第一次已经把它加入foundIndices第二次点击会落入错误反馈分支显示红叉体验不好。更麻烦的是如果用户连续快速点击两个差异点错误反馈的setTimeout会在第二次点击时把第一秒的绘制状态清掉。简单项目里我的处理是点击后 300ms 内禁止再次点击用一个isClickLocked标志控制。这个“锁”虽然简单但能挡住大量误触。性能上这类找茬游戏根本不追求满帧运行因为大部分时间画面是静止的只有点击后有反馈动画。所以我完全没有用requestAnimationFrame做主循环只在动画需要时触发重绘。这样 CPU 占用率极低手机上也完全不卡。4.3 常见问题速查表问题原因解决方案点击点不到但鼠标位置看起来是对的坐标未按 canvas 实际尺寸换算用getBoundingClientRect做缩放图片显示模糊尤其在高分辨率屏Canvas 缓冲区小于物理像素将canvas.width设为视觉尺寸乘以 DPR点中一个差异点后被判错误判定半径太小调整r值或判定时加容差像素倒计时越来越快setInterval重复创建没有清理旧 timer重新开始时先clearInterval图片加载慢瞬间点击没反应事件绑定在图片加载前把事件监听放在img.onload回调里移动端点错位置触屏点击坐标系有差异使用touchstart手动获得坐标并禁止默认滚动5. 让游戏更好玩从简单版到可交付版本5.1 关卡配置化与难度控制找茬游戏天然适合做关卡制。我把levelConfig设计成数组每个关卡独立配置图片和差异点切换关卡时只需更新当前索引再初始化。const levels [ { image: ./assets/level1.jpg, differences: [...], timeLimit: 60 }, { image: ./assets/level2.jpg, differences: [...], timeLimit: 45 }, { image: ./assets/level3.jpg, differences: [...], timeLimit: 30 } ];难度控制的核心就是三个参数差异点数、判定半径、倒计时。差异点越多、半径越小、时间越短难度自然越高。做运营活动页面时通常会把第一关设得比较好过后面逐步加难度用户才有持续玩下去的动力。5.2 音效、动画与视觉提示没引音效文件的情况下可以用 Web Audio API 生成最简单的提示音找到差异时播放一个短促的“叮”点错误时播放低频“咚”。十几行代码就能搞定体验提升非常明显。function playTone(freq, duration) { const audioCtx new (window.AudioContext || window.webkitAudioContext)(); const oscillator audioCtx.createOscillator(); const gainNode audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.value freq; oscillator.type sine; gainNode.gain.setValueAtTime(0.2, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime duration); oscillator.start(); oscillator.stop(audioCtx.currentTime duration); } // 找到差异 playTone(880, 0.15); // 点错 playTone(220, 0.2);视觉反馈上绿色圆圈标记出现时可以做一个半径从小到大的脉冲效果。这需要在每次反馈时记录动画开始时间在requestAnimationFrame回调用当前时间计算插值再重新绘制。实现不复杂但能给用户很强的“好像过关了”的即时满足感。5.3 后续可以怎么扩展简单找茬只是起点。如果时间充裕我会在这个基础上做三件事一是“自动生成差异”工具。写一个标注页面加载图片后手动点击像素位置自动生成differences数组并导出 JSON。有了这个工具业务方自己就能创建新关卡不用每次找开发改代码。二是把两张图切换显示。很多找茬游戏不是把差距直接标在一张图上而是让用户来回切换两张图找不同。这个实现也不复杂Canvas 绘制时根据一个showTarget变量决定画哪张图点击事件再叠加一层透明蒙版即可。三是接后端排行榜。活动页里加一个“通关用时”字段提交到后端并展示前二十名。这时候前面设计的state对象会非常好用因为游戏结束时间点清晰数据汇总也集中。结束语还是要说点实在的这个项目做完之后最大的感受是原生 JavaScript 的基础功是真的值钱尤其是坐标系换算、事件机制和 Canvas 绘制这些不太起眼的知识点。很多人一上来就堆框架、堆依赖遇到一个小游戏反而不知道从哪下手。其实把原生 JS 的常用 API 吃透一个单文件页面完全能承载从交互到视觉的全部逻辑。另一点体会是好的找茬游戏体验反而集中在“反馈”上。用户点错了你要让他立刻知道错了点对了要让他一眼看到被标记的位置。这些反馈不一定要多花哨但一定要卡准时机。用 Canvas 全量重绘的思路去管理反馈代码写起来很踏实后期加动画也特别顺手。如果你也想练手建议不要急着上框架先从最简单的单关卡开始跑通坐标和判定再慢慢加倒计时、音效、多关卡。踩坑过程中遇到最多的就是坐标问题但只要你记住“永远用getBoundingClientRect换算不要凭感觉写坐标”就能躲开大部分坑。最后再分享一个小技巧调试时顺手在页面里输出鼠标在 Canvas 内的实际坐标和你的差异点坐标对照着看问题一眼就能定位比瞎试快得多。本文还有配套的精品资源点击获取