Node.js
非同期処理
イベント駆動

早押しクイズでNode.jsの非同期処理とかイベント駆動とかを試す

Node.jsのイベントループとかを理解するため、早押しクイズの実装をテーマに色々試してみました。
なお、非同期と言いつつPromiseとかの出番はありませんでした。あしからず。

Node.js は v10.11.0 を使用しています。

準備

とりあえず、クイズを行う上で必要な登場人物を列挙しておきます。

const EventEmitter = require('events')

// 司会者
class Presenter extends EventEmitter {
  constructor (personName, quizes) {
    super()
    this._personName = personName
    this._quizes = quizes
    console.log(`司会を務めさせて頂きます、"${personName}"です。`)
  }
  get personName () { return this._personName }

  // 発言
  _say (text) {
    console.log(`${this.personName}${text}」`)
  }
}

// クイズ解答者
class Player {
  constructor (entryName, thinkMs) {
    this._entryName = entryName
    this._thinkMs = thinkMs // 思考時間(ミリ秒)

    console.log(`"${entryName}"です。頑張ります!`)
  }
  get entryName () { return this._entryName }

  // 発言
  _say (text) {
    console.log(`${this.entryName}${text}」`)
  }
}

// 天の声
function anounce (text) { console.log(`---${text}---`) }

// クイズ
class Quiz {
  constructor (number, text, answer) {
    this._number = number
    this._text = text
    this._answer = answer
  }
  get number () { return this._number }
  get text () { return this._text }
  get answer () { return this._answer }
}

anounce('スタッフがクイズの準備をしています。')
const quizes = []
for (let i = 1; i <= 5; i++) {
  quizes.push(new Quiz(i, `${i} × 10 = ?`, i * 10))
}
console.log(quizes)
// [出力結果]
// ---スタッフがクイズの準備をしています。---
// [ Quiz { _number: 1, _text: '1 × 10 = ?', _answer: 10 },
//   Quiz { _number: 2, _text: '2 × 10 = ?', _answer: 20 },
//   Quiz { _number: 3, _text: '3 × 10 = ?', _answer: 30 },
//   Quiz { _number: 4, _text: '4 × 10 = ?', _answer: 40 },
//   Quiz { _number: 5, _text: '5 × 10 = ?', _answer: 50 } ]

anounce('登場人物のご紹介')
const taichi = new Presenter('M太一', quizes)
const kansei = new Player('関成高校', 200)
const nann = new Player('難高校', 200)
const chikukoma = new Player('築波大附属駒破高校', 200)
const kusozako = new Player('バカアホ高校', 500)
// [出力結果]
// ---登場人物のご紹介---
// 司会を務めさせて頂きます、"M太一"です。
// "関成高校"です。頑張ります!
// "難高校"です。頑張ります!
// "築波大附属駒破高校"です。頑張ります!
// "バカアホ高校"です。頑張ります!

司会者と解答者を用意しました。

司会者にはスタッフが用意したクイズ(かけ算の問題5問)を持たせています。
また、EventEmitterを継承することでイベントを取り扱うことができるようにしています。

解答者には思考時間を設定しておきました。
バカアホ高校は少し頭の回転が遅いようです。

目標

1. 出題する

listen.png
ポイントは矢印の向きです。
司会者は誰に向けて問題を出すか意識しません。
問題を聞きたい人が勝手に聞く感じです。

そのためにイベントを利用したいと思います。
司会者は"出題イベント"をemitするのみであり、各解答者がそれを文字通りlistenします。
イベントには引数としてクイズの内容をくっつけ、解答者はそれを元に解答します。

register.png
より正確に表現すると、解答者は「問題に解答するコールバック関数」を司会者の"出題イベント"に登録しておく、という形になります。

callback.png
出題時。司会者はイベント発火により各コールバック関数を呼びます(この際に引数でクイズの内容を渡します)。
解答者はコールバック関数内で解答を考え、司会者に返します。

さて、なぜこんなややこしいことをするのでしょうか。

