JavaScriptでゲーム作り「18:会話の選択肢とイベント分岐」

会話時の選択肢「はい」「いいえ」から、イベント分岐までの流れ。
選択肢の実行デモ
⇒ 選択肢の実行デモ(会話終了後に表示)

選択肢ウィンドウと、その後のイベントを発火させる記述に取り組む

(2019.7.8 ~ 7.14執筆)

メッセージウィンドウの機能も、あとは選択肢の実装で完成を迎えられるだろうと思います。 選択肢とは、選んだ回答に応じてその後のイベントが変化するというもの。 プログラム上で、どういった流れを汲んで記述すればいいか、ここで実践してみるのです。

選択肢とイベント分岐実装までの手順

  1. 会話終了後に、イベントを発生させる記述を試す
  2. 選択肢ウィンドウをクラスとして用意する
  3. 会話終了時に、選択肢ウィンドウを開くイベントを発生させる
  4. 選択肢ウィンドウの選んだ要素に応じて、対応するイベントを発生させる、同時に選択肢を閉じる



選択肢の動きを今までのメッセージウィンドウと組み合わせる場合、このような手順を構想しました。。うむ(' '*)。
まずは下準備として、選択肢関係なくメッセージ終了後にイベントを発生させるところからやっていきたいと思います。

会話終了後に、特定のイベントを発生させる

メッセージウィンドウの元になるWindowUIクラスですが、これは14項で取り上げたEventDispatcherから拡張したクラスです。 ということは、すでにイベントを登録する機能は備わっているということで、会話後の追加イベント登録はとても簡単。

こういう所でも役立つとは...フレームワークの設計、重宝ものです(' '*)

MessageWindowクラスのconstructor内に追記

class MessageWindow extends WindowUI {//メッセージウィンドウの管理と会話送り
    constructor(messages, callback) {
        super(['messageWindow']);
        this.baseColor = "#555"; //テキスト色の初期値
        this.color = this.baseColor;
        this.size = 15; //テキストサイズの初期値
        this.fontStyle = "sans-serif"
        this.lineHeight = this.size * 1.5; //段落の高さ。フォントサイズに対する高さ比で算出

        this.margin = 2; //画面端からウィンドウ描画の間隔をどれくらいあけるか?
        this.paddingLeft = 20; //画面左からテキストをどれくらい開けるか?
        this.nameHeight = 30; //名前表示用のウィンドウ高さ
        this.windowWidth = screenCanvasWidth - this.margin*2; //現在508 ウィンドウ枠の幅をどれくらいとるか
        this.windowHeight = 100; //ウィンドウ枠の高さをどれくらいとるか

        //前もって画面領域外に描画しておくためのcanvasContext
        this.preCanvas = document.createElement('canvas');
        this.preCanvas.width = screenCanvasWidth; //メッセージ欄の幅はそのまま画面の横幅
        this.preCanvas.height = this.windowHeight + this.margin*2 + this.nameHeight; //メッセージ欄で使うcanvas高さの合計
        this.preContext = new RenderContextCanvas(this.preCanvas); 

        this.windowX = this.margin; //左画面端から余白(=margin)がウィンドウ描画位置のX座標にもなる
        this.windowY = screenCanvasHeight - this.preCanvas.height; //ウィンドウ描画位置のY座標
        this.nameX = this.margin + 5; //名前ウィンドウのX座標開始位置
        this.textX = this.margin + this.paddingLeft; //テキスト描画開始位置のx座標
        this.textY = this.nameHeight + 32; //テキスト描画開始位置のy座標
        this.textMaxWidth = screenCanvasWidth - this.paddingLeft*2; //テキスト描画の最大幅

        this._frameCount = 1; //次の表示までの経過フレーム数。会話のテンポをとるのに使用。0より大きい値ならアップデート毎に-1し、0になるまでテキストを更新しない。
        //初期化設定でウィンドウ枠を表示させるため、最初は1フレーム待機させる

        this.messages = messages; //全てのメッセージオブジェクトが格納された配列
        this.currentMessage = this.messages.shift(); //今表示するべきメッセージオブジェクト、全メッセージリストの先頭を取り出した値
        //this._text = 現在表示するテキストクラスを保持する場所

        if(callback !== undefined) { this.addEventListener('messageEnd', callback, {once:true}); }//会話終了時に登録されたコールバック関数を一回だけ起動
        this.addEventListener('messageEnd', () => { this.close(); this.playerIsActive(); }); //会話終了時にウィンドウを破棄&プレイヤー操作再開
    }

まずはメッセージウィンドウの修正からです。new MessageWindow(messages)だったのを、new MessageWindow(messages, callback)というように、callback関数も代入できる形にします。 このcallbackに値が指定されてる場合、"messageEnd"の発火で代入したcallback関数が起動されるように記述を加えました。もちろん従来のメッセージウィンドウを閉じる動作も健在です。

        if(callback !== undefined) { this.addEventListener('messageEnd', callback, {once:true}); }//会話終了時に登録されたコールバック関数を起動
        this.addEventListener('messageEnd', () => { this.close(); this.playerIsActive(); }); //会話終了時にウィンドウを破棄&プレイヤー操作再開
たったこれだけで、会話終了後のイベント(callback関数)を登録できます。
念のためオプションで {once:true}も加えました。一回限りのイベント発生とするためです。

試しにNPCの会話に、会話終了イベントを組み込んでみる


class NPC_girl1 extends NPC {
    constructor(x, y, name) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea, ['npc']);
        this.name = name;
        this.illust = [];
        this.illust[0]=new Sprite(assets.get('up_npc_girl0'), 0, 0, 200, 400); //0
        this.illust[1]=new Sprite(assets.get('up_npc_girl1'), 0, 0, 200, 400); //1
    }
    talkStart(talkNumber) { //番号によって、会話を別の内容で用意できる記述です
        let messages;
        let callback;
        switch (talkNumber) {
          default : messages = [ //ここに会話イベントを登録しておく
              new Talk(this.name, ['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'], new TalkPict(this.illust[0], {enter:true}) ),
              new Talk(this.name, ['ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…'], new TalkPict(this.illust[1], {leave:true}) ),
              new Talk("", ['#<#<#<#<#<※これは#=会話テスト!#.#.'])
            ];
            callback = () => { console.log("会話が終了しました"); this.open(new MessageWindow([new Talk("", ['会話が終了しました。'] )]))}
            break;
        }
        this.open(new MessageWindow(messages, callback));
        this.talkNumber++;
    }
}
どうでしょう...
new MessageWindow()の中にmessagesに加え、対応するcallback関数を代入しています。

が、もしやソースが見づらい感じかもしれませんね。この辺りは描き方を工夫するしか無いですが、うーむ。読めそうでしょうか???

callback = () => { console.log("会話が終了しました"); this.open(new MessageWindow([new Talk("", ['会話が終了しました。'] )]))}

とか、ぜったいミスを誘発する。{(([([])]))}..??? 入れ子状態が訳わからん。
これ、実際ゲーム作る時に、こんなふうに描く、延々と、会話の限り続くのですよ。 できるだけシンプルに記述できるようしておきたい所。


なので、メッセージウィンドウ側にて['テキスト', 'テキスト', 'テキスト']の従来の形でもメッセージ内容を受け取れるように、加えてnew Talk()と織り交ぜた表記でも動くように改良を加えてみます。

