[譯] RxJS 遊戲之貪吃蛇

原文連接: blog.thoughtram.io/rxjs/2017/0…html

本文爲 RxJS 中文社區 翻譯文章,如需轉載,請註明出處,謝謝合做!html5

若是你也想和咱們一塊兒,翻譯更多優質的 RxJS 文章以奉獻給你們,請點擊【這裏】react

衆所周知,Web 發展的很快。現在,響應式編程和 Angular 或 React 這樣的框架同樣,已是 Web 開發領域中最熱門的話題之一。響應式編程變得愈來愈流行,尤爲是在當今的 JavaScript 世界。從命令式編程範式到響應式編程範式,社區已經發生了巨大的變化。然而,許多開發者仍是十分糾結,經常由於響應式編程的複雜度(大量 API)、思惟轉換(從命令式到響應式)和衆多概念而畏縮。git

提及來容易作起來難,人們一旦掌握了某種賴以生存的技能,便會問本身若是放棄這項技能,我該怎麼生存? (譯者注: 人們每每不肯走出溫馨區)github

本文不是要介紹響應式編程,若是你對響應式編程徹底不瞭解的話,我向你推薦以下學習資源:編程

本文的目的是在學習如何使用響應式思惟來構建一個家喻戶曉的經典電子遊戲 - 貪吃蛇。沒錯,就是你知道的那個!這個遊戲頗有趣,但系統自己並不簡單,它要保存大量的外部狀態,例如比分、計時器或玩家座標。對於咱們要實現的這個版本,咱們將重度使用 Observable 和一些操做符來完全避免使用外部狀態。有時,將狀態存儲在 Observable 管道外部可能會很是簡單省事,但記住,咱們想要擁抱響應式編程,咱們不想依賴任何外部變量來保存狀態。canvas

注意: 咱們只使用 HTML5JavaScriptRxJS 來將編程事件循環 (programmatic-event-loop) 的應用轉變成響應事件驅動 (reactive-event-driven) 的應用。數組

代碼能夠經過 Github 獲取,另外還有在線 demo。我鼓勵你們克隆此項目,本身動手並實現一些很是酷的遊戲功能。若是你作到了,別忘了在 Twitter 上@我。瀏覽器

目錄

  • 遊戲概覽
  • 設置遊戲區域
  • 肯定源頭流
  • 蛇的轉向
    • direction$ 流
  • 記錄長度
    • BehaviorSubject 來拯救
    • 實現 score$
  • 馴服 snake$
  • 生成蘋果
    • 廣播事件
  • 整合代碼
    • 性能維護
    • 渲染場景
  • 後續工做
  • 特別感謝

遊戲概覽

正如以前所提到的,咱們將從新打造一款貪吃蛇遊戲,貪吃蛇是自上世紀70年代後期之後的經典電子遊戲。咱們並非徹底照搬經典,有添加一些小改動。下面是遊戲的運行方式。緩存

由玩家來控制飢腸轆轆的蛇,目標是吃掉儘量多的蘋果。蘋果會在屏幕上隨機位置出現。蛇每次吃掉一個蘋果後,它的尾巴就會變長。四周的邊界不會阻擋蛇的前進!但要記住,要不惜一切代價來避免讓蛇首尾相撞。一旦撞上,遊戲便會結束。你能生存多久呢?

下面是遊戲運行時的預覽圖:

對於具體的實現,藍色方塊組成的線表明蛇,而蛇頭是黑色的。你能猜到蘋果長什麼樣子嗎?沒錯,急速紅色方塊。這裏的一切都是由方塊組成的,並非由於方塊有多漂亮,而是由於它們的形狀夠簡單,畫起來容易。遊戲的畫質確實不夠高,可是,咱們的初衷是命令式編程到響應式編程的轉換,而並不是遊戲的藝術。

設置遊戲區域

在開始實現遊戲功能以前,咱們須要建立 <canvas> 元素,它可讓咱們在 JavaScript 中使用功能強大的繪圖 API 。咱們將使用 canvas 來繪製咱們的圖形,包括遊戲區域、蛇、蘋果以及遊戲所需的一切。換句話說,整個遊戲都是渲染在 <canvas> 元素中的。

若是你對 canvas 徹底不瞭解,請先查閱 Keith Peters 在 egghead 上的相關課程

index.html 至關簡單,由於基本全部工做都是由 JavaScript 來完成的。

<html>
<head>
  <meta charset="utf-8">
  <title>Reactive Snake</title>
</head>
<body>
  <script src="/main.bundle.js"></script>
</body>
</html>
複製代碼

添加到 body 尾部的腳本是構建後的輸出,它包含咱們全部的代碼。可是,你可能會疑惑爲何 <body> 中並無 <canvas> 元素。這是由於咱們將使用 JavaScript 來建立元素。此外,咱們還定義了一些常量,好比遊戲區域的行數和列數,canvas 元素的寬度和高度。

export const COLS = 30;
export const ROWS = 30;
export const GAP_SIZE = 1;
export const CELL_SIZE = 10;
export const CANVAS_WIDTH = COLS * (CELL_SIZE + GAP_SIZE);
export const CANVAS_HEIGHT = ROWS * (CELL_SIZE + GAP_SIZE);

export function createCanvasElement() {
  const canvas = document.createElement('canvas');
  canvas.width = CANVAS_WIDTH;
  canvas.height = CANVAS_HEIGHT;
  return canvas;
}
複製代碼

咱們經過調用 createCanvasElement 函數來動態建立 <canvas> 元素並將其追加到 <body> 中:

let canvas = createCanvasElement();
let ctx = canvas.getContext('2d');
document.body.appendChild(canvas);
複製代碼

注意,咱們經過調用 <canvas> 元素的 getContext('2d') 方法來獲取 CanvasRenderingContext2D 的引用。它是 canvas 的 2D 渲染上下文,使用它能夠繪製矩形、文字、線、路徑,等等。

準備就緒!咱們來開始編寫遊戲的核心機制。

肯定源頭流

根據遊戲的預覽圖及描述,得知咱們的遊戲須要下列功能:

  • 使用方向鍵來操控蛇
  • 記錄玩家的分數
  • 記錄蛇(包括吃蘋果和移動)
  • 記錄蘋果(包括生成新蘋果)

在響應式編程中,編程無外乎數據流及輸入數據流。從概念上來講,當響應式編程執行時,它會創建一套可觀察的管道,能夠根據變化採起行動。例如,用戶能夠經過按鍵或簡單開啓一個計時器與應用進行互動。因此這一切都是爲找出什麼能夠發生變化。這些變化一般定義了源頭流。那麼關鍵就在於找出那些表明變化產生的主要源頭,而後將其組合起來以計算出所須要的一切,例如遊戲狀態。

咱們來試着經過上面的功能描述來找出這些源頭流。

首先,用戶輸入確定是隨着時間流逝而一直變化的。玩家使用方向鍵來操控蛇。這意味着咱們找到了第一個源頭流 keydown$,每次按鍵它都會發出值。

接下來,咱們須要記錄玩家的分數。分數主要取決於蛇吃了多少個蘋果。能夠說分數取決於蛇的長度,由於每當蛇吃掉一個蘋果後身體變長,一樣的咱們將分數加 1 。那麼,咱們下一個源頭流是 snakeLength$

此外,找出以計算出任何你所須要的主要數據源 (例如比分) 也很重要。在大多數場景下,源頭流會被合併成更具體的數據流。咱們很快就會接觸到。如今,咱們仍是來繼續找出主要的源頭流。

到目前爲止,咱們已經有了用戶輸入和比分。剩下的是一些遊戲相關或交互相關的流,好比蛇或蘋果。

咱們先從蛇開始。蛇的核心機制其實很簡單,它隨時間而移動,而且它吃的蘋果越多,它就會變得越長。但蛇的源頭流到底應該是什麼呢?目前,讓咱們先暫時放下蛇吃蘋果和身體變長的因素,由於它隨時間而移動,因此它最重要的是依賴於時間因素,例如,每 200ms 移動 5 像素。所以,蛇的源頭流是一個定時器,它每隔必定時間便會產生值,咱們將其稱之爲 ticks$ 。這個流還決定了蛇的移動速度。

最後的源頭流是蘋果。當其餘都準備好後,蘋果就很是簡單了。這個流基本上是依賴於蛇的。每次蛇移動時,咱們都要檢查蛇頭是否與蘋果碰撞。若是相撞,就移除掉蘋果並在隨機位置生成一個新蘋果。也就是說,咱們並不須要爲蘋果引入一個新的源頭流。

