
在实际前端项目里想从零理解游戏开发的核心机制不一定非得先引入 Cocos、Unity 或 Phaser 这类完整引擎。用 Canvas 和原生 JavaScript 做一个最小可运行的动作小游戏反而是更直接的学习路径。本文以“悟空守卫花果山”为示例不依赖任何游戏引擎只用一个 HTML 文件和一段原生 JavaScript在浏览器里实现角色移动、自动攻击、敌人生成、精灵动画切换、生命值与评分等功能。读者完整跑通后会对游戏主循环、对象池复用、按帧更新与按时间更新、碰撞检测等关键概念有直观认识并且可以在现有代码基础上继续加新敌人、技能系统或关卡节奏。这篇内容适合刚接触 Canvas、想理解 2D 游戏基础机制的前端开发者也适合要做 H5 互动页面但不想直接引入重型框架的工程师。示例会分成“概念理解、目录准备、核心代码、运行验证、常见排查、生产建议”几个部分每一段代码都会说明它解决什么问题以及为什么这样写。1. 先理解 Canvas 游戏的基本运行方式1.1 游戏循环决定了每一帧做什么游戏和普通网页最大的区别在于持续运行。普通页面是用户点击后才执行逻辑但游戏里即使玩家什么都不做画面里的天空、流水、敌人也在变化。这种持续更新的能力来自游戏循环。在 Canvas 小游戏中游戏循环通常做四件事处理输入比如记录键盘方向键是否被按住。更新游戏状态比如移动玩家、移动子弹、判断碰撞。清空画布。重新绘制所有对象。代码结构大致是这样function gameLoop(timestamp) { // 1. 根据按键状态更新玩家位置 updatePlayer(); // 2. 更新子弹、敌人、得分、生命值 updateWorld(); // 3. 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 4. 重新绘制场景 drawWorld(); // 5. 请求下一帧 requestAnimationFrame(gameLoop); }这里第 5 步是核心。requestAnimationFrame会让浏览器在下一帧刷新前调用gameLoop从而形成循环。它和setInterval的区别在于requestAnimationFrame会跟随屏幕刷新率通常在 60Hz 显示器上每秒调用 60 次当页面切换到后台标签页时浏览器会自动暂停回调避免无意义的资源消耗。1.2 为什么推荐 requestAnimationFrame 而不是 setInterval早期网页动画经常用setInterval(fn, 16.7)以为设定 16.7 毫秒就能稳定 60 帧。实际上它有两个问题。第一setInterval无法感知屏幕刷新周期。如果浏览器渲染一帧需要 20 毫秒而定时器仍按 16.7 毫秒触发回调会堆积导致卡顿。第二标签页切到后台时setInterval仍然会执行浪费 CPU 和电量。requestAnimationFrame由浏览器统一调度回调的执行时机和渲染管线对齐。这样动画更流畅也能自动避免后台继续绘制。对于游戏主循环这是更合理的选择。1.3 帧率与时间步长的关系游戏不能假设每帧之间时间完全相等。按帧更新时如果某一帧耗时特别长下一帧移动的距离就会偏大。更稳妥的做法是记录上一帧和当前帧的时间差deltaTime再按时间计算位移。let lastTime 0; function gameLoop(timestamp) { const deltaTime (timestamp - lastTime) / 1000; lastTime timestamp; // 玩家移动速度是每秒像素数而不是每帧像素数 player.x player.speed * deltaTime; requestAnimationFrame(gameLoop); }上面的示例里player.speed表示每秒移动 300 像素deltaTime是秒数。这样即使某帧耗时 30 毫秒玩家移动距离也是 300 乘以实际时间不会出现“60 帧电脑上太快、30 帧电脑上太慢”的问题。这里要注意deltaTime如果过大比如页面切后台再切回来一帧可能耗时好几秒角色会瞬间飞出去。生产环境通常还要设置上限比如只允许deltaTime最大 0.05 秒超过后按 0.05 处理。2. 项目初始化与游戏对象设计2.1 单文件跑通还是多文件拆分学习阶段建议先从一个index.html文件开始把 CSS、HTML、JavaScript 全部放在里面方便双击打开验证。等逻辑复杂后再把代码拆成多个模块。目录结构可以参考wukong-guard/ ├── index.html ├── css/ │ └── style.css └── js/ ├── config.js ├── pool.js ├── player.js ├── enemy.js └── main.js不过本篇为了降低入门门槛先使用单 HTML 文件。文件内用script标签包裹全部 JavaScript同样可以完整运行。读者理解逻辑后再拆分为模块即可。2.2 页面骨架与画布初始化先准备一个全屏画布并设置画布尺寸。这里要注意HTML 里canvas的width和height属性决定画布实际像素CSS 里的宽度只负责缩放。游戏逻辑中使用画布像素坐标。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title悟空守卫花果山/title style * { margin: 0; padding: 0; } canvas { display: block; background: #1a2a1a; } /style /head body canvas idgameCanvas/canvas script // 游戏代码 /script /body /html为了让画布铺满窗口可以在脚本里读取窗口尺寸并赋值const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); function resizeCanvas() { canvas.width window.innerWidth; canvas.height window.innerHeight; } window.addEventListener(resize, resizeCanvas); resizeCanvas();resizeCanvas里使用了window.innerWidth和window.innerHeight这可以保证游戏区域覆盖可见窗口。窗口尺寸变化时需要重新设置画布尺寸否则画面会拉伸或者留下空白。2.3 游戏对象的通用数据结构无论玩家、敌人、子弹都是游戏对象。提前设计好它们的数据结构后面写逻辑会少很多重复代码。一个对象至少需要位置、尺寸、速度、存活状态、绘制方法。以玩家对象为例const player { x: 200, y: 200, width: 48, height: 48, speed: 260, hp: 5, maxHp: 5, alive: true, shootCooldown: 0, shootInterval: 0.35, draw() { // 绘制角色区域 } };把玩家设计成对象字面量适合小型 Demo。如果项目变大建议使用类class Player { constructor(x, y) { this.x x; this.y y; this.width 48; this.height 48; this.speed 260; this.hp 5; this.maxHp 5; this.alive true; this.shootCooldown 0; this.shootInterval 0.35; } }使用类的好处是方便创建多个实例、方便继承或组合。对于小型动作游戏一个Player类加一个Enemy类已经足够。需要强调的是对象字段要统一命名为x、y、width、height、speed这样碰撞检测和绘制函数可以复用同一套逻辑。3. 实现玩家移动、攻击和精灵绘制3.1 用键盘状态控制移动玩家移动不能通过“按下方向键就移动一次”实现因为游戏是持续循环的。更合适的做法是维护一个按键状态表在循环里读取当前按住的键然后移动角色。const keys {}; window.addEventListener(keydown, (e) { keys[e.code] true; }); window.addEventListener(keyup, (e) { keys[e.code] false; });然后在每帧更新中读取按键状态function updatePlayer(deltaTime) { let dx 0; let dy 0; if (keys[ArrowLeft] || keys[KeyA]) dx - 1; if (keys[ArrowRight] || keys[KeyD]) dx 1; if (keys[ArrowUp] || keys[KeyW]) dy - 1; if (keys[ArrowDown] || keys[KeyS]) dy 1; if (dx ! 0 || dy ! 0) { const length Math.sqrt(dx * dx dy * dy); dx / length; dy / length; player.x dx * player.speed * deltaTime; player.y dy * player.speed * deltaTime; } }要注意的是斜向移动时如果不归一化玩家按“右 下”会比只按“右”移动得更快。上面代码通过Math.sqrt(dx * dx dy * dy)计算向量长度并归一化保证斜向速度和水平速度一致。角色边界也需要限制否则玩家会移出画布player.x Math.max(0, Math.min(canvas.width - player.width, player.x)); player.y Math.max(0, Math.min(canvas.height - player.height, player.y));3.2 自动攻击与子弹管理攻击可以做成自动射击每隔一段时间产生一颗子弹。使用冷却时间控制发射频率function updateShoot(deltaTime) { if (!player.alive) return; player.shootCooldown - deltaTime; if (player.shootCooldown 0) { spawnBullet(player.x player.width / 2, player.y); player.shootCooldown player.shootInterval; } }子弹对象需要记录位置、速度、范围和存活状态function spawnBullet(x, y) { bullets.push({ x: x, y: y, vx: 0, vy: -420, radius: 6, alive: true }); }每帧更新子弹位置并清理超出屏幕的子弹function updateBullets(deltaTime) { for (const bullet of bullets) { if (!bullet.alive) continue; bullet.x bullet.vx * deltaTime; bullet.y bullet.vy * deltaTime; if (bullet.y -20) { bullet.alive false; } } }这里只写“标记存活状态”而不是直接splice删除是因为遍历时删除元素容易跳过下一个对象。在小型 Demo 里可以先每帧收集存活子弹再替换数组。更优的做法是对象池后面会专门讲。3.3 精灵动画的替代方案真实游戏里角色会有一系列帧图片按固定间隔切换。没有素材时可以用两个或三个状态的几何图形模拟动画。例如把角色背景绘制成“闪动”状态按时间切换颜色let animationTimer 0; let frameIndex 0; function drawPlayer() { animationTimer 0.016; if (animationTimer 0.2) { animationTimer 0; frameIndex (frameIndex 1) % 2; } ctx.fillStyle frameIndex 0 ? #f5c542 : #e6a817; ctx.fillRect(player.x, player.y, player.width, player.height); // 画脸、眼睛等细节 ctx.fillStyle #333; ctx.fillRect(player.x 10, player.y 12, 6, 6); ctx.fillRect(player.x 30, player.y 12, 6, 6); }如果后续要替换成真素材只需把drawPlayer里的绘制逻辑替换成ctx.drawImage并让frameIndex对应素材里的某一帧。注意动画切换使用固定0.016秒会与真实帧率脱节。更稳妥的做法是传入deltaTime累积到一定时间后再切换避免 60Hz 和 144Hz 显示器的动画速度不一致。4. 用对象池管理敌人刷新4.1 为什么不直接频繁创建和销毁对象游戏里敌人在短时间内可能生成大量实例。如果每帧都new Enemy()并在死亡时删除会带来两个问题。第一垃圾回收压力大。JavaScript 的new会分配内存对象不再引用后被垃圾回收。频繁创建、销毁大量小对象会造成 GC 频繁触发出现卡顿。第二管理困难。如果敌人有复杂的初始化流程每次都要重新赋值代码容易遗漏字段。对象池的核心思路是一开始创建固定数量的对象存活对象显示在屏幕上死亡对象进入空闲队列。需要新敌人时先从空闲队列取空闲队列为空时可以选择不再创建也可以扩容。4.2 对象池的最小实现class EnemyPool { constructor(size) { this.pool []; this.active []; for (let i 0; i size; i) { const enemy { x: 0, y: 0, width: 40, height: 40, speed: 80, alive: false, hp: 1, draw() { ctx.fillStyle #c0392b; ctx.fillRect(this.x, this.y, this.width, this.height); } }; this.pool.push(enemy); } } spawn(x, y) { const enemy this.pool.pop(); if (!enemy) return null; enemy.x x; enemy.y y; enemy.alive true; enemy.hp 1; enemy.speed 80 Math.random() * 40; this.active.push(enemy); return enemy; } update(deltaTime) { for (let i this.active.length - 1; i 0; i--) { const enemy this.active[i]; if (!enemy.alive) { this.active.splice(i, 1); this.pool.push(enemy); continue; } enemy.x - enemy.speed * deltaTime; if (enemy.x -50) { enemy.alive false; } } } draw() { for (const enemy of this.active) { enemy.draw(); } } }这段代码里pool保存空闲对象active保存正在使用的对象。敌人死亡时只是把alive标记为falseupdate会在遍历时把它移回池子。可以看到这里刻意从this.active.length - 1倒序遍历这样splice删除元素时不会影响下一个元素的索引。4.3 生成频率与难度曲线敌人不能无脑生成否则玩家没法应对。可以使用时间累积器控制生成间隔let spawnTimer 0; let spawnInterval 1.2; function updateSpawn(deltaTime) { spawnTimer - deltaTime; if (spawnTimer 0) { const x canvas.width 60; const y 80 Math.random() * (canvas.height - 160); enemyPool.spawn(x, y); spawnTimer spawnInterval; // 随着时间推移生成间隔逐步缩短 if (spawnInterval 0.4) { spawnInterval - 0.01; } } }这里的spawnInterval从 1.2 秒逐渐缩短到 0.4 秒形成最简单的难度曲线。更高阶的做法是引入关卡每关设置独立的生成间隔、敌人血量和移动速度。对象池大小也需要配置。实际项目里要根据性能测试确定而不是越大越好。初始值可以参考 50 到 200 之间然后通过浏览器性能面板观察帧率。5. 碰撞检测、得分与游戏状态5.1 圆形碰撞检测比矩形更简单碰撞检测有很多实现方式矩形碰撞AABB和圆形碰撞是最常用的两种。矩形碰撞检测的直觉表达是“两个矩形有没有重叠”function hitTestRect(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; }但矩形碰撞在没有素材、只用几何图形绘制时视觉上不够直观。圆形碰撞只需要比较圆心距离与半径之和function hitTestCircle(ax, ay, ar, bx, by, br) { const dx ax - bx; const dy ay - by; const distSq dx * dx dy * dy; const rSum ar br; return distSq rSum * rSum; }这里没有使用Math.sqrt而是直接比较距离的平方因为平方根计算会消耗更多性能。在大量碰撞检测中这种优化很重要。玩家和敌人的碰撞检测可以写成function checkCollisions() { if (!player.alive) return; const playerCenterX player.x player.width / 2; const playerCenterY player.y player.height / 2; for (const enemy of enemyPool.active) { if (!enemy.alive) continue; const enemyCenterX enemy.x enemy.width / 2; const enemyCenterY enemy.y enemy.height / 2; if (hitTestCircle(playerCenterX, playerCenterY, 20, enemyCenterX, enemyCenterY, 20)) { player.hp - 1; enemy.alive false; if (player.hp 0) { player.alive false; gameState gameover; } } } for (const bullet of bullets) { if (!bullet.alive) continue; for (const enemy of enemyPool.active) { if (!enemy.alive) continue; const enemyCenterX enemy.x enemy.width / 2; const enemyCenterY enemy.y enemy.height / 2; if (hitTestCircle(bullet.x, bullet.y, bullet.radius, enemyCenterX, enemyCenterY, 20)) { bullet.alive false; enemy.alive false; score 10; break; } } } }碰撞检测时要注意子弹击穿多个敌人时要让子弹立即失效否则同一颗子弹可能一次打死一片。实现上可以在命中后主动break或设置标志位。5.2 游戏状态机运行中、暂停、结束没有状态管理的游戏逻辑会在游戏结束后继续运行。比如生命值归零后敌人还在移动、子弹还在飞行这不符合体验。可以用一个简单状态机let gameState playing; function update(deltaTime) { if (gameState playing) { updatePlayer(deltaTime); updateShoot(deltaTime); updateBullets(deltaTime); enemyPool.update(deltaTime); updateSpawn(deltaTime); checkCollisions(); } } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); drawBackground(); enemyPool.draw(); drawBullets(); drawPlayer(); drawUI(); if (gameState gameover) { drawGameOver(); } }游戏结束后可以通过键盘空格键重新开始。重新开始时需要重置所有状态玩家位置、生命值、分数、子弹数组、敌人对象池还有生成间隔。function resetGame() { player.x canvas.width / 2; player.y canvas.height - 100; player.hp player.maxHp; player.alive true; player.shootCooldown 0; score 0; spawnTimer 0; spawnInterval 1.2; bullets.length 0; enemyPool.active []; enemyPool.pool.length 0; // 重新创建对象池对象 }bullets.length 0可以快速清空数组但对象池里的对象需要重新初始化。推荐在resetGame里直接重新new EnemyPool(50)代码更清晰避免状态残留。5.3 HUD 绘制在画布右上角显示生命值、得分、当前帧率是最基本的 HUD。function drawUI() { ctx.fillStyle rgba(0, 0, 0, 0.5); ctx.fillRect(0, 0, canvas.width, 48); ctx.fillStyle #fff; ctx.font 18px monospace; ctx.textAlign left; ctx.fillText(生命: player.hp, 16, 32); ctx.textAlign center; ctx.fillText(得分: score, canvas.width / 2, 32); ctx.textAlign right; ctx.fillText(帧率: Math.round(fps) fps, canvas.width - 16, 32); }帧率统计可以放在主循环里let frameCount 0; let fpsTime 0; let fps 0; function gameLoop(timestamp) { const deltaTime Math.min((timestamp - lastTime) / 1000, 0.05); lastTime timestamp; frameCount; fpsTime deltaTime; if (fpsTime 1) { fps frameCount / fpsTime; frameCount 0; fpsTime 0; } update(deltaTime); draw(); requestAnimationFrame(gameLoop); }显示帧率不仅能满足调试需求也能让读者直观看到“对象池是否发挥作用”。6. 运行验证与常见问题排查6.1 启动方式和预期现象把index.html保存到本地直接用浏览器打开。也可以用命令行开启本地静态服务npx serve .如果使用 VS Code可以直接安装 Live Server 插件右键index.html选择Open with Live Server。本地静态服务的优势是不会遇到某些浏览器对file://协议的模块加载限制。不过本示例没有使用 ES Module直接双击文件也能运行。正常现象是游戏窗口铺满屏幕背景为深绿色。玩家角色出现在画布下方可以用方向键或 WASD 移动。每隔一段时间右侧生成红色矩形敌人向左移动。玩家自动发射子弹命中敌人后得分增加。敌人碰到玩家后玩家生命值减少生命归零后显示游戏结束画面。6.2 通过控制台检查运行状态如果画面没有出现先打开浏览器开发者工具快捷键通常是 F12查看 Console 面板是否有报错。常见错误是Uncaught TypeError: Cannot read properties of null (reading getContext)这个错误说明document.getElementById(gameCanvas)返回了null。原因是脚本放在了canvas元素之前此时 DOM 还没有加载完成。解决方式是把script标签放在/body末尾或者使用DOMContentLoaded事件回调。6.3 常见问题排查表问题现象常见原因检查方式处理建议画面一片空白控制台无错误画布尺寸为 0或者背景绘制代码未执行在draw()开头加调试日志确认resizeCanvas已调用并检查canvas宽高玩家无法移动键盘事件未绑定或keys状态未持续更新在updatePlayer里打印keys确认事件监听使用keydown、keyup并检查 e.code子弹不发射冷却时间计算错误或updateShoot未调用在spawnBullet里打印日志检查player.shootCooldown - deltaTime是否执行敌人不出现生成间隔过大或对象池已满在updateSpawn里打印spawnTimer检查对象池pool是否有空闲对象玩家被碰撞后不会死亡碰撞半径计算错误在checkCollisions里打印碰撞结果检查圆心坐标是否取到了对象中心游戏结束后敌人仍移动gameState判断缺失在update第一行打印gameState确认非 playing 状态时跳过更新逻辑切回页面后角色突然瞬移deltaTime过大打印上一帧时间差使用Math.min(deltaTime, 0.05)限制最大步长6.4 页面卡顿时的性能定位顺序如果敌人数量一大画面开始掉帧不要急着改逻辑先按顺序排查。第一步打开开发者工具的 Performance 面板录制 5 秒游戏过程观察帧率曲线和耗时集中在哪个函数。第二步检查是否出现频繁 GC。如果bullets数组每帧使用filter创建新数组会不断分配内存。替换成“活跃对象标记 原地移动”可以减少 GC。第三步检查碰撞检测算法。当前实现是子弹和敌人双重循环敌人多、子弹多时是 O(n*m)。后续优化可以引入网格碰撞检测把敌人放入对应的格子只检查相邻格子里的对象。第四步确认绘制调用是否合理。每次ctx.fillRect都会触发一次绘制状态切换。批量绘制相同样式的对象时可以先统一设置样式再连续fillRect。7. 从 Demo 到可维护项目的扩展方向7.1 代码拆分和模块化单 HTML 文件适合学习但功能增多后维护成本很高。建议按模块拆分src/ ├── config.js // 全局配置包括画布尺寸、生成间隔、玩家参数 ├── input.js // 键盘鼠标事件处理 ├── object-pool.js // 对象池通用实现 ├── entities/ │ ├── player.js │ ├── enemy.js │ └── bullet.js ├── systems/ │ ├── collision.js │ ├── spawner.js │ └── game-state.js └── main.js // 启动入口模块化的作用不只是整理代码而是让每个模块可以独立测试。例如collision.js不需要关心绘制逻辑可以直接写单元测试。7.2 参数配置化不要把玩家速度、敌人生成间隔、子弹速度散落在各个函数里。统一放进配置对象const CONFIG { player: { speed: 260, hp: 5, shootInterval: 0.35 }, enemy: { poolSize: 100, baseSpeed: 80, spawnInterval: 1.2, minSpawnInterval: 0.4, speedRandomRange: 40 }, bullet: { speed: 420, radius: 6 } };这样做的好处是调数值时不需要在代码里搜索试出合适的节奏后可以直接形成“数值表”。后续如果要加关卡系统还可以用配置表驱动每个关卡的参数。7.3 精灵素材和音效接入几何图形只是占位。接真实素材时主要改draw相关逻辑const img new Image(); img.src ./assets/wukong.png; function drawPlayer() { ctx.drawImage( img, frameIndex * 48, 0, 48, 48, player.x, player.y, 48, 48 ); }这里frameIndex * 48表示从精灵图里截取第几帧。如果精灵图每帧是 48 像素宽就按这个公式取帧。音效接入可以使用AudioContext生成简单的音效也可以播放音频文件。需要注意浏览器自动播放策略首次交互后才能启动音频上下文。7.4 发布到线上前的检查清单检查项说明画布响应式窗口变化时重新设置尺寸坐标跟随画布宽高计算而不是写死屏幕尺寸生命周期页面隐藏时暂停或降低帧率避免后台运行消耗资源资源加载图片、音频加载失败时要有兜底避免白屏移动端适配添加触摸或虚拟按键检测ontouchstart性能测试用低端安卓浏览器测试观察帧率和内存占用错误监控在window.onerror或unhandledrejection里上报异常可玩性设置生成难度上限避免后期无解7.5 继续深入的学习路径如果完成了这个 Demo可以按下面顺序继续深入加入技能系统比如“短暂无敌”或“全屏攻击”理解技能冷却、作用范围和持续时长。加入关卡配置用 JSON 描述不同波次的敌人组合。引入空间哈希网格优化碰撞检测提升大量物体同屏时的性能。尝试把绘制逻辑和逻辑更新彻底分离准备迁移到 WebGL 渲染。利用 Web Worker 处理复杂计算比如生成式地图、寻路算法。需要厘清的是Canvas 2D 的定位是轻量级 2D 绘图 API适合做 H5 互动页、小游戏原型和轻量游戏。如果项目要跨 Android、iOS 双端发布并且对性能要求极高再考虑 Cocos、Unity 或 Laya 这类完整引擎。选择引擎并不等于放弃手写游戏循环的理解反而因为理解了底层运行方式更容易判断引擎里的更新函数、碰撞组件和对象池模块各自解决了什么问题。写一个能运行的小游戏并不难难的是把游戏循环、对象生命周期、碰撞检测和状态管理拆成清晰的模块。本文的“悟空守卫花果山”示例希望帮助读者跑通一条从零到可玩的最小路径。下一步最有价值的练习不是继续堆功能而是把现有代码逐个模块拆开加上注释再尝试手动实现一次碰撞检测和对象池这样才能真正带走这套方法论。