会話表記を短略化できるよう、MessageWindowのget/setを改良


    get currentMessage() { return this._currentMessage; }
    set currentMessage(message) { //会話フェーズの移行と、各要素の初期化
        if(message === undefined) { // もう空っぽだったらtextEndイベントを発生させる
            this.dispatchEvent('messageEnd'); return;// textEndイベントを発行
        }
        //↓現在の会話内容を更新、
        this._currentMessage = message;

        //表示する立ち絵についての初期化判定
        if(!this._currentMessage.illust) { //立ち絵がない場合の操作
            this.textMaxWidth = screenCanvasWidth - this.paddingLeft*2; //テキストの最大表示幅、キャラ立ち絵なしの場合
            if(this.talkPict) {this.talkPict.fadeOut(); this._frameCount = Math.abs(1/this.talkPict.fade); this.talkPict = null;} //前回の立ち絵をフェードアウトさせる

            //messages=['テキスト','テキスト','テキスト','テキスト']という形でもメッセージを渡せるように設定。
            //もちろんmessages=[]...配列要素の中にnew Talk()と単なるテキストを織り交ぜてもOK
            if( typeof (message) == "string" || message instanceof String ) { this.text = message; this.inited = false; return; } //渡された要素が単なる文字列の場合、そのままテキストに代入する
        } 
        else { //立ち絵がある場合
            this.textMaxWidth = screenCanvasWidth - this._currentMessage.illust.sprite.width; //立ち絵ありのときテキスト表示幅はやや縮小。キャラ絵の幅分を確保
            if(this.talkPict) { //前回の立ち絵が残ってる場合
                if(this.name === this.currentMessage.name) { //前回の話し手と同じキャラクターなら
                    this._frameCount = 0; //ウェイト時間は発生しない
                    if(this.talkPict !== this._currentMessage.illust) { this.talkPict.delayClose(); this.talkPict = null;} //表情差分はクロスフェードで重ねて表示させよう
                    else {}//同じ立ち絵ならイラスト変化なし
                }
                else {this.talkPict.fadeOut(); this._frameCount = Math.abs(1/this.talkPict.fade); this.talkPict = null;} //違うキャラクターなら前回の立ち絵をフェードアウト
            }
            else {this._frameCount += Math.abs(1/this._currentMessage.illust.fade);} //キャラクター登場時にフェード時間分だけメッセージ表示を遅らせる
        }
        //this.talkPict ...これは前回の立ち絵のデータを参照している
        if(this._currentMessage.text instanceof Array) { this.text = this._currentMessage.text.shift(); } //テキストの配列から、現在ウィンドウに表示する会話フェーズを取り出す
        else { this.text = this._currentMessage.text } //テキスト単体の場合はそのまま代入
        this.inited = false; //ウィンドウ枠の初期化済み=false
    }
    get text() { return this._text; }
    set text(str) { //会話フェーズ内のテキスト内容を切り替える
        if(str === undefined) {
            this.currentMessage = this.messages.shift(); return;// 次のテキストが見つからない場合、次の会話フェーズに移行する
        }
        this._text = new TextManager(str, this.textMaxWidth, this.textX, this.textY, this.preContext); //受け取った文字列をクラスに変換して操作する
        this.clearPreRenderingText(); //テキスト欄のみ初期化
    }
変更した部分はここらです。
            //messages=['テキスト','テキスト','テキスト','テキスト']という形でもメッセージを渡せるように設定。
            //もちろんmessages=[]...配列要素の中にnew Talk()と単なるテキストを織り交ぜてもOK
            if( typeof (message) == "string" || message instanceof String ) { this.text = message; this.inited = false; return; } //渡された要素が単なる文字列の場合、そのままテキストに代入する
メッセージを受け取る箇所と

        if(this._currentMessage.text instanceof Array) { this.text = this._currentMessage.text.shift(); } //テキストの配列から、現在ウィンドウに表示する会話フェーズを取り出す
        else { this.text = this._currentMessage.text } //テキスト単体の場合はそのまま代入
テキストを代入する箇所。


それぞれ、文字列か配列を判定して、どちらの型でも動くように条件分けを施しました。

あ、もう一つ修正箇所が有ります。

    update(sceneInfo, input, touch) { //毎フレーム呼び出されるupdateメソッド。このクラスのプログラム動作の本体
        this.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める
        if(this.text.compleate ) { //現在の会話の文字数すべて表示されてるなら
            if(input.getKey(' ') || touch.touch) {  //スペースキーorタッチを押した時に
                if(this._currentMessage.text instanceof Array) {this.text = this._currentMessage.text.shift();} // new Talk()のテキストに配列が代入されてるケース。次のテキストへ移行するメソッド
                else { this.currentMessage = this.messages.shift(); } //こちら文字列のみが渡されていた場合の移行措置
            }
        }
        if(!this.inited){ //会話フェーズが切り替わるならウィンドウ枠全体の初期化
            return this.updateInit(sceneInfo, input, touch);
        }
        else if( this._frameCount <= 0 ) { //カウントが0なら、テキストを更新
            return this.text.update(sceneInfo, input, touch); 
        }
        else {this._frameCount--;}
    }

この場所です。
            if(input.getKey(' ') || touch.touch) {  //スペースキーorタッチを押した時に
                if(this._currentMessage.text instanceof Array) {this.text = this._currentMessage.text.shift();} // new Talk()のテキストに配列が代入されてるケース。次のテキストへ移行するメソッド
                else { this.currentMessage = this.messages.shift(); } //こちら文字列のみが渡されていた場合の移行措置
            }
会話内容を移行するときも、現在の受け取ってるデータ型をみて、それに合うような動きをさせてます。
これで['テキスト','テキスト','テキスト']のような従来の描き方もできるようになり、不必要に会話オブジェクトを重ねなくて良くなりました。

同様に、new TalkPictも省略表記できるように


    get currentMessage() { return this._currentMessage; }
    set currentMessage(message) { //会話フェーズの移行と、各要素の初期化
        if(message === undefined) { // もう空っぽだったらtextEndイベントを発生させる
            this.dispatchEvent('messageEnd'); return;// textEndイベントを発行
        }
        //選択肢を渡された場合、いったん会話を中断して選択モードとなる。出現中は選択肢クラス側を操作する
        if(message instanceof SelectWindow) { this.addSelectWindow(message); return;}

        //↓以降は、現在の会話内容を更新
        this._currentMessage = message;
        //表示する立ち絵についての初期化判定
        if(!this._currentMessage.image) { //立ち絵がない場合の操作
            this.textMaxWidth = screenCanvasWidth - this.paddingLeft*2; //テキストの最大表示幅、キャラ立ち絵なしの場合
            if(this.talkPict[0]) {this.talkPict[0].fadeOut(); this._frameCount = Math.abs(1/this.talkPict[0].fade); } //前回の立ち絵をフェードアウトさせる

            //messages=['テキスト','テキスト','テキスト','テキスト']という形でもメッセージを渡せるように設定。
            //もちろんmessages=[]...配列要素の中にnew Talk()と単なるテキストを織り交ぜてもOK
            if( typeof (message) == 'string' || message instanceof String ) { this.text = message; this.inited = false; return; } //渡された要素が単なる文字列の場合、そのままテキストに代入する
        } 
        else { //立ち絵がある場合
            if(this._currentMessage.image instanceof Sprite) { //指定がスプライト画像のみの場合、立ち絵専用のクラスに変換する。
                this._currentMessage.image = new TalkPict(this._currentMessage.image);
            }
            this.textMaxWidth = screenCanvasWidth - this._currentMessage.image.sprite.width; //立ち絵ありのときテキスト表示幅はやや縮小。キャラ絵の幅分を確保
            if(this.talkPict[0]) { //前回の立ち絵が残ってる場合
                if(this.name === this.currentMessage.name) { //前回の話し手と同じキャラクターなら
                    this._frameCount = 0; //ウェイト時間は発生しない
                    this.talkPict[0].delayClose(); //表情差分はクロスフェードで重ねて表示させよう
                } else {
                    this.talkPict[0].fadeOut(); //違うキャラクターなら前回の立ち絵をフェードアウト
                    this._frameCount += Math.abs(1/this._currentMessage.image.fade); //入れ替わるフェード時間のタイムラグを確保
                } 
            } else { this._frameCount += Math.abs(1/this._currentMessage.image.fade); } //キャラクター登場時にフェード時間分だけメッセージ表示を遅らせる
            this.addImage(this._currentMessage.image); //会話に現在の立ち絵を追加する
        }
        //this.talkPict[0] ...これは前回の立ち絵のデータを参照している
        if(this._currentMessage.text instanceof Array) { this.text = this._currentMessage.text.shift(); } //テキストの配列から、現在ウィンドウに表示する会話フェーズを取り出す
        else { this.text = this._currentMessage.text } //テキスト単体の場合はそのまま代入
        this.inited = false; //ウィンドウ枠の初期化済み=false
    }

