Help us understand the problem. What is going on with this article?

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を解決する

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

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

plaid
CXプラットフォーム「KARTE」の開発・運営、EC特化型メディア「Shopping Tribe」の企画・運営、CX特化型メディア「XD(クロスディー)」の企画・運営
https://plaid.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした