JavaScriptでゲーム作り「4:NPCと会話する」

NPCと会話する記述に挑戦

(2019.1.24執筆)

引き続きNPCとの会話に挑戦です。前回、プレイヤーが8方向にアクション[playerAction]を起こせるようになりました。次にプレイヤーのアクションを受け取って会話するNPCを定義し、作成します。

今回も、古都さんのシューティングゲームの記事その2を参考にしながら進めます。

参考 ⇒ JavaScriptで弾幕シューティングゲームをフルスクラッチで作ってみよう、その2


NPCがアクションに触れたとき、会話イベントを発生させる。という記述に応用しましょう! 敵機のクラスを参考に、新たなNPCクラスを作成していきます。


NPCキャラ
NPC用のキャラシート、今回もぴぽやさんの素材より、使わせていただきます(' '*)

class NPCキャラの作成


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), new Rectangle(32, 0, 32, 32));
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, sprite, hitArea, ['npc']);

        this.maxHp = 999;
        this.currentHp = this.maxHp;

        
        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('playerAction')) {
               this.currentHp--;
               this.dispatchEvent('changehp', new GameEvent(this));
           }
        });
    }

    update(gameInfo, input) {
    }
}

NPCクラスは試しにプレイヤークラスの下に追加します。 画像の当てはめ方はプレイヤーを参考に、その他イベントを受け取るなどの記述はEnemyクラスを参考に

        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('playerAction')) {〜
↑の記述で、このNPCが[playerAction]と重なったときのイベントを書き込むことができる。

class EnemyHpBar を NPC用に調整

一応、作動するかどうかの確認で、EnemyHpBarを利用してみます。npc用に書き換えてみましょう。

class EnemyHpBar extends Actor {
    constructor(x, y, npc) {
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(x, y, hitArea);

        this._width = 200;
        this._height = 10;
        
        this._innerWidth = this._width;

        
        npc.addEventListener('changehp', (e) => {
            const maxHp = e.target.maxHp;
            const hp = e.target.currentHp;
            this._innerWidth = this._width * (hp / maxHp);
        });
    }
constructor(x, y, enemy)だった箇所をconstructor(x, y, npc)に。
enemy.addEventListenerだったところを、npc.addEventListenerに修正。

しかしこのHpBar、なぜ小文字のenemyだったりnpcが必要なのかは判らないです...(' '*);;;

class DanmakuStgMainScene extends Scene

準備ができたので、シーンに追加するenemyをNPCに置き換えてみます。

class DanmakuStgMainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const npc = new NPC(150, 100);
        const hpBar = new EnemyHpBar(50, 20, npc);
        this.add(npc);
        this.add(hpBar);
        this.add(new Player(150, 200) );
    }
}

HpBarの記述で、new EnemyHpBar(50, 20, npc);
最後にnpcを記してるところが注意点。

danmaku.jsの最後の方の記述にnpc1の画像を追加

assets.addImage('sprite', 'sprite.png');
assets.addImage('player', 'chara_player.png');
assets.addImage('npc1', 'chara_npc1.png'); //ここを追加
あ、そうそうdanmaku.jsの最後で、新しいnpc画像をアセット[]に追加するのも忘れずに。

これでおおよそ大丈夫なはずです。
デモを確認してみましょう。

(NPCとご対面)

NPCとご対面
⇒ ここまでのデモを開く




。。。


すり抜けますね。。。(o _ o。)
スピリット相手であれば、これでOKです。

しかし基本は生身の人間である。 ならば、HIT判定で「これ以上進めない」動作を組み込まねばなるまい。

いろいろ試してみる。して、Playerクラスの基本部分に以下の記述を追加した。

danmaku.js ⇒ Playerクラス constructor内


        this._speed = 2;
        this._velocityX = 0;
        this._velocityY = 0;
        this.walkCount = 0;
        this._interval = 5;
        this._timeCount = 0;
        this._dir = 0; //プレイヤーの向き
        this._dirGo = 0; //進む方角

//↓ ここから追加分
        const rect = this.sprite.rectangle;
        this.addEventListener('hit', (e) => {
            if(!e.target.hasTag('playerAction')) {
                if(e.target.x > this.x && Math.abs(this.x - e.target.x) > rect.width/2 ) { this.x -= this._speed; }
                if(e.target.y > this.y && Math.abs(this.y - e.target.y) > rect.height/2 ) { this.y -= this._speed; } 
                if(e.target.x < this.x && Math.abs(this.x - e.target.x) > rect.width/2 ) { this.x += this._speed; }
                if(e.target.y < this.y && Math.abs(this.y - e.target.y) > rect.height/2 ) { this.y += this._speed; }
            }
        });
