ジェットストリームでヘビを飼いならす







ウェブは最近非常に速く動いており、私たちは皆それを知っています。 今日、リアクティブプログラミングはWeb開発で最もホットなトピックの1つであり、AngularやReactなどのフレームワークを使用することで、特に現代のJavaScriptの世界で非常に人気が高まっています。 コミュニティは、命令型プログラミングパラダイムから機能的な反応型パラダイムへの大規模な移行を経験しています。 ただし、多くの開発者はこれに対処しようとし、その複雑さ(大規模なAPI)、思考の根本的な変化(命令型から宣言型へ)、および多くの概念にしばしば圧倒されます。



これは最も簡単なトピックではありませんが、理解できるとすぐに、それなしでどのように生きることができるかを自問します。



この記事は、リアクティブプログラミングの紹介を目的とするものではありません。完全な初心者の方は、次のリソースをお勧めします。





この出版物の目的は、私たち全員が知っている、大好きな古典的なビデオゲーム、Snakeを作成することにより、反応的に考えることを学ぶことです。 そう、ビデオゲーム! これは面白いですが、例えばスコア、タイマー、プレーヤーの座標など、多くの外部状態を含む複雑なシステムです。 このバージョンでは、Observables(observables)を広く使用し、いくつかの異なる演算子を使用して、外部状態へのサードパーティの影響を完全に回避します。 ある時点で、Observableストリームの外部に状態を保存したいと思うかもしれませんが、状態を保存する別の外部変数に依存せず、リアクティブプログラミングを使用することを忘れないでください。



ご注意 RxJSのみでHTML5JavaScriptを使用し 、イベントループをリアクティブイベントベースのアプリケーションに変換します。



コードはGithubで入手できます 。デモはこちらから入手できます 。 プロジェクトのクローンを作成し、少し手を加え、興味深い新しいゲーム機能を実装することをお勧めします。 もしそうなら、 Twitterで私にメールしてください。



内容





ゲーム



前述したように、1970年代後半の古典的なビデオゲームであるSnakeを再現します。 ただし、ゲームをコピーするだけでなく、少しバリエーションを追加します。 これがゲームの仕組みです。



プレイヤーとして、あなたは空腹のヘビのように見えるラインを制御します。 目標は、できるだけ長く成長するために、できるだけ多くのリンゴを食べることです。 リンゴは、画面上のランダムな位置にあります。 ヘビがリンゴを食べるたびに、その尾は長くなります。 壁はあなたを止めません! しかし、聞いてください、あなたはあなたの体にどんな犠牲を払っても入らないようにするべきです。 そうしないと、ゲームオーバーです。 どのくらい生き延びられますか?



これから行うことの予備的な例を示します。







この特定の実装では、ヘビは頭が黒く塗られた青い正方形の線として表されます。 果物がどのように見えるか教えてもらえますか? まさに、赤い四角。 すべてのエンティティは正方形です。これは、この方法でより美しく見えるからではなく、非常に単純な幾何学的形状であり、描画しやすいためです。 グラフィックスはそれほど素晴らしいものではありませんが、ゲームアートではなく、命令型プログラミングからリアクティブプログラミングへの移行について話しているところです。



シーン設定



ゲームの機能を使用する前に、強力なJavaScript描画APIを提供するキャンバス要素(キャンバス)を作成する必要があります。 キャンバスを使用して、競技場、ヘビ、リンゴ、基本的にゲームに必要なすべてのものを含むグラフィックを描画します。 言い換えると、ゲームはキャンバス要素に完全に表示されます



これが真新しい場合は、 このキースピーターズのエッグヘッドコースをご覧ください。



index.htmlファイルは、ほとんどの魔法がJavaScriptで発生するため、非常に単純です。



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







本文に追加するスクリプトは、基本的にビルドプロセスの結果であり、すべてのコードが含まれています。 しかし、なぜbodyに canvasのような要素がないのか疑問に思うかもしれません。 これは、JavaScriptを使用してこの要素を作成するためです。 さらに、 列の数、およびキャンバスの高さを決定するいくつかの定数を追加ます。



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





