14:イベントの仕組みを理解する

プログラム上に描くイベントの仕組みは、ゲームを作るのに理解しておいたほうがよさそう。

イベントの仕組みをおさらい

(2019.6.03執筆)

イベントの記述、ここまで当たり前のように使ってました。ぶつかった時に反動が起こる、プレイヤーが話しかけた時に会話が始まるなど。 何かの条件をきっかけに、自動的に発生するのがイベント。それはゲーム中に様々な形で登場することでしょう。

古都さんのフレームワーク作品(弾幕STG)には既にイベント機能が備わっており、イベントを発生させるクラス(EventDispatcher)を元にActorやSceneクラスが作られています。イベントの発生は、ゲーム進行の基本中の基本(' '*)...今回の目的はその仕組みの理解です。

今回のコラムの狙い

  • イベントの仕組み(EventDispatcherクラス)を理解する
  • EventDispatcherを少しカスタマイズしてみる
  • 試しにイベントコードを描いて起動させてみる


先日、古都さんのコラムにEventDispatcherの改良版が掲載されたため、そちらの記事を見ながらコードの流れを追っていきたいと思います。

イベントを発生させるEventDispatcherクラスについて

詳しい説明は古都さんの記事にあるので、参照先を読んでみてください。

参照 ⇒ ブラウザ上で動く独自のイベントシステムを組む


ゲームつくりたての頃、イベントの仕組み?とかさっぱり何のことか分かりませんでした。
イベント? プログラム分野ではどういう意味? 何か特別なこと?って。
今思えば、イベントとは単なる物事を指してるだけでした。


ちょと経験を経た今、少し判ってきた部分もあります。要するにコレは鍵穴と鍵の関係なんだと。 イベント(物事)を発生させるのに必要な部品は2つなのです。

イベントの仕組みは2つの部品で成り立つ

  • addEventListener(keyName, イベント関数)......イベントを格納する箱のようなもの。箱に名前を付けて、中身(イベント関数)を保管しておく役割
  • dispatchEvent(keyName, 値)......イベントを開く鍵のようなもの。keyNameの一致したイベント箱を開けて中身(イベント関数)を発動させる。その関数に代入する"値"もセットで渡す



クラス中に、addEventListenerでイベント名と内容を登録しておいて、必要なときにdispatchEventで取り出す。という使い方。一度登録したイベント箱は、dispatchEvent鍵で何度でも発生させられるので便利です。

EventDispatcherクラスのコードを確認する

下記に、EventDispatcherクラスのコードを記載してみました。
これは当初のフレームワークのものではなく、ブラウザ上で動く独自のイベントシステムを組む記事をみて改良を加えたものになります。

EventDispatcherクラスのコード


class EventDispatcher {//独自クラスでイベントを使うのに、仕組みを自作する
    constructor() {
        this._listenerMap = {};
    }

    getListeners(eventType) { //この関数は、指定したイベントタイプの登録一覧を返す。eventtypeが見つからない場合、空の配列を用意して返す
        return this._listenerMap[eventType] = this._listenerMap[eventType] || [];
    }

    addEventListener(eventType, callback, options = {}) {//addEventListener(type, callback)でコールバック関数を登録
        const listener = { callback, once:!!options.once }; //once:がtrueなら、それが一回限りのイベントとして判別。初期値はfalse
        this.getListeners(eventType).push(listener);
    }

    removeEventListener(eventType, callback) { //これで登録したeventTypeの中にある、特定のコールバック関数を削除することができる
        const listenerList = this.getListeners(eventType);
        const index = listenerList.findIndex((lsn) => lsn.callback === callback);
        if(index >= 0) {
            listenerList.splice(index, 1);
        }
    }

    dispatchEvent(eventType, event) {//dispatchEvent(eventType, event)で登録されたeventTypeのコールバック関数(イベント情報)を実行する仕組みを記述
        const listenerList = this.getListeners(eventType);
        for(let listener of listenerList) {
            listener.callback(event); 
        }

        const filtered = listenerList.filter((lsn) => !lsn.once); //once:tureだった場合、そのイベントが一回起こったら、2回目以降は何も起こらない。
        this._listenerMap[eventType] = filtered;
    }
}

主な機能は2つ。
イベント関数を登録するaddEventListener()。
登録したイベントを起動させるdispatchEvent()。


細かい部分では、他にもいくつか機能が用意されてました。
getListeners(eventType)....指定したイベントタイプを参照する際に、見つからなければ空のリスト[]を返すようにとか。
removeEventListener(eventType, callback)....登録したイベントを消去する機能とか。

これらのお陰で、必要なコードが短略化できる感じがします。


