JavaScriptでゲーム作り「17-3:Canvas最適化2 ダブルバッファリング」

キャンバス最適化の応用編、ダブルバッファリングについて。

Canvasのプリレンダリングを全ての領域で行う

(2019.7.7執筆)

色々試してみると、非表示キャンバスへの描画は、表示されてるキャンバスに比べて更新の負担が軽いような感じがあります。 実際、私のPCではCanvas表示サイズの幅512を超えると極端に動作がモタツクのが問題でした。しかし一方、非表示キャンバスの更新はいくら領域を増やした所で、動作に支障は出ないようでした。

今回、こういった非表示キャンバスの描画更新メソッドを全体で行うとどうなるか試します。画面のもたつきを無くし、描画パフォーマンス向上させ、ひいてはcanvasサイズの許容を一回り大きくできたら...という考えで取り組みます。 この手法はプリ描画用の非表示キャンバスと、データ貼付け用の表示キャンバス。2つのCanvasを用いるので、ダブルバッファリングと呼ばれるそうです。

ダブルバッファリングの手順

  1. GameクラスにCanvasを2つ用意し、DOMに追加する
  2. Gameから各Sceneに渡すcanvasを、プリ描画用の非表示キャンバスにする
  3. Gameクラス側で更新された描画データを受け取り、表示Canvasに貼り付ける



おそらくCanvasの操作は、canvasを直接管理するGameクラスに任せることになります。

それしてもゲーム全体のCanvasの調整。。。大掛かりなことになってしまったように思います。 何しろ此れまで作った全てのクラス描画に関わってくるので、まずはシステムデータのバックアップをとっておく。安全マージンを確保した上で臨みます

Gameクラス内、2つのCanvasの役割設計

さて、設計を考える...ゲーム全体におけるCanvasの扱いについてです。
これまでシーンを切り替える毎に、それぞれのシーン要素にcanvas情報を持たせる形でした。 しかしこれを、inputやtouchのようにアップデート関数の中でのみ受け渡しをするとどうでしょう?

結局のところ、canvas参照が必要なのはupdate()内の描画メソッドの中のみなので。問題なさそうですね。 それに複数のキャンバスがあるといっても、更新されるのは一つだけ。ならば更新の必要な非表示canvasに絞ってゲーム間の受け渡しするのが良さそうです。 一つは更新用の非表示canvas、一つは画面表示用のcanvas。そのようにGameクラス内にて役割を持たせた2つのCanvasを展開してみます。

Gameクラス内のcanvas設定をプログラムする

class Game {
    constructor(title, maxFps) {
        this.title = title;
        this.maxFps = maxFps;
        this.currentFps = 0;
        this._prevTimestamp = 0;

        this._inputReceiver = new InputReceiver();
        this._touchReceiver = new TouchReceiver();

        //プリ描画用の非表示キャンパス、ここで描画データの計算する方法を模索
        const canvas0 = document.createElement('canvas');
        canvas0.width = screenCanvasWidth;
        canvas0.height = screenCanvasHeight;
        canvas0.style.position = 'absolute';
        canvas0.style.visibility='hidden'; //非表示に
        const context0 = new RenderContextCanvas(canvas0);

        //画面表示用のキャンバス、プリ描画データから一括で画面に描画する方法を模索
        const canvas1 = document.createElement('canvas');
        canvas1.width = screenCanvasWidth;
        canvas1.height = screenCanvasHeight;
        canvas1.style.visibility='hidden'; //こちらを表示
        const context1 = new RenderContextCanvas(canvas1);

        //canvasを2つ用意したダブルバッファリングを行うことで負荷軽減、動作のもたつきを無くす
        this.canvasList = [{canvas:canvas0, context:context0}, {canvas:canvas1, context:context1}];

        console.log(`${title}が初期化されました。`);
    }
まずはconstructor内にて、2つのcanvasを用意する所から始めます。 13項でウィンドウ用のキャンバスを追加した時と同じような感じですね。あれから1つに戻しましたが、結局canvasは2つ使うことになりそうです。

違うところといえば、canvas参照データの管理のやり方ですね。 this.canvasListという配列を用意して、そこに2つのcanvasとcontext情報を格納してます。context情報は、17-1で作った中間ラップ用のRenderingContextクラスです。

Gameクラス内のLoop処理


