この記事は「WACUL Advent Calendar 2016」の3日目です。
今年の10月からWACULでフロントエンドエンジニアをしている@bokuwebと申します。
本記事ではRxJS
の勉強にテトリスを実装した話しを書いてみます。
つくったもの
リポジトリ: https://github.com/bokuweb/rxjs-tetris
デモ: https://bokuweb.github.io/rxjs-tetris/
デモの遊び方
Enter
キーでゲームが開始します。(音が鳴るのでご注意ください。)
→
: ブロックを右へ移動
←
: ブロックを左へ移動
↓
: ブロックを下へ移動
SPACE
: ブロックを回転
実装
概要
先に全体の流れを図示すると以下のような構成になりました。
後述しますが、Timer
/KeyInput``Straem
からAction
が発行され、ゲームのステートを更新していきます。更新されたゲームのステートから描画を行うとともに、ゲームオーバー判定や、行削除の有無などの判定を行いAction
を投げる形となっています。
Action
例えばゲームの開始
やブロックの回転
、ブロックの移動
などのイベントをAction
として定義しています。Action
はActionSource$
を介して次のように発行されます。
ActionSource$.next(new RemoveAction(removeRows))
この辺はTackling Stateという記事を参考にしています。
// src/actions.ts
import { Subject } from "rxjs/Subject";
export const actionSource$ = new Subject();
export type Action = NextAction |
StartAction |
DownAction |
LeftAction |
RightAction |
RotateAction |
GameoverAction |
RemoveAction;
export class NextAction {
constructor () {}
}
export class StartAction {
constructor () {}
}
export class DownAction {
constructor () {}
}
export class LeftAction {
constructor () {}
}
export class RightAction {
constructor () {}
}
export class RotateAction {
constructor () {}
}
export class GameoverAction {
constructor () {}
}
export class RemoveAction {
constructor (public removeRows: number[]) {}
}
Key Input
キー入力はfromEvent
でkeydown
イベントを変換map
で各Action
にマッピングしActionSource$
に渡しています。これで各キー入力に応じてAction
が発行されることになります。
// src/index.ts
const keyEvent$ = Observable
.fromEvent(document, 'keydown')
.map((k: KeyboardEvent) => {
switch(k.code) {
case 'ArrowRight': return new RightAction();
case 'ArrowLeft': return new LeftAction();
case 'ArrowDown': return new DownAction();
case 'Enter': return new StartAction();
case 'Space': return new RotateAction();
default: return;
};
})
.filter((action) => !!action)
.subscribe(actionSource$);
Timer
タイマーはinterval
にて作成しています。以下で500ms
毎にDownAction
が発行されることになります。
// src/index.ts
Observable
.interval(500)
.map(_ => new DownAction())
.subscribe(actionSource$);
game State
ゲームのステートとして、ポーズ中かどうか
、落下中のブロックのステート
、フィールドのステート
を保持しています。ここはRedux
でいうところのReducer
に相当しています。
すなわち、scan
を使用して前述のAction
を受け取る度に、前回の状態とActionを引数にもらい次回ステートを演算して返す処理を行います。
このように状態をストリーム内に保持するためにscan
はよく使用するoperator
だと思います。(ちなみにreduce
というoperatorもありますが、こちらはcomplete
しないと値が流れないため、scan
のほうが使用頻度が高い印象があります。)
このあたりは「Reactive Programing with RxJS」 *1
にもAvoiding External State
、Using scan
と書いてあります。パイプラインの外にステートを持ってしまうと、アプリケーションが複雑になった際にバグの温床となるということですね。
*1 この書籍はRxJS5には未対応なのでご注意ください。
// src/index.ts
interface AppState {
isPaused: boolean;
block: Block;
field: Field;
}
const gameState$ = actionSource$
.scan((state: AppState, action: Action) => {
if (action instanceof StartAction) {
return assign(state, { isPaused: false } );
} else if (action instanceof DownAction) {
if (state.isPaused) return state;
if (!isLocked(state)) state.block.y += 1;
return state;
} else if (action instanceof LeftAction) {
if (canMoveX(state, -1)) state.block.x -= 1;
return assign({}, state);
} else if (action instanceof RightAction) {
if (canMoveX(state, 1)) state.block.x += 1;
return state;
} else if (action instanceof NextAction) {
const block = getRandomBlock();
const newField = createNewField(state.field, state.block);
return assign(state, { block, field: newField });
} else if (action instanceof RotateAction) {
const rotatedBlock = assign({}, state.block, { shape: getRotatedShape(state.block.shape) });
const block = isCollision(state.field, rotatedBlock) ? state.block : rotatedBlock;
return assign(state, { block });
} else if (action instanceof RemoveAction) {
action.removeRows.forEach((rowIndex: number) => {
state.field.splice(rowIndex, 1);
state.field.unshift(range(config.ROW).map(() => 99))
});
return state;
} else if (action instanceof GameoverAction) {
return createInitState();
} else {
return state;
}
}, createInitState())
.share()
上記の固まりをみてもわかりにくいですが、あるActionに着目してみると非常に単純な処理をおこなっているだけです。
例えば以下はDownAction
を受け取ったらブロックを1マス下に移動したステートを作成し、次回ステートとして反映するということを行っています。
else if (action instanceof DownAction) {
if (state.isPaused) return state;
if (!isLocked(state)) state.block.y += 1;
return state;
Render
gameState$
から必要なステートをselect
かつ、ブロックのステートと、フィールドのステートを合成して描画すべきフォールドを生成、render
に渡しています。
// src/index.ts
gameState$
.map((state: AppState) => ({
isPaused: state.isPaused,
field: createNewField(state.field, state.block)
}))
.subscribe(render);
render
は蛇足ですが、snabbdom/snabbdom
を使用しています。特に理由はないのですが、どうせなら使ったことのないものを触ってみようという意図です。
以下が記述になります。パッと見拒否反応を示す人もいるかもしれませんが、自分はmithril.js
で遊んでいたこともあり、割りと好みです。
描画はdiv
要素を敷き詰めて、field
とblock
の状態を合成したものをマッピングしています。
ポーズの場合だけは、そのメッセージを表示するようにしています。
import { Renderer, h } from './renderer';
import { colors } from './shape';
const renderer = new Renderer();
renderer.mount(document.getElementById('container'), h('div'));
export const render = (state: any ) => {
const vnode = h('div', [
state.isPaused
? h('div', 'Press enter key to start')
: h('div.field', state.field.map((row: number[]) => (
h('div.raw', row.map(cell => (
h(`div.cell${cell !== 99 ? '--active' : ''}`,
{ style: { backgroundColor: colors[cell] }})))
)
)))
]);
renderer.update(vnode);
};
Judge game state
ここでは流れたきたステートを見て、各種判定を行い、必要に応じてAction
を発行しています。
まずは、接地判定を行い、接地していたらNextAction
を発行し次のブロックを用意させます。
テトリスって接地した後も1マス分横にスライドできたりするんですよね。最初はこの辺無視してたんですが、いざ遊んでみると結構重要な要素っぽく、bufferCount
でFIFO
つくって対応しました。(この辺もっといいOperatorありそうな気がしますが。。)
また、この接地判定は行が消せるかどうかの判定にも使用したいためshare
でhot化しています。
const isLocked$ = gameState$
.map((state: AppState) => isLocked(state))
.bufferCount(2, 1)
.map(lockedBuffer => (
lockedBuffer.every(isLocked => isLocked)
))
.share()
isLocked$
.distinctUntilChanged()
.filter(isLocked => !!isLocked)
.map(_ => new NextAction())
.do(() => beeper.next(100))
.subscribe(actionSource$);
ここではゲームオーバー判定をおこなっています。次のブロックを用意した段階で衝突していたらゲームオーバーとしています。
gameState$
.filter((state: AppState) => state.block.y === 0)
.filter((state: AppState) => (
isCollision(state.field, state.block) || isLocked(state)
))
.map(_ => new GameoverAction())
.delay(200)
.subscribe(actionSource$);
ゲームのステートとisLocked$
ストリームをcombineし、接地状態であれば、消せる行がないか走査するようにしています。横一列そろっている消すことが可能な行があった場合、音を鳴らし、RemoveAction
を発行するようにしています。
delay(50)
してるのは接地から削除までの隙間時間を設けるために入れました。これがないと本当に接地した瞬間に消えてしまいブロックを置いた感
が無かったからです。
gameState$
.combineLatest(isLocked$)
.delay(50)
.filter(([_, isLocked]) => isLocked)
.map(([state, _]: [AppState, boolean]) => (
getRemovableRows(state.field)
))
.filter(removeRows => removeRows.length > 0)
.do(() => beeper.next(400))
.map(removeRows => new RemoveAction(removeRows))
.subscribe(actionSource$);
Audio(おまけ)
これは完全に蛇足なんですが、音が全くならないとフィードバックがなくイマイチだったのでWebAudioで矩形波をつくって、適当な周波数を放り込んでいます。これにより、ブロックが消えたときにピロ
ってなります。
src/sounder.ts
import { Subject } from 'rxjs';
const audio = new window.AudioContext;
export const beeper = new Subject();
beeper.sampleTime(100).subscribe((hz: number) => {
const oscillator = audio.createOscillator();
oscillator.connect(audio.destination);
oscillator.type = 'square';
oscillator.frequency.value = hz;
oscillator.start();
oscillator.stop(audio.currentTime + 0.1);
});
例えば以下で400Hzの音が鳴ることになります。
beeper.next(400);
まとめ
他にも細かいものがありますが、大まかには上記のストリーム等をつなげていくことでこのテトリスは構築されています。
テトリスがRxJS
の学習の題材としてよかったかというと、疑問符がつきますが、それでも学ぶところも多く楽しい題材でした。ただ、やはりRxJS
が一番生きる題材と言えばもう少し時間的な制御が必要なものだったかもしれません。
最後に勉強するに当って参考になって記事やページを挙げておきます。
参考記事
社内フロント勉強会のRx
回では以下のLearn RxJS
の★付きOperatorをみんなで眺めました。なかなかよかったですが、サンプルがひどいケースもあるので他の資料やマーブルダイアグラムも合わせて眺めるのが効果的でした。
その中でもRx逆引き
とその中でリンクが貼られている、RxJavaのjavadoc
を見ると結構よかったです。