1. 缘起:为什么H5小游戏值得花时间啃一遍
2019年底到2020年初那段时间,我手头正好在做一个互动营销页的项目,客户要求页面里嵌入几个轻量级小游戏来拉停留时长。一开始我想着随便找几个开源demo改改就交差,结果真上手才发现,H5小游戏这个领域远比想象中深——它横跨了Canvas渲染、物理引擎、动画循环、资源加载、移动端适配、性能优化等一大堆知识点,每一个单拎出来都够写一篇长文。
后来我索性花了将近两个月,把当时能搜集到的280多款H5小游戏源码全部过了一遍,从最简单的“打砖块”到稍微复杂的“跑酷”“消除”“塔防”,边跑边读边改。这个过程对我的提升是巨大的,因为它把很多零散的前端知识串成了一条完整的线。所以这篇内容,我想把这280多款小游戏的学习路径、技术分类、核心原理和实操方法系统地梳理出来,给同样想入门或者想深入H5游戏开发的朋友一个可复现的参考。
先说清楚这篇内容适合谁:如果你是有一定HTML/CSS/JavaScript基础的前端,想往互动、游戏、可视化方向延伸,那这篇就是为你写的;如果你是刚入门的新手,也不用慌,我会从最基础的游戏循环讲起,配合源码片段,让你能跟着跑起来。核心关键词就三个:H5小游戏、源码学习、从入门到深入。整篇内容我会围绕“怎么选、怎么读、怎么改、怎么避坑”这条主线展开,不讲空话,全部落到能直接抄的操作上。
2. 280多款游戏怎么分类:先建立一张技术地图
拿到280多款源码,最忌讳的就是从第一个文件夹开始一个个硬啃。那样做最大的问题是:你会在大量重复的简单逻辑里消耗耐心,却迟迟碰不到真正有价值的技术点。我的做法是先分类,按“技术复杂度”和“玩法类型”两个维度建一张地图,然后按难度梯度推进。
2.1 按技术复杂度分四档
我把这280多款游戏大致分成了四个难度档位,这个分法不是绝对的,但作为学习顺序的参考非常实用。
| 档位 | 典型玩法 | 核心技术点 | 建议投入时间 |
|---|---|---|---|
| 入门档 | 打砖块、贪吃蛇、2048、翻牌记忆 | Canvas基础绘制、requestAnimationFrame、数组操作 | 每款1-2小时 |
| 进阶档 | 飞机大战、跑酷、接水果、弹球 | 精灵动画、碰撞检测、对象池、音效管理 | 每款3-5小时 |
| 高级档 | 消除类、塔防、卡牌对战 | 状态机、寻路算法、关卡数据设计 | 每款1-2天 |
| 硬核档 | 物理类、多人同步、3D效果 | 物理引擎、WebSocket、WebGL | 每款3天以上 |
为什么要这样分?因为学习曲线必须是渐进的。入门档的游戏代码量通常在200到500行之间,逻辑闭环清晰,你能在一两个小时内完整读懂并跑通,这种“快速完成”的正反馈对坚持学习特别重要。而硬核档的游戏往往依赖第三方库,代码量上千行,如果基础不牢直接上手,很容易被劝退。
2.2 按玩法类型找共性
除了难度,玩法类型也决定了技术侧重点。我把它归成几大类:
- 反应类(打砖块、接水果):核心是碰撞检测和实时输入响应,重点练的是坐标计算和边界判断。
- 益智类(2048、消除、数独):核心是数据结构和状态管理,重点练的是二维数组操作和状态回滚。
- 动作类(跑酷、飞机大战):核心是动画帧管理和对象生命周期,重点练的是对象池和资源回收。
- 策略类(塔防、卡牌):核心是关卡数据和AI逻辑,重点练的是配置表设计和简单寻路。
这么分类的好处是,你读源码时能带着“我要学什么”的明确目标。比如读消除类游戏,我就专门盯着它怎么用二维数组表示棋盘、怎么判断三连、怎么处理下落和补充,其他部分快速略过。这样读十款消除类,比漫无目的地读三十款不同类型的游戏收获还大。
2.3 建立自己的源码索引表
我强烈建议你在开始之前,先建一个Excel或者Markdown表格,字段包括:游戏名、难度档、玩法类型、核心技术点、是否跑通、改造笔记。每读完一款就填一行。这个习惯看起来笨,但两个月下来,你会得到一张属于自己的知识地图。我当时的表格里最后有280多行,回头翻的时候,哪些技术点反复出现、哪些是某个游戏的独门技巧,一目了然。
提示:不要贪多。280多款不是让你每款都精读,而是让你有一个足够大的样本库。真正精读的,每个档位挑3到5款就够了,剩下的作为参考和灵感来源。
3. 入门档实操:从打砖块吃透游戏循环
入门档里我最推荐从打砖块(Breakout)开始,因为它麻雀虽小五脏俱全:有渲染、有输入、有碰撞、有状态(生命值、分数、关卡),几乎涵盖了所有游戏的最小必要元素。把这一款吃透,后面几十款入门游戏你都能举一反三。
3.1 游戏循环:所有游戏的发动机
任何一款游戏,不管多复杂,底层都是一个循环在驱动。这个循环每秒钟执行几十次,每次做三件事:处理输入、更新状态、重新渲染。用代码表示大概是这样:
function gameLoop(timestamp) { // 1. 计算时间差,保证不同刷新率下速度一致 const deltaTime = timestamp - lastTime; lastTime = timestamp; // 2. 处理输入(键盘、鼠标、触摸) handleInput(); // 3. 更新游戏状态(位置、碰撞、分数) update(deltaTime); // 4. 清屏并重新渲染 render(); // 5. 请求下一帧 requestAnimationFrame(gameLoop); }这里有个关键点很多人会忽略:为什么一定要用deltaTime而不是固定步长?因为不同设备的屏幕刷新率不一样,有的是60Hz,有的是120Hz。如果你每帧都让小球移动固定距离,那在120Hz的设备上小球速度就会翻倍。用deltaTime(两帧之间的时间差)乘以速度,就能保证无论刷新率多少,小球每秒移动的距离是一致的。这是移动端适配的第一课,我在实际项目里踩过这个坑,同一款游戏在测试机上正常,到了高刷屏手机上直接快得没法玩。
3.2 碰撞检测:从矩形到圆形
打砖块里有两类碰撞:球和挡板、球和砖块。最常用的方法是轴对齐包围盒(AABB)检测,说白了就是判断两个矩形有没有重叠:
function isColliding(rectA, rectB) { return rectA.x < rectB.x + rectB.width && rectA.x + rectA.width > rectB.x && rectA.y < rectB.y + rectB.height && rectA.y + rectA.height > rectB.y; }这个逻辑很直观:两个矩形在x轴和y轴上的投影都有重叠,才算碰撞。但光检测到碰撞还不够,还得判断是从哪个方向撞的,才能决定球往哪反弹。我的做法是比较球心到砖块四条边的距离,哪条边最近就从哪条边反弹。这个细节很多入门源码里处理得很粗糙,导致球有时候会“卡”在砖块里抖动,你读源码时可以特别留意这一点。
3.3 状态管理:别把分数写死在全局变量里
入门游戏最容易犯的错,就是满屏的全局变量:score、lives、level、ballX、ballY……游戏小的时候没问题,一旦要加个“暂停”“重开”“关卡切换”,代码立刻乱成一团。我在改造这些源码时,统一做了一件事:把所有游戏状态收进一个对象。
const gameState = { score: 0, lives: 3, level: 1, status: 'ready', // ready | playing | paused | over ball: { x: 0, y: 0, vx: 0, vy: 0 }, bricks: [] };这样做的好处是,重开游戏只需要重置这个对象,暂停只需要改status字段,逻辑清晰得多。你读源码时如果看到状态散落各处,可以试着做这个重构,是很好的练习。
注意:入门档不要急着上框架。我见过不少人一上来就用游戏引擎,结果连基本的坐标变换都搞不清。先用原生Canvas手写两三款,把底层原理摸透,再上引擎你会用得更明白。
4. 进阶档核心:精灵动画与对象池
入门档跑通之后,进阶档是真正拉开差距的地方。这个档位的游戏开始有“动起来”的感觉:飞机在飞、敌人在生成、子弹在发射、爆炸在播放。核心就两个技术点:精灵动画和对象池。
4.1 精灵动画:让静态图片动起来
所谓精灵动画,就是把一张包含多个动作帧的图片(雪碧图),按顺序裁剪出不同区域,快速切换形成动画。原理不复杂,但细节很多。关键参数是帧率和帧序列。
const sprite = { image: img, frameWidth: 64, frameHeight: 64, currentFrame: 0, totalFrames: 8, frameDuration: 100, // 每帧持续100毫秒 elapsed: 0 }; function updateSprite(sprite, deltaTime) { sprite.elapsed += deltaTime; if (sprite.elapsed >= sprite.frameDuration) { sprite.elapsed -= sprite.frameDuration; sprite.currentFrame = (sprite.currentFrame + 1) % sprite.totalFrames; } }这里有个经验:帧率不要设太高。很多人以为帧率越高动画越流畅,其实不然。大多数2D游戏动画12到15帧每秒就够了,设太高反而会让动作显得“飘”。我在做飞机爆炸效果时,一开始设了30帧每秒,结果爆炸一闪而过根本看不清,降到12帧每秒后效果好很多。
4.2 对象池:解决频繁创建销毁的性能杀手
进阶档游戏里,子弹、敌人、粒子这些对象会频繁地创建和销毁。如果每次都new一个对象再让它被垃圾回收,在移动端上很容易造成卡顿。对象池的思路是:预先创建一批对象,用完不销毁,而是标记为“空闲”,下次需要时直接复用。
class ObjectPool { constructor(factory, size) { this.factory = factory; this.pool = []; this.active = []; for (let i = 0; i < size; i++) { this.pool.push(factory()); } } acquire() { const obj = this.pool.length > 0 ? this.pool.pop() : this.factory(); this.active.push(obj); return obj; } release(obj) { const index = this.active.indexOf(obj); if (index > -1) { this.active.splice(index, 1); obj.reset(); // 重置状态,准备复用 this.pool.push(obj); } } }对象池的难点不在代码本身,而在reset方法的设计。每个对象被回收时,必须把所有状态字段恢复到初始值,否则复用时会出现“幽灵数据”。我踩过的坑是:子弹对象回收时忘了重置速度,结果复用的子弹带着上一颗的速度飞出去,方向完全乱套。所以reset方法一定要写全,宁可多写几行。
4.3 音效管理:别让声音成为性能瓶颈
进阶档游戏通常有音效,但音频在移动端是个大坑。iOS上音频必须由用户交互触发才能播放,而且同时播放的音频数量有限制。我的做法是:预加载所有音频,用AudioContext统一管理,并且给音效设一个开关。
const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const soundBuffers = {}; async function loadSound(name, url) { const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); soundBuffers[name] = await audioContext.decodeAudioData(arrayBuffer); } function playSound(name, volume = 1) { if (!soundBuffers[name]) return; const source = audioContext.createBufferSource(); const gainNode = audioContext.createGain(); gainNode.gain.value = volume; source.buffer = soundBuffers[name]; source.connect(gainNode); gainNode.connect(audioContext.destination); source.start(0); }用AudioContext而不是直接new Audio()的好处是,可以精确控制音量和播放时机,而且能同时播放多个音效。但要注意,AudioContext在页面加载时是suspended状态,必须在用户第一次点击或触摸时调用resume()激活。这个坑我在三个项目里都遇到过,每次都要重新查一遍。
5. 高级档拆解:状态机与关卡数据设计
到了高级档,游戏逻辑开始变得复杂:消除类要处理连锁反应,塔防要处理多波敌人和多种塔的配合,卡牌要处理回合和技能结算。这时候靠if-else堆逻辑已经不行了,必须引入状态机和数据驱动的设计。
5.1 状态机:把混乱的逻辑理清楚
状态机的核心思想是:一个对象在任意时刻只处于一个状态,状态之间的切换有明确的规则。以塔防游戏里的敌人为例,它的状态可能是:移动中、攻击中、死亡中、已死亡。用状态机表示:
const EnemyState = { MOVING: 'moving', ATTACKING: 'attacking', DYING: 'dying', DEAD: 'dead' }; class Enemy { constructor() { this.state = EnemyState.MOVING; this.stateTime = 0; } update(deltaTime) { this.stateTime += deltaTime; switch (this.state) { case EnemyState.MOVING: this.moveAlongPath(deltaTime); if (this.reachedEnd()) this.setState(EnemyState.ATTACKING); break; case EnemyState.ATTACKING: this.attack(); if (this.targetDestroyed()) this.setState(EnemyState.MOVING); break; case EnemyState.DYING: if (this.stateTime > 500) this.setState(EnemyState.DEAD); break; case EnemyState.DEAD: this.pool.release(this); break; } } setState(newState) { this.state = newState; this.stateTime = 0; } }状态机最大的价值是可读性和可扩展性。当策划说“敌人血量低于30%时要逃跑”,你只需要加一个FLEEING状态和对应的切换条件,不用去动其他逻辑。我读过的很多塔防源码,没用状态机的版本代码量是用了状态机的两倍,而且改一个逻辑要翻半天。
5.2 关卡数据设计:把逻辑和配置分离
高级档游戏的另一个特征是关卡多、配置复杂。如果每关的逻辑都写死在代码里,改一个数值就要重新发版。正确的做法是数据驱动:关卡配置抽成JSON,代码只负责解释数据。
{ "level": 3, "waves": [ { "delay": 0, "enemies": [ { "type": "slime", "count": 5, "interval": 800, "path": 0 } ] }, { "delay": 10000, "enemies": [ { "type": "bat", "count": 8, "interval": 500, "path": 1 }, { "type": "slime", "count": 3, "interval": 1000, "path": 0 } ] } ], "reward": { "gold": 100, "stars": 2 } }这样设计之后,加新关卡就是加一段JSON,调难度就是改数值,测试和策划都能自己动手,开发效率提升非常明显。我在实际项目里把这套思路用上之后,关卡从10关扩展到50关,代码几乎没动。
5.3 消除类的连锁反应:递归还是迭代
消除类游戏最核心的算法是“检测三连、消除、下落、补充、再检测”。这个过程天然是递归的,因为消除后可能产生新的三连。但递归在JavaScript里有栈溢出的风险,尤其是连锁很长的时候。我的做法是用迭代加循环:
function resolveBoard(board) { let comboCount = 0; let hasMatch = true; while (hasMatch) { const matches = findMatches(board); if (matches.length === 0) { hasMatch = false; } else { comboCount++; removeMatches(board, matches); applyGravity(board); refillBoard(board); } } return comboCount; }用while循环代替递归,逻辑一样清晰,但没有栈溢出风险。而且comboCount可以拿来做连击奖励,一举两得。这个细节我在至少五款消除类源码里看到过不同的处理方式,迭代版本是最稳的。
6. 硬核档浅探:物理引擎与多人同步
硬核档的游戏我精读的不多,因为每一款都需要大量前置知识。但既然这280多款里有,我还是挑了两个方向做了研究,这里分享一些入门级的理解,给想深入的朋友一个方向。
6.1 物理引擎:从自己写到用现成的
物理类游戏(比如愤怒的小鸟那种)涉及重力、碰撞响应、摩擦力、弹性系数等。自己从零写一个完整的物理引擎工作量巨大,实际项目中通常用现成的库。但理解基本原理还是必要的,至少要知道重力加速度和碰撞响应是怎么回事。
// 简化的物理更新 function updatePhysics(body, deltaTime) { // 重力 body.vy += gravity * deltaTime; // 位置更新 body.x += body.vx * deltaTime; body.y += body.vy * deltaTime; // 地面碰撞 if (body.y + body.radius > groundY) { body.y = groundY - body.radius; body.vy = -body.vy * restitution; // restitution是弹性系数 body.vx *= friction; // 摩擦力 } }关键参数是弹性系数和摩擦力。弹性系数1表示完全弹性碰撞(永远弹下去),0表示完全不弹。摩擦力0表示无摩擦(永远滑下去),1表示立刻停下。实际调参时,弹性系数0.6到0.8、摩擦力0.98到0.99是比较自然的手感。这些数值没有标准答案,全靠反复试。
6.2 多人同步:状态同步还是帧同步
多人游戏的核心问题是:多个玩家的画面怎么保持一致。主流方案有两种:状态同步(服务器定期广播所有玩家的状态)和帧同步(服务器只广播操作指令,各客户端自己计算)。状态同步实现简单但流量大,帧同步流量小但对客户端计算一致性要求高。
我研究的那款多人对战demo用的是状态同步,服务器每50毫秒广播一次所有玩家的位置和动作。客户端收到后做插值平滑,避免画面跳动。这个方案适合玩家数量少的场景,实现起来也直观。帧同步更适合RTS类游戏,但需要保证所有客户端的随机数一致、浮点运算一致,坑非常多,不建议新手碰。
提示:硬核档的内容不要贪多,选一个方向深入就够了。物理和多人同步是两个完全不同的技术栈,同时学容易两头都学不透。
7. 常见问题与排查技巧实录
这两个月里我遇到的问题五花八门,这里挑几个最有代表性的整理成速查表,都是实际踩过的坑。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 游戏在高刷屏上速度过快 | 用了固定步长而非deltaTime | 在120Hz设备上测试 | 所有移动乘以deltaTime |
| 音频在iOS上不播放 | AudioContext未激活 | 检查是否在用户交互后resume | 首次点击时调用audioContext.resume() |
| 子弹复用后方向错乱 | 对象池reset不完整 | 打印复用对象的初始状态 | 补全reset方法所有字段 |
| 消除后棋盘错位 | 下落逻辑未处理空位 | 打印下落前后的棋盘数组 | 用列优先遍历,从下往上填充 |
| 游戏运行一段时间后卡顿 | 对象未回收,内存泄漏 | 用Performance面板看内存曲线 | 引入对象池,及时release |
| 触摸事件和点击事件重复触发 | 同时绑定了touch和click | 在移动端打印事件日志 | 统一用pointer事件或做防抖 |
| Canvas在高分屏上模糊 | 未处理devicePixelRatio | 对比不同设备的截图 | canvas尺寸乘以dpr,CSS尺寸不变 |
| 游戏切到后台再回来时间错乱 | requestAnimationFrame暂停后时间戳跳跃 | 监听visibilitychange事件 | 切回时重置lastTime |
7.1 高分屏模糊:一个必踩的坑
Canvas在Retina屏上模糊是几乎每个人都会遇到的问题。原因是Canvas的像素尺寸和CSS显示尺寸不一致。解决方案是:
const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);这样设置之后,Canvas的实际像素是显示尺寸的dpr倍,绘制时再缩放回来,画面就清晰了。注意scale之后所有坐标都按CSS像素算,不用手动乘dpr,否则会重复放大。
7.2 切后台时间错乱:visibilitychange是关键
游戏切到后台时,requestAnimationFrame会暂停。切回来时,timestamp会跳变一大截,如果直接用这个差值更新,游戏会瞬间“快进”。解决方案是监听页面可见性变化:
let lastTime = 0; document.addEventListener('visibilitychange', () => { if (!document.hidden) { lastTime = performance.now(); // 重置时间基准 } }); function gameLoop(timestamp) { if (lastTime === 0) lastTime = timestamp; let deltaTime = timestamp - lastTime; lastTime = timestamp; // 限制最大步长,防止切回时跳跃过大 deltaTime = Math.min(deltaTime, 100); // ...更新逻辑 }这里我还加了一个Math.min(deltaTime, 100)的限制,即使时间戳跳变,单帧最多按100毫秒计算,避免物理穿透等问题。这个细节在大多数入门源码里都没有,但实际项目中非常必要。
7.3 我的独家避坑清单
除了上面表格里的,还有几条经验值得单独说:
- 不要相信“这个游戏很简单”。任何一款游戏,只要你打算改造它,都会发现一堆隐藏的边界情况。预留足够时间。
- 先跑通再读代码。拿到源码第一件事是让它跑起来,看到效果再读代码,理解会快很多。
- 改一个参数就测一次。不要一次性改一堆参数,出了问题根本不知道是哪个引起的。
- 保留原始版本。改造前先复制一份,改坏了随时能回到起点。
- 写注释比读注释重要。读源码时把自己的理解写成注释,过一周再回来看,你会感谢自己。
8. 从280款到自己的作品:学习路径建议
最后聊聊怎么把这280多款源码真正转化成自己的能力。我的建议是分三个阶段走。
第一阶段是临摹:挑10款入门档游戏,不看源码先自己写一遍,写完再对照源码找差距。这个阶段的目标是熟悉游戏循环、碰撞检测、状态管理这些基本功。我当时的做法是每天一款,写完对比,两周下来手感就出来了。
第二阶段是改造:挑5款进阶档游戏,在原基础上加功能。比如给打砖块加道具系统,给飞机大战加Boss战,给消除类加特殊方块。这个阶段的目标是理解代码结构,学会在别人的框架里扩展。改造比从零写更能锻炼工程能力,因为你要读懂别人的思路再融入自己的需求。
第三阶段是原创:把学到的技术点组合起来,做一款属于自己的小游戏。不用追求复杂,把玩法做扎实、手感调好就行。我自己的第一款原创是个融合了跑酷和消除的小游戏,技术上没什么新东西,但把之前学的对象池、状态机、数据驱动全用上了,做完之后对整个技术栈的理解上了一个台阶。
这280多款源码最大的价值,不是让你每款都学会,而是给你一个足够大的参考库。遇到某个效果不知道怎么实现时,翻一翻,大概率能找到类似的实现。我到现在还保留着这个库,偶尔还会翻出来看看,每次都有新收获。
如果你也想走这条路,我的建议是:别急着追求数量,先把一款吃透。一款打砖块读十遍,比十款游戏各读一遍收获大得多。技术这东西,深度永远比广度更重要。等你把几款游戏真正吃透了,再看其他的,会发现底层逻辑都是相通的,上手速度会快得超出你想象。