ここでの狙いは、

司会者 <- 解答者

という依存関係をはっきりさせることにあります。

イベントを使わない場合、司会者はあらかじめ解答者のリストを持っておき、出題時に解答者を列挙してそれぞれの解答メソッドを呼んであげなければいけません(クイズの内容は解答メソッドに引数として渡してあげます)。

つまり司会者は、

  • 解答者が誰か
  • 解答者の解答メソッドはどれか

を把握しなければいけません。
それはつまり、司会者の仕様が解答者の仕様に依存してしまうということです。

依存関係は単方向であることが望ましいです。
また、依存する対象は変更の少なそうな安定的なモジュールの方が良いです。

今回、解答者は解答アルゴリズムを色々チューニングすることが予想され、対して司会者はただ出題するだけなので仕様が安定的であると予想されます。
そのため、解答者が司会者に依存する形が望ましいと考え、このような実装方針としました。

まあ、もともとイベントを試したいがためにこの実装を始めたんですが...。

2. 解答する

解答.png
各解答者を非同期で動かし、早さを競わせることが大事です。
同期処理にしてしまうと、解答者の1人目が解答を終えてから2人目が解答を考え始めることになってしまいます。
これでは早押しクイズになりません。

Ver.1 同期処理、全解答を受け付ける

重複するコードは適宜省略しています。

// 司会者
class Presenter extends EventEmitter {
  receiveEntry (player, answerCallback) {
    // 出題イベントにコールバック関数を登録
    this.on('quiz', answerCallback)

    this._say(`${player.entryName}がエントリーしました。頑張れ!`)
  }
  startQuiz () {
    for (const quiz of this._quizes) {
      // 出題
      this._say(`第${quiz.number}問!${quiz.text}`)
      this.emit('quiz', quiz, this)
    }
    this._say(`それではまた次回のクイズでお会いしましょう。さようなら!`)
  }
  // 解答者が解答を送る先
  receiveAnswer (playerAnswer) {
    this._say(`${playerAnswer.player.entryName}${playerAnswer.answer}と` +
    '解答しました。正解!')
  }
}

// クイズ解答者
class Player {
  // クイズに対し解答する
  get answer () { return this._answer.bind(this) }
  _answer (quiz, presenter) {
    // かけ算を解く
    const matched = quiz.text.match(/(\d+) × (\d+) = \?/)
    const answerNumber = Number(matched[1]) * Number(matched[2])
    // 解答
    this._say(`${answerNumber}!`)
    presenter.receiveAnswer(new PlayerAnswer(this, quiz, answerNumber))
  }
}

// 解答者の解答
class PlayerAnswer {
  constructor (player, quiz, answer) {
    this._player = player
    this._quiz = quiz
    this._answer = answer
  }
  get player () { return this._player }
  get quiz () { return this._quiz }
  get answer () { return this._answer }
}

anounce('エントリー開始')
taichi.receiveEntry(kansei, kansei.answer)
taichi.receiveEntry(nann, nann.answer)
taichi.receiveEntry(chikukoma, chikukoma.answer)
taichi.receiveEntry(kusozako, kusozako.answer)
// [出力結果]
// ---エントリー開始---
// M太一「関成高校がエントリーしました。頑張れ!」
// M太一「難高校がエントリーしました。頑張れ!」
// M太一「築波大附属駒破高校がエントリーしました。頑張れ!」
// M太一「バカアホ高校がエントリーしました。頑張れ!」

