JavaScript 編程精解 中文第三版 十6、項目:平臺遊戲

來源: ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目

原文:Project: A Platform Gamejavascript

譯者:飛龍css

協議:CC BY-NC-SA 4.0html

自豪地採用谷歌翻譯java

部分參考了《JavaScript 編程精解(第 2 版)》git

全部現實都是遊戲。github

Iain Banks,《The Player of Games》apache

我最初對電腦的癡迷,就像許多小孩同樣,與電腦遊戲有關。我沉迷在那個計算機所模擬出的小小世界中,我能夠操縱這個世界,我同時也沉迷在那些還沒有展開的故事之中。但我沉迷其中並非由於遊戲實際描述的故事,而是由於我能夠充分發揮個人想象力,去構思故事的發展。編程

我並不但願任何人把編寫遊戲做爲本身的事業。就像音樂產業中,那些但願加入這個行業的熱忱年輕人與實際的人才需求之間存在巨大的鴻溝,也所以產生了一個極不健康的就業環境。不過,把編寫遊戲做爲樂趣仍是至關不錯的。canvas

本章將會介紹如何實現一個小型平臺遊戲。平臺遊戲(或者叫做「跳爬」遊戲)要求玩家操縱一個角色在世界中移動,這種遊戲每每是二維的,並且採用單一側面做爲觀察視角,玩家能夠來回跳躍。數組

遊戲

咱們遊戲大體基於由 Thomas Palef 開發的 Dark Blue。我之因此選擇了這個遊戲,是由於這個遊戲既有趣又簡單,並且不須要編寫大量代碼。該遊戲看起來以下頁圖所示。

黑色的方塊表示玩家,玩家任務是收集黃色的方塊(硬幣),同時避免碰到紅色素材(「岩漿」)。當玩家收集完全部硬幣後就能夠過關。

玩家可使用左右方向鍵移動,並使用上方向鍵跳躍。跳躍正是這個遊戲角色的特長。玩家能夠跳躍到數倍於本身身高的地方,也能夠在半空中改變方向。雖然這樣不切實際,但這有助於玩家感受本身在直接控制屏幕上那個本身的化身。

該遊戲包含一個固定的背景,使用網格方式進行佈局,可可移動元素則覆蓋在背景之上。網格中的元素多是空氣、固體或岩漿。可可移動元素是玩家、硬幣或者某一塊岩漿。這些元素的位置不限於網格,它們的座標能夠是分數,容許平滑運動。

實現技術

咱們會使用瀏覽器的 DOM 來展現遊戲界面,咱們會經過處理按鍵事件來讀取用戶輸入。

與屏幕和鍵盤相關的代碼只是實現遊戲代碼中的很小一部分。因爲全部元素都只是彩色方塊,所以繪製方法並不複雜。咱們爲每一個元素建立對應的 DOM 元素,並使用樣式來爲其指定背景顏色、尺寸和位置。

因爲背景是由不會改變的方塊組成的網格,所以咱們可使用表格來展現背景。自由可移動元素可使用絕對定位元素來覆蓋。

遊戲和某些程序應該在不產生明顯延遲的狀況下繪製動畫並響應用戶輸入,性能是很是重要的。儘管 DOM 最初並不是爲高性能繪圖而設計,但實際上 DOM 的性能表現得比咱們想象中要好得多。讀者已經在第 13 章中看過一些動畫,在現代機器中,即便咱們不怎麼考慮性能優化,像這種簡單的遊戲也能夠流暢運行。

在下一章中,咱們會研究另外一種瀏覽器技術 —— <canvas>標籤。該標籤提供了一種更爲傳統的圖像繪製方式,直接處理形狀和像素而非 DOM 元素。

關卡

咱們須要一種人類可讀的、可編輯的方法來指定關卡。由於一切最開始均可以在網格,因此咱們可使用大型字符串,其中每一個字符表明一個元素,要麼是背景網格的一部分,要麼是可移動元素。

小型關卡的平面圖多是這樣的:

var simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;

