18:会話の選択肢とイベント分岐

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

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

(2019.7.8 ~ 7.14執筆、2021.11.11更新)

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

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

  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 = [ //ここに会話イベントを登録しておく
              {name:this.name, text:['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'],
              img:new TalkPict(this.illust[0], {enter:true}) },
              {name:this.name, text:['ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…'], img:new TalkPict(this.illust[1], {leave:true}) },
              {text:['#<#<#<#<#<※これは#=会話テスト!#.#.']}
            ];
            callback = () => { console.log("会話が終了しました"); this.open(new MessageWindow([{text:['会話が終了しました。']}]))}
            break;
        }
        this.open(new MessageWindow(messages, callback));
        this.talkNumber++;
    }
}
どうでしょう...
new MessageWindow()の中にmessagesに加え、対応するcallback関数を代入しています。

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

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

とか、ぜったいミスを誘発する。{(([{[]}]))}..??? 入れ子状態が訳わからん。
これ、実際ゲーム作る時に制作意欲が削がれる、会話のたびに延々と、こんな書き方をしなくちゃいけないとか。ダメ過ぎる。。。

後の制作のことを考え、できる限りシンプルに記述できるようにしたい! 真っ先に!


なので、メッセージウィンドウ側にて['テキスト', 'テキスト', 'テキスト']の従来の形でもメッセージ内容を受け取れるように、加えて{name:"名前",text:"テキスト"}の会話オブジェクト形式と織り交ぜた表記でも動くように改良を加えてみます。

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


class MessageWindow extends WindowUI {//メッセージウィンドウの管理と会話送り
    constructor(messages, callback) {
        super();
        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; //ウィンドウ枠の幅をどれくらいとるか
        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.talkPict = []; //現在の立ち絵一覧を参照する配列

        this.messages = messages instanceof Array ? messages : [messages]; //基本はメッセージオブジェクトが格納された配列を受け取る...、単発の場合は単発メッセージを配列に代入
        this.currentMessage = this.messages.shift(); //今表示するべきメッセージオブジェクト、全メッセージリストの先頭を取り出した値
        //this._text = 現在表示するテキストクラスを保持する場所

        this.selectWindow = null; //会話選択肢がある場合、更新をストップするため

        this.addEventListener('messageEnd', () => { //会話終了時にウィンドウを破棄&プレイヤー操作再開
            this.close(); this.playerIsActive();
            if(this.talkPict[0]) { this.talkPict[0].fadeOut(); this.open(this.talkPict[0]); } //立ち絵は終了時にフェードアウト、イラスト操作はシーンに引き継ぐ
        });
        if(callback !== undefined) { this.addEventListener('messageEnd', callback, {once:true}); } //会話終了時に登録されたコールバック関数を1回だけ起動
    }

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

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

            //messages=['テキスト','テキスト','テキスト','テキスト']という形でもメッセージを渡せるように設定。
            //もちろんmessages=[]...配列要素の中に{text:テキスト}オブジェクトと単なるテキストを織り交ぜてもOK
            if( typeof (message) == "string" || message instanceof String ) { this.text = message; this.inited = false; return; } //渡された要素が単なる文字列の場合、そのままテキストに代入する
        } 
        else { //立ち絵がある場合
            const image = new TalkPict(message.img, {
                enter:message.enter,
                leave:message.leave,
                duration:message.duration,
                easing:message.easing
            }); //渡されたメッセージオブジェクトから立ち絵クラスを生成する

            this.textMaxWidth = screenCanvasWidth - message.img.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/image.duration); //入れ替わるフェード時間のタイムラグを確保
                } 
            }
            else { this._frameCount += Math.abs(1/image.duration); } //キャラクター登場時にフェード時間分だけメッセージ表示を遅らせる
            this.addImage(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
    }
    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(); //テキスト欄のみ初期化
    }
変更した部分はここらです。
        this.messages = messages instanceof Array ? messages : [messages]; //全てのメッセージオブジェクトが格納された配列...、単発の場合は単発メッセージを配列に代入
