Chienomi

単純反応時間を計測するツールを作った

開発::noddy

簡単な方法で単純反応時間を計測するSimple Reaction Time Testerを書いた。

これはGitHubで入手でき、Git経由でローカルで動かすのが面倒な人はWWW上でも試せる

自分の単純反応時間を知っておくことは色々と役に立つので、便利かもしれない。

ちなみに、ドライブインパクトの猶予は0.433秒である。

使い方はシンプルに「エンターキーを押せ」なので、特に説明しない。 この記事で説明するのは、いつものようにコードのほうだ。

結構簡単なコードなので、今回はやや初心者向けに書いていく。

基本実装方針

なるべく簡単に書く、がまず第一。

この簡単は、労力を割かないということでもあるが、短く書くという意味でもある。 コピペでいける部分も結構あるのだけど、そこはやらない。

使いやすさを考えるとウェブにしてしまうのがいいが、多少精度の問題はある。 これは、なるべく精度を出す方向で無理はしないことにした。

そして、CSSでやれることはCSSでやる。

この結果CSSは割と攻めたものになっていて、(まだ実装しているブラウザの存在しない)random()関数を使っていたりする。

CSSベースの作り方

今回の単純反応時間計測は画面表示の変化に反応する視覚的なものであるが、そのテストの流れを考えると

  1. テストを開始 (計測のセット)
  2. 計測
  3. 結果の提示

からなり、計測は

  1. 変化前
  2. 変化後

の2状態を繰り返す。

JavaScriptベースで作る場合、変化ごとに画面を書き換えれば良いのだが、これは特に変化後の描画時は望ましくない可能性がある。

というのも、変化後の状態が発生した時点でカウントは開始されており、描画に時間がかかるとそれがパフォーマンス損失になってしまう。 計測の公平性と精度を考えるとこの時間は極力短くしたいのだが、JavaScriptのルーチン呼び出しによって追加の時間がかかる可能性がある。

もちろん、JavaScriptのルーチン呼び出しはレンダリングと比べれば軽いので、誤差として飲み込めるという意見もあるかもしれないが、軽減できるものはしたい。 また、DOM操作を行うとJavaScriptといえどちょっと重くなってしまう。 単純反応時間は1/100秒精度がほしい話なので、あまり軽視できない。

それを考えるとCSSを軸にして描画切り替えの発生を1度に済ませるのは良い案に思われる。

表示状態の切り替えは、親要素のクラスを変更することで簡単に実現できる。 つまり、

<html>
  <body>
    <div id="Div1">1</div>
    <div id="Div2">2</div>
  </body>
</html>
div { display: none; }

.div1 #Div1, .div2 #Div2 { display: block }

のようにしておけば、標準状態では何も表示されず、

document.body.className = "div1"

とするだけで1を表示できる。

変化前状態は共通のものなので、

document.body.className = "base"
document.body.className = "div1"
document.body.className = "base"
document.body.className = "div2"

のように切り替えていけば、テストを進めていくことができる。

これは、JavaScriptの中に要素を書く割合が減るという意味でも好ましい。 結果だけは動的なものなので、書かざるを得ないが。

中央に円を書く

円を書くの自体は簡単で、

#Circle {
  width: 500px;
  height: 500px;
  background-color: green;
  bordor-radius: 50%;
}

で良い。 問題はどうやってこれを中央にするかだ。

左右中央でよければ、全幅のブロック(bodyでも良い)に対して幅が固定されたブロックである円で左右marginをautoにすれば良い。

今回の場合は絶対座標で表示したいので、position: fixedが望ましい。

calc()の使用を許容できるなら

#Circle {
  width: 500px;
  height: 500px;
  background-color: green;
  bordor-radius: 50%;
  position: fixed;
  top: calc(50% - 500px / 2);
  left: calc(50% - 500px / 2);
}

で中央に寄せられる。

今回の場合はビューポートのサイズがわからないし、ビューポートがポートレートかランドスケープかもわからない。 まず、サイズに関しては、ビューポート基準のサイズを使えばはみ出さない。

が、vw基準だとランドスケープなビューポートだと縦方向がはみ出す、というようなことが考えられるので、vhvwどちらか小さい方を採用する必要がある。 そこで、min()関数の出番である。

が、top, leftは逆にmax()にする必要がある。 これは具体的な値に換算すれば分かりやすく、ビューポートが1000x800pxだとすると、50vh500px50vw400pxになる。

min(500px, 400px)なら採用されるのは400pxだ。 なので、leftの値は1000px - 400pxになるのが正しい。

で、1000px - 500px1000px - 400pxでは、1000px - 400pxのほうが大きい値になるため、max()を使うことになる。

その結果が

.test4 #Test4 {
  display: block;
  width: min(50vw, 50vh);
  height: min(50vw, 50vh);
  border-radius: 50%;
  background-color: blue;
  position: fixed;
  top: max(calc(50% - 50vw/2), calc(50% - 50vh/2));
  left: max(calc(50% - 50vw/2), calc(50% - 50vh/2));
}

だが、後ですぐ気づいた。

.test4 #Test4 {
  top: calc(50% - min(50vw, 50vh) / 2);
}

と書いたほうが100倍わかりやすい。