anounce('クイズスタート')
taichi.startQuiz()
anounce('番組終了')
// [出力結果]
// ---クイズスタート---
// M太一「第1問!1 × 10 = ?」
// 関成高校「10!」
// M太一「関成高校は10と解答しました。正解!」
// 難高校「10!」
// M太一「難高校は10と解答しました。正解!」
// 築波大附属駒破高校「10!」
// M太一「築波大附属駒破高校は10と解答しました。正解!」
// バカアホ高校「10!」
// M太一「バカアホ高校は10と解答しました。正解!」
// M太一「第2問!2 × 10 = ?」
// 関成高校「20!」
// M太一「関成高校は20と解答しました。正解!」
// 難高校「20!」
// M太一「難高校は20と解答しました。正解!」
//
// (略)
//
// M太一「第5問!5 × 10 = ?」
// 関成高校「50!」
// M太一「関成高校は50と解答しました。正解!」
// 難高校「50!」
// M太一「難高校は50と解答しました。正解!」
// 築波大附属駒破高校「50!」
// M太一「築波大附属駒破高校は50と解答しました。正解!」
// バカアホ高校「50!」
// M太一「バカアホ高校は50と解答しました。正解!」
// M太一「それではまた次回のクイズでお会いしましょう。さようなら!」
// ---番組終了---

解答者はかけ算以外は解けません。
とりあえず、設定した思考時間は無視して全員即答するようにしてみました。
ちなみに司会者は解答者を信頼しきっているので、何も考えずにとりあえず正解と言っています。

解答者がエントリーしている場面がありますが、実際にここでやっているのはイベントへのコールバック関数の登録です。

さて、出力結果に着目してみましょう。
1問出題する毎に全解答者が解答しています。

イベント処理というとなんとなく非同期のイメージが強かったのですが、EventEmitter自体は同期処理のようですね。
一度イベントを発火させたら、全てのコールバック関数を呼び終わるまで次の行へ進めないようです。

queue.png

Ver.2 先着1名の解答しか受け付けない

// 司会者
class Presenter extends EventEmitter {
  constructor (personName, quizes) {
    // (略)
    this._currentQuizNumber = -1
  }
  get currentQuiz () { return this._quizes[this._currentQuizNumber] }

  startQuiz () {
    this._giveNextQuiz()
  }
  receiveAnswer (playerAnswer) {
    this._say(`${playerAnswer.player.entryName}${playerAnswer.answer}と` +
    '解答しました。')
    if (!this.currentQuiz) {
      this._say('今クイズは出していません!帰れ!')
      return
    } else if (this.currentQuiz.number !== playerAnswer.quiz.number) {
      this._say('遅い!その問題はもう終わりました!')
      return
    } else if (this.currentQuiz.answer !== playerAnswer.answer) {
      this._say('残念!違います!')
      return
    }
    this._say('正解!')
    this._giveNextQuiz()
  }
  _giveNextQuiz () {
    this._currentQuizNumber++
    if (this.currentQuiz) {
      this._say(`第${this.currentQuiz.number}問!${this.currentQuiz.text}`)
      this.emit('quiz', this.currentQuiz, this)
    } else {
      this._say(`それではまた次回のクイズでお会いしましょう。さようなら!`)
      this._currentQuizNumber = -1
    }
  }
}

anounce('クイズスタート')
taichi.startQuiz()
anounce('番組終了')
// [出力結果]
// ---クイズスタート---
// M太一「第1問!1 × 10 = ?」
// 関成高校「10!」
// M太一「関成高校は10と解答しました。」
// M太一「正解!」
// M太一「第2問!2 × 10 = ?」
// 関成高校「20!」
// M太一「関成高校は20と解答しました。」
// M太一「正解!」
// M太一「第3問!3 × 10 = ?」
// 関成高校「30!」
// M太一「関成高校は30と解答しました。」
// M太一「正解!」
// M太一「第4問!4 × 10 = ?」
// 関成高校「40!」
// M太一「関成高校は40と解答しました。」
// M太一「正解!」
// M太一「第5問!5 × 10 = ?」
// 関成高校「50!」
// M太一「関成高校は50と解答しました。」
// M太一「正解!」
// M太一「それではまた次回のクイズでお会いしましょう。さようなら!」
// 難高校「50!」
// M太一「難高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// 築波大附属駒破高校「50!」
// M太一「築波大附属駒破高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// バカアホ高校「50!」
// M太一「バカアホ高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// 難高校「40!」
// M太一「難高校は40と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// 築波大附属駒破高校「40!」
// M太一「築波大附属駒破高校は40と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// バカアホ高校「40!」
// M太一「バカアホ高校は40と解答しました。」
// M太一「今クイズは出していません!帰れ!」
//
// (略)
//
// 難高校「10!」
// M太一「難高校は10と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// 築波大附属駒破高校「10!」
// M太一「築波大附属駒破高校は10と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// バカアホ高校「10!」
// M太一「バカアホ高校は10と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// ---番組終了---