    _loop(timestamp) {
        const elapsedSec = (timestamp - this._prevTimestamp) /1000;
        const accuracy = 0.9; // あまり厳密にするとフレームが飛ばされることがあるので
        const frameTime = 1 / this.maxFps * accuracy; // 精度を落とす
        if(elapsedSec <= frameTime) { //fps制限。通常requestAnimationFrameは60fpsで実行されますが、例えば120Hzのモニタを使っていると倍速で動きます。なので制限があったほうがいいでしょう。
            requestAnimationFrame(this._loop.bind(this));
            return;
        }

        this._prevTimestamp = timestamp;
        const input = this._inputReceiver.getInput();
        const touch = this._touchReceiver.getTouch();
        const clientRect = this.canvasList[0].canvas.getBoundingClientRect(); //タッチ座標をcanvasの位置に合わせて調整
        touch.x = touch.pageX - clientRect.left - window.pageXOffset; //タッチx座標を調整した値を保存
        touch.y = touch.pageY - clientRect.top - window.pageYOffset; //タッチy座標を調整した値を保存

        const gameInfo = new GameInfomation(elapsedSec);

        this.currentScene.update(this.canvasList[0].context, input, touch, gameInfo); //シーンアップデートに現canvasのコンテキストを渡す

        this.canvasList[0].canvas.style.visibility='visible'; //見えない領域でcanvas描画してから、表示させる
        this.canvasList[1].canvas.style.visibility='hidden'; //もう一方は非表示に
        this.canvasList = [this.canvasList[1], this.canvasList[0]]; //お互いの役割を入れ替え、2つのキャンバスを交互に非表示更新させ、表示に切り替えることでアニメーションを再現(実質の画面更新負担が30fpsとなる)

        requestAnimationFrame(this._loop.bind(this));
    }
ループ内にて、更新させるcanvasの参照をシーンに渡し、シーンアップデート後に更新した非表示canvasを表示状態に切り替え、もう一方を非表示に! 最後にcanvasListの配列を入れ替えることでcanvasの役割を交代させます。2つの画面が交互に非表示描画更新⇒表示を繰り返す設計になりました。

参考 ⇒ HTML5 + Javascript + canvas でダブルバッファリングをする (Double Buffering)

ゲームスタート時に、2つのキャンバスを画面登録する


class RolePlayingGame extends Game {
    constructor() {
        super('RPG製作', 60);
        const titleScene = new TitleScene();
        this.changeScene(titleScene);
    }
}

const global = {}; //シーン間を跨がって活躍するActor達を格納する場所。

assets.addImage('player', 'images/chara_player.png');
assets.addImage('npc1', 'images/chara_npc1.png');
assets.addImage('bg0', 'images/backimage0.png');
assets.addImage('up_npc_girl0', 'images/up_npc_girl0.png');
assets.addImage('up_npc_girl1', 'images/up_npc_girl1.png');
assets.loadAll().then((a) => {
    const game = new RolePlayingGame();
    document.body.appendChild(game.canvasList[0].canvas);
    document.body.appendChild(game.canvasList[1].canvas);
    game.start();

    global.player = new Player(150, 200, 90); //globalオブジェクトに、player: new Player();のプロパティを追加。global.playerでアクセス可能に!
});
canvasが2つになって名前も変わったので、ゲーム起動時に追加するcanvas要素を修正&追加しておきます。

Sceneのアップデート内を修正

Sceneクラスの方では、updateで受け取る新しい変数に合わせてメソッドを修正していきます。

    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.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)
        );
      }
    }
canvasContextへの参照が、Gameクラスから受け取ったcontextに変化してます。
contextそのままを各アクターやwindowに渡して描画してもらう感じです。

Scene側でcontext情報を保持する必要がなくなったので、従来のrenderingTarget要素(canvasへの参照)は省略できる形になります。

各シーンクラス毎にcanvas情報を保持する必要がなくなった

class MainScene extends Scene {
    constructor() {
        super('メイン', 800, 600, 'bg0');

        this.add(global.player);
        const npc1 = new NPC_girl1(150, 100,'すみれ');
        this.add(npc1);
    }
}

class TitleScene extends Scene {
    constructor() {
        super('タイトル', screenCanvasWidth, screenCanvasHeight, null);
        const title = new TextLabel(100, 200, 'RPG製作',"white", 25);
        this.open(title);
    }

    update(context, input, touch, gameInfo) {
        super.update(context, input, touch, gameInfo);
        if(input.getKeyDown(' ') || touch.touch) {
            const mainScene = new MainScene();
            this.changeScene(mainScene);
        }
    }
}
新しいシーンを追加する場合にも、従来のrenderingTargetを省略してOKになりました。

では、動くかどうかテストしてみます。
実際には、何度かエラーを修正してきちんと動く形になりました。