基本はメッセージ要素を配列で受け取るが、受け取ったメッセージが単発の場合でも動くように、配列に変換するよう記述した
messages=[]; だけでなく、messages='テキスト'やmessages={name:"名前", text:"テキスト", illust:sprite};などのオブジェクト単体でも動くように。

            //messages=['テキスト','テキスト','テキスト','テキスト']という形でもメッセージを渡せるように設定。
            //もちろんmessages=[]...配列要素の中に{name:名前,text:"テキスト",illust:sprite}オブジェクトと単なるテキストを織り交ぜても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();} //textに配列が代入されてる場合。次のテキストへ進むメソッド
                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();} //textに配列が代入されてる場合。次のテキストへ進むメソッド
                else { this.currentMessage = this.messages.shift(); } //こちら文字列のみが渡されていた場合は、メッセージオブジェクトごと次に進む
            }
会話内容を移行するときも、現在の受け取ってるデータ型をみて、それに合うような動きをさせてます。
これで['テキスト','テキスト','テキスト']のような従来の描き方もできるようになり、不必要に会話オブジェクトを重ねなくて良くなりました。

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


    get currentMessage() { return this._currentMessage; }
    set currentMessage(message) { //会話フェーズの移行と、各要素の初期化

〜〜

    }

set currentMessage(message)の中の、この部分ですね
        else { //立ち絵がある場合
            const image = new TalkPict(message.img, {
                enter:message.enter,
                leave:message.leave,
                duration:message.duration,
                easing:message.easing
            }); //渡されたメッセージオブジェクトから立ち絵クラスを生成する

            this.textMaxWidth = screenCanvasWidth - message.img.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/image.duration); //入れ替わるフェード時間のタイムラグを確保
                } 
            }
            else { this._frameCount += Math.abs(1/image.duration); } //キャラクター登場時にフェード時間分だけメッセージ表示を遅らせる
            this.addImage(image); //会話に現在の立ち絵を追加する
        }
