JavaScriptでゲーム作り「13:メッセージウィンドウを自作その1」

今回は、メッセージウィンドウを自作してみようと思います。
⇒ メッセージウィンドウを使ったデモを見る

メッセージウィンドウの仕組みを実装する

(2019.5.31執筆)

当たり判定を最適化した次に、今度はメッセージウィンドウの機能を追加したいと思います。4項にて会話機能を作ってましたが、ウィンドウ枠は当たり判定を必要としないことからActorクラスとは別口で作りなおしたほうが良さそうです。文字情報を表示させるWindowUIクラスを新しく定義し、メッセージウィンドウ枠を本格的に実装してみたいと思います。

グローバルのwindowと名前が被るので、表示枠に使うクラスはWindowUIに名づけ直しました

実装ポイントと大まかな手順

  1. ウィンドウを表示するcanvasを別に用意する
  2. WindowUIクラスを、アクターとは別に定義する
  3. WindowUIクラスを元に、MessageWindowクラスを定義する
  4. Sceneクラスのupdate()内にて、WindowUIクラスの処理を適した形にする
  5. NPCアクタークラスに、会話(window.open)機能を入れる
  6. 会話ウィンドウの状態によって、player操作のON/OFFをきりかえる


では行ってみます(' '*)

ウィンドウを表示するcanvasを別に用意する

フレームアップデートと再描画の必要性がActorのそれと異なってくることから、ウィンドウの描画処理はアクターと切り分けて考える見通しです。

(参考リンク)
HTML5 canvas のパフォーマンスの改善 - HTML5 Rocks


参考リンクの情報より、ウィンドウを表示するcanvasは分けたほうが良いと判断しました。
canvasタグはGameクラスで設定していましたが。これまでのcanvasはActor専用にして、windowUI描画専用の新しいcanvasをGameに追加したいと思います。

Gameクラスにウィンドウ専用canvasを追加


