JavaScriptでゲーム作り「15:メッセージテキスト表示の機能追加」

今回はRPGでよくある会話テキストの表現方法を作りこんでいきます。
会話イベント
⇒ メッセージ表示の完成形デモ(スペースキーでアクション)

メッセージテキストを1文字ずつ表示させる機能

(2019.6.05執筆)

13項にて、一応の会話機能はできてました。このまま与えられたテキストを一括で表示するのもいいのですが、キャラクターが実際に話しているかのような緩急の付け方や表現方法を付加することで、まるでその場で話してるかのような臨場感を宿せるかもしれません。

特に「 」... 言葉にならない空白の時間(表示タイミングをずらす)というのが、何かの気もちの現れを感じたりもする。

会話表示デモを比較する

今回の会話テンポ実装後 ⇒ サンプルデモ(完成)
会話1文字ずつ表示 ⇒ サンプルデモ(途中)
会話一括表示 ⇒ サンプルデモ(最初)


会話送りで単にメッセージ内容を表示するだけではなく、そのキャラクターのリズムや息遣いが感じられるように、機能にカスタマイズを加えたいと思います。

今回のプログラミングの手順

  • メッセージ表示をフレーム毎に1文字ずつ描画する基本機能を作る
  • 特定の文字列で、表示タイミングを遅らせる機能を追加
  • クリックorスペースキーで一括表示の機能を追加
  • 途中でフォントの色やサイズを変更する機能を追加



当初どのように会話送りをプログラミングするか曖昧でしたが、調べるうちに参考になるブログ記事を見つけられたので、そちらを元にコードの流れを追っていきたいと思います。

参考URL
UnityでRPGゲームのメッセージ表示機能を作る
ツクールMV「YANFLY MESSAGE CORE」プラグインでできること

メッセージ会話送りに必要な部品

参考URLを見ながら13項で作ったMessageWindowクラスの中身を確認し、必要な機能を追加するための要素を追加していきます。

これまでのテキスト表示要素

  • this.color = "#555"; //テキスト色の初期値
  • this.size = 15; //テキストサイズの初期値
  • this.allMessages = allMessages; //全てのメッセージ文が格納された配列
  • this.currentMessage = this.allMessages.shift(); //今表示するべきメッセージ文を、リストの先頭から取り出した値


一括表示に必要な要素は4つ程度でした。
テキストの文字色、サイズを指定するのと、受け取った会話内容の全データと、そこから現在表示する会話フェーズを指定するのと。4つ。

追加する要素

  • this.textNumber = 0; //現フレームで表示するのはcurrentMessageの何番目の文字?
  • this.nowMessageText = ['']; //現在表示されてる文字を格納。テキスト幅を測るのに使用
  • this.line = 0; //現在のテキスト文字は、ウィンドウの何段目に表示する?
  • this._timeCount = 0; //次の表示までの経過フレーム数。0より大きい値ならアップデート毎に-1し、0になるまでテキストを更新しない。


ここからメッセージテキストを1文字ずつ更新させるには、表示の途中経過を保存する要素が必要です。
今は会話の何文字目の更新かを示すthis.textNumber、これまでの表示テキストを保管したthis.nowMessageText、そして現在の段落を示すthis.lineを定義しました。

それからthis._timeCount = 0も、会話のテンポを取る際に用意しておきます。

Windowサイズや描画位置を指定する要素

  • this.margin = 2; //画面端からウィンドウ描画の間隔をどれくらいあけるか?
  • this.windowWidth = screenCanvasWidth - this.margin*2; //現在508 ウィンドウ枠の幅をどれくらいとるか
  • this.windowHeight = 100; //ウィンドウ枠の高さをどれくらいとるか
  • this.windowX = this.margin; //左画面端から余白(=margin)がウィンドウ描画位置のX座標にもなる
  • this.windowY = screenCanvasHeight - this.windowHeight - this.margin; //ウィンドウ描画位置のY座標
  • this.textMaxWidth = 472; //テキストの最大表示幅
  • this.lineHeight = this.size * 1.25; //段落の高さ。フォントサイズに対する高さ比で算出


あ〜、それからウィンドウのサイズとか描画位置とか、テキストの最大表示幅とか、次の段落をどれくらいの高さ開けるかとかも、最初に指定しておいたほうが良いでしょうね。 これらの要素を元に、ウィンドウとテキストをどのように描画するかプログラムしていきます。

メッセージウィンドウクラスの雛形を作る


class MessageWindow extends Window {//メッセージウィンドウの管理と会話送り
    constructor(allMessages) {
        super(['messageWindow']);
        this.color = "#555"; //テキスト色の初期値
        this.size = 15; //テキストサイズの初期値
        this.lineHeight = this.size * 1.5; //段落の高さ。フォントサイズに対する高さ比で算出
        this.textMaxWidth = 472; //テキストの最大表示幅

        this.margin = 2; //画面端からウィンドウ描画の間隔をどれくらいあけるか?
        this.windowWidth = screenCanvasWidth - this.margin*2; //現在508 ウィンドウ枠の幅をどれくらいとるか
        this.windowHeight = 100; //ウィンドウ枠の高さをどれくらいとるか
        this.windowX = this.margin; //左画面端から余白(=margin)がウィンドウ描画位置のX座標にもなる
        this.windowY = screenCanvasHeight - this.windowHeight - this.margin; //ウィンドウ描画位置のY座標

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

        this.allMessages = allMessages; //全てのメッセージ文が格納された配列
        this.currentMessage = this.allMessages.shift(); //今表示するべきメッセージ文、全メッセージリストの先頭を取り出した値
        this.textNumber = 0; //今表示するのはcurrentMessageの何番目の文字?
        this.nowMessageText = ['']; //現在表示されてる文字を格納。テキスト幅を測るのに使用
        this.line = 0; //現在のテキスト文字は、ウィンドウの何段目に表示する?
        this.inited = false; //ウィンドウ枠の初期化が必要かどうか

        //会話終了時にウィンドウを破棄&プレイヤー操作再開
        this.addEventListener('textEnd', () => { this.close(); this.playerIsActive(); });
    }

    get currentMessage() { return this._currentMessage; }
    set currentMessage(str) { this._currentMessage = str; //現在の会話内容が更新される際、一旦表示要素をリセットする。
        // もう空っぽだったらtextEndイベントを発生させる
        if(this._currentMessage === undefined) {
            this._currentMessage = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
            this.dispatchEvent('textEnd'); return;// textEndイベントを発行
        }
        this.textNumber = 0; this.line = 0; this.nowMessageText = ['']; this.inited = false; //一旦表示要素をリセット
    }

