用 JavaScript 从零构建太空射击游戏:Web-Dev-For-Beginners 六课实战指南
【免费下载链接】Web-Dev-For-Beginners24 Lessons, 12 Weeks, Get Started as a Web Developer项目地址: https://gitcode.com/GitHub_Trending/we/Web-Dev-For-Beginners
本文以 Web-Dev-For-Beginners 仓库中「太空游戏(Space Game)」系列课程(即 translations/de/6-space-game/README.md 对应的英文原版 6-space-game/README.md)为核心展开。该系列用 6 节课、12 周的课程体系中一个完整可玩的游戏项目,带你掌握Canvas 绘图、笛卡尔坐标系、Pub/Sub 发布订阅架构、Async/Await 异步资源加载、键盘事件处理等进阶 JavaScript 基础。读完本文,你不仅能理解这套太空射击游戏(玩法灵感来自经典《太空侵略者》Space Invaders:驾驶飞船、射击从上方来袭的怪物)从静态画面到完整可玩游戏的全部实现路径,还能直接运行仓库中的 完整解决方案 进行验证。
一、课程全景:六课进阶路线
本系列遵循「理论先行、实践跟进」的教学结构,仓库 6-space-game/README.md 给出了清晰的学习地图:
- 理论
- 使用 JavaScript 构建游戏入门(继承、组合与 Pub/Sub 架构)
- 实践
- 在 Canvas 上绘制图形
- 让元素在屏幕上移动
- 碰撞检测
- 计分系统
- 游戏结束与重新开始
六节课环环相扣:先用第 1 课打牢对象组织与事件架构的地基,再用第 2~3 课实现「画出来 + 动起来」,第 4 课加入战斗与碰撞,第 5~6 课补全计分、生命、胜负判定与重开机制,最终形成完整游戏闭环。
二、Canvas 与笛卡尔坐标系:游戏世界的「画布」
第 2 课 2-drawing-to-canvas/README.md 讲解了 HTML5 Canvas 的绘图基础。Canvas 是浏览器内实现动态图形与动画的核心 API,通过 JavaScript 可以逐像素控制屏幕上的一切,非常适合游戏、数据可视化和交互式艺术。
2.1 画布的声明与坐标系
一个画布元素通常这样声明在页面 body 中:
<canvas id="myCanvas" width="200" height="100"></canvas>关键在于理解 Canvas 使用的坐标系:它基于数学课上学过的笛卡尔坐标系(x 轴水平、y 轴垂直),但原点(0,0)位于左上角,x 值向右递增、y 值向下递增。这与数学坐标系的差异源自早期显示器电子束自上而下的扫描方式。在完整解决方案 6-space-game/solution/index.html 中,游戏画布被设置为1024×768,所有实体坐标都以该逻辑尺寸为基准。
2.2 绘图的固定三步
在 Canvas 上绘制任何内容都遵循三步流程:
- 获取画布元素引用:
document.getElementById("myCanvas") - 获取 2D 渲染上下文:
canvas.getContext("2d"),它提供了全部绘图方法 - 开始绘制:使用上下文内置方法(
fillRect、arc、drawImage等)
// 第 1 步:获取画布元素 const canvas = document.getElementById("myCanvas"); // 第 2 步:获取 2D 渲染上下文 const ctx = canvas.getContext("2d"); // 第 3 步:设置填充色并绘制一个矩形(x, y, width, height) ctx.fillStyle = 'red'; ctx.fillRect(0, 0, 200, 200);Canvas 支持绘制几何形状、任意字体与颜色的文本、以及基于.jpg/.png等图片资源的图像。若需要 3D 能力,可进一步研究 WebGL API。
在 解决方案的渲染函数 中可以看到更完整的实践:clearRect清空画布、按星级分层绘制星空背景、用translate/rotate变换绘制旋转的敌舰与飞船、通过shadowBlur实现发光特效,并且针对高 DPI 屏幕用setTransform(dpr, 0, 0, dpr, 0, 0)做了缩放适配(见 resizeCanvas 函数)。
三、对象组织:继承与组合
第 1 课 1-introduction/README.md 指出:任何游戏都包含「游戏对象」(Game Objects)——英雄、敌人、道具、特效等交互元素。它们虽然视觉各异,却共享四类基本行为:
- 存在于某处:每个对象都有 x、y 坐标,游戏据此绘制
- 可以移动:英雄奔跑、敌人追击、子弹飞驰
- 拥有生命周期:有的常驻,有的(如爆炸特效)短暂出现后消失
- 对事件做出反应:碰撞发生时收集道具、更新血条
3.1 类与继承(Class-Based Approach)
以基础类承载公共属性,再派生子类添加特定能力:
// 第 1 步:创建基础 GameObject 类 class GameObject { constructor(x, y, type) { this.x = x; this.y = y; this.type = type; } } // 第 2 步:通过继承添加移动能力 class Movable extends GameObject { constructor(x, y, type) { super(x, y, type); // 调用父类构造函数 } moveTo(x, y) { this.x = x; this.y = y; } } // 第 3 步:创建具体游戏对象类型 class Hero extends Movable { constructor(x, y) { super(x, y, 'Hero'); // 自动设置类型 } } class Tree extends GameObject { constructor(x, y) { super(x, y, 'Tree'); // 树不需要移动能力 } } // 第 4 步:使用游戏对象 const hero = new Hero(0, 0); hero.moveTo(5, 5); // 英雄可以移动! const tree = new Tree(10, 15); // tree.moveTo() 会报错——树不能移动继承适合清晰的「is-a」关系(Hero 是 Movable 的一种),但层级僵化。第 3 课在 3-moving-elements-around/README.md 中给出了本项目的实际基类形态:GameObject携带x/y、dead标记、type、width/height、img,并提供draw(ctx)方法;Hero与Enemy通过extends GameObject继承。
3.2 组合与工厂函数(Composition Approach)
组合遵循模块化设计哲学,像工程师用可互换组件组装航天器一样,把独立行为对象拼装出恰好满足需求的对象:
// 第 1 步:创建基础行为对象 const gameObject = { x: 0, y: 0, type: '' }; const movable = { moveTo(x, y) { this.x = x; this.y = y; } }; // 第 2 步:通过展开语法组合对象 const movableObject = { ...gameObject, ...movable }; // 第 3 步:用工厂函数创建不同类型的对象 function createHero(x, y) { return { ...movableObject, x, y, type: 'Hero' }; } function createStatic(x, y, type) { return { ...gameObject, x, y, type }; } // 第 4 步:使用组合对象 const hero = createHero(10, 10); hero.moveTo(5, 5); // 正常工作 const tree = createStatic(0, 0, 'Tree'); // tree.moveTo() 为 undefined——没有组合移动行为组合适合「has-a」关系(Hero 拥有移动能力),更加灵活。实战中两种模式可以混用:第 1 课的自测题给出示例——飞行敌人用继承写是class FlyingEnemy extends Movable,用组合写则是{ ...movable, ...flyable, ...gameObject }。
四、Pub/Sub 发布订阅架构:解耦游戏组件通信
第 1 课的核心架构知识点是发布-订阅模式(Publish-Subscribe)。当英雄受伤时,血条要更新、音效要播放、画面要有反馈——若让英雄对象直接耦合这些系统,代码将难以维护。Pub/Sub 让英雄只广播一条「受到伤害」的消息,任何需要响应的系统订阅该消息即可,原理类似无线电广播:一个发射端可覆盖多个接收端,无需知道谁在收听。
4.1 关键角色
- 消息(Messages):如
'PLAYER_SCORED'这样的简单文本标签,描述发生了什么 - 发布者(Publishers):喊出「有事发生了!」的对象
- 订阅者(Subscribers):声明「我关心该事件」并在发生时响应的对象
- 事件系统(Event System):确保消息送达正确监听者的「中间人」
4.2 手写一个 EventEmitter
课程给出了一个简洁而强大的事件系统实现:
// 第 1 步:创建 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); }); } } }4.3 消息常量与键盘事件连接
用常量对象防止消息名拼写错误,并通过键盘事件驱动游戏逻辑:
// 第 1 步:定义消息类型 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); // 第 2 步:设置事件监听器(订阅者) eventEmitter.on(Messages.HERO_MOVE_LEFT, () => { hero.moveTo(hero.x - 5, hero.y); console.log(`Hero moved to position: ${hero.x}, ${hero.y}`); }); // 第 3 步:将键盘输入连接到事件(发布者) 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; } });该模式的收益体现在:新增音效、屏幕震动、粒子特效只需「多挂一个监听器」,无需改动既有键盘或移动代码;测试时每个组件可独立运行;出问题时能准确定位。课程给出的最佳实践包括:尽早以宽泛类别定义消息、使用描述性命名、保持 payload 简单聚焦、为团队文档化消息类型。
五、Async/Await 异步加载游戏资源
第 2 课指出:加载图片资源是异步的(在后台进行)。需要创建Image对象、设置src触发请求,待onload触发后再绘制到画布。课程的进阶方案是把图片加载包装成 Promise,用 ES6 的 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/await 的现代用法 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); } }在完整解决方案中同样采用此模式,将飞船、敌舰等素材加载后才启动游戏循环(见 6-space-game/solution/app.js 中bgImage的加载逻辑,L96-L105),并且支持玩家通过输入框提供自定义背景图 URL——设置img.crossOrigin = 'anonymous'以规避跨域污染问题。
六、键盘事件与游戏循环:让一切动起来
第 3 课 3-moving-elements-around/README.md 专注于「动起来」。
6.1 键盘事件处理
键盘事件发生在 window 层级(整个浏览器窗口监听按键),鼠标点击则可以绑定到具体元素。使用window.addEventListener()传入事件名(如keyup)和回调函数:
window.addEventListener('keyup', (evt) => { // evt.key = 按键的字符串表示 if (evt.key === 'ArrowUp') { // 执行操作 } });按键事件的两个关键属性:key(字符串,如'ArrowUp')与keyCode(数字,如37对应左箭头)。特别注意:方向键会滚动页面、空格键会跳页,必须用e.preventDefault()阻止浏览器默认行为,否则会干扰游戏操控:
const onKeyDown = function (e) { console.log(e.keyCode); switch (e.keyCode) { case 37: case 39: case 38: case 40: // 方向键 case 32: e.preventDefault(); break; // 空格 default: break; // 不拦截其他按键 } }; window.addEventListener('keydown', onKeyDown);在 解决方案的按键处理 中,课程实现更进一步:用keys集合记录持续按下的键、用keyMap同时支持 WASD 与方向键两套方案,并通过preventDefault阻止空格滚动页面;keyup时从集合移除,实现连续平滑移动。
6.2 游戏循环(Game Loop)
游戏循环是「把帧连起来」的引擎:更新所有对象位置 → 清屏 → 重绘 → 循环。课程用setInterval实现:
const gameLoopId = setInterval(() => { function gameLoop() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "black"; ctx.fillRect(0, 0, canvas.width, canvas.height); drawHero(); drawEnemies(); drawStaticObjects(); } gameLoop(); }, 200);帧率控制要点:60 FPS 对应约 16.67ms/帧,30 FPS 约 33.33ms/帧。而 解决方案的循环 采用更专业的requestAnimationFrame(loop)驱动,用dt = Math.min(0.033, (ts - state.lastTs) / 1000)计算每帧时间差并钳制在 33ms 以内,避免切页/失焦导致的跳帧;同时每 0.5 秒统计一次实时 FPS 显示在 HUD 上。
6.3 敌人自动移动
自动移动依赖 JavaScript 定时器,每 100ms 让敌人沿 y 轴下移 10 像素:
const id = setInterval(() => { enemy.y += 10; // 沿 y 轴移动敌人 }, 100);第 3 课还要求用GameObject基类重构(x/y、dead、type、width/height、img、draw(ctx)),Hero与Enemy继承之;Enemy构造器内用setInterval自主向下移动,到达画布底部时clearInterval停止并打印日志。
七、碰撞检测与激光武器:加入战斗系统
第 4 课 4-collision-detection/README.md 引入战斗机制。核心思路是把每个游戏对象当作矩形处理——计算效率高,适合绝大多数游戏场景。
7.1 矩形边界与相交算法
每个对象用位置加尺寸推导出四条边界:
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); }解决方案中的碰撞检测 提供了真实世界的印证:代码用圆形距离检测dist2(e.x, e.y, b.x, b.y) < (e.r + b.r)²判断子弹是否命中敌舰,命中后加分、生成爆炸粒子、移除敌舰;敌舰与玩家碰撞则扣减护盾值,护盾耗尽再扣生命。
7.2 对象生命周期:标记删除而非立即删除
在循环中途删除对象可能导致崩溃。正确做法是「标记为 dead,帧间再过滤」:
enemy.dead = true; // 标记删除,当前帧安全结束 gameObjects = gameObjects.filter(go => !go.dead); // 渲染前过滤7.3 激光与开火冷却
课程用Cooldown类 +Weapon类实现 500ms 开火冷却,防止无限连发:
class Cooldown { constructor(time) { this.cool = false; setTimeout(() => { this.cool = true; }, time); } } class Weapon { constructor() { this.cooldown = null; } fire() { if (!this.cooldown || this.cooldown.cool) { this.cooldown = new Cooldown(500); // 创建激光 } else { // 武器仍在冷却中 } } }Laser类继承GameObject,尺寸9×33,用setInterval每 100ms 上移 15 像素,触顶后自毁。课程要求实现四条碰撞规则:激光击中敌人(双方销毁)、激光触顶(移除)、敌人与英雄碰撞(双方销毁)、敌人到达底部(游戏结束)。解决方案中对应的开火实现 用fireCooldown = 0.15秒的帧间冷却,并借助sfxShoot?.cloneNode(true)?.play()实现低延迟重叠音效。
八、计分与生命:游戏反馈系统
第 5 课 5-keeping-score/README.md 聚焦让游戏「真正可玩」的反馈机制。
8.1 画布文本渲染
用fillText()在画布上绘制分数(左上角)与剩余生命(右下角,用飞船图标直观展示):
ctx.font = "30px Arial"; ctx.fillStyle = "red"; ctx.textAlign = "right"; ctx.fillText("show this on the screen", 0, 0);8.2 计分与生命的实现
游戏规则:每击毁一艘敌舰 +100 分(取整便于玩家心算);英雄初始 3 条生命,与敌人碰撞扣 1 条。核心方法挂在Hero上:
decrementLife() { this.life--; if (this.life === 0) { this.dead = true; // 生命耗尽,标记死亡 } } incrementPoints() { this.points += 100; }再通过事件系统串联:COLLISION_ENEMY_LASER时hero.incrementPoints(),COLLISION_ENEMY_HERO时hero.decrementLife()。绘制函数drawLife()按剩余生命循环绘制生命图标,drawPoints()在左下角渲染文本分数,二者在游戏循环中每次更新。
解决方案的 HUD 实现 走得更远:用语义化<output>元素与aria-live="polite"实时展示分数、等级、生命、护盾、FPS 与时长;updateHUD 函数 每帧同步这些 DOM 输出,其中护盾以百分比显示、时间格式化为MM:SS。
九、胜负判定与重新开始:完整的游戏闭环
第 6 课 6-end-condition/README.md 为游戏补上「终点」。常见的结束条件包括:摧毁 N 艘敌舰、己方飞船被毁(耗尽生命)、收集 N 分、完成关卡目标。
9.1 结束条件检测
function isHeroDead() { return hero.life <= 0; } function isEnemiesDead() { const enemies = gameObjects.filter((go) => go.type === "Enemy" && !go.dead); return enemies.length === 0; }9.2 事件化判定与提示消息
在碰撞事件处理器中先判定失败再判定胜利(避免「同时赢又输」):
eventEmitter.on(Messages.COLLISION_ENEMY_LASER, (_, { first, second }) => { first.dead = true; second.dead = true; hero.incrementPoints(); if (isEnemiesDead()) { eventEmitter.emit(Messages.GAME_END_WIN); } }); eventEmitter.on(Messages.COLLISION_ENEMY_HERO, (_, { enemy }) => { enemy.dead = true; hero.decrementLife(); if (isHeroDead()) { eventEmitter.emit(Messages.GAME_END_LOSS); return; // 失败优先于胜利 } if (isEnemiesDead()) { eventEmitter.emit(Messages.GAME_END_WIN); } });新增消息常量GAME_END_LOSS/GAME_END_WIN,并在keydown中监听 Enter 键发射KEY_EVENT_ENTER触发重开。胜利/失败用颜色编码提示:绿色表示胜利、红色表示失败:
function displayMessage(message, color = "red") { ctx.font = "30px Arial"; ctx.fillStyle = color; ctx.textAlign = "center"; ctx.fillText(message, canvas.width / 2, canvas.height / 2); } function endGame(win) { clearInterval(gameLoopId); setTimeout(() => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "black"; ctx.fillRect(0, 0, canvas.width, canvas.height); if (win) { displayMessage("Victory!!! Pew Pew... - Press [Enter] to start a new game Captain Pew Pew", "green"); } else { displayMessage("You died !!! Press [Enter] to start a new game Captain Pew Pew"); } }, 200); }9.3 重置系统与内存清理
resetGame()必须完整清理旧状态:清除游戏循环、清空事件监听器(为EventEmitter添加clear()方法,重置this.listeners = {})、调用initGame()重建对象、再启动新循环。否则会产生内存泄漏与重复事件处理器导致行为不可预测。
解决方案中的重置实现 更为彻底:整体重建 state 对象(分数、等级、生命、护盾、计时、冷却全部归零),清空子弹/敌人/粒子数组,重置玩家坐标,并同步刷新 HUD。
十、运行与验证:本地起服务看效果
每个课程子目录都准备了your-work(练习脚手架)与solution(参考答案)两套工程,包含assets/图片资源、index.html、app.js与package.json(内置轻量开发服务器)。启动方式统一为:
cd your-work npm start上述命令会在http://localhost:5000启动本地 HTTP 服务器并提供文件监听热更新。以第 2 课为例,your-work结构为:
your-work/ ├── assets/ │ ├── enemyShip.png │ └── player.png ├── index.html ├── app.js └── package.json第 2 课的动手任务(可对照 参考答案 app.js):① 绘制1024×768黑色背景;② 用loadAsset()加载玩家与敌舰贴图;③ 把英雄画在底部居中(canvas.width / 2 - 45, canvas.height - canvas.height / 4);④ 用嵌套循环排布 5×5 敌舰方阵。方阵布局常量如下:
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); } }完整版游戏位于 6-space-game/solution,其 app.js 汇集了全部进阶特性,可作为功能清单对照学习:
- 难度体系(difficultyTable):easy/normal/hard/insane 四档,分别调节敌舰生成速率(1.6→0.7 秒)、移动速度(60→160)与最大敌舰数(12→28)
- 画质分级(graphicsTable):low/medium/high/ultra 控制发光特效、模糊与星空图层数
- 多套操控方案:WASD 与方向键二选一,空格开火、Shift 释放消耗 25 点护盾的「清屏炸弹」(冷却 6 秒)、P 暂停、F 全屏
- 等级进度:每存活 30 秒自动升级(
1 + Math.floor(state.timeSec / 30)) - 本地排行榜:游戏结束时把分数写入
localStorage(键名spaceGameScores),取前 10 名渲染到排行榜(见 gameOver 与 renderScores) - 无障碍与可访问性:index.html 采用语义化 HTML、
aria-label、aria-live、键盘焦点样式,并支持<dialog>原生对话框做设置与暂停界面
十一、素材来源与致谢
本课程使用的全部游戏素材(飞船、敌舰、激光、生命图标、爆炸特效等)来自 Kenney.nl,第 4 课的挑战题正是利用这些素材为「激光击中外星人」添加爆炸特效。
结语
这套太空游戏课程的价值在于:它不是孤立地讲某个 API,而是把**对象组织(继承/组合)、事件驱动(Pub/Sub)、异步加载(Async/Await)、输入处理(键盘事件)、实时渲染(Canvas + 游戏循环)、物理判定(碰撞检测)、反馈设计(计分/生命)、状态管理(结束/重开)**这些软件工程通用概念,编织进一个从零到完整可玩游戏的真实项目中。完成六课学习并对照 solution 源码,你收获的不仅是「做出一个射击游戏」,更是一套可迁移到任意 Web 应用的可扩展架构能力。
【免费下载链接】Web-Dev-For-Beginners24 Lessons, 12 Weeks, Get Started as a Web Developer项目地址: https://gitcode.com/GitHub_Trending/we/Web-Dev-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考