JavaScriptでステートマシンを実装する(その1)
2017/08/30
ステートマシンについて詳しく知りたい方はステートマシンの仕組みのおさらいも参考にしてください。
より多機能なJavaScript ステートマシン・ライブラリ Async-FSMのソースコードをGitHubで公開しました。
Async-FSM – Finite StateMachine JavaScript Library
ステートマシンまたは(有限)状態機械は、UMLといったモデリングで利用される特殊な図や表です。
プログラミング自体が、上から下へ行単位でコードを書いていく作業であるのに対して、モデリングは図や表を駆使してアプリケーションやソフトの機能や振る舞いの全容を記すものです。自分のように個人で小規模のプログラミングをするときには、さほど重要ではないモデリングですが、少し規模が大きくしっかりしたものを作ろうとすると、コードを書くよりもまずはノートに図や表を書き起こすところから始まるのではないでしょうか。
今回、UMLとして技術的に体系化されているステートマシンを、JavaScriptで実装できないかと試行錯誤し、ひとまず最低限の機能が備わった段階になったので公開することにしました。
ステートマシンはゲームなどでも利用される技術です。たとえば、あるアクションゲームのキャラクターの動作を表すのに、単純にキーイベントと動作を結びつけるよりも、「停止」「歩行」「ダッシュ」……などの複数の状態(それもクラスとして)に分けて管理した方が、いざプログラミングを見直すときに分かりやすいと思います。
JavaScriptでステートマシンを実装するスクリプトはネット上で無数にありますが、海外製であったり機能が少し足りなかったりしたので、結局自作することにしました。一応スクリプトで使用される変数名などUML2.0の用語に準拠するようにしています。ただし、ステートマシンのすべての機能をくみ取るにはほど遠い段階で、コンポジット状態やサブ状態、直交状態などの高度な機能は現時点で実装していません。
それではステートマシンの図表をいかにJavaScriptによる実装(コード)に落とし込むか。以下サンプルを示しながら解説していきたいと思います。
《参考サイト》
JavaScript での有限状態マシン: 第 1 回 ウィジェットを設計する
JavaScript で作成するゲームでのオブジェクト指向設計
FSM.jsの仕組み
ダウンロード: JavaScript FSM.js (ver 0.1 14KB)
上記のサイトJavaScript での有限状態マシン: 第 1 回 ウィジェットを設計するで、実際にステートマシンを用いたツールチップの実装例が紹介されています。ただし、ステートマシン図や表が少しUMLのものとずれているところがあるので、これを忠実にUML的に作り直すことにしました。このFSM.js自体がUMLの入門書を参考にしているので、モデリングの仕様が若干違うだけでも実装が難しくなるのです。
上記サイトにおけるステートマシン図
上の図をUMLに忠実に書き直したステートマシン図
参考サイトのツールチップ実装デモ: ブラウザーのツールチップと FadingTooltip ウィジェットの例
FSM.jsによるツールチップ実装デモ: リンクテキストにマウスを置くとツールチップが出現します
FSM.jsはjQueryなどのライブラリに依存しません。ただし、ツールチップの実装でDOM要素を操作しないといけないので、ツールチップの実装コードを書くときはjQueryを使用しています。
コーディングで最初にするのは、FSMクラスをnewして、ステートマシンに名前を付けることです。
var myFSM = new FSM('my-FSM');
FSM.jsは状態と遷移をそれぞれクラスとして扱っています。たとえば状態のオブジェクトはStateクラスをインスタンス化(newする)ことで得られ、遷移のオブジェクトはTransitionクラスをnewすることで得られます。そのため、上の図にあるinactive状態は、
var inactiveState = new State('inactive');
このようにインスタンス化されます。Stateクラスは第1引数に状態名、第2引数にオプションのオブジェクトを指定します。オプションの詳細は次の通り。
entryAction (関数)現在の状態に入ったとき一度だけ実行する関数。内部遷移のときは実行されない。
doActivity (関数)現在の状態に入ったときentryActionに続いて実行する関数。timerをオンにすることで一定時間ごとに実行するようにできる。内部遷移のときはタイマーが停止されず処理が継続される。
exitAction (関数)現在の状態から別の状態へ遷移するとき、一度だけ実行される関数。内部遷移のときは実行されない。
timer (true/false)デフォルトではfalse。trueを指定すると、intervalで指定した時間ごとにdoActivityの処理を繰り返す。
interval (数値 ミリ秒単位)デフォルトは1000(1秒)。doActivityを処理する時間間隔を指定する。timerがtrueになっていなければ無効。
状態インスタンスを作成したら、FSMクラスのregisterState()メソッドでステートマシンに登録しなければなりません。
myFSM.registerState(inactiveState); //状態インスタンスを引数に直接渡す
また、状態インスタンスにアクセスするには、FSMクラスのメソッドを利用します。たとえば、現在の状態名を知るためには、FSMクラスのgetCurrentStateName()メソッドを使用します。
console.log( myFSM.getCurrentStateName() ); //inactive
状態のタイマーをオンにしているときは、その状態が現在の状態に移ったタイミングで時間計測やフレームのカウントが始まります。それらのデータにアクセスするにはFSMクラスのgetElapsedTime()メソッドなどを使用します。
var pauseState = new State('pause', { doActivity: function () { //現在の状態に移って5秒経過したら、次の状態へ遷移(トリガーを発動)する。 if (myFSM.getElapsedTime( myFSM.getCurrentStateName() ) > 5000) { myFSM.trigger('pause-to-fadein'); } }, timer: true, interval: 0 //計測データの誤差が出来るかぎりでないように待ち時間をゼロにしている });
現在の状態を別の状態に遷移させるには、トリガーを発生させます。FSM.jsはトリガーを状態や遷移のようなクラスとして扱っていません。FSMクラスのメソッドとして定義しています。そのため、コード内のどこでも好きな場所でトリガーを置くことができます。たとえば、ある要素のclickイベントに反応させたいときは次のように書きます。
buttonElement.addEventListener('click', function () { myFSM.trigger( 'first-to-second' ); }, false);
FSMクラスのtrigger()メソッドは第1引数に遷移インスタンス名を指定します。遷移インスタンスはTransitionクラスをインスタンス化して作成します。それでは次に遷移クラスを見ていきましょう。
var inactiveToPause = new Transition('inactive-to-pause', 'inactive', 'pause');
Transitionクラスをインスタンス化するとき、第1引数に遷移名を入れます。遷移名は自由に決められますが上の例のように前後の状態名をつないだ名称にすると分かりやすくなるかと思います。第2引数に遷移前の状態名、第3引数に遷移後の状態名を指定します。ここまでは必須のパラメータですが、Stateクラス同様にオプションを指定できます。
guard (関数)遷移可能かどうかを判断する条件。関数を指定しないと無条件に遷移する。関数を指定する場合、返り値がtrueになると、遷移が実行される。true以外が返ると遷移は実行されない。
effect (関数)現在の状態のexitアクションが実行された後、遷移中に実行される処理。
internal (true / false)Transitionインスタンスを作成する際、遷移元と遷移先を同じ状態名にすると自己遷移となるが、internalにtrueを渡すことでexitアクションとentryアクションを実行しない内部遷移となる。内部遷移のときはエフェクトを指定する。
TransitionインスタンスもまたStateクラスと同様にステートマシンに登録しなければなりません。FSMクラスのregisterTransition()メソッドを使用します。
myFSM.registerTransition(inactiveToPause); //遷移インスタンスを引数に直接渡す
またFSMクラスのtrigger()メソッドには第2引数に何らかのデータを渡すことができます。たとえば、クリックイベントでトリガーを発生させるとき、遷移後の状態内でクリックされた要素を参照したいときなどがあると思います。
buttonElement.addEventListener('click', function (eventObject) { myFSM.trigger( 'prev-to-now', eventObject ); //第2引数にイベントオブジェクトを渡している }, false); var nowState = new State('now', { //trigger()メソッドの第2引数に渡したデータは、遷移前の状態のexitAction、遷移後の状態のentryAction、doActivity、およびguard、effectが実行されるとき第1引数として渡される entryAction: function (memo) { console.log( memo.target ); //クリックされた要素を出力する } });
また、FSM.jsはFSMクラスをインスタンス化するとき、内部的に開始擬似状態と終了状態を作成します。この開始擬似状態から最初の状態へ遷移するタイミングはFSMクラスのstart()メソッドで任意に決めることができます。ただし、start()メソッドを呼び出す前に、FSMクラスのsetInitialStateName()メソッドで、開始擬似状態から遷移する最初の状態を指定しておく必要があります。
myFSM.setInitialStateName('inactive'); //最初の状態をinactiveに指定 myFSM.start(); //ステートマシン起動。inactive状態に自動で遷移する
次回はデモページのツールチップ実装を詳しく見て行きたいと思います。