意味としては、'playerAction'のタグを含まないオブジェクトの当たり判定とぶつかった時、反対側にバックする。という感じです。 'playerAction'タグも当たり判定に入れちゃうと、自身のアクションでバックする変な仕様になりましたよ。なので、if(!e.target.hasTag('playerAction'))回避策。

いつか他のオブジェクトとの兼ね合いで不具合起こりそうな感じもありますが、ひとまずこれでOKとします。 しかしこれ、どこかで上手く応用できそうです。射撃の反動とか。

danmaku.js ⇒ メッセージウィンドウを追加する


class MessageWindow extends Actor {
    constructor() { 
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(0,0,hitArea);

        this.addEventListener('textEnd', (e) => { this.destroy(); })
    }
    render(target) {
        const context = target.getContext('2d');
        context.fillStyle = 'rgba(255,255,255,0.99)'; //塗りつぶす色は白';
        context.strokeStyle = 'rgba(125,125,255,0.99)'; //枠は青っぽい
        const windowX = 2;
        const windowY = 384 - 102;
        context.fillRect(windowX, windowY, 508, 100);
        context.strokeRect(windowX, windowY, 508, 100);
    }
}
メッセージウィンドウクラスを新しく追加します。 追加位置は、テキストラベルクラスの下くらいが良いんじゃないかな。 意味は、だいたい察してください。


これで、会話イベントが起こった時に spawnActor()でメッセージウィンドウと台詞(テキストラベル)を呼び出す。 台詞が終了した時に、メッセージウィンドウと共に会話の表示を破棄する...といったアイデアを実現できそうです。


少し、デザインの方を確認してみます。
(シーンに先ほどのメッセージ枠をテスト追加してます。)

NPCとご対面
⇒ ここまでのデモを開く

(メッセージウィンドウの表示 & 衝突判定チェック)


いい感じでしょうか?


では、もう使わないhpBarとenemyクラスを削除して、コードをすっきりさせつつ。 いよいよ、会話文を実装してみましょう。

NPCの会話文を追加する

        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('playerAction')) {〜
↑の記述で、このNPCが[playerAction]と重なったときのイベントを書き込むことができる。


ここに、会話が起こった時に先ほどのメッセージウィンドウとテキストラベルを追加する命令を加えます。 そうですね、どうやって終了時に消すか判らんけど、とりあえずやってみましょう。

        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('playerAction')) {
               this.spawnActor( new MessageWindow() );
               this.spawnActor( new TextLabel(30, 320, 'こんにちは') );
               
           }
        });

あ。。。いけない。

テキストラベルクラスの文字色が白のままやと、白いメッセージウィンドウ上では見えません。 danmaku.jsの最初にあるテキストラベルクラスを、台詞にも使いまわせるよう修正します。

danmaku.js ⇒ class TextLabel extends Actorの修正


class TextLabel extends Actor { 
    constructor(x, y, text, color, size) {
        if (color===undefined) color="#555";
        if (size===undefined) size = 16;
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(x, y, hitArea);        
        this.text = text;
        this.color = color;
        this.size = size;
    }

    render(target) {
        const context = target.getContext('2d');
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = `${this.color}`;
        context.fillText(this.text, this.x, this.y);
    }
}
このクラスに凡庸性を持たせるための工夫。

まずnew TextLabel (x, y, text, color, size)で、最大5つの要素を読み込める形にします。 具体的には文字開始のx座標、y座標、テキスト内容、そして文字色。最後に文字の大きさ。
そして台詞での使い回しを前提にするので、できるだけnew()の中身は省略したい。 そこで基本の文字色を黒っぽい、大きさを16pxに設定する記述を加えました。

↓ この部分、色やサイズを指定しないと、"#555"(濃いグレー色)と16pxを適応。

