JavaScriptでゲーム作り「15-2:テキストのふりがな表示のプログラム」

前回のテキスト表示を作ってから2年位経って、子どもたち向けにプログラミング講座をする(?)らしい流れに。 それで漢字にふりがな、あったらいいなぁ〜と、久々にコードに追記することになりました

ルビ(ふりがな)の記述と反映のさせ方例

(2021.11.05執筆)

前回のメッセージ表示のコーディングから年数が経ち、今回は久々のコード弄り。
忘れてたこともちらほらありましたが、自分の書いたコラムとコード隣のコメントを見ながら、何とか思い出してくれた。
自分の記したメモ書きが後で役に立つ。これは後の自分に向けてのもの。というのを改めて思い知りました。
やはり久々なので、ちょっと弄ったら100%エラーが返ってくるんですが、、これ何処が間違ってるんだっけ??? っていうときの対処方、を、過去に経験してたのも大きかったか。

プログラムが正しく動いてるかどうか怪しい箇所にconnsole.log(怪しい要素名); を挟んで出力結果を確認してみたり。 そもそもコメントに//(コメントアウト)が抜けててコードが停止したり。。あはは・・・
でもまぁ。一晩で「ふりがな」機能をコードに追記することができたのでよかった、記録を残します。

ふりがなイベント
⇒ ふりがな表示の完成形デモ(スペースキーでアクション)

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

  • ふりがな記法、例えば"鎮魂歌#[3,レクイエム]"のような記述を想定(3は漢字数)
  • 文字列を読み取るコードで"#[]"内の文字を"ふりがな"として認識させる
  • ふりがなの文字列は、ふりがな専用の描画関数で別に処理
  • フォントサイズや位置を調整して、ふりがなとして表示できてたらOK



とりあえず、テキストウィンドウ関連のクラスの、以下の部分に追記したら良さそうでした。
文字列の描画が、"通常文字"と"ふりがな"で場合分けしないといけないのが、ひと手間。ここさえ上手く処理分けできたら、だいたいOK。

メッセージウィンドウクラスにふりがなの要素を追記した