    renderingFirst(context) {//最初にメッセージボードを描画する
        context.fillStyle = 'rgba(255,255,255,0.99)'; //塗りつぶす色は白';
        context.strokeStyle = 'rgba(125,125,255,0.99)'; //枠は青っぽい
        context.fillRect(this.windowX, this.windowY, this.windowWidth, this.windowHeight);
        context.strokeRect(this.windowX, this.windowY, this.windowWidth, this.windowHeight);
        this.inited = true;
    }

    clearRendering(target) { //___描画領域をクリアする
        const context = target.getContext('2d');
        const width = this.windowWidth + this.margin*2;
        const height = this.windowHeight + this.margin*2;
        context.clearRect(0, this.windowY - this.margin, width, height);
    }

    updateText(context) { //表示テキストを1文字ずつ追加するメソッド
        const char = this.currentMessage.charAt(this.textNumber); //このアップデートで表示するべき1文字を取り出す
        const nowTextWidth = context.measureText(this.nowMessageText[this.line] + char).width; //テキスト幅を更新した後の値を先読み
        if( char === "\n" || nowTextWidth > this.textMaxWidth ) {  //"\n"や、表示するテキスト幅がtextMaxWidthを超えるとき、自動的に改行する。
            this.line++; this.nowMessageText[this.line] ='';
        }
        if( char !== "\n" ) {this.nowMessageText[this.line] += char;}
        this.textNumber++;
    }

    render(context) { //現在の表示テキストを描画する
        this.renderingFirst(context);

        context.font = `${this.size}px sans-serif`;
        context.fillStyle = `${this.color}`;
        //1文字毎にテキストを描画
        const textX = this.windowX + 20, textY = this.windowY + 30; //テキスト描画位置のX座標(枠の左端から20離す)とY座標(枠の上端から30開ける)
        for (let line=0; line < this.nowMessageText.length; line++) {
            context.fillText(this.nowMessageText[line], textX, textY + this.lineHeight * line);
        }
    }

    update(gameInfo, input, touch, target) { //毎フレーム呼び出されるupdateメソッド。このクラスのプログラム動作の本体
        this.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める
        const context = target.getContext('2d');
        if(!this.inited){this.renderingFirst(context); return;}

        if( this.currentMessage.length < this.textNumber) { //現在の会話の文字数すべて表示されてるなら
            if(input.getKeyDown(' ')) {  //スペースキーを押した時に
                this.currentMessage = this.allMessages.shift(); // 次のメッセージに移行
            } return;
        }
        else if( this._timeCount === 0 ) {
            this.updateText(context);
            this.render(context);
        }
        else if(this._timeCount > 0) {this._timeCount--;}
    }
}

会話ウィンドウでは、主なメソッドを6つ用意しました。

ウィンドウ枠のクリアと初期化のメソッド3つ

  • 現在の会話内容が更新された際、ウィンドウの表示要素を初期化
  • メッセージウィンドウを初期化するrenderingFirst(context)
  • ウィンドウ領域をクリアするclearRendering(target)

アップデート毎の動きと描画のメソッド3つ

  • 表示テキストを1文字ずつ追加するupdateText(context)
  • 現在の表示テキストを描画するrender()
  • 毎フレーム呼び出されるupdate()


順番に見ていきます(' '*)

現在の会話内容が更新された際、いったん表示要素をリセットする

    get currentMessage() { return this._currentMessage; }
    set currentMessage(str) { this._currentMessage = str; //現在の会話内容が更新される際、一旦表示要素をリセットする。
        // もう空っぽだったらtextEndイベントを発生させる
        if(this._currentMessage === undefined) {
            this._currentMessage = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
            this.dispatchEvent('textEnd'); return;// textEndイベントを発行
        }
        this.textNumber = 0; this.line = 0; this.nowMessageText = ['']; this.inited = false; //一旦表示要素をリセット
    }
現在の会話this.currentMessageが新しい内容に更新される時、一旦表示要素をすべてリセット、メッセージの表示内容を1文字目から読み込めるようにします。 また、ウィンドウ枠を白紙の状態にするのも必要なので、その要素も初期化します。尚、もし以降の会話が存在しないなら会話終了イベントを発行します。

renderingFirst(context)の関数

    renderingFirst(context) {//最初にメッセージボードを描画する
        context.fillStyle = 'rgba(255,255,255,0.99)'; //塗りつぶす色は白';
        context.strokeStyle = 'rgba(125,125,255,0.99)'; //枠は青っぽい
        context.fillRect(this.windowX, this.windowY, this.windowWidth, this.windowHeight);
        context.strokeRect(this.windowX, this.windowY, this.windowWidth, this.windowHeight);
        this.inited = true;
    }
二つ目のメソッドは、白紙のウィンドウ枠を表示する描画関数です。これはメッセージ内容のアップデートの際に最初に呼び出すメソッドになります。 メソッドにしておくと、内容が一新される度にthis.renderingFirst(context);1行で呼び出せるので楽です。

clearRendering(target)の関数

    clearRendering(target) {//___描画をクリアする
        const context = target.getContext('2d');
        const width = this.windowWidth + this.margin*2;
        const height = this.windowHeight + this.margin*2;
        context.clearRect(0, this.windowY - this.margin, width, height);
    }
このメソッドは、ウィンドウを閉じるときに描画領域をクリアするものです。 余白分少し広げて描画を消す必要があるので、幅と左右の余白分、高さを上下の余白分足してクリアしてます。
ウィンドウを閉じる際、Scene側でclose()メソッドが呼び出された際に使います。

Sceneでの使用例

    _disposeClosedWindows() {//メッセージウィンドウを閉じる関数
        this._closedWindows.forEach((window) => {
            const index = this.windows.indexOf(window);
            if(index > -1) {this.windows.splice(index, 1);}
            window.clearRendering(this.windowsCanvas); //画面ウィンドウの描画を実際に消す
        });//閉じるウィンドウをシーンから除外
        this._closedWindows = [];//閉じるリストを空にする
    }

表示テキストを1文字ずつ追加するupdateText(context)

    updateText(context) { //表示テキストを1文字ずつ追加するメソッド
        const char = this.currentMessage.charAt(this.textNumber); //このアップデートで表示するべき1文字を取り出す
        const nowTextWidth = context.measureText(this.nowMessageText[this.line] + char).width; //テキスト幅を更新した後の値を先読み
        if( char === "\n" || nowTextWidth > this.textMaxWidth ) {  //"\n"や、表示するテキスト幅がtextMaxWidthを超えるとき、自動的に改行する。
            this.line++; this.nowMessageText[this.line] ='';
        }
        if( char !== "\n" ) {this.nowMessageText[this.line] += char;}
        this.textNumber++;
    }
