JavaScriptでステートマシンを実装する(その2)
2017/08/30
ステートマシンについて詳しく知りたい方はステートマシンの仕組みのおさらいも参考にしてください。
より多機能なJavaScript ステートマシン・ライブラリ Async-FSMのソースコードをGitHubで公開しました。
Async-FSM – Finite StateMachine JavaScript Library
前回の記事JavaScriptでステートマシンを実装する(その1)にてFSM.jsというスクリプトを公開していましたが、その後コンポジット状態などのUMLの機能を盛り込もうと四苦八苦し、結局スクリプトの仕様を大きく変更することになりました。現バージョンは0.2ですが、前のバージョン(0.1)とほとんど別物になっています。
⇒ステートマシンJavaScriptライブラリ FSM-0.2.js(25KB)
追記:FSM.jsの最新版は2015年末現在ver 0.6です。ver 0.2で書かれたコードもver 0.6でそのまま動きます。ただし、ver 0.6はUnderscore.jsに依存するようになりました。
ダウンロード:FSM-0.6.1.js
(その1)の記事を出してから月日がたってしまい、すみませんが(その2)で本来やる予定だったデモページの実装の解説はなかったことにさせてもらいます。ただ、JavaScriptでステートマシン的な機能を実装する試みは放棄していません。新たなバージョンを使用したデモを作成しており、こちらを紹介する予定です。
⇒HTML5/AUDIO機能を使った音楽プレイヤーをFSM.jsで作成
ステートマシン機能を実装するFSM.js(ver 0.2)では、コンポジット状態と直交状態、浅い履歴といった機能を追加しました。APIなどの詳細は後述するとして、ひとまず上記のデモページのスクリプトをざっと載せてみます。
(function () { var button, player, options, audio, loaded, ended, track, state, transition, region; /* HTMLの要素とイベントリスナを指定 */ button = {}; button.off = $('#off'); button.playPause = $('#play-pause'); button.stop = $('#stop'); button.playmode = $('#playmode'); button.playPause.on('click', function () { /* 音楽が再生中なら */ if (state.play.isActive()) { transition.playToPause.trigger(); /* 音楽がポーズ中なら */ } else if (state.pause.isActive()) { transition.pauseToPlay.trigger(); /* 音楽が停止中なら */ } else if (state.stop.isActive()) { transition.stopToPlay.trigger(); } }); button.stop.on('click', function () { /* 音楽が再生中なら */ if (state.play.isActive()) { transition.playToStop.trigger(); /* 音楽がポーズ中なら */ } else if (state.pause.isActive()) { transition.pauseToStop.trigger(); } }); button.off.on('click', function () { /* プレイヤーがオンなら */ if (state.on.isActive()) { transition.onToOff.trigger(); /* プレイヤーがオフなら */ } else if (state.off.isActive()) { transition.offToOn.trigger(); } }); button.playmode.on('click', function () { /* リピート機能がオンなら */ if (state.repeat.isActive()) { transition.repeatToNoRepeat.trigger(); /* リピート機能がオフなら */ } else if (state.noRepeat.isActive()) { transition.noRepeatToRepeat.trigger(); } }); /* 再生する曲のデータ。nameプロパティに曲名を、sourceプロパティにファイルパスを入れる */ track = {name: 'evolution', source: '../../audio/bgm/evolution.mp3'}; /* ボリュームなどのオプションを指定 */ options = { volume: 0.05 }; /* MachineおよびStateの登録 */ /* ステートマシンのオブジェクト作成 */ player = new FSM('audio-player'); /* すべての状態はstateオブジェクトにまとめる */ state = {}; /* 「初期化」を行う状態を作成 */ state.init = new State('init', { entryAction: function () { try { audio = new Audio(); } catch (e) { throw Error('このブラウザは音楽再生に対応していません。'); } loaded = false; ended = false; audio.loop = false; audio.volume = options.volume; audio.src = track.source; audio.load(); /* 音楽データのロードが終了したら、呼び出すコールバック */ audio.addEventListener('loadeddata', function () { loaded = true; }, false); /* 再生中の曲が終了したら呼び出すコールバック */ audio.addEventListener('ended', function () { ended = true; }, false); }, /* 音楽データのロードが終了したら、init状態を完了する */ doActivity: function () { if (loaded) { loaded = false; this.completion(); } }, /* 0.1秒ごとにdoActivityメソッドを実行する */ timer: true, interval: 100 }); /* 「動作中」を示す状態を作成 */ state.on = new State('on'); /* 「動作停止中」を示す状態を作成 */ state.off = new State('off', { entryAction: function () { audio.pause(); audio.currentTime = 0; } }); /* initとon、offをマシン直下に登録 */ player.addState(state.init); player.addState(state.on); player.addState(state.off); /* on状態のサブ状態としてplay、pause、stopを定義 */ state.play = new State('play', { entryAction: function () { audio.play(); }, /* 再生が終了したら自動的にstop状態へ遷移させる */ doActivity: function () { if (ended) { ended = false; audio.loop || transition.playToStop.trigger(); } }, timer: true, interval: 100 }); state.pause = new State('pause', { entryAction: function () { audio.pause(); } }); state.stop = new State('stop', { entryAction: function () { audio.pause(); audio.currentTime = 0; } }); /* これらをon状態のサブ状態として登録 */ state.on.addState(state.play); state.on.addState(state.pause); state.on.addState(state.stop); /* on状態にプレイモードを表す領域をひとつ追加する */ region = {}; region.playmodeRegion = new Region('playmode-region'); state.repeat = new State('repeat', { entryAction: function () { audio.loop = true; } }); state.noRepeat = new State('no-repeat', { entryAction: function () { audio.loop = false; } }); state.on.appendRegion(region.playmodeRegion); region.playmodeRegion.addHistoryState(); /* 領域のサブ状態は明示的にその領域に追加する */ region.playmodeRegion.addState(state.repeat); region.playmodeRegion.addState(state.noRepeat); /* Transitionの登録 */ transition = {}; /* マシン起動時にinit状態へ遷移するfirstTransitionと、init状態からon状態へ遷移するinitToOnを定義 */ transition.firstTransition = new Transition('first-transition', null, 'init'); transition.initToOn = new Transition('init-to-on', 'init', 'on'); transition.onToOff = new Transition('on-to-off', 'on', 'off', { effect: function () { button.off.css('color', '#000'); } }); transition.offToOn = new Transition('off-to-on', 'off', 'on', { effect: function () { button.off.css('color', '#F00'); } }); /* これらをマシン直下に登録 */ player.addTransition(transition.firstTransition); player.addTransition(transition.initToOn); player.addTransition(transition.onToOff); player.addTransition(transition.offToOn); /* on状態に遷移時にstop状態へ遷移するonFirstTransition */ transition.onFirstTransition = new Transition('on-first-transition', null, 'stop'); /* play状態からpause状態への遷移を定義 */ transition.playToPause = new Transition('play-to-pause', 'play', 'pause'); /* pause状態からplay状態への遷移を定義 */ transition.pauseToPlay = new Transition('pause-to-play', 'pause', 'play'); /* stop状態からplay状態への遷移を定義 */ transition.stopToPlay = new Transition('stop-to-play', 'stop', 'play'); /* play状態からstop状態への遷移を定義 */ transition.playToStop = new Transition('play-to-stop', 'play', 'stop'); /* pause状態からstop状態への遷移を定義 */ transition.pauseToStop = new Transition('pause-to-stop', 'pause', 'stop'); /* これらをon状態(の最初の領域)に登録。Transitionインスタンスは必ず「遷移前」の状態が属する領域に追加する */ state.on.addTransition(transition.onFirstTransition); state.on.addTransition(transition.playToPause); state.on.addTransition(transition.pauseToPlay); state.on.addTransition(transition.stopToPlay); state.on.addTransition(transition.playToStop); state.on.addTransition(transition.pauseToStop); /* プレイモード領域の遷移を定義し、その領域に登録 */ transition.playmodeFirstTransition = new Transition('playmode-first-transition', null, 'no-repeat'); transition.repeatToNoRepeat = new Transition('repeat-to-no-repeat', 'repeat', 'no-repeat', { effect: function () { button.playmode.css('color', '#999'); } }); transition.noRepeatToRepeat = new Transition('no-repeat-to-repeat', 'no-repeat', 'repeat', { effect: function () { button.playmode.css('color', '#5AF'); } }); /* 直交状態の領域直下の遷移はその領域に追加する */ region.playmodeRegion.addTransition(transition.playmodeFirstTransition); region.playmodeRegion.addTransition(transition.repeatToNoRepeat); region.playmodeRegion.addTransition(transition.noRepeatToRepeat); player.start(); }());
おそらくステートマシン図を使わずにプレイヤーを作成するならば、こんなに長いコードは不要かと思われます。しかし、ここでFSM.jsを使う理由は、ステートマシン図という設計図を忠実にコードに落とし込む手段として使えるからです。FSM.jsは設計図からコードへの橋渡しとなるはずで、それによって設計図ベースのきっかりしたWebアプリケーションなりプラグインなりを作成できるようになります。
現段階で足りないと思われるところは、ジャンクションや選択擬似状態などの一般的な機能が未実装な点です。また、現状ではステートマシンの状態や遷移を動的に変更、削除できません。ただし、後者について言えば、コードを書く段階で設計図となるステートマシン図は完成しているはずなので、後々から状態を変更したり削除したりするケースを想定する必要はないと考えています。
それではFSM.jsバージョン0.2にて仕様がどう変更したかを見ていきたいと思います。
FSM.jsにこれまであったクラスは、FSM、State、Transitionでしたが、これに加えてRegionクラスが追加されました。これはステートマシンのコンポジット状態と直交状態を実装するのにどうしても必要なクラスです。ただ、ステートマシン図を単純状態のみか、コンポジット状態に限る構成とするならば、領域という概念を意識せずに済むようにしています。直交状態を実装するときに限ってRegionクラスを明示的に記述します。
FSM.jsには状態遷移を実行するために、trigger()メソッドがありました。バージョン0.2にも同メソッドがあり、使い方もほとんど同じです。ただし、FSMクラスのメソッドではなく、Transitionクラスのメソッドに変更しました。そのため引数が不要になっています。
button.playPause.on('click', function () { if (state.play.isActive()) { transition.playToPause.trigger(); } else if (state.pause.isActive()) { transition.pauseToPlay.trigger(); } else if (state.stop.isActive()) { transition.stopToPlay.trigger(); } });
前のバージョンでは開始擬似状態から最初に遷移する状態をsetInitialStateName()で指定していましたが、0.2から他の遷移と同様にTransitionクラスを作成、登録するようにしました。面倒になりますが、遷移前が開始擬似状態のときは状態名を指定する必要はなくnullを渡せばよいようにしました。
transition.firstTransition = new Transition('first-transition', null, 'init');
同様に終了状態へ遷移する前の状態をsetPreFinalStateName()で指定していましたが、0.2から他の遷移と同様にTransitionクラスを作成、登録するようにしました。こちらは遷移後の状態名を指定する必要はなくnullを渡せばよいようにしました。
transition.firstTransition = new Transition('last-transition', 'end', null);作成したTransitionインスタンスはその遷移前状態が含まれる上位状態または領域のaddTransition()メソッドで登録します。たとえば、マシンの最上位の階層にあるTransitionはFSMのaddTransition()メソッドで、コンポジット状態内のサブ状態同士をつなぐTransitionはその親状態のaddTransition()メソッドを使います。さらに直交状態の領域内のTransitionに関しては、そのRegionクラスのaddTransition()メソッドで登録します。
player.addTransition(transition.firstTransition); ...... state.on.addTransition(transition.onFirstTransition); ...... region.playmodeRegion.addTransition(transition.playmodeFirstTransition);
このようにStateとTransitionを登録するとき、前バージョンと異なり親の状態またはコンテナのメソッドで登録する必要があり、相対的な構成を意識する必要があるのが、やや面倒になった点です。その他、状態または領域の登録に順序が決まっていて、必ず上位から順に登録していかなくてはなりません。たとえば、あるRegionを作成して、そこにサブ状態をいくつか登録してからそのRegionを上位の状態に登録するということはできません。
基本となるスクリプティング
ステートマシン・クラスの作成
var fsm = new FSM('my-fsm');
状態の作成とマシン直下への登録
var init = new State('init'); fsm.addState(init);
遷移の作成とマシン直下への登録
var firstTransition = new Transition('first-transition', null, 'init'); fsm.addTransition(firstTransition);
ステートマシンの起動
fsm.start();
サブ状態の登録
var composite = new State('composite'); var subState = new State('sub-state'); composite.addState(subState);
コンポジット状態内の遷移の登録
var transition1 = new Transition('transition1', null, 'init-state1'); composite.addTransition(transition1);
領域の作成と登録
var region = new Region(); composite.appendRegion(region);
領域内のサブ状態と遷移の作成、登録
var state1 = new State('state1'); region.addState(state1); var transition2 = new Transition('transition2', null, 'init-state2'); region.addTransition(transition2);
DOMイベントを起点するときは、isActive()メソッドで状態がアクティブかどうか判断してからトリガーを実行する
button.on('click', function () { if (state.start.isActive()) { transition.startToNext.trigger(); } });
状態がアクティブになったら一定時間後、自動遷移させる例
var state2 = new State('state2', { doActivity: function () { if (this.getElapsedTime() > 10000) { this.completion(); } } });
ひとまず今回はここまででひと区切りしたいと思います。次回以降もFSM.jsメインのエントリを書いていく予定です。FSM.jsはまだ動作確認しきれてないところがあるので、バグ修正や追加機能の報告も逐一していきます。よろしくお願いします。