class Game {//いよいよ総まとめです!Gameクラスを作りましょう。Gameクラスはメインループを持ちます
    constructor(title, maxFps) {
        this.title = title;
        this.maxFps = maxFps;
        this.currentFps = 0;
        this._prevTimestamp = 0;

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

        //アクター描画用のキャンパス
        this.actorsCanvas = document.createElement('canvas');
        this.actorsCanvas.width = screenCanvasWidth;
        this.actorsCanvas.height = screenCanvasHeight;
        this.actorsCanvas.setAttribute("id", "actors");
        this.actorsCanvas.style.cssText = "position: absolute;"
                          + " z-index: 0";

        //メッセージウインドウ描画用のキャンバス
        this.windowsUICanvas = document.createElement('canvas');
        this.windowsUICanvas.width = screenCanvasWidth;
        this.windowsUICanvas.height = screenCanvasHeight;
        this.windowsUICanvas.setAttribute("id", "windowsUI");
        this.windowsUICanvas.style.cssText = "position: absolute;"
                          + " z-index: 1";

        console.log(`${title}が初期化されました。`);
    }

//〜〜
2つのcanvasをレイヤーを重ねるように表示させるため、cssTextでスタイルシートの記述を追加してます。

Sceneクラスの修正

Sceneクラス側でも、2つのcanvas情報を受け取れるように修正していきます。

class Scene extends EventDispatcher {//Actorをまとめあげる舞台の役割、シーンの定義
    constructor(name, width, height, sceneColor, actorsCanvas, windowsUICanvas) {//ここを修正
        super();

        this.name = name;
        this.width = width;
        this.height = height;
        this.sceneColor = sceneColor;
        this.actors = [];
        this._releasedActors = [];
        this.windowsUI = [];
        this._closedWindowsUI = [];
        this.actorsCanvas = actorsCanvas;  //ここを追加
        this.windowsUICanvas = windowsUICanvas; //ここを追加

        this._qTree = new LinearQuadTreeSpace(this.width, this.height, 3);
        this._detector = new CollisionDetector();

        // 背景スクローラー用のオブジェクト。アクターの描画位置を調整するのに使用。
        // scroller.xやscroller.yの値はプレイヤー側で操作。アクターのアップデート後、各シーンで画面端の調整する
        this.scroller =  { x: 0, y: 0 };
    }

//〜〜
だいたいこのような感じ。Scene内部の細かい調整は置いといて、拡張クラスの大まかな修正から入ります。

MainScene(sceneの拡張クラス)の記述修正

class MainScene extends Scene {
    constructor(actorsCanvas, windowsUICanvas) { //ここを修正
        super('メイン', 1512, 1384, 'black', actorsCanvas, windowsUICanvas); //ここを修正

//〜〜
class TitleScene extends Scene {
    constructor(actorsCanvas, windowsUICanvas) { //ここを修正
        super('タイトル', screenCanvasWidth, screenCanvasHeight, 'black', actorsCanvas, windowsUICanvas); //ここを修正
        const title = new TextLabel(100, 200, 'RPG製作',"white", 25);
        this.open(title);
    }

    update(gameInfo, input, touch) {
        super.update(gameInfo, input, touch);
        if(input.getKeyDown(' ') || touch.touch) {
            const mainScene = new MainScene(this.actorsCanvas, this.windowsUICanvas); //ここを修正
            this.changeScene(mainScene);
        }
    }
}
class RolePlayingGame extends Game {
    constructor() {
        super('RPG製作', 60);
        const titleScene = new TitleScene(this.actorsCanvas, this.windowsUICanvas); //ここを修正
        this.changeScene(titleScene);
    }
}
各Sceneクラスのconstructor()にてrenderingTargetのみで指定してた箇所を、actor用とwindowUI用と2つのキャンバス指定に修正しました。

DOMに追加する名前も忘れずに修正

assets.loadAll().then((a) => {
    const game = new RolePlayingGame();
    document.body.appendChild(game.actorsCanvas);
    document.body.appendChild(game.windowsUICanvas);
    document.body.appendChild(timeCounter);
    game.start();

    global.player = new Player(150, 200, 90); //globalオブジェクトに、player: new Player();のプロパティを追加。global.playerでアクセス可能に!
});
ゲームを起動させる際、変更した2つのキャンバス名を忘れずに追加修正します。
次にWindowUIクラスの定義に移ります。

WindowUIクラス(EventDispatcherより拡張)を定義する


class WindowUI extends EventDispatcher {//EventDispatcherイベント発生関数を持つWindowUIクラスの定義、メッセージ欄やメニュー表示に使用
    constructor(tags = []) {
        super();
        this.tags = tags;
    }
    hasTag(tagName) { return this.tags.includes(tagName); } //タグはウィンドウの種類を取得するのに使います
    openWindow(windowUI) { this.dispatchEvent('openWindow', new GameEvent(windowUI)); } //他のWindowUIを展開するときに使用
    //このウィンドウ自身を閉じる、しかし関数で自身を消すことはできないので、イベントだけ発生させてより上位のクラスに実際の処理を任せる
    close() { this.dispatchEvent('close', new GameEvent(this)); }
    clearRendering(target) {}//__close()に合わせて、Scene側でウィンドウの描画をクリアする
    update(gameInfo, input, touch, target) {}//ウィンドウ内のアップデートと描画をひとまとめにした関数
}

基本となるWindowUIクラス。Actorと同様にEventDispatcherから拡張して創ります。扱い方はだいたいActorと同じ。表示位置は固定なので、違いといえば当たり判定や座標、scrollerの値を必要としないことでしょうか。 よって、必要な基本機能はかなり絞られます。だいたい以下のもので十分でしょう。

基本機能

  • hasTag(tagName)......ウィンドウの種別を取得。メッセージウィンドウ?選択肢?メニュー?、など。
  • openWindow(windowUI)......新しいウィンドウUIをシーンに追加 ≒ SpawnActor(Actor)
  • close()......このウィンドウを閉じる(非表示にする)
  • clearRendering(target)......描画領域をクリアする(閉じる動作の時にwindowUICanvasに対して使用)
  • update(gameInfo, input, touch, target)......ウィンドウ内のフレームアップデートの処理、描画も必要に応じて



ウィンドウクラスの特徴として、オブジェクト生成の最初に描画した後は、updateによる画面更新はそこまで必要なかったりもします。 描画するrender()機能は毎フレーム呼び出すのではなく、update()関数内にrenderingTargetを引き継いだ形で、必要最小限に呼び出した方がいいかなと考えました。

とりあえずこんな感じで大丈夫なはずです。たぶん...
他が必要になったら後から追加するのでもいいでしょう。

MessageWindowクラス(WindowUIより拡張)を定義する

では次に、メッセージウィンドウを作っていきます。4項にて作った会話ウィンドウクラスをぼちぼち流用して、WindowUIクラスの書式に合わせてみます。


class MessageWindow extends WindowUI {
    constructor(messages) {
        super(['messageWindow']);
        this.color = #555;
        this.size = 15px;

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

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

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

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

            // もう空っぽだったらtextEndイベントを発生させる
            if(this.currentMessage === undefined) {
                this.currentMessage = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
                this.dispatchEvent('textEnd'); // textEndイベントを発行
            }
        }
        this.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が自分でやると楽
        //"\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] =''; }
            if( char !== "\n") { column[line]+= char; }
        }

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

    clearRendering(target) {//___描画をクリアする
        const context = target.getContext('2d');
        const windowX = 2;
        const windowY = 384 - 104;
        context.clearRect(0, windowY, 512, 104);
    }
}

まだ初期の、ほぼそのままの形。間に合わせですが、メッセージウィンドウクラスの用意はひとまずOKとします。

Sceneクラスで表示の調整を行う

では、Scene側の調整に移りましょう。新しいcanvasやwindowUIクラスを追加したことで、いくつか修正しなければならない点が出てきました。 修正ポイントを挙げると...