class TextLabel extends Actor { 
    constructor(x, y, text, color, size) {
        if (color===undefined) color="#555";
        if (size===undefined) size = 16;

そして、指定した色やサイズを描画時に代入する際。

        const context = target.getContext('2d');
        context.font = '12px sans-serif'; //ここは削る
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = `${this.color}`;
式を代入する形になるので、'〜〜'ではなく`${}`で括らなければなりません。 全体のククリがなぜか、'〜〜'ではなく、`〜〜`に変化しています。すっごい細かいところです、知らんとひっかかかる。自分もつまづきました(o _ o。)


この辺りの注意点は、古都さんの基礎編のコラムにも書かれてます。必読。
https://sbfl.net/blog/2017/12/14/javascript-programming-1/

(文字列を値に埋め込むというページの真ん中あたり)



と、話が横にそれました。テキストラベルクラスの修正はこんな感じにとりあえず。 あとは、既に使われてあるテキストラベルの追加分を修正。タイトルシーンで使われてる文字です。

class DanmakuStgTitleScene extends Scene {
    constructor(renderingTarget) {
        super('タイトル', 'black', renderingTarget);
        const title = new TextLabel(100, 200, 'RPG製作',"white",25);
        this.add(title);
    }
あ、テキストや文字色などは、'〜〜'とか"〜〜"とかで括らないとエラー起こします、ここも注意ですね。(やらかしたやつ)

NPCとご対面
⇒ それでは、デモを確認してみましょう。


(話しかける&会話表示のチェック)


とりあえず表示されました。どうやって消すかは知らんが。
あと、会話表示中にプレイヤーが動けてしまうバグも。

順番に対策を考えていくか。

行き詰まりました

。。。(o _ o。)

この描き方だと、メッセージ文を制御できません。
プレイヤークラス側の操作で全てを動かす、という概念が働いてる限り、無理なようです。

どうしようもないとき、自分の中に無い...全く新しい視点を取り入れる必要がある。 ⇒ 聞いた方が確実...と、思いきって古都さんに質問してみました。


。。。貴重なヒントを頂けました。ありがとうございます><
RPG製作は不慣れだとお聞きしましたが、真剣に考えてくださった会話表示の設計概念、此処でしっかりまとめていきたいと思います。

会話の設計概念(頂いたアドバイスより)

  • メッセージウィンドウは複数の台詞を保持する
  • ボタン押す度にセリフ1->セリフ2->セリフ3と切り替わる
  • セリフ3のあとはウィンドウが消える
  • MessageWindowクラスにメッセージの配列を渡すようするとよい
  • 何ページ目が表示されてるか?の要素を持たせる
  • 表示中のセリフ番号に応じたテキストを描画する
  • 全てのセリフが表示された後に、textEndイベントを発生
  • textEndイベントによって、メッセージウィンドウを閉じる

メッセージウィンドウに関してこのような感じでした。
次に、メッセージウィンドウの表示と、コマ送りと、終わって閉じる扱いについて。

  • ゲームプログラムは、基本それぞれのオブジェクトが独立して動くのが大事。互いを監視せずに
  • なぜかというと収拾がつかなくなるから。コード関係が複雑になるので簡単に計画が崩壊する
  • 「自分のことは自分だけでやる。他のActorに直接は頼らない!(間接的には頼る)」というのがわりと重要
  • 頼るときはイベント発行してどうにかしてもらう感じ
  • よってMessageWindowも「自分でキーボード入力を受け取って自分でメッセージを進める」というのがいい
  • 会話表示中にプレイヤーを止めるのは、普通には難しい
  • 暫定的な対処法として、MainScene側でなんとかする手がある

いただいたアドバイス、私が書き加えたコードの細部に渡るまで気にかけてくださっている。 とてもとても貴重な言葉をいただけて、ありがたい気持ちです(o _ o。)

教わったとおりに、あせらず、ゆっくり、1つずつ、見ていきましょう。
先は長い。地道に進むのが安全、確実。ココ一番のアドバイス(' '*)

メッセージウィンドウに会話文の配列を持たせる

頂いたアドバイスを元に、メッセージウィンドウを改良します。複数の会話文を配列で持たせる仕様にして、このクラス自身でテキストを描画するようにします。。

new MessageWindow(['こんにちは', 'この世界のこと、誰かが決まり文句のように言うの。', 'ようこそ、始まりの空間へ。"Hello World" と']);

MessageWindowクラスの修正


class MessageWindow extends Actor {
    constructor(messages, color, size) {
        if (color===undefined) color="#555";
        if (size===undefined) size = 16;
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(x, y, hitArea);

        this.color = color;
        this.size = size;

        // 渡されたメッセージを保持する
        this.messages = messages;

        // 今表示するべきメッセージ
        // メッセージの先頭を取り出す
        this.currentMessage = this.messages.shift();

        //会話終了時にウィンドウを破棄。
        this.addEventListener('textEnd', (e) => { this.destroy(); })
    }

    update(gameInfo, input) { //セリフを次の表示にする記述
        if(input.getKey(' ')) {
        this.currentMessage = this.messages.shift(); // 次のメッセージ取り出す

        // もう空っぽだったらtextEndイベントを発生させる
        if(this.currentMessage === undefined) {
            this.currentMessage = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
            this.dispatchEvent('textEnd'); // textEndイベントを発行
        }
    }

    //ウィンドウ枠を画面下に描画
    render(target) {
        const context = target.getContext('2d');
        context.fillStyle = 'rgba(255,255,255,0.99)'; //塗りつぶす色は白';
        context.strokeStyle = 'rgba(125,125,255,0.99)'; //枠は青っぽい
        const windowX = 2;
        const windowY = 384 - 102;
        context.fillRect(windowX, windowY, 508, 100);
        context.strokeRect(windowX, windowY, 508, 100);

        //テキスト描画もMessageWindowが自分でやると楽
        //メッセージウィンドウの描画 -> テキストの描画、の順に書かないとテキストがウィンドウで上書きされるので注意
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = `${this.color}`;
        context.fillText(this.currentMessage, windowX + 20, windowY + 30);
    }
}
配列のshiftメソッドについては以下を参考にする
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/shift



NPC側も、メッセージウィンドウクラスの仕様に合わせて修正

NPCクラスの修正


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), new Rectangle(32, 0, 32, 32));
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, sprite, hitArea, ['npc']);
        
        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('playerAction')) {
               this.spawnActor( new MessageWindow(['こんにちは', 'この世界のこと、誰かが決まり文句のように言うの。', 'ようこそ、始まりの空間へ。"Hello World" と']) );               
           }
        });
    }

    update(gameInfo, input) {
    }
}
NPCとご対面
⇒ それでは、デモを確認してみましょう。