ダブルバッファリングのデモを見る(役割交代型)


画面サイズを640×480にしてます。一回り大きくなった。
私のPCで画面を大きくしてもスムーズに表示されます。


一方で、別の方法も試してみました。
こちらは役割固定型。非表示canvasに更新描画したものを、表示canvasに貼り付ける。その繰り返し。


ダブルバッファリングのデモを見る(役割固定型)


私のPCでは従来通り画面がもたついてしまいます。512より幅が大きくなってるので。
やはり表示キャンバスに貼りつけ自体が更新操作の一つなので、許容できなかったかなぁ...ちなみに30fpsだと問題なく動きます。 なので、先の方法にしました。

これまでPCスペックの問題か、もたついて画面サイズを大きくできなかったのが、ダブルバッファリングを用いることで無事に乗り切ることができました。 中々すばらしい成果です。これで完成形の画面サイズを見越したゲーム制作が可能になりました。


案外、仕様変更は簡単でしたね。
これも最初のフレームワークがしっかりしてるお陰です。ことさん、ありがとう(' '*)

これにてダブルバッファリングの解説は終わり。
記事読んでくれてありがとうございます。

おまけ、元々あったウィンドウのプリ描画領域をどうするか?

画面全体がプリレンダリングに対応した現在。元々のメッセージウィンドウに用いてた独自キャンバスは、果たして必要なのだろうか?

  • 従来通り、ウィンドウ領域用の独自キャンバスを持たせておくか
  • テキスト部分の更新だけ、独自のキャンバスを持たせるか


ウィンドウ領域が独自キャンバスを持っていなくとも、全体がプリ描画であることには変わらないので、それだったら管理する総canvasサイズが少ないほど良いのかな?と考えたのです。その分、毎フレームの描画更新にウィンドウ枠の描画が嵩むのですが... 尚、テキスト描画部分については毎フレーム更新する負担が大きいので、ここだけは独自キャンバスにする必要があると言えます...


ということで、2つのパターンの描画速度を比較してみました。

ウィンドウクラスに独自キャンバスを持たせたままの方

会話イベント時間計測1
⇒ 会話パフォーマンスのデモを見る(ウィンドウ全体が独自キャンバス)



テキスト部分のみ独自キャンバスを持たせた方

会話イベント時間計測2
⇒ 会話パフォーマンスのデモを見る(テキストのみ独自キャンバス)



う〜む、どうやらウィンドウ枠に関してはそのまま独自キャンバスを持たせておいたほうが良いみたいです。 描画をクリアした状態から、ただ枠をプリ描画するだけでもけっこうなコストなんだなぁ〜〜。毎フレームだいたい1msくらいの差が出ました。

キャンバスの最適化には、非表示のプリ描画領域を最小限に留めるよりも、状態を不必要に変更しない(描画メソッドを最小限に済ませる)というのが、かなり優先されるといった具合です。


なのでメッセージウィンドウ枠は、テキストとまとめて独自キャンバスに描画したデータを保管しといて、それを丸ごと更新画面に貼り付けるだけのやり方が合ってるみたいです。


ということで、そのようにメッセージウィンドウを最適化します。

メッセージウィンドウクラスの最適化

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

class MessageWindow extends WindowUI {//メッセージウィンドウの管理と会話送り
    constructor(allMessages) {
        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.allMessages = allMessages; //全てのメッセージオブジェクトが格納された配列
        this.currentMessage = this.allMessages.shift(); //今表示するべきメッセージオブジェクト、全メッセージリストの先頭を取り出した値
        //this._text = 現在表示するテキストクラスを保持する場所

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

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

        //表示する立ち絵についての初期化判定
        if(!this._currentMessage.illust) { //立ち絵がない場合の操作
            this.textMaxWidth = screenCanvasWidth - this.paddingLeft*2; //テキストの最大表示幅、キャラ立ち絵なしの場合
            if(this.talkPict) {this.talkPict.fadeOut(); this._frameCount = Math.abs(1/this.talkPict.fade); this.talkPict = null;} //前回の立ち絵をフェードアウトさせる
        } 
        else { //立ち絵がある場合
            this.textMaxWidth = screenCanvasWidth - this._currentMessage.illust.sprite.width; //立ち絵ありのときテキスト表示幅はやや縮小。キャラ絵の幅分を確保
            if(this.talkPict) { //前回の立ち絵が残ってる場合
                if(this.name === this.currentMessage.name) { //前回の話し手と同じキャラクターなら
                    this._frameCount = 0; //ウェイト時間は発生しない
                    if(this.talkPict !== this._currentMessage.illust) { this.talkPict.delayClose(); this.talkPict = null;} //表情差分はクロスフェードで重ねて表示させよう
                    else {}//同じ立ち絵ならイラスト変化なし
                }
                else {this.talkPict.fadeOut(); this._frameCount = Math.abs(1/this.talkPict.fade); this.talkPict = null;} //違うキャラクターなら前回の立ち絵をフェードアウト
            }
            else {this._frameCount += Math.abs(1/this._currentMessage.illust.fade);} //キャラクター登場時にフェード時間分だけメッセージ表示を遅らせる
        }
        //this.talkPict ...これは前回の立ち絵のデータを参照している
        this.text = this._currentMessage.text.shift(); //テキストの配列から、現在ウィンドウに表示する会話フェーズを取り出す
        this.inited = false; //ウィンドウ枠の初期化済み=false
    }
    get text() { return this._text; }
    set text(str) { //会話フェーズ内のテキスト内容を切り替える
        if(str === undefined) {
            this.currentMessage = this.allMessages.shift(); return;// 次のテキストが見つからない場合、次の会話フェーズに移行する
        }
        this._text = new TextManager(str, this.textMaxWidth, this.textX, this.textY, this.preContext); //受け取った文字列をクラスに変換して操作する
        this.clearPreRenderingText(); //テキスト欄のみ初期化
    }

