如果你第一次做网页小游戏,大概率会纠结要不要上游戏引擎。打开 Unity 要装几十 GB 的编辑器,用 Cocos 要熟悉场景编辑器,选 Phaser 还得先理解它的插件机制。但如果你只是想实现一个“用键盘控制三角形躲开障碍物”的小游戏,这些重型方案全都是过度设计。
这篇文章用一个完整的实战项目告诉你:一个可玩的三角形小游戏,用原生 JavaScript + Canvas,200 行代码就能从零跑起来。
我不打算教你怎么套模板,而是带你拆解游戏开发里最核心的几个骨架:绘制、循环、输入、碰撞、状态管理。这些概念放在任何游戏引擎里都一样,但用原生代码写一遍,你才能真正理解它们为什么存在。
读完这篇文章,你会得到:
- 一个可以打开浏览器直接玩的三角形躲避游戏;
- 一组可复用的 Canvas 游戏开发思路;
- 对
requestAnimationFrame、碰撞检测、游戏状态机的深入理解; - 后续扩展到移动端、加音效、加粒子效果的具体方案。
1. 先想清楚:这个三角形游戏到底要做什么
很多人写小游戏有个通病:打开编辑器就开始写代码,写到一半发现规则没定,逻辑越写越乱。做游戏和做后端接口一样,先定义清楚“玩法闭环”,再动手写实现。
我们要做的游戏叫 The Triangle Game,规则非常简单:
- 玩家控制屏幕底部区域的一个三角形;
- 障碍物会从顶部和左右两侧生成并下落;
- 玩家需要移动三角形避开障碍物;
- 存活时间越长,得分越高;
- 随着时间推移,障碍物生成越来越快,难度逐渐上升。
为什么选三角形而不是矩形或圆形?因为三角形在视觉上有方向感,旋转时能清楚看到“朝向”变化;同时它的顶点可以充当碰撞的“尖角”,让玩家对碰撞边界有直觉。对教程来说,三角形是一个很好的教学载体。
这个游戏的核心循环可以拆成五个模块:
| 模块 | 职责 | 对应代码 |
|---|---|---|
| 渲染 | 在 Canvas 上绘制三角形和障碍物 | draw()函数 |
| 输入 | 监听键盘事件并更新玩家位置 | keydown/keyup |
| 逻辑 | 移动玩家、生成障碍物、检测碰撞 | update()函数 |
| 状态 | 管理“待开始 / 游戏中 / 游戏结束”三种状态 | game.state |
| 计分 | 根据存活时间增加分数 | game.score |
这种拆分方式并不复杂,但如果你把这个结构放大一百倍,它就是游戏引擎中Scene、Entity、System这些概念的雏形。先画好模块边界,再填充代码,是小游戏开发最重要的习惯。
2. 核心概念:游戏循环、Canvas 渲染与碰撞检测
2.1 游戏循环为什么不能只用 setInterval
游戏和普通网页脚本最大的区别是:网页脚本是一次性执行,游戏需要每秒钟 60 次(甚至 120 次)反复执行“更新逻辑 + 重新绘制”,这个循环叫游戏循环。
很多人会想到setInterval,但它的缺点很明显:
- 浏览器标签页切到后台时,
setInterval会继续执行,浪费 CPU; - 时间间隔不精确,可能被其他任务阻塞;
- 无法根据屏幕刷新率自动调整。
正确做法是requestAnimationFrame。浏览器会在下一次重绘之前调用你传入的回调函数,并且当页面不可见时自动暂停。这是原生 Canvas 游戏里最重要的 API。
function gameLoop(timestamp) { update(timestamp); draw(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这段代码看起来很简单,但它是整个游戏的心脏。update负责移动玩家、推动障碍物、判断碰撞、更新分数;draw负责清空画布并绘制所有对象;最后再注册下一帧的回调,形成无限循环。
2.2 Collision Detection:别一上来就做像素级碰撞
碰撞检测是初学者最容易“用力过猛”的地方。有人会试图判断“三角形是否碰到矩形”,把点是否在多边形内、线段相交检测全部写出来,结果代码几百行,性能还差。
真实游戏开发里,大多数碰撞都先用简单的几何体近似,碰撞精度不够时才升级。这个三角形游戏里,最合适的近似方案是“圆形包围盒”:把三角形看成一个圆心在三角形中心、半径覆盖整个三角形的圆,判断两个圆是否相交。
function circlesCollide(x1, y1, r1, x2, y2, r2) { const dx = x1 - x2; const dy = y1 - y2; const distanceSq = dx * dx + dy * dy; const radiusSum = r1 + r2; return distanceSq <= radiusSum * radiusSum; }为什么不直接比较距离而比较距离的平方?因为Math.sqrt计算平方根的开销比乘法高,在每帧要检测几十甚至上百个碰撞对象时,省掉开方操作能明显提升性能。这个细节在对象数量少时看不出来,但在子弹、敌人、粒子满屏飞的时候会有体感差别。
用圆形包围盒判断三角形碰撞,确实会存在“边角没碰到却判定为碰撞”的情况。但在这个游戏的节奏下,玩家更在意的是“反应是否及时”,而不是“碰撞是否像素级精确”。等游戏机制稳定后,如果确实需要更精确的判定,再考虑多边形碰撞库如 SAT 算法。
2.3 游戏状态机:让项目可维护的关键
如果你把所有游戏逻辑写在一个大函数里,很快会陷入“这个分支不知道什么时候触发”的困境。游戏状态机就是用来解决这个问题的。
我们只需要三种状态:
ready:游戏开始前,显示操作提示;playing:游戏正常运行中;over:玩家碰撞后,显示得分和重开提示。
每个状态对应不同的更新逻辑和绘制逻辑。比如在ready状态下,玩家三角形可以轻微浮动,但障碍物不生成;在over状态下,玩家可以继续绘制但不能再移动。
const game = { state: 'ready', score: 0, time: 0, };状态切换只有三个动作:按空格从ready进入playing,碰撞后从playing进入over,再按空格从over回到playing。这个结构很轻,但它带来的收益是:你在写任何一段代码时,都知道它应该在哪个状态下执行。
3. 环境准备与项目初始化
这个项目的环境要求低到你不需要安装任何依赖。
- 操作系统:Windows / macOS / Linux 均可;
- 开发工具:任意文本编辑器,推荐 VS Code;
- 运行环境:任一现代浏览器(Chrome、Edge、Firefox、Safari);
- 本地服务器:可选。如果直接用
file://打开 HTML 也能运行,因为本项目不加载外部资源。但为了后续扩展方便,推荐使用 VS Code 的 Live Server 插件或 Python 自带的 HTTP 服务器。
项目目录结构如下:
triangle-game/ ├── index.html ├── style.css └── game.js为什么不把 CSS 和 JS 都写进 HTML?一是为了代码清晰,二是为后续引入构建工具、压缩资源留好位置。一个文件写到底在 50 行以内还可以,超过 200 行就会很痛苦。
初始化项目的命令:
mkdir triangle-game cd triangle-game touch index.html style.css game.js如果在 macOS 或 Linux 上,上面的命令就能创建三个空文件。Windows 用户可以直接在资源管理器里新建文件夹和文件。
为了让页面能通过本地服务器访问,可以执行:
# 在项目目录下启动一个 HTTP 服务器 python3 -m http.server 8080然后浏览器访问http://localhost:8080。使用本地服务器还有个额外好处:后续如果你想加音效、图片资源,就不用担心浏览器对本地文件的跨域限制问题。
4. 完整代码实现:从 HTML 骨架到游戏逻辑
下面我们编写三个文件。先写 HTML 骨架,再写样式,最后写游戏逻辑。每一步我都解释关键设计。
4.1 index.html:页面骨架
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>The Triangle Game</title> <link rel="stylesheet" href="style.css"> </head> <body> <canvas id="gameCanvas"></canvas> <script src="game.js"></script> </body> </html>这里只有一个<canvas>元素。Canvas 是 HTML5 提供的画布能力,我们可以通过 JavaScript 在它上面绘制图形。把所有游戏画面都画在 Canvas 里,而不使用 DOM 元素,是因为 Canvas 在每帧大量重绘时性能更好,适合高频更新的游戏画面。
4.2 style.css:全屏画布与基础样式
* { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; background: #0f1420; font-family: "Courier New", monospace; user-select: none; } canvas { display: block; width: 100vw; height: 100vh; cursor: crosshair; }这里关键点是overflow: hidden,防止页面出现滚动条导致画布位置偏移;user-select: none避免玩家快速点击时选中页面文本;cursor: crosshair让鼠标变成十字准星,增强游戏感。
4.3 game.js:完整游戏逻辑
这是最核心的文件,建议你直接复制并在本地运行,然后逐行阅读。
// 文件路径:triangle-game/game.js const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); // ---------- 画布尺寸与高分屏适配 ---------- let W = window.innerWidth; let H = window.innerHeight; const DPR = window.devicePixelRatio || 1; function resizeCanvas() { W = window.innerWidth; H = window.innerHeight; canvas.width = W * DPR; canvas.height = H * DPR; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } window.addEventListener('resize', resizeCanvas); resizeCanvas(); // ---------- 游戏全局状态 ---------- const game = { state: 'ready', // ready | playing | over score: 0, time: 0, lastSpawn: 0, spawnInterval: 1400, // 初始生成间隔(毫秒) minSpawnInterval: 450, }; // ---------- 玩家 ---------- const player = { x: 0, y: 0, radius: 18, speed: 320, tilt: 0, tiltTarget: 0, }; function initPlayer() { player.x = W / 2; player.y = H - 90; player.tilt = 0; player.tiltTarget = 0; } initPlayer(); // ---------- 障碍物 ---------- const obstacles = []; function spawnObstacle() { const isFromTop = Math.random() > 0.3; const size = 12 + Math.random() * 18; const speedY = 60 + Math.random() * 90; const speedX = (Math.random() - 0.5) * 60; const obstacle = { x: 0, y: 0, radius: size, speedY, speedX, type: Math.random() > 0.5 ? 'rect' : 'circle', rotation: Math.random() * Math.PI, rotateSpeed: (Math.random() - 0.5) * 0.05, }; if (isFromTop) { obstacle.x = 30 + Math.random() * (W - 60); obstacle.y = -40; } else { obstacle.x = Math.random() > 0.5 ? -40 : W + 40; obstacle.y = 100 + Math.random() * (H * 0.4); } obstacles.push(obstacle); } // ---------- 键盘控制 ---------- const keys = {}; window.addEventListener('keydown', (e) => { keys[e.code] = true; if (e.code === 'Space') { e.preventDefault(); if (game.state === 'ready') { startGame(); } else if (game.state === 'over') { resetGame(); startGame(); } } }); window.addEventListener('keyup', (e) => { keys[e.code] = false; }); // ---------- 开始 / 重置 ---------- function startGame() { game.state = 'playing'; game.time = 0; game.score = 0; game.lastSpawn = 0; game.spawnInterval = 1400; obstacles.length = 0; initPlayer(); } function resetGame() { obstacles.length = 0; game.score = 0; game.time = 0; initPlayer(); } // ---------- 碰撞检测(圆形包围盒) ---------- function circlesCollide(x1, y1, r1, x2, y2, r2) { const dx = x1 - x2; const dy = y1 - y2; const distanceSq = dx * dx + dy * dy; const radiusSum = r1 + r2; return distanceSq <= radiusSum * radiusSum; } // ---------- 更新逻辑 ---------- let lastTime = 0; function update(timestamp) { const dt = (timestamp - lastTime) / 1000; // 转成秒 if (dt > 0.1) return; // 防止从后台切回时 deltaTime 过大 lastTime = timestamp; if (game.state === 'playing') { game.time += dt; game.score = Math.floor(game.time * 10); // 根据时间缩短生成间隔,提升难度 const elapsedSeconds = game.time; game.spawnInterval = Math.max( game.minSpawnInterval, 1400 - elapsedSeconds * 20 ); // 生成障碍物 if (timestamp - game.lastSpawn > game.spawnInterval) { spawnObstacle(); game.lastSpawn = timestamp; } // 玩家移动 let moveX = 0; let moveY = 0; if (keys['ArrowLeft'] || keys['KeyA']) moveX -= 1; if (keys['ArrowRight'] || keys['KeyD']) moveX += 1; if (keys['ArrowUp'] || keys['KeyW']) moveY -= 1; if (keys['ArrowDown'] || keys['KeyS']) moveY += 1; if (moveX !== 0 || moveY !== 0) { const len = Math.hypot(moveX, moveY); moveX /= len; moveY /= len; player.x += moveX * player.speed * dt; player.y += moveY * player.speed * dt; } // 玩家边界限制 player.x = Math.max(24, Math.min(W - 24, player.x)); player.y = Math.max(24, Math.min(H - 24, player.y)); // 玩家倾斜效果 player.tiltTarget = moveX * 0.35; player.tilt += (player.tiltTarget - player.tilt) * 10 * dt; // 更新障碍物 for (let i = obstacles.length - 1; i >= 0; i--) { const ob = obstacles[i]; ob.x += ob.speedX * dt; ob.y += ob.speedY * dt; ob.rotation += ob.rotateSpeed * dt; // 移出屏幕后移除 if ( ob.x < -80 || ob.x > W + 80 || ob.y < -80 || ob.y > H + 80 ) { obstacles.splice(i, 1); continue; } // 玩家碰撞 if (circlesCollide(player.x, player.y, player.radius, ob.x, ob.y, ob.radius)) { game.state = 'over'; break; } } } if (game.state === 'ready') { // 待机状态,三角形轻微浮动 player.y = H - 90 + Math.sin(timestamp / 400) * 8; player.tilt += (0 - player.tilt) * 5 * dt; } } // ---------- 绘制逻辑 ---------- function draw() { // 清屏 ctx.fillStyle = '#0f1420'; ctx.fillRect(0, 0, W, H); // 绘制网格背景(增加视觉层次) ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; ctx.lineWidth = 1; for (let i = 0; i < W; i += 48) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, H); ctx.stroke(); } for (let j = 0; j < H; j += 48) { ctx.beginPath(); ctx.moveTo(0, j); ctx.lineTo(W, j); ctx.stroke(); } // 绘制障碍物 obstacles.forEach((ob) => { ctx.save(); ctx.translate(ob.x, ob.y); ctx.rotate(ob.rotation); ctx.fillStyle = 'rgba(255, 100, 100, 0.85)'; if (ob.type === 'rect') { ctx.fillRect(-ob.radius, -ob.radius, ob.radius * 2, ob.radius * 2); } else { ctx.beginPath(); ctx.arc(0, 0, ob.radius, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); }); // 绘制玩家三角形(向上方向,带倾斜) ctx.save(); ctx.translate(player.x, player.y); ctx.rotate(player.tilt); ctx.fillStyle = '#4af2a1'; ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(20, 18); ctx.lineTo(-20, 18); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore(); // ---------- UI 绘制 ---------- ctx.fillStyle = '#ffffff'; ctx.font = '18px Courier New, monospace'; ctx.textAlign = 'left'; ctx.fillText('SCORE: ' + game.score, 20, 36); if (game.state === 'ready') { ctx.textAlign = 'center'; ctx.fillStyle = '#ffffff'; ctx.font = '28px Courier New, monospace'; ctx.fillText('TRIANGLE GAME', W / 2, H / 2 - 50); ctx.font = '16px Courier New, monospace'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('用方向键 / WASD 移动', W / 2, H / 2 + 10); ctx.fillText('躲避所有障碍物', W / 2, H / 2 + 36); ctx.fillText('按 SPACE 开始', W / 2, H / 2 + 72); } if (game.state === 'over') { ctx.textAlign = 'center'; ctx.fillStyle = '#ff6b6b'; ctx.font = '32px Courier New, monospace'; ctx.fillText('GAME OVER', W / 2, H / 2 - 30); ctx.fillStyle = '#ffffff'; ctx.font = '18px Courier New, monospace'; ctx.fillText('SCORE: ' + game.score, W / 2, H / 2 + 10); ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('按 SPACE 重新开始', W / 2, H / 2 + 48); } } // ---------- 主循环 ---------- function gameLoop(timestamp) { update(timestamp); draw(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这段代码的核心逻辑我拆开解释。
高分屏适配部分,关键是DPR和ctx.setTransform。如果屏幕上物理像素是逻辑像素的两倍(Retina 屏),而 Canvas 的宽高不乘DPR,画面就会模糊。这里用DPR把 Canvas 内部分辨率提升,再用setTransform让绘制坐标仍然按逻辑像素计算,一举两得。
游戏循环里,dt是当前帧和上一帧的时间差,单位换算成秒。为什么不直接用固定速度?因为每帧的时间间隔不稳定,如果直接累加固定值,高速显示器上游戏会变快,低速显示器上游戏会变慢。使用 deltaTime 是游戏开发的基本功。
生成障碍物时,我用了timestamp - game.lastSpawn > game.spawnInterval来判断是否生成。这里的时间戳是requestAnimationFrame传入的高精度时间戳,单位是毫秒。随着game.time增加,spawnInterval从 1400ms 逐渐减少到 450ms,这就是难度曲线。
玩家移动时,先判断按键,再计算方向向量。注意我做了归一化处理:如果同时按了上键和左键,移动速度不会变成原来的两倍,而是保持一致的移动速度。很多新手会漏掉Math.hypot(moveX, moveY)归一化这一步,结果斜向移动明显更快,手感非常差。
碰撞检测那里,我从数组尾部往前遍历,原因是splice(i, 1)会改变数组索引,从后往前删除就不会跳过元素。同时,我是在循环体内判断game.state = 'over'后直接break,避免玩家在死亡后还被继续生成障碍物。
绘制玩家三角形时,ctx.save()和ctx.restore()包裹了位置和旋转的变换。注意绘制顺序:先translate到玩家位置,再rotate倾斜,然后以原点为中心绘制三角形。如果顺序反过来,旋转会围绕错误的位置发生。这是 Canvas 变换最容易踩的坑。
5. 运行与验证:怎么判断游戏跑通了
代码写完,运行步骤很简单。如果你已经在项目目录下启动了 HTTP 服务器,直接访问:
http://localhost:8080或者用 VS Code 的 Live Server 插件,右键点击index.html,选择 “Open with Live Server”。
页面打开后,你应该看到:
- 深色背景上有一个浅色网格;
- 屏幕底部中央有一个绿色三角形;
- 屏幕中央显示
TRIANGLE GAME标题和操作说明; - 按下空格键后,标题消失,障碍物开始从顶部和两侧出现;
- 障碍物持续生成,速度逐渐加快;
- 三角形碰到障碍物后,画面中央显示
GAME OVER和最终得分; - 再次按空格键,游戏重新开始。
如果打开页面后看到一片空白,先按F12打开开发者工具,查看 Console 选项卡有没有红色报错。最常见的错误是game.js文件路径不对,或者某个变量名拼写错误。从 Console 报错信息里,基本都能直接定位到问题行。
如果想快速验证碰撞检测是否正常,可以临时把player.radius改大,比如改成50,你会发现三角形还没碰到障碍物就判定游戏结束了,这能帮助你理解碰撞半径的作用。
6. 常见问题与排查思路
我把这个项目里最常遇到的问题整理成表格,方便你对照排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 页面打开后全白 | JS 文件路径错误或代码报错 | 打开 Console 查看报错 | 修正 script 标签地址,修复语法错误 |
| 三角形不移动 | 键盘事件没绑定,或按键码不对 | 在 keydown 回调里打印e.code | 使用ArrowLeft/KeyA等标准按键码 |
| 画面模糊 | 没有做高分屏适配 | 检查 canvas.width 是否乘以 DPR | 执行canvas.width = W * DPR并setTransform |
| 游戏速度在不同显示器上不一样 | 使用固定数值没有乘 deltaTime | 检查 update 里是否用dt乘速度 | 所有移动速度都乘dt |
| 斜向移动速度明显更快 | 没有归一化方向向量 | 打印moveX + moveY的长度 | 使用Math.hypot归一化后再乘以速度 |
| 障碍物越来越多,游戏变卡 | 障碍物移除逻辑有问题 | 查看 obstacles 数组长度 | 移出屏幕后执行splice移除,并限制生成频率 |
| 空格键滚动页面 | 空格默认触发页面滚动 | 检查 keydown 处理 | 调用e.preventDefault() |
| 后台切回后玩家瞬移 | deltaTime 过大导致跳帧 | 检查 dt 上限 | 增加if (dt > 0.1) return;限制 |
这里最值得提醒的是第一个问题。由于这个项目使用了浏览器模块相关的原生 API 和 Canvas API,如果你的game.js里某个 API 拼写错误,整个脚本会直接停止执行,Canvas 区域就会空白。遇到这种情况,不要急着改逻辑,先看 Console 报错,再逐行注释排查,这是所有前端调试的基本功。
另一个容易忽视的坑是:如果你把game.js用<script>标签直接引入,尽量不要声明export关键字,不然浏览器会因为“模块语法出现在非模块脚本中”而报错。如果需要模块化,可以把<script>标签改成<script type="module" src="game.js">。
7. 从能用走向好用:扩展方向与工程建议
这个版本的游戏已经能玩了,但它只是一个 MVP。如果要把项目扩展成真正的作品,下面这些方向值得尝试。
7.1 加入音效与视觉反馈
当前碰撞是瞬间进入game over,缺少反馈层次。你可以用 Web Audio API 生成简单的音效,不需要加载外部音频文件:
function playBeep(frequency = 440, duration = 0.1) { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.value = frequency; oscillator.type = 'square'; gainNode.gain.setValueAtTime(0.2, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration); oscillator.start(audioCtx.currentTime); oscillator.stop(audioCtx.currentTime + duration); }在玩家移动时播放轻音,碰撞时播放低音,分数超过整十分时播放上升音阶。不要小看音效,它是提升手感最廉价的方案。
7.2 移动端适配
当前版本只支持键盘,在手机上完全没法玩。要适配移动端,重点是两件事:
- 把
keydown逻辑替换为触摸事件,用虚拟摇杆或触摸滑动控制移动; - 在 Canvas 上绘制一个半透明的摇杆区域,监听
touchstart、touchmove、touchend更新玩家的移动方向。
如果不想手写摇杆,也可以把移动改成“触摸跟随”:手指按住屏幕,三角形向手指方向移动。这更适合休闲小游戏的节奏。
7.3 对象池优化性能
当障碍物数量非常多时,反复push和splice会造成内存抖动。标准方案是对象池:提前创建一批障碍物对象,不使用时标记为“禁用”,使用时从池中取出并重置属性,移出屏幕后归还池。
const obstaclePool = []; function getObstacle() { return obstaclePool.pop() || {}; } function recycleObstacle(ob) { obstaclePool.push(ob); }这个优化在当前的障碍物数量下没有必要,但如果你想做“子弹满天飞”的射击游戏,对象池就是必须掌握的技巧。
7.4 难度曲线与关卡设计
目前的难度曲线是线性的:生成间隔从 1400ms 匀速降到 450ms。更好的设计是分段提升难度:每 10 秒进入一个新阶段,每个阶段增加障碍物类型、速度、生成角度等维度。
const STAGES = [ { duration: 10, spawnInterval: 1300, extraObstacles: 0 }, { duration: 20, spawnInterval: 900, extraObstacles: 1 }, { duration: 30, spawnInterval: 650, extraObstacles: 2 }, ];这种分段设计能让玩家感受到“关卡在推进”,而不是“时间越长越难”,反馈更清晰。
7.5 工程层面的建议
如果你打算把这个项目继续做大,下面几条工程建议值得提前遵守:
- 把游戏状态、玩家、障碍物拆成独立模块。可以先用单文件,但保持对象边界清晰,为后续拆分做准备。
- 使用全局状态对象而不是散落的全局变量。现在
player、game、obstacles都是独立的,后续如果有敌人、子弹、道具,建议收敛到一个game world对象中。 - 写一个简单的调试面板。在开发阶段把
game.state、obstacles.length、FPS显示在画布角落,很多问题一眼就能看出来。 - 版本管理从第一天开始。哪怕只有一个人开发,
git init之后每个稳定版本打一个 tag,方便回退。
8. 对比一下:原生 Canvas 和游戏引擎的边界在哪
你可能会有疑问:既然原生 Canvas 能写游戏,为什么还要用 Phaser、Cocos 这些游戏引擎?
这个问题的答案取决于项目的规模和复杂度。
原生 Canvas 适合:
- 教学演示、原型验证、小型休闲游戏;
- 对包体积敏感、不想引入运行时依赖的场景;
- 想深入理解游戏底层原理的学习阶段。
游戏引擎适合:
- 需要场景管理、物理引擎、动画状态机、粒子系统协同工作的中型游戏;
- 需要跨平台打包发布到 iOS / Android / Steam 的场景;
- 多人协作、需要可视化编辑器的团队项目。
做一个简单判断:如果游戏的核心机制在 300 行代码内能表达清楚,原生 Canvas 足够;如果还需要导出到手机端和桌面端,选引擎更划算。这个三角形游戏就是前者的典型案例。
9. 总结与下一步行动
这个项目虽然叫 The Triangle Game,但它真正演示的不是“三角形怎么动”,而是游戏开发里的最小闭环:状态管理、输入监听、帧循环、碰撞检测、难度曲线和 UI 反馈。这些概念在任何游戏引擎中都会以不同形式出现,理解它们之后,你再去学 Phaser 或 Cocos,会发现自己其实在学“工具”,而不是在学“游戏开发”。
建议你动手做三件事:
- 先按文章代码跑通一版,确认自己理解了每一行逻辑;
- 修改一个参数,比如玩家移动速度、障碍物生成间隔、碰撞半径,观察游戏手感变化;
- 选定一个扩展方向,做一个真正属于你个人的版本,比如给三角形换皮、加入道具、增加连击计分。
后续可以继续深入的方向包括:Canvas 粒子系统、Web Audio API 音频合成、requestAnimationFrame 的性能优化、简单物理引擎的碰撞分组,以及如何用 TypeScript 重写这个游戏以提升可维护性。每一块都可以单独写一篇文章展开,但从这个 200 行的小项目出发,你的每一步探究都会更扎实。