Web-Dev-For-Beginners 项目实战:用 JavaScript 从零构建太空射击游戏(Space Game)
导读:本篇技术指南围绕 Web-Dev-For-Beginners 开源课程中的 6-space-game 模块(对应 translations/bg/6-space-game/README.md 及其英文原文 6-space-game/README.md)展开,解析该模块"用六节课打造一款类 Space Invaders 浏览器游戏"的教学路径。游戏的目标是操控一艘太空飞船,向上射击不断下压的怪物。读完本文,你将掌握本模块所承载的进阶 JavaScript 核心能力:操作 HTML5 Canvas 绘制画面、理解直角坐标系、用发布-订阅(Pub/Sub)模式搭建松耦合的游戏架构、用 Async/Await 加载游戏资源,以及处理键盘事件驱动游戏交互。
模块概览:为什么用"做游戏"来教进阶 JavaScript
6-space-game 是 Web-Dev-For-Beginners 课程中篇幅较大的一个模块,它不满足于语法讲解,而是把 24 节课中的进阶 JavaScript 主题浓缩进一个可运行、可玩耍、可反复扩展的真实项目里。模块的英文原文与多语言翻译(包括保加利亚语 translations/bg/6-space-game/README.md)结构一致,官方将其定位为:
A space game to teach more advanced JavaScript fundamentals(一款用来教授更进阶 JavaScript 基础知识的太空游戏)
如果你玩过经典街机游戏 "Space Invaders",就很好理解本项目的玩法设定——操控飞船左右移动,朝从上方压下来的怪物开火。下面是 6-space-game/images/pewpew.gif 展示的成品效果:
完成整个模块后,你将依次获得以下五类核心能力(与文档列出的学习目标一一对应):
| 编号 | 学习目标 | 对应技术主题 | 落地课节 |
|---|---|---|---|
| 1 | Interact(交互) | 使用 Canvas 元素在屏幕上绘制图形 | 第 2 课:绘制到画布 |
| 2 | Understand(理解) | 笛卡尔(直角)坐标系与屏幕坐标差异 | 第 2 课 |
| 3 | Learn(学习) | 发布-订阅(Pub/Sub)模式,构建易维护、易扩展的健壮架构 | 第 1、3 课 |
| 4 | Leverage(利用) | Async/Await 异步加载游戏资源 | 第 2 课 |
| 5 | Handle(处理) | 键盘事件处理 | 第 3 课 |
模块结构:理论 + 实践的分层路线
模块内置"理论先导、实践跟进"的课程编排(以下链接均已换算为仓库根目录相对路径):
- 理论(Theory)
- 实践(Practice)
保加利亚语读者可以直接在 translations/bg/6-space-game/ 下找到与上面各节一一对应的翻译版课程,例如 translations/bg/6-space-game/1-introduction/README.md、translations/bg/6-space-game/4-collision-detection/README.md。
素材与授权
游戏所用美术素材来自 kenney.nl 的开放素材库(模块 README 的 Credits 部分注明),这是一个对游戏开发者非常友好的资源站:大量素材免费、部分收费。各课的 assets/ 目录与模块级 6-space-game/solution/spaceArt/ 中即可找到飞船、敌人、激光等精灵图。
第 1 课:对象组织——继承 vs 组合,以及 Pub/Sub 通信
游戏世界中的可交互元素(英雄、敌人、道具、特效)统称为游戏对象(Game Object)。尽管外观各异,它们共享四类基础行为:存在于某个位置(拥有 x/y 坐标)、多数可以移动、有生命周期(有的常驻,有的如爆炸转瞬即逝)、会对事件做出反应(碰撞、拾取、扣血)。
用类与继承表达行为
第 1 课正文用"生物分类学"类比继承:从携带公共属性的基类出发,派生出各具能力的子类。
// Step 1: 创建基础 GameObject 类
class GameObject {
constructor(x, y, type) {
this.x = x;
this.y = y;
this.type = type;
}
}
// Step 2: 通过继承加入移动能力
class Movable extends GameObject {
constructor(x, y, type) {
super(x, y, type); // 调用父类构造函数
}
moveTo(x, y) {
this.x = x;
this.y = y;
}
}
// Step 3: 派生具体对象类型
class Hero extends Movable {
constructor(x, y) {
super(x, y, 'Hero'); // 自动设置类型
}
}
class Tree extends GameObject {
constructor(x, y) {
super(x, y, 'Tree'); // 树不需要移动能力
}
}
// Step 4: 使用游戏对象
const hero = new Hero(0, 0);
hero.moveTo(5, 5); // Hero 可以移动
const tree = new Tree(10, 15);
// tree.moveTo() 会报错——树不能移动
要点:super() 负责初始化继承的属性;类层级本身就能"防止不恰当的行为"——Tree 根本没有 moveTo,从类型系统层面杜绝误用。
用组合与工厂函数表达行为
组合遵循"模块化设计"哲学:把独立行为(position 数据、move 逻辑)拆成互不依赖的对象,再按需拼接。
// Step 1: 基础行为对象
const gameObject = { x: 0, y: 0, type: '' };
const movable = {
moveTo(x, y) { this.x = x; this.y = y; }
};
// Step 2: 用展开语法拼接
const movableObject = { ...gameObject, ...movable };
// Step 3: 工厂函数
function createHero(x, y) {
return { ...movableObject, x, y, type: 'Hero' };
}
function createStatic(x, y, type) {
return { ...gameObject, x, y, type };
}
// Step 4: 使用
const hero = createHero(10, 10);
hero.moveTo(5, 5); // 正常工作
const tree = createStatic(0, 0, 'Tree');
// tree.moveTo 是 undefined——没有组合进移动行为
何时选哪种(课程给出的判断标准):当对象间存在清晰的 "is-a"(是一种) 关系时用继承(Hero is-a Movable);当对象需要的是 "has-a"(拥有) 关系时用组合(Hero has 移动能力)。两种模式可以在同一项目内混用。
通信模式:亲手实现一个 EventEmitter
当"英雄受伤"时,血条、音效、视觉特效都要响应。若让英雄直接引用这些系统,代码会高度耦合。Pub/Sub(publish-subscribe,发布-订阅)的核心参与者有四类:Message(消息,如 'PLAYER_SCORED')、Publisher(发布者)、Subscriber(订阅者)、Event System(事件系统,转发的中介)。课程给出了一个可立即复用的最小实现:
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',
ENEMY_SPOTTED: 'ENEMY_SPOTTED'
};
const eventEmitter = new EventEmitter();
const hero = createHero(0, 0);
eventEmitter.on(Messages.HERO_MOVE_LEFT, () => {
hero.moveTo(hero.x - 5, hero.y);
});
window.addEventListener('keydown', (event) => {
switch (event.key) {
case 'ArrowLeft': eventEmitter.emit(Messages.HERO_MOVE_LEFT); break;
case 'ArrowRight': eventEmitter.emit(Messages.HERO_MOVE_RIGHT); break;
}
});
这样做的价值在于:想加音效、震屏、粒子特效,只需新增订阅者,键盘与移动逻辑完全不用改动——这正是后几节课持续受益的架构基础。课程提醒一个常见误区:初期不要定义过多过细的消息类型,先用宽泛类别,等需求清晰后再细化。
第 2 课:Canvas 绘图、屏幕坐标系与异步加载素材
Canvas 三要素与坐标系
<canvas> 是 HTML5 提供的一块"可编程画布",区别于静态图片,它允许对每个像素施加控制。声明方式极简:<canvas id="myCanvas" width="200" height="100"></canvas>。绘图的固定三步流程是:拿到元素引用 → 取得 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 向下增大(源自早期显示器自上而下的电子束扫描方式),与数学课上的坐标系正好上下颠倒。下图即课程中的坐标系网格示意图:
用 Async/Await 优雅地加载图片素材
几何图形只是起步,游戏真正需要的是精灵图。图片加载是异步的,课程给出两种写法:基础的 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 renderGameScreen() {
try {
const heroImg = await loadAsset('hero.png');
const monsterImg = await loadAsset('monster.png');
const canvas = document.getElementById("myCanvas");
const ctx = canvas.getContext("2d");
ctx.drawImage(heroImg, canvas.width / 2, canvas.height / 2);
ctx.drawImage(monsterImg, 0, 0);
} catch (error) {
console.error('Failed to render game screen:', error);
}
}
异步加载的意义在于"在后台加载,不阻塞应用主流程",配合 try/catch 让任何加载失败都可被优雅捕获。
实战:布置英雄与 5×5 敌机方阵
本课的目标页面是 1024×768 的黑色 Canvas,居中底部绘制英雄飞船,顶部绘制 5×5 敌机方阵。课程先给出布局常量:ENEMY_TOTAL = 5、ENEMY_SPACING = 98、FORMATION_WIDTH = ENEMY_TOTAL * ENEMY_SPACING,再用 START_X = (canvas.width - FORMATION_WIDTH) / 2 让方阵水平居中;随后用双层嵌套循环均匀排布 25 个敌人:
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);
}
}
外循环负责横向逐列、内循环负责纵向逐行,配合常量计算即可得到整洁专业的方阵布局。完成效果参见课程预期截图:
第 3 课:加入运动——游戏循环、键盘事件与敌人自主移动
静态画面只是截图,本课把"翻页动画"原理搬进代码:更新位置 → 擦除旧帧 → 绘制新帧,足够快地循环即形成流畅运动。
游戏循环与基于对象的重构
课程将渲染主体改造为 GameObject 基类 + 子类结构(对应第 3 课 6-space-game/3-moving-elements-around/README.md 中的逐步代码),基类携带位置、尺寸、存活标记与图片,并统一提供 draw(ctx):
class GameObject {
constructor(x, y) {
this.x = x; this.y = y;
this.dead = false; this.type = "";
this.width = 0; this.height = 0;
this.img = undefined;
}
draw(ctx) {
ctx.drawImage(this.img, this.x, this.y, this.width, this.height);
}
}
Hero 与 Enemy 均继承自它。Enemy 的构造函数直接在内部用 setInterval 驱动"自动下降":每隔 300ms 向下移动 5px,一旦 this.y >= canvas.height - this.height 就停止并 clearInterval 清理定时器——这是**游戏自主移动(game-induced movement)**的示范。
游戏循环则由 window.onload 启动:先 await loadTexture(...) 依次加载玩家、敌机与激光贴图,再调用 initGame(),最后以 setInterval(示例为 100ms,即 10 FPS)驱动"清屏 → 刷黑背景 → 重绘所有对象":
window.onload = async () => {
canvas = document.getElementById("canvas");
ctx = canvas.getContext("2d");
heroImg = await loadTexture("assets/player.png");
enemyImg = await loadTexture("assets/enemyShip.png");
laserImg = await loadTexture("assets/laserRed.png");
initGame();
const gameLoopId = setInterval(() => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGameObjects(ctx);
}, 100);
};
键盘事件与默认行为拦截
键盘事件挂在 window 级别(鼠标点击才绑定具体元素)。事件对象上有两个可用属性:key(字符串,如 'ArrowUp')与 keyCode(数字,如 37 对应左箭头)。方向键会滚动页面、空格键会跳页——这些浏览器默认行为必须被拦截,否则会干扰操控:
const onKeyDown = function (e) {
switch (e.keyCode) {
case 37: case 39: case 38: case 40: // 方向键
case 32: // 空格
e.preventDefault();
break;
default:
break; // 不拦截其他按键
}
};
window.addEventListener("keydown", onKeyDown);
随后把"按键 → 游戏逻辑"接入第 1 课实现的 EventEmitter:keyup 监听器把方向键翻译成 KEY_EVENT_UP / DOWN / LEFT / RIGHT 四条消息,再由 initGame() 中注册的订阅者真正去修改 hero.x / hero.y(步进 5px)。这样输入检测与游戏逻辑彻底解耦,后续改键位或加输入方式都不触碰游戏本体。
第 4 课:激光武器与碰撞检测——矩形相交算法与对象生命周期
把每个对象当作矩形:分离测试法
碰撞检测的高效思路是把所有游戏对象简化为边界矩形(课程类比空中交通管制用简化几何形状追踪飞机)。每个对象依据 (x, y) 与 (width, height) 推导出四条边界:
rectFromGameObject() {
return {
top: this.y,
left: this.x,
bottom: this.y + this.height,
right: this.x + this.width
};
}
两矩形是否碰撞,用分离测试(比直接求交更高效):只要"r2 完全在 r1 右侧 / 左侧 / 下方 / 上方"任一成立,就必然不相交;反之必然重叠。
function intersectRect(r1, r2) {
return !(r2.left > r1.right ||
r2.right < r1.left ||
r2.top > r1.bottom ||
r2.bottom < r1.top);
}
对象生命周期:"打标记、再清除",切忌循环内即时删除
激光命中敌机后两者都要移除,但在遍历循环中直接删除对象会引发崩溃或跳过元素。正确姿势是两段式:碰撞发生时仅把 enemy.dead = true 打上标记(当前帧安全跑完),在下一轮渲染前用 filter 统一过滤:
gameObjects = gameObjects.filter(go => !go.dead);
规则清单、冷却机制与激光类
本课需实现的四条游戏规则:激光击中敌机(双双销毁);激光飞出屏幕上边界(自我清除);敌机与英雄相撞(双双销毁);敌机压到屏幕底部(游戏结束)。
为防止无脑连发破坏平衡,Hero 增加**冷却(Cooldown)**机制:canFire() 检查 cooldown === 0,fire() 生成新激光并把冷却置为 500ms,由 setInterval 每 200ms 递减 100ms 直到归零。激光自身是一个 Laser extends GameObject,宽 9px、高 33px,在构造函数里用 setInterval 每 100ms 上移 15px,触顶即自毁:
class Laser extends GameObject {
constructor(x, y) {
super(x, y);
this.width = 9; this.height = 33;
this.type = 'Laser'; this.img = laserImg;
let id = setInterval(() => {
if (this.y > 0) {
this.y -= 15;
} else {
this.dead = true;
clearInterval(id);
}
}, 100);
}
}
碰撞检测集中在一个 updateGameObjects() 中:先把对象按类型筛出 enemies 与 lasers,对两者做双重遍历 + intersectRect 判断,命中即通过事件系统广播 COLLISION_ENEMY_LASER(载荷携带 { first, second } 两个对象),最后由订阅者统一置 dead = true。务必记得把 updateGameObjects() 挂进主游戏循环,碰撞系统才会逐帧生效。
第 5 课:计分与生命——用 fillText 给玩家即时反馈
好游戏需要即时反馈。本课引入 Canvas 文本渲染 fillText() 来显示分数,并借此讲解"生命"这一核心游戏设计概念(源自弹球机/雅达利街机的容错设计)。文本完全可控:字体、颜色、对齐方式均可指定:
ctx.font = "30px Arial";
ctx.fillStyle = "red";
ctx.textAlign = "right";
ctx.fillText("show this on the screen", 0, 0);
在事件驱动架构下,分数与生命的增减不应散落各处,而是由"敌人被击毁""英雄被撞"等事件触发集中式的状态更新,继续沿用前几课的消息常量与订阅模式,保持代码模块化。(详细步骤见 6-space-game/5-keeping-score/README.md。)
第 6 课:结束条件与重开机制——让游戏"有始有终"
课程把游戏设计中的经典结束条件归纳为四类(详见 6-space-game/6-end-condition/README.md):
- 摧毁 N 架敌机:常用于关卡制游戏的分段胜利判定;
- 飞船被毁 / 生命耗尽:可引入生命值概念,每被摧毁一次扣一条命,清零则失败;
- 集满 N 分:把得分关联到击毁敌机或拾取掉落物等活动;
- 通关:通常是"X 架敌机 + Y 分 + 特定道具"等条件的组合。
重开机制的要点是"平滑 + 无泄漏":胜利/失败 → 显示对应消息并提示按 Enter → 进入重置流程时清理所有 interval 与事件监听器(避免内存泄漏与重复触发)→ 重新初始化游戏状态。只有处理好对象生命周期与状态迁移,游戏才能被玩家反复游玩而保持稳定。
工程参照:模块级完整方案(solution)
若想对照一份"跳出教学脚手架、面向完整产品"的实现,可直接研读模块根目录的 6-space-game/solution/app.js(657 行,配套 index.html)。从源码结构看,它把课程中的思想扩展成了带完整 HUD 的工程化版本:
- 画布与 UI 解耦:通过
$/$$封装获取#myCanvas、开始/暂停/重置/静音/全屏按钮以及分数、关卡、生命、护盾、FPS 等输出节点; - 参数化难度与画质:内置
difficultyTable(easy/normal/hard/insane,对应敌机生成频率、移动速度与最大数量)与graphicsTable(low/medium/high/ultra,控制辉光、模糊、星空图层数等渲染开销); - 可配置输入与音频:控制方案支持
wasd/arrows/custom,并管理射击音效、爆炸音效与背景音乐的音量。
在动手实现各课时,这套 solution 可作为功能与架构的最终参照(游戏素材集中在 6-space-game/solution/spaceArt/ 与 spritesheet.png)。需要注意的是,各课 your-work/ 起步包(含 index.html、app.js、package.json 与 assets/)才是你编码的起点,运行方式统一为在该目录执行 cd your-work && npm start,随后浏览器访问 http://localhost:5000 即可调试——此时页面应当渲染出英雄与全部敌人,但一切静止,等待你在后续步骤中赋予它们生命。
小结
6-space-game 模块把六节课串成一条完整的进阶主线:以对象组织(继承/组合)与 Pub/Sub 事件架构为地基,以 Canvas 渲染 + 异步资源加载为视觉层,以游戏循环 + 键盘事件为交互骨架,再以矩形碰撞检测与对象生命周期管理构筑战斗玩法,最终由计分、生命与结束/重开机制收束为可反复游玩的完整游戏。这些能力并不局限于游戏开发——事件驱动架构、异步资源管理与生命周期安全清理,正是复杂 Web 应用(前端框架状态管理、后端微服务通信、移动端通知系统)共通的工程素养。学完本模块,你完全可以带着同样的架构思路,去构建任何需要"实时更新 + 响应输入 + 可扩展"的浏览器应用。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00