司会者に、自分が今どの問題を出題しているかを覚えさせました。
また、さすがに解答者の解答が正しいかチェックするようにしました。

さて、一番の変更点は、正解者が出た時点で次の問題を出題するようにした点です。
出力結果を見るとおもしろいことになっています。

はじめにかい...関成が全問正解し、司会者が終了宣言をした後、他の高校からの解答が遅れてどんどん到着しています。
解答がだんだん古くなっていく点が興味深い。

これはつまり、イベント発火時にコールバック関数がキューに割り込む形で登録されることを示しています。

interrupt.png

Ver.3 キューを消化してから出題する

// 司会者
class Presenter extends EventEmitter {
  startQuiz () {
    this._currentQuizNumber++
    this._giveCurrentQuiz()
  }
  receiveAnswer (playerAnswer) {
    this._say(`${playerAnswer.player.entryName}${playerAnswer.answer}と` +
    '解答しました。')
    if (!this.currentQuiz) {
      this._say('今クイズは出していません!帰れ!')
      return
    } else if (this.currentQuiz.number !== playerAnswer.quiz.number) {
      this._say('遅い!その問題はもう終わりました!')
      return
    } else if (this.currentQuiz.answer !== playerAnswer.answer) {
      this._say('残念!違います!')
      return
    }
    this._say('正解!')
    this._currentQuizNumber++
    process.nextTick(this._giveCurrentQuiz)
  }
  get _giveCurrentQuiz () { return this.__giveCurrentQuiz.bind(this) }
  __giveCurrentQuiz () {
    if (this.currentQuiz) {
      this._say(`第${this.currentQuiz.number}問!${this.currentQuiz.text}`)
      this.emit('quiz', this.currentQuiz, this)
    } else {
      this._say(`それではまた次回のクイズでお会いしましょう。さようなら!`)
      this._currentQuizNumber = -1
    }
  }
}

anounce('クイズスタート')
taichi.startQuiz()
anounce('番組終了')
// [出力結果]
// ---クイズスタート---
// M太一「第1問!1 × 10 = ?」
// 関成高校「10!」
// M太一「関成高校は10と解答しました。」
// M太一「正解!」
// 難高校「10!」
// M太一「難高校は10と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 築波大附属駒破高校「10!」
// M太一「築波大附属駒破高校は10と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// バカアホ高校「10!」
// M太一「バカアホ高校は10と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// ---番組終了---
// M太一「第2問!2 × 10 = ?」
// 関成高校「20!」
// M太一「関成高校は20と解答しました。」
// M太一「正解!」
// 難高校「20!」
// M太一「難高校は20と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 築波大附属駒破高校「20!」
// M太一「築波大附属駒破高校は20と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// バカアホ高校「20!」
// M太一「バカアホ高校は20と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// M太一「第3問!3 × 10 = ?」
// 関成高校「30!」
// M太一「関成高校は30と解答しました。」
// M太一「正解!」
// 難高校「30!」
// M太一「難高校は30と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
//
// (略)
//
// M太一「築波大附属駒破高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// バカアホ高校「50!」
// M太一「バカアホ高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// M太一「それではまた次回のクイズでお会いしましょう。さようなら!」

出題イベントを発火する部分を_giveCurrentQuiz()メソッドにまとめた上で、process.nextTick()関数を使用しました。
これにより、出題イベントを発火するのは一通りコードを消化し終わってからになります。