それと重宝しそうなのが、イベントが起こるのを1回限りにする工夫とか。

    addEventListener(eventType, callback, options = {}) {//addEventListener(type, callback)でコールバック関数を登録
        const listener = { callback, once:!!options.once }; //once:がtrueなら、それが一回限りのイベントとして判別。初期値はfalse
        //~~~~略
    }

    dispatchEvent(eventType, event) {//dispatchEvent(eventType, event)で登録されたeventTypeのコールバック関数(イベント情報)を実行する仕組みを記述
        //~~~~略

        const filtered = listenerList.filter((lsn) => !lsn.once); //once:tureだった場合、そのイベントが一回起こったら、2回目以降は何も起こらない。
        this._listenerMap[eventType] = filtered;
    }

特にRPGでは、1回限りのイベントとなるケースも多いのでonce要素はぜひ使っていきたいです。なるほど...(' '*)

補足...callback関数に渡す値(new Event{})について

callback関数は、そのままでは物事が展開できない?こともあります。 イベント発生(dispatchEvent)時に受け取る情報...例えばぶつかったときの衝突相手によって、軽いケガで済むのか遠くまで吹き飛ばされるほどの大事故になるのか...変わってくるからです。 dispatchEvent(eventType, event={target:obj})

このときaddEventListener側でcallback(event) => { if(event.target === obj){〜〜} }...といった相手の情報も読み取れるよう、 代入するeventの書式を決めておくとイベントの展開が描きやすくなります。

Eventクラスの書式を決める


class Event {//dispatchEventにて、addEventListenerに登録した関数に値を渡す役割
    constructor(target, info) { 
        this.target = target;
        this.info = info;
    }
}
Event()クラスを定めて、callback関数に代入する値として使います。
callback関数のメインターゲットとなるevent.targetと、補足情報となるevent.info、今のところコレで十分です。

試しにイベントを発生させてみる

実際のゲームでは、EventDispatcherクラスを拡張したActorクラスやSceneクラス上で、イベントを登録(addEventListener)したり、イベントを発火(dispatchEvent)させたりしてます。 それぞれのActorが固有のイベントを持っていたり、それぞれのSceneが固有イベントを持っていたりする。そしてActor⇔Scene間の繋がりを通じて鍵と鍵穴の受け渡し、イベントの発生をコントロールしています。

Sceneでは、hitTestを通じてイベント発生を制御している


class Scene extends EventDispatcher {
    constructor(name, width, height, sceneColor, actorsCanvas, windowsCanvas) {
        super();
        //~~省略
    }
    
    //~~省略

    //Sceneクラス中のhitTest関数
    update(){
        if(hit) {
            actor1.dispatchEvent('hit', new Event(actor2, hit));
            actor2.dispatchEvent('hit', new Event(acotr1, hit));
        }
    //~~省略
イベント発生の主たる方法が、Sceneクラス中の_hitTestです。アクターとアクターが接触した時に「hit!イベント」を発生させる鍵を双方のアクターに渡してます。

Actor側でhitイベントの内容を登録しておくと、hit時にイベント発生

そしてActor側では、hitイベントが起こった時の内容を予め書き込んでおくことができます。何かがhitする度に、登録した内容のイベントが反応して出てきます。

例...NPCの会話イベントを登録している


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28); //Circle(16, 16, 16); 
        super(x, y, sprite, hitArea, ['npc']);
        this.Rad_To_8 = 4/Math.PI; //ラジアン表記の角度を±4の8段階で

        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                this.dir = Math.PI + e.target.dir;
                const messageWindow = new MessageWindow(['あら、壁を越えて来てくださったのかしら♪', 'あなたって、根気強いのね']);
                this.dispatchEvent('openWindow',  new Event(messageWindow));
                e.target.release(); //アクション範囲をシーンから消す(複数イベントが重なるのを防ぐため)
                global.player.isActive = false; //プレイヤーの行動を止める
            }
        });
    }
    //~~省略

会話ウィンドウ
⇒ 会話イベントのデモ(スペースキーでアクション)



前回の会話デモ、NPCがアクションに触れたときに会話イベントが起こる例です(' '*)

once機能を使ってみる


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28); //Circle(16, 16, 16); 
        super(x, y, sprite, hitArea, ['npc']);
        this.Rad_To_8 = 4/Math.PI; //ラジアン表記の角度を±4の8段階で

        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                this.dir = Math.PI + e.target.dir;
                const messageWindow = new MessageWindow(['あら、壁を越えて来てくださったのかしら♪', 'あなたって、根気強いのね']);
                this.dispatchEvent('openWindow',  new Event(messageWindow));
                e.target.release(); //アクション範囲をシーンから消す(複数イベントが重なるのを防ぐため)
                global.player.isActive = false; //プレイヤーの行動を止める
            }
        }, {once:true}); //ここを追加
    }
    //~~省略

