⚔️

『RPGツクールMZ』メッセージ表示の仕組みを調べてみた

2021/06/07に公開

『RPGツクールMZ』関連記事 目次

以前書いた 『RPGツクールMZ』文字表示関連のメソッド一覧 の記事で大枠はつかめているので、今回はしっかり文字表示の処理を追っていきましょう。

メッセージウィンドウの呼び出し

言うまでもないですが、メッセージウィンドウは[文章の表示]イベントコマンドで呼ばれます。
イベントコマンドは Game_Interpreter で処理されています。
[文章の表示]はコマンドナンバー101、command101()メソッドです。

命令を番号で管理するのはデジタルなんで当然なんだけど、メソッド名の段階まで番号で管理しているのは解せません。
間にひとつテーブルかますだけでいいのに、なんでその手間を惜しむのかっ!!
command101()メソッドはどんな処理をしますか」って聞かれても「データを…なんかするんでしょうね…たぶん。メソッドってのは命令(command)でもあるし特に意味のない名前だよなぁ」みたいなことしか答えられないけど、これが「commandShowText()メソッドはどんな処理をしますか」だったら、「文字を表示するみたいですね、commandってのはイベントコマンドかな?選択肢かな?」ぐらいの予想はつくわけです。
とにかく『RPGツクールMZ』のコードは数字を生のまま使うことが多くてヘキヘキです、へっきるーん♡ もとい!! 辟易です!

まともかく command101() を見ると、受け取った引数(params)を $gameMessage の[顔]、[背景]、[ウィンドウ位置]、[名前]の設定メソッドに割り振っています。
$gameMessageGame_Message を保持している大域変数。
Window_Message に送るんじゃなくて Game_Message に送るのがなんだか変な感じですが、一旦バッファ(緩衝)領域に入れておくという手法をとると、送る方も受け取る方も相手の都合を気にせずに処理できるというメリットがあって、なかなか悪くない方法というか定番のやり口なのです。

またマンマシンUI(要するに機械対人のやりとり)だと、人間が反応するのを機械が待つ必要があります。
なので、UIに関するものをGame_Message で一本化しておいて、人が応えたら次の表示を行いまた人間の入力待ち、という直線的(シーケンシャル)な処理を行っていくのは理にかなっています。

ここで、次のイベントが401の場合…えー401というのは文章の表示の本体、つまり[文章の表示]コマンドの文章の部分です。
[文章の表示]をはじめ、イベントコマンドでは1コマンドだけど Game_Interpreter では複数のコマンドに分割されているものがいくつかあります。
この401が続く限り Game_Message にデータを追加していきます。

そして、102、103、104…ね、番号書かれても分かんないでしょ。
僕も分かんないんだよっ!!!

番号 説明
102 選択肢の表示
103 数値入力の処理
104 アイテム選択の処理

ですっ!
これらがメッセージの次に出てきたら $gameMessage に追加して、最後にウェイトモードを "message" にする。
ここまでが Game_Interpreter でやることです。

ちなみに、ウェイトモードは updateWaitMode()メソッドでチェックされ、$gameMessage に登録された処理がなくなったところで解除されます。

メッセージウィンドウの更新

『RPGツクールMZ』のウィンドウ文字表示を調べてみた である程度 Window_Message の表示関連は調べたので、ここでは時系列順のまとめを書くことにします。
先に読んでいるか、読まなくてもだいたい知識はある前提で書きますので悪しからず。
特にステートオブジェクト(MV.TextState) の知識は必須です。

ここからはコードを詳細に読んでいきましょう。
『RPGツクールMZ』において、毎フレームの更新処理は update()メソッドで行われます。以下はコメントを加えたコード。

Window_Message.prototype.update = function() {
   this.checkToNotClose(); // 継続の必要があるか調べてウィンドウが開くのを維持
   Window_Base.prototype.update.call( this );  // スーパークラスで枠などアップデート
   this.synchronizeNameBox();  // 名前表示ウィンドウの開閉状態を揃える
   while( !this.isOpening() && !this.isClosing() ) {   // 開閉途中でない場合
       if( this.updateWait() ) {   // 待ち状態ならカウントダウンして更新終了
           return;
       } else if( this.updateLoading() ) { // [顔]画像の読み込み中なら更新終了
           return;
       } else if( this.updateInput() ) {   // 入力待ち状態なら更新終了
           return;
       } else if( this.updateMessage() ) { // メッセージ表示を行ったら更新終了
           return;
       } else if( this.canStart() ) {  // メッセージ文字列があるか
           this.startMessage();    // メッセージの初期化
       } else {
           this.startInput();  // 入力待ち開始
           return;
       }
   }
};