そのため、司会者が2問目の出題をする前に一度コードが最後まで到達してしまい、天の声が番組終了を告げた後に出演者たちが残業する形になっています。

Ver.4 非同期で解答する

// クイズ解答者
class Player {
  get answer () { return this._answer.bind(this) }
  _answer (quiz, presenter) {
    setTimeout(() => {
      // かけ算を解く
      const matched = quiz.text.match(/(\d+) × (\d+) = \?/)
      const answerNumber = Number(matched[1]) * Number(matched[2])
      // 解答
      this._say(`${answerNumber}!`)
      presenter.receiveAnswer(new PlayerAnswer(this, quiz, answerNumber))
    }, this._thinkMs)
  }
}

anounce('クイズスタート')
taichi.startQuiz()
anounce('番組終了')
// [出力結果]
// ---クイズスタート---
// M太一「第1問!1 × 10 = ?」
// ---番組終了---
// 関成高校「10!」
// M太一「関成高校は10と解答しました。」
// M太一「正解!」
// 難高校「10!」
// M太一「難高校は10と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 築波大附属駒破高校「10!」
// M太一「築波大附属駒破高校は10と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// M太一「第2問!2 × 10 = ?」
// 関成高校「20!」
// M太一「関成高校は20と解答しました。」
// M太一「正解!」
// 難高校「20!」
// M太一「難高校は20と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 築波大附属駒破高校「20!」
// M太一「築波大附属駒破高校は20と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// M太一「第3問!3 × 10 = ?」
// バカアホ高校「10!」
// M太一「バカアホ高校は10と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 関成高校「30!」
// M太一「関成高校は30と解答しました。」
// M太一「正解!」
// 難高校「30!」
// M太一「難高校は30と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 築波大附属駒破高校「30!」
// M太一「築波大附属駒破高校は30と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// M太一「第4問!4 × 10 = ?」
// バカアホ高校「20!」
// M太一「バカアホ高校は20と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 関成高校「40!」
// M太一「関成高校は40と解答しました。」
// M太一「正解!」
// 難高校「40!」
// M太一「難高校は40と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 築波大附属駒破高校「40!」
// M太一「築波大附属駒破高校は40と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// M太一「第5問!5 × 10 = ?」
// バカアホ高校「30!」
// M太一「バカアホ高校は30と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 関成高校「50!」
// M太一「関成高校は50と解答しました。」
// M太一「正解!」
// 難高校「50!」
// M太一「難高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// 築波大附属駒破高校「50!」
// M太一「築波大附属駒破高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// M太一「それではまた次回のクイズでお会いしましょう。さようなら!」
// バカアホ高校「40!」
// M太一「バカアホ高校は40と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// バカアホ高校「50!」
// M太一「バカアホ高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」

ここでやっと解答者に設定した思考時間を反映させます。
結果、バカアホ高校が本領を出してきました。
あと、天の声の定時退社が激しくなってます。

async.png

Ver.5 定期チェックにより無駄な解答を防ぐ

// クイズ解答者
class Player {
  get answer () { return this._answer.bind(this) }
  _answer (quiz, presenter) {
    // 定期チェック処理
    const check = setInterval(() => {
      this._say(`(${quiz.number}問目を考え中)`)
      if (!presenter.currentQuiz ||
        presenter.currentQuiz.number !== quiz.number) {
        // もうこの解答はしても意味がないことに気づく
        this._say(`(え、${quiz.number}問目終わっとるやん...)`)
        // 定期チェックを終了
        clearInterval(check)
        // 解答をキャンセル
        clearInterval(doAnswer)
      }
    }, 90)
    const doAnswer = setTimeout(() => {
      // 定期チェックを終了
      clearInterval(check)
      // かけ算を解く
      const matched = quiz.text.match(/(\d+) × (\d+) = \?/)
      const answerNumber = Number(matched[1]) * Number(matched[2])
      // 解答
      this._say(`${answerNumber}!`)
      presenter.receiveAnswer(new PlayerAnswer(this, quiz, answerNumber))
    }, this._thinkMs)
  }
}