  • WindowUIクラスを管理する配列
  • WindowUIクラスを追加する関数
  • WindowUIクラスを削除する関数
  • 各windowUIオブジェクトのupdate()を呼び出す関数
  • 従来のrenderingTargetで記載した箇所を置き換え


だいたい以下のように書き換えられます。

WindowUIクラスを管理する配列を追加

        this.windowsUI = [];   //シーン中のwindowUIオブジェクトを格納
        this._closedWindowsUI = [];    //シーンから閉じるwindowUIオブジェクトを格納
まずはwindowUI専用の配列を用意する。

WindowUIクラスを追加する関数

    add(actor) {//Actorたちを保持する(追加・削除)
        this.actors.push(actor);
        actor.addEventListener('spawnactor', (e) => this.add(e.target));//spawnactorイベントが発生した場合はシーンにActorを追加
        actor.addEventListener('release', (e) => this._releasedActors.push(e.target));//releaseイベントはそのActorを降板リストに追加
        actor.addEventListener('openWindow', (e) => this.open(e.target));//openWindowイベントが起こったら、シーンにメッセージウィンドウを追加
    }

    open(windowUI) {//メッセージウィンドウを保持する(追加・削除)
        this.windowsUI.push(windowUI);
        windowUI.addEventListener('openWindow', (e) => this.open(e.target));//openWindowイベントが発生した場合はシーンにWindowUIを追加
        windowUI.addEventListener('close', (e) => this._closedWindowsUI.push(e.target));//closeイベントはそのWindowUIを降板リストに追加
    }
アクターを追加する関数にopenWindow機能を追加。そしてWindowUIクラスをシーンに追加する際、新しいウィンドウを開く機能とそのウィンドウ自身を閉じる機能を追加している。

Windowクラスを削除する関数

    _disposeClosedWindowsUI() {//メッセージウィンドウを閉じる関数
        this._closedWindowsUI.forEach((windowUI) => {
            const index = this.windowsUI.indexOf(windowUI);
            this.windowsUI.splice(index, 1);
            windowUI.clearRendering(this.windowsUICanvas); //画面ウィンドウの描画を実際に消す
        });//閉じるウィンドウをシーンから除外
        this._closedWindowsUI = [];//閉じるリストを空にする
    }
閉じるウィンドウリストに格納されている順に、ウィンドウを閉じる関数を実行。 また、ウィンドウ領域の描画をクリア(透明に)する関数も実行。

各WindowUIオブジェクトのupdate()を呼び出す関数

    _updateAll(gameInfo, input, touch) {//ActorとWindowUIオブジェクトの動作を更新する
        this.actors.forEach((actor) => actor.update(gameInfo, input, touch));

        this.scroller = gameInfo.scroller; //アップデート後のスクロール情報をシーンに読み込む
        if(this.scroller.x > 0) { this.scroller.x = 0; } //左右スクロール限界を設定
        else if(this.scroller.x < screenCanvasWidth - this.width) { this.scroller.x = screenCanvasWidth - this.width; }
        if(this.scroller.y > 0) { this.scroller.y = 0; } //上下スクロール限界を設定
        else if(this.scroller.y < screenCanvasHeight - this.height) { this.scroller.y = screenCanvasHeight - this.height; }

        //WindowUIオブジェクトはアップデート関数で描画まで行う
        this.windowsUI.forEach((windowUI) => windowUI.update(gameInfo, input, touch, this.windowsUICanvas));
    }
_updateAll()の最後の行に、ウィンドウオブジェクト一覧のupdate()も追加。

従来のrenderingTargetで記載した箇所を置き換え

