[譯] RxJS 遊戲之貪吃蛇

SangKa發表於2019-02-26

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

本文為 RxJS 中文社群 翻譯文章,如需轉載,請註明出處,謝謝合作!

如果你也想和我們一起,翻譯更多優質的 RxJS 文章以奉獻給大家,請點選【這裡】

眾所周知,Web 發展的很快。如今,響應式程式設計和 Angular 或 React 這樣的框架一樣,已經是 Web 開發領域中最熱門的話題之一。響應式程式設計變得越來越流行,尤其是在當今的 JavaScript 世界。從指令式程式設計正規化到響應式程式設計正規化,社群已經發生了巨大的變化。然而,許多開發者還是十分糾結,常常因為響應式程式設計的複雜度(大量 API)、思維轉換(從命令式到響應式)和眾多概念而畏縮。

說起來容易做起來難,人們一旦掌握了某種賴以生存的技能,便會問自己如果放棄這項技能,我該怎麼生存? (譯者注: 人們往往不願走出舒適區)

本文不是要介紹響應式程式設計,如果你對響應式程式設計完全不瞭解的話,我向你推薦如下學習資源:

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

注意: 我們只使用 HTML5JavaScriptRxJS 來將程式設計事件迴圈 (programmatic-event-loop) 的應用轉變成響應事件驅動 (reactive-event-driven) 的應用。

程式碼可以通過 Github 獲取,另外還有線上 demo。我鼓勵大家克隆此專案,自己動手並實現一些非常酷的遊戲功能。如果你做到了,別忘了在 Twitter 上@我。

目錄

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

遊戲概覽

正如之前所提到的,我們將重新打造一款貪吃蛇遊戲,貪吃蛇是自上世紀70年代後期以後的經典電子遊戲。我們並不是完全照搬經典,有新增一些小改動。下面是遊戲的執行方式。

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

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

[譯] RxJS 遊戲之貪吃蛇

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

設定遊戲區域

在開始實現遊戲功能之前,我們需要建立 <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 管道修改過的值,橙色的表示結果流上的發出值。

[譯] RxJS 遊戲之貪吃蛇

記錄長度

在實現蛇本身之前,我們先想想如何來記錄它的長度。為什麼我們首先需要長度呢?我們需要長度資訊作為比分的資料來源。在指令式程式設計的世界中,蛇每次移動時,我們只需簡單地檢查是否有碰撞即可,如果有的話就增加比分。所以完全不需要記錄長度。但是,這樣仍然會引入另一個外部狀態變數,這是我們要極力避免的。

在響應式程式設計的世界中,實現方式是不同的。一個簡單點的方式是使用 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() 前面,以避免指定種子值(初始的累積值)。

來看看我們剛剛所實現的視覺化展示:

[譯] RxJS 遊戲之貪吃蛇

通過上圖,你可能會奇怪為什麼 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 上找到此函式的原始碼。

下面的圖片是上面程式碼的視覺化展示:

[譯] RxJS 遊戲之貪吃蛇

看到如何對 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 渲染所需的所有資料。

[譯] RxJS 遊戲之貪吃蛇

效能維護

無論是遊戲,還是 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 對遊戲程式碼所給予的幫助。

相關文章