不錯,源頭流已經都找出來了。下面是本遊戲所需的全部源頭流的簡要概述:

  • keydown$: keydown 事件 (KeyboardEvent)
  • snakeLength$: 表示蛇的長度 (Number)
  • ticks$: 定時器,表示蛇的速度 (Number)

這些源頭流構成了遊戲的基礎,其餘咱們所須要的值,包括比分、蛇和蘋果,能夠經過這些源頭流計算出來。

在下節中,咱們將會介紹如何來實現每一個源頭流,並將它們組合起來生成咱們所需的數據。

蛇的轉向

咱們來深刻到編碼環節並實現蛇的轉向機制。正如前一節所說起的,蛇的轉向依賴於鍵盤輸入。實際上很簡單,首先建立一個鍵盤事件的 observable 序列。咱們能夠利用 fromEvent() 操做符來實現:

let keydown$ = Observable.fromEvent(document, 'keydown');
複製代碼

這是咱們的第一個源頭流,用戶每次按鍵時它都會發出 KeyboardEvent 。注意,按字面意思理解是會發出每一個 keydown 事件。然而,咱們其實關心的是隻是方向鍵,並不是全部按鍵。在咱們處理這個具體問題以前,先定義了一個方向鍵的常量映射:

export interface Point2D {
  x: number;
  y: number;
}

export interface Directions {
  [key: number]: Point2D;
}

export const DIRECTIONS: Directions = {
  37: { x: -1, y: 0 }, // 左鍵
  39: { x: 1, y: 0 },  // 右鍵
  38: { x: 0, y: -1 }, // 上鍵
  40: { x: 0, y: 1 }   // 下鍵
};
複製代碼

KeyboardEvent 對象中每一個按鍵都對應一個惟一的 keyCode 。爲了獲取方向鍵的編碼,咱們能夠查閱這個表格

每一個方向的類型都是 Point2DPoint2D 只是具備 xy 屬性的簡單對象。每一個屬性的值爲 1-10,值代表蛇前進的方向。後面,咱們將使用這個方向爲蛇的頭和尾巴計算出新的網格位置。

direction$ 流

如今,咱們已經有了 keydown 事件的流,每次玩家按鍵後,咱們須要將其映射成值,即把 KeyboardEvent 映射成上面的某個方向向量。對此咱們可使用 map() 操做符。

let direction$ = keydown$
  .map((event: KeyboardEvent) => DIRECTIONS[event.keyCode])
複製代碼

如前面所提到的,咱們會收到每一個按鍵事件,由於咱們還未過濾掉咱們不關心的按鍵,好比字符鍵。可是,可能有人會說,咱們已經經過在方向映射中查找事件來進行過濾了。在映射中找不到的 keyCode 會返回 undefined 。儘管如此,對於咱們的流來講這並不是真正意義上的過濾,這也就是咱們爲何要使用 filter() 操做符來過濾出方向鍵。

let direction$ = keydown$
  .map((event: KeyboardEvent) => DIRECTIONS[event.keyCode])
  .filter(direction => !!direction)
複製代碼

好吧,這也很簡單。上面的代碼已經足夠好了,也能按咱們的預期工做。可是,它還有提高的空間。你能想到什麼嗎?

有一點就是咱們想要阻止蛇朝反方向前進,例如,從左至右或從上到下。像這樣的行爲徹底沒有意義,由於遊戲的首要原則是避免首尾相撞,還記得嗎?

解決方法也想當簡單。咱們緩存前一個方向,當新的 keydown 事件發出後,咱們檢查新方向與前一個方向是不是相反的。下面是計算下一個方向的函數:

export function nextDirection(previous, next) {
  let isOpposite = (previous: Point2D, next: Point2D) => {
    return next.x === previous.x * -1 || next.y === previous.y * -1;
  };

  if (isOpposite(previous, next)) {
    return previous;
  }

  return next;
}
複製代碼

這是咱們首次嘗試在 Observable 管道外存儲狀態,由於咱們須要保存前一個方向,是這樣吧?使用外部狀態變量來保存前一個方向確實是種簡單的解決方案。可是等等!咱們要極力避免這一切,不是嗎?