    _renderAllActors() {
        //シーンの初期化、アクター描画の前にアクター表示の画面をクリアする
        const context = this.actorsCanvas.getContext('2d');
        context.fillStyle = "black";
        context.fillRect(0, 0, screenCanvasWidth, screenCanvasHeight);

        //各アウターのrender()メソッドを呼び出して、描画します。
        this.actors.forEach(
            (obj) => obj.render(this.actorsCanvas, this.scroller)
        );
    }
アクタークラスの描画で使っていたrenderingTargetを新しい文字列(actorsCanvas)に置き換え。 変更点は以上です。

Sceneクラスの調整後の記述


class Scene extends EventDispatcher {//Actorをまとめあげる舞台の役割、シーンの定義
    constructor(name, width, height, sceneColor, actorsCanvas, windowsUICanvas) {
        super();

        this.name = name;
        this.width = width;
        this.height = height;
        this.sceneColor = sceneColor;
        this.actors = [];
        this._releasedActors = [];
        this.windowsUI = [];
        this._closedWindowsUI = [];
        this.actorsCanvas = actorsCanvas;
        this.windowsUICanvas = windowsUICanvas;

        this._qTree = new LinearQuadTreeSpace(this.width, this.height, 3);
        this._detector = new CollisionDetector();

        // 背景スクローラー用のオブジェクト。アクターの描画位置を調整するのに使用。
        // scroller.xやscroller.yの値はプレイヤー側で操作。アクターのアップデート後、各シーンで画面端の調整する
        this.scroller =  { x: 0, y: 0 };
    }

    changeScene(newScene) {//Sceneを変更します。ですがScene自身がSceneを変更することはできないので、Actorのときと同じようにイベントだけ発生させて、実際の処理は他のクラスに任せます
        this.dispatchEvent('changescene', new GameEvent(newScene));
    }

    add(actor) {//Actorたちを保持する(追加・削除)
        this.actors.push(actor);
        actor.addEventListener('spawnactor', (e) => this.add(e.target));//spawnactorイベントが発生した場合はシーンにActorを追加
        actor.addEventListener('release', (e) => this._releasedActors.push(e.target));//releaseイベントはそのActorを降板リストに追加
        actor.addEventListener('openWindow', (e) => this.open(e.target));//openWindowイベントが起こったら、シーンにメッセージウィンドウを追加
    }
    open(windowUI) {//メッセージウィンドウを保持する(追加・削除)
        this.windowsUI.push(windowUI);
        windowUI.addEventListener('openWindow', (e) => this.open(e.target));//openWindowイベントが発生した場合はシーンにWindowUIを追加
        windowUI.addEventListener('close', (e) => this._closedWindowsUI.push(e.target));//closeイベントはそのWindowUIを降板リストに追加
    }
    _disposeReleasedActors() {//役者を解放する関数
        this._releasedActors.forEach((actor) => {
            const index = this.actors.indexOf(actor);
            this.actors.splice(index, 1);
        });//降板する役者をシーンから除外
        this._releasedActors = [];//降板する役者リストを空にする
    }
    _disposeClosedWindowsUI() {//メッセージウィンドウを閉じる関数
        this._closedWindowsUI.forEach((windowUI) => {
            const index = this.windowsUI.indexOf(windowUI);
            this.windowsUI.splice(index, 1);
            windowUI.clearRendering(this.windowsUICanvas); //画面ウィンドウの描画を実際に消す
        });//閉じるウィンドウをシーンから除外
        this._closedWindowsUI = [];//閉じるリストを空にする
    }

    update(gameInfo, input, touch) {//updateメソッドではシーンに登録されているActor全てを更新し、当たり判定を行い、描画します。描画の前に一度画面全体をクリアするのを忘れないで下さい。
        this._updateInfo(gameInfo, input, touch);//アップデートに追加情報を挿入
        this._updateAll(gameInfo, input, touch);//ActorやWindowUIの動きを更新する

        this._qTree.clear();
        this.actors.forEach((a) => this._qTree.addActor(a)); //hittest四分木空間に各アクターを振り分け
        this._hitTest();//当たり判定を処理する

        this._disposeReleasedActors();//役目を終えたActorをシーンから降ろす
        this._disposeClosedWindowsUI();//役目を終えたウィンドウを閉じる
        this._renderAllActors();//各アクターをシーンに再描画する
    }

