ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

Web-Dev-For-Beginners 太空游戏第 5 课:用 Canvas 实现计分与生命系统(Scoring and Lives) 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.htmlcanvas idcanvas width1024 height768。生命——不仅仅是数字在游戏设计中生命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 startnpm 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与heroImg、enemyImg、laserImg并列由loadTexture()返回的PromiseImage填充let heroImg, enemyImg, laserImg, lifeImg, canvas, ctx, gameObjects [], hero, eventEmitter new EventEmitter();需要说明的是loadTexture是一个异步加载器它创建Image对象、设置src并等待onload后才resolveyour-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类与全局作用域分别实现三个绘制函数定义见上文的drawLife、drawPoints与drawText然后在游戏主循环中紧跟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。组件化设计drawText、loadTexture、intersectRect等都被抽成可复用函数。性能意识所有绘制都在固定的 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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进