この中の、この部分ですね
        else { //立ち絵がある場合
            if(this._currentMessage.image instanceof Sprite) { //指定がスプライト画像のみの場合、立ち絵専用のクラスに変換する。
                this._currentMessage.image = new TalkPict(this._currentMessage.image);
            }
新しいmessageが渡された場合、もしimage要素がsprite画像そのままだったら、TalkPictクラスに変換して渡せるように記述を加えました。これでオプションが必要ないなら、new TalkPict()の表記も省略可能になります。

改良後の会話終了イベント

改良後の会話イベント表記がこちらです。

class NPC_girl1 extends NPC {
    constructor(x, y, name) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea, ['npc']);
        this.name = name;
        this.illust = [];
        this.illust[0]=new Sprite(assets.get('up_npc_girl0'), 0, 0, 200, 400); //0
        this.illust[1]=new Sprite(assets.get('up_npc_girl1'), 0, 0, 200, 400); //1
    }
    talkStart(talkNumber) { //会話を表示する関数、NPCの拡張クラスではこの関数を上書きして各々の会話内容を設定します。
        let messages;
        let callback;
        switch (talkNumber) { //番号によって、会話を別の内容で用意できる記述です
          default : messages = [ //ここに会話イベントを登録しておく
              new Talk(this.name, ['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'], new TalkPict(this.illust[0], {enter:true}) ),
              new Talk(this.name, 'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…', new TalkPict(this.illust[1], {leave:true}) ),
              '#<#<#<#<#<※これは#=会話テスト!#.#.'
            ];
            callback = () => { console.log("会話が終了しました"); this.open(new MessageWindow(['会話が終了しました。'])) }
            break;
        }
        this.open(new MessageWindow(messages, callback));
        this.talkNumber++;
    }
}
前のと比べて、余分な[]とか、new Talk()とかが無くなってます。その分、表記を簡潔にできました。
挙動が複雑になるほど、できるだけ機能を絞っていける対応も必要ですね。製作時の主となる表記(会話など)が、シンプルに書けることを優先です。

callback = () => { console.log("会話が終了しました"); this.open(new MessageWindow(['会話が終了しました。'])) }


{(([([])]))}から... {(([]))}にできた。中々の成果です(' '*)

さらにmessageOpen関数を用意して表記を簡略化

それからthis.open()と、new MessageWindowの生成を一つのメソッドにまとめる、というのも有効そうです。 NPCにこのようなメソッドを用意します。
    messageOpen(messages, callback) { //メッセージウィンドウを生成してシーンに追加する関数
        const messageWindow = new MessageWindow(messages, callback);
        this.open(messageWindow);
    }

すると、会話表記がさらに簡潔なものとなります。

class NPC_girl1 extends NPC {
    constructor(x, y, name) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea, ['npc']);
        this.name = name;
        this.illust = [];
        this.illust[0]=new Sprite(assets.get('up_npc_girl0'), 0, 0, 200, 400); //0
        this.illust[1]=new Sprite(assets.get('up_npc_girl1'), 0, 0, 200, 400); //1
    }
    talkStart(talkNumber) { //会話を表示する関数、NPCの拡張クラスではこの関数を上書きして各々の会話内容を設定します。
        let messages;
        let callback;
        switch (talkNumber) { //番号によって、会話を別の内容で用意できる記述です
          default : messages = [ //ここに会話イベントを登録しておく
              new Talk(this.name, ['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'], new TalkPict(this.illust[0], {enter:true}) ),
              new Talk(this.name, 'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…', new TalkPict(this.illust[1], {leave:true}) ),
              '#<#<#<#<#<※これは#=会話テスト!#.#.'
            ];
            callback = () => { console.log("会話が終了しました"); this.messageOpen(['会話が終了しました。']); }
            break;
        }
        this.messageOpen(messages, callback);
        this.talkNumber++;
    }
    messageOpen(messages, callback) { //メッセージウィンドウを生成してシーンに追加する関数
        const messageWindow = new MessageWindow(messages, callback);
        this.open(messageWindow);
    }
}

callback = () => { console.log("会話が終了しました"); this.messageOpen(['会話が終了しました。']); }

callbackの新しいメッセージウィンドウが、最終的に{([])}という入れ子状態で済むようになりました。約半分の短縮で、会話も作りやすい感じ。素晴らしい成果です。

会話イベントのcallbackデモ

はてさて、話を戻して。
会話イベント終了後に新たなイベントを起こすテストをしてましたね。本題からそれた。
改めてコールバック関数を登録したのでデモを見てみます。

会話終了イベント
⇒ 会話終了イベントのデモ



無事に、イベント発火できましたね(' '*)、今回の肝となる部分です。
会話終了のお知らせをメッセージウィンドウに載せましたが、これを応用して選択肢ウィンドウを表示、さらに選択肢ウィンドウからの選択後イベントで、対応する選択肢のcallbackを発火させるという連撃イベント。次々とイベントを展開させて、狙ったとおりの反応を再現できそうですよ。

選択肢ウィンドウ SelectWindowクラスを作成する

さて、ここから本題。選択肢ウィンドウを自作してみます。必要な機能は、選択肢を配列で受け取る。選んだ選択によるイベント(callback関数)も配列で受け取る。 後は、細かいデザインくらいでしょうか。

まぁ、単に選択肢を表示させる所からやってみましょう。
焦らず、1歩ずつイメージを形にしていきます。

SelectWindowクラスの原型、選択肢をウィンドウで表示させる


class SelectWindow extends WindowUI {
    constructor(selectItems) {
        super(['select']);
        this.selectItems = selectItems;
       
        this.fontStyle = "sans-serif"
        this.fontSize = 15; //選択肢文字のフォントサイズ
        this.lineHeight = this.fontSize * 1.5; //行の高さ
        this.maxWidth = 0; //後で文字列の最大幅を求める。
        this.height = this.lineHeight * this.selectItems.length; //選択肢の高さ

        this.preCanvas = document.createElement('canvas');
        this.preContext = new RenderContextCanvas(this.preCanvas);
        this.preContext.font(`${this.fontSize}px ${this.fontStyle}`);

        //渡された選択肢の最大幅をチェック、maxWidth値を要素に保存する
        for (let str of this.selectItems) { //渡された選択肢1つずつに、文字列の長さを確認
            const itemWidth = this.preContext.measureText(str).width;
            if(itemWidth > this.maxWidth) { this.maxWidth = itemWidth; } //文字列の最大幅が決定
        }

        this.padding = 20; //ウィンドウ枠の端から文字描画までどれくらいの間隔を空けるか?
        this.preCanvas.width = this.maxWidth + this.padding*2;
        this.preCanvas.height = this.height + this.padding*2; //メッセージ欄で使うcanvas高さの合計

        //求めたcanvas幅と高さから、選択肢ウィンドウが画面中央に来る座標をチェック(左上の座標を求める)
        this.x = (screenCanvasWidth - this.preCanvas.width)*0.5; 
        this.y = (screenCanvasHeight - this.preCanvas.height)*0.5;

        this.preRenderingWindow();
        this.preRenderingSelectItems();
    }

