Web-Dev-For-Beginners 太空游戏第 5 课实战:用 Canvas fillText 与事件驱动实现分数和生命系统
本篇基于 Web-Dev-For-Beginners 课程仓库中的太空游戏系列第 5 课文档(阿语版、英文版)展开,讲解如何让第 4 课"激光击毁敌人"的半成品真正变得可玩:通过 Canvas 的 fillText() 渲染分数文本,用飞船图标渲染剩余生命,并用事件发射器(EventEmitter)把碰撞结果转化为分数增减与生命扣除。读完并动手完成后,你将掌握 Canvas 文本渲染、游戏状态管理、事件驱动反馈这三项把"演示程序"升级为"真正游戏"的核心技能。
课程定位:从"能玩"到"像样"
太空游戏系列的前 4 课已经完成了:Canvas 绘制(第 2 课)、元素移动(第 3 课)、碰撞检测(第 4 课)。第 5 课的主题是玩家反馈系统——正如课程文档所说,正是分数与生命这类机制,把 Space Invaders 这类早期街机游戏从"简单演示"变成了"有趣的娱乐"。
本课时在仓库中对应的目录结构如下(your-work 是你的工作副本,solution 是完整参考答案):
- 工作目录:6-space-game/5-keeping-score/your-work
- 参考答案:6-space-game/5-keeping-score/solution/app.js
- 课后作业:6-space-game/5-keeping-score/assignment.md
对比 your-work/app.js 与 solution/app.js 可以直观看到本课要补上的差距:
| 对比项 | your-work(课前状态) | solution(课后状态) |
|---|---|---|
Hero 类状态 |
只有 cooldown(L52) |
新增 this.life = 3 与 this.points = 0(solution L53-L54) |
Messages 常量 |
6 个键:5 个按键 + COLLISION_ENEMY_LASER(L119-L126) |
新增 COLLISION_ENEMY_HERO(L137) |
updateGameObjects() |
只检测激光-敌人碰撞(L191-L207) | 追加英雄-敌人碰撞检测(L208-L213) |
| 事件监听 | 只有 5 个按键事件 + 激光碰撞 | 追加 COLLISION_ENEMY_LASER 加分、COLLISION_ENEMY_HERO 扣命两个监听(L261-L270) |
| 渲染 | 只画游戏对象 | 每帧额外调用 drawPoints() 与 drawLife()(L307-L308) |
准备工作:启动本地开发服务器
课程要求在 your-work 目录下工作,打开后应看到 assets(含 enemyShip.png、player.png、laserRed.png)、index.html、app.js、package.json 这些文件。
运行游戏的方式由 your-work/package.json 中的启动脚本决定:
cd your-work
npm start
该脚本的实际内容是 npx http-server -c-1 -p 5000(见 package.json L7),即在本地 5000 端口启动一个禁用缓存(-c-1)的静态服务器。启动后在浏览器打开 http://localhost:5000 即可看到游戏,用方向键控制飞船、空格键发射激光,确认第 3、4 课的功能仍然正常。
画布尺寸由 solution/index.html 决定:<canvas id="canvas" width="1024" height="768">。后续所有布局常量(如生命图标起点 canvas.width - 180)都是基于这个 1024×768 的坐标系,修改画布尺寸时需同步调整这些数值。
在 Canvas 上绘制文本:fillText 是游戏的"声音"
早期街机在屏幕上显示分数和状态信息用的是同样的技术——Canvas 2D 上下文的 fillText() 方法。课程文档指出,你对文本外观拥有完全控制权:字体族、字号、颜色、对齐方式,再加上 x/y 坐标,六个维度共同决定一段文字如何呈现:
ctx.font = "30px Arial"; // 字体:大小 + 字体族
ctx.fillStyle = "red"; // 颜色
ctx.textAlign = "right"; // 对齐方式
ctx.fillText("show this on the screen", 0, 0); // 在 (0,0) 处绘制
课程文档建议参考浏览器自带的 Canvas API 文本绘制教程(MDN 上的 "Drawing text" 一节)进一步探索字体与排版的创意空间。
值得注意的是 Canvas 文本与 HTML 文本的本质区别:fillText() 画出来的是"像素",不是 DOM 节点——它不能被选中、不能被无障碍工具读取、不随视口缩放。这正是游戏 HUD(抬头显示)的典型做法:游戏状态跟着每一帧重绘,而不是维护一个独立的 DOM 层。
在参考实现中,文本绘制被拆成两个小函数,见 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);
}
(对应 L283-L292。)drawPoints() 负责样式设置(30px Arial、红色、左对齐),drawText() 负责定位(x=10,y=画布高度减 20,即左下角)。把"样式"和"落笔"分离后,以后要显示其他文字(比如最高分提示)只需复用 drawText()。分数显示在左下角,这是文档中明确的设计决策:分数放左下、生命放右下,两个 HUD 元素对称分布且不遮挡战场中央。
生命:不仅仅是数字
课程文档先讲设计、再写代码,这个顺序值得保留。"生命(life)"概念源于弹珠机——你拿到若干颗球去尝试;在 Asteroids 这类早期游戏里,生命赋予玩家冒险的许可:允许犯错、从失败中学习,同时用"风险-收益"的权衡制造紧张感。
文档中给出了一个经典的设计权衡问题与答案:为什么街机游戏普遍使用"3 条命"? 因为它是在挑战感与可玩性之间的平衡点——足够让你认真起来,又不至于挫败。另一个小问题是:为什么分值常用 100 这样的整数? 答案是整数更容易被玩家心算,连续的 +100 会带来"数钱"般的心理满足感。
用图标而不是数字表示生命
文档强调视觉表征的重要性:显示 3 个小飞船图标,比写"生命: 3"能产生即时视觉识别——早期街机柜正是用图标跨越语言障碍与玩家沟通的。课程配套的生命图标就是本文开头展示的那张 35×27 像素的小飞船(源文件:solution/assets/life.png)。
参考实现中的 drawLife() 如下(L273-L281):
function drawLife() {
// TODO, 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
);
}
}
从源码结构看,这里有三个可以推敲的细节:
- 循环上限是动态的:
i < hero.life意味着图标数量直接跟随状态变量——生命从 3 变 2 时,下一帧自动少画一个图标,无需任何"更新界面"的显式代码。这是游戏 HUD 与 Web 界面最大的思维差异:不是"改数据后刷新 UI",而是"每帧按数据重新画一遍"。 START_POS = canvas.width - 180:起点取画布宽度减 180,为右下角预留出一段宽度 180 的图标带,3 个图标最多占45×4 = 180像素(预留了一个空位,方便生命上限提升)。- 间距 45 像素、底部偏移 37 像素:图标原始尺寸是 35×27,45 的步进让图标之间有 10 像素间隙;y 坐标
canvas.height - 37使图标底边距画布底部约 10 像素,与左下角canvas.height - 20的分数基线视觉上对齐。代码中残留的// TODO, 35, 27注释提示作者曾考虑用drawImage的宽度/高度参数显式指定 35×27,而当前写法是按图片原始尺寸绘制。
奖励系统:碰撞、事件与状态变更
本课的反馈链路是一条清晰的事件驱动管道,文档用时序图描述,落到代码上就是 4 个角色:
- 玩家动作(射击 / 撞向敌人);
- 游戏引擎(
updateGameObjects()每帧做碰撞判定并emit消息); - 分数系统 / 生命系统(
hero.incrementPoints()/hero.decrementLife()); - 显示(
drawPoints()/drawLife()在下一次重绘时反映新状态)。
支撑这条管道的是一个手写的 EventEmitter 迷你实现(solution/app.js L2-L19):on(message, listener) 把监听器挂到 listeners 字典的对应数组上,emit(message, payload) 遍历数组逐个调用。所有消息名集中在 Messages 常量对象中(L130-L138),本课要新增其中的 COLLISION_ENEMY_HERO。
两条规则对应两条事件:
- 击毁敌人 → 加 100 分(
COLLISION_ENEMY_LASER); - 撞上敌人 → 扣 1 条命(
COLLISION_ENEMY_HERO);生命归零时hero.dead = true,游戏进入结束条件(这是第 6 课的主题,本课先把状态准备好)。
从源码结构看,"每帧重绘 + 事件通知"的组合意味着:事件监听器只负责改状态,绝不在监听器里直接画画;画布只由游戏循环驱动。这种分工就是文档总结里提到的 MVC 思想——数据(hero.life/hero.points)、逻辑(事件监听器)、展示(draw* 函数)三者各自独立。
动手实现:六步完成分数与生命系统
以下六步完整继承课程文档的操作顺序,每步都给出参考实现中的对应位置以便核对。
第 1 步:引入生命图标资源
把 life.png 从 solution/assets/ 复制到你的 your-work 目录,然后在 window.onload 中加载它,并把它加入文件顶部的资源变量声明列表:
// window.onload 内
lifeImg = await loadTexture("assets/life.png");
// 文件顶部
let heroImg,
...
lifeImg,
...
eventEmitter = new EventEmitter();
参考实现位于 L140-L148(声明)与 L300(加载)。loadTexture() 是一个返回 Promise 的包装器(L116-L124),所以 window.onload 才能声明为 async 并 await 四张纹理全部就绪后再 initGame()——这避免了图片未加载完成就开始绘制导致的"闪帧"。
第 2 步:初始化游戏状态变量
在 Hero 类构造函数中、this.cooldown = 0 之后,为英雄加上两个状态字段(参考 L52-L54):
this.cooldown = 0;
this.life = 3; // 剩余生命:3 条,街机标准
this.points = 0; // 累计分数:从 0 开始
第 3 步:扩展 updateGameObjects() 检测英雄-敌人碰撞
在现有"激光-敌人"碰撞检测之外,遍历所有敌人,用 rectFromGameObject() 取矩形、intersectRect() 判交,命中则发出 COLLISION_ENEMY_HERO(参考 L208-L213):
enemies.forEach(enemy => {
const heroRect = hero.rectFromGameObject();
if (intersectRect(heroRect, enemy.rectFromGameObject())) {
eventEmitter.emit(Messages.COLLISION_ENEMY_HERO, { enemy });
}
});
其中 intersectRect() 是四方向分离测试(L126-L128):只要两个矩形在水平或垂直任一方向完全分离就返回 false,否则相交。注意判定是每帧进行的——敌人以每 300ms 5 像素下移(Enemy 构造函数内的 setInterval,L88-L95),一旦重叠窗口内未处理就会持续触发事件,扣命逻辑因此放在事件监听器里集中执行。
第 4 步:给 Hero 添加扣命与加分方法
decrementLife() {
this.life--;
if (this.life === 0) {
this.dead = true; // 生命耗尽,标记死亡(第 6 课用于游戏结束判定)
}
}
incrementPoints() {
this.points += 100; // 每次命中 +100,整数便于心算
}
对应 L72-L80。这两个方法把"游戏规则"封装进 Hero 内部:外部只知道"英雄扣了一条命",不需要关心 life 和 dead 的联动细节。
第 5 步:写绘制函数并挂进游戏循环
drawLife()、drawPoints()、drawText() 的完整代码见本文前面两节。最后把它们加入 window.onload 中的游戏循环,参考实现的完整循环体如下(L303-L311):
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 帧/秒),执行顺序固定为:清屏 → 填充黑色背景 → 画分数 → 画生命图标 → 更新游戏对象(含碰撞判定)→ 画所有游戏对象。文档要求把 drawPoints() 与 drawLife() 加在 updateGameObjects() 附近,参考实现把两个 draw* HUD 调用放在更新之前——由于上一轮 updateGameObjects() 已经改完了状态,两种顺序显示结果一致。
第 6 步:把状态变更绑定到碰撞事件
在 initGame() 中注册两个监听器(参考 L261-L270):
eventEmitter.on(Messages.COLLISION_ENEMY_LASER, (_, { first, second }) => {
first.dead = true; // 激光销毁
second.dead = true; // 敌人销毁
hero.incrementPoints(); // 玩家 +100 分
});
eventEmitter.on(Messages.COLLISION_ENEMY_HERO, (_, { enemy }) => {
enemy.dead = true; // 撞毁的敌人销毁
hero.decrementLife(); // 玩家 -1 生命
});
监听器参数里第一个下划线是消息名(未使用),第二个是 emit 时传入的 payload:激光事件传 { first: 激光, second: 敌人 },英雄事件传 { enemy }。first.dead = true 这类标记会被 updateGameObjects() 末尾的 gameObjects = gameObjects.filter((go) => !go.dead)(L226)在每帧末尾统一清除——销毁逻辑同样集中在引擎一侧,事件监听器保持"只改状态"。
验证:你的游戏现在应该呈现什么
按文档描述,完成六步后运行 npm start 打开 http://localhost:5000,应观察到:
- 左下角:红色 30px 的
Points: 0文本,每命中一个敌人增加 100; - 右下角:3 个像素飞船图标横向排列,每次英雄撞敌减少一个;
- 生命减到 0 时
hero.dead变为true(游戏结束界面留给第 6 课); - 空格键仍有 500ms 冷却(
canFire()检查cooldown === 0),不能连发。
文档还建议在验证阶段插入 console.log 跟踪分数与生命的变化轨迹,并测试"生命耗尽""连续命中"等边界情况。
扩展挑战:从本课走向更完整的反馈系统
课程文档按时间尺度给了四档练习清单,值得按需取用:
未来 5 分钟可做
- 尝试不同的字体大小和颜色来展示分数;
- 改变分数值(比如 +50、+150),感受数值变化对"手感"的影响;
- 加
console.log跟踪分数与生命变化; - 测试生命耗尽、高分等边界情形。
未来 1 小时可完成
- 为得分和失命添加音效;
- 用
localStorage实现最高分记录; - 为不同类型的敌人设定不同分值;
- 生命减一时加入屏幕震动等视觉特效。
未来 1 周可达成
- 完成整款太空游戏并打磨反馈系统;
- 实现连击(combo)倍率等进阶计分机制;
- 加入成就与可解锁内容;
- 设计菜单与游戏结束界面。
未来 1 个月可达成
- 构建带完整成长系统的游戏;
- 学习游戏分析(analytics)与玩家行为度量;
- 参与开源游戏项目;
- 建立展示游戏设计能力的作品集。
此外文档还设了一个面向 AI 编程助手的挑战:扩展最高分持久化(localStorage)、连击加分系统、不同敌人分值,并在玩家刷新纪录时给出视觉提示。课后作业(assignment.md)则要求你以有创意的方式展示生命与分数——例如用爱心表示生命、在屏幕下方中央显示大号分数,并给出了评分标准(完整可玩 / 部分可玩 / 有缺陷的部分实现)。
小结:这一课你真正学到了什么
对照参考实现 solution/app.js,本课时浓缩为四条可迁移的能力:
- Canvas 文本渲染:
fillText()配合font/fillStyle/textAlign构成游戏 HUD 的全部排版能力;Canvas 画的是像素而非 DOM,每帧重绘天然反映最新状态; - 事件驱动的状态变更:碰撞检测只负责
emit消息,业务规则(加分、扣命、销毁对象)全部收敛在eventEmitter.on()的监听器里——这就是文档总结中"观察者模式"的最小实践; - 状态与展示分离:
hero.life/hero.points是纯数据,drawLife()/drawPoints()是纯展示,updateGameObjects()是纯逻辑,三者通过游戏循环按固定顺序协作,对应 MVC 思想; - 游戏设计心理学:3 条命平衡挑战与留存,+100 的整数分提供即时心算的满足感,图标化生命展示实现跨语言的即时识别。
按课程序列继续,下一课(第 6 课)将利用本课埋下的 hero.dead 标志实现游戏结束条件,让"生命耗尽"从状态变化变成完整的 Game Over 流程。
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 StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
