Web-Dev-For-Beginners 太空游戏第 5 课:用 Canvas 实现计分与生命系统(Scoring and Lives)
2026/9/10 19:35:05 网站建设 项目流程

Web-Dev-For-Beginners 太空游戏第 5 课:用 Canvas 实现计分与生命系统(Scoring and Lives)

【免费下载链接】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)》系列第 5 课为蓝本(英文原文位于 6-space-game/5-keeping-score/README.md),讲解如何为基于 Canvas 的射击游戏加入计分(Scoring)生命(Lives)两大反馈系统。读完本文,你将掌握fillText()画布文字渲染、游戏状态(分数/生命)管理、事件驱动的碰撞反馈机制,并能够独立把"能打能撞"的演示程序升级为拥有即时奖励与惩罚的"真正可玩"游戏。

本课对应仓库中的实操目录为 6-space-game/5-keeping-score/your-work(学生起点)与 solution(完整参考答案),本文中的全部代码均可在这两个目录的app.js中逐行验证。

本课在课程体系中的位置

整个太空游戏系列共 6 课,循序渐进地覆盖"用 JavaScript 构建游戏"的进阶语法与设计思想:

  • 第 1 课 游戏与 JavaScript 入门
  • 第 2 课 Canvas 绘制
  • 第 3 课 元素移动
  • 第 4 课 碰撞检测
  • 第 5 课 计分与生命(本文)
  • 第 6 课 游戏结束与重开

进入第 5 课时,游戏已经具备:玩家飞船的键盘移动(方向键)与射击(空格键,带 500ms 冷却)、敌人下落动画,以及"激光击中敌人即双方标记为死亡"的碰撞处理(见 your-work/app.js)。但它仍然缺少游戏的"灵魂"——玩家看不到自己的表现,也没有任何后果。本课要解决的正是"如何让玩家感知并影响游戏进程"这一核心问题,正如原文所强调的:计分与生命机制是让《太空入侵者(Space Invaders)》这类早期街机游戏从"简单演示"变成"令人上瘾的娱乐"的关键。

在画布上绘制文字——游戏的声音

要在画布上显示分数,首先要学会在 Canvas 上渲染文字。HTML 中的<h1><p>等元素由浏览器排版引擎渲染;而 Canvas 上的文字则是"绘制"出来的像素,使用 Canvas 2D API 的fillText()方法。原文指出,这正是经典街机游戏显示分数和状态信息的同一套技术。

fillText(message, x, y)的完整外观由三组上下文属性共同决定,你拥有完全控制权:

ctx.font = "30px Arial"; // 字体族 + 字号 ctx.fillStyle = "red"; // 填充颜色 ctx.textAlign = "right"; // 对齐方式: left / right / center ctx.fillText("show this on the screen", 0, 0); // 内容 + (x, y) 坐标

理解这四个要素即可组合出任意文字样式;本课后面绘制分数时就会复用这套 API。在 solution/app.js 中,分数显示正是这样实现的:

function drawPoints() { ctx.font = '30px Arial'; ctx.fillStyle = 'red'; ctx.textAlign = 'left'; drawText('Points: ' + hero.points, 10, canvas.height - 20); } function drawText(message, x, y) { ctx.fillText(message, x, y); }

可以观察到两个细节:一是把fillText再包一层drawText()函数,方便统一定义"如何书写一段文字",符合组件化/复用思想;二是坐标并非写死,而是基于canvas.height - 20计算——Canvas 坐标系以左上角为原点、y 轴向下增长,因此canvas.height - 20表示贴近画布底部的横向文本基线。而本课使用的画布尺寸定义在 index.html:<canvas id="canvas" width="1024" height="768">

生命——不仅仅是数字

在游戏设计中,"生命(life)"代表玩家"允许犯错"的余量。这个概念可以追溯到弹球机(pinball)——玩家拥有多个球的机会;而在《爆破彗星(Asteroids)》等早期游戏中,生命给了玩家冒险与试错的许可。

视觉呈现至关重要。原文特别强调:与其显示文本"Lives: 3",不如直接绘制飞船图标——早期街机框体正是借助图标化(iconography)来跨越语言障碍进行沟通。本课采用"用drawImage把小飞船贴图循环贴出 N 份"的方式呈现剩余生命:

function drawLife() { // TODO, 35, 27 ← 注释提示生命图标本身是 35×27 像素 const START_POS = canvas.width - 180; for (let i = 0; i < hero.life; i++) { ctx.drawImage(lifeImg, START_POS + 45 * (i + 1), canvas.height - 37); } }

其中lifeImg就是仓库中的(35×27 像素的红色小飞船贴图)。循环从canvas.width - 180起步、每个图标横向间隔 45 像素,三份生命恰好排列在画面右下角,无需任何布局框架即可完成"信息层级清晰"的 HUD 设计。

构建游戏的奖励系统

在动笔之前,先明确本课要实现的反馈系统规格(这也是 solution/app.js 中能被逐行验证的行为契约):

子系统规则界面位置
计分系统每击毁一架敌机 +100 分(使用整百数字便于玩家心算)左下角Points: N
生命计数器英雄初始 3 条命(早期街机平衡挑战性与可玩性的标准值);与敌机相撞扣 1 条右下角,用飞船图标逐艘展示

设计要点:用整百计分不仅方便心算,还能形成"每次命中都有确定的、可预期的满足感",这是心理学层面的"即时正反馈";将分数放在左下、生命放在右下,保持 UI 位置的一致性,玩家视线无需搜索即可定位关键信息。

环境准备与启动开发服务器

进入实操前,先确认工作区。学生起点目录your-work中的文件结构与第 4 课结束时一致:

-| assets -| enemyShip.png // 敌人贴图 -| player.png // 英雄贴图 -| laserRed.png // 激光贴图 -| index.html // 页面入口,含 1024×768 canvas -| app.js // 全部游戏逻辑 -| package.json

启动本地开发服务器并预览游戏:

cd your-work npm start

npm start实际执行的是npx http-server -c-1 -p 5000(见 your-work/package.json),随后在浏览器打开http://localhost:5000即可游玩。用方向键移动、空格射击敌人,确认前几课的功能均正常后,再开始本课的编码改造。

实战编码:为英雄加上分数与生命

下面按依赖关系逐步改造your-work/app.js(完整目标实现见 solution/app.js)。

第 1 步:复制生命贴图并加入资源加载链

从 solution/assets 目录把life.png复制到自己的assets/中,然后在window.onload中加载它:

lifeImg = await loadTexture('assets/life.png');

同时在顶层资源声明列表中补上lifeImg(与heroImgenemyImglaserImg并列,由loadTexture()返回的Promise<Image>填充):

let heroImg, enemyImg, laserImg, lifeImg, canvas, ctx, gameObjects = [], hero, eventEmitter = new EventEmitter();

需要说明的是loadTexture是一个异步加载器,它创建Image对象、设置src并等待onload后才resolve(your-work/app.js);因此资源加载只能发生在window.onload = async () => {...}内部并通过await等待——这是对课程第 2 课"Async/Await 加载资源"知识的复用。

第 2 步:初始化游戏状态变量

游戏变量本质上就是记录"玩家当前处境"的状态。在Hero类的构造函数中、this.cooldown = 0下方加入:

this.life = 3; // 初始三条命 this.points = 0; // 初始零分

之所以放在Hero上而非全局变量,是因为从架构上看Hero是这些状态的唯一属主(controller/model 归属),绘制函数与事件处理器统一通过hero.life/hero.points读取,保证"单一数据源"。

第 3 步:扩展碰撞检测——让"撞机"成为事件

在第 4 课中,updateGameObjects()已经处理了"激光 vs 敌人"。本课需要新增"英雄 vs 敌人"的检测分支。注意两个对象相交的判定复用了同一个矩形相交函数intersectRect()(其原理是判断两个轴对齐矩形在 x、y 方向是否重叠),而每个对象通过rectFromGameObject()把自身位置与宽高换算成{ top, left, bottom, right }矩形:

enemies.forEach((enemy) => { const heroRect = hero.rectFromGameObject(); if (intersectRect(heroRect, enemy.rectFromGameObject())) { eventEmitter.emit(Messages.COLLISION_ENEMY_HERO, { enemy }); } });

这里遵循了全系列一贯的发布/订阅(Pub-Sub)架构:检测逻辑只负责"发现问题并发布事件",并不关心后果如何执行。为此还需在Messages常量表中注册新事件名:

const Messages = { // ...KEY_EVENT_* 等既有消息 COLLISION_ENEMY_LASER: 'COLLISION_ENEMY_LASER', // 既有 COLLISION_ENEMY_HERO: 'COLLISION_ENEMY_HERO', // 本课新增 };

第 4 步:在游戏循环中接入 HUD 绘制

Hero类与全局作用域分别实现三个绘制函数(定义见上文的drawLifedrawPointsdrawText),然后在游戏主循环中,紧跟updateGameObjects()之后调用它们——注意顺序:先更新逻辑、再绘制界面,确保分数与生命永远反映"本帧已结算"后的最新状态:

window.onload = async () => { canvas = document.getElementById('canvas'); ctx = canvas.getContext('2d'); // ... await 加载四张贴图 initGame(); let gameLoopId = setInterval(() => { ctx.clearRect(0, 0, canvas.width, canvas.height); // 清屏 ctx.fillStyle = 'black'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 刷黑背景 drawPoints(); // 左下角分数 drawLife(); // 右下角生命图标 updateGameObjects(); // 状态更新(含碰撞结算) drawGameObjects(ctx); // 重绘所有存活对象 }, 100); // 100ms 一轮 ≈ 10 FPS };

完整实现见 solution/app.js。drawGameObjects(ctx)内部用gameObjects.forEach((go) => go.draw(ctx))把所有对象的贴图按自身坐标画一遍;被标记为dead的对象则在updateGameObjects()末尾被统一过滤掉(gameObjects = gameObjects.filter((go) => !go.dead)),实现"死亡即消失"。

第 5 步:实现后果与奖励

光有显示还不够——玩家的行动必须产生后果。为Hero增加两个"结算方法":

// 撞机扣命:生命归零则英雄死亡 decrementLife() { this.life--; if (this.life === 0) { this.dead = true; } } // 命中加分:每次 +100 incrementPoints() { this.points += 100; }

然后把它们绑定到第 3 步(撞机)与第 4 课已存在(激光命中敌人)的两类碰撞事件上:

eventEmitter.on(Messages.COLLISION_ENEMY_LASER, (_, { first, second }) => { first.dead = true; // 激光消失 second.dead = true; // 敌人被击毁 hero.incrementPoints(); // 加分 }); eventEmitter.on(Messages.COLLISION_ENEMY_HERO, (_, { enemy }) => { enemy.dead = true; // 相撞后敌人销毁(抵消一次伤害) hero.decrementLife(); // 英雄扣除一条命 });

EventEmitter的订阅签名是on(message, listener),回调收到(message, payload);解构出payload中的对象后即可访问first/second/enemy等碰撞双方。第 4 课中"激光命中"的处理器在此被扩展出hero.incrementPoints()一行,正是职责清晰、低侵入式扩展的体现——碰撞检测代码一行未动,只通过订阅新消息完成加分/扣命。

验证完整的反馈闭环

按上述步骤改完后运行npm start,你会看到:画面左下角红色大字Points: 0随每次命中 +100 递增;右下角 3 艘小飞船图标随每次撞机逐一减少;生命归零时英雄被置为dead并随主循环的过滤逻辑从画布消失(完整的"游戏结束/重开"留待第 6 课 6-end-condition 处理)。

从实现反观设计:架构模式与体验原则

对照 solution/app.js 全文,可以提炼出本课刻意训练的软件工程模式:

  • MVC 思想:游戏状态(Hero.life/points等数据)、游戏逻辑(decrementLife/incrementPoints/updateGameObjects)、表现层(drawLife/drawPoints/drawGameObjects)被清晰分离,改动界面不影响数据结算。
  • 观察者模式(Pub-Sub):碰撞检测只emit事件,后果由订阅者执行;新玩法(如加分、音效、连击)只需新增订阅者,不必改动既有检测逻辑。EventEmitter类实现见 solution/app.js。
  • 组件化设计drawTextloadTextureintersectRect等都被抽成可复用函数。
  • 性能意识:所有绘制都在固定的 100ms 主循环内以"清屏 → 刷底 → 绘制"方式完成,对象池通过dead标记 + 过滤回收,避免遍历幽灵对象。

从用户体验(UX)角度,本课同时实践了:视觉层级(重要信息置于角落并保持位置一致)、即时反馈(命中即加分、撞机即扣命)、认知负荷(信息简单清晰)、情感化设计(图标与红色文字建立玩家与游戏的连接)。

本课作业与进阶方向

配套练习详见 6-space-game/5-keeping-score/assignment.md:请创造性地展示生命与分数,例如把生命画成心形、把分数用大号数字显示在画面底部中央,可自行寻找免费游戏素材替换贴图(仓库贴图本身即源自 kenney.nl 免费素材集,见 6-space-game/README.md)。

课程同时给出了层层递进的挑战清单,供学有余力者自测:

  • 5 分钟:为分数显示尝试不同字号与配色;修改分值观察手感差异;在分数/生命变化处加console.log调试;测试"生命耗尽""刷出高分"等边界场景。
  • 1 小时:为得分与扣命添加音效;用localStorage实现最高分(High Score)持久化;为不同敌机类型设置不同分值;扣命时加入屏幕震动等视觉特效。
  • 一周:实现连击倍率等进阶计分机制;加入成就与解锁内容;设计难度曲线;制作菜单与 Game Over 界面。
  • 进阶探索:关注游戏计分/排行榜/玩家成长的后端方案,思考如何把本地游戏与在线服务对接。

完成本课后,你的游戏已具备早期街机游戏令人着迷的三要素——清晰的目标、即时的反馈、对玩家行为有意义的后果;而life/points状态与事件订阅这套骨架,也将直接服务于第 6 课"游戏结束与重开"的收尾实现。

【免费下载链接】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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询