描画領域にテキストを表示した際の幅を測るため、contextを代入した状態でメソッドを動かします。 contextはsceneアップデートから渡されたrenderingtargetからgetContext()した値を受け取る感じです。

このメソッドの役割は、更新する文字を1文字取り出して、特殊文字(改行とか)ならその動作を、普通の文字ならその文字列を表示テキストに追加する役目を果たします。 基本はアップデート毎に1回呼び出す、またはスキップボタンが押されたら特定の条件が満たされるまで1フレームに何度も呼び出せる感じです。

現在の表示テキストを描画するrender()

    render(context) { //現在の表示テキストを描画する
        this.renderingFirst(context); //メッセージ表示欄の初期化
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = `${this.color}`;
        //1文字毎にテキストを描画
        const textX = this.windowX + 20, textY = this.windowY + 30; //テキスト描画位置のX座標(枠の左端から20離す)とY座標(枠の上端から30開ける)
        for (let line=0; line < this.nowMessageText.length; line++) {
            context.fillText(this.nowMessageText[line], textX, textY + this.size * this.lineHeight * line);
        }
    }
表示テキストを実際に描画するrender()メソッド。最初にthis.renderingFirst(context)を呼び出して白紙のウィンドウ枠を用意してから、描画の記述をしています。

毎フレーム呼び出されるupdate()の関数

    update(gameInfo, input, touch, target) { //毎フレーム呼び出されるupdateメソッド。このクラスのプログラム動作の本体
        this.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める
        const context = target.getContext('2d');
        if(!this.inited){this.renderingFirst(context); return;} //メッセージが切り替わる際に、会話ウィンドウを初期化する

        if( this.currentMessage.length < this.textNumber) { //現在の会話の文字数すべて表示されてるなら
            if(input.getKeyDown(' ')) {  //スペースキーを押した時に
                this.currentMessage = this.allMessages.shift(); // 次のメッセージに移行
            } return;
        }
        else if( this._timeCount === 0 ) {
            this.updateText(context);
            this.render(context);
        }
        else if(this._timeCount > 0) {this._timeCount--;}
    }

そして、1フレーム毎の動きをまとめる大枠のupdate()関数。

基本は1フレーム毎に1文字ずつ表示を更新していきます。 this.currentMessage.length(この会話フェーズの内容は全部で何文字?)>= this.textNumber(現在何文字目まで表示されてるか)を判定し、全て描画されてるならスペースキーを押した時には会話が次の内容に更新されます。内容そのままで再描画が必要ない場合は何もしません。


会話イベント
⇒ ここまでのメッセージ表示のデモ(スペースキーでアクション)



会話の表示ベースはこんな感じでいいでしょう(' '*)
今の段階では等間隔、一定速度で表示が進むようになります。

会話表示のリズムを作る、実際に話してるかのように

ここからタイミングの空白を開ける記述も増やしてみます。this._timeCountの使い方が肝。

updateText(context)に、特定の文字列で_timeCountを増やすよう追記

    updateText(context) { //表示テキストを1文字ずつ追加するメソッド
        const char = this.currentMessage.charAt(this.textNumber); //このアップデートで表示するべき1文字を取り出す
        const nowTextWidth = context.measureText(this.nowMessageText[this.line] + char).width; //テキスト幅を更新した後の値を先読み
        if( char === "\n" || nowTextWidth > this.textMaxWidth ) {  //"\n"や、表示するテキスト幅がtextMaxWidthを超えるとき、自動的に改行する。
            this.line++; this.nowMessageText[this.line] ='';
        }

        const spaceChar = ["\n", "\."]; //文字表示をしない文字列を定義
        if( spaceChar.indexOf(char) === -1 ) {this.nowMessageText[this.line] += char;} //現在の1文字が空白文字でない場合、表示テキストに追加

        const frameSkip30 = ["。", "?", "…"]; //この後に表示間隔を30フレーム開ける文字列を定義(0.5秒)
        const frameSkip15 = ["、", "\."]; //この後に表示間隔を15フレーム開ける文字列を定義(0.25秒)
        if( frameSkip30.indexOf(char) > -1 ) {this._timeCount = 30;} //現在の文字が、その文字列のいずれかと一致した場合30フレーム開ける
        else if( frameSkip15.indexOf(char) > -1 ) {this._timeCount = 15;}
  
        this.textNumber++;
    }
現在の文字をconst char で取得し、普通の文字ならそのまま追加。それが\で始まる特殊文字だった場合の動作を修正しました。 + 「。」や「、」や「\.」だったりした場合に、_timeCountを一定数設ける記述も。このtimeCountは、0になるまでフレームアップデートをスキップし、スキップした場合にカウントを-1する感じです。つまりタイムラグの役割を与えてます。

ちなみに1秒で60フレーム計算なので、30フレームで0.5秒。15フレームで0.25秒の計算です。

update()メソッドにスキップ機能を追加

    update(gameInfo, input, touch, target) { //毎フレーム呼び出されるupdateメソッド。このクラスのプログラム動作の本体
        this.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める
        const context = target.getContext('2d');
        if(!this.inited){this.renderingFirst(context); return;} //メッセージが切り替わる際に、会話ウィンドウを初期化する

        if( this.currentMessage.length < this.textNumber) { //現在の会話の文字数すべて表示されてるなら
            if(input.getKey(' ') || touch.touch) {  //スペースキーorタッチを押した時に
                this.currentMessage = this.allMessages.shift(); // 次のメッセージに移行
            } return;
        }
        else if( this._timeCount === 0 ) {
            this.updateText(context);
            if( (input.getKey(' ') || touch.touch) && this.textNumber > 3) { let count = 0; //スペースキーorタッチで一度に最大10文字まで更新、会話更新のボタンと被るので、最初の3文字まではスキップしない。
                while( count < 5 && (input.getKey(' ') || touch.touch) && this._timeCount == 0 && this.currentMessage.length >= this.textNumber) {
                    this.updateText(context); count++;
                } 
                if(this._timeCount > 0) { this._timeCount >> 1; } //スペースキーが押されてるなら、タイムラグを半分の時間(端数切り捨て)に
            }
            this.render(context);
        }
        else if(this._timeCount > 0) {this._timeCount--;}
    }
全体の動きをまとめるupdate()メソッドでは、スペースキーorタッチパネルが押されてるときの表示早送り機能を吟味してみました。

早送り機能が働いてる時は、while( count < 5 && (input.getKey(' ') || touch.touch) && this._timeCount == 0 && this.currentMessage.length >= this.textNumber) ...つまり、1度の更新で5文字進むか、キーが押されてる間か、「。」や「、」などでタイムラグが発生するか、すべての内容が表示されるまで、updateTextが呼び出されます。そしてタイムラグが発生したならthis._timeCount >> 1;...右シフト演算1回でラグを1/2(端数切捨て)に。早送り時に表示スピードを早めるためです。