要避免使用外部狀態,咱們須要一種方法來聚合無限的 Observables 。RxJS 爲咱們提供了這樣一個便利的操做符來解決此類問題: scan()

scan() 操做符與 Array.reduce() 很是相像,不過它不是返回最後的聚合值,而是每次 Observable 發出值時它都會發出生成的中間值。使用 scan(),咱們即可以聚合值,並沒有限次地將傳入的事件流歸併爲單個值。這樣的話,咱們就能夠保存前一個方向而無需依靠外部狀態。

下面是應用 scan() 後,最終版的 direction$ 流:

let direction$ = keydown$
  .map((event: KeyboardEvent) => DIRECTIONS[event.keyCode])
  .filter(direction => !!direction)
  .scan(nextDirection)
  .startWith(INITIAL_DIRECTION)
  .distinctUntilChanged();
複製代碼

注意這裏咱們使用了 startWith(),它會在源 Observable (keydown$) 開始發出值錢發出一個初始值。若是不使用 startWith(),那麼只有當玩家按鍵後,咱們的 Observable 纔會開始發出值。

第二個改進點是隻有當發出的方向與前一個不一樣時纔會將其發出。換句話說,咱們只想要不一樣的值。你可能注意到上面代碼中的 distinctUntilChanged() 。這個操做符替咱們完成了抑制重複項的繁重工做。注意,distinctUntilChanged() 只會過濾掉兩次發送之間的相同值。

下圖展現了 direction$ 流以及它的工做原理。藍色的值表示初始值,黃色的表示通過 Observable 管道修改過的值,橙色的表示結果流上的發出值。

記錄長度

在實現蛇自己以前,咱們先想一想如何來記錄它的長度。爲何咱們首先須要長度呢?咱們須要長度信息做爲比分的數據來源。在命令式編程的世界中,蛇每次移動時,咱們只需簡單地檢查是否有碰撞便可,若是有的話就增長比分。因此徹底不須要記錄長度。可是,這樣仍然會引入另外一個外部狀態變量,這是咱們要極力避免的。

在響應式編程的世界中,實現方式是不一樣的。一個簡單點的方式是使用 snake$ 流,每次發出值時咱們便知道蛇的長度是否增加。然而這也取決於 snake$ 流的實現,但這並不是咱們用來實現的方式。一開始咱們就知道 snake$ 依賴於 ticks$,由於它隨着時間而移動。snake$ 流自己也會累積成身體的數組,而且由於它基於 ticks$ticks$x 毫秒會發出一個值。也就是說,及時蛇沒有發生任何碰撞,snake$ 流也會生成不一樣的值。這是由於蛇在不停的移動,因此數組永遠都是不同的。

這可能有些難以理解,由於不一樣的流之間存在一些同級依賴。例如,apples$ 依賴於 snake$ 。緣由是這樣的,每次蛇移動時,咱們須要蛇身的數組來檢查是否與蘋果相撞。然而,apples$ 流自己還會累積出蘋果的數組,咱們須要一種機制來模擬碰撞,同時避免循環依賴。

BehaviorSubject 來拯救

解決方案是使用 BehaviorSubject 來實現廣播機制。RxJS 提供了不一樣類型的 Subjects,它們具有不一樣的功能。Subject 類自己爲建立更特殊化的 Subjects 提供了基礎。總而言之, Subject 類型同時實現了 ObserverObservable 類型。Observables 定義了數據流併產生數據,而 Observers 能夠訂閱 Observables (觀察者) 並接收數據。

BehaviorSubject 是一種特殊類型的 Subject,它表示一個隨時間而變化的值。如今,當觀察者訂閱了 BehaviorSubject,它會接收到最後發出的值以及後續發出的全部值。它的獨特性在於須要一個初始值,所以全部觀察者在訂閱時至少都能接收到一個值。

咱們繼續,使用初始值 SNAKE_LENGTH 來建立一個新的 BehaviorSubject:

// SNAKE_LENGTH 指定了蛇的初始長度
let length$ = new BehaviorSubject<number>(SNAKE_LENGTH);
複製代碼

到這,距離實現 snakeLength$ 只需一小步:

let snakeLength$ = length$
  .scan((step, snakeLength) => snakeLength + step)
  .share();