句號是空的位置,井號(#)字符是牆,加號是岩漿。玩家的起始位置是 AT 符號(@)。每一個O字符都是一枚硬幣,等號(=)是一塊來回水平移動的岩漿塊。

咱們支持兩種額外的可移動岩漿:管道符號(|)表示垂直移動的岩漿塊,而v表示下落的岩漿塊 —— 這種岩漿塊也是垂直移動,但不會來回彈跳,只會向下移動,直到遇到地面纔會直接回到其起始位置。

整個遊戲包含了許多關卡,玩家必須完成全部關卡。每關的過關條件是玩家須要收集全部硬幣。若是玩家碰到岩漿,當前關卡會恢復初始狀態,而玩家能夠再次嘗試過關。

讀取關卡

下面的類存儲了關卡對象。它的參數應該是定義關卡的字符串。

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將數組索引做爲第二個參數傳遞給映射函數,它告訴咱們給定字符的xy座標。遊戲中的位置將存儲爲一對座標,左上角爲0, 0,而且每一個背景方塊爲 1 單位高和寬。

爲了解釋平面圖中的字符,Level構造器使用levelChars對象,它將背景元素映射爲字符串,角色字符映射爲類。當type是一個角色類時,它的create靜態方法用於建立一個對象,該對象被添加到startActors,映射函數爲這個背景方塊返回"empty"

角色的位置存儲爲一個Vec對象,它是二維向量,一個具備xy屬性的對象,像第六章同樣。

當遊戲運行時,角色將停在不一樣的地方,甚至徹底消失(就像硬幣被收集時)。咱們將使用一個State類來跟蹤正在運行的遊戲的狀態。

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類,例如角色的位置和大小。

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屬性,存儲了當前速度,來模擬動量和重力。

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構造器傳遞的字符,並建立適當的岩漿角色。

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屬性中)。

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產生的波長是。咱們能夠將Math.random的返回值乘以,計算出硬幣波形軌跡的初始位置。

如今咱們能夠定義levelChars對象,它將平面圖字符映射爲背景網格類型,或角色類。

const levelChars = {
  ".": "empty", "#": "wall", "+": "lava",
  "@": Player, "o": Coin,
  "=": Lava, "|": Lava, "v": Lava
};

這給了咱們建立Level實例所需的全部部件。

let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9

上面一段代碼的任務是將特定關卡顯示在屏幕上,並構建關卡中的時間與動做。

成爲負擔的封裝

本章中大多數代碼並無過多考慮封裝。首先,封裝須要耗費額外精力。封裝使得程序變得更加龐大,並且會引入額外的概念和接口。我儘可能將程序的體積控制在較小的範圍以內,避免讀者由於代碼過於龐大而走神。

其次,遊戲中的大量元素是緊密耦合在一塊兒的,若是其中一個元素行爲改變,其餘的元素頗有可能也會發生變化。咱們須要根據遊戲的工做細節來爲元素之間設計大量接口。這使得接口的效果不是很好。每當你改變系統中的某一部分時,因爲其餘部分的接口可能沒有考慮到新的狀況,所以你須要關心這一修改是否會影響到其餘部分的代碼。

系統中的某些分割點能夠經過嚴格的接口對系統進行合理的劃分,但某些分割點則不是如此。嘗試去封裝某些本沒有合理邊界的代碼必然會致使浪費大量精力。當你犯下這種大錯之際,你就會注意到你的接口變得龐大臃腫,並且隨着程序不斷演化,你須要頻繁修改這些接口。

咱們會封裝的一部分代碼是繪圖子系統。其緣由是咱們會在下一章中使用另外一種方式來展現相同的遊戲。經過將繪圖代碼隱藏在接口以後,咱們能夠在下一章中使用相同的遊戲程序,只須要插入新的顯示模塊便可。

繪圖

咱們經過定義一個「顯示器」對象來封裝繪圖代碼,該對象顯示指定關卡,以及狀態。本章定義的顯示器類型名爲DOMDisplay,由於該類型使用簡單的 DOM 元素來顯示關卡。

咱們會使用樣式表來設定實際的顏色以及其餘構建遊戲中所需的固定的屬性。建立這些屬性時,咱們能夠直接對元素的style屬性進行賦值,但這會使得遊戲代碼變得冗長。