そして描画します。


会話イベント
⇒ タイムラグを設けたメッセージ表示のデモ(スペースキーでアクション)



この段階で、メッセージの与える印象がだいぶ違ってきてます。言葉に息遣いやリズムが出てますね。

修正点;途中で文字の大きさや色を変えるには?

だいたいOKな仕上がりになりました。この描き方でもよいのですが、一つ問題がありまして、途中で文字の大きさや色を変えられない設計となってしまいました。 というのもこの描き方だと、1文字ずつ表示する文字列を追加した後に、一度ウィンドウを白紙に戻して再描画しているからです。

context.fillText(char, textX, textY);で文字を描画しますが、一度にまとめて描画すると部分的にフォントを変えることができません。 よって、render部分もupdateTextが呼び出される度に1文字ずつ表示する必要が出てきます。


この場合...シーンとキャンバスの兼ね合いもあります。
シーンのアクターを再描画するさいに、一度スクリーン全体をクリアするので、もし同じキャンバス上にテキストも描画した場合は一緒に消えてしまうでしょう。 ということは、シーンアクターとは別のキャンバスにテキストを描画するか、アクターのアップデートを完全に止めてしまうか。という2択を迫られます。 もしかしたら違う方法もあるのかもしれませんが、今回はキャンバスを2つに分ける(13項)を取り入れて、修正を施しました。

updateTextの度に1文字ずつ描画するようrenderメソッドを変更する

render...テキスト描画する際、もう色々とupdateTextから要素を引き継がなければならなくなったので、updateText()とrender()メソッドと一体化させました。

updateText(context)とrender()を一体化させる

    updateText(context) { //表示テキストを1文字ずつ追加するメソッド
        const char = this.currentMessage.charAt(this.textNumber); //このアップデートで表示するべき1文字を取り出す

        const frameSkip24 = ["。", "?", "…"]; //この後に表示間隔を24フレーム開ける文字列を定義(0.4秒)
        const frameSkip12 = ["、", "\."]; //この後に表示間隔を15フレーム開ける文字列を定義(0.2秒)
        if( frameSkip24.indexOf(char) > -1 ) {this._timeCount = 24;} //現在の文字が、その文字列のいずれかと一致した場合24フレーム開ける
        else if( frameSkip12.indexOf(char) > -1 ) {this._timeCount = 12;}

        if(char === "\t") {this.size += 3;} //"\t"でフォントサイズを3大きくする
        if(char === "\m") {this.size -= 3;} //"\m"でフォントサイズを3小さくする
        if(char === "\f") {this.size = 15;} //"\f"でフォントサイズを元の大きさにする
        if(char === "\r") {this.color = "#c64";} //"\r"でフォント色を赤に
        if(char === "\b") {this.color = "#555";} //"\b"でフォント色を元に戻す

        const nextTextWidth = this.nowTextWidth + context.measureText(char).width; //テキスト幅を更新した後の値を先読み
        if( char === "\n" || nextTextWidth > this.textMaxWidth ) {  //"\n"や、表示するテキスト幅がtextMaxWidthを超えるとき、自動的に改行する。
            this.line++; this.nowTextWidth = 0; //段落を変更したら、これまでのテキスト幅は0に。
        }

        //ここからテキスト描画
        const spaceChar = ["\n", "\.", "\r", "\b", "\t", "\m", "\f", ""]; //文字表示をしない文字列を定義
        if( spaceChar.indexOf(char) === -1 && char !== undefined) { //表示する文字列だったら、テキスト描画メソッドを呼び出す
            context.font = `${this.size}px sans-serif`;
            context.fillStyle = `${this.color}`;
            //1文字毎にテキストを描画
            const textX = this.windowX + 20 + this.nowTextWidth; //テキスト描画位置のX座標 =「枠の左端から20離す」に、これまでのテキスト幅をカウント
            const textY = this.windowY + 30 + this.lineHeight * this.line; // Y座標 =「枠の上端から30開ける」に、現在の段落*段落分の高さをカウント
            context.fillText(char, textX, textY); //テキスト描画の関数本体

            this.nowTextWidth += context.measureText(char).width; //表示テキスト幅に現在の文字の幅分を追加
        } 
        this.textNumber++; //全ての処理が終わったら、表示テキスト番号を次のカウントに振る
    }
主な変更点は、this.nowTextWidthの部分でしょうか。context.measureText(char).width;で、これまで表示した分のテキスト幅を追加していき、テキスト描画のx座標にて調整を入れてます。もし段落が変更されていたらこの値は0に。。。そして1文字ずつ描画。
this.nowMessageText = [''];は、もう役目を終えたので削除でOK。。。