    preRenderingWindow() { //ウィンドウをプリ描画する
        this.preContext.beginPath();
        this.preContext.strokeColor('rgb(255,255,255)');
        this.preContext.fillColor('rgba(0,0,0,0,5)');
        this.preContext.rect(0, 0, this.preCanvas.width, this.preCanvas.height);
        this.preContext.fill();
        this.preContext.stroke();
    }
    preRenderingSelectItems() { //選択肢をプリ描画する
        this.preContext.fillColor('rgb(255,255,255)');
        this.preContext.font(`${this.fontSize}px ${this.fontStyle}`);
        for (let i=0; i < this.selectItems.length; i++) {
            const x = this.padding;
            const y = this.padding + this.fontSize + (this.lineHeight*i);
            const text = this.selectItems[i];

            this.preContext.fillText(text, x, y);
        }
    }

    update(sceneInfo, input, touch) {
    }

    render(context) { 
        //スクリーン画面にプリ描画データ(選択肢ウィンドウ全て)を描画
        context.drawImage(this.preCanvas, 0, 0, this.preCanvas.width, this.preCanvas.height, this.x, this.y);
    }
}
選択肢ウィンドウのデザインだけで、結構な行数でした。

        this.preCanvas = document.createElement('canvas');
        this.preContext = new RenderContextCanvas(this.preCanvas);
        this.preContext.font(`${this.fontSize}px ${this.fontStyle}`);

        //渡された選択肢の最大幅をチェック、maxWidth値を要素に保存する
        for (let str of this.selectItems) { //渡された選択肢1つずつに、文字列の長さを確認
            const itemWidth = this.preContext.measureText(str).width;
            if(itemWidth > this.maxWidth) { this.maxWidth = itemWidth; } //文字列の最大幅が決定
        }
プレCanvasを用意し、選択肢の文字列それぞれを調べて、一番幅の長い文字列をウィンドウの基準幅にする。
選択肢の数を元に、ウィンドウの高さを算出。

        this.padding = 20; //ウィンドウ枠の端から文字描画までどれくらいの間隔を空けるか?
        this.preCanvas.width = this.maxWidth + this.padding*2;
        this.preCanvas.height = this.height + this.padding*2; //メッセージ欄で使うcanvas高さの合計
あとは、余白とかの数値を入れて、ウィンドウ全体の必要な描画サイズを決める。

        this.preRenderingWindow();
        this.preRenderingSelectItems();
    }

    preRenderingWindow() { //ウィンドウをプリ描画する
        this.preContext.beginPath();
        this.preContext.strokeColor('rgb(255,255,255)');
        this.preContext.fillColor('rgba(0,0,0,0,5)');
        this.preContext.rect(0, 0, this.preCanvas.width, this.preCanvas.height);
        this.preContext.fill();
        this.preContext.stroke();
    }
    preRenderingSelectItems() { //選択肢をプリ描画する
        this.preContext.fillColor('rgb(255,255,255)');
        this.preContext.font(`${this.fontSize}px ${this.fontStyle}`);
        for (let i=0; i < this.selectItems.length; i++) {
            const x = this.padding;
            const y = this.padding + this.fontSize + (this.lineHeight*i);
            const text = this.selectItems[i];

            this.preContext.fillText(text, x, y);
        }
    }
プリ描画のcanvasサイズが決まったら、ウィンドウ枠を描画して、選択肢の文字列も貼り付ける。
アップデートにて、この選択肢が存在してる間だけスクリーンにthis.preCanvasの全データを貼り付ける。

        //求めたcanvas幅と高さから、選択肢ウィンドウが画面中央に来る座標をチェック(左上の座標を求める)
        this.x = (screenCanvasWidth - this.preCanvas.width)*0.5; 
        this.y = (screenCanvasHeight - this.preCanvas.height)*0.5;
その際、ウィンドウが画面中央に来るように座標を計算してる。。などなど。



あ、やること多いですね(' '*)。。。
プログラムによる設計はただただ慣れが必要。
小石の集まりでしか岩を表現できないみたいな。

まぁ、そんなもんです(o _ o。)

デモで選択画面を確認

ではでは、会話終了後に選択肢を表示だけさせてみます。
中身はひとまず['はい','いいえ']だけで良いでしょう。

NPCクラスの会話にて、callback関数に選択肢ウィンドウのオープンメソッドを追加します。

class NPC_girl1 extends NPC {
    constructor(x, y, name) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea, ['npc']);
        this.name = name;
        this.illust = [];
        this.illust[0]=new Sprite(assets.get('up_npc_girl0'), 0, 0, 200, 400); //0
        this.illust[1]=new Sprite(assets.get('up_npc_girl1'), 0, 0, 200, 400); //1
    }
    talkStart(talkNumber) { //会話を表示する関数、NPCの拡張クラスではこの関数を上書きして各々の会話内容を設定します。
        let messages=[];
        let callback=()=>{};
        switch (talkNumber) { //番号によって、会話を別の内容で用意できる記述です
          default : messages = [ //ここに会話イベントを登録しておく
              new Talk(this.name, ['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'], new TalkPict(this.illust[0], {enter:true}) ),
              new Talk(this.name, 'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#< Z#< Z#< Z#< ZZ…', new TalkPict(this.illust[1], {leave:true}) ),
              '選択肢ウィンドウを表示します'
            ];
            callback = () => { this.selectOpen(['はい', 'いいえ']); }
            break;
        }
        this.messageOpen(messages, callback);
        this.talkNumber++;
    }

    selectOpen(selectItems, callbackList) {
        const selectWindow = new SelectWindow(selectItems, callbackList);
        this.open(selectWindow);
    }
}


選択肢の表示
⇒ 選択肢の表示デモ(会話終了後に表示)



ぶじに表示されたぞ!(' '*)

選択肢をマウスや矢印キーで選択できるようにする

次は、選択肢を選択できるようにしてみます。クリックやタッチに反応する選択範囲も必要でしょうね。 そしてupdate()内にて、キーやタッチの動きに応じて「選択されている」という状態を示すのも必要。試しに描いてみます。

選択肢クラスを選択できる状態にするコード


class SelectWindow extends WindowUI {
    constructor(selectItems) {
        super(['select']);
        this.selectItems = selectItems;
        this.length = selectItems.length; //選択肢の数をキャッシュ
       
        this.fontStyle = "sans-serif"
        this.fontSize = 15; //選択肢文字のフォントサイズ
        this.lineHeight = this.fontSize * 1.5; //行の高さ
        this.maxWidth = 0; //後で文字列の最大幅を求める。
        this.height = this.lineHeight * this.length; //選択肢の高さ

        this.preCanvas = document.createElement('canvas');
        this.preContext = new RenderContextCanvas(this.preCanvas);
        this.preContext.font(`${this.fontSize}px ${this.fontStyle}`);

        //渡された選択肢の最大幅をチェック、maxWidth値を要素に保存する
        for (let item of this.selectItems) { //渡された選択肢1つずつに、文字列の長さを確認
            const itemWidth = this.preContext.measureText(item).width;
            if(itemWidth > this.maxWidth) { this.maxWidth = itemWidth; } //文字列の最大幅が決定
        }

        this.padding = 20; //ウィンドウ枠の端から文字描画までどれくらいの間隔を空けるか?
        this.preCanvas.width = this.maxWidth + this.padding*2;
        this.preCanvas.height = this.height + this.padding*2; //メッセージ欄で使うcanvas高さの合計

        this.preRenderingWindow();
        this.preRenderingSelectItems();

        //求めたcanvas幅と高さから、選択肢ウィンドウが画面中央に来る絶対座標をチェック(左上の座標を求める)
        this.x = (screenCanvasWidth - this.preCanvas.width)*0.5; 
        this.y = (screenCanvasHeight - this.preCanvas.height)*0.5;

        this.hitAreaArray = [];
        for (let i=0; i< this.length; i++) { //選択肢1つずつにhitAreaを設定、クリックやタッチで選択肢を選べるように
            const left = this.x + this.padding;
            const top = this.y + this.padding + (this.lineHeight*i); //段落数分、y座標を調整
            const right = left + this.maxWidth;
            const bottom = top + this.lineHeight;
            const hitArea = new aabbRect(left, top, right, bottom);
            this.hitAreaArray.push(hitArea);
        }

        this.selectIndex = null; //現在選択されてる配列のインデックス値を指定
    }