(話しかける&会話表示のチェック)


ふむ、形としてはOKです。 話しかけた後にプレイヤーを移動しないと、キーを押す度に新しい会話ウィンドウが立ち上がって、延々とこんにちはを繰り返しますが。。。


なるほど、ここで会話中にプレイヤーがキー入力を受け付けないよう、切り替えを行う必要があるみたいです。

会話文、プレイヤー入力の切り替えをメインシーンから制御

あとは表示するときにプレイヤーを止めるのですが、これは普通に難しいですね。
とりあえず暫定的な対処法として、MainScene側でなんとかするという手があります。
NPC側でメッセージウィンドウを作るのをやめて、NPCは「メッセージリクエストイベント」を発行するだけにします。

// NPC側。this.spawnActor(new MessageWindow(['ハロー!'])); をやめて
this.dispatchEvent('messagewindowrequest', ['ハロー!']); // にする

それでMainScene側でこれを受け取って、プレイヤーの動きを止めると、とりあえずは動作すると思います。

頂いたアドバイスを元に、メインシーンに手を加えていきます。

MainSceneのconstructorを修正


class DanmakuStgMainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const npc = new NPC(150, 100);
        const player = new Player(150, 200);
        this.add(npc);
        this.add(player);

        // MainSceneのconstructorの中で
        this.actors.forEach((actor) => actor.addEventListener('messageWindowRequest', (event) => {
            // この場合のeventの中身はメッセージ配列です
            const messageWindow = new MessageWindow(event);

            // プレイヤーの動きを止めます、isActiveみたいな変数をPlayerに用意しておくと良いかもです
            // そしてそれをfalseに切り替えて、あとはPlayer側でif(isAcitve)ならキーボードで動くようにする……とか
            player.isActive = false;

            // そしてメッセージが終わったらプレイヤーをactiveに戻す処理を登録
            messageWindow.addEventListener('textEnd', (e) => player.isActive = true);
            // やっとシーンに追加
            this.add(messageWindow);
        }));
    }
}
ほぼ、頂いた内容そのまま記載してます。

NPC側の会話リクエストを修正


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), new Rectangle(32, 0, 32, 32));
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, sprite, hitArea, ['npc']);
        
        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('playerAction')) {
               this.dispatchEvent('messageWindowRequest', ['こんにちは', 'この世界のこと、誰かが決まり文句のように言うの。', 'ようこそ、始まりの空間へ。"Hello World" と'] );               
           }
        });
    }
}

PlayerクラスにisActiveを追加し、キー入力受付を調整