複製代碼

在上面的代碼中,咱們能夠看到 snakeLength$ 是基於 length$ 的,length$ 也就是咱們的 BehaviorSubject 。這意味着每當咱們使用 next() 來給 Subject 提供值,這個值就會在 snakeLength$ 上發出。此外,咱們使用了 scan() 來隨時間推移累積長度。酷,但你可能會好奇,這個 share() 是作什麼的,是這樣吧?

正如以前所提到的,snakeLength$ 稍後會做爲 snake$ 的輸入流,但同時又是玩家比分的源頭流。所以,咱們將對同一個 Observable 進行第二次訂閱,最終致使從新建立了源頭流。這是由於 length$ 是冷的 Observable 。

若是你徹底不清楚熱的和冷的 Observables,咱們以前寫過一篇關於 Cold vs Hot Observables 的文章。

關鍵點是使用 share() 來容許屢次訂閱 Observable,不然每次訂閱都會從新建立源 Observable 。此操做符會自動在原始源 Observable 和將來全部訂閱者之間建立一個 Subject 。只要訂閱者的數量從0 變爲到 1,它就會將 Subject 鏈接到底層的源 Observable 並廣播全部通知。全部將來的訂閱者都將鏈接到中間的 Subject,因此實際上底層的冷的 Observable 只有一個訂閱。

酷!如今咱們已經擁有了向多個訂閱者廣播值的機制,咱們能夠繼續來實現 score$

實現 score$

玩家比分其實很簡單。如今,有了 snakeLength$ 的咱們再來建立 score$ 流只需簡單地使用 scan() 來累積玩家比分便可:

let score$ = snakeLength$
  .startWith(0)
  .scan((score, _) => score + POINTS_PER_APPLE);
複製代碼

咱們基本上使用 snakeLength$length$ 來通知訂閱者有碰撞(若是有的話),咱們經過 POINTS_PER_APPLE 來增長分數,每一個蘋果的分數是固定的。注意 startWith(0) 必須在 scan() 前面,以免指定種子值(初始的累積值)。

來看看咱們剛剛所實現的可視化展現:

經過上圖,你可能會奇怪爲何 BehaviorSubject 的初始值只出如今 snakeLength$ 中,而並無出如今 score$ 中。那是由於第一個訂閱者將使得 share() 訂閱底層的數據源,而底層的數據源會當即發出值,當隨後的訂閱再發生時,這個值實際上是已經存在了的。

酷。準備就緒後,咱們來實現蛇的流,是否是很興奮呢?

馴服 snake$

到目前爲止,咱們已經學過了一些操做符,咱們能夠用它們來實現 snake$ 流。正如本文開頭所討論過的,咱們須要相似計時器的東西來讓飢餓的蛇保持移動。原來有個名爲 interval(x) 的便利操做符能夠作這件事,它每隔 x 毫秒就會發出值。咱們將每一個值稱之爲 tick (鐘錶的滴答聲)。

let ticks$ = Observable.interval(SPEED);
複製代碼

ticks$ 到最終的 snake$ ,咱們還有一小段路要走。每次定時器觸發,咱們是想要蛇繼續前進仍是增長它的身長,這取決於蛇是否吃到了蘋果。因此,咱們依舊可使用熟悉的 scan() 操做符來累積出蛇身的數組。可是,你或許已經猜到了,咱們仍面臨一個問題。如何將 direction$snakeLength$ 流引入進來?

這絕對是合理的問題。不管是方向仍是蛇的長度,若是想要在 snake$ 流中輕易訪問它們,那麼就要在 Observable 管道以外使用變量來保存這些信息。可是,這樣的話咱們將再次違背了修改外部狀態的規則。

幸運的是,RxJS 提供了另外一個很是便利的操做符 withLatestFrom() 。這個操做符用來組合流,並且它偏偏是咱們所須要的。此操做符應用於主要的源 Observable,由它來控制合適將數據發送到結果流上。換句話說,你能夠把 withLatestFrom() 看做是一種限制輔助流輸出的方式。

如今,咱們有了實現最終 snake$ 流所需的工具:

let snake$ = ticks$
  .withLatestFrom(direction$, snakeLength$, (_, direction, snakeLength) => [direction, snakeLength])
  .scan(move, generateSnake())
  .share();
