時折思い出したようにHaskellで簡単なツールを作る程度には関数型言語好きなのですが、UIやゲームの設計スタイルが革新的に変わると巷で噂の関数型リアクティブプログラミング(FRP: Functional Reactive Programming)は未経験だったので、ゴールデンウィークを機に勉強し始めました。
様々な記事を読む中で、特に以下の2つの記事が大変わかりやすく役に立ったのですが、後者はまだ日本語版がないようなので、この機会に翻訳してみたいと思います。
- The introduction to Reactive Programming you've been missing(翻訳)
- Functional Reactive Game Programming – RxJS Breakout
と、その前にお約束のマントラを。Everything is a stream!
【翻訳】関数型リアクティブゲームプログラミング ー RxJSブロック崩し
関数型リアクティブプログラミングはJavaScriptコミュニティで今最も話題のパラダイムの一つだ。私は数週間にわたってRxJSを試し、理解のための最も良い方法は古典的ゲームを作り直すことだと確信した。コンピューターゲームは忌まわしくも大量の外部状態を保持するが、私のゴールは外部の状態変数に一切頼らず、すべてをストリームで作ることだった。
この記事はRxJSの全くの初心者である私の取り組みを共有するものであるため、もしおかしなところがあればコメント欄にメッセージを残して欲しい。また、もしあなたが自分のゲームでこの記事のコードを使いたい場合は自由にコピーしてもらって構わない。ただ、私が個々の問題に対する異なるアプローチを見ることで得られることがあるかもしれないので、是非あなたのプログラムのリンクを残して欲しい。
本記事のコードはGitHubで取得でき、ゲームは以下のCodePenでプレイできる。iFrameにフォーカスしてカーソルキーを使用する。ああ、スマホだと動かないが、本家のアーケードマシンも同じだ!音も鳴るので同僚を驚かせないように注意して欲しい。
すべてはストリーム
あなたはAndre StaltzのチュートリアルGist、The introduction to Reactive Programming you’ve been missing(【翻訳】あなたが求めていたリアクティブプログラミング入門)を読んだかもしれない。 彼のマントラに従って、私はゲームのすべてをストリームで作ろうと試みた。アタリ社のブロック崩し(Breakout)について知っていることを前提に説明する。プレイヤーができるすべてのことはパドルを動かすことだ。では、どのようにプレイヤーの入力をストリームとして表現しよう?
入力ストリーム
私たちはキーの種類だけでなくどれだけの間押されていたかも知りたい。もしプレイヤーが左キーを押すとパドルは左に動く必要があるし、逆も然りだ。
最初のステップとして、すべてのkeydown
イベントを1次元の方向ベクトル(複雑に聞こえるかもしれないが、実のところパドルがどこに向かうかを示す単なる-1か0か1の値だ)に変換するobservableを作成する。そしてこのobservableを、すべてのkeyup
イベントを取得し、プレイヤーが指を離すとベクトルを0にリセットする2つ目のobservableとmergeする。
(コメントでこのよりシンプルなやり方を教えてくれたJohn Lindquistありがとう!)
最後に気にかけるべきことは、最初のobservableはキーを押している間、数ミリ秒ごとにkeydown
イベントを返すことだ。distinctUntilChanged()
オペレーターで要素が以前のものと異なる時だけストリームに要素をプッシュするようにできる。
const input$ = Rx.Observable .merge( Rx.Observable.fromEvent(document, 'keydown', event => { switch (event.keyCode) { case PADDLE_KEYS.left: return -1; case PADDLE_KEYS.right: return 1; default: return 0; } }), Rx.Observable.fromEvent(document, 'keyup', event => 0) ) .distinctUntilChanged();
パドルストリーム
前のコードで作成した入力ストリームを備えることで、プレイヤーの入力に基づきパドルの位置を返すストリームを作ることができる。ティッカーを入力ストリームとcombineし、ティック毎に位置を再計算する。ティッカーについては次のセクションで説明する。
scanオペレーターの中の関数は、最初に前のフレームからの経過時間(ticker.deltaTime
)に基づきパドルを動かし、次にキャンバスの領域内に値を留める。
const paddle$ = ticker$ .withLatestFrom(input$) .scan((position, [ticker, direction]) => { let next = position + direction * ticker.deltaTime * PADDLE_SPEED; return Math.max(Math.min(next, canvas.width - PADDLE_WIDTH / 2), PADDLE_WIDTH / 2); }, canvas.width / 2) .distinctUntilChanged();
ティッカーストリーム
ティッカーストリームは毎秒およそ60ティックを提供する単純なストリームだ。滑らかに位置を更新するための参考として使用する経過時間を計算して返せるように、各ティックは現在時刻にmapされる。
const ticker$ = Rx.Observable .interval(TICKER_INTERVAL, Rx.Scheduler.requestAnimationFrame) .map(() => ({ time: Date.now(), deltaTime: null })) .scan( (previous, current) => ({ time: current.time, deltaTime: (current.time - previous.time) / 1000 }) );
ゲームストリーム
これは今回の実装で最も分りやすいストリームの一つだ。このストリームはすべてのゲームの状態とオブザーバーをcombineし、アップデート関数に供給する。sample
オペレーターはゲームを60fps以内に留めるために使われている。もしこれをしないと、プレイヤーがパドルを動かすとゲームがスピードアップしてしまう。奇妙な振る舞いなので、是非試して何が起きるか確認してみて欲しい。プレイヤーがすべてのブロックを壊すかボールが床に当たると、アップデート関数はsubscriptionのdispose
を呼びゲームを終了させる。
const game = Rx.Observable
.combineLatest(ticker$, paddle$, objects$)
.sample(TICKER_INTERVAL)
.subscribe(update);
オブジェクトストリーム
オブジェクトストリームと言っているが、これはゲームのスコアも保持する。なぜならスコアは残っているブロックの数によって決まるからだ。このストリームは各更新サイクルでボールとブロックのプロパティを含む新しいオブジェクトを返す。初期オブジェクトは中央から右下に向かうボールとたくさんのブロック、0点のスコアを保持している。
const INITIAL_OBJECTS = { ball: { position: { x: canvas.width / 2, y: canvas.height / 2 }, direction: { x: 2, y: 2 } }, bricks: factory(), score: 0 };
さらに面白いのは、ゲームの新しい状態を計算する関数だ。この関数は前回の速度ベクトルに従ってボールを動かし、衝突の発生と、その対象がブロック、パドル、壁、床のいずれかを判定する。もしボールが何かに衝突したら方向を変更し、正しい音を再生するために衝突を保存し、プレイヤーが失敗したことを意味するボールと床の衝突かを判定する。
const objects$ = ticker$ .withLatestFrom(paddle$) .scan(({ball, bricks, collisions, score}, [ticker, paddle]) => { let survivors = []; collisions = { paddle: false, floor: false, wall: false, ceiling: false, brick: false }; ball.position.x = ball.position.x + ball.direction.x * ticker.deltaTime * BALL_SPEED; ball.position.y = ball.position.y + ball.direction.y * ticker.deltaTime * BALL_SPEED; bricks.forEach((brick) => { if (!collision(brick, ball)) { survivors.push(brick); } else { collisions.brick = true; score = score + 10; } }); collisions.paddle = hit(paddle, ball); if (ball.position.x < BALL_RADIUS || ball.position.x > canvas.width - BALL_RADIUS) { ball.direction.x = -ball.direction.x; collisions.wall = true; } collisions.ceiling = ball.position.y < BALL_RADIUS; if (collisions.brick || collisions.paddle || collisions.ceiling ) { ball.direction.y = -ball.direction.y; } return { ball: ball, bricks: survivors, collisions: collisions, score: score }; }, INITIAL_OBJECTS);
Web Audio APIでブラウザから音を出す
Web Audio APIを使ったことはあるだろうか? 私は今までなかったが、とても楽しいものだ。オブザーバーの周波数の計算式はWikipediaから持ってきたものだ。この式はピアノのキー番号を周波数に変換する。これで私たちは慣れ親しんだ方法で音について考えることができる。キー40は中音のC(ド)で、そこから上げたり下げたりできる。
サウンドはボールがパドル、壁、ブロックに当たるたびに再生される。ブロックの段が高くなると音程は高くなる。一度に複数のブロックに当たった時に、多数の音を再生すると私のブラウザは文句を言うようなので、observableをビープ音の長さにsampleしている。
const audio = new (window.AudioContext || window.webkitAudioContext)(); const beeper = new Rx.Subject(); beeper.sample(100).subscribe((key) => { let oscillator = audio.createOscillator(); oscillator.connect(audio.destination); oscillator.type = 'square'; oscillator.frequency.value = Math.pow(2, (key - 49) / 12) * 440; oscillator.start(); oscillator.stop(audio.currentTime + 0.100); });
あなたへパス
これでRxJSブロック崩しの実装の説明は終了だ。改善のために何か提案はあるだろうか? RxJSについて私は何か誤解していただろうか? もしそうなら下にコメントを残すかGitHubにPull Requestを送って欲しい。
このゲームは私にとって面白い学習経験だった。関数型リアクティブプログラミングを身につけるためには、あなたは脳に紐付けられた従来のパラダイムを手放す必要がある。私はまだCycle.jsを試していないが、Canvas Driverができたらすぐ試そうと思う。Reactに対する興味深い代替手段でRxJS上に作られているそうだ。
もしあなたがこの記事を気に入ったら、あなたのフォロワーへの共有を検討して欲しい。