これにより、この関数を呼び出して、オンザフライでキャンバス要素を作成し、それをページの本文に追加できます。



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





キャンバス要素でgetContext( '2d')を呼び出すことにより、 CanvasRenderingContext2Dへの参照も取得することに注意してください。 このキャンバス2Dレンダリングコンテキストを使用すると、たとえば、四角形、テキスト、線、パスなどを描画できます。



移動する準備ができました! ゲームの基本的な仕組みを始めましょう。



ソースストリームの識別



ゲームの例と説明に基づいて、次の機能が必要であることを知っています。





リアクティブプログラミングでは、データストリーム、入力データストリームに基づいたプログラミングについて話しています。 概念的には、リアクティブプログラムが実行されている場合、情報ソースの監視を確立し、たとえば、キーボードでキーが押されたとき、または単に間隔の次の段階で、アプリケーションとのユーザーインタラクションなどの変更に応答します。 ですから、全体が何が変わるのかを理解することです。 これらの変更は、多くの場合、 ソーススレッドを決定します 。 主なタスクは、ソースストリームを特定し、それらを組み合わせて、たとえばゲームの状態など、必要なすべてを計算することです。



上記の関数を見て、ソーススレッドを見つけてみましょう。



まず第一に、ユーザー入力は間違いなく時間とともに変化します。 プレイヤーは矢印キーを使用して空腹のヘビを動かします。 つまり、最初のソースストリームはkeydown $であり、キーが押されるたびに値の変更がトリガーされます。



次に、プレーヤーのスコアを監視する必要があります。 スコアは、主にヘビが食べたリンゴの数に依存します。 ヘビが成長するたびにカウントを1増やしたいため、カウントはヘビの長さに依存すると言うことができます。 したがって、次のソースストリームはsnakeLength $です。



繰り返しになりますが、たとえばアカウントなど、必要なすべてを計算できるメインフローを決定することが重要です。 ほとんどの場合、元のストリームは結合され、より具体的なデータストリームに変換されます。 すぐに実際に動作します。 とりあえず、メインフローの定義を続けましょう。



現時点では、ユーザー入力イベントとアカウントを受け取っています。 私たちには、ヘビやリンゴなど、より多くのゲームまたはインタラクティブストリームが残っています。



ヘビから始めましょう。 ヘビの基本的なメカニズムは単純です。ヘビは時間とともに移動し、食べるリンゴが増えるほど成長します。 しかしスネークストリームとは正確には何ですか? 現時点では、彼女が食べたり成長したりすることを忘れることができます。最初に重要なのは、 200 msごとに5ピクセルなど、 時間とともに移動する時間要因に依存するからです 。 したがって、 ソースストリームは、一定期間ごとに値を送信する間隔であり、 ticks $と呼びます。 この流れは、蛇の速度も決定します。



最後になりましたが、リンゴ。 すべてを考慮して、フィールドにリンゴを置くことは非常に簡単です。 この流れは主にヘビに依存しています。 ヘビが移動するたびに、ヘビの頭がリンゴと衝突するかどうかを確認します。 その場合、このリンゴを削除して、フィールド上の任意の位置に新しいリンゴを生成します。 前述のように、リンゴ用の新しいデータストリームを導入する必要はありません。



さて、メインスレッドについては以上です。 ゲームに必要なすべてのストリームの概要は次のとおりです。





これらのソースストリームはゲームの基礎を形成し、そこからスコア、ヘビ、リンゴの状態など、必要な他のすべての値を計算できます。



以下のセクションでは、これらの各ソースフローを実装し、必要なデータを生成するためにそれらを適用する方法を詳細に検討します。



ヘビ管理



コードに飛び込み、ヘビの制御メカニズムを実装しましょう。 前のセクションで述べたように、制御はキーボード入力に依存します。 それは非常に簡単であることが判明し、最初のステップはキーボードイベントから観察可能なシーケンスを作成することです。 これを行うには、 fromEvent()演算子を使用できます。



 let keydown$ = Observable.fromEvent(document, 'keydown');
      
      