anounce('クイズスタート')
taichi.startQuiz()
anounce('番組終了')
// [出力結果]
// ---クイズスタート---
// M太一「第1問!1 × 10 = ?」
// ---番組終了---
// 関成高校「(1問目を考え中)」
// 難高校「(1問目を考え中)」
// 築波大附属駒破高校「(1問目を考え中)」
// バカアホ高校「(1問目を考え中)」
// 関成高校「(1問目を考え中)」
// 難高校「(1問目を考え中)」
// 築波大附属駒破高校「(1問目を考え中)」
// バカアホ高校「(1問目を考え中)」
// 関成高校「10!」
// M太一「関成高校は10と解答しました。」
// M太一「正解!」
// 難高校「10!」
// M太一「難高校は10と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 築波大附属駒破高校「10!」
// M太一「築波大附属駒破高校は10と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// M太一「第2問!2 × 10 = ?」
// バカアホ高校「(1問目を考え中)」
// バカアホ高校「(え、1問目終わっとるやん...)」
// 関成高校「(2問目を考え中)」
// 難高校「(2問目を考え中)」
// 築波大附属駒破高校「(2問目を考え中)」
// バカアホ高校「(2問目を考え中)」
// 関成高校「(2問目を考え中)」
// 難高校「(2問目を考え中)」
// 築波大附属駒破高校「(2問目を考え中)」
// バカアホ高校「(2問目を考え中)」
// 関成高校「20!」
// M太一「関成高校は20と解答しました。」
// M太一「正解!」
// 難高校「20!」
// M太一「難高校は20と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// 築波大附属駒破高校「20!」
// M太一「築波大附属駒破高校は20と解答しました。」
// M太一「遅い!その問題はもう終わりました!」
// M太一「第3問!3 × 10 = ?」
// バカアホ高校「(2問目を考え中)」
// バカアホ高校「(え、2問目終わっとるやん...)」
// 関成高校「(3問目を考え中)」
// 難高校「(3問目を考え中)」
// 築波大附属駒破高校「(3問目を考え中)」
// バカアホ高校「(3問目を考え中)」
//
// (略)
//
// 関成高校「(5問目を考え中)」
// 難高校「(5問目を考え中)」
// 築波大附属駒破高校「(5問目を考え中)」
// バカアホ高校「(5問目を考え中)」
// 関成高校「50!」
// M太一「関成高校は50と解答しました。」
// M太一「正解!」
// 難高校「50!」
// M太一「難高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// 築波大附属駒破高校「50!」
// M太一「築波大附属駒破高校は50と解答しました。」
// M太一「今クイズは出していません!帰れ!」
// M太一「それではまた次回のクイズでお会いしましょう。さようなら!」
// バカアホ高校「(5問目を考え中)」
// バカアホ高校「(え、5問目終わっとるやん...)」

解答者は0.09秒毎に会場の様子をチェックし、今解いている問題が終わっていることに気づいたらそれ以上考えるのはやめるようにしました。
おかげでバカアホ高校が会場の展開についていけるようになっているのが分かります。
まあ一度も解答できていませんが。

フルバージョン

ソース部分をまとめておきます。

const EventEmitter = require('events')

// 司会者
class Presenter extends EventEmitter {
  constructor (personName, quizes) {
    super()
    this._personName = personName
    this._quizes = quizes
    this._currentQuizNumber = -1
    console.log(`司会を務めさせて頂きます、"${personName}"です。`)
  }
  get personName () { return this._personName }
  get currentQuiz () { return this._quizes[this._currentQuizNumber] }

  _say (text) {
    console.log(`${this.personName}${text}」`)
  }

