
简介这是一个基于JavaScript开发的找茬小游戏完整项目面向前端初学者、网页交互爱好者和需要课堂案例的师生演示了DOM操作、事件监听、图像比较与页面状态管理的组合运用玩法是在限定时间内找出两幅相似图片的差异。压缩包共28个文件含HTML入口页面、CSS样式表、6个JS脚本含jQuery库及findGame多个版本、15张JPG配图、3张PNG界面元素和2个PSD源文件约2.68MB轻量易用已有773人学习下载。test.html定义页面骨架style.css控制视觉样式images中成对图片对应各关卡差异素材js下不同版本展示从基础到优化的思路PSD便于二次修改美术资源。逐行阅读代码可掌握游戏初始化、随机关卡加载、点击判定、计分计时、胜负反馈等核心逻辑对比jQuery与原生JavaScript写法理解像素比对的落地方式是一份适合入门与教学演示的轻量级前端游戏源码。1. 找茬游戏不是静态页面JS小游戏源码里的交互闭环值得拆开看市面上找茬游戏大多被当成前端练手的小项目但真把它从“能玩”做到“能上线”你很快会发现它并不是一套静态页面那么简单。我最近拆了一份用原生JS开发的找茬游戏源码没有框架、没有构建工具却把图片差异标定、点击命中判定、倒计时、提示次数、关卡切换这些机制完整串了起来。这份资源适合两类人一是刚学完JS基础、想找个能跑通完整交互链路项目的初学者二是要做Web端小游戏demo、需要一个能改造成产品的底子的开发者。它不是让你抄一遍就完事而是给出一套能换图片、能调难度、能扩展关卡的骨架。2. 核心机制拆解差异点怎么标定、点击怎么才算命中找茬游戏的逻辑主线其实只有一条告诉程序“图A和图B哪里不同”然后监听用户点击判断点击是否落在差异点附近。主线之外才是倒计时、计分、提示这些外围功能。先把这条主线拆透后面做什么都是加分项。2.1 差异数据模型坐标、半径与命中判定的数学基础这份源码里差异点不是存图片像素而是存一个坐标数组。每个差异点用一个对象表示包含中心点的x、y坐标和一个容差半径radius。多数找茬游戏都会采用这种模型因为它足够轻量判定时只需要算一下两点距离。// config.js 或 game.js 顶部的差异点配置 const differences [ { x: 120, y: 85, radius: 18, name: 云朵形状 }, { x: 320, y: 240, radius: 15, name: 树梢颜色 }, { x: 540, y: 400, radius: 20, name: 窗户多了一根栏杆 }, ];这里有个关键点radius不是随便拍的。数值太小用户得精确点到像素上体验极差数值太大点击判定又太宽松用户随手一点就能蒙中多个差异。我一般建议先把图片按实际显示尺寸切好在调试模式下把每次点击的坐标打印出来挑几个真实点击样本量出用户最偏的距离再定radius的基准值。正常情况18到25像素比较合适如果是高分辨率大图可以按比例放大。这个模型的另一个好处是差集是确定的不会因为图片压缩、加载时序而产生歧义。判定时用的是数学上的欧几里得距离也就是Math.sqrt((clickX - d.x) ** 2 (clickY - d.y) ** 2)小于等于radius就算命中。这个计算在性能上没有任何压力差异点就算上百个一次点击遍历完也只是微秒级操作。2.2 点击事件与命中判定从e.clientX到canvas坐标的换算如果你用Canvas渲染两张对比图点击事件绑定在canvas元素上事件对象里的clientX和clientY是浏览器视口坐标不能直接拿去和差异坐标比较。因为canvas可能有CSS缩放、可能有边框、可能不是从页面左上角开始直接比较会得到非常诡异的结果——点击的位置和实际标定的差异点永远对不上。常见的换算方式是getBoundingClientRect()拿到canvas相对视口的位置和实际渲染尺寸再做比例映射。canvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); // canvas宽高是画布逻辑尺寸rect宽高是页面实际显示尺寸 const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; const clickX (e.clientX - rect.left) * scaleX; const clickY (e.clientY - rect.top) * scaleY; for (const d of differences) { if (d.found) continue; // 已找到的差异点直接跳过 const dist Math.sqrt((clickX - d.x) ** 2 (clickY - d.y) ** 2); if (dist d.radius) { d.found true; score config.scorePerDiff; drawCircleMark(d.x, d.y, d.radius); break; // 一次只命中一个点避免连踩 } } });这段代码有两个容易忽略的细节。第一个是scaleX和scaleYcanvas内部逻辑尺寸比如800乘600和CSS显示尺寸可能不同如果CSS把canvas拉伸成1200宽那么画布内部坐标系和实际点击坐标系之间必须做这个比例缩放否则点在左半边的差异却判定到右半边的坐标上。第二个是d.found这个标志位它保证已经找到的差异不会重复计分也避免提示功能把已找到的点再次标记。config.scorePerDiff是从配置里读取的单次得分这样计分规则可以随时调整而不改动游戏逻辑。2.3 游戏状态与倒计时setInterval的两处陷阱倒计时是这类游戏最容易写出隐藏bug的地方。我拆的这份源码里倒计时的实现使用的是setInterval每秒把剩余时间减一到零就结束游戏。逻辑很简单但有两处容易翻车。第一处是setInterval只能创建一次。如果每次点击“开始游戏”按钮都新建一个setInterval而不清理旧的时间会越走越快。第二处是倒计时归零后如果没做状态保护用户还能继续点canvas点中差异依然加分这就等于无视游戏失败条件。let timerId null; let timeLeft config.totalTime; function startTimer() { // 先清理旧计时器再创建新计时器 if (timerId) { clearInterval(timerId); timerId null; } timeLeft config.totalTime; timerId setInterval(() { timeLeft - 1; updateTimeDisplay(timeLeft); if (timeLeft 0) { clearInterval(timerId); timerId null; endGame(); } }, 1000); }一个更稳健的做法是不要依赖setInterval计数而是记录开始时间每次刷新时拿当前时间减去开始时间算出剩余秒数。这样即使浏览器切到后台再回来时间也不会产生累积误差。setInterval在页面最小化时会被浏览器节流有的环境会把它降到每分钟一次如果你只是做timeLeft - 1回来时会发现时间几乎没走但游戏已经结束了。3. 把源码跑起来目录结构、启动方式与参数调优拿到这份找茬游戏源码后第一步不是改代码而是先把它跑起来。这听起来像是废话但很多人第一步就跑偏了——他们直接用文件管理器双击index.html然后用file://协议打开结果图片加载不出来、canvas一片黑第一反应是“源码有问题”。3.1 目录结构与文件分工谁管渲染、谁管逻辑、谁管配置我拆的这份源码文件结构很清爽看成型的JS小游戏项目基本都按这个思路分层。文件/目录职责备注index.html页面骨架、canvas和按钮挂载点只负责结构不写逻辑css/style.css页面布局、按钮样式、反馈动画不影响核心玩法js/config.js游戏参数配置入口难度调整基本只改这里js/game.js游戏主逻辑差异判定、计时、计分核心文件js/levels.js关卡数据差异点数组、图片路径新增关卡主要改这里images/对比图资源同一关放两张图a图、b图这个分层方式值得借鉴配置和数据分离出来之后改难度、加关卡不需要碰主逻辑代码。如果你之后想把这个项目改造成React或Vue版本game.js里的纯逻辑部分也可以直接移植UI部分再重写即可。3.2 本地启动与调试别用file://直接开在本地跑这个项目推荐用任意静态文件服务而不是直接双击打开html。因为file://协议下很多浏览器会限制canvas对本地图片资源的读取权限尤其在涉及像素读写时会报跨域错误。我习惯在项目目录下起一个简单的Python HTTP服务cd find-difference-game python3 -m http.server 8080然后在浏览器访问http://localhost:8080。如果你用VS Code装Live Server插件后右键index.html选“Open with Live Server”也行本质上是同一个道理——通过HTTP协议访问资源避开file://的限制。这一步做完图片能正常加载canvas拿像素数据也不会被拦。3.3 核心参数解析难度、时间、提示次数怎么调配置参数集中在config.js里这份源码的参数命名比较直观我看了一眼基本不用查文档就能改。const config { totalTime: 60, // 单关倒计时单位秒 maxHints: 3, // 最多可用提示次数 hintPenalty: 5, // 每次提示扣多少分 scorePerDiff: 10, // 每找到一个差异得多少分 wrongClickPenalty: 2, // 点错一次扣多少分 diffRadius: 18, // 命中判定半径 };每个参数的调整都会直接影响游戏的手感。我拆项目时习惯先把diffRadius调到15到20之间再开一关自己玩两遍感受一下“明明点对了却没反应”和“随便一点就命中”之间的临界值。wrongClickPenalty这一点很多游戏会忽略但它对玩法体验影响很大——没有点错惩罚玩家可以全图乱点找到差异只是时间问题惩罚太高玩家又会怕点错不敢点。个人建议点错扣除的分值控制在一个差异得分的五分之一到二分之一之间这样既保留试错空间又不至于毫无压力。maxHints也一样新手可以给到5次熟练后压到2次游戏节奏完全不同。提示的实现方式一般是遍历differences数组找到第一个found为false的点画一个闪烁的圈。这个不难但要记得提示后扣除惩罚分并更新计时或状态否则提示就成了无本买卖反而降低了游戏的挑战性。4. 实战避坑记录四个找茬小游戏翻车点与修复方案这部分是我在拆这类小游戏源码时真实踩过的坑有些是调试半小时才定位到的低级错误但每一个都可能让你对源码产生“是不是作者写错了”的怀疑。我按“现象—原因—解决”的方式记录下来你对照排查即可。4.1 现象页面打开黑屏图片区域一片空白直接双击index.html打开时两张对比图经常加载不出来canvas画布上什么也不显示。原因浏览器对file://协议下本地文件的读取做了跨域限制。尤其是canvas绘制了本地图片之后如果你再尝试用getImageData读取像素数据浏览器会直接抛SecurityError。这在“给找茬游戏的其中一张图加噪点差异”这种场景极其常见。解决不用file://改成本地HTTP服务访问。具体起服务命令在前面3.2里已经写了这里不重复。另外补充一个检测技巧如果你看到浏览器控制台有SecurityError基本就是协议问题如果是404 Not Found才是路径问题不要把两种混为一谈。4.2 现象倒计时越走越快60秒的关卡25秒就走完了游戏刚开始时计时正常但玩到第二关或第三次重开时倒计时速度明显翻倍甚至一秒跳两秒。原因每点击一次“开始游戏”都新创建了一个setInterval但没有清理旧的。旧计时器还在跑新计时器又叠加上去相当于两个计时器同时每秒减一时间当然走得快。我在多个小游戏项目里见过这个bug几乎成了“倒计时标配坑”。解决在创建新计时器之前先clearInterval存好的timerId。具体代码在第2.3节已经给出。还有一个预防性建议全局只保留一个timerId变量任何创建定时器的地方都必须先清理旧值形成一个固定习惯。4.3 现象点击位置和实际反馈位置对不上鼠标在左侧点圈却画在右侧在canvas上点击某个差异点程序反馈的命中圈画到了别的位置或者明明点在差异点上却没反应。原因canvas的width属性画布逻辑尺寸和CSS样式里的width显示尺寸不一致导致坐标系错位。比如canvas逻辑尺寸是800乘600CSS把它拉伸显示成1200宽点击坐标没有做比例换算就直接参与判定自然错位。解决用getBoundingClientRect()获取实际显示尺寸再把视口坐标换算成画布内部坐标。注意scaleX和scaleY必须分别计算不要想当然地认为宽高缩放比例相同。如果还错位检查一下页面里有没有给canvas设置border或padding这两个属性会把点击坐标进一步推偏。4.4 现象已经找到的差异点被重复计分或者提示功能“卡死”不再工作找到一个差异后再点同一位置还能加分提示按钮用了几次后明明还有未找到的差异点了却没有任何反应。原因没有维护found标志位或者维护的位置不对。found标记应该在命中判定时同步设置并且后续遍历、提示、计分都要先检查这个标志。提示功能“卡死”的常见原因是它遍历的是纯坐标数组而每次命中后没有把found更新成true导致提示永远在提示已经找到的点。解决在命中判定的循环里命中后立即设置d.found true并break提示函数里遍历时跳过found true的项。如果你还需要“重置关卡”的功能记得在重置函数里把每个差异对象的found重新设为false而不是直接替换整个数组引用否则旧状态的残留会带来更隐蔽的bug。5. 把Demo变成产品关卡配置、差异自动生成与移动端适配一份能跑的找茬游戏源码和一份能拿出去当demo演示的找茬游戏中间还隔着三个距离关卡可配置、差异可扩展、移动端可触碰。这一章把这三个点逐一补齐。5.1 多关卡配置化把differences数组抽成关卡数据原源码里只有一关的差异点数组要加几关最清晰的做法是建一个levels.js存放关卡配置每一关包含两张图片路径和对应的差异点列表。const levels [ { id: 1, name: 湖边小镇, images: { a: images/level1-a.jpg, b: images/level1-b.jpg }, differences: [ { x: 80, y: 60, radius: 18 }, { x: 240, y: 300, radius: 16 }, { x: 500, y: 210, radius: 20 }, ], timeLimit: 60, }, { id: 2, name: 街角咖啡店, images: { a: images/level2-a.jpg, b: images/level2-b.jpg }, differences: [ { x: 150, y: 120, radius: 20 }, { x: 380, y: 180, radius: 15 }, { x: 420, y: 360, radius: 16 }, { x: 600, y: 280, radius: 18 }, ], timeLimit: 50, }, ];切关的时候主逻辑只关心“当前关卡索引”和“当前关卡数据”。切关函数做的事只有三件更新当前关卡索引、预加载两张新图片、重置差异点found状态和倒计时。这里预加载图片很重要否则切关瞬间有一张图加载中用户看着一片空白会觉得游戏卡死了。常见做法是提前用new Image()把下一关的图片预载等加载完成后再切换。5.2 像素级差异自动生成用canvas逐像素对比如果你手里没有成对的差异图只有一张原图想快速生成一个测试关卡可以写一个小工具把原图和添过噪点的图逐像素对比自动输出差异点坐标。这个工具不是游戏运行时的功能而是开发期的生成脚本。async function detectDifferences(srcA, srcB) { const canvas document.createElement(canvas); const ctx canvas.getContext(2d, { willReadFrequently: true }); const imgA await loadImage(srcA); const imgB await loadImage(srcB); canvas.width imgA.naturalWidth; canvas.height imgA.naturalHeight; ctx.drawImage(imgA, 0, 0); const dataA ctx.getImageData(0, 0, canvas.width, canvas.height).data; ctx.drawImage(imgB, 0, 0); const dataB ctx.getImageData(0, 0, canvas.width, canvas.height).data; const diffCenters []; const threshold 30; // RGB差值超过该值视为差异 for (let y 0; y canvas.height; y 4) { for (let x 0; x canvas.width; x 4) { const idx (y * canvas.width x) * 4; const dr Math.abs(dataA[idx] - dataB[idx]); const dg Math.abs(dataA[idx 1] - dataB[idx 1]); const db Math.abs(dataA[idx 2] - dataB[idx 2]); if (dr dg db threshold) { diffCenters.push({ x, y, radius: 15 }); } } } return diffCenters; }注意这段代码我是按常见做法补的不是源码自带的。它的作用是快速生成候选差异坐标但生成的坐标是离散的点需要再做一次聚合——挨得太近的点合并成一个中心点否则会有几十个“差异”挤在同一片区域。另外像素级的差异不一定符合人的肉眼感知有些区域RGB变了但视觉上很难察觉自动生成的差异点最终还是要人工过一遍把不明显的删掉把明显的补上。这个脚本可以省掉你手动记录坐标的苦差事但代替不了人工验收。5.3 移动端适配touch事件与禁用缩放的三个要点找茬游戏天生适合触屏操作但PC版的点击事件在移动端有一个明显的延迟问题click事件在移动端有约300毫秒延迟用户点了差异点半秒后才出现命中反馈这几乎摧毁了找茬游戏的爽快感。解决方式是用touchstart事件替代click并配合preventDefault阻止浏览器默认行为。canvas.addEventListener(touchstart, (e) { e.preventDefault(); const touch e.touches[0]; const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; const clickX (touch.clientX - rect.left) * scaleX; const clickY (touch.clientY - rect.top) * scaleY; handleClick(clickX, clickY); });除了事件替换还有三个移动端细节要注意。第一在index.html的head区加meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno禁止双指缩放不然用户连续点击过程中页面会不断缩放坐标全乱。第二给canvas添加touch-action: none样式禁止浏览器在canvas区域触发默认的滚动和缩放手势。第三提示按钮和重开按钮这些可点击元素建议统一用touchstart事件处理减少误触延迟。6. 一个加分技巧用Web Storage做本地排行榜落地一个找茬游戏demo时排行榜是最容易出效果的加分项而且完全不需要后端。用localStorage就能存下前十名成绩刷新页面后数据依然保留。实现不复杂但有一个细节处理不好会让排行榜形同虚设成绩数组的存取必须做序列化和反序列化并且要在每次新增成绩后重新排序截断。6.1 排行榜的数据结构与存取逻辑我把排行榜设计成一个对象数组每个元素包含玩家名、得分、关卡编号和时间戳。时间戳不仅用于排序时区分相同得分的先后还能在后面对成绩做防作弊校验。保存成绩的函数先读旧数据再把新成绩插入排序后截取前10条。const RANK_KEY find-diff-rank; function saveScore(playerName, score, level, elapsed) { const list JSON.parse(localStorage.getItem(RANK_KEY) || []); list.push({ name: playerName || 匿名玩家, score: score, level: level, elapsed: elapsed, ts: Date.now(), }); list.sort((a, b) b.score - a.score || a.elapsed - b.elapsed); const top list.slice(0, 10); localStorage.setItem(RANK_KEY, JSON.stringify(top)); }这里排序规则写了两层先按得分降序得分相同就按用时升序。elapsed字段存的是该关卡剩余秒数或实际消耗秒数需要你在游戏结束时从计时器里取出来传进来。localStorage.getItem(RANK_KEY) || []这个写法必须保留因为首次运行时getItem返回null直接JSON.parse(null)会得到null而不是数组往null上push就会抛错。6.2 防作弊小技巧加一个简单的校验字段纯前端的排行榜本质上防不了作弊因为localStorage是明文存储懂技术的人打开开发者工具就能改。但你可以做一个低成本校验在保存成绩时同时存一个校验码读取时校验不一致就丢弃这条成绩。function checksum(obj) { const raw ${obj.score}|${obj.level}|${obj.elapsed}; // 简单混淆每一位字符的ASCII码求和后取余 let sum 0; for (let i 0; i raw.length; i) { sum raw.charCodeAt(i); } return sum % 997; }写入排行榜时把checksum和成绩一起存进去读取时重新计算比对不一致就过滤掉。这个方案挡不住刻意改校验码的人但能挡住绝大多数顺手改成绩的玩家。做小游戏demo的时候有这个校验和没有是完全两个观感——至少别人打开你的demo时看到排行榜里有一个99,999,999分的成绩不太会怀疑是你的代码有漏洞而会觉得是某个玩家改出来的。从那以后我每次给这类小游戏加排行榜都会先问一句“这个成绩能不能直接被人从开发者工具里改掉”。如果答案是“能”就至少加个校验字段再考虑上线。这个习惯帮我挡掉了不少demo演示时的尴尬场面。希望帮到你。本文还有配套的精品资源点击获取