    preRenderingWindow() { //ウィンドウをプリ描画する
        this.preContext.beginPath();
        this.preContext.strokeColor('rgb(255,255,255)');
        this.preContext.fillColor('rgba(0,0,0,0,5)');
        this.preContext.rect(0, 0, this.preCanvas.width, this.preCanvas.height);
        this.preContext.fill();
        this.preContext.stroke();
    }
    preRenderingSelectItems() { //選択肢をプリ描画する
        this.preContext.fillColor('rgb(255,255,255)');
        this.preContext.font(`${this.fontSize}px ${this.fontStyle}`);
        for (let i=0; i< this.length; i++) {
            const x = this.padding;
            const y = this.padding + this.fontSize + (this.lineHeight*i);
            const text = this.selectItems[i];

            this.preContext.fillText(text, x, y);
        }
    }

    hitTest(touch, rect) { //矩形範囲とタッチ座標との当たり判定
        return (rect.left < touch.x && touch.x < rect.right) && (rect.top < touch.y && touch.y < rect.bottom);
    }
    hitTestIndex(touch) { //配列それぞれのhitAreaに対して当たり判定を行い、hitしたら配列のIndex値を返す
        for (let i=0; i< this.length; i++) {
            if(this.hitTest(touch, this.hitAreaArray[i])) {return i;}
        } return null;
    }

    update(sceneInfo, input, touch) {
        if( this.selectIndex === null ) { //未だ選択準備中のとき
            if(input.getKeyDown(' ') || input.getKeyDown('ArrowUp') || input.getKeyDown('ArrowDown')) { this.selectIndex = 0; return; }
        }
        const i = this.hitTestIndex(touch);
        if(i !== null) { this.selectIndex = i; }
        if(input.getKeyDown('ArrowDown')) { this.selectIndex = Math.min(this.selectIndex+1, this.length-1)} //↓キーで、選択中のインデックス値を1増やす。上限は選択肢の数 -1(配列が0番目から始まるので)
        if(input.getKeyDown('ArrowUp')) { this.selectIndex = Math.max(this.selectIndex-1, 0)} //↑キーが押された時、選択中のインデックス値を1減らす。下限は0
        console.log(this.selectIndex);
    }

    renderFlash(context, i) {
        if(i === null) {return;} //インデックス値が無指定なら何もしない
        context.beginPath();
        context.fillColor('rgba(200, 200, 40, 0.5)');
        context.rect(this.hitAreaArray[i].left, this.hitAreaArray[i].top, this.maxWidth, this.lineHeight);
        context.fill();
    }

    render(context) { 
        //スクリーン画面にプリ描画データ(選択肢ウィンドウ全て)を描画
        context.drawImage(this.preCanvas, 0, 0, this.preCanvas.width, this.preCanvas.height, this.x, this.y);
        this.renderFlash(context, this.selectIndex); //選択されてる箇所を強調させる
    }
}

長いです、人の書いたコードなんて読む気失せますね。
なので要点だけ掻い摘みます。


まず、今選択肢の何番目が選択されているか?という変数をもたせます。最初は無指定でnull。
        this.selectIndex = null; //現在選択されてる配列のインデックス値を指定
そしてタッチの選択範囲を矩形で表せるようにします。当たり判定の要領です。
        this.hitAreaArray = [];
        for (let i=0; i< this.length; i++) { //選択肢1つずつにhitAreaを設定、クリックやタッチで選択肢を選べるように
            const left = this.x + this.padding;
            const top = this.y + this.padding + (this.lineHeight*i); //段落数分、y座標を調整
            const right = left + this.maxWidth;
            const bottom = top + this.lineHeight;
            const hitArea = new aabbRect(left, top, right, bottom);
            this.hitAreaArray.push(hitArea);
        }


あとはupdate()内にて、タッチやキーボードの動きに応じて、this.selectIndexの値を変化させてあげます。
    update(sceneInfo, input, touch) {
        if( this.selectIndex === null ) { //未だ選択準備中のとき
            if(input.getKeyDown(' ') || input.getKeyDown('ArrowUp') || input.getKeyDown('ArrowDown')) { this.selectIndex = 0; return; }
        }
        const i = this.hitTestIndex(touch);
        if(i !== null) { this.selectIndex = i; }
        if(input.getKeyDown('ArrowDown')) { this.selectIndex = Math.min(this.selectIndex+1, this.length-1)} //↓キーで、選択中のインデックス値を1増やす。上限は選択肢の数 -1(配列が0番目から始まるので)
        if(input.getKeyDown('ArrowUp')) { this.selectIndex = Math.max(this.selectIndex-1, 0)} //↑キーが押された時、選択中のインデックス値を1減らす。下限は0
        console.log(this.selectIndex);
    }


もしタッチ操作があった場合、タッチ座標と選択範囲で示した矩形との当たり判定を行います。返す値はインデックス値、或いはnullです。
    hitTest(touch, rect) { //矩形範囲とタッチ座標との当たり判定
        return (rect.left < touch.x && touch.x < rect.right) && (rect.top < touch.y && touch.y < rect.bottom);
    }
    hitTestIndex(touch) { //配列それぞれのhitAreaに対して当たり判定を行い、hitしたら配列のIndex値を返す
        for (let i=0; i< this.length; i++) {
            if(this.hitTest(touch, this.hitAreaArray[i])) {return i;}
        } return null;
    }


最後に、選択範囲が可視化できるように選択されてる箇所を強調させる記述
    renderFlash(context, i) {
        if(i === null) {return;} //インデックス値が無指定なら何もしない
        context.beginPath();
        context.fillColor('rgba(200, 200, 40, 0.5)');
        context.rect(this.hitAreaArray[i].left, this.hitAreaArray[i].top, this.maxWidth, this.lineHeight);
        context.fill();
    }


だいたいこんな感じです(' '*)

選択肢の選択表示
⇒ 選択肢の選択表示デモ(会話終了後に表示)



選択肢を選んだ後、対応するイベントを起動させる!

さ、後は選ばれた配列のインデックス番号に応じて、コールバック関数を起動させるだけですよ。
選択肢と一緒に、コールバック関数もセットで入れられるようにしてみます。

Talkと同じ要領で、Selectという選択肢専用のクラスを用意してみるとどうでしょう。

選択肢専用クラス、Selectを作成

class Select { //SelectWindowに渡す必要情報をまとめたクラス
    constructor(str, callback, message) {
        this.str = str;
        this.callback = callback;
        this.message = message;
    }
}
//SelectWindowに渡す情報は、選択肢の数だけSelectクラスを用意して、配列の形にまとめて渡す
//new SelectWindow([new select(), new select(), new select(), new select()])

NPC側で、選択肢と対応するイベントを記述する

このクラスを用意することで、NPC側での選択肢とイベントの描き方が、以下のようになります。

