Promiseを使った逐次処理でユーザー入力との待ち合わせができるイベントループを記述する

More than 5 years have passed since last update.


何がしたいか


  • ゲームループがループごとにイベントを複数生成するのだが、それを時系列に沿って順番に捌きたい

  • でもオブザーバでグローバルなイベント投げまくるとすぐクソコード化する

  • どうにかしてピュアなモデルからビューとの待ち合わせ(オブザーバー含む)を切り離したい。なんでもしますから!

というわけでちゃんと設計練って書いてみたら結構いいんじゃねとなったので、解説書く。


設計の概要



  • EventSource#createEvents() は Eventの配列を生成する


    • 実際にはここがユーザーが記述する部分になる




  • EventRunner はループ毎にEventSource#createEvents()を呼び、上から順に処理する


    • event.eventType毎に処理の仕方を記述する

    • この例ではEvent間の時間を100msとなるよ]うにしてるが、実際なんでもいい。




コード

CoffeeScriptで書く。暗黙のリターンがPromiseと相性が良い。詳しくはコードで解説する。

今回はPromiseにはbluebirdを使ってるが、Promiseの規格を満たしているならなんでもいい。nodeでもブラウザでも動く。

Promise = require 'bluebird'

class Event
constructor: ({@eventType, @log}) ->

class EventSource
createEvents: ->
for i in [1..3]
seed = Math.random()
if 0 < seed < 0.5
new Event
eventType: 'a'
log: 'eventType A'
else if 0 < seed < 0.9
new Event
eventType: 'b'
log: 'eventType B'
else
new Event
eventType: 'waitInput'
log: 'waitInput'

class EventRunner
constructor: ->
@source = new EventSource

start: =>
do update = =>
@source.createEvents()
.reduce @processEvent, Promise.resolve()
.then update

processEvent: (p, event) => new Promise (done) => p.then =>
setTimeout (=>
switch event.eventType
when 'a'
@log event.log
done()
when 'b'
@log event.log
done()
when 'waitInput'
@waitInput().then =>
done()
else
throw 'unknown event type:'+event.eventType
), 100

waitInput: -> new Promise (done) =>
@log 'wait user input'
# catch something event for your application
setTimeout done, 1000

log: (message) ->
console.log 'log:', message

runner = new EventRunner
runner.start()

実行するとこうなる

~/s/event-runner (master) $ coffee main.coffee 

log: eventType B
log: eventType B
log: wait user input
log: wait user input
log: eventType A
log: eventType B
log: eventType B
log: eventType B
log: eventType B
log: eventType A
log: eventType A
log: eventType A
log: eventType A
log: eventType B
log: eventType A
log: eventType A
log: eventType A
log: wait user input

ログだとわかりにくいけど、それぞれの実行は前のイベントを待って実行されてる。


解説

EventSourceはEventのArrayを返せばなんでもいい。この例では5割でa, 4割でb, 1割でwaitInputを返す。適当に作ったんでなんでもいい。

class EventSource

createEvents: ->
for i in [1..3]
seed = Math.random()
if 0 < seed < 0.5
new Event
eventType: 'a'
log: 'eventType A'
else if 0 < seed < 0.9
new Event
eventType: 'b'
log: 'eventType B'
else
new Event
eventType: 'waitInput'
log: 'waitInput'

ループ部分

  start: =>

do update = =>
@source.createEvents()
.reduce @processEvent, Promise.resolve()
.then update

eventの配列をreduceでPromiseチェーンを作り、全てのpromiseが吐けたら再帰する。詳しくは次の関数。

  processEvent: (p, event) => new Promise (done) => p.then =>

setTimeout (=>
switch event.eventType
when 'a'
@log event.log
done()
when 'b'
@log event.log
done()
when 'waitInput'
@waitInput().then =>
done()
else
throw 'unknow event type:'+event.eventType
), 100

具体的にイベント事にどう捌くか記述しているのだが、冒頭部分が肝で、

  processEvent: (p, event) => new Promise (done) => p.then =>

processEventは「Promise化された」関数で(coffeeの暗黙のリターン)、第一引数のpはreduceで集約された前回のpromiseインスタンス。これをthenでチェーンしてやることで、シーケンシャルに実行するPromiseチェーンが作れる。

waitInputもPromise化された関数で、今回は実は関数名が嘘で、1秒待ってdoneを解決するだけ

  waitInput: -> new Promise (done) =>

@log 'wait user input'
# catch something event for your application
setTimeout done, 1000

たとえばボタン押したらこのdoneを解決するように書けば、ユーザーの入力を待って処理を再開することができる。jQueryを使う例を示す。

# 再開するボタンを作る

$resumeButton = $('button.resume')
$resumeButton.hide() # 最初は表示しない

# ... 中略
waitInput: -> new Promise (done) =>
$resumeButton.show() # ユーザーにボタンを見せる
$resumeButton.on 'click', => # click時のコールバックを定義
$resumeButton.off() # clickが二重定義にならないように剥がす
$resumeButton.hide() # 再度隠す
done() # Promiseを解決する

これがユーザーとのインタラクションを定義する方法になる。

なんか昔仕事でみたコードがこんな感じのループ処理が地獄だった記憶があったんだけど、これぐらいだと綺麗にかけて良いのではないか!