    _updateInfo(gameInfo, input, touch) {// アップデートに追加情報を挿入
        gameInfo.sceneName = this.name; // 各アクターに渡すシーンの名前を定義する
        gameInfo.sceneColor = this.sceneColor; //シーンの特徴カラーを渡す
        gameInfo.sceneWidth = this.width; // 各アクターに渡すシーンの幅を定義する
        gameInfo.sceneHeight = this.height; // 各アクターに渡すシーンの高さを定義する
        gameInfo.scroller = this.scroller; //プレイヤーにスクロール情報を渡して操作してもらう。

        //タッチ座標の設定。タッチした画面座標に、canvas要素の相対座標や、スクローラー要素を反映させる
        const clientRect = this.actorsCanvas.getBoundingClientRect();
        touch.x = touch.pageX - clientRect.left - this.scroller.x - window.pageXOffset;
        touch.y = touch.pageY - clientRect.top - this.scroller.y - window.pageYOffset;
    }

    _updateAll(gameInfo, input, touch) {//ActorとWindowUIオブジェクトの動作を更新する
        this.actors.forEach((actor) => actor.update(gameInfo, input, touch));

        this.scroller = gameInfo.scroller; //アップデート後のスクロール情報をシーンに読み込む
        if(this.scroller.x > 0) { this.scroller.x = 0; } //左右スクロール限界を設定
        else if(this.scroller.x < screenCanvasWidth - this.width) { this.scroller.x = screenCanvasWidth - this.width; }
        if(this.scroller.y > 0) { this.scroller.y = 0; } //上下スクロール限界を設定
        else if(this.scroller.y < screenCanvasHeight - this.height) { this.scroller.y = screenCanvasHeight - this.height; }

        //WindowUIオブジェクトはアップデート関数で描画まで行う
        this.windowsUI.forEach((windowUI) => windowUI.update(gameInfo, input, touch, this.windowsUICanvas));
    }

    _renderAllActors() {
        //シーンの初期化、アクター描画の前にアクター表示の画面をクリアする
        const context = this.actorsCanvas.getContext('2d');
        context.fillStyle = "black";
        context.fillRect(0, 0, screenCanvasWidth, screenCanvasHeight);

        //各アウターのrender()メソッドを呼び出して、描画します。
        this.actors.forEach(
            (obj) => obj.render(this.actorsCanvas, this.scroller)
        );
    }

    _hitTest(//〜〜〜〜省略

NPCアクタークラスに、会話(window.open)機能を入れる

仕上げに、NPCに会話オープン機能を入れてみます。プレイヤーアクションに触れた時に会話ウィンドウをopenする記述を追加です。

NPCクラスの修正


class NPC extends SpriteActor {
    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']);
        this.Rad_To_8 = 4/Math.PI; //ラジアン表記の角度を±4の8段階で

        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                this.dir = Math.PI + e.target.dir;
                const messageWindow = new MessageWindow(['あら、壁を越えて来てくださったのかしら♪', 'あなたって、根気強いのね']);
                this.dispatchEvent('openWindow',  new GameEvent(messageWindow));
                e.target.release(); //アクション範囲をシーンから消す(複数イベントが重なるのを防ぐため)
            }
        });
    }

//〜〜略

以上で、だいたいの修正が終わりました。 実際の作業は、ひとつひとつ動作を確認しながら進めました(そうしないと抜け漏れ多発してヤバイ)

ここまでのデモを見る

会話ウィンドウ
⇒ 会話ウィンドウのデモを見る(スペースキーでアクション)



デモをみますと、ちゃんと動いてることが分かります。 4項から何も進んでないようにも見えますが、プログラム内部ではアクターとウィンドウが分離して動いてますので、いろいろ応用が利きやすいかと。 そして会話動作の足りない部分も見えてきたりして。

修正の必要な箇所

  • 会話ウィンドウが表示されてる時、プレイヤーを動作させない


多重に会話が展開するのを防いだり、プレイヤーがメッセージの操作に集中できるので必要。 これは会話ウィンドウが表示/非表示となる度に、player操作のオンとオフを切り替える機能をつければ良さそうです。

player操作のオンとオフを切り替え

会話イベントが起こる度に、playerオブジェクトのisActive要素を書き換えるコードが必要。今のところglobal.playerで要素内にアクセスできるようにしてますので、コードを描くのは簡単そうです。

大まかな手順をイメージ