class Player extends SpriteActor {
    constructor(x, y) {
        〜〜
        〜〜
        this._dir = 90 * Math.PI/180; //プレイヤーの向き...角度(ラジアン)の単位。。角度°にπ/180をかけた値
        this._dirGo = 90 * Math.PI/180; //進む方角...初期は上向き
        this.isActive = true; //この行を追加

    update(gameInfo, input) {
        this._velocityX = 0;
        this._velocityY = 0;
        const rect = this.sprite.rectangle;

        〜〜
        〜〜
        〜〜
        
    if(this.isActive) {//isActive = trueのときのみ、移動やアクションができる
        //矢印キーを押してる間、歩行カウントをすすめて、進む方角に応じた移動距離を計算
        if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
            this.walkCount += this._speed;
            this._velocityX += this._speed * Math.cos(this._dirGo);
            this._velocityY += -this._speed * Math.sin(this._dirGo);
        }
        〜〜
        〜〜
        〜〜
    }//if(isActive)
会話中はプレイヤー動作が無効。こんなものでしょうか。。
確認してみましょう(o _ o。)

NPCとご対面
⇒ それでは、デモを確認してみましょう。


(話しかける&会話表示のチェック)


素晴らしい!!!
念願の会話シーンが実現しました。

テキストの改行、会話イベントの場合分け

(2019.1.27執筆)

前回で無事に会話イベントの基本的な流れを記述できました。予想以上に長くなりましたね。。。 あとはメッセージ表示の改行だったり、話しかけたNPCがこっちを向くなどの調整を試みたいと思います。

まずはテキストの改行、折り返しの導入からやっていきます。

class MessageWindow ⇒ render(target)のカスタマイズ


    //ウィンドウ枠を画面下に描画
    render(target) {
        const context = target.getContext('2d');
        context.fillStyle = 'rgba(255,255,255,0.99)'; //塗りつぶす色は白';
        context.strokeStyle = 'rgba(125,125,255,0.99)'; //枠は青っぽい
        const windowX = 2;
        const windowY = 384 - 102;
        context.fillRect(windowX, windowY, 508, 100);
        context.strokeRect(windowX, windowY, 508, 100);

        //テキスト描画もMessageWindowが自分でやると楽
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = `${this.color}`;

        //"\n"や、テキスト幅がtextWidthを超えると、自動的に改行する仕組み。
        const textWidth = 472, lineHeight = 1.28;
        const column =['']; let line = 0;
        //現在の行に1文字ずつ追加してテキスト幅の確認、"\n"の箇所か、一定以上の幅になったときに改行
        for (let i=0; i < this.currentMessage.length; i++) {
            const char = this.currentMessage.charAt(i)
            if( char === "\n" || context.measureText(column[line] + char).width > textWidth) { line++; column[line] =''; }
            column[line]+= char;
        }

        //1行毎にテキストを描画
        for (let j=0; j < column.length; j++) {
        context.fillText(column[j], windowX + 20, windowY + 30 + this.size * lineHeight * j);
        }
    }
テキスト部分の描画方法について、改行の仕組みを取り入れました。
参考リンク ⇒ http://q.hatena.ne.jp/1401414096


では、NPCちゃんに会話テストさせてみましょう。

NPCクラスに会話文を追加


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), new Rectangle(32, 0, 32, 32));
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, sprite, hitArea, ['npc']);
        
        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('playerAction')) {
               this.dispatchEvent('messageWindowRequest', ['こんにちは', 'この世界のこと、誰かが決まり文句のように言うの。', 'ようこそ、始まりの空間へ。\n"Hello World" と aaaaaaaaaaaaaaaaaaaaaああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ', '※これは会話テストです'] );
           }
        });
    }

    update(gameInfo, input) {
    }
}
NPCとご対面
⇒ デモを確認してみましょう。



無事に改行されるようになりましたね。


話しかけた時にNPCの向きを変える

では次に、話しかけた方向でNPCちゃんが向きを変える記述に挑戦。
前回の、プレイヤーがオブジェクトにぶつかった時に、これ以上進まないコードが応用できそうですね。

プレイヤーアクションのxとy座標を求めて、NPCの座標と比較させるか??いやいや。ちょっと複雑だ。。

(考え中)


そうか、プレイヤーのdir要素をアクションに引き継がせればいい! そしたらNPC側からhit時に読み取れて、アクションの方向に応じて場合分けできます。やってみましょう。

PlayerActionクラスに dir要素を組み込む


