# 十六、項目:平臺游戲
> 原文:[Project: A Platform Game](https://eloquentjavascript.net/16_game.html)
>
> 譯者:[飛龍](https://github.com/wizardforcel)
>
> 協議:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/)
>
> 自豪地采用[谷歌翻譯](https://translate.google.cn/)
>
> 部分參考了[《JavaScript 編程精解(第 2 版)》](https://book.douban.com/subject/26707144/)
> 所有現實都是游戲。
>
> Iain Banks,《The Player of Games》

我最初對電腦的癡迷,就像許多小孩一樣,與電腦游戲有關。我沉迷在那個計算機所模擬出的小小世界中,我可以操縱這個世界,我同時也沉迷在那些尚未展開的故事之中。但我沉迷其中并不是因為游戲實際描述的故事,而是因為我可以充分發揮我的想象力,去構思故事的發展。
我并不希望任何人把編寫游戲作為自己的事業。就像音樂產業中,那些希望加入這個行業的熱忱年輕人與實際的人才需求之間存在巨大的鴻溝,也因此產生了一個極不健康的就業環境。不過,把編寫游戲作為樂趣還是相當不錯的。
本章將會介紹如何實現一個小型平臺游戲。平臺游戲(或者叫作“跳爬”游戲)要求玩家操縱一個角色在世界中移動,這種游戲往往是二維的,而且采用單一側面作為觀察視角,玩家可以來回跳躍。
## 游戲
我們游戲大致基于由 Thomas Palef 開發的 [Dark Blue](http://www.lessmilk.com/games/10)。我之所以選擇了這個游戲,是因為這個游戲既有趣又簡單,而且不需要編寫大量代碼。該游戲看起來如下頁圖所示。

黑色的方塊表示玩家,玩家任務是收集黃色的方塊(硬幣),同時避免碰到紅色素材(“巖漿”)。當玩家收集完所有硬幣后就可以過關。
玩家可以使用左右方向鍵移動,并使用上方向鍵跳躍。跳躍正是這個游戲角色的特長。玩家可以跳躍到數倍于自己身高的地方,也可以在半空中改變方向。雖然這樣不切實際,但這有助于玩家感覺自己在直接控制屏幕上那個自己的化身。
該游戲包含一個固定的背景,使用網格方式進行布局,可可移動元素則覆蓋在背景之上。網格中的元素可能是空氣、固體或巖漿。可可移動元素是玩家、硬幣或者某一塊巖漿。這些元素的位置不限于網格,它們的坐標可以是分數,允許平滑運動。
## 實現技術
我們會使用瀏覽器的 DOM 來展示游戲界面,我們會通過處理按鍵事件來讀取用戶輸入。
與屏幕和鍵盤相關的代碼只是實現游戲代碼中的很小一部分。由于所有元素都只是彩色方塊,因此繪制方法并不復雜。我們為每個元素創建對應的 DOM 元素,并使用樣式來為其指定背景顏色、尺寸和位置。
由于背景是由不會改變的方塊組成的網格,因此我們可以使用表格來展示背景。自由可移動元素可以使用絕對定位元素來覆蓋。
游戲和某些程序應該在不產生明顯延遲的情況下繪制動畫并響應用戶輸入,性能是非常重要的。盡管 DOM 最初并非為高性能繪圖而設計,但實際上 DOM 的性能表現得比我們想象中要好得多。讀者已經在第 13 章中看過一些動畫,在現代機器中,即使我們不怎么考慮性能優化,像這種簡單的游戲也可以流暢運行。
在下一章中,我們會研究另一種瀏覽器技術 —— `<canvas>`標簽。該標簽提供了一種更為傳統的圖像繪制方式,直接處理形狀和像素而非 DOM 元素。
## 關卡
我們需要一種人類可讀的、可編輯的方法來指定關卡。因為一切最開始都可以在網格,所以我們可以使用大型字符串,其中每個字符代表一個元素,要么是背景網格的一部分,要么是可移動元素。
小型關卡的平面圖可能是這樣的:
```js
var simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;
```
句號是空的位置,井號(`#`)字符是墻,加號是巖漿。玩家的起始位置是 AT 符號(`@`)。每個`O`字符都是一枚硬幣,等號(`=`)是一塊來回水平移動的巖漿塊。
我們支持兩種額外的可移動巖漿:管道符號(`|`)表示垂直移動的巖漿塊,而`v`表示下落的巖漿塊 —— 這種巖漿塊也是垂直移動,但不會來回彈跳,只會向下移動,直到遇到地面才會直接回到其起始位置。
整個游戲包含了許多關卡,玩家必須完成所有關卡。每關的過關條件是玩家需要收集所有硬幣。如果玩家碰到巖漿,當前關卡會恢復初始狀態,而玩家可以再次嘗試過關。
## 讀取關卡
下面的類存儲了關卡對象。它的參數應該是定義關卡的字符串。
```js
class Level {
constructor(plan) {
let rows = plan.trim().split("\n").map(l => [...l]);
this.height = rows.length;
this.width = rows[0].length;
this.startActors = [];
this.rows = rows.map((row, y) => {
return row.map((ch, x) => {
let type = levelChars[ch];
if (typeof type == "string") return type;
this.startActors.push(
type.create(new Vec(x, y), ch));
return "empty";
});
});
}
}
```
`trim`方法用于移除平面圖字符串起始和終止處的空白。這允許我們的示例平面圖以換行開始,以便所有行都在彼此的正下方。其余的字符串由換行符拆分,每一行擴展到一個數組中,生成了字符數組。
因此,`rows`包含字符數組、平面圖的行。我們可以從中得出水平寬度和高度。但是我們仍然必須將可移動元素與背景網格分開。我們將其稱為角色(Actor)。它們將存儲在一個對象數組中。背景將是字符串的數組的數組,持有字段類型,如`"empty"`,`"wall"`,或`"lava"`。
為了創建這些數組,我們在行上映射,然后在它們的內容上進行映射。請記住,`map`將數組索引作為第二個參數傳遞給映射函數,它告訴我們給定字符的`x`和`y`坐標。游戲中的位置將存儲為一對坐標,左上角為`0, 0`,并且每個背景方塊為 1 單位高和寬。
為了解釋平面圖中的字符,`Level`構造器使用`levelChars`對象,它將背景元素映射為字符串,角色字符映射為類。當`type`是一個角色類時,它的`create`靜態方法用于創建一個對象,該對象被添加到`startActors`,映射函數為這個背景方塊返回`"empty"`。
角色的位置存儲為一個`Vec`對象,它是二維向量,一個具有`x`和`y`屬性的對象,像第六章一樣。
當游戲運行時,角色將停在不同的地方,甚至完全消失(就像硬幣被收集時)。我們將使用一個`State`類來跟蹤正在運行的游戲的狀態。
```js
class State {
constructor(level, actors, status) {
this.level = level;
this.actors = actors;
this.status = status;
}
static start(level) {
return new State(level, level.startActors, "playing");
}
get player() {
return this.actors.find(a => a.type == "player");
}
}
```
當游戲結束時,`status`屬性將切換為`"lost"`或`"won"`。
這又是一個持久性數據結構,更新游戲狀態會創建新狀態,并使舊狀態保持完整。
## 角色
角色對象表示,游戲中給定可移動元素的當前位置和狀態。所有的角色對象都遵循相同的接口。它們的`pos`屬性保存元素的左上角坐標,它們的`size`屬性保存其大小。
然后,他們有`update`方法,用于計算給定時間步長之后,他們的新狀態和位置。它模擬了角色所做的事情:響應箭頭鍵并且移動,因巖漿而來回彈跳,并返回新的更新后的角色對象。
`type`屬性包含一個字符串,該字符串指定了角色類型:`"player"`,`"coin"`或者`"lava"`。這在繪制游戲時是有用的,為角色繪制的矩形的外觀基于其類型。
角色類有一個靜態的`create`方法,它由`Level`構造器使用,用于從關卡平面圖中的字符中,創建一個角色。它接受字符本身及其坐標,這是必需的,因為`Lava`類處理幾個不同的字符。
這是我們將用于二維值的`Vec`類,例如角色的位置和大小。
```js
class Vec {
constructor(x, y) {
this.x = x; this.y = y;
}
plus(other) {
return new Vec(this.x + other.x, this.y + other.y);
}
times(factor) {
return new Vec(this.x * factor, this.y * factor);
}
}
```
`times`方法用給定的數字來縮放向量。當我們需要將速度向量乘時間間隔,來獲得那個時間的行走距離時,這就有用了。
不同類型的角色擁有他們自己的類,因為他們的行為非常不同。讓我們定義這些類。稍后我們將看看他們的`update`方法。
玩家類擁有`speed`屬性,存儲了當前速度,來模擬動量和重力。
```js
class Player {
constructor(pos, speed) {
this.pos = pos;
this.speed = speed;
}
get type() { return "player"; }
static create(pos) {
return new Player(pos.plus(new Vec(0, -0.5)),
new Vec(0, 0));
}
}
Player.prototype.size = new Vec(0.8, 1.5);
```
因為玩家高度是一個半格子,因此其初始位置相比于`@`字符出現的位置要高出半個格子。這樣一來,玩家角色的底部就可以和其出現的方格底部對齊。
`size`屬性對于`Player`的所有實例都是相同的,因此我們將其存儲在原型上,而不是實例本身。我們可以使用一個類似`type`的讀取器,但是每次讀取屬性時,都會創建并返回一個新的`Vec`對象,這將是浪費的。(字符串是不可變的,不必在每次求值時重新創建。)
構造`Lava`角色時,我們需要根據它所基于的字符來初始化對象。動態巖漿以其當前速度移動,直到它碰到障礙物。這個時候,如果它擁有`reset`屬性,它會跳回到它的起始位置(滴落)。如果沒有,它會反轉它的速度并以另一個方向繼續(彈跳)。
`create`方法查看`Level`構造器傳遞的字符,并創建適當的巖漿角色。
```js
class Lava {
constructor(pos, speed, reset) {
this.pos = pos;
this.speed = speed;
this.reset = reset;
}
get type() { return "lava"; }
static create(pos, ch) {
if (ch == "=") {
return new Lava(pos, new Vec(2, 0));
} else if (ch == "|") {
return new Lava(pos, new Vec(0, 2));
} else if (ch == "v") {
return new Lava(pos, new Vec(0, 3), pos);
}
}
}
Lava.prototype.size = new Vec(1, 1);
```
`Coin`對象相對簡單,大多時候只需要待在原地即可。但為了使游戲更加有趣,我們讓硬幣輕微搖晃,也就是會在垂直方向上小幅度來回移動。每個硬幣對象都存儲了其基本位置,同時使用`wobble`屬性跟蹤圖像跳動幅度。這兩個屬性同時決定了硬幣的實際位置(存儲在`pos`屬性中)。
```js
class Coin {
constructor(pos, basePos, wobble) {
this.pos = pos;
this.basePos = basePos;
this.wobble = wobble;
}
get type() { return "coin"; }
static create(pos) {
let basePos = pos.plus(new Vec(0.2, 0.1));
return new Coin(basePos, basePos,
Math.random() * Math.PI * 2);
}
}
Coin.prototype.size = new Vec(0.6, 0.6);
```
第十四章中,我們知道了`Math.sin`可以計算出圓的`y`坐標。因為我們沿著圓移動,因此`y`坐標會以平滑的波浪形式來回移動,正弦函數在實現波浪形移動中非常實用。
為了避免出現所有硬幣同時上下移動,每個硬幣的初始階段都是隨機的。由`Math.sin`產生的波長是`2π`。我們可以將`Math.random`的返回值乘以`2π`,計算出硬幣波形軌跡的初始位置。
現在我們可以定義`levelChars`對象,它將平面圖字符映射為背景網格類型,或角色類。
```js
const levelChars = {
".": "empty", "#": "wall", "+": "lava",
"@": Player, "o": Coin,
"=": Lava, "|": Lava, "v": Lava
};
```
這給了我們創建`Level`實例所需的所有部件。
```js
let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9
```
上面一段代碼的任務是將特定關卡顯示在屏幕上,并構建關卡中的時間與動作。
## 成為負擔的封裝
本章中大多數代碼并沒有過多考慮封裝。首先,封裝需要耗費額外精力。封裝使得程序變得更加龐大,而且會引入額外的概念和接口。我盡量將程序的體積控制在較小的范圍之內,避免讀者因為代碼過于龐大而走神。
其次,游戲中的大量元素是緊密耦合在一起的,如果其中一個元素行為改變,其他的元素很有可能也會發生變化。我們需要根據游戲的工作細節來為元素之間設計大量接口。這使得接口的效果不是很好。每當你改變系統中的某一部分時,由于其他部分的接口可能沒有考慮到新的情況,因此你需要關心這一修改是否會影響到其他部分的代碼。
系統中的某些分割點可以通過嚴格的接口對系統進行合理的劃分,但某些分割點則不是如此。嘗試去封裝某些本沒有合理邊界的代碼必然會導致浪費大量精力。當你犯下這種大錯之際,你就會注意到你的接口變得龐大臃腫,而且隨著程序不斷演化,你需要頻繁修改這些接口。
我們會封裝的一部分代碼是繪圖子系統。其原因是我們會在下一章中使用另一種方式來展示相同的游戲。通過將繪圖代碼隱藏在接口之后,我們可以在下一章中使用相同的游戲程序,只需要插入新的顯示模塊即可。
## 繪圖
我們通過定義一個“顯示器”對象來封裝繪圖代碼,該對象顯示指定關卡,以及狀態。本章定義的顯示器類型名為`DOMDisplay`,因為該類型使用簡單的 DOM 元素來顯示關卡。
我們會使用樣式表來設定實際的顏色以及其他構建游戲中所需的固定的屬性。創建這些屬性時,我們可以直接對元素的`style`屬性進行賦值,但這會使得游戲代碼變得冗長。
下面的幫助函數提供了一種簡潔的方法,來創建元素并賦予它一些屬性和子節點:
```js
function elt(name, attrs, ...children) {
let dom = document.createElement(name);
for (let attr of Object.keys(attrs)) {
dom.setAttribute(attr, attrs[attr]);
}
for (let child of children) {
dom.appendChild(child);
}
return dom;
}
```
我們創建顯示器對象時需要指定其父元素,顯示器將會創建在該父元素上,同時還需指定一個關卡對象。
```js
class DOMDisplay {
constructor(parent, level) {
this.dom = elt("div", {class: "game"}, drawGrid(level));
this.actorLayer = null;
parent.appendChild(this.dom);
}
clear() { this.dom.remove(); }
}
```
由于關卡的背景網格不會改變,因此只需要繪制一次即可。角色則需要在每次刷新顯示時進行重繪。`drawFame`需要使用`actorLayer`屬性來跟蹤已保存角色的動作,因此我們可以輕松移除或替換這些角色。
我們的坐標和尺寸以網格單元為單位跟蹤,也就是說尺寸或距離中的 1 單元表示一個單元格。在設置像素級尺寸時,我們需要將坐標按比例放大,如果游戲中的所有元素只占據一個方格中的一個像素,那將是多么可笑。而`scale`綁定會給出一個單元格在屏幕上實際占據的像素數目。
```js
const scale = 20;
function drawGrid(level) {
return elt("table", {
class: "background",
style: `width: ${level.width * scale}px`
}, ...level.rows.map(row =>
elt("tr", {style: `height: ${scale}px`},
...row.map(type => elt("td", {class: type})))
));
}
```
前文提及過,我們使用`<table>`元素來繪制背景。這非常符合關卡中`grid`屬性的結構。網格中的每一行對應表格中的一行(`<tr>`元素)。網格中的每個字符串對應表格單元格(`<td>`)元素的類型名。擴展(三點)運算符用于將子節點數組作為單獨的參數傳給`elt`。
下面的 CSS 使表格看起來像我們想要的背景:
```css
.background { background: rgb(52, 166, 251);
table-layout: fixed;
border-spacing: 0; }
.background td { padding: 0; }
.lava { background: rgb(255, 100, 100); }
.wall { background: white; }
```
其中某些屬性(border-spacing和padding)用于取消一些我們不想保留的表格默認行為。我們不希望在單元格之間或單元格內部填充多余的空白。
其中`background`規則用于設置背景顏色。CSS中可以使用兩種方式來指定顏色,一種方法是使用單詞(`white`),另一種方法是使用形如`rgb(R,G,B)`的格式,其中`R`表示顏色中的紅色成分,`G`表示綠色成分,`B`表示藍色成分,每個數字范圍均為 0 到 255。因此在`rgb(52,166,251)`中,紅色成分為 52,綠色為 166,而藍色是 251。由于藍色成分數值最大,因此最后的顏色會偏向藍色。而你可以看到`.lava`規則中,第一個數字(紅色)是最大的。
我們繪制每個角色時需要創建其對應的 DOM 元素,并根據角色屬性來設置元素坐標與尺寸。這些值都需要與`scale`相乘,以將游戲中的尺寸單位轉換為像素。
```js
function drawActors(actors) {
return elt("div", {}, ...actors.map(actor => {
let rect = elt("div", {class: `actor ${actor.type}`});
rect.style.width = `${actor.size.x * scale}px`;
rect.style.height = `${actor.size.y * scale}px`;
rect.style.left = `${actor.pos.x * scale}px`;
rect.style.top = `${actor.pos.y * scale}px`;
return rect;
}));
}
```
為了賦予一個元素多個類別,我們使用空格來分隔類名。在下面展示的 CSS 代碼中,`actor`類會賦予角色一個絕對坐標。我們將角色的類型名稱作為額外的 CSS 類來設置這些元素的顏色。我們并沒有再次定義`lava`類,因為我們可以直接復用前文為巖漿單元格定義的規則。
```css
.actor { position: absolute; }
.coin { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64); }
```
`setState`方法用于使顯示器顯示給定的狀態。它首先刪除舊角色的圖形,如果有的話,然后在他們的新位置上重新繪制角色。試圖將 DOM 元素重用于角色,可能很吸引人,但是為了使它有效,我們需要大量的附加記錄,來關聯角色和 DOM 元素,并確保在角色消失時刪除元素。因為游戲中通常只有少數角色,重新繪制它們開銷并不大。
```js
DOMDisplay.prototype.setState = function(state) {
if (this.actorLayer) this.actorLayer.remove();
this.actorLayer = drawActors(state.actors);
this.dom.appendChild(this.actorLayer);
this.dom.className = `game ${state.status}`;
this.scrollPlayerIntoView(state);
};
```
我們可以將關卡的當前狀態作為類名添加到包裝器中,這樣可以根據游戲勝負與否來改變玩家角色的樣式。我們只需要添加 CSS 規則,指定祖先節點包含特定類的`player`元素的樣式即可。
```css
.lost .player {
background: rgb(160, 64, 64);
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
```
在遇到巖漿之后,玩家的顏色應該變成深紅色,暗示著角色被燒焦了。當玩家收集完最后一枚硬幣時,我們添加兩個模糊的白色陰影來創建白色的光環效果,其中一個在左上角,一個在右上角。
我們無法假定關卡總是符合視口尺寸,它是我們在其中繪制游戲的元素。所以我們需要調用`scrollPlayerIntoView`來確保如果關卡在視口范圍之外,我們可以滾動視口,確保玩家靠近視口的中央位置。下面的 CSS 樣式為包裝器的DOM元素設置了一個最大尺寸,以確保任何超出視口的元素都是不可見的。我們可以將外部元素的`position`設置為`relative`,因此該元素中的角色總是相對于關卡的左上角進行定位。
```css
.game {
overflow: hidden;
max-width: 600px;
max-height: 450px;
position: relative;
}
```
在`scrollPlayerIntoView`方法中,我們找出玩家的位置并更新其包裝器元素的滾動坐標。我們可以通過操作元素的`scrollLeft`和`scrollTop`屬性,當玩家接近視口邊界時修改滾動坐標。
```js
DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
let width = this.dom.clientWidth;
let height = this.dom.clientHeight;
let margin = width / 3;
// The viewport
let left = this.dom.scrollLeft, right = left + width;
let top = this.dom.scrollTop, bottom = top + height;
let player = state.player;
let center = player.pos.plus(player.size.times(0.5))
.times(scale);
if (center.x < left + margin) {
this.dom.scrollLeft = center.x - margin;
} else if (center.x > right - margin) {
this.dom.scrollLeft = center.x + margin - width;
}
if (center.y < top + margin) {
this.dom.scrollTop = center.y - margin;
} else if (center.y > bottom - margin) {
this.dom.scrollTop = center.y + margin - height;
}
};
```
找出玩家中心位置的代碼展示了,我們如何使用`Vec`類型來寫出相對可讀的計算代碼。為了找出玩家的中心位置,我們需要將左上角位置坐標加上其尺寸的一半。計算結果就是關卡坐標的中心位置。但是我們需要將結果向量乘以顯示比例,以將坐標轉換成像素級坐標。
接下來,我們對玩家的坐標進行一系列檢測,確保其位置不會超出合法范圍。這里需要注意的是這段代碼有時候依然會設置無意義的滾動坐標,比如小于 0 的值或超出元素滾動區域的值。這是沒問題的。DOM 會將其修改為可接受的值。如果我們將`scrollLeft`設置為`–10`,DOM 會將其修改為 0。
最簡單的做法是每次重繪時都滾動視口,確保玩家總是在視口中央。但這種做法會導致畫面劇烈晃動,當你跳躍時,視圖會不斷上下移動。比較合理的做法是在屏幕中央設置一個“中央區域”,玩家在這個區域內部移動時我們不會滾動視口。
我們現在能夠顯示小型關卡。
```html
<link rel="stylesheet" href="css/game.css">
<script>
let simpleLevel = new Level(simpleLevelPlan);
let display = new DOMDisplay(document.body, simpleLevel);
display.setState(State.start(simpleLevel));
</script>
```
我們可以在`link`標簽中使用`rel="stylesheet"`,將一個 CSS 文件加載到頁面中。文件`game.css`包含了我們的游戲所需的樣式。
## 動作與沖突
現在我們是時候來添加一些動作了。這是游戲中最令人著迷的一部分。實現動作的最基本的方案(也是大多數游戲采用的)是將時間劃分為一個個時間段,根據角色的每一步速度和時間長度,將元素移動一段距離。我們將以秒為單位測量時間,所以速度以單元每秒來表示。
移動東西非常簡單。比較困難的一部分是處理元素之間的相互作用。當玩家撞到墻壁或者地板時,不可能簡單地直接穿越過去。游戲必須注意特定的動作會導致兩個對象產生碰撞,并需要采取相應措施。如果玩家遇到墻壁,則必須停下來,如果遇到硬幣則必須將其收集起來。
想要解決通常情況下的碰撞問題是件艱巨任務。你可以找到一些我們稱之為物理引擎的庫,這些庫會在二維或三維空間中模擬物理對象的相互作用。我們在本章中采用更合適的方案:只處理矩形物體之間的碰撞,并采用最簡單的方案進行處理。
在移動角色或巖漿塊時,我們需要測試元素是否會移動到墻里面。如果會的話,我們只要取消整個動作即可。而對動作的反應則取決于移動元素類型。如果是玩家則停下來,如果是巖漿塊則反彈回去。
這種方法需要保證每一步之間的時間間隔足夠短,確保能夠在對象實際碰撞之前取消動作。如果時間間隔太大,玩家最后會懸浮在離地面很高的地方。另一種方法明顯更好但更加復雜,即尋找到精確的碰撞點并將元素移動到那個位置。我們會采取最簡單的方案,并確保減少動畫之間的時間間隔,以掩蓋其問題。
該方法用于判斷某個矩形(通過位置與尺寸限定)是否會碰到給定類型的網格。
```js
Level.prototype.touches = function(pos, size, type) {
var xStart = Math.floor(pos.x);
var xEnd = Math.ceil(pos.x + size.x);
var yStart = Math.floor(pos.y);
var yEnd = Math.ceil(pos.y + size.y);
for (var y = yStart; y < yEnd; y++) {
for (var x = xStart; x < xEnd; x++) {
let isOutside = x < 0 || x >= this.width ||
y < 0 || y >= this.height;
let here = isOutside ? "wall" : this.rows[y][x];
if (here == type) return true;
}
}
return false;
};
```
該方法通過對坐標使用`Math.floor`和`Math.ceil`,來計算與身體重疊的網格方塊集合。記住網格方塊的大小是`1x1`個單位。通過將盒子的邊上下顛倒,我們得到盒子接觸的背景方塊的范圍。

我們通過查找坐標遍歷網格方塊,并在找到匹配的方塊時返回`true`。關卡之外的方塊總是被當作`"wall"`,來確保玩家不能離開這個世界,并且我們不會意外地嘗試,在我們的“`rows`數組的邊界之外讀取。
狀態的`update`方法使用`touches`來判斷玩家是否接觸巖漿。
```js
State.prototype.update = function(time, keys) {
let actors = this.actors
.map(actor => actor.update(time, this, keys));
let newState = new State(this.level, actors, this.status);
if (newState.status != "playing") return newState;
let player = newState.player;
if (this.level.touches(player.pos, player.size, "lava")) {
return new State(this.level, actors, "lost");
}
for (let actor of actors) {
if (actor != player && overlap(actor, player)) {
newState = actor.collide(newState);
}
}
return newState;
};
```
它接受時間步長和一個數據結構,告訴它按下了哪些鍵。它所做的第一件事是調用所有角色的`update`方法,生成一組更新后的角色。角色也得到時間步長,按鍵,和狀態,以便他們可以根據這些來更新。只有玩家才會讀取按鍵,因為這是唯一由鍵盤控制的角色。
如果游戲已經結束,就不需要再做任何處理(游戲不能在輸之后贏,反之亦然)。否則,該方法測試玩家是否接觸背景巖漿。如果是這樣的話,游戲就輸了,我們就完了。最后,如果游戲實際上還在繼續,它會查看其他玩家是否與玩家重疊。
`overlap`函數檢測角色之間的重疊。它需要兩個角色對象,當它們觸碰時返回`true`,當它們沿`X`軸和`Y`軸重疊時,就是這種情況。
```js
function overlap(actor1, actor2) {
return actor1.pos.x + actor1.size.x > actor2.pos.x &&
actor1.pos.x < actor2.pos.x + actor2.size.x &&
actor1.pos.y + actor1.size.y > actor2.pos.y &&
actor1.pos.y < actor2.pos.y + actor2.size.y;
}
```
如果任何角色重疊了,它的`collide`方法有機會更新狀態。觸碰巖漿角色將游戲狀態設置為`"lost"`,當你碰到硬幣時,硬幣就會消失,當這是最后一枚硬幣時,狀態就變成了`"won"`。
```js
Lava.prototype.collide = function(state) {
return new State(state.level, state.actors, "lost");
};
Coin.prototype.collide = function(state) {
let filtered = state.actors.filter(a => a != this);
let status = state.status;
if (!filtered.some(a => a.type == "coin")) status = "won";
return new State(state.level, filtered, status);
};
```
## 角色的更新
角色對象的`update`方法接受時間步長、狀態對象和`keys`對象作為參數。`Lava`角色類型忽略`keys`對象。
```js
Lava.prototype.update = function(time, state) {
let newPos = this.pos.plus(this.speed.times(time));
if (!state.level.touches(newPos, this.size, "wall")) {
return new Lava(newPos, this.speed, this.reset);
} else if (this.reset) {
return new Lava(this.reset, this.speed, this.reset);
} else {
return new Lava(this.pos, this.speed.times(-1));
}
};
```
它通過將時間步長乘上當前速度,并將其加到其舊位置,來計算新的位置。如果新的位置上沒有障礙,它移動到那里。如果有障礙物,其行為取決于巖漿塊的類型:滴落巖漿具有`reset`位置,當它碰到某物時,它會跳回去。跳躍巖漿將其速度乘以`-1`,從而開始向相反的方向移動。
硬幣使用它們的`act`方法來晃動。他們忽略了網格的碰撞,因為它們只是在它們自己的方塊內部晃動。
```js
const wobbleSpeed = 8, wobbleDist = 0.07;
Coin.prototype.update = function(time) {
let wobble = this.wobble + time * wobbleSpeed;
let wobblePos = Math.sin(wobble) * wobbleDist;
return new Coin(this.basePos.plus(new Vec(0, wobblePos)),
this.basePos, wobble);
};
```
遞增`wobble`屬性來跟蹤時間,然后用作`Math.sin`的參數,來找到波上的新位置。然后,根據其基本位置和基于波的偏移,計算硬幣的當前位置。
還剩下玩家本身。玩家的運動對于每和軸單獨處理,因為碰到地板不應阻止水平運動,碰到墻壁不應停止下降或跳躍運動。
```js
const playerXSpeed = 7;
const gravity = 30;
const jumpSpeed = 17;
Player.prototype.update = function(time, state, keys) {
let xSpeed = 0;
if (keys.ArrowLeft) xSpeed -= playerXSpeed;
if (keys.ArrowRight) xSpeed += playerXSpeed;
let pos = this.pos;
let movedX = pos.plus(new Vec(xSpeed * time, 0));
if (!state.level.touches(movedX, this.size, "wall")) {
pos = movedX;
}
let ySpeed = this.speed.y + time * gravity;
let movedY = pos.plus(new Vec(0, ySpeed * time));
if (!state.level.touches(movedY, this.size, "wall")) {
pos = movedY;
} else if (keys.ArrowUp && ySpeed > 0) {
ySpeed = -jumpSpeed;
} else {
ySpeed = 0;
}
return new Player(pos, new Vec(xSpeed, ySpeed));
};
```
水平運動根據左右箭頭鍵的狀態計算。當沒有墻壁阻擋由這個運動產生的新位置時,就使用它。否則,保留舊位置。
垂直運動的原理類似,但必須模擬跳躍和重力。玩家的垂直速度(`ySpeed`)首先考慮重力而加速。
我們再次檢查墻壁。如果我們不碰到任何一個,使用新的位置。如果存在一面墻,就有兩種可能的結果。當按下向上的箭頭,并且我們向下移動時(意味著我們碰到的東西在我們下面),將速度設置成一個相對大的負值。這導致玩家跳躍。否則,玩家只是撞到某物上,速度就被設定為零。
重力、跳躍速度和幾乎所有其他常數,在游戲中都是通過反復試驗來設定的。我測試了值,直到我找到了我喜歡的組合。
## 跟蹤按鍵
對于這樣的游戲,我們不希望按鍵在每次按下時生效。相反,我們希望只要按下了它們,他們的效果(移動球員的數字)就一直有效。
我們需要設置一個鍵盤處理器來存儲左、右、上鍵的當前狀態。我們調用`preventDefault`,防止按鍵產生頁面滾動。
下面的函數接受一個按鍵名稱數組,返回跟蹤這些按鍵的當前位置的對象。并注冊`"keydown"`和`"keyup"`事件,當事件對應的按鍵代碼存在于其存儲的按鍵代碼集合中時,就更新對象。
```js
function trackKeys(keys) {
let down = Object.create(null);
function track(event) {
if (keys.includes(event.key)) {
down[event.key] = event.type == "keydown";
event.preventDefault();
}
}
window.addEventListener("keydown", track);
window.addEventListener("keyup", track);
return down;
}
const arrowKeys =
trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);
```
兩種事件類型都使用相同的處理程序函數。該處理函數根據事件對象的type屬性來確定是將按鍵狀態修改為true(“keydown”)還是false(“keyup”)。
## 運行游戲
我們在第十四章中看到的`requestAnimationFrames`函數是一種產生游戲動畫的好方法。但該函數的接口有點過于原始。該函數要求我們跟蹤上次調用函數的時間,并在每一幀后再次調用`requestAnimationFrame`方法。
我們這里定義一個輔助函數來將這部分煩人的代碼包裝到一個名為`runAnimation`的簡單接口中,我們只需向其傳遞一個函數即可,該函數的參數是一個時間間隔,并用于繪制一幀圖像。當幀函數返回`false`時,整個動畫停止。
```js
function runAnimation(frameFunc) {
let lastTime = null;
function frame(time) {
let stop = false;
if (lastTime != null) {
let timeStep = Math.min(time - lastTime, 100) / 1000;
if (frameFunc(timeStep) === false) return;
}
lastTime = time;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
```
我們將每幀之間的最大時間間隔設置為 100 毫秒(十分之一秒)。當瀏覽器標簽頁或窗口隱藏時,`requestAnimationFrame`調用會自動暫停,并在標簽頁或窗口再次顯示時重新開始繪制動畫。在本例中,`lastTime`和`time`之差是隱藏頁面的整個時間。一步一步地推進游戲看起來很傻,可能會造成奇怪的副作用,比如玩家從地板上掉下去。
該函數也會將時間單位轉換成秒,相比于毫秒大家會更熟悉秒。
`runLevel`函數的接受Level對象和顯示對象的構造器,并返回一個`Promise`。`runLevel`函數(在`document.body`中)顯示關卡,并使得用戶通過該節點操作游戲。當關卡結束時(或勝或負),`runLevel`會多等一秒(讓用戶看看發生了什么),清除關卡,并停止動畫,如果我們指定了`andThen`函數,則`runLevel`會以關卡狀態為參數調用該函數。
```js
function runLevel(level, Display) {
let display = new Display(document.body, level);
let state = State.start(level);
let ending = 1;
return new Promise(resolve => {
runAnimation(time => {
state = state.update(time, arrowKeys);
display.setState(state);
if (state.status == "playing") {
return true;
} else if (ending > 0) {
ending -= time;
return true;
} else {
display.clear();
resolve(state.status);
return false;
}
});
});
}
```
一個游戲是一個關卡序列。每當玩家死亡時就重新開始當前關卡。當完成關卡后,我們切換到下一關。我們可以使用下面的函數來完成該任務,該函數的參數為一個關卡平面圖(字符串)數組和顯示對象的構造器。
```js
async function runGame(plans, Display) {
for (let level = 0; level < plans.length;) {
let status = await runLevel(new Level(plans[level]),
Display);
if (status == "won") level++;
}
console.log("You've won!");
}
```
因為我們使`runLevel`返回`Promise`,`runGame`可以使用`async`函數編寫,如第十一章中所見。它返回另一個`Promise`,當玩家完成游戲時得到解析。
在[本章的沙盒](https://eloquentjavascript.net/code#16)的`GAME_LEVELS`綁定中,有一組可用的關卡平面圖。這個頁面將它們提供給`runGame`,啟動實際的游戲:
```html
<link rel="stylesheet" href="css/game.css">
<body>
<script>
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
```
## 習題
### 游戲結束
按照慣例,平臺游戲中玩家一開始會有有限數量的生命,每死亡一次就扣去一條生命。當玩家生命耗盡時,游戲就從頭開始了。
調整`runGame`來實現生命機制。玩家一開始會有 3 條生命。每次啟動時輸出當前生命數量(使用`console.log`)。
```html
<link rel="stylesheet" href="css/game.css">
<body>
<script>
// The old runGame function. Modify it...
async function runGame(plans, Display) {
for (let level = 0; level < plans.length;) {
let status = await runLevel(new Level(plans[level]),
Display);
if (status == "won") level++;
}
console.log("You've won!");
}
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
```
### 暫停游戲
現在實現一個功能 —— 當用戶按下 ESC 鍵時可以暫停或繼續游戲。
我們可以修改`runLevel`函數,使用另一個鍵盤事件處理器來實現在玩家按下 ESC 鍵的時候中斷或恢復動畫。
乍看起來,`runAnimation`無法完成該任務,但如果我們使用`runLevel`來重新安排調度策略,也是可以實現的。
當你完成該功能后,可以嘗試加入另一個功能。我們現在注冊鍵盤事件處理器的方法多少有點問題。現在`arrows`對象是一個全局綁定,即使游戲沒有運行時,事件處理器也是有效的。我們稱之為系統泄露。請擴展`tracKeys`,提供一種方法來注銷事件處理器,接著修改`runLevel`在啟動游戲時注冊事件處理器,并在游戲結束后注銷事件處理器。
```html
<link rel="stylesheet" href="css/game.css">
<body>
<script>
// The old runLevel function. Modify this...
function runLevel(level, Display) {
let display = new Display(document.body, level);
let state = State.start(level);
let ending = 1;
return new Promise(resolve => {
runAnimation(time => {
state = state.update(time, arrowKeys);
display.setState(state);
if (state.status == "playing") {
return true;
} else if (ending > 0) {
ending -= time;
return true;
} else {
display.clear();
resolve(state.status);
return false;
}
});
});
}
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
```
### 怪物
它是傳統的平臺游戲,里面有敵人,你可以跳到它頂上來打敗它。這個練習要求你把這種角色類型添加到游戲中。
我們稱之為怪物。怪物只能水平移動。你可以讓它們朝著玩家的方向移動,或者像水平巖漿一樣來回跳動,或者擁有你想要的任何運動模式。這個類不必處理掉落,但是它應該確保怪物不會穿過墻壁。
當怪物接觸玩家時,效果取決于玩家是否跳到它們頂上。你可以通過檢查玩家的底部是否接近怪物的頂部來近似它。如果是這樣的話,怪物就消失了。如果沒有,游戲就輸了。
```html
<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>
<body>
<script>
// Complete the constructor, update, and collide methods
class Monster {
constructor(pos, /* ... */) {}
get type() { return "monster"; }
static create(pos) {
return new Monster(pos.plus(new Vec(0, -1)));
}
update(time, state) {}
collide(state) {}
}
Monster.prototype.size = new Vec(1.2, 2);
levelChars["M"] = Monster;
runLevel(new Level(`
..................................
.################################.
.#..............................#.
.#..............................#.
.#..............................#.
.#...........................o..#.
.#..@...........................#.
.##########..............########.
..........#..o..o..o..o..#........
..........#...........M..#........
..........################........
..................................
`), DOMDisplay);
</script>
</body>
```