
简介这是一份基于HTML5 Canvas开发的天火决冒险类闯关游戏完整源码面向Web前端初学者与游戏开发入门者用于学习Canvas绘图、动画循环、交互事件、物理模拟与碰撞检测等核心技能。压缩包共39个文件包含31张PNG图片素材角色、怪物、背景、武器、道具等、5个JavaScript逻辑脚本如HGame.js、start.js、data.js等、1个HTML入口页面、1个JPG文件及使用须知整体仅267KB体量虽小却覆盖了游戏开发的关键环节。目前已有96人学习下载。通过研读源码可以看清项目如何用Canvas绘制背景地形与角色动画如何通过定时器实现移动攻击等帧动画如何监听鼠标触摸完成操作驱动游戏逻辑以及如何对物体位置、速度和加速度进行计算以模拟重力与碰撞同时源码中菜单、得分、生命值等UI界面也直接渲染在Canvas上与游戏画面融为一体。配合使用须知和图片素材读者能轻松运行项目、替换美术资源是快速上手Canvas游戏开发的轻量级实战参考。1. 天火决一份能拆着学的 HTML5 Canvas 冒险闯关源码天火决是一份用原生 HTML5 Canvas 实现的冒险类闯关游戏源码目录结构非常“素”index.html 负责挂载 canvasjs 目录下 HGame.js 管主循环与引擎调度objProto.js 管实体对象的原型继承start.js 管初始化algorithm.js 管碰撞与敌人逻辑data.js 存关卡配置。素材按拼音简写分目录bg 是背景monster 是怪物rw 是角色wuqi 是武器jn 是技能dx 和 wp 大概率是道具和物品。没有依赖第三方引擎所以非常适合当 canvas 绘图引擎的学习样本。但第一次打开别直接用浏览器双击 index.html。Canvas 的 drawImage 在 file:// 协议下加载本地图片会被浏览器安全策略拦截现象是白屏、控制台报错和“源码有问题”是两回事。使用须知.txt 里一般会写这层运行环境。正确姿势是起一个本地 HTTP 服务再访问。重点看五个脚本怎么协作每个模块都会给出可以直接拿走的代码和参数说明。2. HGame.js 主循环与资源预加载canvas 绘图引擎的地基2.1 requestAnimationFrame 驱动的帧循环比 setInterval 稳在哪原生 Canvas 游戏没有现成的播放器帮你调度HGame.js 承担的是“每帧该做什么”的总控。新手写小游戏时很容易用 setInterval(loop, 16)但 setInterval 有两个问题第一它按固定间隔排队执行页面切到后台也继续跑移动端尤其耗电第二它与显示器刷新不同步容易出现半帧跳变。requestAnimationFrame 由浏览器在每次实际重绘前回调帧率自动与屏幕刷新率对齐页面不可见时自动停掉这套源码的主循环基本都建立在它上面。// HGame.js 中常见的主循环骨架天火决的 update/render 都从这里进入 const HGame { canvas: document.getElementById(gameCanvas), ctx: null, state: load, // load - menu - play - pause - over lastTime: 0, deltaTime: 0, // 上一帧到当前帧的秒数 init() { this.ctx this.canvas.getContext(2d); this.lastTime performance.now(); this.loop(this.lastTime); }, loop(now) { // 换算成秒并限制最大步长防止切后台回来角色瞬移 this.deltaTime (now - this.lastTime) / 1000; this.deltaTime Math.min(this.deltaTime, 0.05); this.lastTime now; if (this.state play) { this.update(this.deltaTime); this.render(); } requestAnimationFrame((t) this.loop(t)); }, update(dt) { // 实体在这里移动、攻击、碰撞 }, render() { // 背景、实体、特效、UI 都画到 ctx 上 } }; HGame.init();这里 now 和 lastTime 的单位是毫秒来自 performance.now()除以 1000 转成秒角色移动速度如果用 200 px/s 乘 dt结果就是帧率无关的位移0.05 秒的上限意味着切后台超过 50ms 后回来不会把怪物位置一口气推进一大截。init 里先拿 canvas 上下文再启动 requestAnimationFrame这是 HTML5 动画标准的启动路径。如果拿不到 canvas要立刻检查 index.html 里 canvas 标签的 id 是否和脚本里 getElementById 的参数一致少了这步后面所有绘制都会在空对象上报错。2.2 用状态机管理 loading、play、pause避免逻辑串台闯关游戏在运行过程中至少会经过“加载资源、菜单、关卡内、暂停、通关或失败”几个状态。如果只用一堆散落的布尔变量很容易出现怪物在暂停时继续移动、加载没完就开始渲染的情况。天火决这类原生源码里状态机一般就是一个字符串字段加一份常量对照表足够轻量又比到处写魔法字符串好维护。// 游戏状态用常量收敛避免字符串直接散落在各处 const GAME_STATE { LOADING: loading, MENU: menu, PLAYING: playing, PAUSE: paused, GAME_OVER: over }; // 状态切换统一走这里方便在边界做清理 function switchState(next) { if (HGame.state GAME_STATE.PLAYING next GAME_STATE.PAUSE) { // 暂停时把定时器和音效挂起 } HGame.state next; }状态机的关键约束是update 只在 PLAYING 时执行render 在 LOADING 时只画进度条PAUSE 时画面定格。如果加载没完成就把 state 切到 PLAYING实体列表里全是空对象update 一访问 this.x 就会抛 TypeError。所以 switchState 里做边界清理比在 loop 里堆 if 判断干净得多。命名上建议和 GAME_STATE 常量保持一致data.js 里的关卡配置也按状态字段来切场景。2.3 资源预加载monster、bg、wuqi 目录的图片要全部就位再开跑Canvas 的 drawImage 如果传一个没加载完的 Image 对象画出来是空白如果 src 路径写错控制台直接报 404。天火决素材按 bg、monster、jn、rw、wuqi、dx、wp 分目录HGame.js 里通常会有一个资源表把所有图片路径先登记好用 onload 计数全部完成后才把 state 切到 play。// 资源预加载多张图片并行加载全部完成后回调 function loadAssets(srcMap, onComplete) { const keys Object.keys(srcMap); let loaded 0; const assets {}; keys.forEach((key) { const img new Image(); img.onload () { assets[key] img; loaded 1; if (loaded keys.length) onComplete(assets); }; img.onerror () { // 单张失败不能卡死整局打印路径后跳过并继续计数 console.error(image load failed:, srcMap[key]); loaded 1; if (loaded keys.length) onComplete(assets); }; img.src srcMap[key]; }); } // 调用处加载完再切状态assets 挂在全局方便 drawImage 取用 loadAssets({ bg: bg/desert.png, hero: rw/hero.png, slime: monster/slime.png, sword: wuqi/sword.png }, (loadedAssets) { HGame.assets loadedAssets; switchState(GAME_STATE.PLAYING); });onerror 分支很关键某张策划配的图缺失时游戏卡在 loading 状态比缺一张图更难受。assets 字典以 key 取图后续绘制时传HGame.assets[hero]给 drawImage。注意 onload 里不能直接赋值给全局 assets 再判断长度因为多张图加载完成的顺序不确定计数器方式比顺序数组靠谱。下面是这套源码里素材目录的常见用途处理 404 时可以直接对着查目录名源码中的常见用途加载失败时的现象bg关卡背景、场景地面角色浮空背景全白monster怪物精灵图敌人消失但碰撞还在rw玩家角色精灵图只有影子或完全不可见wuqi武器动画帧攻击无表现判定仍在jn技能特效帧招式无光效dx / wp道具、物品图标拾取得不到视觉反馈提示本地调试时不要双击 index.html 用 file:// 打开canvas 的 drawImage 加载本地图片会被浏览器策略拦掉。用 VSCode 的 Live Server 或者python -m http.server 8000起服务再访问 http://localhost:8000 才能完整复现效果。3. objProto.js 原型链与角色动画rw、wuqi、jn 对象如何画出来3.1 为什么 2D 闯关源码偏爱原型继承和对象池天火决里有很多同类实体普通小怪、精英怪、玩家、武器弹道、技能特效。如果每个对象把方法复制一遍内存浪费且难维护如果全部用 class 写早期源码又不想引入编译步骤。objProto.js 的做法很直接定义 Entity 基类把通用的 x、y、width、height、alive 字段放在构造函数里把 update、draw 挂在 prototype 上子类通过 Object.create 继承覆盖需要变化的方法。这个模式在无构建工具的 HTML5 项目里很常见运行开销也低。// objProto.js 风格的实体基类与继承 function Entity(opts) { this.x opts.x; this.y opts.y; this.width opts.width; this.height opts.height; this.alive true; } Entity.prototype.update function (dt) { // 基类不做事子类覆盖 }; Entity.prototype.draw function (ctx) { // 基类不做事子类覆盖 }; // 玩家角色继承 Entity function Hero(opts) { Entity.call(this, opts); this.hp opts.hp || 100; this.speed opts.speed || 200; this.attackCd 0; // 攻击冷却单位秒 } Hero.prototype Object.create(Entity.prototype); Hero.prototype.constructor Hero; Hero.prototype.update function (dt) { // 移动、攻击冷却递减、触发技能判定 if (this.attackCd 0) this.attackCd - dt; }; Hero.prototype.draw function (ctx) { // 用 sprite 帧画角色具体见 3.2 };为什么用 Entity.call 而不是直接 Hero.prototype new Entity()后者会创建一个带 undefined 字段的实例污染原型链Object.create 只继承原型方法不执行基类构造函数。opts 是配置对象字段保持与 data.js 数据表一致attackCd 用秒做单位与主循环 deltaTime 搭配。现在新项目可以直接写 class但读这份源码时还是按原型链理解更顺因为碰撞检测和动画绘制都基于这个继承模型而且 class 的 super 调用与这里的 call 语义不完全等价。3.2 用 drawImage 的 9 参数从 sprite sheet 里裁出动画帧角色动画最朴素的做法是多张图片轮换但天火决这类项目更可能是把动作帧横排在一张 PNG 里每一帧用 drawImage 的源裁剪参数取出来。怪物和角色都复用一个绘制函数只需要指定当前在第几列第几行。这也是 canvas 游戏源码最常见的帧动画实现方式比切多张小图省去大量 http 请求。// 从精灵图中裁剪第 (col, row) 帧并绘制到 (x, y) function drawSprite(ctx, img, col, row, frameW, frameH, x, y, scale) { ctx.drawImage( img, col * frameW, row * frameH, frameW, frameH, // 源矩形裁剪精灵图 x, y, frameW * scale, frameH * scale // 目标矩形画到画布 ); } // 播放攻击动画每 50ms 换一列共 4 帧 // ctx 为 HGame.ctximg 为 wuqi 目录下加载好的武器图 drawSprite(ctx, HGame.assets[wuqi], Math.floor(this.animTime / 50) % 4, 0, 64, 64, this.x, this.y, 1);9 参 drawImage 的前 4 个参数是源图中的裁剪区域后 4 个是画布上的目标区域。col * frameW 计算帧偏移row * frameH 支持竖排多行动画。animTime 在 update 里累加 dt除以 50 毫秒取整再对 4 取模就得到 0、1、2、3 循环的帧号。如果图片不是等宽帧这个算法会错位需要额外传每帧宽度的数组。参数含义对应关系如下调试时最容易搞混的是把源区和目标区写反结果整张图糊在画布左上角。参数段含义天火决里的典型值img已加载的 Image 对象HGame.assets[bg] 或 [monster]源区 sx, sy, sw, sh从精灵图里裁剪的位置和尺寸col64, row64, 64, 64目标区 dx, dy, dw, dh画到画布的位置和尺寸角色坐标, 宽高乘缩放3.3 start.js 的初始化顺序先有画布再建实体后读关卡看 start.js 时先理清执行顺序等 DOM 渲染完成拿到 canvas初始化 HGame再根据 data.js 里的关卡数据创建玩家和小怪最后把控制权交给主循环。如果在 onload 之前就去 getElementById拿回来是 null后面所有实体都会在空对象上报错。// start.js入口脚本常见流程 window.onload function () { const canvas document.getElementById(gameCanvas); if (!canvas) { console.error(未找到 gameCanvas请检查 index.html 的 canvas 标签); return; } HGame.init(); // 拿 2d 上下文启动主循环 HGame.entities []; // 实体列表objProto.js 的子类都放这里 HGame.hero new Hero({ x: 100, y: 400, width: 48, height: 64, hp: 100, speed: 200 }); // 先创建实体但不进入 play等图片加载完再开始 LEVEL_CONFIG.monsters.forEach((m) { HGame.entities.push(new Monster(m)); }); loadAssets(ASSET_SRC, (loadedAssets) { HGame.assets loadedAssets; switchState(GAME_STATE.PLAYING); }); };window.onload 保证 DOM 和初始图片就绪但怪物精灵图其实是走 loadAssets 异步加载所以 switchState 必须放在回调里避免首帧渲染时 assets 还是空对象。Hero 配置里的 width、height 会传给基类 Entity 构造函数碰撞检测直接用这组尺寸计算。新增实体类型时先 new 再 push 到 entities主循环统一调用 update 和 render这种集中注册方式比散落各处好排查。4. algorithm.js 碰撞检测与 data.js 关卡表闯关怎么判定成功4.1 AABB 矩形碰撞是 2D 闯关的默认选项攻击命中的判定、角色踩到道具、出口传送门几乎都可以用 AABB 轴对齐包围盒解决。原理是两个矩形各自用 x、y、width、height 表达水平和垂直两个方向同时重叠才判为碰撞。algorithm.js 里这个函数通常只有四行却是整个关卡循环里调用频率最高的代码。// algorithm.jsAABB 矩形碰撞判定返回是否重叠 function hitTest(a, b) { return a.x b.x b.width a.x a.width b.x a.y b.y b.height a.y a.height b.y; }四个不等式分别检测 a 的左边是否在 b 的右边以左、a 的右边是否在 b 的左边以右垂直方向同理。注意这个模型假设实体移动后每帧都同步更新 x 和 y且碰撞盒不旋转如果角色的武器是斜向挥砍需要额外画一个扇形或角度的攻击盒单靠矩形会明显“打不到”。参数 a、b 只要带 x、y、width、height 四个字段就能参与运算不一定必须是同一类型道具和出口也可以复用。碰撞方案对比方式性能准确度适用场景AABB 矩形快纯比较一般大多数实体、子弹、攻击范围圆形快需开方较好角色与弹幕边界更自然像素级慢逐像素高技能特效与复杂地形的边缘判定jn 技能特效如果用了半透明光效一般还是用矩形框近似只有 BOSS 的复杂弹幕才值得逐像素。逐像素常见优化是先用 AABB 粗筛再用 getImageData 比较 alpha 通道但全屏逐像素每帧做会非常卡只能用在局部小范围。4.2 怪物 AI从巡逻到追击本质是距离判断algorithm.js 里除了碰撞还负责怪物行为。闯关小怪的 AI 不需要很复杂一个 alertRange 就能区分“你走你的、我追我的”两种状态。追击时朝玩家方向按速度前进巡逻时在 minX 和 maxX 之间来回。攻击逻辑放在碰撞阶段追击到接触范围就触发 hitTest命中后按攻击冷却计算伤害。// monster 的简单 AI巡逻与追击 Monster.prototype.update function (dt, hero) { // 与玩家的直线距离 const dist Math.hypot(hero.x - this.x, hero.y - this.y); if (dist this.alertRange) { // 追击方向归一化后乘以速度得到像素位移 const dirX (hero.x - this.x) / dist; const dirY (hero.y - this.y) / dist; this.x dirX * this.speed * dt; this.y dirY * this.speed * dt; this.state chase; } else { // 巡逻只在 minX/maxX 区间来回 this.x this.dir * this.speed * 0.4 * dt; if (this.x this.minX || this.x this.maxX) { this.dir * -1; } this.state patrol; } };dist 被用两次一次做距离判断一次做方向归一化。除以 dist 前要先排除 dist 为 0 的情况否则 dirX 会变成 NaN。speed 与 0.4 配合让巡逻速度明显低于追击玩家离得远时能观察到规律离得近才感到压力。alertRange 放大了怪物感应范围改太大容易变成全图追人。这套源码多数不区分怪物视野方向想做得更公平可以在追击前加一个“玩家是否在怪物正面”的判断。4.3 data.js 数据驱动关卡怪物坐标、道具、出口都在配置表里天火决的 data.js 承担关卡配置的职责改配置就能换关不用动代码。字段名和实体构造参数一一对应启动时由 start.js 遍历配置创建对象。这种数据驱动方式对调数值非常友好难度曲线、怪物密度都收敛在一个文件里。// data.js第一关配置字段名与 Entity 构造参数对应 const LEVEL_CONFIG { id: 1, bg: bg/desert.png, // 背景图路径 monsters: [ { type: slime, x: 300, y: 450, width: 48, height: 48, alertRange: 120, speed: 80, dir: 1, minX: 200, maxX: 500 }, { type: slime, x: 620, y: 450, width: 48, height: 48, alertRange: 140, speed: 90, dir: -1, minX: 500, maxX: 800 } ], weapons: [ { type: sword, x: 700, y: 460, width: 60, height: 40, damage: 25 } ], items: [ { type: hp, x: 350, y: 420, width: 32, height: 32, value: 30 } ], exit: { x: 960, y: 200, width: 60, height: 80 } };monsters 里的每个对象都会传给 new Monster(m)字段直接成为怪物属性exit 在 AABB 判定中当作隐形矩形玩家 hitTest 到它就算通关。x 和 y 是出生坐标建议先打开背景图确认地面位置别让怪物浮空或埋进地里。items 的 value 是加血值要和 Hero 的 maxHp 匹配给大了关卡失去平衡。怪物常用字段的调试思路字段作用调参建议alertRange开始追击的距离100 左右有紧张感超过 240 接近全图追speed追击像素速度比玩家慢 2030% 较好dir初始巡逻方向1 右、-1 左minX / maxX巡逻区间边界别超出背景宽度提示改完 data.js 后如果新增了字段要在 objProto.js 对应实体的构造函数里接收并赋初值否则实体上会挂 undefined 属性碰撞检测和绘制都会受影响。5. 把天火决改造成自己的 HTML5 小游戏帧率排查与交互扩展5.1 帧率与耗时统计先看清卡在哪一帧改造任何 canvas 绘图引擎项目第一步都是在主循环里加 FPS 统计。不要每帧都输出60 帧聚合成一个平均值看波动比看瞬时值更有意义。// 简易 FPS 统计每秒输出一次 let frameCount 0; let fpsStart performance.now(); function fpsStat() { frameCount 1; const now performance.now(); if (now - fpsStart 1000) { console.log(FPS:, Math.round(frameCount * 1000 / (now - fpsStart))); frameCount 0; fpsStart now; } } // 在 HGame.loop 的 update 之前调用 fpsStat()frameCount 累加帧数等过 1 秒后按实际毫秒数换算能反映长时间掉帧。如果 FPS 低于 55先看是不是 drawImage 次数太多或者背景整张大图每帧重绘。把静止背景先画到离屏 canvas 缓存一份再 drawImage 整块贴回通常能救回不少帧。5.2 键盘与触屏输入层一套操作走通 PC 和移动端输入层建议统一收集而不是在每个实体里重复监听键盘。战斗逻辑只读 input 对象换设备不用改玩法。// 输入层同时兼容键盘与触屏 const input { left: false, right: false, jump: false, attack: false }; // 键盘按下与松开都要处理否则会卡键 window.addEventListener(keydown, (e) { if (e.code ArrowLeft || e.code KeyA) input.left true; if (e.code ArrowRight || e.code KeyD) input.right true; if (e.code Space || e.code ArrowUp) input.jump true; }); window.addEventListener(keyup, (e) { if (e.code ArrowLeft || e.code KeyA) input.left false; if (e.code ArrowRight || e.code KeyD) input.right false; if (e.code Space || e.code ArrowUp) input.jump false; }); // 触屏端点按屏幕右侧视为攻击左侧视为跳跃 canvas.addEventListener(touchstart, (e) { const touchX e.touches[0].clientX; if (touchX window.innerWidth / 2) input.attack true; else input.jump true; });keydown 和 keyup 必须成对监听漏了 keyup 角色会一直往左跑这是 Canvas 游戏最常见的操控 bug。e.code 是物理键位不受输入法影响触屏端用 clientX 与屏幕宽度比较比写死坐标兼容更多的异形屏。5.3 替换素材与数值最小的“换皮”路径天火决这套代码最值得复用的是两个接口HGame.js 的主循环和 objProto.js 的实体模型。想改成别的题材保持 data.js 字段不变替换 img 目录下的图片路径把 algorithm.js 里 alertRange 从 120 调到 80难度立刻下降把 Hero 的 speed 从 200 提到 260手感从“稳重”变“轻快”。复用这套源码时我先改 data.js 的 LEVEL_CONFIG 验证主循环能跑通再替换图片路径最后调 algorithm.js 里的数值每一步都能快速验证效果。本文还有配套的精品资源点击获取