class PlayerAction extends Actor {
    constructor(x, y, dir) { //dirを追加
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, hitArea, ['playerAction']);
        this._dir = dir; //ここも追加
    }

Playerクラス ⇒ update(gameInfo, input) 内...アクション部分に追記


        //スペースキーでアクション発生
        if(input.getKeyDown(' ')) {
            const actorX = this.x + rect.width * Math.cos(this._dir);
            const actorY = this.y - rect.height * Math.sin(this._dir);
            this.spawnActor( new PlayerAction(actorX, actorY, this._dir) );//, this._dirを追加
        }
    }//if(isActive)の中に入れ込むこと!
これで、アクション要素にプレイヤーのdirが引き継がれます。 あとは、NPC側でhit時に読み取る記述をします。

NPCクラスのカスタマイズ


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), new Rectangle(32, 0, 32, 32));
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, sprite, hitArea, ['npc']);

        const rect = this.sprite.rectangle;
        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                if(e.target._dir == 90 * Math.PI/180 ) {rect.y = 0;} //上向きのとき、この子下向く
                if(e.target._dir == -90 * Math.PI/180) {rect.y = 96;} //下向きのとき、この子上向く
                if(e.target._dir == 0 * Math.PI/180 || e.target._dir == 45 * Math.PI/180 || e.target._dir == -45 * Math.PI/180 ) {rect.y = 32;}
                if(e.target._dir == 180 * Math.PI/180 || e.target._dir == 135 * Math.PI/180 || e.target._dir == -135 * Math.PI/180 ) {rect.y = 64;}

                this.dispatchEvent('messageWindowRequest', ['こんにちは', 'この世界のこと、誰かが決まり文句のように言うの。', 'ようこそ、始まりの空間へ。\n"Hello World" と aaaaaaaaaaaaaaaaaaaaaああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ', '※これは会話テストです'] );
            }
        });
    }

    update(gameInfo, input) {
    }
}
NPCとご対面
⇒ ここまでのデモを確認してみる。



NPCの向きも変わりました。基本的な会話機能がやっと実装できました!


【目次】
  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/
古都さん
的確なアドバイス、大きな助けとなりました。ありがとうございます><


プレイヤーキャラ
ぴぽやさん、NPCの素材も使わせて頂きました。ありがとうです!




尚、ここまでの差分ファイルは、プレイヤー画像chara_npc1.pngの追加と、danmaku.jsの修正のみです。

danmaku.js修正後のソースコード

'use strict';

class TextLabel extends Actor { 
    constructor(x, y, text, color, size) {
        if (color===undefined) color="#555";
        if (size===undefined) size = 16;
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(x, y, hitArea);        
        this.text = text;
        this.color = color;
        this.size = size;
    }

    render(target) {
        const context = target.getContext('2d');
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = `${this.color}`;
        context.fillText(this.text, this.x, this.y);
    }
}

class MessageWindow extends Actor {
    constructor(messages, color, size) {
        if (color===undefined) color="#555";
        if (size===undefined) size = 16;
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(0, 0, hitArea);

        this.color = color;
        this.size = size;

        // 渡されたメッセージを保持する
        this.messages = messages;

        // 今表示するべきメッセージ
        // メッセージの先頭を取り出す
        this.currentMessage = this.messages.shift();

        //会話終了時にウィンドウを破棄。
        this.addEventListener('textEnd', (e) => { this.destroy(); })
    }

    update(gameInfo, input) { //セリフを次の表示にする記述
        if(input.getKeyDown(' ')) {  //スペースキーを押した時に
            this.currentMessage = this.messages.shift(); // 次のメッセージ取り出す

            // もう空っぽだったらtextEndイベントを発生させる
            if(this.currentMessage === undefined) {
                this.currentMessage = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
                this.dispatchEvent('textEnd'); // textEndイベントを発行
            }
        }
    }