  1. 会話ウィンドウが開かれる時、player操作を受け付けないようにする
  2. 会話ウィンドウが終了する時、player操作を再開する

会話ウィンドウが開かれる時、player.isActiveをfalseに

これはNPCアクター側で調整できます。会話イベントが始まる際にplayerのisActiveをfalseに変更します。

class NPC extends SpriteActor {
    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']);
        this.Rad_To_8 = 4/Math.PI; //ラジアン表記の角度を±4の8段階で

        this.addEventListener('hit', (e) => {
            if(e.target.hasTag('playerAction')) {
                this.dir = Math.PI + e.target.dir;
                const messageWindow = new MessageWindow(['あら、壁を越えて来てくださったのかしら♪', 'あなたって、根気強いのね']);
                this.dispatchEvent('openWindow',  new GameEvent(messageWindow));
                e.target.release(); //アクション範囲をシーンから消す(複数イベントが重なるのを防ぐため)
                global.player.isActive = false; //プレイヤーの行動を止める
            }
        });
    }

//~~略

会話ウィンドウが終了する時、player.isActiveをtrueに戻す

これはシーン側で調整できます。会話終了イベントはtextEndタグで発行するようにしてますが、そのイベントの際にplayer.isActiveをtrueにすればOK。 windowUIを追加する際に、そのような処理を追加しておきます。

SceneクラスのwindowUI追加時に追記


class Scene extends EventDispatcher {//Actorをまとめあげる舞台の役割、シーンの定義
    constructor(name, width, height, sceneColor, actorsCanvas, windowsUICanvas) {
        super();

        this.name = name;
        this.width = width;
        this.height = height;
        this.sceneColor = sceneColor;
        this.actors = [];
        this._releasedActors = [];
        this.windowsUI = [];
        this._closedWindowsUI = [];
        this.actorsCanvas = actorsCanvas;
        this.windowsUICanvas = windowsUICanvas;

        this._qTree = new LinearQuadTreeSpace(this.width, this.height, 3);
        this._detector = new CollisionDetector();

        // 背景スクローラー用のオブジェクト。アクターの描画位置を調整するのに使用。
        // scroller.xやscroller.yの値はプレイヤー側で操作。アクターのアップデート後、各シーンで画面端の調整する
        this.scroller =  { x: 0, y: 0 };
    }

    changeScene(newScene) {//Sceneを変更します。ですがScene自身がSceneを変更することはできないので、Actorのときと同じようにイベントだけ発生させて、実際の処理は他のクラスに任せます
        this.dispatchEvent('changescene', new GameEvent(newScene));
    }