下面的幫助函數提供了一種簡潔的方法,來建立元素並賦予它一些屬性和子節點:

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;
}

咱們建立顯示器對象時須要指定其父元素,顯示器將會建立在該父元素上,同時還需指定一個關卡對象。

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綁定會給出一個單元格在屏幕上實際佔據的像素數目。

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 使表格看起來像咱們想要的背景:

.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相乘,以將遊戲中的尺寸單位轉換爲像素。

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類,由於咱們能夠直接複用前文爲岩漿單元格定義的規則。

.actor  { position: absolute;            }
.coin   { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64);   }

setState方法用於使顯示器顯示給定的狀態。它首先刪除舊角色的圖形,若是有的話,而後在他們的新位置上從新繪製角色。試圖將 DOM 元素重用於角色,可能很吸引人,可是爲了使它有效,咱們須要大量的附加記錄,來關聯角色和 DOM 元素,並確保在角色消失時刪除元素。由於遊戲中一般只有少數角色,從新繪製它們開銷並不大。

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元素的樣式便可。

.lost .player {
  background: rgb(160, 64, 64);
}
.won .player {
  box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}

在遇到岩漿以後,玩家的顏色應該變成深紅色,暗示着角色被燒焦了。當玩家收集完最後一枚硬幣時,咱們添加兩個模糊的白色陰影來建立白色的光環效果,其中一個在左上角,一個在右上角。

咱們沒法假定關卡老是符合視口尺寸,它是咱們在其中繪製遊戲的元素。因此咱們須要調用scrollPlayerIntoView來確保若是關卡在視口範圍以外,咱們能夠滾動視口,確保玩家靠近視口的中央位置。下面的 CSS 樣式爲包裝器的DOM元素設置了一個最大尺寸,以確保任何超出視口的元素都是不可見的。咱們能夠將外部元素的position設置爲relative,所以該元素中的角色老是相對於關卡的左上角進行定位。

.game {
  overflow: hidden;
  max-width: 600px;
  max-height: 450px;
  position: relative;
}

scrollPlayerIntoView方法中,咱們找出玩家的位置並更新其包裝器元素的滾動座標。咱們能夠經過操做元素的scrollLeftscrollTop屬性,當玩家接近視口邊界時修改滾動座標。

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。

最簡單的作法是每次重繪時都滾動視口,確保玩家老是在視口中央。但這種作法會致使畫面劇烈晃動,當你跳躍時,視圖會不斷上下移動。比較合理的作法是在屏幕中央設置一個「中央區域」,玩家在這個區域內部移動時咱們不會滾動視口。

咱們如今可以顯示小型關卡。

<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包含了咱們的遊戲所需的樣式。

動做與衝突

如今咱們是時候來添加一些動做了。這是遊戲中最使人着迷的一部分。實現動做的最基本的方案(也是大多數遊戲採用的)是將時間劃分爲一個個時間段,根據角色的每一步速度和時間長度,將元素移動一段距離。咱們將以秒爲單位測量時間,因此速度以單元每秒來表示。

移動東西很是簡單。比較困難的一部分是處理元素之間的相互做用。當玩家撞到牆壁或者地板時,不可能簡單地直接穿越過去。遊戲必須注意特定的動做會致使兩個對象產生碰撞,並須要採起相應措施。若是玩家遇到牆壁,則必須停下來,若是遇到硬幣則必須將其收集起來。

想要解決一般狀況下的碰撞問題是件艱鉅任務。你能夠找到一些咱們稱之爲物理引擎的庫,這些庫會在二維或三維空間中模擬物理對象的相互做用。咱們在本章中採用更合適的方案:只處理矩形物體之間的碰撞,並採用最簡單的方案進行處理。

在移動角色或岩漿塊時,咱們須要測試元素是否會移動到牆裏面。若是會的話,咱們只要取消整個動做便可。而對動做的反應則取決於移動元素類型。若是是玩家則停下來,若是是岩漿塊則反彈回去。

這種方法須要保證每一步之間的時間間隔足夠短,確保可以在對象實際碰撞以前取消動做。若是時間間隔太大,玩家最後會懸浮在離地面很高的地方。另外一種方法明顯更好但更加複雜,即尋找到精確的碰撞點並將元素移動到那個位置。咱們會採起最簡單的方案,並確保減小動畫之間的時間間隔,以掩蓋其問題。