    //ウィンドウ枠を画面下に描画
    render(target) {
        const context = target.getContext('2d');
        context.fillStyle = 'rgba(255,255,255,0.99)'; //塗りつぶす色は白';
        context.strokeStyle = 'rgba(125,125,255,0.99)'; //枠は青っぽい
        const windowX = 2;
        const windowY = 384 - 102;
        context.fillRect(windowX, windowY, 508, 100);
        context.strokeRect(windowX, windowY, 508, 100);

        //テキスト描画もMessageWindowが自分でやると楽
        //"\n"や、テキスト幅がtextWidthを超えると、自動的に改行する仕組み。
        const textWidth = 472, lineHeight = 1.28;
        const column =['']; let line = 0;
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = `${this.color}`;

        //テキストを1文字ずつ追加して幅の確認、"\n"の箇所か、一定以上の幅になったときに改行
        for (let i=0; i < this.currentMessage.length; i++) {
            const char = this.currentMessage.charAt(i)
            if( char === "\n" || context.measureText(column[line] + char).width > textWidth) { line++; column[line] =''; }
            column[line]+= char;
        }

        //1行毎にテキストを描画
        for (let j=0; j < column.length; j++) {
        context.fillText(column[j], windowX + 20, windowY + 30 + this.size * lineHeight * j);
        }
    }
}


class Player extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('player'), new Rectangle(32, 96, 32, 32));
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea);
        
        this._speed = 2;
        this._velocityX = 0;
        this._velocityY = 0;
        this.walkCount = 0;
        this._interval = 5;
        this._timeCount = 0;
        this._dir = 90 * Math.PI/180; //プレイヤーの向き...角度(ラジアン)の単位。。角度°にπ/180をかけた値
        this._dirGo = 90 * Math.PI/180; //進む方角...初期は上向き
        this.isActive = true;

        const rect = this.sprite.rectangle;
        this.addEventListener('hit', (e) => {
            if(!e.target.hasTag('playerAction')) {
                if(e.target.x > this.x && Math.abs(this.x - e.target.x) > rect.width/2 ) { this.x -= this._speed; }
                if(e.target.y > this.y && Math.abs(this.y - e.target.y) > rect.height/2 ) { this.y -= this._speed; } 
                if(e.target.x < this.x && Math.abs(this.x - e.target.x) > rect.width/2 ) { this.x += this._speed; }
                if(e.target.y < this.y && Math.abs(this.y - e.target.y) > rect.height/2 ) { this.y += this._speed; }
            }
        });
    }
    
    update(gameInfo, input) {
        this._velocityX = 0;
        this._velocityY = 0;
        const rect = this.sprite.rectangle;

        //矢印キーを押しただけの時に、プレーヤーの向きを変える。
        if(!input.getKey(' ')) {
            if(input.getKey('ArrowUp')) { rect.y = 96; this._dir = 90 * Math.PI/180;} //上
            if(input.getKey('ArrowRight')) { rect.y = 64; this._dir = 0 * Math.PI/180;} //右
            if(input.getKey('ArrowDown')) { rect.y = 0; this._dir = -90 * Math.PI/180;} //下
            if(input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 180 * Math.PI/180; } //左
            if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = 45 * Math.PI/180;} //右上
            if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = -45 * Math.PI/180;} //右下
            if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = -135 * Math.PI/180;} //左下
            if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 135 * Math.PI/180;} //左上
        }

        //進む方角の設定
        if(input.getKey('ArrowUp')) { this._dirGo = 90 * Math.PI/180;} //上
        if(input.getKey('ArrowRight')) { this._dirGo = 0 * Math.PI/180;} //右
        if(input.getKey('ArrowDown')) { this._dirGo = -90 * Math.PI/180;} //下
        if(input.getKey('ArrowLeft')) { this._dirGo = 180 * Math.PI/180; } //左
        if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { this._dirGo = 45 * Math.PI/180;} //右上
        if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { this._dirGo = -45 * Math.PI/180;} //右下
        if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { this._dirGo = -135 * Math.PI/180;} //左下
        if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { this._dirGo = 135 * Math.PI/180;} //左上

    if(this.isActive) {//isActive = trueのときのみ、移動やアクションができる
        //矢印キーを押してる間、歩行カウントをすすめて、進む方角に応じた移動距離を計算
        if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
            this.walkCount += this._speed;
            this._velocityX += this._speed * Math.cos(this._dirGo);
            this._velocityY += -this._speed * Math.sin(this._dirGo);
        }

        //歩行カウントが一定以上に達した時、歩くモーションを変化
        if(this.walkCount > 0) { rect.x = 64; }
        if(this.walkCount > 16) { rect.x = 32; }
        if(this.walkCount > 32) { rect.x = 0; }
        if(this.walkCount > 48) { rect.x = 32; this.walkCount = -15; }
        
        //立ち止まった時に直立姿勢
        if(input.getKeyUp('ArrowUp') || input.getKeyUp('ArrowDown') || input.getKeyUp('ArrowRight') || input.getKeyUp('ArrowLeft')) {rect.x = 32; this.walkCount = 0;}
        
        //移動を反映させる
        this.x += this._velocityX;
        this.y += this._velocityY;

        const boundWidth = gameInfo.screenRectangle.width - this.width;
        const boundHeight = gameInfo.screenRectangle.height - this.height;
        const bound = new Rectangle(this.width, this.height, boundWidth, boundHeight);
        
        if(this.isOutOfBounds(bound)) {
            this.x -= this._velocityX;
            this.y -= this._velocityY;
        }
        
        //スペースキーでアクション発生
        if(input.getKeyDown(' ')) {
            const actorX = this.x + rect.width * Math.cos(this._dir);
            const actorY = this.y - rect.height * Math.sin(this._dir);
            this.spawnActor( new PlayerAction(actorX, actorY, this._dir) );
        }
    }//if(isActive)
    }
}

