首页
/ Web-Dev-For-Beginners 太空射击游戏实战:用 6 节课系统掌握 Canvas 与高级 JavaScript 游戏架构

Web-Dev-For-Beginners 太空射击游戏实战:用 6 节课系统掌握 Canvas 与高级 JavaScript 游戏架构

2026-09-06 18:20:25作者:贡沫苏Truman

本文以 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.pngplayer.png)、index.htmlapp.jspackage.json。在本地安装 Node.js 与 npm 后,进入该目录执行:

cd your-work
npm start

即可在 http://localhost:5000 打开开发服务器(支持文件热监听)。其实现要点是:

  1. 黑色背景只需两行代码:ctx.fillStyle = 'black' 后调用 ctx.fillRect(0, 0, canvas.width, canvas.height)
  2. await loadAsset() 分别加载玩家与敌人贴图并缓存到变量;
  3. 英雄居中偏下:ctx.drawImage(heroImg, canvas.width / 2 - 45, canvas.height - canvas.height / 4)
  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.jsgameOver() 会将 { initials, score, t } 写入 localStoragespaceGameScores,排序后保留前 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 四档的敌人生成间隔(enemyRate 1.6→0.7)、速度(60→160)与最大敌人数(12→28);
  • 分级画质graphicsTable 控制低/中/高/超高下的辉光、模糊与星空层数;
  • 双操控方案:WASD 与方向键通过 controlScheme 切换,空格开火、Shift 释放消耗护盾的"清屏炸弹";
  • 无障碍与 UI 细节:HTML 使用语义化 dialogaria-labelrole="status"、skip link,并提供设置对话框、音效(通过音频元素 cloneNode 实现低延迟叠音)与全屏支持;
  • 对外 API:通过 window.SpaceGame 暴露 start/pause/resume/reset/fullscreen/mute,便于外部扩展或自动化测试挂钩。

6-space-game/solution/spaceArt/ 的 20 余张飞船/激光/陨石 PNG 与 spritesheet.png 看,这套模块在六课教学之外已具备相当完整的素材管线。无论你是跟随课程逐步搭建,还是直接研读最终解决方案,本模块都提供了一条从"会写页面"到"能设计可扩展游戏架构"的渐进路径。

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

项目优选

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