複製代碼

咱們主要的源 Observable 是 ticks$,每當管道上有新值發出,咱們就取 direction$snakeLength$ 的最新值。注意,即便輔助流頻繁地發出值(例如,玩家頭撞鍵盤上),也只會在每次定時器發出值時處理數據。

此外,咱們給 withLatestFrom 傳入了選擇器函數,當主要的流產生值時纔會調用此函數。此函數是可選的,若是不傳,將會生成包含全部元素的列表。

這裏咱們並無講解 move() 函數,由於本文的首要目的是幫助你進行思惟轉換。可是,你能夠在 GitHub 上找到此函數的源碼。

下面的圖片是上面代碼的可視化展現:

看到如何對 direction$ 進行節流了吧?關鍵在於 withLatestFrom(),當你想組合多個流時,而且對這些被組合的流所發出的數據不敢興趣時,它是很是實用的。

生成蘋果

你或許已經注意到了,隨着咱們學到的操做符愈來愈多,實現咱們遊戲的核心代碼塊,得愈來愈簡單了。若是你已經堅持到這了,那麼剩下的部分基本也沒什麼難度。

目前爲止,咱們已經實現了一些流,好比 direction$snakeLength$score$snake$ 。若是如今講這些流組合在一塊兒的話,咱們其實已經能夠操縱蛇跑來跑去了。可是,若是貪吃蛇遊戲沒有任何能吃的,那遊戲就一點意思都沒有了,無聊的很。

咱們來生成一些蘋果以知足蛇的食慾。首先,咱們須要理清須要保存的狀態。它能夠是一個對象,也能夠是一個對象數組。咱們在這裏的實現將使用後者,蘋果的數組。你是否聽到了勝利的鐘聲?

好吧,咱們能夠再次使用 scan() 來累積出蘋果的數組。咱們開始提供蘋果數組的初始值,而後每次蛇移動時都檢查是否有碰撞。若是有碰撞,咱們就生成一個新的蘋果並返回一個新的數組。這樣的話咱們即可以利用 distinctUntilChanged() 來過濾掉徹底相同的值。

let apples$ = snake$
  .scan(eat, generateApples())
  .distinctUntilChanged()
  .share();
複製代碼

酷!這意味着每當 apples$ 產生一個新值時,咱們就能夠假定蛇吞掉了一個蘋果。剩下要作的就是增長比分,還要將此事件通知給其餘流,好比 snake$,它從 snakeLength$ 中獲取最新值,以肯定是否將蛇的身體變長。

廣播事件

以前咱們已經實現了廣播機制,還記得嗎?咱們用它來觸發目標動做。下面是 eat() 的代碼:

export function eat(apples: Array<Point2D>, snake) {
  let head = snake[0];

  for (let i = 0; i < apples.length; i++) {
    if (checkCollision(apples[i], head)) {
      apples.splice(i, 1);
      // length$.next(POINTS_PER_APPLE);
      return [...apples, getRandomPosition(snake)];
    }
  }

  return apples;
}
複製代碼

簡單的解決方式就是直接在 if 中調用 length$.next(POINTS_PER_APPLE) 。但這樣作的話將面臨一個問題,咱們沒法將這個工具方法提取到它本身的模塊 (ES2015 模塊) 中。ES2015 模塊通常都是一個模塊一個文件。這樣組織代碼的目的主要是讓代碼變的更容易維護和推導。

複雜一點的解決方式是引入另一個流,咱們將其命名爲 applesEaten$ 。這個流是基於 apples$ 的,每次流種發出新值時,咱們就執行某個動做,即調用 length$.next() 。爲此,咱們可使用 do() 操做符,每次發出值時它都會執行一段代碼。

聽起來可行。可是,咱們須要經過某種方式來跳過 apple$ 發出的第一個值 (初始值)。不然,最終將變成開場馬上增長比分,這在遊戲剛剛開始時是沒有意義的。好在 RxJS 爲咱們提供了這樣的操做符,skip()

事實上,applesEaten$ 只負責扮演通知者的角色,它只負責通知其餘的流,而不會有觀察者來訂閱它。所以,咱們須要手動訂閱。

let appleEaten$ = apples$
  .skip(1)
  .do(() => length$.next(POINTS_PER_APPLE))
  .subscribe();