class NPC_girl1 extends NPC {
    constructor(x, y, name) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea, ['npc']);
        this.name = name;
        this.illust = [];
        this.illust[0]=new Sprite(assets.get('up_npc_girl0'), 0, 0, 200, 400); //0
        this.illust[1]=new Sprite(assets.get('up_npc_girl1'), 0, 0, 200, 400); //1
    }
    talkStart(talkNumber) { //会話を表示する関数、NPCの拡張クラスではこの関数を上書きして各々の会話内容を設定します。
        let messages=[];
        let callback=()=>{};
        switch (talkNumber) { //番号によって、会話を別の内容で用意できる記述です
          default : messages = [ //ここに会話イベントを登録しておく
              new Talk(this.name, ['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'], new TalkPict(this.illust[0], {enter:true}) ),
              '選択肢ウィンドウを表示します'
            ];
            callback = () => { this.selectOpen([ //ここから選択肢を表示
                new Select('はい', () => this.messageOpen(['はいが選ばれました'])),
                new Select('いいえ', () => this.messageOpen(['いいえが選ばれました']))
            ]);}
            break;
        }
        this.messageOpen(messages, callback);
        this.talkNumber++;
    }

    selectOpen(selectItems, callbackList) {
        const selectWindow = new SelectWindow(selectItems, callbackList);
        this.open(selectWindow);
    }
}

この部分ですね。選択肢とイベントが見やすく記述できてます。いい感じです。
            callback = () => { this.selectOpen([ //ここから選択肢を表示
                new Select('はい', () => this.messageOpen(['はいが選ばれました'])),
                new Select('いいえ', () => this.messageOpen(['いいえが選ばれました']))
            ]);}

SelectWindowクラスの修正と追記

あとは、書式を変えたことによるSelectWindow内の修正と、コールバック関数の起動に関する記述ですね。
エラーを確認しながら、ちょいちょいっとやっていきます。ま〜色々休憩しながらやってました。シンプルにするには、思考を練るひつようあるし、それにゃ気分転換も要るし。何よりコーd面倒くさいし...(o _ o。)

class SelectWindow extends WindowUI {
    constructor(selectItems, callbackList, messages) {
        super(['select']);
        this.selectItems = selectItems;
        this.length = selectItems.length; //選択肢の数をキャッシュ
       
        this.fontStyle = "sans-serif"
        this.fontSize = 15; //選択肢文字のフォントサイズ
        this.lineHeight = this.fontSize * 1.5; //行の高さ
        this.maxWidth = 0; //後で文字列の最大幅を求める。
        this.height = this.lineHeight * this.length; //選択肢の高さ

        this.preCanvas = document.createElement('canvas');
        this.preContext = new RenderContextCanvas(this.preCanvas);
        this.preContext.font(`${this.fontSize}px ${this.fontStyle}`);

        //渡された選択肢の最大幅をチェック、maxWidth値を要素に保存する
        for (let item of this.selectItems) { //渡された選択肢1つずつに、文字列の長さを確認
            const itemWidth = this.preContext.measureText(item.str).width;
            if(itemWidth > this.maxWidth) { this.maxWidth = itemWidth; } //文字列の最大幅が決定
        }

        this.padding = 20; //ウィンドウ枠の端から文字描画までどれくらいの間隔を空けるか?
        this.preCanvas.width = this.maxWidth + this.padding*2;
        this.preCanvas.height = this.height + this.padding*2; //メッセージ欄で使うcanvas高さの合計

        this.preRenderingWindow();
        this.preRenderingSelectItems();

        //求めたcanvas幅と高さから、選択肢ウィンドウが画面中央に来る絶対座標をチェック(左上の座標を求める)
        this.x = (screenCanvasWidth - this.preCanvas.width)*0.5; 
        this.y = (screenCanvasHeight - this.preCanvas.height)*0.5;
        this.margin = 2;

        this.hitAreaArray = [];
        for (let i=0; i< this.length; i++) { //選択肢1つずつにhitAreaを設定、クリックやタッチで選択肢を選べるように
            const left = this.x + this.padding;
            const top = this.y + this.padding + (this.lineHeight*i); //段落数分、y座標を調整
            const right = left + this.maxWidth;
            const bottom = top + this.lineHeight;
            const hitArea = new aabbRect(left, top, right, bottom);
            this.hitAreaArray.push(hitArea);
        }

        this.selectIndex = null; //現在選択されてる配列のインデックス値を指定

        this.addEventListener('selectEnd', (e) => {
            this.selectItems[e.target].callback(); //対応するコールバック関数を起動して、選択肢を終了
            this.close(); this.playerIsActive();
        });
    }

    preRenderingWindow() { //ウィンドウをプリ描画する
        this.preContext.beginPath();
        this.preContext.strokeColor('rgb(255,255,255)');
        this.preContext.fillColor('rgba(0,0,0,0,5)');
        this.preContext.rect(0, 0, this.preCanvas.width, this.preCanvas.height);
        this.preContext.fill();
        this.preContext.stroke();
    }
    preRenderingSelectItems() { //選択肢をプリ描画する
        this.preContext.fillColor('rgb(255,255,255)');
        this.preContext.font(`${this.fontSize}px ${this.fontStyle}`);
        for (let i=0; i< this.length; i++) {
            const x = this.padding;
            const y = this.padding + this.fontSize + (this.lineHeight*i);
            const text = this.selectItems[i].str;

            this.preContext.fillText(text, x, y);
        }
    }

    hitTest(touch, rect) { //矩形範囲とタッチ座標との当たり判定
        return (rect.left < touch.x && touch.x < rect.right) && (rect.top < touch.y && touch.y < rect.bottom);
    }
    hitTestIndex(touch) { //配列それぞれのhitAreaに対して当たり判定を行い、hitしたら配列のIndex値を返す
        for (let i=0; i< this.length; i++) {
            if(this.hitTest(touch, this.hitAreaArray[i])) {return i;}
        } return null;
    }

    update(sceneInfo, input, touch) {
        this.playerIsStay(); //選択肢のある間は、プレイヤーを操作できない
        if( this.selectIndex === null ) { //未だ選択準備中のとき
            if(input.getKeyDown(' ') || input.getKeyDown('ArrowUp') || input.getKeyDown('ArrowDown')) { this.selectIndex = 0; return; }
        }
        const i = this.hitTestIndex(touch);
        if(i !== null) {
            this.selectIndex = i;
            if(touch.touchUp) {this.dispatchEvent('selectEnd', new Event(i)); return;}
        }
        if(input.getKeyDown('ArrowDown')) { this.selectIndex = Math.min(this.selectIndex+1, this.length-1)} //↓キーで、選択中のインデックス値を1増やす。上限は選択肢の数 -1(配列が0番目から始まるので)
        if(input.getKeyDown('ArrowUp')) { this.selectIndex = Math.max(this.selectIndex-1, 0)} //↑キーが押された時、選択中のインデックス値を1減らす。下限は0
        if(input.getKeyDown(' ') && this.selectIndex !== null) {this.dispatchEvent('selectEnd', new Event(this.selectIndex)); return;}
    }

    renderFlash(context, i) { //選択されてる箇所を強調させる
        if(i === null) {return;} //インデックス値が無指定なら何もしない
        context.beginPath();
        context.fillColor('rgba(200, 200, 40, 0.5)');
        context.rect(this.hitAreaArray[i].left, this.hitAreaArray[i].top, this.maxWidth, this.lineHeight);
        context.fill();
    }

    render(context) { 
        //スクリーン画面にプリ描画データ(選択肢ウィンドウ全て)を描画
        context.drawImage(this.preCanvas, 0, 0, this.preCanvas.width, this.preCanvas.height, this.x, this.y);
        this.renderFlash(context, this.selectIndex); //選択されてる箇所を強調させる
    }
}