文字表示関連の処理と直接関係ないものが多いのでややこしく見えますが、注目すべきなのは startMessage()updateMessage() だけです。

canStart() ってメソッドで判定して、結局どっちでも start のつくメソッドを呼び出しているので、ここのコードだけ見ると意味不明なのが風情ありますね……ないですね。

メッセージの初期化

メッセージ表示関連ではまず startMessage() が呼ばれます。
以下はコメントを加えたコード。

Window_Message.prototype.startMessage = function() {
    const text = $gameMessage.allText();    // メッセージ文字列の取り出し
    const textState = this.createTextState( text, 0, 0, 0 );    // メッセージ文字列をステートオブジェクトに変換
    textState.x = this.newLineX( textState );   // 行開始位置を決める
    textState.startX = textState.x; // 行開始位置の設定
    this._textState = textState;    // ステートオブジェクトの設定
    this.newPage( this._textState );    // ウィンドウ内容の初期化
    this.updatePlacement(); // ウィンドウ位置の初期化
    this.updateBackground();    // ウィンドウ枠の初期化
    this.open();    // ウィンドウを開く
    this._nameBoxWindow.start();    // 名前ウィンドウを開始
};

要はひたすら初期化をやってる感じです。
ここまでで、メッセージを表示する前段階までできました。

ちなみに open() でウィンドウを開くといっても、アニメーションして開く必要がありますから「ウィンドウを開くアニメーション開始」がより正しい表記と言えます。
update()while( !this.isOpening() && !this.isClosing() ) の部分で isOpening() から true が返ってくるようになるので、しばらくは whileループには入らず、ウィンドウを開くアニメーションをする、というわけです。

ちなみに、開くアニメーション処理は Window_Baseクラスの updateOpen()メソッドでやってます。

メッセージの表示

メッセージ文字列がステートオブジェクトとして設定され、ウィンドウも開きました。
次は updateMessage()メソッドで文字を表示していきます。
_textState が設定されてなかったら、何もせずに false を返すだけのメソッドですが、今は設定されているのでなんかやります。
なにをやるかは、以下のコメントを加えたコードを見てみましょう。

Window_Message.prototype.updateMessage = function() {
    const textState = this._textState;
    if( textState ) {
        while( !this.isEndOfText( textState ) ) { // 文末になるまでループ
            if( this.needsNewPage( textState ) ) {  // 新規ページが必要か
                this.newPage( textState );  // ウィンドウ内容の初期化
            }
            this.updateShowFast();  // ユーザ入力があれば高速表示に
            this.processCharacter( textState ); // 文字の前処理(Window_Base)
            if( this.shouldBreakHere( textState ) ) { // ループ中止の必要があるか
                break;
            }
        }
        this.flushTextState( textState );   // 文字を表示(Window_Base)
        if( this.isEndOfText( textState ) && !this.pause ) {    // 文末かつポーズ状態でなければ
            this.onEndOfText(); // 文末処理
        }
        return true;
    } else {
        return false;
    }
};

needsNewPage() はウィンドウから文字がはみ出るかどうかで判定しています。

shouldBreakHere() 通常の表示状態だとtrueを返すメソッド。
組みになっている文字コード(サロゲートペア)の処理中や、一気に文字を表示する場合は false を返します。
何のことやら分かりにくく、意味やコードを整理して欲しい感ありますが、要は処理対象が単体の文字なら true、連続した文字なら false

processCharacter(), flushTextState(), onEndOfText() については、さらに詳細にみていきましょう。

文字の前処理

まずは processCharacter() です。
これは Window_Message ではなく Window_Base にあるメソッドです。
以下コメントを加えたコードです。

Window_Base.prototype.processCharacter = function( textState ) {
    const c = textState.text[ textState.index++ ];  // 最新の文字をひとつ取り出す
    if( c.charCodeAt( 0 ) < 0x20 ) {    // 文字がコントロールコードの場合
        this.flushTextState( textState );   // 文字を表示
        this.processControlCharacter( textState, c );   // コントロールコードの処理
    } else {    // 通常の文字の場合
        textState.buffer += c;  // バッファに追加
    }
};

メッセージの最新の文字を取り出したあと、基本的には else 側の処理をやるわけですが、すごく単純に buffer に追加してるだけです。
メッセージは buffer に貯めては表示というサイクルを繰り返すんですが、基本的には貯めずに buffer に渡した文字は即表示してます。
そしてまた1フレーム後に update() が実行されて1文字表示されるというのが、いつものメッセージ表示です。

