Web-Dev-For-Beginners 太空射击游戏第 6 课:终局条件、胜负状态判定与 Enter 键重启系统的完整实现
【免费下载链接】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课程中"太空射击游戏"系列(6-space-game)的收官之课,核心目标是为此前已具备移动、射击、碰撞与计分能力的游戏补上"游戏结束"与"重新开始"两块拼图。你将学习如何用事件驱动架构定义胜利/失败条件(全灭敌人获胜、生命归零失败),如何通过 Enter 键实现即时重启,以及如何通过彻底的状态清理(清除游戏循环、事件监听器)避免内存泄漏。读完本文,你将掌握一套可复用的"终局 + 重启"模式,可迁移到任意 Canvas/JavaScript 游戏项目中。
为什么游戏必须定义"终点"与"重启"
一个永远在跑的循环并不能构成好游戏。就像 NASA 1977 年发射的旅行者号探测器可以持续飞行数十年,但游戏需要明确的终点来制造满足感。经典街机游戏早已验证这一设计逻辑:Pac-Man 在玩家被幽灵抓住或吃光所有豆子时结束,Space Invaders 在敌人抵达屏幕底部或全被消灭时结束。作为游戏创作者,你需要为胜利与失败各定义清晰的判定条件。
本课之前,游戏会无限运行;本课之后,它将具备可完成、可重玩的完整闭环——这正是"完成品"与"原型"的本质区别。在 solution/app.js 中可以看到本课全部内容的真实落地实现,阅读时可对照参考。
理解游戏终局条件:四种常见设计模式
在设计终局条件时,可以从四种被反复验证的模式中选择或组合:
- 击毁
N艘敌舰:若游戏按关卡划分,通常需要击毁一定数量的敌舰才能通关,这是最常见的胜利条件。 - 己方飞船被摧毁(生命制):部分游戏飞船被毁即失败;更常见的是引入"生命"概念,每次被毁扣除一条生命,生命耗尽则失败。
- 收集
N分:为击毁敌舰、拾取掉落物等活动赋予分值,达到目标分数即胜利。分值如何分配由你决定。 - 完成关卡:可组合多种子条件,例如击毁
X艘敌舰、收集Y分、拾取特定道具。
本项目的落地实现组合了前两类:全灭敌人即胜利,生命归零即失败(见 solution/app.js)。判定流程可概括为:
游戏开始 → 条件检查 ─┬─ 敌人数 = 0 → 胜利 🏆 ├─ 生命数 = 0 → 失败 💀 ├─ 分数 ≥ 目标 → 胜利 └─ 关卡目标完成 → 胜利 胜利 / 失败 → 可选重启 🔄从源码结构看,Hero的初始生命被设为3(solution/app.js),每次击毁敌舰奖励100分(solution/app.js),敌人以 5 列 × 5 行的编队生成(solution/app.js)——这些数值都可在实现中按需调整。
重启机制的设计目标:像 Tetris 一样顺滑
好游戏通过顺滑的重启机制鼓励重复游玩。玩家通关或失败后,往往想立刻再来一局刷新分数。Tetris 是典范:方块堆到顶部后,无需穿梭复杂菜单即可马上开始新游戏。
本课重启系统的完整状态流转:
[初始] → Playing(开始游戏) Playing → Victory(全灭敌人) Playing → Defeat(生命 = 0) Victory → MessageDisplay(显示胜利信息) Defeat → MessageDisplay(显示失败信息) MessageDisplay → WaitingRestart(提示按 Enter) WaitingRestart → Resetting(按下 Enter) Resetting → CleanupMemory(清除 interval) CleanupMemory → ClearEvents(移除监听器) ClearEvents → InitializeGame(全新初始化) InitializeGame → Playing(新游戏开始)两个关键设计注记:颜色反馈——胜利显示绿色、失败显示红色;完全重置状态——彻底的状态重置可防止内存泄漏。
✅反思练习:回想你玩过的游戏,它们分别在什么条件下结束?如何提示你重启?是什么让一次重启体验顺滑,又是什么让它令人沮丧?把观察结果应用到你的实现中。
今日构建目标与环境准备
本课将添加四项最终功能,它们是把项目从"基础原型"升级为"完整游戏体验"的分水岭:
- 胜利条件:消灭所有敌人,并给予相称的庆祝反馈;
- 失败条件:生命耗尽,呈现失败画面;
- 重启机制:按下 Enter 立即回到战场;
- 状态管理:每次都是干净开局——上一局不会残留敌人或奇怪 bug。
项目结构
你应已从之前课程备齐太空游戏的全部文件,结构大致如下:
-| assets -| enemyShip.png -| player.png -| laserRed.png -| life.png -| index.html -| app.js -| package.json仓库中的参考实现6-space-game/6-end-condition/solution/即采用此结构,其中 package.json 定义了启动脚本。
启动开发服务器
cd your-work npm start此命令会:在http://localhost:5000启动本地服务器、正确伺服游戏文件、并在文件变更时自动刷新页面。打开http://localhost:5000验证游戏可正常移动、射击、与敌人交互后,即可开始实现。
💡开发者提示:为避免 Visual Studio Code 的警告,应将
gameLoopId声明在文件顶部,即let gameLoopId;,而不是声明在window.onload函数内部——这符合现代 JavaScript 的变量声明最佳实践。参考实现正是如此:文件第 2 行即声明let gameLoopId;(solution/app.js)。
六步实现总览
整体实现按以下六个步骤推进,每一步都依赖前一步:
1. 条件跟踪(isHeroDead / isEnemiesDead) 2. 事件处理器(碰撞事件 → 终局事件) 3. 消息常量(GAME_END_WIN / GAME_END_LOSS) 4. 重启控制(Enter 键触发) 5. 消息显示(胜利/失败,颜色编码文本) 6. 重置系统(状态清理 + 全新初始化) 6 → 🎮 完整游戏第一步:创建终局条件跟踪函数
如同国际空间站上持续监控关键系统的传感器,我们需要两个函数持续检查游戏状态,判断游戏是否应该结束:
function isHeroDead() { return hero.life <= 0; } function isEnemiesDead() { const enemies = gameObjects.filter((go) => go.type === "Enemy" && !go.dead); return enemies.length === 0; }幕后原理:
- 检查英雄是否耗尽生命(
life <= 0); - 统计仍存活且活跃的敌人数量(通过
type === "Enemy"过滤类型,通过!go.dead排除已标记死亡的敌人); - 战场清空时返回
true; - 采用简单的布尔逻辑,清晰易懂;
- 过滤全部游戏对象以找出幸存者。
源码中的实现与文档完全一致(solution/app.js)。其中dead标志由GameObject基类在构造时统一初始化为false(solution/app.js),任何游戏对象(敌舰、激光、英雄)都共享这一属性。
第二步:更新事件处理器以触发终局
接下来把这些条件检查接入游戏的碰撞事件系统。每次碰撞发生时,游戏都会评估其是否触发终局条件,从而对关键事件做出即时反馈。本项目的碰撞检测由updateGameObjects()中的矩形相交判断(intersectRect)驱动(solution/app.js),命中后通过EventEmitter发出对应消息。
完整的事件流:
碰撞系统 → 游戏逻辑 → 条件检查 isEnemiesDead() ├─ 全部敌人被击败 → 事件系统发出 GAME_END_WIN → 界面显示胜利信息 └─ 仍有敌人存活 → 游戏继续 碰撞系统 → 游戏逻辑(扣除生命)→ 条件检查 isHeroDead() ├─ 生命 = 0 → 发出 GAME_END_LOSS → 显示失败信息 └─ 生命剩余 → 继续检查 isEnemiesDead() ├─ 全部敌人被击败 → 发出 GAME_END_WIN └─ 仍有敌人 → 继续游戏对应的处理器代码:
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); } }); eventEmitter.on(Messages.GAME_END_WIN, () => { endGame(true); }); eventEmitter.on(Messages.GAME_END_LOSS, () => { endGame(false); });要点解析:
- 激光击中敌人:双方标记死亡、玩家加分、随后检查是否获胜;
- 敌人撞上你:扣除一条生命、检查你是否还活着;
- 智能排序:先判定失败再判定胜利(没人想"赢了又输")——这正是
COLLISION_ENEMY_HERO处理器中return注释"loss before victory"的含义(solution/app.js); - 即时响应:重要事件一旦发生,游戏立刻知晓。
源码细节补充:decrementLife()在生命减至 0 时同时将hero.dead置为true(solution/app.js),而updateGameObjects()每帧会过滤掉所有dead对象(solution/app.js),保证死亡对象不再参与渲染与碰撞。
第三步:添加新的消息常量
需要在Messages常量对象中新增消息类型。常量有助于保持一致性,并防止事件系统里出现拼写错误:
GAME_END_LOSS: "GAME_END_LOSS", GAME_END_WIN: "GAME_END_WIN",这样做的收益:
- 为游戏结束事件添加常量以保持一致性;
- 使用能清楚表达事件用途的描述性名称;
- 沿用既有的消息类型命名约定。
在参考实现中,Messages对象完整定义了所有游戏消息(solution/app.js):
const Messages = { KEY_EVENT_ENTER: 'KEY_EVENT_ENTER', KEY_EVENT_UP: 'KEY_EVENT_UP', KEY_EVENT_DOWN: 'KEY_EVENT_DOWN', KEY_EVENT_LEFT: 'KEY_EVENT_LEFT', KEY_EVENT_RIGHT: 'KEY_EVENT_RIGHT', KEY_EVENT_SPACE: 'KEY_EVENT_SPACE', COLLISION_ENEMY_LASER: 'COLLISION_ENEMY_LASER', COLLISION_ENEMY_HERO: 'COLLISION_ENEMY_HERO', GAME_END_LOSS: 'GAME_END_LOSS', GAME_END_WIN: 'GAME_END_WIN', };这些字符串常量贯穿整个事件驱动架构:EventEmitter.on()注册监听、emit()触发通知,两者都以消息名为键(solution/app.js)。
第四步:实现重启控制
现在添加键盘控制,让玩家能够重启游戏。Enter 键是自然之选,因为它通常与"确认动作、开始新游戏"的语义绑定。
在既有 keydown 事件监听器中加入 Enter 键检测:
else if(evt.key === "Enter") { eventEmitter.emit(Messages.KEY_EVENT_ENTER); }新增消息常量:
KEY_EVENT_ENTER: "KEY_EVENT_ENTER",你需要知道:
- 扩展现有的键盘事件处理系统;
- 用 Enter 键作为重启触发器,交互直觉;
- 发出自定义事件,供游戏其他部分监听;
- 与其他键盘控制保持相同模式。
参考实现中,键盘监听绑定在keyup事件上,ArrowUp/ArrowDown/ArrowLeft/ArrowRight/空格/Enter 六类按键统一转换为消息发出(solution/app.js)。注意keydown只用于阻止方向键与空格键的浏览器默认滚动行为(solution/app.js),这体现了"输入事件与游戏逻辑解耦"的架构思想。
第五步:创建消息显示系统
游戏必须向玩家清晰传达结果。我们将创建消息系统,用颜色编码的文本展示胜利与失败状态——就像早期计算机终端界面一样,绿色代表成功、红色代表错误。
创建displayMessage()函数:
function displayMessage(message, color = "red") { ctx.font = "30px Arial"; ctx.fillStyle = color; ctx.textAlign = "center"; ctx.fillText(message, canvas.width / 2, canvas.height / 2); }逐步拆解:
- 设置字号与字体族,确保文本清晰可读;
- 应用颜色参数,默认
"red"用于警告场景; - 将文本在画布上水平、垂直居中;
- 使用现代 JavaScript 默认参数,颜色传入更灵活;
- 借助 canvas 2D 上下文直接渲染文本。
创建endGame()函数:
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) }这个函数做了什么:
- 冻结一切——飞船和激光不再移动(
clearInterval(gameLoopId)停止主循环); - 留出200ms 微小停顿,确保最后一帧绘制完成;
- 清屏并涂黑,营造戏剧化效果;
- 为胜者与败者显示不同的消息;
- 用颜色编码传达信息——绿色代表胜利,红色代表……嗯,结果不太理想;
- 明确告诉玩家如何重新开始。
参考实现完全一致(solution/app.js)。游戏循环本身以100ms为间隔运行(即约 10 FPS),由window.onload中首次创建的setInterval驱动(solution/app.js)。
🔄 教学检查点:游戏状态管理
在实现重置功能前,请确认你理解:
- ✅ 终局条件如何创造清晰的游戏目标;
- ✅ 为什么视觉反馈对玩家理解至关重要;
- ✅ 正确的清理工作对防止内存泄漏的意义;
- ✅ 事件驱动架构如何支撑干净的状态迁移。
快速自测:如果重置期间不清除事件监听器会发生什么?答案:内存泄漏与重复的事件处理器,导致不可预测的行为。
正在实现的游戏设计原则:
- 清晰目标:玩家确切知道何为成功、何为失败;
- 即时反馈:游戏状态变化立刻传达给玩家;
- 用户控制:玩家可以随时重启;
- 系统可靠性:正确的清理防止 bug 与性能问题。
第六步:实现游戏重置功能
重置系统必须彻底清理当前游戏状态并初始化全新会话,确保玩家获得干净开局,不留上一局的任何残留数据。
创建resetGame()函数:
function resetGame() { if (gameLoopId) { clearInterval(gameLoopId); eventEmitter.clear(); initGame(); 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); } }逐段理解:
- 检查重置前是否有游戏循环正在运行(
if (gameLoopId)防御性判断); - 清除既有游戏循环,终止一切正在进行的游戏活动;
- 移除所有事件监听器(
eventEmitter.clear()),防止内存泄漏; - 重新初始化游戏状态——全新对象与变量(
initGame()); - 启动新游戏循环,包含全部必要的游戏函数;
- 保持100ms 相同间隔,维持一致的性能表现。
在initGame()函数中添加 Enter 键事件处理器:
eventEmitter.on(Messages.KEY_EVENT_ENTER, () => { resetGame(); });为 EventEmitter 类添加clear()方法:
clear() { this.listeners = {}; }关键记忆点:
- 将 Enter 键按下与游戏重置功能连接起来;
- 在游戏初始化期间注册该事件监听器;
- 提供干净的方式来移除全部监听器;
- 通过在局间清除事件处理器防止内存泄漏;
- 将监听器对象重置为空状态,供全新初始化使用。
在参考实现中,initGame()负责:清空gameObjects、重建敌舰编队与英雄、注册全部事件监听器(方向键移动、空格开火、碰撞处理、Enter 重启)(solution/app.js)。由于resetGame()先调用eventEmitter.clear()再调用initGame(),每次重启都会先清空旧监听器再注册新监听器,从而避免监听器无限累积——这正是自测题答案中"重复处理器导致不可预测行为"的规避方案。
完整系统的运行流程
将六个步骤串联后,整个游戏生命周期如下:
- 首局启动:
window.onload异步加载四张贴图(assets/player.png、assets/enemyShip.png、assets/laserRed.png、assets/life.png,见 solution/app.js),随后initGame()初始化并启动100ms间隔的游戏循环; - 游戏中:循环每帧清屏、绘制得分与生命、更新对象(含碰撞检测)、绘制全部对象;
- 触发终局:碰撞处理器检测到全灭敌人或英雄生命归零,发出
GAME_END_WIN/GAME_END_LOSS,调用endGame()停止循环并延时显示彩色消息; - 等待重启:玩家看到消息后按 Enter;
- 重启:
KEY_EVENT_ENTER触发resetGame()——清循环、清监听器、重建对象、重启循环。
index.html仅提供一个 1024×768 的 canvas 画布与app.js引入(solution/index.html),全部逻辑收敛于一个脚本文件,结构极简、适合教学。
完整实现后的总结
🎉 至此,你已从零构建出一款完整游戏。就像 20 世纪 70 年代创造首批电子游戏的程序员一样,你把一行行代码变成了拥有正确游戏机制与用户反馈的交互式体验。
你已完成:
- 实现完整的胜负条件与用户反馈;
- 创建无缝重启系统,支持连续游玩;
- 设计游戏状态的清晰视觉传达;
- 管理复杂的游戏状态迁移与清理;
- 将所有组件整合为连贯、可玩的游戏。
🔄 教学检查点:完整游戏开发体系
- ✅ 终局条件如何创造令人满意的玩家体验?
- ✅ 为什么正确的状态管理对游戏稳定性至关重要?
- ✅ 视觉反馈如何增强玩家理解?
- ✅ 重启系统在玩家留存中扮演什么角色?
掌握的系统能力:
- 全栈游戏开发:从图形、输入到状态管理;
- 专业架构:事件驱动系统 + 正确清理;
- 用户体验设计:清晰反馈与直觉控制;
- 性能优化:高效渲染与内存管理;
- 打磨与完成度:让游戏"有完成感"的所有细节。
工业级技能清单:
- 游戏循环架构:稳定性能的实时系统;
- 事件驱动编程:有效扩展的解耦系统;
- 状态管理:复杂数据处理与生命周期管理;
- 用户界面设计:清晰沟通与快速响应;
- 测试与调试:迭代开发与问题解决。
作业:构建一个示例游戏
课后作业要求把本课技能迁移到全新游戏上(详见 assignment.md)。
核心要求:
- 至少两种终局方式:基于积分的胜利(达到目标分数/收集道具)、基于生命的失败(生命或体力耗尽)、目标完成(全灭敌人/集齐道具/达成目标)、基于时间的结束(倒计时归零);
- 重启功能:清除全部游戏对象与变量、以全新玩家状态/敌人/目标重新初始化、提供清晰的重启操作指引;
- 玩家反馈:胜利消息(正向庆祝)、失败消息(鼓励重试)、进度指示(当前分数、生命或目标状态)。
参考创意方向:回合制战斗的文本冒险游戏(含多种攻击方式、敌我体力值、背包系统、多类型敌人)、收集游戏(躲避障碍收集指定物品)、解谜游戏(逐关升级难度、步数/时间限制)、防御游戏(抵御多波敌人、基地被毁即失败)。
评分标准(节选):游戏功能(多终局条件 + 顺滑重启 + 打磨体验为满分档)、代码质量(现代 JavaScript + 充分注释 + 清晰结构)、用户体验、技术实现与文档五维度评估,优秀档(16–20 分)要求"创意特性 + 打磨实现"。
可选增强挑战:为游戏加入音效
为游戏添加音频:用声音强化体验!建议覆盖以下场景:
- 玩家开火时的激光射击声;
- 舰船被击中时的敌人摧毁声;
- 玩家受击时的英雄受伤声;
- 游戏获胜时的胜利音乐;
- 游戏失败时的失败音效。
实现示例:
// 创建音频对象 const laserSound = new Audio('assets/laser.wav'); const explosionSound = new Audio('assets/explosion.wav'); // 在游戏事件中播放声音 function playLaserSound() { laserSound.currentTime = 0; // 重置到开头 laserSound.play(); }你应该知道:
- 为不同音效创建
Audio对象; - 重置
currentTime以支持连续快速发声; - 通过"由用户交互触发声音"来应对浏览器的自动播放策略;
- 管理音量与时机,获得更好的游戏体验。
你的游戏开发成长路线图
接下来 5 分钟:完整游玩你的游戏,测试全部胜负条件;尝试调整终局条件参数;添加console.log追踪游戏状态变化;把游戏分享给朋友收集反馈。
本小时可完成:完成课后测验并复盘开发历程;为胜负状态添加音频效果;实现更多终局条件(如时间限制、奖励目标);创建不同难度关卡(不同敌人数目);用更好的字体与配色打磨视觉呈现。
一周精通计划:完成含多关卡与进度系统的增强版太空游戏;添加能量道具、多类型敌人、特殊武器等进阶功能;建立带持久化存储的排行榜系统;设计菜单、设置与游戏选项界面;针对不同设备与浏览器优化性能;将游戏部署上线与社区分享。
一月职业之路:制作多款探索不同流派与机制的完整游戏;学习 Phaser、Three.js 等进阶游戏框架;为开源游戏项目做贡献;研究游戏设计原理与玩家心理;建立展示开发技能的作品集;与游戏开发者社区建立连接。
长期成长:掌握高级游戏引擎、平台部署、变现策略与行业人脉——你已准备好探索高级游戏框架、3D 图形、多人游戏系统,或迈向职业游戏开发岗位。
本课使用的仓库资源:
- 完整参考实现:solution/app.js(含
EventEmitter、GameObject/Hero/Enemy/Laser类与全部终局逻辑) - 页面骨架:solution/index.html
- 启动脚本:solution/package.json
- 课后作业:assignment.md
- 系列课程入口:6-space-game/README.md,前置课程:第 5 课 计分系统
【免费下载链接】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),仅供参考