首页
/ Web-Dev-For-Beginners 太空游戏第 5 课实战:用 Canvas fillText 与事件驱动实现分数和生命系统

Web-Dev-For-Beginners 太空游戏第 5 课实战:用 Canvas fillText 与事件驱动实现分数和生命系统

2026-09-07 14:27:20作者:滑思眉Philip

本篇基于 Web-Dev-For-Beginners 课程仓库中的太空游戏系列第 5 课文档(阿语版英文版)展开,讲解如何让第 4 课"激光击毁敌人"的半成品真正变得可玩:通过 Canvas 的 fillText() 渲染分数文本,用飞船图标渲染剩余生命,并用事件发射器(EventEmitter)把碰撞结果转化为分数增减与生命扣除。读完并动手完成后,你将掌握 Canvas 文本渲染、游戏状态管理、事件驱动反馈这三项把"演示程序"升级为"真正游戏"的核心技能。

生命图标 life.png:像素风格的小型飞船图标,由 drawLife() 在画布右下角重复绘制来表示剩余生命

课程定位:从"能玩"到"像样"

太空游戏系列的前 4 课已经完成了:Canvas 绘制(第 2 课)、元素移动(第 3 课)、碰撞检测(第 4 课)。第 5 课的主题是玩家反馈系统——正如课程文档所说,正是分数与生命这类机制,把 Space Invaders 这类早期街机游戏从"简单演示"变成了"有趣的娱乐"。

本课时在仓库中对应的目录结构如下(your-work 是你的工作副本,solution 是完整参考答案):

对比 your-work/app.jssolution/app.js 可以直观看到本课要补上的差距:

对比项 your-work(课前状态) solution(课后状态)
Hero 类状态 只有 cooldownL52 新增 this.life = 3this.points = 0solution L53-L54
Messages 常量 6 个键:5 个按键 + COLLISION_ENEMY_LASERL119-L126 新增 COLLISION_ENEMY_HEROL137
updateGameObjects() 只检测激光-敌人碰撞(L191-L207 追加英雄-敌人碰撞检测(L208-L213
事件监听 只有 5 个按键事件 + 激光碰撞 追加 COLLISION_ENEMY_LASER 加分、COLLISION_ENEMY_HERO 扣命两个监听(L261-L270
渲染 只画游戏对象 每帧额外调用 drawPoints()drawLife()L307-L308

准备工作:启动本地开发服务器

课程要求在 your-work 目录下工作,打开后应看到 assets(含 enemyShip.pngplayer.pnglaserRed.png)、index.htmlapp.jspackage.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
    );
  }
}

从源码结构看,这里有三个可以推敲的细节:

  1. 循环上限是动态的i < hero.life 意味着图标数量直接跟随状态变量——生命从 3 变 2 时,下一帧自动少画一个图标,无需任何"更新界面"的显式代码。这是游戏 HUD 与 Web 界面最大的思维差异:不是"改数据后刷新 UI",而是"每帧按数据重新画一遍"。
  2. START_POS = canvas.width - 180:起点取画布宽度减 180,为右下角预留出一段宽度 180 的图标带,3 个图标最多占 45×4 = 180 像素(预留了一个空位,方便生命上限提升)。
  3. 间距 45 像素、底部偏移 37 像素:图标原始尺寸是 35×27,45 的步进让图标之间有 10 像素间隙;y 坐标 canvas.height - 37 使图标底边距画布底部约 10 像素,与左下角 canvas.height - 20 的分数基线视觉上对齐。代码中残留的 // TODO, 35, 27 注释提示作者曾考虑用 drawImage 的宽度/高度参数显式指定 35×27,而当前写法是按图片原始尺寸绘制。

奖励系统:碰撞、事件与状态变更

本课的反馈链路是一条清晰的事件驱动管道,文档用时序图描述,落到代码上就是 4 个角色:

  1. 玩家动作(射击 / 撞向敌人);
  2. 游戏引擎updateGameObjects() 每帧做碰撞判定并 emit 消息);
  3. 分数系统 / 生命系统hero.incrementPoints() / hero.decrementLife());
  4. 显示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.pngsolution/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 才能声明为 asyncawait 四张纹理全部就绪后再 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 构造函数内的 setIntervalL88-L95),一旦重叠窗口内未处理就会持续触发事件,扣命逻辑因此放在事件监听器里集中执行。

第 4 步:给 Hero 添加扣命与加分方法

decrementLife() {
  this.life--;
  if (this.life === 0) {
    this.dead = true;   // 生命耗尽,标记死亡(第 6 课用于游戏结束判定)
  }
}

incrementPoints() {
  this.points += 100;   // 每次命中 +100,整数便于心算
}

对应 L72-L80。这两个方法把"游戏规则"封装进 Hero 内部:外部只知道"英雄扣了一条命",不需要关心 lifedead 的联动细节。

第 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,本课时浓缩为四条可迁移的能力:

  1. Canvas 文本渲染fillText() 配合 font / fillStyle / textAlign 构成游戏 HUD 的全部排版能力;Canvas 画的是像素而非 DOM,每帧重绘天然反映最新状态;
  2. 事件驱动的状态变更:碰撞检测只负责 emit 消息,业务规则(加分、扣命、销毁对象)全部收敛在 eventEmitter.on() 的监听器里——这就是文档总结中"观察者模式"的最小实践;
  3. 状态与展示分离hero.life / hero.points 是纯数据,drawLife() / drawPoints() 是纯展示,updateGameObjects() 是纯逻辑,三者通过游戏循环按固定顺序协作,对应 MVC 思想;
  4. 游戏设计心理学:3 条命平衡挑战与留存,+100 的整数分提供即时心算的满足感,图标化生命展示实现跨语言的即时识别。

按课程序列继续,下一课(第 6 课)将利用本课埋下的 hero.dead 标志实现游戏结束条件,让"生命耗尽"从状态变化变成完整的 Game Over 流程。

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

项目优选

收起
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