もし途中でフォントサイズを変える記述が成される場合、そうですね...\l「\t」で一段階大きく、\s「\m」で一段階小さくするなんてどうかな。
(\lと\sだと、普通にlやsを記述した時にも反応してしまった(なぜ?)ので「\t」と「\m」に変更した。

それから色を変更する場合。重要な部分を赤色で表示できるように...「\r」で文字色を赤に、「\b」で文字色を通常に戻すなんてどうかな。


描き方はその人の思うように、とりあえずこのように設定してみるとします。後で変えるかもだけど...
それからupdate()にて、もう使わないrender()メソッドを消去しました。これで無事に動作します。

後はActor側のセリフを変更するだけ

// NPCクラスの中身の一部
        this.addEventListener('talkStart', (e) => {
            let messages;
            switch (e.target) {
                case 0 : messages = ['こんにちは。あのーなにしてるんですか?どなたさまですか?','わたしは\rおねむ\bよ。\rねむねむ\bよ。まっくらすやぁってぐっすりねるの。', 'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ\tZ\tZ\tZ\tZZ…', '※これは\f会話テスト!\.\.']; break;
                case 1 : messages = ['ふいーーーーーー、よくネたわ。\nすっきりお目ざめかしら。','すてきなミカンがいっぱいふってくる夢をみたわ。たべほうだい。\nふゆはこたつでぐー0…', '※これは会話テストです\.\.']; break;
                case 2 : messages = ['こんにちは。この世界のこと、誰かが決まり文句のように言うの。', 'ようこそ始まりの空間へ。「Hetto fWorld」と aaaaaaaaaaaaaaaaaaaaaあああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ…', '※これは会話テストです\.\.']; break;
                default: messages = ['さて…']; this.talkNumber=-1; break;
            }
            const messageWindow = new MessageWindow(messages);
            this.dispatchEvent('openWindow',  new Event(messageWindow));
            this.talkNumber++;
        });

セリフに専用の特殊文字を入れてみました。これで確認してみましょう。

会話イベント
⇒ 途中で色やサイズを変更するメッセージ表示のデモ(スペースキーでアクション)



まぁ、こんなもんかな?
無事にメッセージウィンドウの調整が終わった。ということにします(' '*)...
うーん。まぁ上出来か。。。

追記:"\"を使った特殊文字について

後日古都さんにアドバイスを頂いて、"\"について思い違いをしてたことが分かりました。

頂いたメール内容より

JavaScriptでは「\n」「\r」「\t」「\b」「\f」「\v」「\0」が特殊文字として使用できます。
これらの文字は特殊な意味を持ち、改行とかバックスペースを表すのに使われます。

一方で「\l」は普通の文字です。理由は上記のどれにも当てはまらないからです。
そしてこのとき「\」に特殊な意味が発生します。「\の次の文字の意味を失わせる」という機能が動きます。
もちろん「l」に特殊な意味などないのですが、それはそれとして「\」は消費されてしまうので
「\l」はただの「l」に変換されます。つまり'\l' === 'l'です。本当に単なるlです。

なのでHelloのlに反応してしまいます。
このあたり複雑なので、「\」始まりの制御は避けた方がいいかもです。

ちなみに文字列中で「\」を使いたい場合は「\\」と書きます。
「\」自体を「\」で無効にするというややこしいやつです。

そうです。「\」について誤解がありました! 既に特殊文字として使えるアルファベットが決まってたのですね。 偶然にも特殊文字に対応するアルファベットを使ったから、上手く作動していたのか…(T_T)
改善策を考えてみてます。
\nは、本来の意味で使われてるからOKとして、他はどうすればいいのか…

一晩思考...何かしらの記号[#]とか[$]とかと一致した場合に、次の文字列によって判定が変わるようにするか…よし

「#」⇒続く文字に応じて、テキスト装飾を与える関数を記述

\のような機能を形作るのに、別の記号「#」を選んで再現してみることにしました。

updateText()の内容を修正します

    updateText(context) { //表示テキストを1文字ずつ追加するメソッド
        const char = this.currentMessage.charAt(this.textNumber); //このアップデートで表示するべき1文字を取り出す
        const frameSkip24 = ["。", "?", "…"]; //この後に表示間隔を24フレーム開ける文字列を定義(0.4秒)
        const frameSkip12 = ["、"]; //この後に表示間隔を12フレーム開ける文字列を定義(0.2秒)
        if( frameSkip24.indexOf(char) > -1 ) {this._timeCount = 24;} //現在の文字が、その文字列のいずれかと一致した場合24フレーム開ける
        else if( frameSkip12.indexOf(char) > -1 ) {this._timeCount = 12;}

        if(char === "#") { this.textNumber++; //取り出した文字が#の場合、次の一文字を調べる。#の直後に特定の文字列だった場合に、対応する関数を記述
            const char2 = this.currentMessage.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 === "r") { this.color = "#c64";} //"#r"でフォント色を赤に
            else if(char2 === "b") { this.color = "#555";} //"#b"でフォント色を元の黒いグレーに戻す
            else if(char2 === ".") { this._timeCount = 12; } //"#."で次の文字の表示ラグを12フレーム(1/5秒)開ける
            else if(char2 === "#") {this.render(context, char2);} //いずれの特殊文字でもない場合、2文字目を普通に描画する
        }
        else {this.render(context, char);} //#でないなら、取り出した1文字を普通に描画する
        this.textNumber++; //全ての処理が終わったら、表示テキスト番号を次のカウントに振る
    }
現在の一文字を参照して"#"だった場合、次の一文字を参照できるようにしました。 そしてこの条件下に限り、特定の文字でテキストを大きくしたり、小さくしたり、色を変えたり、表示ラグを設けたり...というのを設定しています。 最初から備わっていた"\"と違って、この描き方だと後からでも機能拡張しやすい。スッキリして素晴らしいです。

render()メソッドの追記

    render(context, char) { //ここからテキスト描画メソッド
        const nextTextWidth = this.nowTextWidth + context.measureText(char).width; //テキスト幅を更新した後の値を先読み
        if( nextTextWidth > this.textMaxWidth ) {  //表示するテキスト幅がtextMaxWidthを超えるとき、自動的に改行する。
            this.line++; this.nowTextWidth = 0; //段落を変更したら、これまでのテキスト幅は0に。
        }
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = `${this.color}`;
        //1文字毎にテキストを描画
        const textX = this.windowX + 20 + this.nowTextWidth; //テキスト描画位置のX座標 =「枠の左端から20離す」に、これまでのテキスト幅をカウント
        const textY = this.windowY + 30 + this.lineHeight * this.line; // Y座標 =「枠の上端から30開ける」に、現在の段落*段落分の高さをカウント
        context.fillText(char, textX, textY); //テキスト描画の関数本体
        this.nowTextWidth += context.measureText(char).width; //表示テキスト幅に現在の文字の幅分を追加
    }
あとはrender()メソッドも復活させたので、そちらも合わせて追記します。

NPCのメッセージ内容修正


class NPC_girl1 extends NPC {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), 32, 0, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28); //Circle(16, 16, 16);
        super(x, y, sprite, hitArea, ['npc']);
    }
    talkStart(talkNumber) { //会話を表示する関数、NPCの拡張クラスではこの関数を上書きして各々の会話内容を設定します。
        let messages;
        switch (talkNumber) {
            case 0 : messages = ['こんにちは。あのーなにしてるんですか? どなたさまですか?','わたしは#rおねむ#bよ。#rねむねむ#bよ。まっくらすやぁってぐっすりねるの。', 'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…', '※これは#=会話テスト!#.#.']; break;
            case 1 : messages = ['ふいーーーーーー、よくネたわ。#nすっきりお目ざめかしら。','すてきなミカンがいっぱいふってくる夢をみたわ。たべほうだい。#nふゆはこたつでぐー0…', '※これは会話テストです#.#.']; break;
            case 2 : messages = ['この世界のこと、誰かが決まり文句のように言うの。', 'ようこそ始まりの空間へ。「Hettom fWorld」と aaaaaaaaaaaaaaaaaaaaaあああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ…', '※これは会話テストです#.#.']; break;
        default: messages = ['さて...#.#.#.']; this.talkNumber=-1; break;
        }
        const messageWindow = new MessageWindow(messages);
        this.dispatchEvent('openWindow',  new Event(messageWindow));
        this.talkNumber++;
    }
}
テキストの書式を変えたので、NPCのメッセージ内容も合わせて修正します。
これで無事に動くようになりました。