class MessageWindow extends WindowUI {//メッセージウィンドウの管理と会話送り
    constructor(allMessages) {
        super(['messageWindow']);
        this.color = "#555"; //テキスト色の初期値
        this.size = 16; //テキストサイズの初期値
        this.fontStyle = "sans-serif"
        this.lineHeight = this.size * 1.6; //段落の高さ。フォントサイズに対する高さ比で算出
        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 = 16; //フォントサイズも初期化
        this.rubySize=9; //ふりがなのフォントサイズ
        this.rubyX = null; //ふりがなを宛てる漢字は何文字?というのを保持するための要素,,,ふりがな描画するモードかどうかの判定も兼ねる
        this.ruby = 0; //ふりがなのひらがなの文字数をカウント。
        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文字ずつ追加するメソッド
        if(!this.rubyX) { //ふりがな描画モードでないなら、通常の文字の処理
          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; this.rubySize += 1; } /* "#<"でフォントサイズを3大きく、ふりがなを1大きくする*/
            else if(char2 === ">") { this.size -= 3; this.rubySize -= 1; } // "#>"でフォントサイズを3小さく、ふりがなを1小さくする
            else if(char2 === "=") { this.size = 15; this.rubySize = 6; } // "#="でフォントサイズを元の大きさにする
            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.textNumber++; //ふりがなを宛てる開始位置(ふりがなモードON) 例:振仮名#[3,ふりがな]という記述を想定している
                const char3 = this.currentMessage.charAt(this.textNumber); //[の次は、ふりがな宛て先の「漢字文字数」が指定されている。この要素は数字。!isNaN(char)
                this.rubyX = char3; this.textNumber++; //以後、今回のふりがなモードではthis.rubyXが漢字(宛先)の文字数を表す
            }
            else if(char2 === "#") {this.render(context, char2);} //##で続く場合、2文字目の#を普通に描画する
          }
          else {this.render(context, char);} //#でないなら、取り出した1文字を普通に描画する
          this.textNumber++; //描画処理が終わったら、表示テキスト番号を次のカウントに振る
       }
       else if(this.rubyX) {//ふりがな描画モードなら、ふりがな専用の処理を適用
       let rubyStr = ""; //rubyStrという要素に、先の"ふりがな"文字列を格納する
         for(let count = 0; count < 31; count++) { //さすがに31文字以上の「ふりがな」は無いだろう。"]"で処理を終了するし
           const char = this.currentMessage.charAt(this.textNumber);
           this.textNumber++;
           if(char === "]") {break;} //"]"でふりがなモードを終了。それ以外ならふりがな一文字を追加して次のふりがなへ
           rubyStr += char; this.ruby++;
           } 
         this.renderRuby(context, rubyStr); //ふりがなを描画するメソッド  
         this.rubyX = false; this.ruby = 0; //描き終わった後の、ふりがなモードを終了(リセット)するための記述
       }
    }

    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; //表示テキスト幅に現在の文字の幅分を追加
    }

    renderRuby(context, char) { //ふりがなを描画するメソッド
        context.font = `${this.rubySize}px ${this.fontStyle}`;
        context.fillStyle = `${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.windowX + 20 + this.nowTextWidth + rubyRelativeX; //テキスト描画位置のX座標 =「枠の左端から20離す」に、これまでのテキスト幅をカウントし、調整幅分だけ左にずらす
        const textY = this.windowY + 30 + this.lineHeight * this.line + this.heightPlus - this.size + 1; // Y座標 =「枠の上端から30開ける」に、現在の段落*段落分の高さをカウント
        context.fillText(char, textX, textY ); //テキスト描画の関数本体、ふりがな用に描画位置を調整している
    }

    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--;}
    }
}

とりあえず、調整を施してこのようなバランスに落ち着きました。
このクラスをゲームエンジンに記述すれば、アクター側でメッセージウィンドウを呼び出すときに、此処で設定したふりがな専用の記法を用いることで、楽にふりがな表示できるようになりました。

試しに、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 = ['この世界#[2,せかい]のこと、誰かが決まり文句のように言うの。', 'ようこそ始まりの空間#[2,くうかん]へ。「Hettom fWorld」と aaaaaaaaaaaaaaaaaaaaaああああああああああああああああああああああああああああああああああああああああああああああ#[2,ああああああ]あああああああああああああああああああああああああああああ…', '※これは会話テストです#.#.']; break;
            case 1 : messages = ['ふいーーーーーー、よく#^ネ#v#vた#^わ。#nすっきりお目ざめかしら。','すてきなミカンがいっぱいふってくる夢をみたわ。たべほうだい。#nふゆはこたつでぐー0…', '※これは会話テストです#.#.']; break;
            case 2 : messages = ['こんにちは。あのーなにしてるんですか? どなたさまですか?','わたしは#rおねむ#bよ、#rねむねむ#bよ。まっくらすやぁってぐっすりねるの。', 'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#※これは#=会話テスト!#.#.']; break;
        }
        const messageWindow = new MessageWindow(messages);
        this.dispatchEvent('openWindow',  new Event(messageWindow));
        this.talkNumber++;
    }
}


...


実際にコード自分で弄ってみないと意味不明なんだ。 弄ってみたら何となく分かる。たぶん。方法は他にも在ると思いますが、私はこのやり方で参ります。導入成功、やっったね。


ふりがなイベント
⇒ ここまでのふりがな表示のデモ(スペースキーでアクション)




次回は、会話ウィンドウに立ち絵や名前を表示させたりと、オプションも追加していきますか。
JavaScriptでゲーム作り「16:会話時の立ち絵&名前表示」


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

すぺしゃるさんくす

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


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