メッセージオブジェクトのまま、image要素やその他のオプションをベタ書きで渡せるように修正。この方が楽。 ↓これで会話編集時の、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 = [ //ここに会話イベントを登録しておく
{name:this.name, text:['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'],
 img:this.illust[0], enter:true},
{name:this.name, text:'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…',
 img:this.illust[1], leave:true},
'選択肢ウィンドウを表示します'
            ];
            callback = () => { console.log("会話が終了しました"); this.open(new MessageWindow('会話が終了しました。')) }
            break;
        }
        this.open(new MessageWindow(messages, callback));
        this.talkNumber++;
    }
}
前のと比べて、余分な[]とか、{}とかが無くなってます。その分、表記を簡潔にできました。
挙動が複雑になるほど、できるだけ機能を絞っていける対応も必要ですね。製作時の主となる表記(会話など)が、シンプルに書けることを優先です。

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 = [ //ここに会話イベントを登録しておく
{name:this.name, text:['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'],
 img:this.illust[0], enter:true},
{name:this.name, text:'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…',
 img:this.illust[1], leave:true},
'選択肢ウィンドウを表示します'
            ];
            callback = () => { console.log("会話が終了しました"); this.messageOpen('会話が終了しました。'); }
            break;
        }
        this.messageOpen(messages, callback);
        this.talkNumber++;
    }

    //↓のメソッドは継承前のNPCクラスに記述して良い
    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 = [ //ここに会話イベントを登録しておく
              {name:this.name, text:['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'], 
              img:this.illust[0], enter:true},
              {name:this.name, text:'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…', img: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 = this.selectIndex+1; //↓キーで、選択中のインデックス値を1増やす。
            if(this.selectIndex > this.length-1) { this.selectIndex = 0;} //上限を超えたら最初の0を選択
        }
        if(input.getKeyDown('ArrowUp')) {
            this.selectIndex = this.selectIndex-1; //↑キーが押された時、選択中のインデックス値を1減らす。
            if(this.selectIndex < 0) { this.selectIndex = this.length-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 = this.selectIndex+1; //↓キーで、選択中のインデックス値を1増やす。
            if(this.selectIndex > this.length-1) { this.selectIndex = 0;} //上限を超えたら最初の0を選択
        }
        if(input.getKeyDown('ArrowUp')) {
            this.selectIndex = this.selectIndex-1; //↑キーが押された時、選択中のインデックス値を1減らす。
            if(this.selectIndex < 0) { this.selectIndex = this.length-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;
    }
}
//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 = [ //ここに会話イベントを登録しておく
              {name:this.name, text:['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'], 
              img: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) {
        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 = this.selectIndex+1; //↓キーで、選択中のインデックス値を1増やす。
            if(this.selectIndex > this.length-1) { this.selectIndex = 0;} //上限を超えたら最初の0を選択
        }
        if(input.getKeyDown('ArrowUp')) {
            this.selectIndex = this.selectIndex-1; //↑キーが押された時、選択中のインデックス値を1減らす。
            if(this.selectIndex < 0) { this.selectIndex = this.length-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 = this.selectIndex+1; //↓キーで、選択中のインデックス値を1増やす。
            if(this.selectIndex > this.length-1) { this.selectIndex = 0;} //上限を超えたら最初の0を選択
        }
        if(input.getKeyDown('ArrowUp')) {
            this.selectIndex = this.selectIndex-1; //↑キーが押された時、選択中のインデックス値を1減らす。
            if(this.selectIndex < 0) { this.selectIndex = this.length-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 = this.selectIndex+1; //↓キーで、選択中のインデックス値を1増やす。
            if(this.selectIndex > this.length-1) { this.selectIndex = 0;} //上限を超えたら最初の0を選択
        }
        if(input.getKeyDown('ArrowUp')) {
            this.selectIndex = this.selectIndex-1; //↑キーが押された時、選択中のインデックス値を1減らす。
            if(this.selectIndex < 0) { this.selectIndex = this.length-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からセレクトウィンドウを操作する

今度は、メッセージトークの中に選択肢クラスも対応させていこうと思います。 これは名前とか立ち絵と同じように、会話内容で選択肢クラスを受け取ったら表示、このMessageWindowクラスから一括して、それぞれにアップデートや描画を指示できるように。

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 = [ //ここに会話イベントを登録しておく
              {name:this.name, text:['こんにちは。あのー、せんたくもんだいです。', 'クイズ出すから答えてね。'], img:this.illust[0], enter:true },
              {name:this.name, text:'火で燃やすと何になる?', img:this.illust[1] },
              {select:[ //ここから選択肢を表示
                  new Select('はい', () => {
                      messages.unshift('はいが選ばれました。'); this.flag = true;
                  }),
                  new Select('いいえ', () => {
                      messages.unshift('いいえが選ばれました。'); this.flag = false;
                  })
              ]},
              {name:this.name, text:'さ、果たして正解は?!#.#.#nもういちど話しかけると答え合わせだよ。', img:this.illust[0], leave:true },
            ];
            callback = () => {this.talkNumber++;}
            break;
          }
        }
        else if(this.flag === true) {messages = {name:this.name, text:'あなたは正解をえらびました。はい。', img:this.illust[0], leave:true}; this.flag=null;}
        else if(this.flag === false) {messages = 'いいえ残念、あなたは不正解です。'; this.flag=null;}
        this.messageOpen(messages, callback);
    }
}
選択肢の部分で一旦メッセージの流れをストップ、選択したら選択肢に応じたリストの先頭に.unshift()で'メッセージ'を割り込み追加。
後は共通するメッセージの残りを表示です。

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


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

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



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

後は細かいところですが、選択肢表示のフレーム数を少し遅らせて、誤操作を減らすと使いやすかったです。
最初の数フレームをスキップする記述を加えて、最終的なセレクトウィンドウのコードはこのようになりました。


class SelectWindow extends WindowUI {
    constructor(selectItems) {
        super();
        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}`);

        this._frameCount = 20; //誤操作の対策で、選択肢表示を数フレーム分遅らせる

        //渡された選択肢の最大幅をチェック、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(this._frameCount) { this._frameCount--; return; } //最初の数フレームは反応させないように。
            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 = this.selectIndex+1; //↓キーで、選択中のインデックス値を1増やす。
            if(this.selectIndex > this.length-1) { this.selectIndex = 0;} //上限を超えたら最初の0を選択
        }
        if(input.getKeyDown('ArrowUp')) {
            this.selectIndex = this.selectIndex-1; //↑キーが押された時、選択中のインデックス値を1減らす。
            if(this.selectIndex < 0) { this.selectIndex = this.length-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) { 
        //スクリーン画面にプリ描画データ(選択肢ウィンドウ全て)を描画
        if(this._frameCount) { return; } //最初の数フレームは描画をスキップ。
        context.drawImage(this.preCanvas, 0, 0, this.preCanvas.width, this.preCanvas.height, this.x, this.y);
        this.renderFlash(context, this.selectIndex); //選択されてる箇所を強調させる
    }
}


それから一応、メッセージウィンドウクラスの最終形も掲載しておきます。

今回のコラムの後、メッセージ内容にnew Selectwindow([])で選択肢を渡してたのを{select:[]}で渡せるように修正してみました。
短くなったので、本編の会話編集が少し楽になるかなと思います。


////////// //////////
//テキストやウィンドウ表示に関するWindowUIクラスの管理

//messagesWindowに渡すmessages=[]の中のメッセージオブジェクトの書式は
//{
//    name:'名前',
//    text:'会話内容',
//
//    img:立ち絵スプライト
//    enter:立ち絵がスライドで表示true or false
//    leave:立ち絵がスライドで非表示true or false
//    duration:立ち絵のフェードイン・フェードアウトのフレーム数
//    easing:立ち絵の表示アニメーションの種類
//
//    select:[new Select(), new Select()] 選択肢を表示する場合のみ、オブジェクトの中身をselect:の項目だけで記載
//}
//または'テキスト'のみでもOK

class MessageWindow extends WindowUI {//メッセージウィンドウの管理と会話送り
    constructor(messages, callback) {
        super();
        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; //ウィンドウ枠の幅をどれくらいとるか
        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.talkPict = []; //現在の立ち絵一覧を参照する配列

        this.messages = messages instanceof Array ? messages : [messages]; //基本はメッセージオブジェクトが格納された配列を受け取る...、単発の場合は単発メッセージを配列に代入
        this.currentMessage = this.messages.shift(); //今表示するべきメッセージオブジェクト、全メッセージリストの先頭を取り出した値
        //this._text = 現在表示するテキストクラスを保持する場所

        this.selectWindow = null; //会話選択肢がある場合、更新をストップするため

        this.addEventListener('messageEnd', () => { //会話終了時にウィンドウを破棄&プレイヤー操作再開
            this.close(); this.playerIsActive();
            if(this.talkPict[0]) { this.talkPict[0].fadeOut(); this.open(this.talkPict[0]); } //立ち絵は終了時にフェードアウト、イラスト操作はシーンに引き継ぐ
        });
        if(callback !== undefined) { this.addEventListener('messageEnd', callback, {once:true}); } //会話終了時に登録されたコールバック関数を1回だけ起動
    }

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

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

            //messages=['テキスト','テキスト','テキスト','テキスト']という形でもメッセージを渡せるように設定。
            //もちろんmessages=[]...配列要素の中に{text:テキスト}オブジェクトと単なるテキストを織り交ぜてもOK
            if( typeof (message) == 'string' || message instanceof String ) { this.text = message; this.inited = false; return; } //渡された要素が単なる文字列の場合、そのままテキストに代入する
        } 
        else { //立ち絵がある場合
            const image = new TalkPict(message.img, {
                enter:message.enter,
                leave:message.leave,
                duration:message.duration,
                easing:message.easing //アニメーションの推移パターン(指定されてないなら初期値)
            }); //渡されたメッセージオブジェクトから立ち絵クラスを生成する

            this.textMaxWidth = screenCanvasWidth - message.img.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(image.duration); //入れ替わるフェード時間のタイムラグを確保
                } 
            } else { this._frameCount += Math.abs(image.duration); } //キャラクター登場時にフェード時間分だけメッセージ表示を遅らせる
            this.addImage(image); //会話に現在の立ち絵を追加する
        }
        //this.talkPict[0] ...これは前回の立ち絵のデータを参照している

        this.text = this._currentMessage.text instanceof Array ?
            this._currentMessage.text.shift() : 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(); //テキスト欄のみ初期化
    }

    addImage(image) { //会話中に立ち絵を追加するメソッド
        image.addEventListener('close', (e) => { //追加前にcloseイベントを付与、その絵を会話シーンから削除するイベントを記述
            const index = this.talkPict.indexOf(e.target);
            if(index > -1) {this.talkPict.splice(index, 1);}
        });
        this.talkPict.push(image); //会話シーンに立ち絵を追加する
    }
    addSelectWindow(selectWindow) { //会話中に選択肢ウィンドウを追加するメソッド
        selectWindow.addEventListener('close', () => {
            this.selectWindow = null; //選択肢ウィンドウが閉じる時、選択肢があるよ!チェックをnullに
            this.currentMessage = this.messages.shift(); //選択したら会話を次に進める
        }); //closeイベントで、選択モードを終了する予約をした
        this.selectWindow = selectWindow; //会話シーンに選択肢を追加する
    }

    updateInit(sceneInfo, input, touch) { //ウィンドウ全体の初期化処理
        if(this.name !== this.currentMessage.name && (this.name || this.currentMessage.name)) { //前回と話し手が違う場合で、いずれかの名前が存在する場合
            this.clearPreRendering(); //ウィンドウ枠全体を初期化、一旦閉じる。
            this.name = this.currentMessage.name; //話し手の名前をキャッシュ(次回との比較用)
        }
        if(this._frameCount > 0) { //セリフを話すキャラが違う場合、メッセージ表示までのフレームカウントが存在する
            this._frameCount--; //表示待機中ならフレームカウントを消費
            if((input.getKey(' ') || touch.touch) && this._frameCount > 0) {this._frameCount--;} //スキップキーが押されてるなら速度2倍
            if(this._frameCount <= 0) {this.preRenderingBoard(); this.preRenderingName(); this.inited = true;} //カウントを消費して0以下になったらウィンドウ枠を再描画して初期化完了
        }
        else { //フレームカウントが存在しない場合
            this.inited = true; //初期化完了
        }
    }
    update(sceneInfo, input, touch) { //毎フレーム呼び出されるupdateメソッド。このクラスのプログラム動作の本体
        this.talkPict.forEach((image) => image.update(sceneInfo)); //立ち絵の状態を更新する
        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();} //textに配列が代入されてる場合。次のテキストへ進むメソッド
                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--;}
    }

    clearPreRendering() { //メッセージクラス全体のプリ描画領域をクリアする
        this.preContext.clear();  //名前表示&テキストスペースクリア
    }
    preRenderingBoard() { //最初にメッセージボードを描画する
        this.preContext.beginPath();
        this.preContext.lineWidth(3); //枠線は2の太さで
        this.preContext.fillColor('rgba(255,255,255,0.99)'); //塗りつぶす色は白';
        this.preContext.strokeColor('rgba(125,125,255,0.99)'); //枠は青っぽい
        this.preContext.rect(this.margin, this.margin + this.nameHeight, this.windowWidth, this.windowHeight);
        this.preContext.stroke();
        this.preContext.fill();
    }
    clearPreRenderingText() { //テキスト領域のみクリア
        const padding = this.margin*2;
        this.preContext.clearRect(padding, padding + this.nameHeight, this.windowWidth - padding, this.windowHeight - padding);
        this.preContext.beginPath();
        this.preContext.fillColor('rgba(255,255,255,0.99)'); //塗りつぶす色は白';
        this.preContext.rect(padding, padding + this.nameHeight, this.windowWidth - padding, this.windowHeight - padding);
        this.preContext.fill();
    }
    preRenderingName() { //会話の最初に名前を描画する関数
        if(!this.currentMessage.name || this.currentMessage.name === "") {return;} //名前がないなら表示しない
        this.preContext.font(`${this.size}px ${this.fontStyle}`);
        const nameWidth = this.preContext.measureText(this.currentMessage.name).width; //名前表示の文字列の長さを取得

        const width = nameWidth + this.paddingLeft*2;
        const height = this.nameHeight; //this.nameHeightは、constructor内にて30に設定
        this.preContext.beginPath();
        this.preContext.strokeColor('rgba(125,125,255,0.99)'); //枠は青っぽい
        this.preContext.rect(this.nameX, this.margin, width, height - this.margin);
        this.preContext.stroke();
        this.preContext.fillColor('rgba(255,255,255,1.0)'); //塗りつぶす色は白
        this.preContext.rect(this.nameX, this.margin, width, height + 1);//白枠の延長
        this.preContext.fill();

        this.preContext.fillColor("#063");
        const textX = this.nameX + this.paddingLeft; //テキスト描画位置のX座標 =「枠の左端から20」をカウント
        const textY = this.nameHeight - 5; // Y座標 =「枠の上端から5上にカウント
        this.preContext.fillText(this.currentMessage.name, ~~textX, ~~textY); //テキスト描画の関数本体
    }

    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); }//選択肢ウィンドウが存在するなら、選択肢も描画
    }
}


class TextManager { //ウィンドウクラスの子要素となる、テキスト描画に関するクラス(これはシーンに追加せず、親クラスからcontextを受け取って操作する)
    constructor(str, textMaxWidth, x, y, context, option={}) {
        this.str = str
        this.textMaxWidth = textMaxWidth; //テキストの最大幅を指定
        this.x = x; //テキスト描画開始位置のx座標
        this.y = y; //テキスト描画開始位置のy座標
        this.context = context; //受け取ったcanvasContextに描画する

        this.baseColor = "#555"; //テキスト色の初期値
        this.color = this.baseColor;
        this.baseSize = 16; //テキストサイズの初期値
        this.fontStyle = "sans-serif"
        this.lineHeight = this.baseSize * 1.5; //段落の高さ。フォントサイズに対する高さ比で算出

        this._frameCount = 0; //次の表示までの経過フレーム数。会話のテンポをとるのに使用。0より大きい値ならアップデート毎に-1し、0になるまでテキストを更新しない。

        //ここから各種表示要素の初期化
        this.textLength = this.str.length; //テキストに渡された文字数をキャッシュ
        this.textNumber = 0; //今表示するのは何番目の文字?
        this.line = 0; //現在のテキスト文字は、ウィンドウの何段目に表示する?
        this.nowTextWidth = 0; //現在表示されてるテキスト幅を測るのに使用
        this.heightPlus = 0; //テキスト表示位置の高さの微調整に使用
        this.heightPlusArray = null; //その段落の高さ調整分の値を配列に記録。初期値null
        this.size = this.baseSize; //フォントサイズも初期化
        this.rubySize = this.baseSize * 5 / 8;  //ふりがなのフォントサイズ
        this.rubyX = null; //ふりがなを宛てる漢字は何文字?というのを保持するための要素,,,ふりがな描画するモードかどうかの判定も兼ねる
        this.ruby = 0; //ふりがなのひらがなの文字数をカウント。
        this.compleate = null; //テキストが最後まで表示されたかどうかの判定
        this.messageSizeCheck(); //段落ごとの最大フォントサイズをチェック
    }

    messageSizeCheck() { //段落ごとの最大フォントサイズをチェック、これで未登録の場合のcanvas最大サイズを測って設定しても良いかもしれない
        const checkNum1 = this.str.indexOf("#<"); //フォントを大きくする特殊文字が含まれないなら-1が代入される
        if(checkNum1 === -1) {return;} // フォントを大きくする記述がないならそのままでOK
        else { /*#<文字列が含まれるなら最大フォントサイズチェック!*/
            let count = 0;
            let maxSize = this.baseSize; //初期値15
            let nowSize = this.baseSize; //初期値15
            let textWidth = 0;
            let heightPlus = 0;
            this.heightPlusArray = []; //この値を求めるための↓長ったらしい関数。その段落の高さ調整分をthis.heightPlus[this.line]で取り出せる形にするのに、こんな行数かかってしまうとは。。しかし表示を綺麗にするにはしゃーないのか。文字サイズ弄らなければ無駄に動くこともないし、いいか...(' '*)
            while(count < this.textLength) {
                const char = this.str.charAt(count);
                let char2;
                if(char === "#") { count++; //取り出した文字が#の場合、次の一文字を調べる。関数が登録されてる文字列の場合無視
                    char2 = this.str.charAt(count);
                    if(char2 === "n") {
                        heightPlus += maxSize - this.baseSize; // this.baseSize=15;
                        this.heightPlusArray.push(heightPlus); checkWidth = 0; maxSize = nowSize;
                    }
                    else if(char2 === "<") { nowSize += 3; if(nowSize > maxSize) {maxSize=nowSize;} }
                    else if(char2 === ">") { nowSize -= 3; }
                }
                if(char !== "#" || char2 === "#") { //ここで取り出した1文字が#でない場合と、##で続いた場合の#を拾える
                    this.context.font(`${nowSize}px ${this.fontStyle}`);
                    const charWidth = this.context.measureText(char).width;
                    const nextCheckWidth = textWidth + charWidth;
                    if( nextCheckWidth > this.textMaxWidth ) {  //表示するテキスト幅がtextMaxWidthを超えるとき、判定1行終わり、高さ調整の判定値を配列に追加。
                        heightPlus += maxSize - this.baseSize; // this.baseSize=16;
                        this.heightPlusArray.push(heightPlus); textWidth = 0; maxSize = nowSize;
                    }
                    textWidth += charWidth;
                }
                count++;
            }
            heightPlus += maxSize - this.baseSize; // this.baseSize=15;
            this.heightPlusArray.push(heightPlus);  //全ての文字を表示した場合の、最後の段落判定
        }
    }

    updateText() { //表示テキストを1文字ずつ追加するメソッド
        if(this.textLength < this.textNumber ) {//全てのテキストが表示されてたら、フラグを立てて終了
            if(this._frameCount === 0) { this.compleate = true; }
            return;
        }
        if(!this.rubyX) { //ふりがな描画モードでないなら、通常の文字の処理
          const char = this.str.charAt(this.textNumber); //このアップデートで表示するべき1文字を取り出す
          const frameSkip20 = ["。", "…"]; //この後に表示間隔を20フレーム開ける文字列を定義(1/3秒)
          const frameSkip10 = ["、"]; //この後に表示間隔を10フレーム開ける文字列を定義(1/6秒)
          if( frameSkip20.indexOf(char) > -1 ) {this._frameCount = 20;} //20フレーム(1/3秒)開ける
          else if( frameSkip10.indexOf(char) > -1 ) {this._frameCount = 10;} //(1/6秒)

          if(char === "#") { this.textNumber++; //取り出した文字が#の場合、次の一文字を調べる。#の直後に特定の文字列だった場合に、対応する関数を記述
            const char2 = this.str.charAt(this.textNumber);
            if(char2 === "n") { this.line++; this.nowTextWidth = 0; } //"#n"で、自動的に改行する。段落を変更したら、これまでのテキスト幅は0に。
            else if(char2 === "<") { this.size += 3; } /* "#<"でフォントサイズを3大きくする*/
            else if(char2 === ">") { this.size -= 3; } // "#>"でフォントサイズを3小さくする
            else if(char2 === "=") { this.size = 15; } // "#="でフォントサイズを元の大きさにする
            else if(char2 === "^") { this.heightPlus -= 3; } // "#^"でフォント位置を上に3ずらす
            else if(char2 === "v") { this.heightPlus += 3; } // "#v"でフォント位置を下に3ずらす
            else if(char2 === "r") { this.color = this.color !== "#c64" ? "#c64" : this.baseColor;} //"#r"でフォント色を赤に、赤なら元の色に
            else if(char2 === ".") { this._frameCount = 10; } //"#."で次の文字の表示ラグを10フレーム(1/6秒)開ける
            else if(char2 === "[") { this.textNumber++; //ふりがなを宛てる開始位置(ふりがなモードON) 例:振仮名#[3,ふりがな]という記述を想定している
                const char3 = this.str.charAt(this.textNumber); //[の次は、ふりがな宛て先の「漢字文字数」が指定されている。この要素は数字。!isNaN(char)
                this.rubyX = char3; this.textNumber++; //以後、今回のふりがなモードではthis.rubyXが漢字(宛先)の文字数を表す
            }
            else if(char2 === "#") {this.preRenderingText(char2, context);} //##で続く場合、2文字目の#を普通に描画する
          }
          else {this.preRenderingText(char);} //#でないなら、取り出した1文字を普通に描画する
          this.textNumber++; //描画処理が終わったら、表示テキスト番号を次のカウントに振る
       }
       else if(this.rubyX) {//ふりがな描画モードなら、ふりがな専用の処理を適用
       let rubyStr = ""; //rubyStrという要素に、先の"ふりがな"文字列を格納する
         for(let count = 0; count < 31; count++) { //さすがに31文字以上の「ふりがな」を使うことは無いだろう。"]"で処理を終了するし
           const char = this.str.charAt(this.textNumber);
           this.textNumber++;
           if(char === "]") {break;} //"]"でふりがなモードを終了。それ以外ならふりがな一文字を追加して次のふりがなへ
           rubyStr += char; this.ruby++;
           } 
         this.preRenderingRuby(rubyStr); //ふりがなを描画するメソッド  
         this.rubyX = false; this.ruby = 0; //描き終わった後の、ふりがなモードを終了(リセット)するための記述
       }
    }
    preRenderingText(char) { //ここからテキスト描画メソッド
        this.context.font(`${this.size}px ${this.fontStyle}`);
        this.context.fillColor(`${this.color}`);
        const nextTextWidth = this.nowTextWidth + this.context.measureText(char).width; //テキスト幅を更新した後の値を先読み
        if( nextTextWidth > this.textMaxWidth ) {  //表示するテキスト幅がtextMaxWidthを超えるとき、自動的に改行する。
            this.line++; this.nowTextWidth = 0; //段落を変更したら、これまでのテキスト幅は0に。
        }
        if(this.nowTextWidth === 0 && this.heightPlusArray) { this.heightPlus += this.heightPlusArray[this.line]; } //段落最初に判定、この行の最大文字サイズで、表示位置の高さを調整
        //1文字毎にテキストを描画
        const textX = this.x + this.nowTextWidth; //テキスト描画位置のX座標 = 元の基準値に、これまでのテキスト幅をカウント
        const textY = this.y + this.lineHeight * this.line + this.heightPlus; // Y座標描画位置 = 元の基準値に、現在の段落*段落分の高さをカウント
        this.context.fillText(char, textX, textY); //テキスト描画の関数本体
        this.nowTextWidth += this.context.measureText(char).width; //表示テキスト幅に現在の文字の幅分を追加
    }
    preRenderingRuby(char) { //ここからテキスト描画メソッド
        this.context.font(`${this.rubySize}px ${this.fontStyle}`);
        this.context.fillColor(`${this.color}`);

        const rubyHiraganaWidth = this.rubySize * this.ruby; //ふりがなの描画の幅(描画位置の調整に必要な値)
        const rubyTextWidth = this.rubyX * this.size; //ふりがな宛先のテキスト幅(描画位置の調整に必要な値2)
        const rubyRelativeX = (rubyTextWidth - rubyHiraganaWidth) * 0.5 - rubyTextWidth; //ルビ描画位置のX軸方向の調整値を計算
        const textX = this.x + this.nowTextWidth + rubyRelativeX; //テキスト描画位置のX座標 = 元の基準値に、これまでのテキスト幅をカウントし、調整幅分だけ左にずらす
        const textY = this.y + this.lineHeight * this.line + this.heightPlus - this.size + 1; // Y座標描画位置 = 元の基準値に、現在の段落*段落分の高さをカウント
        this.context.fillText(char, textX, textY); //テキスト描画の関数本体
    }

    update(sceneInfo, input, touch){ //親要素から呼び出される更新メソッドの本体
        if( this._frameCount <= 0 ) { //タイムラグカウントが0以下なら更新
            this.updateText();
            if( (input.getKey(' ') || touch.touch) && this.textNumber > 3) { let count = 0; //スペースキーorタッチで早送り。会話更新のボタンと被るので、最初の3文字までは早送りしない。
                while( count < 9 && this._frameCount == 0 && !this.compleate) {
                    this.updateText();
                    if(!this.rubyX) { count++; } //一度に最大8文字(ふりがなの場合は文字数に含まない)を更新
                }
                if(this._frameCount > 0) { this._frameCount >> 1; } //スキップ時、タイムラグを半分の時間(端数切り捨て)に
            }
        }
        else {this._frameCount--;} //カウントが在るなら一つ減らす
    }
}
メッセージ機能だけで1ヶ月半か...
ちと休憩を入れよう。


次回は、立ち絵のTweenアニメーションの実装、よりアニメーションを制御しやすくする記述を試してみます。
JavaScriptでゲーム作り「19:Tweenアニメーションを実装する」


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

すぺしゃるさんくす

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


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