
简介使用HTML5 Canvas实现的天火决冒险类闯关游戏源码面向Web前端开发者、游戏编程初学者以及对Canvas动画感兴趣的读者是一份可直接运行与学习的完整项目。资源共39个文件压缩包仅267KB包含5个JavaScript脚本、31张PNG图像素材、1个HTML页面、1个使用说明和1张JPG图片JS脚本按数据、算法、对象原型、游戏主逻辑等模块拆分图像素材覆盖怪物、角色、武器、道具、背景等游戏元素结构清晰便于定位与扩展。通过阅读和调试源码可以理解Canvas场景绘制、基于requestAnimationFrame/定时器的动画更新、键盘与触摸交互响应、碰撞检测等常见游戏机制的实现方式也可根据使用须知快速搭建本地运行环境。该资源已有96人学习体量小巧但典型性完整既适合入门者动手改造换皮练习也能为课堂项目或独立开发提供可复用的架构参考。1. 一个canvas冒险游戏该拆成哪几块做“天火决”这类冒险闯关时最先卡住人的往往不是美术素材而是“画面和逻辑谁先谁后”。canvas只是给了你一张位图画布和一套绘图上下文但跳跃、碰撞、敌人巡逻、过关判定全部要靠JavaScript自己组织。很快你就会遇到画布上明明站着一个角色一按方向键就拖影一碰障碍就穿模——根因不是canvas画得慢而是渲染循环和逻辑更新被塞进了同一个回调。一个能持续扩展的html5 canvas冒险类闯关游戏至少要拆成四块固定步长的逻辑循环、独立的精灵对象、数据驱动的关卡地图、可切换的游戏状态机。这四块能单独测试拼起来就是天火决从角色出生到BOSS战的完整闭环。这套结构同样适用于html5超级玛丽同人复刻版、flappy bird html5原版复刻这类项目——循环与碰撞的地基一旦对了后面换贴图、换音效就像换皮肤行为表现不会跟着重写。2. canvas主循环与画布初始化——从“画得动”到“跑得准”2.1 固定步长为什么是闯关手感的地基直接用requestAnimationFrame重绘的人过两分钟就会觉得手感不对劲。浏览器刷新通常走16.7ms一帧但主线程一忙就会掉到20ms、30ms。如果每一帧都拿“上一帧到当前帧的真实间隔”做步长慢帧之后角色会突然蹿出去跳跃高度也会忽高忽低玩家对整段流程完全没有预期。闯关对时序极其敏感这种手感偏差比掉帧更难忍。对比一下三种常见的循环策略循环策略计时来源速度一致性适用场景可变步长rAF回调timestamp实时相减弱高刷新率下角色明显更快图表动画、演示稿固定步长累加器rAF里累计差值按固定时长消耗强30/60/120Hz下表现一致平台跳跃、冒险类闯关setInterval锁帧定时器直接驱动最弱切后台再恢复会失控极简demo、教学片成熟的canvas绘图引擎大多也是把rAF封装成固定步长只是没有把细节暴露给使用者。自己实现的固定步长逻辑更新永远按1/60秒走画面可以画得时快时慢但物理计步不受显示器刷新率影响这就是平台跳跃类游戏要的手感基线。2.2 初始化画布与主循环最小骨架先看页面入口注意画布放在body里而不是后面动态创建!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno / title天火决 - html5 canvas冒险闯关/title /head body canvas idgamecanvas/canvas script srcsrc/main.js/script /body /html移动端viewport加了user-scalableno防止双指缩放导致画布尺寸不停变化。PC键盘版可以不要这条但高分屏的物理像素适配仍然要做。main.js 里的固定步长骨架const canvas document.getElementById(gamecanvas); const ctx canvas.getContext(2d); // 逻辑分辨率 960x540物理像素按 DPR 放大 const DPR window.devicePixelRatio || 1; canvas.width 960 * DPR; canvas.height 540 * DPR; canvas.style.width 960px; canvas.style.height 540px; ctx.scale(DPR, DPR); const STEP 1000 / 60; // 一个逻辑帧对应 1/60 秒 const MAX_FRAME_TIME 250; // 防止切后台回来一次补几百帧 let lastTime performance.now(); let accumulator 0; function update(dtSec) { // 每 1/60 秒调用一次物理、AI、技能结算都放这里 } function render() { ctx.clearRect(0, 0, 960, 540); // 地图、角色、特效的绘制 } function frameLoop(now) { const delta Math.min(now - lastTime, MAX_FRAME_TIME); lastTime now; accumulator delta; while (accumulator STEP) { update(STEP / 1000); accumulator - STEP; } render(); requestAnimationFrame(frameLoop); } requestAnimationFrame(frameLoop);逻辑说明DPR处理高分屏模糊逻辑坐标恒定物理分辨率自适应while会追回卡顿欠下的逻辑帧防止速度计算断裂MAX_FRAME_TIME把切后台回来后的一次性补帧限制在 250ms防止物理崩坏。参数说明STEP想改成 30 帧逻辑就把1000/60改成1000/30但第 3 章的加速度数值也要按比例调整因为单位是“像素/秒”和逻辑帧频率无关。提示脚本放在 body 底部仍建议监听 DOMContentLoaded 再取 canvas直接拿到 null 的概率不高但防御性写法不会多花几行。2.3 游戏状态机MENU、PLAYING、PASSED没有状态机的闯关会陷入“死亡后角色还在跑”“通关后还能吃金币”这类逻辑互斥。把状态切换单独抽出来每进入一个状态只做一次清理const GameState { MENU: MENU, PLAYING: PLAYING, PASSED: PASSED, GAMEOVER: GAMEOVER }; let state GameState.MENU; function changeState(next) { const prev state; state next; if (next GameState.MENU) resetWorld(); if (next GameState.PLAYING prev GameState.MENU) { worldTimer 0; player.hp PLAYER_MAX_HP; } } function update(dtSec) { if (state ! GameState.PLAYING) return; updatePlayer(dtSec); updateEnemies(dtSec); if (player.hp 0) changeState(GameState.GAMEOVER); }切换状态时只补位一次的代码避免了每帧判断“是否刚过关”再去执行存档。将来加暂停态也只需在update开头增加if (state GameState.PAUSED) return;。3. 角色动作与物理键盘输入、跳跃、墙边修正3.1 AABB碰撞盒不是越贴图越好新手画碰撞区总想把整张精灵图全框进去结果走到悬崖边时脚没踩到地面却已经悬空。平台跳跃的碰撞体积实际要比贴图瘦一圈、矮半截视觉上“差点蹭到”对玩家而言仍然安全。以竖版冒险角色为例碰撞盒宽度占贴图60%~70%高度占75%~80%是比较舒服的区间这也是html5超级玛丽同人复刻版采用的标准整理方式。盒子字段含义典型取值box.x相对贴图左上角的横向偏移贴图宽 * 0.10~0.15box.w碰撞盒宽度贴图宽 * 0.60~0.70box.y相对贴图左上角的纵向偏移贴图高 * 0.05~0.15box.h碰撞盒高度贴图高 * 0.75~0.85代码上把“取真实矩形”独立成方法class Player { constructor(w, h) { this.x 100; this.y 200; this.w w; this.h h; this.box { x: w * 0.12, y: h * 0.08, w: w * 0.66, h: h * 0.78 }; } getRect() { return { x: this.x this.box.x, y: this.y this.box.y, w: this.box.w, h: this.box.h }; } } function hitAABB(a, b) { return a.x b.x b.w a.x a.w b.x a.y b.y b.h a.y a.h b.y; }碰撞盒与绘制分离后换贴图、换皮肤都不用动判定逻辑。攻击判定的刀光还能用另一套更大的矩形去计算不会和受击反馈互相污染。3.2 跳跃与重力参数怎么定不要直接抄物理课本里的9.8。canvas坐标里像素没有统一度量更实用的方法是先定跳跃高度再反推重力。天火决里我给的初始参数是跳跃距离约一格、滞空时间约0.15秒反推后的数值如下。const PHYSICS { gravity: 2000, // 像素/秒^2 moveSpeed: 260, // 像素/秒 maxFall: 620, // 像素/秒限幅下落速度 jumpVelocity: -580, // 跳起瞬间速度负数向上 coyoteTime: 0.08, // 走出平台边沿后可跳缓冲 jumpBufferTime: 0.1 // 早按跳键仍可起跳 };对应更新代码function updatePlayer(dt) { const left keyMap[ArrowLeft] || keyMap[a]; const right keyMap[ArrowRight] || keyMap[d]; player.vx right ? PHYSICS.moveSpeed : left ? -PHYSICS.moveSpeed : 0; player.vy PHYSICS.gravity * dt; player.vy Math.min(player.vy, PHYSICS.maxFall); player.x player.vx * dt; player.y player.vy * dt; solveVerticalCollision(player, map); if (player.onGround player.vy 0) { player.vy 0; } }为什么jumpVelocity是负数canvas坐标y轴向下向上跳速度就是负的新手常在这里写反。coyoteTime解决“走出平台边缘再按跳”的遗漏jumpBufferTime解决“提前按了跳键落不了地”的挫败两者共同让手感变“粘”。3.3 墙边修正x方向为什么要分两步检测角色卡在墙缝里疯狂抖动根源是同时改x和y再统一做碰撞检测。向右移动并下落时同时碰到右侧墙和脚下地面根本分不清该往左挪还是往上顶。把水平与垂直拆开后每个方向独立求出修正量冲突就消失了。function moveEntity(entity, map, dt) { // 水平移动 entity.x entity.vx * dt; let rect entity.getRect(); const walls map.solids.filter(t hitAABB(rect, t)); if (entity.vx 0) { entity.x Math.min(...walls.map(t t.x)) - entity.box.x - entity.box.w; } else if (entity.vx 0) { entity.x Math.max(...walls.map(t t.x t.w)) - entity.box.x; } entity.vx 0; // 垂直移动 entity.y entity.vy * dt; rect entity.getRect(); const floors map.solids.filter(t hitAABB(rect, t)); if (entity.vy 0) { entity.y Math.min(...floors.map(t t.y)) - entity.box.y - entity.box.h; entity.onGround true; } else if (entity.vy 0) { entity.y Math.max(...floors.map(t t.y t.h)) - entity.box.y; } entity.vy 0; }多块tile同时命中时Math.min/Math.max用来挑选当前真正穿过去的那一面。onGround true只在vy 0时赋值上顶到天花板不会误判成落地这是跳跃逻辑中最常被忽略的分支。敌人和子弹也能直接复用这个方法只要它们有统一的getRect接口。注意贴着墙往上跳时水平速度会被清零下一帧又重新挂上方向键的加速度所以不会出现“顶墙跳不起来”的黏滞感。4. 闯关关卡的数据驱动配置与地图重置4.1 地表用二维数组角色用对象列表关卡设计若全在代码里硬编码坐标改一张图就要翻一大段逻辑。更常见的方案是拆两层地表结构用二维数组员工与机关用对象列表。数组描述网格天然做碰撞实体留着自由坐标boss和道具往往要卡在非整格位置。const LEVEL_1 { name: 焚风峡谷, width: 40, height: 15, tileSize: 32, map: [ 1111111111111111111111111111111111111111, 1000000000000000000000000000000000000001, 1011010000000000000000000000000000000011 ], entities: [ { type: playerSpawn, x: 96, y: 384 }, { type: spike, x: 512, y: 416 }, { type: coin, x: 480, y: 352 }, { type: boss, x: 1152, y: 352, phase2: true } ] };地图里的字符只代表地表玩家出生点、敌人、金币全部放entities。后续出“第二关机关改地形”的需求只改数组字符串实体部分原地保留不会互相踩踏。4.2 地图代号表与解析约定给常用tile先定一张表解析后直接带出属性代号生成内容碰撞特性0空气无无1石砖实心普通地面2炎晶石实心可被火焰技能击碎3尖刺无接触扣血4金币无拾取计分5出口门无进入后触发PASSED解析代码不要写十层if用配置对象展开const TILE_DEFS { 0: { solid: false }, 1: { solid: true, sprite: ground }, 2: { solid: true, sprite: crystal, breakable: true }, 3: { solid: false, damage: 1, sprite: spike }, 4: { solid: false, coin: 1, sprite: coin }, 5: { solid: false, exit: true, sprite: door } }; function parseLevel(data) { const grid []; data.map.forEach((rowText, y) { grid[y] []; for (let x 0; x rowText.length; x) { const code Number(rowText[x]); grid[y][x] { code, x: x * data.tileSize, y: y * data.tileSize, ...TILE_DEFS[code] }; } }); return grid; }判断碰撞时只看tile.solid要加新元素时在TILE_DEFS里补一个代号游戏主逻辑不用改动。这种写法对“第二关要加传送门”的扩展也更友好只改配置表即可。4.3 关卡重载出生点与实体复位闯关游戏最考验代码设计的节点是“重开”。玩家死亡后金币不能被吃光了、boss血量不能停在30%。常见做法是保留解析前的原始数据重开时整体还原let levelSource null; let currentGrid []; let currentEntities []; function loadLevel(level) { levelSource level; currentGrid parseLevel(level); currentEntities level.entities.map(e ({ ...e, initX: e.x, initY: e.y, hp: e.hp || 1, alive: true })); spawnPlayer(level.entities.find(e e.type playerSpawn)); } function restartLevel() { currentGrid parseLevel(levelSource); currentEntities.forEach(e { e.x e.initX; e.y e.initY; e.hp e.hp || 1; e.alive true; }); player.hp PLAYER_MAX_HP; const spawn levelSource.entities.find(e e.type playerSpawn); player.x spawn.x; player.y spawn.y; }restartLevel()不重新执行loadLevel而是恢复已有对象的字段避免反复重建粒子对象和事件监听器。实体对象持有initX和initY后出生点信息即使发生变化也只需改地图数据文件。5. 战利品、BOSS战与天火机制的具体落地5.1 道具拾取与无敌帧收集反馈是冒险游戏的心流来源。金币不能只从画布上消失还要有飘字、缩放、粒子飞散。受击时必须有无敌帧否则boss连招会瞬间把血条抽干玩家完全没有反应窗口。战利品触发规则可先归纳成一张表道具类型效果判定生命周期金币计分并触发飘字碰撞后立即存活标志为假红心回复1点血量满血时不拾取天火种6秒火焰附体普攻变火弹附带灼烧进入buff计时流云羽清除全场敌方子弹立即执行一次拾取与无敌帧的实现放在一起const PICKUPS []; function collectPickup(player, item) { if (!item.alive) return; player.score item.value; spawnScorePop(item.x, item.y, item.value); item.alive false; } const PlayerHit { invincible: 0, hit(damage) { if (this.invincible 0) return; player.hp - damage; this.invincible 30; // 0.5秒 screenShake 6; this.skipDrawFrames 6; }, tick() { if (this.invincible 0) this.invincible--; } };无敌帧用“逻辑帧数”而不是performance.now()秒数因为固定步长下时间能被精确切成单位帧渲染卡顿不会让无敌时长漂移。玩家对受击节奏的体验是“半秒整体”这比毫秒精度更重要。提示遍历道具列表时不要边遍历边splice把alivefalse之后统一用filter清理否则下标跳跃会漏掉后方判定。5.2 BOSS行为用招式序列代替状态迷宫小怪只需要“巡逻→追击”两个状态但冒险关卡的boss要有节奏感否则打起来完全没法预判。常见做法是在boss内部放一个招式序列用固定时长驱动切换而不是在update里堆满条件分支。class Boss { constructor() { this.hp 300; this.patternIndex 0; this.timer 0; this.patterns [ { name: charge, duration: 1.2, speed: 420 }, { name: fireball, duration: 1.6, speed: 0 }, { name: spread, duration: 1.0, speed: 0 }, { name: rest, duration: 0.8, speed: 0 } ]; } update(dt, player) { this.timer - dt; if (this.timer 0) { const p this.patterns[this.patternIndex % this.patterns.length]; this.patternIndex; this.timer p.duration; this.state p.name; this.vx (player.x this.x ? 1 : -1) * p.speed; } if (this.state fireball) { spawnFireball(this.x, this.y 30, player); } } }用数组轮询表现出的样式是固定的调节奏只改duration。血量低于50%时再换一组速度更快的招式序列比在同一次模式里用if堆二阶段逻辑更清晰。出招前给地面画一个红色预警圈算是所有同类游戏通用的前瞻提示这里也不另外提。5.3 闯关完成判定与存档通关判定要一次性查齐条件直接看函数function checkLevelPassed() { const exit currentEntities.find(e e.type door); if (!exit) return false; const playerRect player.getRect(); const doorRect { x: exit.x, y: exit.y, w: exit.w, h: exit.h }; if (!hitAABB(playerRect, doorRect)) return false; return currentLevel.totalCoins 0 || collectedAllCoins(); }过关后的存单不放整个游戏对象只放关卡号、分数、血量这三个基础字段。恢复时按关卡号重新loadLevel再往上叠加数值就够了。localStorage的读写在隐私模式下可能抛异常所以包一层try/catchfunction saveGame() { const data { level: currentLevelIndex, score: player.score, hp: player.hp, version: 1 }; try { localStorage.setItem(tianhuojue_save, JSON.stringify(data)); } catch (e) { // 隐私模式或存储已满时只提示不阻塞游戏 } } function loadGame() { const raw localStorage.getItem(tianhuojue_save); if (!raw) return null; try { return JSON.parse(raw); } catch (e) { localStorage.removeItem(tianhuojue_save); return null; } }读档时要校验level落在已存在范围内防止手工改localStorage跳到不存在的关卡。存档key统一写一个常量后续多账号时还要带玩家ID前缀。6. 打包为zip源码前的性能调优与目录整理6.1 canvas特效离屏合成与粒子合并天火决这类带火焰、余烬、冲刺拖尾的游戏如果把每个粒子直接画在主ctx每次清理都要跟画面其他内容抢状态。更常见的做法是准备一张离屏canvas先把粒子画满再整体贴回主画布const fxLayer document.createElement(canvas); fxLayer.width 960; fxLayer.height 540; const fx fxLayer.getContext(2d); function renderParticles() { fx.clearRect(0, 0, 960, 540); particles.forEach(p { fx.globalAlpha (p.life / p.maxLife) * 0.6; fx.fillStyle p.color; fx.fillRect(p.x, p.y, p.size, p.size); }); ctx.drawImage(fxLayer, 0, 0); }合并到单张图层后主画面绘制顺序固定为“背景→地图→角色→特效→UI”。低端设备上帧率不足时优先把粒子数量砍半比降低画布分辨率对视觉影响更小。6.2 高分屏与移动端触摸热区canvas在笔记本上正常、换手机就模糊的问题90%出在devicePixelRatio没处理。初始化时把物理像素设成逻辑分辨率乘以DPR所有代码仍按960×540工作即可。移动端的触摸事件不要直接用clientX充当地图坐标要通过getBoundingClientRect换算成逻辑坐标canvas.addEventListener(touchstart, e { e.preventDefault(); const rect canvas.getBoundingClientRect(); const touch e.touches[0]; const gx (touch.clientX - rect.left) / rect.width * 960; const gy (touch.clientY - rect.top) / rect.height * 540; // 按gx、gy判断左半屏为移动区右半屏为跳跃按钮 });按钮热区不要按照画出来的icon大小绑定逻辑上至少给48×48像素以上的触发区。物理手指触摸命中远比鼠标窄热区太小会出现“明明点上却没触发”的误判。6.3 zip源码包里该放哪些文件、按什么约定写注释解压一个“html5 canvas实现的天火决冒险类闯关游戏源码.zip”后第一件事通常是看目录是否清爽。能直接运行的源码包建议包含五类内容文件或目录职责index.html唯一入口只放画布与脚本引用src/main.js游戏初始化、主循环、状态机src/game.js玩家、敌人、boss类定义src/levels/level1.js第一关数据和tile配置assets/精灵图、音频、粒子素材注释约定直接影响这份zip被二次传播后的价值。脚本注释写“这个函数做什么”不写“这一步是判断”之类的流水账重力、跳跃高度这类数值集中在文件顶部配置区不要散落在代码深处。拿到包后先在console里执行一次typeof startGame function返回true说明初始化函数已暴露再执行requestAnimationFrame确认主循环可以启动——这一步比任何README都更能暴露文件引用断裂的问题解压后改代码可以先从这条命令开始。本文还有配套的精品资源点击获取