Web-Dev-For-Beginners 太空射击游戏实战:用 6 节课系统掌握 Canvas 与高级 JavaScript 游戏架构
本文以 Web-Dev-For-Beginners 课程中的"太空射击游戏(Space Game)"模块为主线,完整梳理其 6 个阶段课程:从对象架构(继承与组合)、Pub/Sub 事件通信,到 Canvas 绘制、异步资源加载、游戏循环、碰撞检测、计分与胜负判定。读完本文,你将理解如何用纯浏览器技术(HTML <canvas> + JavaScript)从零构建一个可玩、可扩展的射击游戏,并掌握可用于任何前端工程的事件驱动架构与对象组织范式。
模块定位:以"太空入侵者"玩法教授高级 JavaScript 基础
该模块属于 Web-Dev-For-Beginners 的 6-space-game 课程目录,其玩法与经典街机游戏《太空入侵者》相似:玩家操控一艘飞船,向下落(或逼近)的怪物开火。模块概述(6-space-game/README.md)明确列出了六课要掌握的核心能力:
- 与
<canvas>元素交互,在屏幕上绘制画面; - 理解笛卡尔坐标系在计算机图形中的使用;
- 学习 Pub/Sub(发布—订阅)模式,构建更易维护、易扩展的"健全"游戏架构;
- 借助 Async/Await 加载游戏资源;
- 处理键盘事件。
课程划分为一个"理论"小节与五个"实践"小节:
| 阶段 | 课程 | 主题 |
|---|---|---|
| Theory | 1-introduction/README.md | 游戏架构基础:继承 vs 组合、Pub/Sub 通信模式 |
| Practice | 2-drawing-to-canvas/README.md | 在 Canvas 上绘制英雄与怪物编队 |
| Practice | 3-moving-elements-around/README.md | 让元素在屏幕上运动、接入游戏循环 |
| Practice | 4-collision-detection/README.md | 激光武器与碰撞检测 |
| Practice | 5-keeping-score/README.md | 分数、生命与游戏奖励系统 |
| Practice | 6-end-condition/README.md | 胜负判定与一键重启 |
模块内每课均配有 assignment.md 作业、your-work/ 起点工程与 solution/ 参考答案,并附有开源素材致谢:游戏素材来自 Kenney 提供的免费/付费游戏素材库(6-space-game/solution/spaceArt/ 目录中可见大量 PNG/SVG 资源与一张 spritesheet.png 精灵图)。
第 1 课:用继承与组合组织游戏对象
任何游戏都由 game objects(游戏对象)构成——英雄、敌人、道具、爆炸特效皆是。尽管外观不同,它们共享若干基础行为:存在于某处(拥有 x/y 坐标)、大多可以移动、有生命周期、会对碰撞等事件做出反应。
基于类的继承方案
课程给出了逐层构建的示例:先建立承载坐标与类型的基类 GameObject,再通过 super() 继承并扩展移动能力,最后派生出具体角色:
class GameObject {
constructor(x, y, type) {
this.x = x;
this.y = y;
this.type = type;
}
}
class Movable extends GameObject {
constructor(x, y, type) {
super(x, y, type);
}
moveTo(x, y) {
this.x = x;
this.y = y;
}
}
class Hero extends Movable {
constructor(x, y) {
super(x, y, 'Hero');
}
}
class Tree extends GameObject {
constructor(x, y) {
super(x, y, 'Tree'); // 树不需要移动
}
}
const hero = new Hero(0, 0);
hero.moveTo(5, 5); // Hero 可以移动
const tree = new Tree(10, 15);
// tree.moveTo() 会报错——树不能移动
这一方案的价值在于类层次结构天然阻止"不恰当的调用"(树没有 moveTo),适合明确的 is-a(是一个) 关系,例如 "Hero is-a Movable object"。
基于组合(工厂函数)的方案
组合借鉴模块化设计哲学:用对象展开符 ... 把多个"能力对象"混合成恰好具备所需功能的对象,通过工厂函数产出:
const gameObject = { x: 0, y: 0, type: '' };
const movable = {
moveTo(x, y) { this.x = x; this.y = y; }
};
const movableObject = { ...gameObject, ...movable };
function createHero(x, y) {
return { ...movableObject, x, y, type: 'Hero' };
}
function createStatic(x, y, type) {
return { ...gameObject, x, y, type };
}
const hero = createHero(10, 10);
hero.moveTo(5, 5); // 正常
const tree = createStatic(0, 0, 'Tree');
// tree.moveTo() 为 undefined
课程给出的选型建议是:当存在清晰的"is-a"层级时选继承,当需要"has-a(拥有某项能力)"、追求最大灵活性时选组合(如 { ...movable, ...flyable, ...gameObject } 即可得到一个"既能移动又会飞"的 Flying Enemy),同一应用里两种方式也可混用。
第 1 课核心:用 EventEmitter 实现 Pub/Sub 通信
当"英雄受伤"时,血条、音效、红屏反馈等多个系统都要响应。若让英雄对象直接引用这些系统会造成紧耦合;Pub/Sub 模式让英雄只广播一条 'HERO_DAMAGED' 消息,任何关心它的订阅者各自响应——这与广播电台"一个发射端、多个接收端、互不知晓"的原理一致。Pub/Sub 的四个关键角色是:消息(描述"发生了什么"的文本标签)、发布者、订阅者与作为"中间人"的事件系统。
课程给出了一个轻量 EventEmitter 的实现:
class EventEmitter {
constructor() {
this.listeners = {};
}
on(message, listener) {
if (!this.listeners[message]) {
this.listeners[message] = [];
}
this.listeners[message].push(listener);
}
emit(message, payload = null) {
if (this.listeners[message]) {
this.listeners[message].forEach(listener => {
listener(message, payload);
});
}
}
}
随后用消息常量 + 订阅 + 键盘发布,构成一个完整的解耦链路——把输入处理与移动逻辑分离,后续加音效、镜头跟随只需新增监听器,无需改动现有代码:
const Messages = {
HERO_MOVE_LEFT: 'HERO_MOVE_LEFT',
HERO_MOVE_RIGHT: 'HERO_MOVE_RIGHT'
};
eventEmitter.on(Messages.HERO_MOVE_LEFT, () => {
hero.moveTo(hero.x - 5, hero.y);
});
window.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft') eventEmitter.emit(Messages.HERO_MOVE_LEFT);
if (event.key === 'ArrowRight') eventEmitter.emit(Messages.HERO_MOVE_RIGHT);
});
课程给出的最佳实践包括:用常量对象避免消息名拼写错误、按逻辑归类消息、保持 payload 精简、尽早记录消息类型文档;并提醒不要在一开始就创建过多过细的消息类型,应从宽泛类别起步再逐步细化。
第 2 课:Canvas 绘制与异步图片加载
三步绘制流程与坐标系
<canvas> 是 HTML5 提供的可编程绘图表面,声明方式如下:
<canvas id="myCanvas" width="200" height="100"></canvas>
绘制的固定三步是:从 DOM 获取 canvas 引用 → 调用 getContext('2d') 取得 2D 渲染上下文 → 用上下文方法绘制:
const canvas = document.getElementById("myCanvas");
const ctx = canvas.getContext("2d");
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, 200, 200); // x, y, width, height
需要特别注意:Canvas 虽使用笛卡尔坐标,但原点 (0,0) 位于左上角,x 向右增大、y 向下增大——这与数学课坐标系的 y 轴方向相反(根源在于早期电子束显示器自上而下的扫描方式)。课程中配套的网格示意图见 canvas_grid.png。Canvas API 可绘制几何图形、任意字体颜色的文本、以及 .jpg/.png 等图片素材;如需 3D 则要使用 WebGL API。
用 Promise + Async/Await 健壮地加载资源
图片加载是异步的。课程先展示朴素写法(new Image() 设置 src、监听 onload),随后升级为可复用的 Promise 封装并配合现代 async/await:
function loadAsset(path) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = path;
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${path}`));
});
}
async function initializeGame() {
try {
const heroImg = await loadAsset('hero.png');
const monsterImg = await loadAsset('monster.png');
} catch (error) {
console.error('Failed to load game assets:', error);
}
}
异步加载的意义在于避免图片请求阻塞主线程与首帧渲染。
实战任务:搭建 1024×768 的游戏画面
第二课的动手环节要求搭建渲染 1024*768 黑色屏幕、绘制英雄飞船与 5×5 敌人编队的页面。起始工程位于 your-work 子目录(如 6-space-game/2-drawing-to-canvas/your-work/),结构为 assets/(含 enemyShip.png、player.png)、index.html、app.js 与 package.json。在本地安装 Node.js 与 npm 后,进入该目录执行:
cd your-work
npm start
即可在 http://localhost:5000 打开开发服务器(支持文件热监听)。其实现要点是:
- 黑色背景只需两行代码:
ctx.fillStyle = 'black'后调用ctx.fillRect(0, 0, canvas.width, canvas.height); - 用
await loadAsset()分别加载玩家与敌人贴图并缓存到变量; - 英雄居中偏下:
ctx.drawImage(heroImg, canvas.width / 2 - 45, canvas.height - canvas.height / 4); - 敌人编队由常数与嵌套循环控制:
const ENEMY_TOTAL = 5;
const ENEMY_SPACING = 98;
const FORMATION_WIDTH = ENEMY_TOTAL * ENEMY_SPACING;
const START_X = (canvas.width - FORMATION_WIDTH) / 2;
const STOP_X = START_X + FORMATION_WIDTH;
for (let x = START_X; x < STOP_X; x += ENEMY_SPACING) {
for (let y = 0; y < 50 * 5; y += 50) {
ctx.drawImage(enemyImg, x, y);
}
}
以 1024 宽画布计算:编队宽 490px、起始横坐标约 267px,5 列 x 坐标约 267/365/463/561/659,即可自动居中排布。完成后的参考效果可见 6-space-game/2-drawing-to-canvas/solution/ 目录下的可运行工程。
第 3 课:键盘事件、游戏循环与元素运动
运动分为玩家控制运动与自动运动(如巡逻的敌人)两类。屏幕动画本质是"翻页动画":每帧先更新位置 → 清空旧画面 → 重绘新画面,快速重复便形成平滑运动。最朴素的手写示例如:
hero.x += 5;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(heroImg, hero.x, hero.y);
(课程也提醒:每帧全量重绘会有性能开销,可参考 Canvas 官方优化文档探索局部绘制等方案。)
键盘事件处理细节
课程演示了监听窗口级键盘事件并阻止浏览器默认行为:
window.addEventListener('keydown', (evt) => {
// 例如空格键会滚动页面,需 preventDefault()
e.preventDefault();
});
window.addEventListener('keyup', (evt) => { /* ... */ });
对方向键、空格这类会产生页面默认行为的按键,调用 e.preventDefault() 是保证游戏体验的关键;同时要区分按下(keydown)与抬起(keyup)以支持持续按住移动。
游戏循环
课程用 setInterval 驱动循环并给出"游戏循环是电影放映机"的比喻:
const id = setInterval(() => {
// 1. 更新游戏对象位置
// 2. 清屏重绘
}, 100); // 每 100ms 一帧
第三课将前两课技术组合起来:建立全局 EventEmitter、把键盘事件桥接到 Pub/Sub 消息、并用 setInterval 定期刷新,使敌人在消息驱动下自动移动。值得对比的是,仓库最终解决方案 6-space-game/solution/app.js 采用更现代的 requestAnimationFrame(loop) 驱动循环:以 ts 时间戳差分计算 dt(并 Math.min(0.033, …) 钳制在 33ms 防止切后台后跳帧),每帧执行 update(dt) 与 render(),同时每 0.5s 统计一次实时 FPS——从源码结构看,这正是把课程的循环思想落地为高性能实现的范例。
第 4 课:碰撞检测与对象生命周期管理
第 4 课以"任务控制中心持续监控交会轨道"为喻引入碰撞检测:当激光命中敌人、敌人撞上英雄时,游戏需要精确判定并触发后续事件。碰撞实现分步骤展开:
- 添加矩形碰撞边界:为每个对象定义矩形范围(坐标 + 宽高),得到一个可供碰撞算法判定的"包围盒";
- 编写矩形相交判定:核心思路是当两个矩形在水平或垂直方向"没有分离"时即发生重叠——即
!(a.x + a.w < b.x || b.x + b.w < a.x || …)式的分离轴判断; - 事件驱动响应:碰撞发生时通过事件把两个对象交给处理函数,判定后清理被摧毁对象,保证画面中不残留"幽灵实体";
- 接入游戏循环:课程特别提醒要把
updateGameObjects()加入window.onload里的主循环,碰撞检测才真正生效。
游戏规则被归纳为清晰的碰撞矩阵:激光击中敌人 → 双方销毁并计分;敌人与英雄相交 → 双方销毁。可验证的实现同样存在于 6-space-game/solution/app.js:敌人循环内用平方距离比较 dist2(e.x, e.y, b.x, b.y) < (e.r + b.r) * (e.r + b.r) 判定圆-圆相交,命中即移除子弹与敌人并生成爆炸粒子;敌人与玩家相撞时扣减护盾、损失生命,护盾耗尽触发 lives 递减,直至 gameOver()。
第 5 课:在屏幕上绘制文字、生命与计分系统
记分课首先讲解如何用 Canvas 直接绘制文字——这是 HUD(游戏内信息面板)的"发声"方式。课程规定:英雄以三条生命开局(早期街机确立的标准平衡设定),每次与敌人碰撞扣除一条,画面右下角用飞船图标展示剩余生命。
奖励系统逐步落地:给击中敌机的行为加分并实时渲染在画面左下角;为生命与分数搭建一个面向整个游戏系统的反馈闭环。课程作业进一步建议可用 localStorage 实现本地最高分持久化——这一想法在最终解决方案中得到验证:6-space-game/solution/app.js 中 gameOver() 会将 { initials, score, t } 写入 localStorage 键 spaceGameScores,排序后保留前 10 名并渲染排行榜;HTML 侧(6-space-game/solution/index.html)则用 <output> 与 role="status" 区域显示 Score / Level / Lives / Shield / FPS / Time,构成完整的实时 HUD。
第 6 课:胜负判定与平滑重启
清晰的对局结束条件
最后一课定义了胜负规则并给出经典街机设计反思。两种典型失败模型是:飞船被毁即输,或引入"生命"概念——每次飞船被摧毁扣一条生命,生命归零才落败(这也是第 5 课"三条命"铺垫的原因)。
实现重启机制
课程强调"优秀游戏鼓励重玩":结束时应当让玩家立刻再开一局。重启流程的关键实现点包括:
- 用
endGame(win)集中处理收尾:先判定"失败",再判定"胜利",避免同时输赢的竞态; - 结束前停顿约 200ms,让最后一帧完整渲染完,避免视觉断裂;
- 胜者与败者显示不同提示消息;
- 以 Enter 键作为重启触发(符合"确认/开始"的认知习惯);
- 在文件顶部用
let gameLoopId;声明循环句柄,以便随时清除旧循环、干净地重置状态再开新局。
在最终解决方案 6-space-game/solution/app.js 中可看到等价机制:resetGame() 整体重建 state 对象并清空 bullets/enemies/particles 数组、复位玩家坐标,gameOver() 停表、持久化成绩并以暂停对话框承担"Restart"入口。
试玩最终成品与源码级扩展点
想直接体验整套六课成果,可打开 6-space-game/solution/index.html(配合同目录 app.js)运行完整游戏。从最终源码可以看到大量超越课程基础的工程化设计,可作为继续深造的扩展点:
- 响应式高清画布:按
window.devicePixelRatio缩放像素缓冲再以 CSS 像素绘制(见resizeCanvas()); - 参数化难度:
difficultyTable定义 easy/normal/hard/insane 四档的敌人生成间隔(enemyRate1.6→0.7)、速度(60→160)与最大敌人数(12→28); - 分级画质:
graphicsTable控制低/中/高/超高下的辉光、模糊与星空层数; - 双操控方案:WASD 与方向键通过
controlScheme切换,空格开火、Shift 释放消耗护盾的"清屏炸弹"; - 无障碍与 UI 细节:HTML 使用语义化
dialog、aria-label、role="status"、skip link,并提供设置对话框、音效(通过音频元素cloneNode实现低延迟叠音)与全屏支持; - 对外 API:通过
window.SpaceGame暴露start/pause/resume/reset/fullscreen/mute,便于外部扩展或自动化测试挂钩。
从 6-space-game/solution/spaceArt/ 的 20 余张飞船/激光/陨石 PNG 与 spritesheet.png 看,这套模块在六课教学之外已具备相当完整的素材管线。无论你是跟随课程逐步搭建,还是直接研读最终解决方案,本模块都提供了一条从"会写页面"到"能设计可扩展游戏架构"的渐进路径。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