該方法用於判斷某個矩形(經過位置與尺寸限定)是否會碰到給定類型的網格。

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.floorMath.ceil,來計算與身體重疊的網格方塊集合。記住網格方塊的大小是1x1個單位。經過將盒子的邊上下顛倒,咱們獲得盒子接觸的背景方塊的範圍。

咱們經過查找座標遍歷網格方塊,並在找到匹配的方塊時返回true。關卡以外的方塊老是被看成"wall",來確保玩家不能離開這個世界,而且咱們不會意外地嘗試,在咱們的「rows數組的邊界以外讀取。

狀態的update方法使用touches來判斷玩家是否接觸岩漿。

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軸重疊時,就是這種狀況。

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"

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對象。

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方法來晃動。他們忽略了網格的碰撞,由於它們只是在它們本身的方塊內部晃動。

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的參數,來找到波上的新位置。而後,根據其基本位置和基於波的偏移,計算硬幣的當前位置。

還剩下玩家自己。玩家的運動對於每和軸單獨處理,由於碰到地板不該阻止水平運動,碰到牆壁不該中止降低或跳躍運動。

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"事件,當事件對應的按鍵代碼存在於其存儲的按鍵代碼集合中時,就更新對象。

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時,整個動畫中止。

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調用會自動暫停,並在標籤頁或窗口再次顯示時從新開始繪製動畫。在本例中,lastTimetime之差是隱藏頁面的整個時間。一步一步地推動遊戲看起來很傻,可能會形成奇怪的反作用,好比玩家從地板上掉下去。

該函數也會將時間單位轉換成秒,相比於毫秒你們會更熟悉秒。

runLevel函數的接受Level對象和顯示對象的構造器,並返回一個PromiserunLevel函數(在document.body中)顯示關卡,並使得用戶經過該節點操做遊戲。當關卡結束時(或勝或負),runLevel會多等一秒(讓用戶看看發生了什麼),清除關卡,並中止動畫,若是咱們指定了andThen函數,則runLevel會以關卡狀態爲參數調用該函數。

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;
      }
    });
  });
}

一個遊戲是一個關卡序列。每當玩家死亡時就從新開始當前關卡。當完成關卡後,咱們切換到下一關。咱們可使用下面的函數來完成該任務,該函數的參數爲一個關卡平面圖(字符串)數組和顯示對象的構造器。

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返回PromiserunGame可使用async函數編寫,如第十一章中所見。它返回另外一個Promise,當玩家完成遊戲時獲得解析。

本章的沙盒GAME_LEVELS綁定中,有一組可用的關卡平面圖。這個頁面將它們提供給runGame,啓動實際的遊戲:

<link rel="stylesheet" href="css/game.css">

<body>
  <script>
    runGame(GAME_LEVELS, DOMDisplay);
  </script>
</body>

習題

遊戲結束

按照慣例,平臺遊戲中玩家一開始會有有限數量的生命,每死亡一次就扣去一條生命。當玩家生命耗盡時,遊戲就從頭開始了。

調整runGame來實現生命機制。玩家一開始會有 3 條生命。每次啓動時輸出當前生命數量(使用console.log)。

<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在啓動遊戲時註冊事件處理器,並在遊戲結束後註銷事件處理器。

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

怪物

它是傳統的平臺遊戲,裏面有敵人,你能夠跳到它頂上來戰勝它。這個練習要求你把這種角色類型添加到遊戲中。

咱們稱之爲怪物。怪物只能水平移動。你可讓它們朝着玩家的方向移動,或者像水平岩漿同樣來回跳動,或者擁有你想要的任何運動模式。這個類沒必要處理掉落,可是它應該確保怪物不會穿過牆壁。

當怪物接觸玩家時,效果取決於玩家是否跳到它們頂上。你能夠經過檢查玩家的底部是否接近怪物的頂部來近似它。若是是這樣的話,怪物就消失了。若是沒有,遊戲就輸了。

<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>
相關文章
相關標籤/搜索