  receiveEntry (player, answerCallback) {
    this.on('quiz', answerCallback)

    this._say(`${player.entryName}がエントリーしました。頑張れ!`)
  }
  startQuiz () {
    this._currentQuizNumber++
    this._giveCurrentQuiz()
  }
  receiveAnswer (playerAnswer) {
    this._say(`${playerAnswer.player.entryName}${playerAnswer.answer}と` +
    '解答しました。')
    if (!this.currentQuiz) {
      this._say('今クイズは出していません!帰れ!')
      return
    } else if (this.currentQuiz.number !== playerAnswer.quiz.number) {
      this._say('遅い!その問題はもう終わりました!')
      return
    } else if (this.currentQuiz.answer !== playerAnswer.answer) {
      this._say('残念!違います!')
      return
    }
    this._say('正解!')
    this._currentQuizNumber++
    process.nextTick(this._giveCurrentQuiz)
  }
  get _giveCurrentQuiz () { return this.__giveCurrentQuiz.bind(this) }
  __giveCurrentQuiz () {
    if (this.currentQuiz) {
      this._say(`第${this.currentQuiz.number}問!${this.currentQuiz.text}`)
      this.emit('quiz', this.currentQuiz, this)
    } else {
      this._say(`それではまた次回のクイズでお会いしましょう。さようなら!`)
      this._currentQuizNumber = -1
    }
  }
}

// クイズ解答者
class Player {
  constructor (entryName, thinkMs) {
    this._entryName = entryName
    this._thinkMs = thinkMs

    console.log(`"${entryName}"です。頑張ります!`)
  }
  get entryName () { return this._entryName }

  _say (text) {
    console.log(`${this.entryName}${text}」`)
  }

  get answer () { return this._answer.bind(this) }
  _answer (quiz, presenter) {
    const check = setInterval(() => {
      this._say(`(${quiz.number}問目を考え中)`)
      if (!presenter.currentQuiz ||
        presenter.currentQuiz.number !== quiz.number) {
        this._say(`(え、${quiz.number}問目終わっとるやん...)`)
        clearInterval(check)
        clearInterval(doAnswer)
      }
    }, 90)
    const doAnswer = setTimeout(() => {
      clearInterval(check)
      const matched = quiz.text.match(/(\d+) × (\d+) = \?/)
      const answerNumber = Number(matched[1]) * Number(matched[2])
      this._say(`${answerNumber}!`)
      presenter.receiveAnswer(new PlayerAnswer(this, quiz, answerNumber))
    }, this._thinkMs)
  }
}

// 天の声
function anounce (text) { console.log(`---${text}---`) }

// クイズ
class Quiz {
  constructor (number, text, answer) {
    this._number = number
    this._text = text
    this._answer = answer
  }
  get number () { return this._number }
  get text () { return this._text }
  get answer () { return this._answer }
}

// 解答者の解答
class PlayerAnswer {
  constructor (player, quiz, answer) {
    this._player = player
    this._quiz = quiz
    this._answer = answer
  }
  get player () { return this._player }
  get quiz () { return this._quiz }
  get answer () { return this._answer }
}

anounce('スタッフがクイズの準備をしています。')
const quizes = []
for (let i = 1; i <= 5; i++) {
  quizes.push(new Quiz(i, `${i} × 10 = ?`, i * 10))
}
console.log(quizes)

anounce('登場人物のご紹介')
const taichi = new Presenter('M太一', quizes)
const kansei = new Player('関成高校', 200)
const nann = new Player('難高校', 200)
const chikukoma = new Player('築波大附属駒破高校', 200)
const kusozako = new Player('バカアホ高校', 500)

anounce('エントリー開始')
taichi.receiveEntry(kansei, kansei.answer)
taichi.receiveEntry(nann, nann.answer)
taichi.receiveEntry(chikukoma, chikukoma.answer)
taichi.receiveEntry(kusozako, kusozako.answer)

anounce('クイズスタート')
taichi.startQuiz()
anounce('番組終了')

これで早押しクイズはある程度まともになったかと思います。

イベント発火時の挙動とかが分かり、非同期も試せたのでスッキリしました。
他の人にとっても何かためになったなら幸い。

おしまい。