    clearPreRendering() { //メッセージクラス全体のプリ描画領域をクリアする
        this.preContext.clear();  //名前表示&テキストスペースクリア
    }
    preRenderingBoard() { //最初にメッセージボードを描画する
        this.preContext.beginPath();
        this.preContext.lineWidth(3); //枠線は2の太さで
        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.fillColor('rgba(255,255,255,0.99)'); //塗りつぶす色は白';
        this.preContext.rect(this.margin, this.margin + this.nameHeight, this.windowWidth, this.windowHeight);
        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); //テキスト描画の関数本体
    }

    updateInit(sceneInfo, input, touch) { //ウィンドウ全体の初期化処理
        if(this._currentMessage.illust && !this.talkPict) { //未だ立ち絵が表示されてないなら
            this.openWindow(this._currentMessage.illust); //先に立ち絵を表示
            this.talkPict = this._currentMessage.illust; //今回の立ち絵イラストをキャッシュ(次回との比較用)
        }
        if(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.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める
        if(this.text.compleate) { //現在の会話の文字数すべて表示されてるなら
            if(input.getKey(' ') || touch.touch) {  //スペースキーorタッチを押した時に
                this.text = this._currentMessage.text.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--;}
    }

    render(context) { //前もってpreCanvasに描画しておいたのを、実際のゲーム画面に貼り付ける
        context.drawImage(this.preCanvas, 0, 0, this.preCanvas.width, this.preCanvas.height, 0, this.windowY);
    }
}
本体のスクリーンサイズや表示する立ち絵の幅に合わせて、テキスト最大幅が自動で計算されるようにしました。
テキストの描画は別クラスで管理するようになったので、メッセージウィンドウ設計と会話フェイズのアップデートのみに機能を集中。読みやすい感じになりました。 しかし長いね、読みやすいのは自分だけ?? まぁいいです(' '*)

テキスト表示を管理するTextManagerクラス

メッセージのテキスト表示部分は別のクラスにして、違うメッセージクラスのパターンでも扱えるようにしてみました。ナレーション表示とかでも使えるように、先を見越しての形です。

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 = 15; //テキストサイズの初期値
        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.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=15;
                        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;
        } 
        const char = this.str.charAt(this.textNumber); //このアップデートで表示するべき1文字を取り出す
        const frameSkip24 = ["。", "…"]; //この後に表示間隔を24フレーム開ける文字列を定義(0.4秒)
        const frameSkip12 = ["、"]; //この後に表示間隔を12フレーム開ける文字列を定義(0.2秒)
        if( frameSkip24.indexOf(char) > -1 ) {this._frameCount = 24;} //現在の文字が、その文字列のいずれかと一致した場合24フレーム開ける
        else if( frameSkip12.indexOf(char) > -1 ) {this._frameCount = 12;}

        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 = 12; } //"#."で次の文字の表示ラグを12フレーム(1/5秒)開ける
            else if(char2 === "#") {this.preRenderingText(char2, context);} //##で続く場合、2文字目の#を普通に描画する
        }
        else {this.preRenderingText(char);} //#でないなら、取り出した1文字を普通に描画する
        this.textNumber++; //描画処理が終わったら、表示テキスト番号を次のカウントに振る
    }
    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; //表示テキスト幅に現在の文字の幅分を追加
    }

    update(sceneInfo, input, touch){ //親要素から呼び出される更新メソッドの本体
        if( this._frameCount <= 0 ) { //タイムラグカウントが0以下なら更新
            this.updateText();
            if( (input.getKey(' ') || touch.touch) && this.textNumber > 3) { let count = 0; //会話更新のボタンと被るので、最初の3文字まではスキップしない。
                while( count < 9 && (input.getKey(' ') || touch.touch) && this._frameCount == 0 && !this.compleate) {
                    this.updateText(); count++; //スペースキーorタッチで一度に最大10文字まで更新
                }
                if(this._frameCount > 0) { this._frameCount >> 1; } //スキップ時、タイムラグを半分の時間(端数切り捨て)に
            }
        }
        else if(this._frameCount > 0) {this._frameCount--;} //カウントが在るなら一つ減らす
    }
}
あいやー、分けてもデタラメに長い(' '*);;;
流れ読めるでしょうか。これでも大分読みやすく描いたつもりなんです。
しかしだいぶ機能盛り込んだからなぁ。。。これはプログラム言語の弊害ですね。機能盛り込むほど阿呆になるやつ。