細かい文字列の修正は割愛して、コールバック関数の起動に関してだけ。
    update(sceneInfo, input, touch) {
        this.playerIsStay(); //選択肢のある間は、プレイヤーを操作できない
        if( this.selectIndex === null ) { //未だ選択準備中のとき
            if(input.getKeyDown(' ') || input.getKeyDown('ArrowUp') || input.getKeyDown('ArrowDown')) { this.selectIndex = 0; return; }
        }
        const i = this.hitTestIndex(touch);
        if(i !== null) {
            this.selectIndex = i;
            if(touch.touchUp) {this.dispatchEvent('selectEnd', new Event(i)); return;}
        }
        if(input.getKeyDown('ArrowDown')) { this.selectIndex = Math.min(this.selectIndex+1, this.length-1)} //↓キーで、選択中のインデックス値を1増やす。上限は選択肢の数 -1(配列が0番目から始まるので)
        if(input.getKeyDown('ArrowUp')) { this.selectIndex = Math.max(this.selectIndex-1, 0)} //↑キーが押された時、選択中のインデックス値を1減らす。下限は0
        if(input.getKeyDown(' ') && this.selectIndex !== null) {this.dispatchEvent('selectEnd', new Event(this.selectIndex)); return;}
    }


タッチの場合。タッチを放した時に、選択肢と触れていれば対応する配列のイベントが発火するように!
        const i = this.hitTestIndex(touch);
        if(i !== null) {
            this.selectIndex = i;
            if(touch.touchUp) {this.dispatchEvent('selectEnd', new Event(i)); return;}
        }


キーの場合で別のパターン。決定キーを押した時に、インデックス番号が指定されてるなら、その番号でイベントが発火。
        if(input.getKeyDown('ArrowDown')) { this.selectIndex = Math.min(this.selectIndex+1, this.length-1)} //↓キーで、選択中のインデックス値を1増やす。上限は選択肢の数 -1(配列が0番目から始まるので)
        if(input.getKeyDown('ArrowUp')) { this.selectIndex = Math.max(this.selectIndex-1, 0)} //↑キーが押された時、選択中のインデックス値を1減らす。下限は0
        if(input.getKeyDown(' ') && this.selectIndex !== null) {this.dispatchEvent('selectEnd', new Event(this.selectIndex)); return;}


そして選択されたときのイベント内容はこちらです。
        this.addEventListener('selectEnd', (e) => {
            this.selectItems[e.target].callback(); //対応するコールバック関数を起動して、選択肢を終了
            this.close(); this.playerIsActive();
        });
e.targetで選ばれた配列のインデックス番号を受け取って、対応する番号のコールバック関数を実行しています。そして選択肢を閉じる流れ。


選択肢の実行デモ
⇒ 選択肢の実行デモ(会話終了後に表示)



選択肢、綺麗に決まりましたね。
後は、細かい調整とか、メッセージウィンドウとの連携をどうするかですね。
選択肢表示までの微妙なラグも、プレイヤーの動く隙間ができて問題ですね〜。

ウィンドウを閉じる時、playerがアクティブになるのを1フレーム遅らせる

メッセージウィンドウからセレクトウィンドウに移る際、1フレームだけアクティブの隙間ができてました。 これはウィンドウを閉じる瞬間にアクティブになり、セレクトウィンドウが開けてからアップデートの開始(プレイヤーの動きを止める)までのラグがあるからでした。 ということで、1フレーム分の隙間を埋めておきたいと思います。 Scene側で、このようにplayerIsActive()の挙動を修正しました。

Sceneクラスの修正

    open(windowUI) {//(メッセージ)ウィンドウを保持する(追加・削除)
        this.windowsUI.push(windowUI);
        windowUI.addEventListener('open', (e) => this.open(e.target));//openWindowイベントが発生した場合はシーンにWindowを追加
        windowUI.addEventListener('close', (e) => this._closedWindowsUI.push(e.target));//closeイベントはそのWindowを降板リストに追加
        windowUI.addEventListener('playerIsStay', () => this.infomation.playerIsActive = false);//プレイヤーの操作を受け付けないようにする
        windowUI.addEventListener('playerIsActive', () => this.infomation.playerIsActive = null);//プレイヤーの操作を再度可能にする(null指定で、アクターのアップデート後にtrueへ切り替え
    }
まず、指定をnullにします。nullの時点では未だfalse判定です。


    update(context, input, touch, gameInfo) {//updateメソッドではシーンに登録されているActor全てを更新し、当たり判定を行い、描画します。描画の前に一度画面全体をクリアするのを忘れないで下さい。
        this._updateInfo(gameInfo, input, touch);//アップデートに追加情報を挿入
      if(this.actors[0]) { //アクターが存在する場合
        this._updateActors(this.infomation, input, touch);//Actorの動きを更新する
        this._qTree.clear();
        this.actors.forEach((a) => this._qTree.addActor(a)); //hittest四分木空間に各アクターを振り分け
        this._hitTest();//当たり判定を処理する
        this._disposeReleasedActors();//役目を終えたActorをシーンから降ろす
      }

      if(this.infomation.playerIsActive === null) { this.infomation.playerIsActive = true; } //メッセージウィンドウ終了時にプレイヤー操作可能にするタイミングは此処

      if(this.windowsUI[0]) { //ウィンドウが存在する場合
        this.windowsUI.forEach((windowUI) => windowUI.update(this.infomation, input, touch));
        this._disposeClosedWindowsUI();//役目を終えたウィンドウを閉じる
      }
        this._clearRendering(context); //画面の描画をリセット
      if(this.actors[0]) {
        this.actors.forEach(//各アウターのrender()メソッドを呼び出して、描画します
            (obj) => obj.render(context, this.scroller)
        );
      }
      if(this.windowsUI[0]) {
     this.windowsUI.forEach(//各ウィンドウUIのrender()メソッドを呼び出して、描画します。
            (obj) => obj.render(context)
        );
      }
    }
そしてupdate()のアクターアップデート終了後に、this.infomation.playerIsActiveのnullをtrueに変える関数を差し込みます。 すると、ウィンドウ終了後から次のウィンドウ更新までの間がnull指定で保たれるので、プレイヤー動作可能の隙間を埋めることが可能でした。

判定trueになるのに、1フレーム分のラグを設ける形です。
これで、前回のデモでも上手くウィンドウが繋がるようになりました。

MessageWindowからセレクトウィンドウを操作する

方法その2。今度は、メッセージトークの中に選択肢クラスも埋め込んでいくやり方です。 これは名前とか立ち絵とかと同じように、会話内容に選択肢クラスを渡して表示、そしてメッセージウィンドウ側でそれぞれのアップデートや描画を操作していく形ですね。