class PlayerAction extends Actor {
    constructor(x, y, dir) {
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, hitArea, ['playerAction']);
        this._dir = dir;
    }

    render(target) {//オーバーライドしたrenderメソッドで、アクション範囲を可視化してみる
        const context = target.getContext('2d');
        const rect = this.hitArea;
        context.fillStyle = "rgba(255,255,255,0.7)"; //半透明の白で塗りつぶす
        context.fillRect(
            rect.x, rect.y,
            rect.width, rect.height);
    }

    update(gameInfo, input) {
        this.destroy();
    }
}


class NPC extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('npc1'), new Rectangle(32, 0, 32, 32));
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, sprite, hitArea, ['npc']);

        const rect = this.sprite.rectangle;
        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                if(e.target._dir == 90 * Math.PI/180 ) {rect.y = 0;} //上向きのとき、この子下向く
                if(e.target._dir == -90 * Math.PI/180) {rect.y = 96;} //下向きのとき、この子上向く
                if(e.target._dir == 0 * Math.PI/180 || e.target._dir == 45 * Math.PI/180 || e.target._dir == -45 * Math.PI/180 ) {rect.y = 32;}
                if(e.target._dir == 180 * Math.PI/180 || e.target._dir == 135 * Math.PI/180 || e.target._dir == -135 * Math.PI/180 ) {rect.y = 64;}

                this.dispatchEvent('messageWindowRequest', ['こんにちは。あのーなにしてるんですか?どなたさまですか?','わたしはおねむよ。ねむねむよ。まっくらすやぁってぐっすりねるの。', 'ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZZZZZZ', '※これは会話テストです'] );
            }
        });
    }

    update(gameInfo, input) {
    }
}

class DanmakuStgMainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const npc = new NPC(150, 100);
        const player = new Player(150, 200);
        this.add(npc);
        this.add(player);

        // MainSceneのconstructorの中で
        this.actors.forEach((actor) => actor.addEventListener('messageWindowRequest', (event) => {
            // この場合のeventの中身はメッセージ配列です
            const messageWindow = new MessageWindow(event);
            // プレイヤーの動きを止めます
            // isActiveみたいな変数をPlayerに用意しておくと良いかもです
            // そしてそれをfalseに切り替えて、あとはPlayer側でif(isAcitve)ならキーボードで動くようにする……とか
            player.isActive = false;

            // そしてメッセージが終わったらプレイヤーをactiveに戻す処理を登録
            messageWindow.addEventListener('textEnd', (e) => player.isActive = true);
            // やっとシーンに追加
            this.add(messageWindow);
        }));
    }
}

class DanmakuStgTitleScene extends Scene {
    constructor(renderingTarget) {
        super('タイトル', 'black', renderingTarget);
        const title = new TextLabel(100, 200, 'RPG製作',"white", 25);
        this.add(title);
    }

    update(gameInfo, input) {
        super.update(gameInfo, input);
        if(input.getKeyDown(' ')) {
            const mainScene = new DanmakuStgMainScene(this.renderingTarget);
            this.changeScene(mainScene);
        }
    }
}

class DanamkuStgGame extends Game {
    constructor() {
        super('RPG製作', 512, 384, 60);
        const titleScene = new DanmakuStgTitleScene(this.screenCanvas);
        this.changeScene(titleScene);
    }
}

assets.addImage('sprite', 'sprite.png');
assets.addImage('player', 'chara_player.png');
assets.addImage('npc1', 'chara_npc1.png');
assets.loadAll().then((a) => {
    const game = new DanamkuStgGame();
    document.body.appendChild(game.screenCanvas);
    game.start();
});