立ち絵イラストを表示するTalkPictクラス

前回の解説でお馴染みの立ち絵イラスト表示クラス。canvas枠が大きくなっても表示が崩れないように、y座標の位置も予め指定しておきました。

class TalkPict extends WindowUI {//会話時のキャラクターイラスト、メッセージウィンドウの子要素となるクラス
    constructor(sprite, option={}) {
        super(['charactur']);
        this.sprite = sprite;
        this.fade = option.fade ? 1/option.fade : 1/16; //フェード効果の早さを指定、デフォルトは1/16
        this.enter = option.enter ? true : false; //登場時に画面端からスライドする?
        this.leave = option.leave ? true : false; //退場時に画面端にスライドする?

        this.opacity = 0; //描画の透明度。最初は0で透明に。メッセージをフェードイン表示させる
        this.x = screenCanvasWidth - this.sprite.width; //描画開始位置のx座標、登場時のスライドがfalseなら定位置に。
        this.y = Math.max(0, screenCanvasHeight - this.sprite.height); //描画位置のy座標。0より小さくなる場合は0で
    }

    delayClose(value = 500) { //valueミリ秒後にフェードアウトする
        setTimeout(() => this.fadeOut(), value); //表情差分を重ねて表示させる時間を確保するためにメソッドを用意
    }
    fadeOut(value) { //valueのフレーム数でフェードアウトするメソッド。デフォルトは元々指定してたフェード値に-1を掛けた値
        this.fade = value ? - Math.abs(1/value) : - Math.abs(this.fade);
    }

    update(sceneInfo, input, touch) {
        if(this.fade > 0 && this.opacity < 1) { //フェードイン途中の場合
            this.opacity += this.fade;
            if(this.enter){ this.x = screenCanvasWidth - this.sprite.width * (this.opacity*0.5 + 0.5); } // キャラクター登場時のX座標を調整
        }
        else if(this.fade < 0 && this.opacity > 0) { //フェードアウト途中の場合
            this.opacity += this.fade;
            if(this.leave) { this.x = screenCanvasWidth - this.sprite.width * (this.opacity*0.5 + 0.5); } // キャラクター退場時のX座標を調整
        }
        if(this.fade < 0 && this.opacity <= 0) { //フェードアウトしたらこのオブジェクトを閉じる
            this.close();
        }
    }
    renderingCharactor(context) {
        context.drawImage(this.sprite.image, //ここからsprite画像の描画
            this.sprite.x, this.sprite.y,
            this.sprite.width, this.sprite.height,
            this.x, this.y
        ); //sprite画像ここまで
    }
    render(context) { //立ち絵表示は、通常のcanvasContextにて行う
        if( this.opacity < 1 ) { //描画時にフェード効果を付ける
            context.setAlpha(this.opacity);
            this.renderingCharactor(context);
            context.setAlpha(1);
        }
        else { this.renderingCharactor(context); }
    }
}

総じてメッセージウィンドウ周りはこんな感じでしょうか。 残りは選択肢の表示と、選んだ回答によるレスポンスを、どう実装するかです。 そこまで行けば、会話についてのプログラミングは完了となります。

だいぶ機能が充実してまいりました...
canvasも2dの状態で、可能な限りのパフォーマンス改善が施されてます。

そして会話周りだけ立派に。。これノベルゲームかい? んにゃ。


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


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