    add(actor) {//Actorたちを保持する(追加・削除)
        this.actors.push(actor);
        actor.addEventListener('spawnactor', (e) => this.add(e.target));//spawnactorイベントが発生した場合はシーンにActorを追加
        actor.addEventListener('release', (e) => this._releasedActors.push(e.target));//releaseイベントはそのActorを降板リストに追加
        actor.addEventListener('openWindow', (e) => this.open(e.target));//openWindowイベントが起こったら、シーンにメッセージウィンドウを追加
    }
    open(windowUI) {//メッセージウィンドウを保持する(追加・削除)
        this.windowsUI.push(windowUI);
        windowUI.addEventListener('openWindow', (e) => this.open(e.target));//openWindowイベントが発生した場合はシーンにWindowUIを追加
        windowUI.addEventListener('close', (e) => this._closedWindowsUI.push(e.target));//closeイベントはそのWindowUIを降板リストに追加
        windowUI.addEventListener('textEnd', () => global.player.isActive = true);//textEndイベントは会話終了後に、プレイヤーを行動可能にする
    }

//~~略
最後の一行を追加しました。これでだいたい動くはずです。

修正後のデモを見る

会話ウィンドウ
⇒ 会話ウィンドウのデモ修正後(スペースキーでアクション)



最初の、最低限の会話機能としてはこんなものでしょう。 ここからメッセージ送りや会話テンポの入力、選択肢ウィンドウなどを作りこんでいきます...タッチ入力の調整もむずかしいので後日で(' '*)

追記...player操作が可能かどうかをScene側で制御するとどうか

さて反省点。global.playerという記述には、ちょい問題がございまして(' '*)
プログラム上のどこからでもアクセスできる領域というのがグローバルという位置づけなのですが、これは極力使わないのが望ましいというものです。教わったことでもありますが、最近実感が強くなってます。

そもそも古都さんのコードが如何に理解しやすいものであるか。。。コードの流れが簡潔で、そのまま予測しやすいのは何故か。 というのが、自分の要素を弄るのは自分のクラス定義の中でのみ。他では絶対に弄らないというのが秘訣だったようにに思いました。 global領域を多用すればするほど、どこでオブジェクトの値が変化するのか、プログラム全体の挙動に予測がつかなくなる。一昔前のjavascriptのようにですね。あれは作るときは楽なのかもしれませんが、後から修正するにも他人が読むにも判りにくいですからね...

よって最終的にglobal.playerはどこかの領域に属させて、外からは触れないようにする予定です。その方が古都さんのフレームワークのように、プログラム的にも安全なものになるでしょう。

では、どのように現在のplayerがアクティブか待機状態かを切り替えればよいでしょう? 全てのActorたちはSceneに通じている。。もしかしたらScene側でplayerIsActive?要素を持ったほうがいいのかもしれないという考えに行き着きます。

SceneにplayerIsActive要素を追加する

class Scene extends EventDispatcher {//Actorをまとめあげる舞台の役割、シーンの定義
    constructor(name, width, height, sceneColor, actorsCanvas, windowsUICanvas) {
        super();
     this.playerIsActive = true; //プレイヤーの操作入力を受け付けるかどうか

//〜〜〜〜省略
例えば、constructor内にplayerIsActive?要素を追加してみるか...

Scene内のupdate()に渡すinfoオブジェクトを設定する


class Scene extends EventDispatcher {//Actorをまとめあげる舞台の役割、シーンの定義
    constructor(name, width, height, sceneColor, actorsCanvas, windowsUICanvas) {
        super();

        // 背景スクローラー用のオブジェクト。アクターの描画位置を調整するのに使用。
        // scroller.xやscroller.yの値はプレイヤー側で操作。アクターのアップデート後、各シーンで画面端の調整する
        this.scroller =  { x: 0, y: 0 };

        this.infomation = {width, height, //アクターの各アップデートに渡すシーン情報をまとめたオブジェクト
            scroller:this.scroller,
            playerIsActive:true //現在プレイヤー操作が可能かどうかを定義
        };

//〜〜〜〜省略
それかconstructor内に、各アクターのupdate()に渡す値をまとめてオブジェクトで設定しておくと早いかもです。
このオブジェクトは、sceneアップデート中に各アクターのアップデート関数に対して活用されます。

Sceneアップデートの際に、scene.infomation要素を各アクターに渡す

//Sceneクラス中のアップデート関数を改良
    update(input, touch) {//updateメソッドではシーンに登録されているActor全てを更新し、当たり判定を行い、描画します。描画の前に一度画面全体をクリアするのを忘れないで下さい。
        this._updateInfo(input, touch);//アップデートに追加情報を挿入
        this._updateAll(this.infomation, input, touch);//ActorやWindowUIの動きを更新する

//〜〜以下省略
各アクターのupdate()に渡す値、思い切ってgameInfoオブジェクトを削除して、代わりにscene.infomationオブジェクトに差し替えました。今のとこgameクラスから引き継ぐ要素はないので、差し替えて問題なかったです。

Playerクラスのupdate()内にあるisActive部分を、sceneから渡された値を参照するよう書き換える

//Playerクラス中のアップデート関数を見直し
    update(sceneInfo, input, touch) {
        if(sceneInfo.playerIsActive) {//isActive = trueでアクション可
            if(!touch.touch) {//タッチ操作でない場合、キーで移動できる
            //進む方角の設定
              if( input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
                this._isStand = false;
                if(input.getKey('ArrowUp')) { this._dirGo = Math.PI*0.5;} //上 90*Math.PI/180
                if(input.getKey('ArrowRight')) { this._dirGo = 0;} //右 0*Math.PI/180

//〜〜以下省略
プレイヤー操作が可能かどうかを、渡されたsceneInfoオブジェクトを参照したsceneInfo.playerIsActiveで判別するように変更しました。 これで各アクターのupdate()中に、sceneInfo.playerIsActiveの要素を切り替えることでplayer操作のON/OFFきりかえができるようになります。
※PlayerクラスのisActive要素は削除してOK

会話イベント開始時と終了時に、sceneInfo.playerIsActiveのオンとオフを切り替える

後は会話イベント開始時に、メッセージウィンドウ側でsceneInfo.playerIsActiveを切り替えるようにするとよいです。 イベントの扱い方は次項で詳しく確認するとして、ソースだけ手短に掲載します。

Sceneクラスのopen(windowUI)関数にイベントを追記

//Sceneクラス中の関数
    open(windowUI) {//(メッセージ)ウィンドウを保持する(追加・削除)
        this.windowsUI.push(windowUI);
        windowUI.addEventListener('openWindow', (e) => this.open(e.target));//openWindowイベントが発生した場合はシーンにWindowUIを追加
        windowUI.addEventListener('close', (e) => this._closedWindowsUI.push(e.target));//closeイベントはそのWindowUIを降板リストに追加
        windowUI.addEventListener('playerIsStay', () => this.infomation.playerIsActive = false);//プレイヤーの操作を受け付けないようにする
        windowUI.addEventListener('playerIsActive', () => this.infomation.playerIsActive = true);//プレイヤーの操作を再度可能にする
    }

WindowUIクラスにイベントを発生させる関数を追記


class WindowUI extends EventDispatcher {//WindowUIクラスの定義、メッセージ欄やメニュー表示に使用
    constructor(tags = []) {
        super();
        this.tags = tags;
    }
    hasTag(tagName) { return this.tags.includes(tagName); } //タグはウィンドウの種類を取得するのに使います
    openWindow(windowUI) { this.dispatchEvent('openWindow', new Event(windowUI)); } //他のWindowUIを展開するときに使用
    //このウィンドウ自身を閉じる、しかし関数で自身を消すことはできないので、イベントだけ発生させてより上位のクラスに実際の処理を任せる
    close() { this.dispatchEvent('close', new Event(this)); }

    playerIsStay() { this.dispatchEvent('playerIsStay');} //scene側でプレイヤー操作を受け付けないようにする関数
    playerIsActive() { this.dispatchEvent('playerIsActive');} //scene側でプレイヤー操作受付を再開する関数

    clearRendering(target) {}//__close()に合わせて、Scene側でウィンドウの描画をクリアする
    update(sceneInfo, input, touch, target) {}//ウィンドウ内のアップデートと描画をひとまとめにした関数
}

MessageWindowクラスにて、追記したプレイヤー操作切り替えのイベントを使う


class MessageWindow extends WindowUI {//メッセージウィンドウの管理と会話送り
    constructor(allMessages) {
        super(['messageWindow']);
        this.color = "#555"; //テキスト色の初期値
        this.size = 15; //テキストサイズの初期値

        this.allMessages = allMessages; //全てのメッセージ文が格納された配列
        this.currentMessage = this.allMessages.shift(); //今表示するべきメッセージ文、全メッセージリストの先頭を取り出した値

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

    update(sceneInfo, input, touch, target) { //セリフを次の表示にする記述
        this.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める
        if(input.getKeyDown(' ')) {  //スペースキーを押した時に
            this.currentMessage = this.allMessages.shift(); // 次のメッセージ取り出す
            // もう空っぽだったらtextEndイベントを発生させる
            if(this.currentMessage === undefined) {
                this.currentMessage = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
                this.dispatchEvent('textEnd'); // textEndイベントを発行
            }
        }

//〜〜省略
この場合、メッセージウィンドウが画面に存在してる間は常にupdate()内で、sceneInfo.playerIsActive = false...或いは設定したイベント関数{this.dispatchEvent('playerIsStay');}とか何とか使ってプレイヤ操作を受け付けないようにし、ウィンドウを閉じるときに合わせてthis.playerIsActive();とか何とかで、scene側のplayerIsActive要素を切り替えられるようになりました。


...動作の内容は、あんまり変わらないです。単にプレイヤー操作ON/OFFを、playerオブジェクトの中身で判定してたのが、sceneオブジェクトの中身で判定するようになったというだけです。 プログラムの視点だと、各アクターやウィンドウはSceneと全て繋がっているので、sceneを介してplayerオブジェクトを操作するよりも切り替えがスムーズなのが良いところです。

イベント関数を活用すると応用が利きやすい?

上記の例では、イベント関数を活用してupdate()内に限らずsceneのplayerIsActive切り替えを可能にしています。 これ、一度イベントの仕組みについておさらいしておいたほうがいいかもですね。


【目次】
  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/
古都さん
コードの見本をありがとう! とても判りやすく、簡潔なソースコード。いつも勉強になってます。


プレイヤーキャラ
ぴぽやさんもありがとう、この画像作るの、私では大変でした。助かりました。