エンターキーのキャプチャ

document.addEventListener("keypress", e => {
  if (e.key === "Enter") { /* ... */ }
})

で良い。

keypressイベントをlistenすることで、余計なイベントを見ないのも大事なこと。 イベントを見ているとJavaScriptの処理が遅延してしまう(並列では動かない)ためだ。

時間のキャプチャ

変化イベントの発生はwindow.setTimeout()で実現できる。

ただ、window.setTimeout()は「セットからの時間」を指定するのに対して、イベント発生タイミングは現在時刻を指すDateオブジェクトに基づく。

つまり、そのDateオブジェクトを作ってからsetTimeout()するまでに1秒かかったとするならば、計測自体も1秒伸びてしまうことになる。

このため、Dateオブジェクトを作ってからsetTimeout()まではできるだけ高速に処理できるようにしたい。

実際のコードは

const addition = 3000 + Math.floor(Math.random() * 10000)
const target_date = new Date().getTime() + addition
eventTarget.push(target_date)
timer_event = setTimeout(() => {
  document.body.className = clsname
}, addition)

だが、もっと徹底的にやるなら

const addition = 3000 + Math.floor(Math.random() * 10000)
const now = new Date()
timer_event = setTimeout(() => {
  document.body.className = clsname
}, addition)
const target_date = now.getTime() + addition
eventTarget.push(target_date)

とすればさらに詰められる。

所要時間は keypress発生タイミング - timeout発生タイミング で求められるわけだが、keypressを発生させてからその時刻を確定するまでも可能な限り早くしなければならない。

が、こっちはすごく単純に、keypressイベントコールバックの最初にnew Date()すれば良い。

実際のコードでも、

document.addEventListener("keypress", e => {
  const triggered = new Date().getTime()

と一番最初に行っている。

triggeredの時刻は変動しないので、あとはゆっくり処理して大丈夫だ。 やることは結果の登録と、次のテストのセットなのだから、これも急ぐ必要はない。

ちなみに、keypressが発生してから一連の処理が終わる前に再度キーを押したらどうなるのか、だが、これはどうもならない

ブラウザのJavaScriptではイベントコールバックが走っている間はイベントは拾われないので、そのイベントのコールバックの中で複数回イベントを拾わないようにしてしまえば、イベントコールバックが重複する可能性はない。

もし連打して処理後にキーイベントを拾ってしまったら、それは単に次のテストがセットされた状態で変化発生前にキーを押しただけのことなので、普通にフライングになる。

フライングの扱い

単純反応時間計測でフライングに寛容なことをすると、ギャンブルして良い結果を出すことが可能になってしまうため、フライングには厳しいほうが良い。

ギャンブルが可能な状態だと、「変化した」ということを「ちゃんと」認識することなく押すことが可能で、こうなるとギャンブルに成功しなくても本来よりも良い結果を出しやすくなる。

このソフトウェアでは10回テストを行う上に、1回のテストごとの発生時間は3〜13秒である。 しかもフライング1回で終了になるので、慎重になる、つまりは「ちゃんと」認識してから押そうとするだろう。

イベントのセット

ここがひと工夫したところ。

コードはこちら

const TEST_VOLUME = 10

function set_test(clsname) {
  reset()
  currentEvent++
  const addition = 3000 + Math.floor(Math.random() * 10000)
  const target_date = new Date().getTime() + addition
  eventTarget.push(target_date)
  timer_event = setTimeout(() => {
    document.body.className = clsname
  }, addition)

  nextEvent = (triggered) => {
    pressing[currentEvent] = (triggered - target_date)
    if (currentEvent < TEST_VOLUME) {
      set_test(`test${currentEvent + 1}`)
    } else {
      finishEvent()
      nextEvent = null
    }
  }
}

nextEvent = function() {
  set_test("test1")
}

これは規模の大きいコードでは絶対書かないような内容である。

各テストの中身はCSSによって定義されているから、JavaScript側ではbody要素のクラスの書き換えだけである。 つまりは、テストの中身に関係なく、行う処理は同じ。クラス名が違うだけだ。

愚直に書くと、setTimeout()の中のコールバックで次のテストをセットすることになるため、「そのテストは次のテストを知っていなければならない」となり、全体がネストした関数になってしまう。

だが、ここで必要なものはクラス名だけなので、文字列を渡せば良い。 しかも、順番に行われるテストでしかないので、インデックスだけ変動させれば良い。

という条件下であれば、テスト回数だけを指定すれば、その回数だけクラス名をインクリメントしながら反復させることが可能である。 テストは順番に増やしていったので、開発手順的にもこれが楽。

個人的にはかなり短く書けたと思うのだが、もっと短くする方法がありそうな気がしないでもない。

ランキングとかなんとか

私は、本質的に必要でもないのにインターネットと通信するようなソフトウェアが心底嫌いなので、このソフトウェアがなにかを送信したりはしない。 なので、ランキングみたいなものはない。

その気になれば簡単にチートは可能なので、不正したくなるような機能を追加する気もない。

SNSシェア機能は作れなくもないけれど、これも単なるシェア内容でしかなく、簡単に書き換えられて意味がないので作らなかった。

あくまで、自分が参考にするために測定する用途に使ってもらえたらいいと思う。