从零到一:用纯前端 Canvas 打造"大家来找茬"小游戏
从零到一:用纯前端 Canvas 打造"大家来找茬"小游戏
作者:Eddygit
日期:2026-10-10
标签:Canvas, JavaScript, 游戏开发, 纯前端
前言
"大家来找茬"是一款经典的观察力益智游戏——两幅看似相同的图片中藏着若干不同之处,玩家需要在限定时间内全部找出来。这个看似简单的游戏,背后涉及到 Canvas 绘图、随机算法、碰撞检测、状态管理等多个技术点。本文记录了从构思到实现的完整技术路线,分享踩坑经验和优化思考。
一、技术选型:为什么选纯 Canvas?
方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 真实图片 + CSS 定位 | 视觉效果好 | 需要图片素材,差异点固定 |
| SVG 矢量图 | 可缩放、可交互 | 复杂场景绘制繁琐 |
| Canvas 2D | 灵活、性能好、零依赖 | 需手动处理交互 |
| 游戏引擎 (Phaser/Pixi) | 功能完善 | 引入依赖,杀鸡用牛刀 |
最终选择 Canvas 2D + 原生 JS,理由:
- 零依赖:单 HTML 文件即可运行,部署/分享极简
- 程序化生成:每关随机生成场景和差异,重玩价值高
- 体积可控:整个游戏不到 20KB,秒开
- 教学友好:代码结构清晰,适合学习 Canvas API
二、架构设计
整体结构
index.html(单文件)
├── <style> CSS3 样式与动画
├── <body> 三个屏幕:开始页 / 游戏页 / 过关动画
└── <script> 游戏逻辑
├── genScene() → 场景生成器
├── drawEl() → 元素绘制器(14种元素)
├── drawScene() → 场景渲染
├── genDifferences()→ 差异生成器
├── onCanvasClick() → 点击检测
└── 游戏状态管理 → 关卡/得分/计时/过关
状态机
开始页面 → [点击开始] → 游戏中 → [找到全部差异] → 过关动画 → [下一关] → 游戏中
↓ [时间到]
游戏结束 → [重新开始] → 游戏中
三、核心技术实现
3.1 场景生成:14种元素的组合艺术
每种场景类型(风景/房间/太空)由不同的元素组合构成。关键是让元素足够丰富,又不过于复杂:
function genScene() {
var types = ['landscape', 'room', 'space'];
var type = types[Math.floor(Math.random() * types.length)];
var elements = [];
if (type === 'landscape') {
elements.push({type:'rect', ...}); // 天空渐变
elements.push({type:'circle', ...}); // 太阳
elements.push({type:'cloud', ...}); // 云朵
elements.push({type:'tree', ...}); // 树
elements.push({type:'house', ...}); // 房子
// ...
}
return {type, elements};
}
心得:用数据驱动而非硬编码绘制。每个元素是一个对象,drawEl() 函数根据 type 字段分发绘制逻辑。这样场景生成和渲染完全解耦,新增元素类型只需加一个 case。
3.2 差异生成:让"不同"恰到好处
这是游戏的核心难点——差异要"可发现但不太明显"。算法分三步:
- 筛选候选元素:排除背景渐变和装饰性元素(如星星)
- 随机打乱:Fisher-Yates shuffle 保证无偏
- 施加变换:对选中的5个元素随机应用颜色/大小/位置变换
var changeType = Math.floor(Math.random() * 4);
// 0: 颜色变化 1: 大小变化 2: 位置偏移 3: 兜底颜色变化
踩坑记录:最初差异点容差半径固定为25px,在小元素上太大(一点就中),在大元素上太小(精确点击才中)。后改为根据元素半径动态计算 diff.r = el.r + 10,体验好很多。
3.3 点击检测:Canvas 坐标换算
Canvas 的坐标系统和浏览器事件坐标不一致,需要换算:
var rect = canvas.getBoundingClientRect();
var scaleX = canvas.width / rect.width; // Canvas内部分辨率 / CSS显示宽度
var x = (e.clientX - rect.left) * scaleX; // 换算到Canvas坐标
踩坑记录:响应式布局下 Canvas 的 CSS 尺寸和内部分辨率不同(width="400" 但 CSS 可能显示为 350px),不做缩放换算会导致点击位置偏移。
3.4 视觉反馈:多层级响应
| 事件 | 反馈 | 实现 |
|---|---|---|
| 正确点击 | 红圈标记 + 加分 | CSS markPop 动画 |
| 错误点击 | 红色闪烁 + 扣分 | CSS errorFlash 动画 |
| 提示使用 | 脉冲黄圈 + 扣分 | CSS hintPulse 动画 |
| 过关 | 彩纸 + 星级 + 时间奖励 | JS 生成 confetti DOM |
| 时间紧迫 | 计时器变红 + 脉冲 | CSS pulse 动画 |
心得:游戏的"手感"很大程度来自反馈的即时性和层次感。正确点击的 markPop 动画虽然只有0.3秒,但让"找到差异"这件事有了仪式感。
四、实战经验总结
4.1 单文件策略的取舍
优点:部署极简,双击即玩,适合教学演示和快速分享。
代价:无法做代码分割、Tree-shaking,复用需复制粘贴。
结论:对于这个规模的项目(~400行),单文件是正确选择。如果项目增长到1000行以上,应考虑拆分。
4.2 setInterval 计时的精度问题
setInterval(fn, 1000) 在页面失焦时可能被节流,导致计时变慢。生产环境应使用 requestAnimationFrame + 时间差计算:
// 更精确的计时方案(未在本项目实现,优化方向)
var lastTime = performance.now();
function tick(now) {
var dt = now - lastTime;
lastTime = now;
state.time -= dt / 1000;
if (state.time > 0) requestAnimationFrame(tick);
}
4.3 随机性的平衡
纯随机的场景+差异有时会产生"太简单"或"太难"的组合。理想方案是加入难度评估——计算差异的视觉显著度(颜色差异ΔE、距离、面积比),过滤掉过于明显或过于隐蔽的组合。本项目未实现这一层,是一个优化方向。
五、存在问题
- 场景复用率低:只有3种场景,玩10局后基本都见过了
- 差异类型不够丰富:缺少"添加/删除元素"这种最直观的差异类型
- 无音效反馈:纯视觉反馈,缺少听觉维度
- 无数据持久化:最高分、通关数未保存
- 移动端体验:触摸事件已支持但 Canvas 尺寸在小屏上偏小
六、优化路线图
| 优先级 | 优化项 | 预期收益 |
|---|---|---|
| P0 | 增加"添加/删除元素"差异类型 | 差异多样性+50% |
| P0 | localStorage 保存最高分 | 用户留存提升 |
| P1 | 增加至6-8种场景 | 重玩价值大幅提升 |
| P1 | Web Audio API 音效 | 沉浸感提升 |
| P1 | 难度递增曲线 | 关卡后期挑战性 |
| P2 | 支持上传图片自动找茬 | UGC内容扩展 |
| P2 | WebSocket 多人对战 | 社交属性 |
七、技术路线回顾
需求分析 → 技术选型(Canvas+原生JS) → 场景设计(3类14元素)
→ 差异算法(随机变换) → 交互实现(点击检测) → 视觉打磨(动画反馈)
→ 响应式适配 → 测试验证 → 发布上线
整个开发过程约2小时,其中场景设计和差异算法占60%时间——这正是游戏核心玩法所在。先把核心玩法做对,再打磨视觉和体验,这个优先级是正确的。
结语
"大家来找茬"这样的小游戏,麻雀虽小五脏俱全:Canvas 绘图、随机算法、碰撞检测、状态管理、动画系统、响应式布局——每个都是前端开发的必修课。用一个小项目把这些知识点串起来,比看十篇教程都管用。
代码已开源:https://gitcode.com/Eddygit/spot-the-difference-game
欢迎提 Issue 和 PR!
- 点赞
- 收藏
- 关注作者
评论(0)