途中で文字が変わる会話デモを見る

文字サイズが変わったときの段落高さ調整

もう一つ修正しないといけない部分がありました。文字の大きさが変わったとき、その段落の高さも合わせて調整しないとです。 これは、前もって1行ごとの最大フォンとサイズを確認して、行の始まりで最大フォントサイズ分の高さを調整するしかないのではなかろうか...(' '*) 2重の手間がかかりますが、ちょっと実装してみたいと思います。

テキスト表示位置のY座標に、調整値を入れる


        const textY = this.windowY + 30 + this.lineHeight * this.line + this.heightPlus; // Y座標 =「枠の上端から30開ける」に、現在の段落*段落分の高さを
        // さらに文字サイズが変更された際の調整値this.heightPlusを加える
このtextXは、文字描画のy座標を指定するものとして扱ってます。
this.heightPlus = 0;を予め用意しておいて、もし途中で最大フォントサイズが変更された際に、その差分を加えておく。 そしてテキスト描画の際に、その値を高さに反映する、という流れを考えてます。


で、この高さ調整の値の計算は、会話内容を新しくセットをする毎にチェックを入れます。

ウィンドウ初期化メソッドに、テキストチェックを設ける

    get currentMessage() { return this._currentMessage; }
    set currentMessage(str) {
        if(str === undefined) { // もう空っぽだったらtextEndイベントを発生させる
            this._currentMessage = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
            this.dispatchEvent('textEnd'); return;// textEndイベントを発行
        }
        this._currentMessage = str; //↓現在の会話内容が更新される際、一旦表示要素をリセットする。
        this.textNumber = 0; //今表示するのはcurrentMessageの何番目の文字?
        this.line = 0; //現在のテキスト文字は、ウィンドウの何段目に表示する?
        this.nowTextWidth = 0; //現在表示されてるテキスト幅を測るのに使用
        this.heightPlus = 0; //テキスト表示位置の高さの微調整に使用
        this.heightPlusArray = null; //その段落の高さ調整分の値を配列に記録。初期値null
        this.size = 15; //フォントサイズも初期化
        this.inited = false; //ウィンドウ枠の初期化が必要ならfalse
    }
テキスト表示要素を一旦リセットし、初期化を必要とする!意を、this.initedプロパティに持たせました。
this.initedがfalseならば、フレームupdate()時にウィンドウ初期化メソッドを起動します。

