从零开发Canvas三角形游戏:用原生JavaScript实现完整游戏循环与碰撞检测

📅 2026/8/26 13:34:58
从零开发Canvas三角形游戏:用原生JavaScript实现完整游戏循环与碰撞检测
这次我们来看一个非常典型的游戏开发入门项目The Triangle Game, from Zero。项目名字很直白意思就是从零开始把最高潮的部分都砍掉不依赖任何游戏引擎只用一个 HTML 文件用 Canvas 和原生 JavaScript 从空白画布开始一步步做出一个可以真正操作的三角形游戏。这类项目的价值不是“画面多炫酷”而是把游戏开发里最核心的几条链路全部串起来窗口与画布、渲染循环、键盘输入、对象管理、碰撞检测、计分与结束逻辑。做完之后你手里会有一套最小可运行的三角形游戏模板之后无论往 WebGL、Three.js、Phaser 还是 Cocos 方向走这些底层概念都是一模一样的。本文会带你完成这几件事先讲清楚这个项目到底解决什么问题、适合什么人然后把环境准备、项目结构、三角形渲染、玩家控制、敌人生成、碰撞检测、计分与重启逻辑全部过一遍最后给出完整的可运行代码以及运行调试中常见的坑和后续扩展思路。整篇内容不依赖 GPU、不需要安装复杂依赖浏览器能打开就能跑适合零基础入门也适合给已有前端基础但想接触游戏开发的人做第一块跳板。1. The Triangle Game 核心能力速览先给一张速览表方便你快速判断这个项目值不值得点开能力项说明项目类型浏览器端 Canvas 2D 游戏开发实战运行时环境任意现代浏览器Chrome / Edge / Firefox 均可开发语言HTML CSS JavaScript是否需要安装依赖不需要直接使用原生 Canvas API是否需要 GPU不需要启动方式双击 HTML 文件或通过本地静态服务器访问鼠标键盘支持键盘方向键 / A、D 控制移动主要功能玩家三角形移动、敌人生成与下落、碰撞检测、计分、游戏结束与重启是否支持接口 API纯前端项目无后端接口是否支持批量任务游戏引擎自带批量对象渲染能力用数组管理多个敌人适合场景游戏开发入门、Canvas 图形编程、前端算法练习扩展方向关卡系统、音效、粒子特效、WebGL 3D 渲染从表中可以看出这个项目最大的特点就是门槛极低。环境要求只有一条能打开浏览器。不需要配置 Python、不需要装 Node、不需要处理 CUDA 和显卡驱动下载一个文件到本地就能开始玩这对于第一次接触游戏开发的人来说非常友好。2. 适用场景与使用边界The Triangle Game from Zero 更适合以下人群第一类是完全没有游戏开发经验的新手。你不懂游戏循环、不懂坐标系统、不懂 requestAnimationFrame没有关系整个项目就是从画一个三角形开始的每一步的代码量都很小逻辑也直观跟着敲一遍就能理解游戏程序的基本运转方式。第二类是有一定前端基础但没写过游戏逻辑的开发者。这类读者对 DOM 和 CSS 已经熟悉但对 Canvas 画画和游戏循环不太了解。通过这个项目你会补上“如何用 JavaScript 驱动每帧画面变化”这一块空白之后再看 Phaser、Three.js 时理解成本会低很多。第三类是想做小工具或小原型验证的人。比如你想验证一个自动寻路算法、一个弹幕碰撞逻辑、一个 UI 交互三角形原型比搭一个完整游戏引擎快得多。同时也要说清楚适用边界。这个项目不是商业游戏框架不适合直接拿来做一个完整的商业游戏产品它也没有复杂的资源加载体系、物理引擎、动画状态机、多线程并发能力。它就是一张干净的画布加一个循环把游戏开发中最核心的骨架暴露在你面前。更准确地说它是“入门教学项目”价值在学习过程而不是成品效果。另外提醒一点虽然这个项目使用的三角形和圆形素材都是代码绘制的不存在版权问题但后续你如果引入了图片、音频、字体等外部素材要注意确认素材的授权状态不要使用来源不明的素材做公开分发或商用。3. 环境准备与前置条件环境准备非常简单不需要安装任何开发框架。但是为了保证后续调试方便建议按下面的清单准备。3.1 必须项一个现代浏览器推荐 Chrome 或 EdgeFirefox 也可以。现代浏览器对 Canvas 2D 和 requestAnimationFrame 的支持都很完善。一个文本编辑器推荐 VS Code也可以使用记事本或任何能编辑纯文本文件的工具。3.2 可选项本地静态服务器如果只是双击 HTML 文件打开不需要服务器如果你后续要加载本地图片、音频文件建议起一个静态服务器避免浏览器因 file:// 协议拦截本地资源请求。Node.js仅在起本地服务器时才需要。如果你不想装 Node也可以用 VS Code 的 Live Server 插件或者 Python 的 http.server 模块。如果你装了 Python可以直接在项目目录下运行python -m http.server 8080然后打开浏览器访问http://localhost:8080/index.html如果你只想看最基础的效果双击 index.html 文件直接用浏览器打开也可以。两种方式在本文的代码范围内都能正常运行。3.3 确认浏览器可用 Canvas打开浏览器开发者工具按 F12 打开 Console 标签输入下面这一行代码如果输出true说明当前浏览器支持 Canvas 2Dconsole.log(document.createElement(canvas).getContext(2d) ! null);这一步是为了排查极少数浏览器安全策略导致 Canvas 不可用的情况。4. 搭建最小项目结构The Triangle Game 本身没有复杂的工程目录我们只需要一个 HTML 文件。为了让代码结构更清晰这里按这样的目录来组织triangle-game/ ├── index.html └── script.js如果你追求极简把所有代码写进一个 index.html 也是可以的。本文后面给出的完整代码就是单文件版本方便你直接保存运行。不过在开发过程中我建议把 HTML 和 JavaScript 分文件存放逻辑更清楚编辑器提示也更友好。先创建 index.html。这个文件负责页面骨架和游戏画布!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleThe Triangle Game, from Zero/title style body { margin: 0; background: #1e1e2e; display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: Microsoft YaHei, Consolas, monospace; } .game-container { text-align: center; } canvas { background: #12121c; border: 2px solid #3a3a4a; display: block; max-width: 100%; } .tip { color: #aaa; font-size: 14px; margin-top: 12px; } /style /head body div classgame-container canvas idgameCanvas width800 height600/canvas p classtip方向键左右或 A / D 控制移动空格开始R 重新开始/p /div script srcscript.js/script /body /html这段代码做了三件事定义了页面背景创建了一个 800x600 的画布引入 script.js 文件。在继续写 JavaScript 之前先理解 Canvas 的坐标系统。Canvas 的坐标系原点(0, 0)在画布左上角x 轴向右递增y 轴向下递增。这意味着y值越大图形越靠近底部。很多新手在这里容易搞反以为 y 是向上增长的结果物体运动方向全部反了。5. 从零渲染第一个三角形现在我们来解决这个项目最核心的问题怎么把三角形画到画布上。Canvas 2D 中绘制三角形的方式是先定义一个路径然后把路径首尾闭合填充。三角形由三个顶点组成我们以玩家对象为例假设玩家中心坐标是(player.x, player.y)那么三个顶点可以定义为顶点 A(player.x, player.y - 22)这个点在中心上方作为三角形的头部顶点 B(player.x - 16, player.y 14)这个点在中心左下作为三角形的左下角顶点 C(player.x 16, player.y 14)这个点在中心右下作为三角形的右下角用 Canvas API 画出来就是function drawPlayer(ctx, player) { ctx.beginPath(); ctx.moveTo(player.x, player.y - 22); ctx.lineTo(player.x - 16, player.y 14); ctx.lineTo(player.x 16, player.y 14); ctx.closePath(); ctx.fillStyle #4ade80; ctx.fill(); ctx.strokeStyle #16a34a; ctx.lineWidth 2; ctx.stroke(); }运行后画面上会出现一个绿色实心三角头朝上尾朝下。这个三角形就是玩家的视觉化身。不过仅仅画一个不动的三角形没有游戏意义。游戏之所以是游戏核心在于画面每一帧都在变化。所以接下来要引入游戏循环每一帧清空画布、更新对象状态、然后重新绘制所有对象。这个过程用requestAnimationFrame来实现浏览器会以显示器的刷新率通常是 60 次每秒自动调用我们的更新函数。function gameLoop(timestamp) { const dt (timestamp - lastTime) / 1000; lastTime timestamp; update(dt); render(ctx); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这里的dt是“上一帧到这一帧的时间差”单位是秒。用它来乘以移动速度可以保证不同帧率下物体移动速度一致不会出现 60Hz 显示器上跑得快、30Hz 显示器上跑得慢的问题。6. 玩家控制与三角形移动三角形画出来了接下来让它响应键盘。这里采用一个简单实用的方式用一个keys对象维护当前哪些按键处于按下状态然后在每一帧更新中根据按键状态修改玩家的坐标。const keys {}; document.addEventListener(keydown, (e) { keys[e.code] true; }); document.addEventListener(keyup, (e) { keys[e.code] false; });更新玩家位置时判断左右方向键和 A / D 键const PLAYER_SPEED 320; function updatePlayer(player, dt) { if (keys[ArrowLeft] || keys[KeyA]) { player.x - PLAYER_SPEED * dt; } if (keys[ArrowRight] || keys[KeyD]) { player.x PLAYER_SPEED * dt; } // 边界限制防止三角形移出画布 const halfWidth 16; if (player.x halfWidth) { player.x halfWidth; } if (player.x 800 - halfWidth) { player.x 800 - halfWidth; } }这里注意两点。第一点为什么用keys[e.code]而不是e.key。因为code代表物理按键位置比如ArrowLeft和KeyA不受输入法、大小写、键盘布局影响稳定性更好。第二点边界限制。如果不加限制三角形可以移动到画布外甚至消失不见。这个边界不只是为了视觉效果也是后续碰撞检测的基础游戏对象应该在画布内活动。7. 敌人生成、碰撞检测与计分逻辑有了玩家和移动能力游戏还需要挑战。现在加入敌人生成系统。敌人同样是三角形但颜色是红色从画布顶部均匀出现向下移动。为了让玩法有节奏我们用计时器控制生成间隔每过0.8秒左右生成一个新的敌人。const enemies []; let spawnTimer 0; let score 0; let gameOver false; function updateEnemies(enemies, dt) { spawnTimer - dt; if (spawnTimer 0) { enemies.push({ x: 40 Math.random() * 720, y: -20, vx: (Math.random() - 0.5) * 80, vy: 120 Math.random() * 120 }); spawnTimer 0.8; } for (let i enemies.length - 1; i 0; i--) { const enemy enemies[i]; enemy.x enemy.vx * dt; enemy.y enemy.vy * dt; if (enemy.y 620) { enemies.splice(i, 1); score 1; } } }碰撞检测是整个游戏最重要的逻辑。这里不采用像素级精细判断而是用圆形碰撞检测的简化思路把玩家三角形当成一个以中心为圆心、半径为 18 的圆把敌人三角形当成一个以中心为圆心、半径为 16 的圆。两个圆心之间的距离小于两者半径之和就判定为碰撞。function checkCollision(player, enemy) { const dx player.x - enemy.x; const dy player.y - enemy.y; const distance Math.sqrt(dx * dx dy * dy); return distance 18 16; }在更新循环中遍历所有敌人一旦检测到碰撞就把gameOver设为true游戏停止更新玩家和敌人。计分逻辑也放在这里敌人成功越过画布底部并移除时玩家得一分。游戏结束后显示最终分数。8. 完整可运行代码为了让你直接跑起来这里给出一个完整的单文件版本。创建triangle-game.html把下面的代码原样粘贴进去浏览器打开即可玩。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleThe Triangle Game, from Zero/title style body { margin: 0; background: #1e1e2e; display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: Microsoft YaHei, Consolas, monospace; color: #eee; } .game-container { text-align: center; } canvas { background: #12121c; border: 2px solid #3a3a4a; display: block; max-width: 100%; } .tip { color: #aaa; font-size: 14px; margin-top: 12px; } /style /head body div classgame-container canvas idgameCanvas width800 height600/canvas p classtip方向键左右或 A / D 控制移动空格开始R 重新开始/p /div script const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const WIDTH 800; const HEIGHT 600; const PLAYER_SPEED 320; const PLAYER_RADIUS 18; const ENEMY_RADIUS 16; const SPAWN_INTERVAL 0.8; const keys {}; let lastTime 0; let spawnTimer 0; let score 0; let gameOver false; let started false; const player { x: WIDTH / 2, y: HEIGHT - 60 }; const enemies []; document.addEventListener(keydown, (e) { keys[e.code] true; if (e.code Space !started) { started true; resetGame(); } if (e.code KeyR gameOver) { resetGame(); } }); document.addEventListener(keyup, (e) { keys[e.code] false; }); function resetGame() { player.x WIDTH / 2; player.y HEIGHT - 60; enemies.length 0; spawnTimer 0; score 0; gameOver false; started true; } function updatePlayer(dt) { if (keys[ArrowLeft] || keys[KeyA]) { player.x - PLAYER_SPEED * dt; } if (keys[ArrowRight] || keys[KeyD]) { player.x PLAYER_SPEED * dt; } player.x Math.max(PLAYER_RADIUS, Math.min(WIDTH - PLAYER_RADIUS, player.x)); } function updateEnemies(dt) { spawnTimer - dt; if (spawnTimer 0) { enemies.push({ x: 40 Math.random() * (WIDTH - 80), y: -20, vx: (Math.random() - 0.5) * 80, vy: 120 Math.random() * 120 }); spawnTimer SPAWN_INTERVAL; } for (let i enemies.length - 1; i 0; i--) { const enemy enemies[i]; enemy.x enemy.vx * dt; enemy.y enemy.vy * dt; if (enemy.y HEIGHT 30) { enemies.splice(i, 1); if (!gameOver) { score; } } } } function checkCollisions() { for (const enemy of enemies) { const dx player.x - enemy.x; const dy player.y - enemy.y; const distance Math.sqrt(dx * dx dy * dy); if (distance PLAYER_RADIUS ENEMY_RADIUS) { gameOver true; return; } } } function update(dt) { if (!started || gameOver) { return; } updatePlayer(dt); updateEnemies(dt); checkCollisions(); } function drawTriangle(x, y, size, color, borderColor) { ctx.beginPath(); ctx.moveTo(x, y - size); ctx.lineTo(x - size * 0.8, y size * 0.7); ctx.lineTo(x size * 0.8, y size * 0.7); ctx.closePath(); ctx.fillStyle color; ctx.fill(); ctx.strokeStyle borderColor; ctx.lineWidth 2; ctx.stroke(); } function draw() { ctx.clearRect(0, 0, WIDTH, HEIGHT); // 玩家 drawTriangle(player.x, player.y, 22, #4ade80, #16a34a); // 敌人 for (const enemy of enemies) { drawTriangle(enemy.x, enemy.y, 20, #f87171, #b91c1c); } // UI 文字 ctx.fillStyle rgba(255, 255, 255, 0.7); ctx.font 20px Microsoft YaHei, Consolas, monospace; ctx.textAlign left; ctx.fillText(Score: score, 16, 36); if (!started) { ctx.fillStyle rgba(255, 255, 255, 0.85); ctx.font 28px Microsoft YaHei, Consolas, monospace; ctx.textAlign center; ctx.fillText(按空格开始, WIDTH / 2, HEIGHT / 2 - 20); } if (gameOver) { ctx.fillStyle rgba(255, 255, 255, 0.9); ctx.font 32px Microsoft YaHei, Consolas, monospace; ctx.textAlign center; ctx.fillText(游戏结束, WIDTH / 2, HEIGHT / 2 - 20); ctx.font 20px Microsoft YaHei, Consolas, monospace; ctx.fillText(最终得分: score, WIDTH / 2, HEIGHT / 2 20); ctx.fillText(按 R 重新开始, WIDTH / 2, HEIGHT / 2 54); } } function gameLoop(timestamp) { const dt Math.min((timestamp - lastTime) / 1000, 0.05); lastTime timestamp; update(dt); draw(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop); /script /body /html这段代码包含了完整的游戏状态机开始前画面等待按空格游戏中玩家移动、敌人下落、碰撞检测、计分游戏结束后显示分数并等待按 R 重置。代码总共一百多行核心逻辑集中在 update 和 draw 两个函数中。9. 运行调试与常见问题排查这个项目虽然简单运行过程中仍可能遇到几种典型问题。下面整理成排查清单遇到情况可以直接对照。问题现象可能原因排查方式解决方案打开页面后一片空白JavaScript 报错或 Canvas 尺寸异常按 F12 打开 Console 查看报错检查是否有语法错误确认 canvas 元素 id 正确三角形不动未触发 requestAnimationFrame 或按键事件未绑定Console 查看是否有报错确认 script 标签没有加载失败按键反应迟钝页面窗口未聚焦点击画布区域后再次按键确保浏览器窗口处于活动状态三角形飞到画面外缺失边界限制代码查看 updatePlayer 函数用 Math.min / Math.max 限制坐标范围游戏速度不稳定未使用 dt 修正帧间隔检查 update 中是否使用 dt按照文中方式计算并传入 dt标签页切回后游戏瞬间结束浏览器对后台标签页暂停 requestAnimationFrame恢复时 dt 过大检查 dt 是否被 clamp使用Math.min(dt, 0.05)防止单帧时间过长敌人出现太密或太稀疏生成间隔不合适调整 SPAWN_INTERVAL建议从 0.6 到 1.2 之间调节碰撞感觉不准确圆形碰撞检测的半径与实际图形大小不匹配检查 PLAYER_RADIUS 和 ENEMY_RADIUS根据实际图形视觉大小微调半径其中比较容易踩的坑有两个。第一个是标签页切回后游戏瞬间死亡。后台标签页的 requestAnimationFrame 会被浏览器暂停切回来时timestamp会有一个很大的跳变导致dt变成几秒钟。玩家来不及反应敌人也已经移动了很远。解决方案就是我在代码中写的const dt Math.min((timestamp - lastTime) / 1000, 0.05);把单帧时间限制在 0.05 秒以内。第二个是键盘事件焦点问题。如果你点击了浏览器的开发者工具区域页面可能失去键盘焦点按方向键时游戏没有响应。这时候点击画布区域让页面重新获得焦点即可。10. 性能观察与优化方向从性能角度观察这个三角形游戏对现代浏览器来说没有任何压力。每一帧只需要绘制几个三角形和若干文字工作量非常小。但如果你想按专业游戏引擎的思路来优化可以从几个方向入手。第一个方向是控制渲染对象的数量。当敌人数组无限增长时每帧都要遍历全部对象时间复杂度是 O(n)而且每个对象都触发一次路径绘制这些 draw call 会成为性能瓶颈。更稳妥的做法是引入对象池预先创建固定数量的对象实例当敌人淘汰后不是从数组中删除而是标记为“活动状态为 false”后续重新分配避免频繁创建和销毁对象。第二个方向是减少不必要的状态切换。Canvas 绘制时fillStyle、strokeStyle、lineWidth这些状态切换会影响性能。同类对象可以放在一起绘制先统一设置颜色再连续填充多个三角形而不是每个三角形都设置一次属性。第三个方向是注意高分屏和缩放。如果你的显示器是高分屏Canvas 默认的 800x600 像素会被浏览器按 CSS 像素放大边缘可能出现模糊。可以通过设置canvas.width 800 * devicePixelRatio然后调用ctx.scale(devicePixelRatio, devicePixelRatio)来获得更锐利的渲染效果。不过对于 The Triangle Game 这个项目来说以上优化属于“进阶思维”当前阶段只要保证代码结构清晰、逻辑可读即可。先跑通再优化是游戏开发中最务实的学习路径。11. 从最小原型到完整游戏扩展思路当你跑通上面的代码后就有了一个可玩的最小原型。接下来可以沿着这些方向继续扩展。11.1 增加游戏难度曲线现在敌人生成间隔和下落速度都是固定的。更合理的玩法是随着分数提高逐渐降低SPAWN_INTERVAL同时提高vy的范围。比如分数每增加 5 分生成间隔减少 0.05 秒但保留下限 0.3 秒否则游戏会快速变得不可玩。11.2 增加多类型敌人可以给敌人增加颜色和尺寸属性。比如红色小敌人速度快但体积小蓝色大敌人速度慢但碰撞范围大。通过不同尺寸和速度的组合玩法的多样性会明显提升。11.3 增加音效和粒子效果原生的AudioContext可以生成简单的音效比如碰撞时播放短促的合成音得分时播放轻快的提示音。粒子效果可以用来表现敌人被击中的爆炸碎片具体可以做一个小粒子数组由碰撞位置发射若干小三角形让它们向外扩散并逐渐消失。11.4 增加攻击和射击能力让玩家按空格发射子弹子弹同样是三角形或圆形的对象。发射后向上移动击中敌人后敌人消失并加分。这一改动会引入新的对象管理逻辑是理解“对象生命周期”的好练习。11.5 支持触屏和鼠标控制监听mousedown、mousemove、touchmove事件把鼠标或手指的 x 坐标映射为玩家目标位置然后让玩家平滑移动过去。这样游戏就同时支持 PC 键盘和移动端触屏。11.6 持久化最高分用localStorage保存历史最高分每次游戏结束比较当前得分和最高分。这个改动对新手很友好可以直接体验浏览器存储 API 的实际用途。11.7 迁移到 WebGL 或游戏引擎如果最终目标是学习 WebGL可以在 Canvas 2D 版跑通之后把绘制部分替换成 WebGL 渲染逻辑部分基本不用动。如果目标是做完整的商用游戏可以用 Phaser 或 Cocos Creator 重建游戏逻辑你会发现游戏循环、对象管理、碰撞检测这些概念在游戏引擎里都有对应的设计模式。12. 总结与下一步The Triangle Game from Zero 是一个把游戏开发核心链路浓缩在一个文件里的教学项目。它的价值不在于成品有多精致而在于把游戏最本质的三个问题讲清楚了怎么在一帧一帧的循环中更新画面怎么处理玩家输入和对象运动怎么判断碰撞并反馈到游戏状态。建议你拿到完整代码后先运行一遍亲手操作一下确认控制、计分、结束和重启流程都正常。然后不要急着继续加功能先改掉其中一个小逻辑观察效果比如把玩家移动速度调快、把敌人生成间隔调短、把碰撞半径调大。通过这种小改动你会比单纯读代码更深刻地理解每个参数在游戏中的实际作用。下一步最值得做的是增加射击功能因为它会强迫你重新思考对象管理和碰撞检测的组织方式。你可以在玩家中心生成一个向上运动的子弹对象子弹与敌人碰撞时同时移除两个对象并在生成敌人之前先判断子弹与敌人的距离。这个过程能把本文提到的所有概念再串联一遍。如果你在运行过程中遇到问题优先按下面的顺序排查先看浏览器 Console 有没有报错再确认 canvas 的 id 和 script 标签加载路径是否正确最后检查 dt 是否被正确计算和传入 update 函数。调试完多试几次三角形游戏虽然简单但它已经是一个完整的、能玩能调试能扩展的游戏项目了。