原文连接: blog.thoughtram.io/rxjs/2017/0…html
本文为 RxJS 中文社区 翻译文章,如需转载,请注明出处,谢谢合做!html5
若是你也想和咱们一块儿,翻译更多优质的 RxJS 文章以奉献给你们,请点击【这里】react
众所周知,Web 发展的很快。现在,响应式编程和 Angular 或 React 这样的框架同样,已是 Web 开发领域中最热门的话题之一。响应式编程变得愈来愈流行,尤为是在当今的 JavaScript 世界。从命令式编程范式到响应式编程范式,社区已经发生了巨大的变化。然而,许多开发者仍是十分纠结,经常由于响应式编程的复杂度(大量 API)、思惟转换(从命令式到响应式)和众多概念而畏缩。git
提及来容易作起来难,人们一旦掌握了某种赖以生存的技能,便会问本身若是放弃这项技能,我该怎么生存? (译者注: 人们每每不肯走出温馨区)github
本文不是要介绍响应式编程,若是你对响应式编程彻底不了解的话,我向你推荐以下学习资源:编程
本文的目的是在学习如何使用响应式思惟来构建一个家喻户晓的经典电子游戏 - 贪吃蛇。没错,就是你知道的那个!这个游戏颇有趣,但系统自己并不简单,它要保存大量的外部状态,例如比分、计时器或玩家坐标。对于咱们要实现的这个版本,咱们将重度使用 Observable 和一些操做符来完全避免使用外部状态。有时,将状态存储在 Observable 管道外部可能会很是简单省事,但记住,咱们想要拥抱响应式编程,咱们不想依赖任何外部变量来保存状态。canvas
注意: 咱们只使用 HTML5、JavaScript 和 RxJS 来将编程事件循环 (programmatic-event-loop) 的应用转变成响应事件驱动 (reactive-event-driven) 的应用。数组
代码能够经过 Github 获取,另外还有在线 demo。我鼓励你们克隆此项目,本身动手并实现一些很是酷的游戏功能。若是你作到了,别忘了在 Twitter 上@我。浏览器
正如以前所提到的,咱们将从新打造一款贪吃蛇游戏,贪吃蛇是自上世纪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
。为了获取方向键的编码,咱们能够查阅这个表格。
每一个方向的类型都是 Point2D
,Point2D
只是具备 x
和 y
属性的简单对象。每一个属性的值为 1
、-1
或 0
,值代表蛇前进的方向。后面,咱们将使用这个方向为蛇的头和尾巴计算出新的网格位置。
如今,咱们已经有了 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
来实现广播机制。RxJS 提供了不一样类型的 Subjects,它们具有不一样的功能。Subject
类自己为建立更特殊化的 Subjects 提供了基础。总而言之, Subject 类型同时实现了 Observer
和 Observable
类型。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$
。
玩家比分其实很简单。如今,有了 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$
流。正如本文开头所讨论过的,咱们须要相似计时器的东西来让饥饿的蛇保持移动。原来有个名为 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 Henry 和 Brecht Billiet 对游戏代码所给予的帮助。