update()の最初の記述

    update(gameInfo, input, touch, target) { //毎フレーム呼び出されるupdateメソッド。このクラスのプログラム動作の本体
        this.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める
        const context = target.getContext('2d');
        if(!this.inited){ //初期化メソッド起動
            this.renderingFirst(context);
            this.messageSizeCheck(context);
            this.inited = true;
        return;} //メッセージが切り替わる際に、会話ウィンドウを初期化してメッセージ表示の調整チェック
メッセージチェックまでの流れをだいたい描きました。 あとは、this.messageSizeCheck(context);の中身を記述すればOKです。

メッセージチェックの関数を記述する

    messageSizeCheck(context) { //ここから、段落ごとの最大フォントサイズをチェック
        const checkNum1 = this.currentMessage.indexOf("#<"); /*フォントを大きくする特殊文字が含まれないなら-1が、含まれるなら文字列のn番目のnを返す*/
        if(checkNum1 === -1) {return;} // フォントを大きくする記述がないならそのままでOK
        else { /*#<文字列が含まれるなら最大フォントサイズチェック!*/ }
    }
関数の大枠はだいたいこんな感じです。フォントサイズを大きくする"#<"が含まれてるかどうかだけを判定。もし含まなければ、そのまま関数を終了してOK。 で、もし含まれてる場合に段落毎の文字サイズの最大値を調べるようにします。

else以下をさらに記述

    messageSizeCheck(context) { //ここから、段落ごとの最大フォントサイズをチェック
        const checkNum1 = this.currentMessage.indexOf("#<"); //フォントを大きくする特殊文字が含まれないなら-1が、含まれるなら文字列のn番目のnを返す
        if(checkNum1 === -1) {return;} // フォントを大きくする記述がないならそのままでOK
        else { /*#<文字列が含まれるなら最大フォントサイズチェック!*/
            let count = 0;
            let maxSize = 15;
            let nowSize = 15;
            let textWidth = 0;
            let heightPlus = 0;
            this.heightPlusArray = []; //この値を求めるための↓長ったらしい関数。その段落の高さ調整分をthis.heightPlus[this.line]で取り出せる形にするのに、こんな行数かかってしまうとは。。しかし表示を綺麗にするにはしゃーないのか。文字サイズ弄らなければ無駄に動くこともないし、いいか...(' '*)
            while(count < this.currentMessage.length) {
                const char = this.currentMessage.charAt(count);
                let char2;
                if(char === "#") { count++; //取り出した文字が#の場合、次の一文字を調べる。関数が登録されてる文字列の場合無視
                    char2 = this.currentMessage.charAt(count);
                    if(char2 === "n") {
                        heightPlus += maxSize - 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文字が#でない場合と、##で続いた場合の#を拾える
                    context.font = `${nowSize}px ${this.fontStyle}`;
                    const charWidth = context.measureText(char).width;
                    const nextCheckWidth = textWidth + charWidth;
                    if( nextCheckWidth > this.textMaxWidth ) {  //表示するテキスト幅がtextMaxWidthを超えるとき、判定1行終わり、高さ調整の判定値を配列に追加。
                        heightPlus += maxSize - 15;
                        this.heightPlusArray.push(heightPlus); textWidth = 0; maxSize = nowSize;
                    }
                    textWidth += charWidth;
                }
                count++;
            }
            heightPlus += maxSize - 15;
            this.heightPlusArray.push(heightPlus);  //全ての文字を表示した後の、最後の段落判定
        }
    }
はあああああ、、、めちゃ長いですね。流れはだいたいupdateText()と同じ。 ただ実際に描画するわけではなく、その行の最大フォントサイズを記録して、基準となるサイズ「15」との差を求めて、結果をthis.heightPlusArrayに記録していくだけ。

それだけの行程に、これほどの行数を必要とするとは...はああああ、、面倒くさいですね。でもやらないと、手動で高さ調整するほうがもっと大変。システム組むときに製作作業できるだけ軽減できるように、ここはやっといたほうが良いのです...(o _ o。)...システム屋さんの苦労が伺えます。ふぅう。

this.heightPlusArrayをconsole.log()で見てみる

会話イベント

計算結果はこのように、段落毎の高さ調整値が、配列で返ってきてます。

描画時に仕上げ、段落の最初の1文字目でheightPlusArrayから調整値を取り出す

    render(context, char) { //ここからテキスト描画メソッド
        const nextTextWidth = this.nowTextWidth + context.measureText(char).width; //テキスト幅を更新した後の値を先読み
        if( nextTextWidth > this.textMaxWidth ) {  //表示するテキスト幅がtextMaxWidthを超えるとき、自動的に改行する。
            this.line++; this.nowTextWidth = 0; //段落を変更したら、これまでのテキスト幅は0に。
        }

        context.font = `${this.size}px ${this.fontStyle}`;
        context.fillStyle = `${this.color}`;
        if(this.nowTextWidth === 0 && this.heightPlusArray) { this.heightPlus += this.heightPlusArray[this.line]; } //段落最初に判定、この行の最大文字サイズで、表示位置の高さを調整
        //1文字毎にテキストを描画
        const textX = this.windowX + 20 + this.nowTextWidth; //テキスト描画位置のX座標 =「枠の左端から20離す」に、これまでのテキスト幅をカウント
        const textY = this.windowY + 30 + this.lineHeight * this.line + this.heightPlus; // Y座標 =「枠の上端から30開ける」に、現在の段落*段落分の高さをカウント
        context.fillText(char, textX, textY); //テキスト描画の関数本体
        this.nowTextWidth += context.measureText(char).width; //表示テキスト幅に現在の文字の幅分を追加
    }

render()メソッドの中に、この1行を追加して完成です。
        if(this.nowTextWidth === 0 && this.heightPlusArray) { this.heightPlus += this.heightPlusArray[this.line]; } //段落最初に判定、この行の最大文字サイズで、表示位置の高さを調整
もし段落の最初も文字で、this.heightPlusArrayの値が存在するなら、配列の[i]番目(iは現在の段落数を表す)を参照して、その値をheightPlusに追加します。これで高さ調整が自動でとれるようになります! OK...長かった.........

おまけ:文字の高さ調節を特定の文字列でもできるようにする

せっかくthis.heightPlusという値も組み込めたので、特定の文字列でも個別に高さを増減できるようにしてみます。

updateText()に追記

    updateText(context) { //表示テキストを1文字ずつ追加するメソッド
        const char = this.currentMessage.charAt(this.textNumber); //このアップデートで表示するべき1文字を取り出す
        const frameSkip24 = ["。", "?", "…"]; //この後に表示間隔を24フレーム開ける文字列を定義(0.4秒)
        const frameSkip12 = ["、"]; //この後に表示間隔を12フレーム開ける文字列を定義(0.2秒)
        if( frameSkip24.indexOf(char) > -1 ) {this._timeCount = 24;} //現在の文字が、その文字列のいずれかと一致した場合24フレーム開ける
        else if( frameSkip12.indexOf(char) > -1 ) {this._timeCount = 12;}

        if(char === "#") { this.textNumber++; //取り出した文字が#の場合、次の一文字を調べる。#の直後に特定の文字列だった場合に、対応する関数を記述
            const char2 = this.currentMessage.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 = "#c64";} //"#r"でフォント色を赤に
            else if(char2 === "b") { this.color = "#555";} //"#b"でフォント色を元の黒いグレーに戻す
            else if(char2 === ".") { this._timeCount = 12; } //"#."で次の文字の表示ラグを12フレーム(1/5秒)開ける
            else if(char2 === "#") {this.render(context, char2);} //##で続く場合、2文字目の#を普通に描画する
        }
        else {this.render(context, char);} //#でないなら、取り出した1文字を普通に描画する
        this.textNumber++; //描画処理が終わったら、表示テキスト番号を次のカウントに振る
    }
「#^」でも上に、「#v」でも下に高さを調整できるようにしました(。0 _ 0。)ノ
いたずらなテキスト表現もできるようになりました。

会話イベント文字高さ調節
⇒ メッセージ表示の完成形デモ(スペースキーでアクション)



長かった...
修正を何度も重ねて、やっと納得の仕上がり。
古都さん、サポートしてくださってありがとうございました><

MessageWindowクラス現在の最終形

細かい部分でちょこちょこコードを変更してるので、もう一度メッセージウィンドウの全体を記しておきます。

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

        this.margin = 2; //画面端からウィンドウ描画の間隔をどれくらいあけるか?
        this.windowWidth = screenCanvasWidth - this.margin*2; //現在508 ウィンドウ枠の幅をどれくらいとるか
        this.windowHeight = 100; //ウィンドウ枠の高さをどれくらいとるか
        this.windowX = this.margin; //左画面端から余白(=margin)がウィンドウ描画位置のX座標にもなる
        this.windowY = screenCanvasHeight - this.windowHeight - this.margin; //ウィンドウ描画位置のY座標

        this._timeCount = 0; //次の表示までの経過フレーム数。会話のテンポをとるのに使用。0より大きい値ならアップデート毎に-1し、0になるまでテキストを更新しない。
        this.allMessages = allMessages; //全てのメッセージ文が格納された配列
        this.currentMessage = this.allMessages.shift(); //今表示するべきメッセージ文、全メッセージリストの先頭を取り出した値

        //会話終了時にウィンドウを破棄&プレイヤー操作再開
        this.addEventListener('textEnd', () => { this.close(); this.playerIsActive(); });
    }

    get currentMessage() { return this._currentMessage; }
    set currentMessage(str) {
        if(str === undefined) { // もう空っぽだったらtextEndイベントを発生させる
            this._currentMessage = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
            this.dispatchEvent('textEnd'); return;// textEndイベントを発行
        }
        this._currentMessage = str; //↓現在の会話内容が更新される際、一旦表示要素をリセットする。
        this.textNumber = 0; //今表示するのはcurrentMessageの何番目の文字?
        this.line = 0; //現在のテキスト文字は、ウィンドウの何段目に表示する?
        this.nowTextWidth = 0; //現在表示されてるテキスト幅を測るのに使用
        this.heightPlus = 0; //テキスト表示位置の高さの微調整に使用
        this.heightPlusArray = null; //その段落の高さ調整分の値を配列に記録。初期値null
        this.size = 15; //フォントサイズも初期化
        this.inited = false; //ウィンドウ枠の初期化が必要ならfalse
    }

    renderingFirst(context) { //最初にメッセージボードを描画し、段落毎の行の高さ調整を計算する
        context.fillStyle = 'rgba(255,255,255,0.99)'; //塗りつぶす色は白';
        context.strokeStyle = 'rgba(125,125,255,0.99)'; //枠は青っぽい
        context.fillRect(this.windowX, this.windowY, this.windowWidth, this.windowHeight);
        context.strokeRect(this.windowX, this.windowY, this.windowWidth, this.windowHeight);
    }
    messageSizeCheck(context) { //ここから、段落ごとの最大フォントサイズをチェック
        const checkNum1 = this.currentMessage.indexOf("#<"); //フォントを大きくする特殊文字が含まれないなら-1が、含まれるなら文字列のn番目のnを返す
        if(checkNum1 === -1) {return;} // フォントを大きくする記述がないならそのままでOK
        else { /*#<文字列が含まれるなら最大フォントサイズチェック!*/
            let count = 0;
            let maxSize = 15;
            let nowSize = 15;
            let textWidth = 0;
            let heightPlus = 0;
            this.heightPlusArray = []; //この値を求めるための↓長ったらしい関数。その段落の高さ調整分をthis.heightPlus[this.line]で取り出せる形にするのに、こんな行数かかってしまうとは。。しかし表示を綺麗にするにはしゃーないのか。文字サイズ弄らなければ無駄に動くこともないし、いいか...(' '*)
            while(count < this.currentMessage.length) {
                const char = this.currentMessage.charAt(count);
                let char2;
                if(char === "#") { count++; //取り出した文字が#の場合、次の一文字を調べる。関数が登録されてる文字列の場合無視
                    char2 = this.currentMessage.charAt(count);
                    if(char2 === "n") {
                        heightPlus += maxSize - 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文字が#でない場合と、##で続いた場合の#を拾える
                    context.font = `${nowSize}px ${this.fontStyle}`;
                    const charWidth = context.measureText(char).width;
                    const nextCheckWidth = textWidth + charWidth;
                    if( nextCheckWidth > this.textMaxWidth ) {  //表示するテキスト幅がtextMaxWidthを超えるとき、判定1行終わり、高さ調整の判定値を配列に追加。
                        heightPlus += maxSize - 15;
                        this.heightPlusArray.push(heightPlus); textWidth = 0; maxSize = nowSize;
                    }
                    textWidth += charWidth;
                }
                count++;
            }
            heightPlus += maxSize - 15;
            this.heightPlusArray.push(heightPlus);  //全ての文字を表示した後の、最後の段落判定
        }
    }

    clearRendering(target) { //___描画領域をクリアする
        const context = target.getContext('2d');
        const width = this.windowWidth + this.margin*2;
        const height = this.windowHeight + this.margin*2;
        context.clearRect(0, this.windowY - this.margin, width, height);
    }

    updateText(context) { //表示テキストを1文字ずつ追加するメソッド
        const char = this.currentMessage.charAt(this.textNumber); //このアップデートで表示するべき1文字を取り出す
        const frameSkip24 = ["。", "?", "…"]; //この後に表示間隔を24フレーム開ける文字列を定義(0.4秒)
        const frameSkip12 = ["、"]; //この後に表示間隔を12フレーム開ける文字列を定義(0.2秒)
        if( frameSkip24.indexOf(char) > -1 ) {this._timeCount = 24;} //現在の文字が、その文字列のいずれかと一致した場合24フレーム開ける
        else if( frameSkip12.indexOf(char) > -1 ) {this._timeCount = 12;}

        if(char === "#") { this.textNumber++; //取り出した文字が#の場合、次の一文字を調べる。#の直後に特定の文字列だった場合に、対応する関数を記述
            const char2 = this.currentMessage.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 = "#c64";} //"#r"でフォント色を赤に
            else if(char2 === "b") { this.color = "#555";} //"#b"でフォント色を元の黒いグレーに戻す
            else if(char2 === ".") { this._timeCount = 12; } //"#."で次の文字の表示ラグを12フレーム(1/5秒)開ける
            else if(char2 === "#") {this.render(context, char2);} //##で続く場合、2文字目の#を普通に描画する
        }
        else {this.render(context, char);} //#でないなら、取り出した1文字を普通に描画する
        this.textNumber++; //描画処理が終わったら、表示テキスト番号を次のカウントに振る
    }

    render(context, char) { //ここからテキスト描画メソッド
        const nextTextWidth = this.nowTextWidth + context.measureText(char).width; //テキスト幅を更新した後の値を先読み
        if( nextTextWidth > this.textMaxWidth ) {  //表示するテキスト幅がtextMaxWidthを超えるとき、自動的に改行する。
            this.line++; this.nowTextWidth = 0; //段落を変更したら、これまでのテキスト幅は0に。
        }

        context.font = `${this.size}px ${this.fontStyle}`;
        context.fillStyle = `${this.color}`;
        if(this.nowTextWidth === 0 && this.heightPlusArray) { this.heightPlus += this.heightPlusArray[this.line]; } //段落最初に判定、この行の最大文字サイズで、表示位置の高さを調整
        //1文字毎にテキストを描画
        const textX = this.windowX + 20 + this.nowTextWidth; //テキスト描画位置のX座標 =「枠の左端から20離す」に、これまでのテキスト幅をカウント
        const textY = this.windowY + 30 + this.lineHeight * this.line + this.heightPlus; // Y座標 =「枠の上端から30開ける」に、現在の段落*段落分の高さをカウント
        context.fillText(char, textX, textY); //テキスト描画の関数本体
        this.nowTextWidth += context.measureText(char).width; //表示テキスト幅に現在の文字の幅分を追加
    }


    update(gameInfo, input, touch, target) { //毎フレーム呼び出されるupdateメソッド。このクラスのプログラム動作の本体
        this.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める
        const context = target.getContext('2d');
        if(!this.inited){
            this.renderingFirst(context);
            this.messageSizeCheck(context);
            this.inited = true;
        return;} //メッセージが切り替わる際に、会話ウィンドウを初期化してメッセージ表示の調整チェック

        if( this.currentMessage.length < this.textNumber) { //現在の会話の文字数すべて表示されてるなら
            if(input.getKey(' ') || touch.touch) {  //スペースキーorタッチを押した時に
                this.currentMessage = this.allMessages.shift(); // 次のメッセージに移行
            } return;
        }
        else if( this._timeCount === 0 ) {
            this.updateText(context);
            if( (input.getKey(' ') || touch.touch) && this.textNumber > 3) { let count = 0; //スペースキーorタッチで一度に最大10文字まで更新、会話更新のボタンと被るので、最初の3文字まではスキップしない。
                while( count < 5 && (input.getKey(' ') || touch.touch) && this._timeCount == 0 && this.currentMessage.length >= this.textNumber) {
                    this.updateText(context); count++;
                }
                if(this._timeCount > 0) { this._timeCount >> 1; } //スペースキーが押されてるなら、タイムラグを半分の時間(端数切り捨て)に
            }
        }
        else if(this._timeCount > 0) {this._timeCount--;}
    }
}


【目次】
  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の基礎(フレームワーク)を使わせていただいてます。感謝!


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