これが最初のソーススレッドであり、ユーザーがキーを押すたびにKeyboardEventをトリガーします。 文字通りすべてのキーダウンがイベントをトリガーすることに注意してください。 そのため、興味のないキーのイベントも受け取ります。これらは基本的に、矢印キーを除く他のすべてのキーです。 しかし、この特定の問題を解決する前に、方向の永続的なマップを定義します。



 export interface Point2D { x: number; y: number; } export interface Directions { [key: number]: Point2D; } export const DIRECTIONS: Directions = { 37: { x: -1, y: 0 }, // Left Arrow 39: { x: 1, y: 0 }, // Right Arrow 38: { x: 0, y: -1 }, // Up Arrow 40: { x: 0, y: 1 } // Down Arrow };
      
      





KeyboardEventオブジェクトを見ると、各キーに一意のkeyCodeがあると仮定できます。 矢印キーのコードを取得するには、 このテーブルを使用できます。



各方向のタイプはPoint2Dで 、これは単なるxおよびyプロパティを持つオブジェクトです。 各プロパティの値は1-1、または0で、ヘビがどこにいるかを示します。 後で、この方向を使用して、蛇の頭と尾の新しいメッシュ位置を取得します。



方向フロー(方向$)



そのため、すでにキーダウンイベントのストリームがあり、プレーヤーがキーを押すたびに、値( 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)
      
      





まあ、それは簡単でした。 上記のコードは正常に機能し、期待どおりに機能します。 しかし、まだ改善すべきことがあります。 何を正確に推測しましたか?



1つのアイデアは、たとえばヘビが反対方向に進むのを止めたいということです。 右から左または上から下。 実際、ルール1は自分の尻尾に入らないようにするため、この動作を許可しても意味がありません。



解決策は非常に簡単です。 前の方向をキャッシュし、新しいイベントがトリガーされると、新しい方向が反対方向と異なるかどうかを確認します。 次の方向を計算する関数は次のとおりです。



 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( observable )情報ソースの外部に状態を保存したいのはこれが初めてです...簡単な解決策は、外部状態変数に以前の方向を単に保存することです。 しかし、ちょっと待ってください! 結局、これを避けたかったのですよね?



外部変数を回避するには、集約された無限のObservableをソートする方法が必要です。 RxJSには、問題のscan()を解決するために使用できる非常に便利な演算子があります。



scan()演算子はArray.reduce()と非常に似ていますが、最後の値を返すだけでなく、各中間結果の送信を開始します。 scan()を使用すると、基本的に値を蓄積し、着信イベントのフローを1つの値に無限に減らすことができます。 したがって、外部状態に依存せずに前の方向を追跡できます。



これを適用して、最終的な方向$ストリームを見てみましょう。



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





Observable( keydown $ )ソースからの値の送信を開始する前に、 startWith()を使用して初期値を開始することに注意してください。 この演算子がないと、Observableはプレーヤーがキーを押したときにのみ値の送信を開始します。



2番目の改善点は、新しい方向が前の方向と異なる場合にのみ値の送信を開始することです。 つまり、 異なる値のみが必要です 。 上記のスニペットでdistinctUntilChanged()に気づいたかもしれません。 この演算子は私たちのために汚い仕事をし、繰り返し要素を抑制します。 distinctUntilChanged()は、別の値が選択されていない限り、同じ値のみを除外することに注意してください。



次の図は、 $ストリームの方向とその仕組みを視覚化したものです。 青色の値は初期値を表し、黄色はObservableストリームで値が変更されたことを意味し、 結果ストリームで送信された値はオレンジ色になります。







長さの追跡



ヘビ自体に気付く前に、その長さを追跡する方法を理解しましょう。 最初に長さが必要なのはなぜですか? さて、この情報を使用してアカウントをモデル化します。 命令型の世界では、ヘビが移動するたびに衝突があったかどうかを確認し、そうであればカウントを増やします。 したがって、実際には、長さを追跡する必要はありません。 ただし、そのようなアプローチでは、別の外部状態変数が導入される可能性があります。



ジェットの世界では、解決策は少し異なります。 単純なアプローチの1つは、 snake $ストリームを使用することです。値を送信するたびに、snakeの長さが増加していることがわかります。 snake $の実装に本当に依存していますが、このスレッドはまだ実装しません。 最初から、ヘビはティック$に依存していることがわかっています。これは、ヘビが一定の距離を移動するためです。 この方法で、snake $はbodyセグメントの配列を蓄積し、 ティック$に基づいているため、 xミリ秒ごとに値を生成します。 ただし、たとえスネークが何かと衝突しなくても、 スネーク$は値を送信します。 これは、ヘビが常にフィールド上を移動しているため、配列が常に異なるためです。



異なるスレッド間には特定の依存関係があるため、これを理解するのは少し難しい場合があります。 たとえば、 りんご$蛇$に依存します。 これは、ヘビが移動するたびに、これらの部分のいずれかがリンゴと衝突するかどうかを確認するために、体のセグメントの配列が必要だからです。 apples $ストリームはリンゴの配列を蓄積しますが、同時に循環依存を回避する衝突モデリングメカニズムが必要です。



救いとしてのBehaviorSubject



この問題の解決策は、 BehaviorSubjectを使用してブロードキャストメカニズムを実装することです。 RxJSは、さまざまな機能を持つさまざまな種類のサブジェクトを提供します。 したがって、 Subjectクラスは、より専門化されたSubjectを作成するための基盤を提供します。 一言で言えば、SubjectはObserver( observer )型とObservable( observable )型の両方を実装する型です。 オブザーバブルはデータの流れを定義してデータを作成しますが、オブザーバーはオブザーバブルにサブスクライブしてデータを受信できます。



BehaviorSubjectは、時間の経過とともに変化する値を提供する、より専門的なサブジェクトです。 これで、オブザーバーがBehaviorSubjectにサブスクライブすると、送信された最後の値を受信し、その後すべての値を受信します。 その一意性は、 初期値が含まれているという事実にあるため、すべてのオブザーバーはサブスクライブ時に少なくとも1つの値を受け取ります。



続けて、 SNAKE_LENGTHの初期値を持つ新しいBehaviorSubjectを作成します。



 // SNAKE_LENGTH specifies the initial length of our snake let length$ = new BehaviorSubject<number>(SNAKE_LENGTH);
      
      





この時点から、snakeLength $を実装するための小さなステップが残っています。



 let snakeLength$ = length$ .scan((step, snakeLength) => snakeLength + step) .share();
      
      





上記のコードでは、 snakeLength $は BehaviorSubjectであるlength $に基づいていることがわかります。 これは、 next()を使用してSubjectに新しい値を渡すたびに、値をsnakeLength $に送信することを意味します。 さらに、 scan()を使用して、時間の経過とともに長さを累積します。 クールですが、この共有()が何であるか疑問に思うかもしれませんよね?



すでに述べたように、 snakeLength $は後でsnake $の入力として使用されますが、同時にプレーヤーのアカウントのソースストリームとして機能します。 その結果、同じObservableへの2番目のサブスクリプションでこの元のストリームを再作成します。 これは、 長さ$冷たい Observableであるためです。



ホットオブザーバブルとコールドオブザーバブルに完全に慣れていない場合は、コールドオブホットオブザーバブルに関する記事を書きました。



実際、 share()を使用してObservableへのマルチサブスクリプションを許可します。そうしないと、サブスクリプションごとにソースを再作成します。 このステートメントは、元のソースと将来のすべてのサブスクライバーの間にサブジェクトを自動的に作成します。 サブスクライバの数が0から1になるとすぐに、SubjectをベースのObservableソースに接続し、すべての通知の送信を開始します。 将来のすべてのサブスクライバーはこの中間サブジェクトに接続されるため、基礎となるコールドオブザーバブルのサブスクリプションは1つだけであることが効果的です。 これはマルチキャストと呼ばれ、目立つのに役立ちます。



すごい! 複数のサブスクライバーに値を渡すために使用できるメカニズムが用意できたので、次はスコア$を実装します。



アカウントの実装(スコア$)



プレーヤーのアカウントの実装はできるだけ簡単です。 snakeLength $装備して、 スコア$ストリームを作成できます。これは、 scan()を使用してプレーヤーのスコアを単純に累積します。



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





本質的に、衝突が発生したことをサブスクライバーに通知するためにsnakeLength $またはむしろlength $を使用します。その場合は、リンゴあたり一定のポイント数であるPOINTS_PER_APPLEだけスコアを増やします。 初期値の指定を避けるために、 startWith(0)を scan()の前に追加する必要があることに注意してください。



実装した内容のより視覚的な表現を見てみましょう。







上の図を見ると、なぜBehaviorSubjectの初期値がsnakeLength $にのみ表示され、 スコア$にないのか疑問に思うかもしれません。 これは、最初のサブスクライバーが共有()を基になるデータソースにサブスクライブさせ、元のデータソースが直ちに値を送信するため、この値は後続のサブスクリプションが発生するまでに送信されるためです。



素晴らしい。 この場所から始めて、ヘビのフローを実装しましょう。 エキサイティングではありませんか?



ヘビを飼いならす(ヘビ$)



この時点で、私たちはすでに多くの演算子を学んでおり、今ではそれらを使用してsnake $ストリームを実装できます。 この記事の冒頭で説明したように、空腹のヘビを動かし続けるためのティッカーが必要です。 この間隔(x)にはxミリ秒ごとに値を送信する便利なステートメントがあります。 各値tickを呼び出しましょう。



 let ticks$ = Observable.interval(SPEED);
      
      





その瞬間から最終ストリームの実装まで、 snake $はかなりの量です。 ティックごとに、ヘビがリンゴを食べたかどうかに応じて、リンゴを前方に移動するか、新しいセグメントを追加します。 したがって、身近なscan()関数を使用して、体のセグメントの配列を蓄積できます。 しかし、ご想像のとおり、私たちの前に疑問が生じます。 方向$またはsnakeLength $のフローはどこで作用しますか?



絶対に正当な質問。 この情報は、観察されたストリームの外部の変数にこの情報を格納すると、 ヘビの$ストリーム内でヘビの長さと同様に簡単にアクセスできます。 しかし、再び、外部の状態を変えないという規則に違反します。



幸いなことに、RxJSはwithLatestFrom()と呼ばれる別の非常に便利な演算子を提供します。 これはスレッドを結合するために使用される演算子であり、まさに私たちが探しているものです。 このステートメントは、結果ストリームにデータを送信するタイミングを制御するプライマリストリームに適用されます。 つまり、 withLatestFrom()は、セカンダリストリームデータの送信を規制する方法と考えることができます。



上記を考えると、空腹の$ snakeの最終的な実装に必要なツールがあります。



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





ticks$ , , , direction$ , snakeLength$ . , , , , .



, ( ) withLatestFrom , , . , , .



move() , . , GitHub .



, :







direction$ ? , withLatestFrom() , , Observable ( ), .







, , . , .



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





length$.next(POINTS_PER_APPLE) . , ( ES2015). ES2015 , . , , .



, applesEaten$ . apples$ , , - , length$.next() . do() , .



. - () , apples$ . , , . , 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 }));
      
      





, , , Observables ( ) . , , . , .











, - . , 60 .



, , ticks$ , . :



 // Interval expects the period to be in milliseconds which is why we devide FPS by 1000 Observable.interval(1000 / FPS)
      
      





, JavaScript . , . , . , , . , . .



, requestAnimationFrame , . Observable? , , interval() , Scheduler ( ). , Scheduler — , - .



RxJS , , , animationFrame . window.requestAnimationFrame .



いいね! , Observable game$ :



 // Note the last parameter const game$ = Observable.interval(1000 / FPS, animationFrame)
      
      





16 , 60 FPS.







game$ scene$ . , ? , , , 60 . game$ , , , scene$ . ? , withLatestFrom .



 // Note the last parameter 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 .



:











, , . , , , .



連絡を取り合いましょう!







James Henry Brecht Billiet .



All Articles