ここに新しくonce機能を入れてみるとどうでしょう...? 会話イベントが1回限りにできそうですね。


⇒ 初回のみ会話イベントのデモ(once:true)


...?(' '*)?

あ、これじゃダメだ。ぶつかったら会話してるしてないに関わらずイベントが消えてしまう。。
つまりこの場合は、hitイベントと会話イベントを分けて記述する必要があるみたい?

once機能を正しく使う


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28); //Circle(16, 16, 16); 
        super(x, y, sprite, hitArea, ['npc']);
        this.Rad_To_8 = 4/Math.PI; //ラジアン表記の角度を±4の8段階で

        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                this.dir = Math.PI + e.target.dir;
                e.target.release(); //アクション範囲をシーンから消す(複数イベントが重なるのを防ぐため)

                this.dispatchEvent('talkStart');
            }
        });

        this.addEventListener('talkStart', () => {
            const messageWindow = new MessageWindow(['あら、壁を越えて来てくださったのかしら♪', 'あなたって、根気強いのね']);
            this.dispatchEvent('openWindow',  new Event(messageWindow));
            global.player.isActive = false; //プレイヤーの行動を止める
        }, {once:true}); //ここを追加
    }
    //~~省略
一回のみでOKなイベント箇所を切り分けて、描きなおしてみました。

⇒ 初回のみ会話イベントのデモ改良後(once:true)


OK、想定通りに動きました。

話しかける度に会話内容を変える

イベントの描き方がわかれば、話しかける度に会話内容を切り替えるのもできそうです。
会話内容を切り替えるためのトーク番号を用意して、会話イベント時にトーク番号に応じた内容を開く。で、話す度にトーク番号を1ずつ加算できればOKかな? 最後に全パターン回ったらトーク番号をリセット。

そのように描いてみます。

このActorの会話イベント内容をローションする記述


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28); //Circle(16, 16, 16); 
        super(x, y, sprite, hitArea, ['npc']);
        this.Rad_To_8 = 4/Math.PI; //ラジアン表記の角度を±4の8段階で

        this.talkNumber = 0; //会話イベント時の会話内容を切り替えるID

        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                this.dir = Math.PI + e.target.dir;
                e.target.release(); //アクション範囲をシーンから消す(複数イベントが重なるのを防ぐため)
                this.dispatchEvent('talkStart', new Event(this.talkNumber));
            }
        });
        this.addEventListener('talkStart', (e) => {
            let messages;
            switch (e.target) {
                case 0 : messages = ['こんにちは。あのーなにしてるんですか?どなたさまですか?','わたしはおねむよ。ねむねむよ。まっくらすやぁってぐっすりねるの。', 'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZZZZZZ', '※これは会話テスト']; break;
                case 1 : messages = ['ふいーーーーーー\nよくネたわ。すっきりお目ざめかしら。','すてきなミカンがいっぱいふってくる夢をみたわ。たべほうだい。ふゆはこたつでぐー0', '※これは会話テストです']; break;
                case 2 : messages = ['こんにちは', 'この世界のこと、誰かが決まり文句のように言うの。', 'ようこそ、始まりの空間へ。\n"Hello World" と aaaaaaaaaaaaaaaaaaaaaあああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ', '※これは会話テストです']; break;
                default: messages = ['さて']; this.talkNumber=-1; break;
            } 
            const messageWindow = new MessageWindow(messages);
            this.dispatchEvent('openWindow',  new Event(messageWindow));
            global.player.isActive = false; //プレイヤーの行動を止める
            this.talkNumber++;
        });
    }

会話イベント
⇒ ローテ会話イベントのデモ(スペースキーでアクション)



話しかける度に内容が変わります。すばらしいですね〜(' '*)
現在メッセージウィンドウの調整中でしたが、イベント見直したらついでに会話ローテーションもできてよかったのでした。

会話が始まるときに、プレイヤーとNPCが向き合うようにする

イベントの扱い方が判ったので、会話アクションにもう少し改良を加えます。

プレイヤーアクションに、player自身の要素を加える

class PlayerAction extends Actor {
    constructor(x, y, player) {
        const hitArea = new Circle(0, 0, 1);
        super(x, y, hitArea, ['playerAction', 'event']);
        this.player = player;
    }

