零依赖从零实现:Canvas 2D三角形街机游戏开发教程

零依赖从零实现:Canvas 2D三角形街机游戏开发教程
这次我们来看一个很适合练手和教学的开发主题The Triangle Game, from Zero。从项目名就能看出它强调的不是复杂引擎而是“从零开始”不引入游戏引擎不安装第三方运行时不依赖任何外部素材包只用一个空目录、一个浏览器、一个纯文本编辑器把一款以三角形为主角的街机小游戏完整做出来。对想理解游戏底层原理的读者来说这种“零依赖”的做法反而是最直接的切入方式。在具体实现上这个项目把游戏开发最核心的几件事全部压缩在一个很小的代码量里Canvas 2D 渲染、requestAnimationFrame 游戏主循环、键盘输入处理、圆形碰撞检测、障碍物生成与回收、计分与难度曲线。得益于“零依赖”的设计任何一台能打开现代浏览器的电脑都能运行代码里没有黑盒每一行都在做一件看得见的事。这比直接去读 Unity 或 Godot 的完整工程更容易建立直觉。这篇文章会带你走一遍完整流程先准备环境和项目结构再写画布与主循环然后实现三角形渲染、键盘操控、碰撞判定、游戏结束与重启接着补上计分和难度提升最后给出性能观察方法、常见问题排查表以及后续可以继续扩展的方向。如果你正在学游戏开发或者想快速验证一个玩法原型这篇文章可以直接收藏边看边写最终你会得到一份能跑、能玩、方便继续改的完整游戏代码。1. 核心能力速览能力项说明项目类型从零实现的三角形主题 2D 街机小游戏浏览器端运行方式浏览器直接打开 HTML 文件或通过本地静态服务访问外部依赖无不使用游戏引擎、不使用第三方 JS 库、不使用外部素材核心玩法控制三角形在画布内移动躲避从屏幕边缘来袭的障碍物生存越久得分越高关键技术点Canvas 2D 绘制、requestAnimationFrame 主循环、键盘输入、圆形碰撞检测、游戏状态与难度曲线开发语言HTML CSS 原生 JavaScript推荐环境较新的 Chrome / Edge / Firefox 浏览器接口 API不涉及纯静态页面无服务端接口批量任务不适用本文的“批量”更多体现为多障碍物同帧更新与回收适合场景游戏开发入门、Canvas 教学、玩法原型验证、前端基础练习表格里的能力项就是本项目最值得关注的信息。它的核心优势不是功能多而是“干净”渲染、逻辑、输入、状态全部肉眼可见适合逐行阅读。显存、GPU、服务端接口这些词在这里都不存在因为它根本不走这条路。你需要准备的只有一块能显示网页的屏幕和一份愿意读完代码的耐心。2. 适用场景与使用边界这类“从零写游戏”的项目最适合三类人。第一类是刚开始学编程或游戏开发的人在一份代码里可以同时接触渲染、输入、碰撞和状态管理比背语法书直观得多。第二类是前端开发者尤其是想补一补 Canvas 和 requestAnimationFrame 用法的同学这个项目就是一份带交互的活教材。第三类是独立开发者或做玩法原型验证的人想快速确认“某个小玩法好不好玩”不需要打开重型编辑器写几个文件丢进浏览器就能测。它能解决的问题也很明确怎么让画面动起来、怎么读键盘输入、怎么判断两个物体撞没撞、怎么让游戏越来越难、怎么优雅地结束和重启。这些都是任何游戏都绕不开的基础问题。一旦理解了这套最小闭环后面再学 Phaser、PixiJS、Unity 都会轻松得多。但也要说清楚边界。这个项目不涉及网络同步做不了多人联机不适合做重美术、重音频表现的大型游戏也没有服务端 API 和排行榜。本文所有图形都由 Canvas 矢量绘图接口生成不使用外部图片、音频、字体素材。如果你后续要加入新素材请确认素材授权情况如果扩展成带账号、排行榜或用户生成内容的版本还需要补充用户隐私保护、内容审核等合规设计。合法授权和隐私边界从第一天就要有意识。3. 环境准备与前置条件3.1 最小环境清单项目要求操作系统Windows / macOS / Linux 均可浏览器建议 Chrome、Edge、Firefox 最新稳定版代码编辑器VS Code、Sublime、Notepad 等任意文本编辑器本地静态服务可选推荐用于模拟真实部署需 Python 或 Node 环境磁盘空间仅需几个 KB项目源码不超过几十 KB这个项目的环境门槛基本为零。它不需要安装依赖不需要配置构建工具不需要下载模型文件。唯一可能遇到的小麻烦是编辑器对纯文本文件的支持任何主流编辑器都能解决。下面给出的命令都是通用模板实际路径、端口以你的本机环境为准。3.2 本地静态服务可选一般情况下直接双击 index.html 就能运行。但如果你后续要加入音频、异步加载配置或者使用 ES Module 写法部分浏览器在 file:// 协议下会有限制。建议尽早养成用静态服务打开页面的习惯。cd triangle-game python -m http.server 8000使用 Node 也可以npx serve .启动后访问 http://127.0.0.1:8000 即可看到游戏页面。如果端口被占用换个端口再启动就行。4. 从零搭建项目结构与游戏循环4.1 项目结构从零开始不需要复杂的目录分层三个文件就是最小可运行结构triangle-game/ ├── index.html ├── style.css └── game.js这个结构刻意保持极简HTML 负责页面入口CSS 负责全屏布局game.js 负责全部游戏逻辑。后续如果继续扩展再按 scripts、assets、docs 的思路拆分目录。现在这个阶段文件越少越容易集中注意力。4.2 HTML 与样式先写 index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleThe Triangle Game, From Zero/title link relstylesheet hrefstyle.css /head body canvas idgameCanvas/canvas script srcgame.js/script /body /html页面主体只有一个 canvas 元素。script 放在 body 末尾保证 script 执行时 canvas 已经出现在文档中。如果你把 script 放到 head 里需要手动监听 DOMContentLoaded否则 getElementById 会拿到 null这是初学者最常见的报错来源之一。再写 style.css* { margin: 0; padding: 0; box-sizing: border-box; } body { background: #1a1a2e; overflow: hidden; font-family: Courier New, monospace; } canvas { display: block; background: #16213e; }body 的 overflow: hidden 是为了避免游戏画面超出窗口时出现滚动条canvas 设为 block 可以去掉 inline 元素默认的内联间隙避免底部出现一条白边。背景色可以根据自己喜好调整深色背景在长时间调试时更舒服。4.3 自适应画布进入 game.js。第一段是获取画布并把尺寸铺满窗口const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); let W canvas.width window.innerWidth; let H canvas.height window.innerHeight; let lastTime 0; window.addEventListener(resize, () { W canvas.width window.innerWidth; H canvas.height window.innerHeight; });这里把画布宽高保存在 W、H 两个全局变量里后续绘制时统一引用避免每次都从 window 上取值。resize 事件在窗口尺寸变化时同步调整画布否则画面会拉伸或失真。ctx 是 2D 绘图上下文接下来所有绘制操作都挂在它上面。4.4 游戏主循环游戏能“动”的核心不是 setInterval而是 requestAnimationFrame。它会把回调放在浏览器每次刷新屏幕之前执行帧率与显示器刷新率同步而且页面切到后台时自动暂停比固定间隔 setTimeout 更省资源。function loop(t) { const dt Math.min((t - lastTime) / 1000, 0.05); lastTime t; update(dt); draw(); requestAnimationFrame(loop); } function update(dt) { // 每帧逻辑更新 } function draw() { // 每帧绘制 } requestAnimationFrame(loop);这里的 dt 是上一帧到当前帧的时间差单位是秒。游戏逻辑必须在 dt 下工作否则会出现在高刷新率显示器上移动更快、低刷新率上移动更慢的问题。Math.min 把 dt 限制在 0.05 秒以内是为了防止浏览器从后台切回来的一瞬间 dt 过大导致障碍物瞬移穿透。这两行可以说是整个游戏稳定性的地基。5. 三角形渲染与键盘操控实现5.1 玩家与按键状态在 update 之前先定义玩家对象和按键状态表。玩家对象里存的是位置、朝向、速度和尺寸这些都是游戏对象最基础的属性const player { x: W / 2, y: H / 2, angle: 0, speed: 300, size: 18 }; const keys {};speed 的单位是像素每秒这样配合 dt 计算起来非常直观。size 是三角形外接圆的半径既用于绘制也用于碰撞判定。把物理属性和绘制属性放在同一个对象里在小型项目里完全够用也方便阅读。5.2 键盘输入监听按键监听要区分按下和松开两个动作。按下时把键位标记为 true松开时改成 falseupdate 里只查这张状态表这是处理“连续移动”的标准做法window.addEventListener(keydown, (e) { keys[e.code] true; if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Space].includes(e.code)) { e.preventDefault(); } }); window.addEventListener(keyup, (e) { keys[e.code] false; });使用 e.code 而不是 e.key是因为 code 对应物理按键的位置不随键盘布局变化。方向键的默认行为是滚动页面必须 preventDefault否则按方向键时画布内容会跟着跳动这是很多新手第一次打开游戏时觉得画面“发飘”的原因。5.3 移动逻辑与朝向update 函数里的移动逻辑是游戏手感的核心function update(dt) { let mx 0; let my 0; if (keys[ArrowLeft] || keys[KeyA]) mx - 1; if (keys[ArrowRight] || keys[KeyD]) mx 1; if (keys[ArrowUp] || keys[KeyW]) my - 1; if (keys[ArrowDown] || keys[KeyS]) my 1; if (mx ! 0 || my ! 0) { const len Math.hypot(mx, my); player.x (mx / len) * player.speed * dt; player.y (my / len) * player.speed * dt; player.angle Math.atan2(my, mx); } player.x Math.max(player.size, Math.min(W - player.size, player.x)); player.y Math.max(player.size, Math.min(H - player.size, player.y)); }mx、my 是输入方向向量。同时按上和右时向量的模长是根号 2如果直接乘速度斜向移动速度会比单项快约 41%。除以 len 做归一化后所有方向的速度保持完全一致这是操作手感“正”的关键细节。angle 用 Math.atan2 计算让三角形的头部始终朝向移动方向。最后用 Math.max 和 Math.min 把玩家限制在画布范围内防止跑出屏幕。5.4 绘制三角形绘制三角形的思路是先平移到玩家坐标再旋转到当前朝向然后按局部坐标画三个顶点function drawPlayer() { ctx.save(); ctx.translate(player.x, player.y); ctx.rotate(player.angle); ctx.beginPath(); ctx.moveTo(player.size, 0); ctx.lineTo(-player.size, player.size * 0.7); ctx.lineTo(-player.size, -player.size * 0.7); ctx.closePath(); ctx.fillStyle #00d4ff; ctx.fill(); ctx.strokeStyle #ffffff; ctx.lineWidth 2; ctx.stroke(); ctx.restore(); }moveTo 到 (player.size, 0) 表示三角形头部朝右后面两个点是尾部的上下两角一个等腰三角形的形状就出来了。save 和 restore 是一对配套操作保证旋转和平移只作用于当前三角形的绘制不会污染后续其他图形。6. 碰撞检测与游戏规则6.1 障碍物的生成障碍物从屏幕四条边的外侧生成然后朝玩家方向移动。这样玩家会持续面临来自四面八方的压力游戏节奏自然就紧张起来function spawnObstacle() { const side Math.floor(Math.random() * 4); const size 12 Math.random() * 20; const speed 60 Math.random() * 60 level * 12; let x 0; let y 0; if (side 0) { x -30; y Math.random() * H; } else if (side 1) { x W 30; y Math.random() * H; } else if (side 2) { x Math.random() * W; y -30; } else { x Math.random() * W; y H 30; } const dx player.x - x; const dy player.y - y; const len Math.hypot(dx, dy); obstacles.push({ x, y, vx: (dx / len) * speed, vy: (dy / len) * speed, size }); }随机选一条边在边外 30 像素

最新新闻

日新闻

周新闻

月新闻