if( c.charCodeAt( 0 ) < 0x20 ) をまともに説明すると長くなるので結論だけですが、これで文字コードがコントロールコードであるという判定になります。
コントロールコードの処理の前に、それまでにバッファに入っていた文字は flushTextState() で表示してしますね。

コントロールコードの前処理

processControlCharacter()メソッドを見る前に、createTextState()メソッドから呼ばれている convertEscapeCharacters()メソッドを見る必要があります。
startMessage() のコメントに「メッセージをステートオブジェクトに変換」とだけ書いて、ちゃらっと流してますが、ここで結構重要な下準備をしてあります。
以下コメントを加えたコードです。

Window_Base.prototype.convertEscapeCharacters = function( text ) {
    /* eslint no-control-regex: 0 */
    text = text.replace( /\\/g, "\x1b" );   // \\ → Esc
    text = text.replace( /\x1b\x1b/g, "\\" );   // EscEsc → \\
    text = text.replace( /\x1bV\[(\d+)\]/gi, ( _, p1 ) =>
        $gameVariables.value( parseInt( p1 ) )  // EscV[n] → 変数の内容
    );
    text = text.replace( /\x1bV\[(\d+)\]/gi, ( _, p1 ) =>
        $gameVariables.value( parseInt( p1 ) )  // EscV[n] → 変数の内容
    );
    text = text.replace( /\x1bN\[(\d+)\]/gi, ( _, p1 ) =>
        this.actorName( parseInt( p1 ) )  // EscN[n] → アクター名
    );
    text = text.replace( /\x1bP\[(\d+)\]/gi, ( _, p1 ) =>
        this.partyMemberName( parseInt( p1 ) )  // EscP[n] → パーティーメンバー名
    );
    text = text.replace( /\x1bG/gi, TextManager.currencyUnit );  // EscG → 通貨単位
    return text;
};

メッセージはJSON文字列として、MapXXX.jsonファイルに格納されているわけですが、その際に制御文字の \ がJSON化されることでさらに加わって \\ となっています。
JSONファイルを直接テキストエディタなどでのぞいて \ の嵐を見たことある人もいるかと思います。
そのままでは流石に扱いづらいので、ここでコントロールコードの Esc に変換しているわけです。

その後は表示中の動作に関わらない制御文字の変換を一通り行って、下準備完了です。

コントロールコードの処理

さていよいよ processControlCharacter()メソッドです。
Window_MessageWindow_Base 2クラスにあるので、一緒に見ておきましょう。
以下コメントを加えたコードです。

Window_Base.prototype.processControlCharacter = function( textState, c ) {
    if( c === "\n" ) {  // 改行なら
        this.processNewLine( textState );   // 行末処理
    }
    if( c === "\x1b" ) {    // Escなら
        const code = this.obtainEscapeCode( textState );    // 制御文字を取り出す(Window_Base)
        this.processEscapeCharacter( code, textState ); // 制御文字の処理(Window_Base)
    }
};
Window_Message.prototype.processControlCharacter = function( textState, c ) {
    Window_Base.prototype.processControlCharacter.call( this, textState, c );
    if( c === "\f" ) {  // FF(フォームフィード)なら
        this.processNewPage( textState );   // ページ末処理
    }
};

という感じなんだけど、[文章の表示]イベントコマンドは、改行ごとに401、ページ切り替えごとに101のオブジェクトとして分割されるので、JSONファイル内で "\n" と "\f" が登場することないんですよね。
それらは Game_MessagenewPage() で"\f" が allText() で"\n"が追加されています。
ただし newPage() の方は戦闘ログとレベルアップメッセージでしか使われてないようです。

つまり『RPGツクールMZ』のメッセージ用データは、次のような変遷をたどっているのです。

JSON文字列 → JSオブジェクト → 文字列の配列 → "\n"連結の文字列

色々都合があるとはいえ、ややこしいですね。

文字の表示

flushTextState()メソッドは最初 flush って言葉からして、画面を光らせるやつだなと思い込んでいたんで、全然そんな処理してなくてかなり混乱しました。
ここでの flush は「さっと流し込む」みたいな意味です。
ちなみにスペルがよく似てる flash も流れると光るの意味があって、ネイティブでない僕には、イマイチ使いわけがわかりません。

それはそれとして flushTextState() コメント付きをどーぞ。

