# 构建太空游戏第三部分:添加运动 ## 课前测验 [课前测验](https://ff-quizzes.netlify.app/web/quiz/33) 游戏只有在屏幕上有外星人跑来跑去时才会变得有趣!在这个游戏中,我们将使用两种类型的运动: - **键盘/鼠标运动**:用户通过键盘或鼠标与屏幕上的对象交互并移动它。 - **游戏驱动运动**:游戏以一定的时间间隔自动移动对象。 那么我们如何在屏幕上移动物体呢?这完全是关于笛卡尔坐标系:我们改变对象的位置 (x, y),然后重新绘制屏幕。 通常需要以下步骤来实现屏幕上的*运动*: 1. **设置新位置**:为对象设置一个新位置,这样才能让用户感知到对象已经移动。 2. **清除屏幕**:在每次绘制之间需要清除屏幕。我们可以通过绘制一个填充背景色的矩形来清除屏幕。 3. **在新位置重新绘制对象**:通过这样做,我们最终实现了将对象从一个位置移动到另一个位置。 以下是代码示例: ```javascript //set the hero's location hero.x += 5; // clear the rectangle that hosts the hero ctx.clearRect(0, 0, canvas.width, canvas.height); // redraw the game background and hero ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.fillStyle = "black"; ctx.drawImage(heroImg, hero.x, hero.y); ``` ✅ 你能想到为什么每秒重绘你的英雄多次可能会导致性能问题吗?阅读关于[这种模式的替代方案](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas)。 ## 处理键盘事件 通过将特定事件附加到代码中来处理事件。键盘事件会在整个窗口中触发,而鼠标事件(如`click`)可以连接到点击特定元素。我们将在整个项目中使用键盘事件。 要处理事件,你需要使用窗口的`addEventListener()`方法,并提供两个输入参数。第一个参数是事件的名称,例如`keyup`。第二个参数是事件发生时应该调用的函数。 以下是一个示例: ```javascript window.addEventListener('keyup', (evt) => { // `evt.key` = string representation of the key if (evt.key === 'ArrowUp') { // do something } }) ``` 对于键盘事件,事件上有两个属性可以用来查看按下了哪个键: - `key`:这是按下的键的字符串表示,例如`ArrowUp`。 - `keyCode`:这是数字表示,例如`37`,对应于`ArrowLeft`。 ✅ 键盘事件的操作在游戏开发之外也很有用。你能想到这种技术的其他用途吗? ### 特殊键:一个注意事项 有一些*特殊*键会影响窗口。这意味着如果你正在监听`keyup`事件,并使用这些特殊键移动你的英雄,它也会执行水平滚动。因此,在构建游戏时,你可能需要*关闭*这种内置的浏览器行为。你需要如下代码: ```javascript let onKeyDown = function (e) { console.log(e.keyCode); switch (e.keyCode) { case 37: case 39: case 38: case 40: // Arrow keys case 32: e.preventDefault(); break; // Space default: break; // do not block other keys } }; window.addEventListener('keydown', onKeyDown); ``` 上述代码将确保箭头键和空格键的*默认*行为被关闭。*关闭*机制发生在调用`e.preventDefault()`时。 ## 游戏驱动运动 我们可以通过使用计时器(例如`setTimeout()`或`setInterval()`函数)让物体自动移动,这些计时器会在每个时间间隔更新对象的位置。以下是代码示例: ```javascript let id = setInterval(() => { //move the enemy on the y axis enemy.y += 10; }) ``` ## 游戏循环 游戏循环是一个概念,实际上是一个以固定间隔调用的函数。它被称为游戏循环,因为所有应该显示给用户的内容都在循环中绘制。游戏循环利用游戏中的所有对象,绘制所有对象,除非某些对象不再属于游戏。例如,如果一个对象是被激光击中的敌人并爆炸,它就不再属于当前的游戏循环(你将在后续课程中了解更多)。 以下是游戏循环的典型代码示例: ```javascript let gameLoopId = setInterval(() => function gameLoop() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "black"; ctx.fillRect(0, 0, canvas.width, canvas.height); drawHero(); drawEnemies(); drawStaticObjects(); }, 200); ``` 上述循环每`200`毫秒调用一次以重绘画布。你可以选择适合你游戏的最佳时间间隔。 ## 继续太空游戏 你将使用现有代码并进行扩展。可以从你在第一部分完成的代码开始,或者使用[第二部分的起始代码](../../../../6-space-game/3-moving-elements-around/your-work)。 - **移动英雄**:你将添加代码以确保可以使用箭头键移动英雄。 - **移动敌人**:你还需要添加代码以确保敌人以一定速度从上到下移动。 ## 推荐步骤 找到在`your-work`子文件夹中为你创建的文件。它应该包含以下内容: ```bash -| assets -| enemyShip.png -| player.png -| index.html -| app.js -| package.json ``` 通过输入以下命令启动你的项目: ```bash cd your-work npm start ``` 上述命令将在地址`http://localhost:5000`上启动一个HTTP服务器。打开浏览器并输入该地址,现在它应该渲染英雄和所有敌人;目前还没有任何移动! ### 添加代码 1. **为`hero`、`enemy`和`game object`添加专用对象**,它们应该具有`x`和`y`属性。(记住关于[继承或组合](../README.md)的部分)。 *提示*:`game object`应该是具有`x`和`y`属性并能够将自身绘制到画布上的对象。 >提示:从添加一个新的GameObject类开始,其构造函数如下所示,然后将其绘制到画布上: ```javascript class GameObject { constructor(x, y) { this.x = x; this.y = y; this.dead = false; this.type = ""; this.width = 0; this.height = 0; this.img = undefined; } draw(ctx) { ctx.drawImage(this.img, this.x, this.y, this.width, this.height); } } ``` 现在,扩展这个GameObject以创建Hero和Enemy。 ```javascript class Hero extends GameObject { constructor(x, y) { ...it needs an x, y, type, and speed } } ``` ```javascript class Enemy extends GameObject { constructor(x, y) { super(x, y); (this.width = 98), (this.height = 50); this.type = "Enemy"; let id = setInterval(() => { if (this.y < canvas.height - this.height) { this.y += 5; } else { console.log('Stopped at', this.y) clearInterval(id); } }, 300) } } ``` 2. **添加键盘事件处理程序**以处理键盘导航(移动英雄上下左右)。 *记住*:这是一个笛卡尔系统,左上角是`0,0`。还要记得添加代码以停止*默认行为*。 >提示:创建你的onKeyDown函数并将其附加到窗口: ```javascript let onKeyDown = function (e) { console.log(e.keyCode); ...add the code from the lesson above to stop default behavior } }; window.addEventListener("keydown", onKeyDown); ``` 此时检查你的浏览器控制台,观察按键被记录。 3. **实现**[发布订阅模式](../README.md),这将使你的代码在后续部分保持整洁。 要完成最后这部分,你可以: 1. **在窗口上添加事件监听器**: ```javascript window.addEventListener("keyup", (evt) => { if (evt.key === "ArrowUp") { eventEmitter.emit(Messages.KEY_EVENT_UP); } else if (evt.key === "ArrowDown") { eventEmitter.emit(Messages.KEY_EVENT_DOWN); } else if (evt.key === "ArrowLeft") { eventEmitter.emit(Messages.KEY_EVENT_LEFT); } else if (evt.key === "ArrowRight") { eventEmitter.emit(Messages.KEY_EVENT_RIGHT); } }); ``` 1. **创建一个EventEmitter类**以发布和订阅消息: ```javascript class EventEmitter { constructor() { this.listeners = {}; } on(message, listener) { if (!this.listeners[message]) { this.listeners[message] = []; } this.listeners[message].push(listener); } emit(message, payload = null) { if (this.listeners[message]) { this.listeners[message].forEach((l) => l(message, payload)); } } } ``` 1. **添加常量**并设置EventEmitter: ```javascript const Messages = { KEY_EVENT_UP: "KEY_EVENT_UP", KEY_EVENT_DOWN: "KEY_EVENT_DOWN", KEY_EVENT_LEFT: "KEY_EVENT_LEFT", KEY_EVENT_RIGHT: "KEY_EVENT_RIGHT", }; let heroImg, enemyImg, laserImg, canvas, ctx, gameObjects = [], hero, eventEmitter = new EventEmitter(); ``` 1. **初始化游戏** ```javascript function initGame() { gameObjects = []; createEnemies(); createHero(); eventEmitter.on(Messages.KEY_EVENT_UP, () => { hero.y -=5 ; }) eventEmitter.on(Messages.KEY_EVENT_DOWN, () => { hero.y += 5; }); eventEmitter.on(Messages.KEY_EVENT_LEFT, () => { hero.x -= 5; }); eventEmitter.on(Messages.KEY_EVENT_RIGHT, () => { hero.x += 5; }); } ``` 1. **设置游戏循环** 重构window.onload函数以初始化游戏并以合适的间隔设置游戏循环。你还将添加一个激光束: ```javascript window.onload = async () => { canvas = document.getElementById("canvas"); ctx = canvas.getContext("2d"); heroImg = await loadTexture("assets/player.png"); enemyImg = await loadTexture("assets/enemyShip.png"); laserImg = await loadTexture("assets/laserRed.png"); initGame(); let gameLoopId = setInterval(() => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "black"; ctx.fillRect(0, 0, canvas.width, canvas.height); drawGameObjects(ctx); }, 100) }; ``` 5. **添加代码**以使敌人以一定间隔移动 重构`createEnemies()`函数以创建敌人并将它们推入新的gameObjects类: ```javascript function createEnemies() { const MONSTER_TOTAL = 5; const MONSTER_WIDTH = MONSTER_TOTAL * 98; const START_X = (canvas.width - MONSTER_WIDTH) / 2; const STOP_X = START_X + MONSTER_WIDTH; for (let x = START_X; x < STOP_X; x += 98) { for (let y = 0; y < 50 * 5; y += 50) { const enemy = new Enemy(x, y); enemy.img = enemyImg; gameObjects.push(enemy); } } } ``` 并添加一个`createHero()`函数以对英雄执行类似的过程。 ```javascript function createHero() { hero = new Hero( canvas.width / 2 - 45, canvas.height - canvas.height / 4 ); hero.img = heroImg; gameObjects.push(hero); } ``` 最后,添加一个`drawGameObjects()`函数以开始绘制: ```javascript function drawGameObjects(ctx) { gameObjects.forEach(go => go.draw(ctx)); } ``` 你的敌人应该开始向你的英雄飞船推进! --- ## 🚀 挑战 如你所见,当你开始添加函数、变量和类时,你的代码可能会变成“意大利面条代码”。你如何更好地组织代码,使其更易读?即使代码仍然在一个文件中,也可以画出一个系统来组织代码。 ## 课后测验 [课后测验](https://ff-quizzes.netlify.app/web/quiz/34) ## 复习与自学 虽然我们在没有使用框架的情况下编写游戏,但有许多基于JavaScript的画布框架可用于游戏开发。花些时间阅读[相关内容](https://github.com/collections/javascript-game-engines)。 ## 作业 [为你的代码添加注释](assignment.md) **免责声明**: 本文档使用AI翻译服务[Co-op Translator](https://github.com/Azure/co-op-translator)进行翻译。尽管我们努力确保翻译的准确性,但请注意,自动翻译可能包含错误或不准确之处。原始语言的文档应被视为权威来源。对于重要信息,建议使用专业人工翻译。我们不对因使用此翻译而产生的任何误解或误读承担责任。