首页
/ Web-Dev-For-Beginners 项目实战:用 JavaScript 从零构建太空射击游戏(Space Game)

Web-Dev-For-Beginners 项目实战:用 JavaScript 从零构建太空射击游戏(Space Game)

2026-09-07 15:05:18作者:宣海椒Queenly

导读:本篇技术指南围绕 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-gameWeb-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 课

模块结构:理论 + 实践的分层路线

模块内置"理论先导、实践跟进"的课程编排(以下链接均已换算为仓库根目录相对路径):

保加利亚语读者可以直接在 translations/bg/6-space-game/ 下找到与上面各节一一对应的翻译版课程,例如 translations/bg/6-space-game/1-introduction/README.mdtranslations/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 向下增大(源自早期显示器自上而下的电子束扫描方式),与数学课上的坐标系正好上下颠倒。下图即课程中的坐标系网格示意图:

Canvas 坐标系网格示意图,标注原点位于左上角

用 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 = 5ENEMY_SPACING = 98FORMATION_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);
  }
}

外循环负责横向逐列、内循环负责纵向逐行,配合常量计算即可得到整洁专业的方阵布局。完成效果参见课程预期截图:

黑色游戏画面上绘制了英雄飞船与 5×5 敌机方阵的完成效果

第 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);
  }
}

HeroEnemy 均继承自它。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 === 0fire() 生成新激光并把冷却置为 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() 中:先把对象按类型筛出 enemieslasers,对两者做双重遍历 + 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 等输出节点;
  • 参数化难度与画质:内置 difficultyTableeasy/normal/hard/insane,对应敌机生成频率、移动速度与最大数量)与 graphicsTablelow/medium/high/ultra,控制辉光、模糊、星空图层数等渲染开销);
  • 可配置输入与音频:控制方案支持 wasd / arrows / custom,并管理射击音效、爆炸音效与背景音乐的音量。

在动手实现各课时,这套 solution 可作为功能与架构的最终参照(游戏素材集中在 6-space-game/solution/spaceArt/spritesheet.png)。需要注意的是,各课 your-work/ 起步包(含 index.htmlapp.jspackage.jsonassets/)才是你编码的起点,运行方式统一为在该目录执行 cd your-work && npm start,随后浏览器访问 http://localhost:5000 即可调试——此时页面应当渲染出英雄与全部敌人,但一切静止,等待你在后续步骤中赋予它们生命。

小结

6-space-game 模块把六节课串成一条完整的进阶主线:以对象组织(继承/组合)与 Pub/Sub 事件架构为地基,以 Canvas 渲染 + 异步资源加载为视觉层,以游戏循环 + 键盘事件为交互骨架,再以矩形碰撞检测与对象生命周期管理构筑战斗玩法,最终由计分、生命与结束/重开机制收束为可反复游玩的完整游戏。这些能力并不局限于游戏开发——事件驱动架构、异步资源管理与生命周期安全清理,正是复杂 Web 应用(前端框架状态管理、后端微服务通信、移动端通知系统)共通的工程素养。学完本模块,你完全可以带着同样的架构思路,去构建任何需要"实时更新 + 响应输入 + 可扩展"的浏览器应用。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389