Window_Base.prototype.flushTextState = function( textState ) {
    const text = textState.buffer;  // 文字バッファ(貯めた文字)
    const rtl = textState.rtl;  // アラビア語(右から左に書く)かの真偽値
    const width = this.textWidth( text );   // 表示する文字の幅(ピクセル)
    const height = textState.height;    // 文字の高さ
    const x = rtl ? textState.x - width : textState.x;  // x位置を設定
    const y = textState.y;  // y位置を設定
    if( textState.drawing ) {   // 描画モードか
        this.contents.drawText( text, x, y, width, height );    // 文字の描画
    }
    textState.x += rtl ? -width : width;    // 表示した分位置を進める
    textState.buffer = this.createTextBuffer( rtl );    // バッファを初期化
    const outputWidth = Math.abs( textState.x - textState.startX ); // 出力幅を計算
    if( textState.outputWidth < outputWidth ) { // 現在の幅より広いなら
        textState.outputWidth = outputWidth;    // 幅を更新
    }
    textState.outputHeight = y - textState.startY + height; // 出力高さを計算
};

これちょっと分かりづらいのが、描画モードですね。
文字を表示する必要はないけど計算結果だけ欲しい場合に、drawingfalse に設定して、実行する場合があるのです。
具体的には Window_BasetextSizeEx() メソッドがそれで、文字をセンタリングしたい場合など、先に全体の長さがわかっていないと描画位置が決められないので、こういう仕組みがあるんです。

今あなたの心を読みました!!
「なら[文章の表示]にセンタリングオプションつけてくれよ!!」
ですよねー。
センタリング機能、このように内部では9割ぐらいの処理が作られてるのにエディタの機能として存在しない。
ツクールこーゆーの多いんですよ。機能は多けりゃいいってものでもなくて、機能が増えるとメンテナンスしなきゃいけないことが増え、それが制作コスト、ひいてはソフトの値段に跳ね返ってくるというのも分かりますが…センタリングぐらい欲しいです安西先生(顎をタプタプしながら)。

表示の終了

あらためて updateMessage() の中を見ると、次のようなコードがあります。

        if( this.isEndOfText( textState ) && !this.pause ) {
            this.onEndOfText();
        }

isEndOfText() は、処理の現在位置(index)が末尾に到達したか判定しています。

そして onEndOfText() が呼ばれて終了処理です。
はい、コメント付きのコード。

Window_Message.prototype.onEndOfText = function() {
    if( !this.startInput() ) {  //  次のコマンドが入力系でない
        if( !this._pauseSkip ) {    // 一時停止を飛ばさないなら
            this.startPause();  // 一時停止
        } else {
            this.terminateMessage();    //  メッセージ終了処理
        }
    }
    this._textState = null; // ステートオブジェクトを空に
};

if( !this._pauseSkip ) のところ、なんでわざわざ ! を入れて否定してんのか意味不明だし、「〇〇しない」という否定の意味の skip が識別子に使われているのも輪をかけて分かりづらい。そもそも pause 自体がネガティブ感のある単語だし、どんだけ否定するんだと。
ちなみにプログラムの教本には大抵、識別子には not などの否定語は入れない方がいい、あるいは絶対入れるな、みたいに書いてあります。ツクールェ…。

さ、気を取り直して startPause()するルートですが、その後 updateInput() の方で terminateMessage() が呼ばれるので、一時停止が間に入るだけで最終的な処理は else 側と一緒です。

terminateMessage() はつまりウィンドウを閉じるわけですが、メッセージが連続している場合、閉じるアニメーションが始まる前に次のメッセージが開始されるので、挙動としてはウィンドウはそのまま表示され続ける、ということになります。

演出的には同一人物の発言の時は閉じないで続けて、別の人物に切り替わるタイミングでは閉じたい感じですが、閉じる場合は[文章を表示]と[文章を表示]の間に[ウェイト]を置いて閉じるのを待つという、結構力押しなことをしないといけません。

ちなみに、1フレームで32ピクセルだけウィンドウの高さが減るので、標準のウィンドウ高さ168ピクセルを割ると5.25、端数フレームはないので6フレームで閉じる。
つまり最短で6フレームウェイトを挟めば一度ウィンドウを閉じる動きになります。

まとめ

ややこしい!!

1文字ずつ表示されること、制御文字でポーズやウェイトが入ること、選択肢などのUIコマンドが直後に来た場合は終了するまで待つこと、といった要素が加わっているので、とにかく面倒臭い。
といって『RPGツクールMZ』は文字は一括表示のみで、[文章を閉じる]イベントコマンドを実行するまでウィンドウは表示しっぱなしの仕様ですで通用するかというと、カジュアル開発環境としてはそれはない。

気軽に使えるようにいい感じにメッセージ表示を制御するためには、こんなに大変な苦労(コード)が水面下で動いていたのです。
いやーありがたいですね。ツクールはイベントコマンドでゲームを作るもの、JavaScriptとか使うもんじゃないです(笑)

とはいえ、この記事が皆様のお役に立てば幸いです。

エンジョイ! ツクールライフ!

Discussion