MessageWindowに選択肢が渡された場合、選択肢モードに切り替える

    get currentMessage() { return this._currentMessage; }
    set currentMessage(message) { //会話フェーズの移行と、各要素の初期化
        if(message === undefined) { // もう空っぽだったらtextEndイベントを発生させる
            this.dispatchEvent('messageEnd'); return;// textEndイベントを発行
        }
        //選択肢を渡された場合、いったん会話を中断して選択モードとなる。出現中は選択肢クラス側を操作する
        if(message instanceof SelectWindow) { this.addSelectWindow(message); return;}

        //↓以降は、現在の会話内容を更新
        this._currentMessage = message;
//~~~続く
会話フェーズに選択肢が渡された場合の挙動を描く
     addSelectWindow(selectWindow) { //会話中に選択肢ウィンドウを追加するメソッド
        selectWindow.addEventListener('close', () => {
            this.selectWindow = null; //選択肢ウィンドウが閉じる時、選択肢があるよ!チェックをnullに
            this.currentMessage = this.messages.shift(); //選択したら会話を次に進める
        }); //選択肢のcloseイベントで、選択モードを解除するのを予約
        this.selectWindow = selectWindow; //会話シーンに選択肢を追加する
    }
これが、選択肢ウィンドウが渡された時の動きの中身。
メッセージウィンドウクラス内にthis.selectWindowでセレクトモードかどうかを判定する要素を用意。 もし選択肢が渡されたら、ここに選択肢クラスを代入します。その際に、選択肢のclose()でモード解除できるように。そして次の会話に進めるようにします。


    update(sceneInfo, input, touch) { //毎フレーム呼び出されるupdateメソッド。このクラスのプログラム動作の本体
        this.talkPict.forEach((image) => image.update(sceneInfo, input, touch)); //立ち絵の状態を更新する
        if(this.selectWindow) { this.selectWindow.update(sceneInfo, input, touch); } //選択肢ウィンドウが存在するなら、選択肢の状態を更新
        this.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める

        if(this.text.compleate && !this.selectWindow) { //現在の会話の文字数すべて表示、かつ選択肢が存在しないなら
            if(!this.selectMode && (input.getKey(' ') || touch.touch)) { //会話選択肢がないなら、スペースキーorタッチを押した時に会話を更新
                if(this._currentMessage.text instanceof Array) {this.text = this._currentMessage.text.shift();} // new Talk()のテキストに配列が代入されてるケース。次のテキストへ移行するメソッド
                else { this.currentMessage = this.messages.shift(); } //こちら文字列のみが渡されていた場合の移行措置
            }
        }
        if(!this.inited){ //会話フェーズが切り替わるならウィンドウ枠全体の初期化
            return this.updateInit(sceneInfo, input, touch);
        }
        else if( this._frameCount <= 0 ) { //カウントが0なら、テキストを更新
            return this.text.update(sceneInfo, input, touch); 
        }
        else {this._frameCount--;}
    }
そして選択肢クラスが代入されてるときの、アップデート内容がこちら。

        if(this.selectWindow) { this.selectWindow.update(sceneInfo, input, touch); } //選択肢ウィンドウが存在するなら、選択肢の状態を更新
アップデートの最初で、選択肢クラスのアップデート関数を実行。

        if(this.text.compleate && !this.selectWindow) { //現在の会話の文字数すべて表示、かつ選択肢が存在しないなら
            if(!this.selectMode && (input.getKey(' ') || touch.touch)) { //会話選択肢がないなら、スペースキーorタッチを押した時に会話を更新
                if(this._currentMessage.text instanceof Array) {this.text = this._currentMessage.text.shift();} // new Talk()のテキストに配列が代入されてるケース。次のテキストへ移行するメソッド
                else { this.currentMessage = this.messages.shift(); } //こちら文字列のみが渡されていた場合の移行措置
            }
        }
もし、選択肢ウィンドウが存在する場合は、次の会話フェーズに進めないようにもしておきます。


    render(context) { //前もってpreCanvasに描画しておいたのを、実際のゲーム画面に貼り付ける
        context.drawImage(this.preCanvas, 0, 0, this.preCanvas.width, this.preCanvas.height, 0, this.windowY);
        this.talkPict.forEach(//その後、各立ち絵をスクリーンに描画します。
            (image) => image.render(context) //メッセージウィンドウ側から描画すると、立ち絵の位置がウィンドウの手前か奥か調整が効く
        );
        if(this.selectWindow) { this.selectWindow.render(context); }//選択肢ウィンドウが存在するなら、選択肢も描画
    }
最後に、SelectWindowが存在する場合は、選択肢の描画メソッドまで呼び出す形で。
これで、メッセージウィンドウの配列内にSelectWindowクラスも渡して大丈夫になりました。テストしてみます。

選択肢ウィンドウを入れ込んで見るテスト

NPCに、以下の会話内容をもたせます。会話の描き方、こんな感じでどうでしょう?
class NPC_girl1 extends NPC {
    constructor(x, y, name) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea, ['npc']);
        this.name = name;
        this.flag = null; //選択肢のフラグ
        this.illust = [];
        this.illust[0]=new Sprite(assets.get('up_npc_girl0'), 0, 0, 200, 400); //0
        this.illust[1]=new Sprite(assets.get('up_npc_girl1'), 0, 0, 200, 400); //1
    }
    talkStart(talkNumber) { //会話を表示する関数、NPCの拡張クラスではこの関数を上書きして各々の会話内容を設定します。
        let messages=[''];
        let callback=()=>{};
        if(this.flag === null) { //フラグによって会話を切り替えることもできる
          switch (this.talkNumber) { //番号によって、会話を別の内容で用意できる記述です
            default : messages = [ //ここに会話イベントを登録しておく
              new Talk(this.name, ['こんにちは。あのー、せんたくもんだいです。', 'クイズ出すから答えてね。'], new TalkPict(this.illust[0], {enter:true}) ),
              new Talk(this.name, '火で燃やすと何になる?#.#.', this.illust[1] ),
              new SelectWindow([ //ここから選択肢を表示
                  new Select('はい', () => {
                      messages.unshift('はいが選ばれました。'); this.flag = true;
                  }),
                  new Select('いいえ', () => {
                      messages.unshift('いいえが選ばれました。'); this.flag = false;
                  })
              ]),
              new Talk(this.name, ['さ、果たして正解は?!#.#.#nもういちど話しかけると答え合わせだよ。'], new TalkPict(this.illust[0], {leave:true}) )
            ];
            callback = () => {this.talkNumber++;}
            break;
          }
        }
        else if(this.flag === true) {messages = [new Talk(this.name, 'あなたは正解をえらびました。はい。', new TalkPict(this.illust[0], {leave:true}) ) ]; this.flag=null;}
        else if(this.flag === false) {messages = ['いいえ残念、あなたは不正解です。']; this.flag=null;}
        this.messageOpen(messages, callback);
    }
}
選択肢の部分で一旦メッセージの流れをストップ、選択したら選択肢に応じたリストの先頭に.unshift()で'メッセージ'を割り込み追加。
後は共通するメッセージの残りを表示です。

選んだ選択肢によって、その後のフラグも書き換えてる所が肝かな。フラグの有無で、次の会話内容が切り替わるようにもしてます。 こういったフラグは、アクター自身に持たせるも良いし、シーン間で共有させてもいいし、グローバルで管理するのも有りだと思う。
セーブ&ロードの兼ね合いで、どこに保存するのがいいか自分なりに設計ですね。


さて、完成形のデモを見てみましょう。

選択肢の実行デモ2
⇒ 選択肢の実行デモ



選択後の会話内容が、後に続く形で描けるので、これはこれで望ましい形ではないかと思います。
これにて、メッセージ機能のほぼ全てが実装できました。ぱちぱち♪ヽ(。◕ v ◕。)ノ~*:・'゚☆

メッセージ機能だけで1ヶ月半か...
ちと休憩を入れよう。


ボイス機能は、次項の音声再生からじっくり取り組みます。


【目次】
  1. JavaScriptでゲーム作り「1:基礎編」
  2. JavaScriptでゲーム作り「2:プレイヤーの歩かせ方」
  3. JavaScriptでゲーム作り「3:プレイヤーの向きに応じてアクションを起こす」
  4. JavaScriptでゲーム作り「4:NPCと会話する」
  5. JavaScriptでゲーム作り「5:当たり判定を最適化する」
  6. JavaScriptでゲーム作り「6:線分の当たり判定を実装する」
  7. JavaScriptでゲーム作り「7:フィールドの背景スクロール」
  8. JavaScriptでゲーム作り「8:プログラムの最適化、高速化、コード整理」
  9. JavaScriptでゲーム作り「9:タッチ・マウスイベント入力」
  10. JavaScriptでゲーム作り「10:法線ベクトルと衝突時のバウンス判定」
  11. JavaScriptでゲーム作り「11:多角形を使った魔法陣を実装する」
  12. JavaScriptでゲーム作り「12:キャッシュの扱い方と計算処理の高速化」
  13. JavaScriptでゲーム作り「13:メッセージウィンドウを実装する」
  14. JavaScriptでゲーム作り「14:イベントの仕組みを理解する」
  15. JavaScriptでゲーム作り「15:メッセージテキスト表示の機能追加」
  16. JavaScriptでゲーム作り「16:会話時の立ち絵&名前表示」


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

すぺしゃるさんくす

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


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