    render(target, scroller) {//オーバーライドしたrenderメソッドで、アクション範囲を可視化してみる
        const context = target.getContext('2d');
        const rect = this.hitArea;
        context.beginPath();
        context.fillStyle = "rgba(255,255,255,0.7)"; //半透明の白で塗りつぶす
        context.arc(this.x + scroller.x, this.y + scroller.y, this.hitArea.radius, 0, Math.PI*2, false);
        context.fill();
    }
    update(sceneInfo, input, touch) { 
        if(this.hitArea.radius === 1) { this.hitArea.radius = 8;}
        else if(this.hitArea.radius === 8) { this.hitArea.radius = 16;}
        else if(this.hitArea.radius === 16) {this.release();}
    }
}
プレイヤーアクションのconstructor()内で、プレイヤー要素を参照できるようにします。 そしてプレイヤー側では、アクションを発生させるときに自分自身を要素に加えるようにします。

player側では、アクション時に自分自身を要素に加える

            if(input.getKeyDown(' ')) {
                const circleRadius = 24;
                const targetX = this.hitArea.cx + circleRadius * Math.cos(this.dir);
                const targetY = this.hitArea.cy - circleRadius * Math.sin(this.dir);
                this.spawnActor( new PlayerAction(targetX, targetY, this ) );
            }
このようにすると、プレイヤーアクションに触れたNPC側から間接的にplayer要素にアクセスすることができるようになります。

NPC側で、会話イベントが起こる(アクションに触れる)ときに、プレイヤーと向き合う

class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28); //Circle(16, 16, 16); 
        super(x, y, sprite, hitArea, ['npc']);
        this.Rad_To_8 = 4/Math.PI; //ラジアン表記の角度を±4の8段階で

        this.talkNumber = 0; //会話イベント時の会話内容を切り替えるID

        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                const vect = new Vector2D(e.target.player.hitArea.cx - this.hitArea.cx, e.target.player.hitArea.cy - this.hitArea.cy); //正の値ならプレイヤー右向
                this.dir = Math.atan2(-vect.dy,vect.dx); //2Dベクトルから角度に変換する式で、NPCの向きを変更
;               e.target.player.dir = this.dir + Math.PI; //プレイヤーはNPCと反対方向を向く
                e.target.release(); //アクション範囲をシーンから消す(複数イベントが重なるのを防ぐため)
                this.dispatchEvent('talkStart', new Event(this.talkNumber));
            }
        });

this.addEventListener('hit', (e) => {})の部分にて、触れた相手がプレイヤーアクションだった場合に、プレイヤーの中心座標から自身の中心座標を引いた値でベクトルを求め、そのベクトルからラジアン角度に変換する式を用いて、自分自身の向く方角を求めました。。この値が判れば、playerは反対方向を向くことが分かるので自身の角度に180度...ラジアンに直したらMath.PIを足すことで、player側の方角も指定できます。

これでどのような角度からでも、顔を向き合って話すことができるようになりました。


会話イベント
⇒ 会話イベントのデモ(スペースキーでアクション)

追記:予期せぬバグへの対処

アクションを試してる段階で、予期せぬバグが発生しました。 会話が発生する時、たまにアクターがシーンから消滅するバグ。

アクター消滅バグ

???

これの意味不明な所は、コンソールにエラーとして表示されないこと。つまり、プログラム的には正しく動作してるということなんです。。。(o _ o。) どうやって対処すればいいんでしょう?

今回の例では、プログラムの成果をChapter毎にファイルとして残してあるので(インターネット上に記録しておくためにも) いったいどのタイミングからバグが発生しだしたのか、振り返ることができました。 全部上書きせず、段階的にファイルを残しておくと、こういうときに役立ちますね。。。何とか。。。

どうも会話の章(13項)に入った辺りからバグが発生してるみたいなんですね。。(最初はこんなこと無かったから)


すると、いったいどこに原因があるのか、ある程度目星をつけることができます。

発生条件と更新履歴からバグの目星をつける

発生条件は会話を発生させること。そしてプレイヤーアクション...この2つ
その辺りで、13〜14項までに更新した内容を振り返ってみます。


過去の更新履歴とゲームファイルを起動させながら、原因となりそうな箇所を特定します。

・player要素をアクションに加えたこと? 違う、既にそれ以前からバグは起こっていた。
・会話をローテーションさせたこと? 違う、既にバグは起こっていた。
・windowクラスをシーンに新しく追加したが、それが意図しない動作をしてる? 分からん...
・2重に会話が起こるのを防ぐため、会話発生時にアクション範囲を消去していた!(←ここが怪しい!)