複製代碼

整合代碼

此刻,咱們已經實現了遊戲中的全部核心代碼塊,咱們終於能夠將這些組合成最終的結果流 scene$ 了。咱們將使用 combineLatest 操做符。它相似於 withLatestFrom,但有一些不一樣點。首先,咱們來看下代碼:

let scene$ = Observable.combineLatest(snake$, apples$, score$, (snake, apples, score) => ({ snake, apples, score }));
複製代碼

withLatestFrom 不一樣的是,咱們不會對限制輔助流,咱們關心每一個輸入 Observable 產生的新值。最後一個參數仍是選擇器函數,咱們將全部數據組合成一個表示遊戲狀態的對象,並將對象返回。遊戲狀態包含了 canvas 渲染所需的全部數據。

性能維護

不管是遊戲,仍是 Web 應用,性能都是咱們所追求的。性能的意義重大,但就咱們的遊戲而言,咱們但願每秒重繪整個場景 60 次。

咱們能夠經過引入另一個相似 tick$ 的流來負責渲染。從根本上來講,它就是另一個定時器:

// interval 接收以毫秒爲單位的時間週期,這也就是爲何咱們要用 1000 來除以 FPS
Observable.interval(1000 / FPS)
複製代碼

問題是 JavaScript 是單線程的。最糟糕的狀況是,咱們阻止瀏覽器執行任何操做,致使其鎖定。換句話說,瀏覽器可能沒法快速處理全部這些更新。緣由是瀏覽器正在嘗試渲染一幀,而後當即被要求渲染下一幀。做爲結果,它會拋下當前幀以維持速度。這時候動畫就開始看上去有些不流暢了。

幸運的是,咱們可使用 requestAnimationFrame 來容許瀏覽器對任務進行排隊,並在最合適的時間執行任務。可是,咱們如何在 Observable 管道中使用呢?好消息是包括 interval() 在內的衆多操做符都接收 Scheduler (調度器) 做爲最後的參數。總而言之,Scheduler 是一種調度未來要執行的任務的機制。

雖然 RxJS 提供了多種調度器,但咱們關心的是名爲 animationFrame 的調度器。此調度器在 window.requestAnimationFrame 觸發時執行任務。

完美!咱們來將其應用於 interval,咱們將結果 Observable 命名爲 game$:

// 注意最後一個參數
const game$ = Observable.interval(1000 / FPS, animationFrame)
複製代碼

如今 interval 大概每 16ms 發出一次值,從而保持 FPS 在 60 左右。

渲染場景

剩下要作的就是將 game$scene$ 組合起來。你能猜到咱們要使用哪一個操做符嗎?這兩個流都是計時器,只是時間間隔不一樣,咱們的目標是將遊戲場景渲染到 canvas 中,每秒 60 次。咱們將 game$ 做爲主要的流,每次它發出值時,咱們將它與 scene$ 中的最新值組合起來。聽上去很耳熟是吧?沒錯,咱們此次使用的仍是 withLastFrom

// 注意最後一個參數
const game$ = Observable.interval(1000 / FPS, animationFrame)
  .withLatestFrom(scene$, (_, scene) => scene)
  .takeWhile(scene => !isGameOver(scene))
  .subscribe({
    next: (scene) => renderScene(ctx, scene),
    complete: () => renderGameOver(ctx)
  });
複製代碼

你或許已經發現上面代碼中的 takeWhile() 了。它是另一個很是有用的操做符,能夠在現有的 Observable 上來調用它。它會返回 game$ 的值直到 isGameOver() 返回 true

就是這樣!咱們已經完成了整個貪吃蛇遊戲,而且徹底是用響應式編程的方式完成的,徹底沒有依賴任何外部狀態,使用的只有 RxJS 提供的 Observables 和操做符。

這是能夠在線試玩的 demo

後續工做

目前遊戲實現的還很簡單,在後續文章中咱們未來擴展各類功能,其中一個即是從新開始遊戲。此外,咱們還將介紹如何實現暫停繼續功能,以及不一樣級別的難度。

敬請關注!

特別感謝

在此特別感謝 James HenryBrecht Billiet 對遊戲代碼所給予的幫助。

相關文章
相關標籤/搜索