如果你第一次做网页小游戏大概率会纠结要不要上游戏引擎。打开 Unity 要装几十 GB 的编辑器用 Cocos 要熟悉场景编辑器选 Phaser 还得先理解它的插件机制。但如果你只是想实现一个“用键盘控制三角形躲开障碍物”的小游戏这些重型方案全都是过度设计。这篇文章用一个完整的实战项目告诉你一个可玩的三角形小游戏用原生 JavaScript Canvas200 行代码就能从零跑起来。我不打算教你怎么套模板而是带你拆解游戏开发里最核心的几个骨架绘制、循环、输入、碰撞、状态管理。这些概念放在任何游戏引擎里都一样但用原生代码写一遍你才能真正理解它们为什么存在。读完这篇文章你会得到一个可以打开浏览器直接玩的三角形躲避游戏一组可复用的 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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleThe Triangle Game/title link relstylesheet hrefstyle.css /head body canvas idgameCanvas/canvas script srcgame.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 typemodule srcgame.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 行的小项目出发你的每一步探究都会更扎实。