原因となりそうな箇所を特定


        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                this.dir = Math.PI + e.target.dir;
                e.target.release(); //アクション範囲をシーンから消す(複数イベントが重なるのを防ぐため)
                this.dispatchEvent('talkStart', new Event(this.talkNumber));
            }
        });

NPCクラスのこの部分、が、もしかしたら変な挙動を起こしていたのかもしれない。 試しに、playerActionクラスも振り返ってみます...

他に原因がないか、関連クラスを振り返る


class PlayerAction extends Actor {
    constructor(x, y, player) {
        const hitArea = new Circle(0, 0, 1);
        super(x, y, hitArea, ['playerAction', 'event']);
        this.player = player;
    }

    render(target, scroller) {//オーバーライドしたrenderメソッドで、アクション範囲を可視化してみる
        const context = target.getContext('2d');
        const rect = this.hitArea;
        context.beginPath();
        context.fillStyle = "rgba(255,255,255,0.7)"; //半透明の白で塗りつぶす
        context.arc(this.x + scroller.x, this.y + scroller.y, this.hitArea.radius, 0, Math.PI*2, false);
        context.fill();
    }
    update(sceneInfo, input, touch) { 
        if(this.hitArea.radius === 1) { this.hitArea.radius = 8;}
        else if(this.hitArea.radius === 8) { this.hitArea.radius = 16;}
        else if(this.hitArea.radius === 16) {this.release();} //ここ??
    }
}

これのアップデート中に、else if(this.hitArea.radius === 16) {this.release();} //ここ??という箇所がある。 もしかしたら、this.release();...シーンからアクターを消去するメソッドですが、これが2重に起こった場合に、範囲が違うアクターに及ぶのではなかろうか? と、おおよその予想がつけられるようになりました。

検証、this.release()を2重に起動させる

検証として、通常時からプレイヤーアクションに2重でthis.release();が発生するようにしてみます。

class PlayerAction extends Actor {
    constructor(x, y, player) {
        const hitArea = new Circle(0, 0, 1);
        super(x, y, hitArea, ['playerAction', 'event']);
        this.player = player;
    }

    render(target, scroller) {//オーバーライドしたrenderメソッドで、アクション範囲を可視化してみる
        const context = target.getContext('2d');
        const rect = this.hitArea;
        context.beginPath();
        context.fillStyle = "rgba(255,255,255,0.7)"; //半透明の白で塗りつぶす
        context.arc(this.x + scroller.x, this.y + scroller.y, this.hitArea.radius, 0, Math.PI*2, false);
        context.fill();
    }
    update(sceneInfo, input, touch) { 
        if(this.hitArea.radius === 1) { this.hitArea.radius = 8;}
        else if(this.hitArea.radius === 8) { this.hitArea.radius = 16;}
        else if(this.hitArea.radius === 16) {this.release(); this.release();} //2重に起動させて検証
    }
}

会話イベントバグの検証
⇒ 会話イベントバグの検証(スペースキーでアクション)



結果、あたり!
つまり2重に消去メソッドが起こった場合に、それを1回限りの起動に制限すれば改善されそうですね。

フレームワークのシステム部分のことなので、これは作者の古都さんに質問したほうが早い。すぐに解決策を教えてもらうことが出来ました(' '*)

2重消去バグを解決する方法

シーン側のremove()メソッドを見直します。

従来のremove()メソッド

remove(actor) {
    const index = this.actors.indexOf(actor);
    this.actors.splice(index, 1);
}

古都さんから頂いたアドバイス

これは当然1回目は動くのですが、2回目になると
indexの値が-1になり(indexOfは何もみつからないときに-1を返します!)
spliceに-1を指定すると「後ろから1番目」を削除してしまいます。

なので、↓ たぶんこれで正常に動くかと!

改善後のremove()メソッド

remove(actor) {
    const index = this.actors.indexOf(actor);
    if(index > -1) {
        this.actors.splice(index, 1);
    }
}
無事解決です! 古都さん、いつもありがとうございます。


イベントの仕組みを見直せて、色々と自由度が広がって、バグも一つ解決。
何も進んでないように見えるけど、確実な進歩です。


次回は、これらを応用してメッセージウィンドウ周りを強化していきます。 ⇒ JavaScriptでゲーム作り「15:メッセージテキスト表示の機能追加」


古都さんのフレームワークを元にほぼ最初から作ってます。
たぶん順番に見ないとちんぷんかんぷん(' '*)...

すぺしゃるさんくす

https://sbfl.net/
古都さん
JavaScriptで作る弾幕STGの基礎(フレームワーク)を使わせていただいてます。感謝!


プレイヤーキャラ
ぴぽやさんもありがとう、キャラチップをお借りしています。