JavaScriptでゲーム作り「17-2:Canvas描画のフェードイン・アウト」

キャラクターの表示画像などに、フェードイン・アウトのアニメーションを実装します。

会話イベント立ち絵&名前
⇒ 立ち絵フェード表示の完成形デモ(スペースキーでアクション)

会話の立ち絵にフェード効果を入れたい

(2019.7.2執筆)

立ち絵が表示できるようになった! しかし前回までは即座に立ち絵が切り替わる。どことなく固定された硬い印象です。 コレを、だんだん表示されてだんだん去っていく。あるいはだんだん表情が切り替わる。ちょっとしたアニメーションで、キャラクターに生きた存在感を感じられるように。ぜひ実装してみたいものです。

この章では画像の表示にフェードイン・アウトを加えて、単純な動き(登場時のスライドなど)も入れることを目標にします。

今回のコラムの狙い

  • 立ち絵をフェードインで表示
  • 立ち絵を切り替える際のフェード効果を、条件分岐で使い分けたい
  • 立ち絵の登場時、退場時の動きを制御できるようにしたい



立ち絵に関して、けっこう複雑な動きをさせるようになります。 なので従来のメッセージウィンドウから立ち絵表示部分を切り出して、新しいクラスに定義したほうが良さそうです。

プログラミングの手順

  1. メッセージウィンドウのイラスト表示を新しいクラスで定義
  2. 新クラス内で、フェード効果に必要な関数を作る
  3. メッセージウィンドウ側から操作してみる


さて、設計を考える...立ち絵クラスはメッセージウィンドウの状態で切り替わるので、メッセージウィンドウ側から制御する方が分かりやすいだろうか??? 順に構築していきましょう。

立ち絵クラス「class talkPict」を作ってみる

立ち絵表示を独立したクラスにしてまとめました。

class TalkPict の下書きコード

class TalkPict extends WindowUI {//会話時のキャラクターイラスト
    constructor(sprite) {
        super(['charactur']);
        this.sprite = sprite;
        this.x = screenCanvasWidth - this.sprite.width; //描画開始位置のx座標
    }
    update(sceneInfo, input, touch) {
    }
    renderingCharactor(context) {
        context.drawImage(this.sprite.image, //ここからsprite画像の描画
            this.sprite.x, this.sprite.y,
            this.sprite.width, this.sprite.height,
            this.x, 0
        ); //sprite画像ここまで
    }
    render(context) { //立ち絵表示は、通常のcanvasContextにて行う
         this.renderingCharactor(context);
    }
}
まずは普通に表示できればよし。手始めの立ち絵クラスはシンプルに行きましょう。

メッセージウィンドウ側のメソッド修正

メッセージウィンドウの方では、新しいクラスに合わせて立ち絵表示のメソッドを修正していきます。
    get currentMessage() { return this._currentMessage; }
    set currentMessage(message) { //会話フェーズの移行と、各要素の初期化
        if(message === undefined) { // もう空っぽだったらtextEndイベントを発生させる
            this._currentMessageText = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
            this.dispatchEvent('textEnd'); return;// textEndイベントを発行
        }
        this._currentMessage = message; //↓現在の会話内容が更新される際、一旦表示要素をリセットする。
        this.currentMessageText = this._currentMessage.text.shift(); //テキストの配列から、現在ウィンドウに表示する会話フェーズを取り出す

        //表示する立ち絵についての初期化判定
        if(!this._currentMessage.illust) { //立ち絵がない場合の操作
            this.textMaxWidth = 472; //テキストの最大表示幅、キャラ立ち絵なしのとき472
            if(this.talkPict) {this.talkPict.close();} //前回の立ち絵をフェードアウト
        } 
        else { //立ち絵がある場合
            this.textMaxWidth = 320; //立ち絵ありのとき320
            if(this.talkPict) { //前回の立ち絵が残ってる場合
                if(this.name === this.currentMessage.name) {this.talkPict.close();} //前回の話し手と同じキャラクターならイラストを重ねて表示
                else {this.talkPict.close();} //違うキャラクターなら前回の立ち絵をフェードアウト
            }
            else {} //キャラクター登場時にフェード時間分だけメッセージ表示を遅らせる
        }
        this.talkPict = null; //前回の立ち絵のキャッシュを削除
        this.inited = false; //ウィンドウ枠の初期化済み=false
    }
主に会話内容の初期化(立ち絵の判定)と、
(ちなみに入退場時のフェード操作予定の箇所は開けておきます)

    updateInit(sceneInfo, input, touch) { //ウィンドウ全体の初期化処理
        if(this.name !== this.currentMessage.name) { //前回と話し手が違う場合
            this.clearPreRendering(); //ウィンドウ枠全体を初期化
            this.name = this.currentMessage.name; //話し手の名前をキャッシュ(次回との比較用)
        }
        if(this._currentMessage.illust && !this.talkPict) { //未だ立ち絵が表示されてないなら
            this.openWindow(this._currentMessage.illust); //先に立ち絵を表示
            this.talkPict = this._currentMessage.illust; //今回の立ち絵イラストをキャッシュ(次回との比較用)
        }
        if(this._frameCount <= 0) { //立ち絵表示開始からメッセージ表示までのタイムラグが消費された
            this.preRenderingBoard(); //メッセージボードを描画
            this.preRenderingName(); //名前表示欄を描画
            this.inited = true; //初期化完了
        }
        else{ this._frameCount--; //表示待機中ならフレームカウントを消費
            if((input.getKey(' ') || touch.touch) && this._frameCount > 0) {this._frameCount--;} //速度2倍に
        }
    }

    update(sceneInfo, input, touch) { //毎フレーム呼び出されるupdateメソッド。このクラスのプログラム動作の本体
        this.playerIsStay(); //メッセージウィンドウが存在する限り、プレイヤー操作を止める
        if(!this.inited){ //会話フェーズが切り替わるならウィンドウ枠全体の初期化
            return this.updateInit(sceneInfo, input, touch);
        }
    //~~省略
    }
立ち絵をシーンに追加するところですね。

NPCのトークメソッドの修正

    talkStart() { //会話を表示する関数、NPCの拡張クラスではこの関数を上書きして各々の会話内容を設定します。
        const messages = [
            new Talk(this.name, ['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'], new TalkPict(this.illust[0]) ),
            new Talk(this.name, ['ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…',], new TalkPict(this.illust[1]) ),
            new Talk("", ['#<#<#<#<#<※これは#=会話テスト!#.#.'])
        ];
        const messageWindow = new MessageWindow(messages);
        this.dispatchEvent('openWindow',  new Event(messageWindow));
    }
最後にNPCキャラクターのトーク発生イベントも、描き方を変えます。 このとき新しいクラス名が短いほど編集がしやすいので、会話に使うクラス名は短めにしてみました。 今後、ゲーム制作の会話編集で多用するクラスなので...こういうとこ短いの重要。(かつ要素の意味が伝わるように)

メッセージウィンドウに渡すTalkクラス

class Talk { //MessageWindowに渡す必要情報をまとめた会話イベントクラス
    constructor(name, text, illust, voice) {
        this.name = name;
        this.text = text;
        this.illust = illust;
        this.voice = voice;
    }
}
//従来までは、["テキスト","テキスト","テキスト","テキスト"]という形で渡していたメッセージ内容を
//このクラスを使って、[new Message(), new Message(), new Message(), new Message()]という配列に置き換えます
//テキスト内容に加え、会話フェーズに合わせてセリフを喋ってる人物の名前、立ち絵、ボイスをそれぞれ渡せる形になります。
ここのクラス名は、短いほど後の会話編集が楽。

⇒ 立ち絵フェード表示のデモ(スペースキーでアクション)


さて、書式を変えたので一旦会話テストです。
エラーが出なくなるまで修正してOK。これで次に進めます

立ち絵クラスにフェードインを実装する

では、ここから本題です。立ち絵をフェードインで表示できるようにしてみましょう。 基本的には立ち絵クラス内で完結する描き方をします。

TalkPictクラスにフェードイン効果を組み込む

class TalkPict extends WindowUI {//会話時のキャラクターイラスト
    constructor(sprite) {
        super(['charactur']);
        this.sprite = sprite;
        this.fade = 1/16; //フェード効果の早さを指定、デフォルトは1/16...
        this.opacity = 0; //描画の透明度。最初は0で透明に。メッセージをフェードイン表示させる
        this.x = screenCanvasWidth - this.sprite.width; //描画開始位置のx座標、登場時のスライドがfalseなら定位置に。
    }

    update(sceneInfo, input, touch) { //毎フレーム呼び出される関数
        if(this.fade > 0 && this.opacity < 1) {
            this.opacity += this.fade;
            this.x = screenCanvasWidth - this.sprite.width * (this.opacity*0.5 + 0.5); // キャラクター登場時のX座標を調整
        }
    }
    renderingCharactor(context) {
        context.drawImage(this.sprite.image, //ここからsprite画像の描画
            this.sprite.x, this.sprite.y,
            this.sprite.width, this.sprite.height,
            this.x, 0
        ); //sprite画像ここまで
    }
    render(context) { //立ち絵表示は、通常のcanvasContextにて行う
        if( this.opacity < 1 ) { //描画時にフェード効果を付ける
            context.setAlpha(this.opacity);
            this.renderingCharactor(context);
            context.setAlpha(1);
        }
        else { this.renderingCharactor(context); }
    }
}
ゲームのrequestAnimationFrame中において、フェードイン・アウトさせるのは簡単です。
描画する前に、context.globalAlpha(透明度に当たる数値)をupdate()毎に加算すればいいだけ。
同時に座標も少し弄ればスライド表示も可能になります。


会話イベント立ち絵&名前
⇒ 立ち絵フェードインのデモを見る



フェード効果、あっさり実装できちゃいましたね。。。スライドまで...
ただ表情差分が切り替わるだけでもスライドしちゃうのが微妙かな? そこの調整が必要。
加えて、フェードアウトの方もできるように改造していきましょう。

立ち絵のフェードアウト、その他を実装する

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なら定位置に。
    }

    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, 0
        ); //sprite画像ここまで
    }
    render(context) { //立ち絵表示は、通常のcanvasContextにて行う
        if( this.opacity < 1 ) { //描画時にフェード効果を付ける
            context.setAlpha(this.opacity);
            this.renderingCharactor(context);
            context.setAlpha(1);
        }
        else { this.renderingCharactor(context); }
    }
}
スライドのON/OFF(登場時と退場時のそれぞれで)
そしてフェードアウト機能に必要なメソッドを追加しました。

TalkPictクラスconstructor()におけるoption={}について

constructor(sprite, option={})におけるインスタンス生成時のメソッドには、optionで定義したオブジェクト要素を含むことが出来ます。 例えばNPCクラスに記述するトーク内容を、以下のように記述できます。

NPC側で記述するトーク内容

new Talk(this.name, ['表示するテキストの中身'], new TalkPict(this.illust[0], {enter:true}) )

new TalkPict(this.illust[0], {enter:true})とする場合、登場時のスライドがONになります。
new TalkPict(this.illust[0], {leave:true})とする場合、退場時のスライドがONになります。
new TalkPict(this.illust[0], {fade:32})とする場合、フェードインが32フレーム分のゆっくりしたスピードになります。

もちろんこれらは合わせて
new TalkPict(this.illust[0], {enter:true, leave:true, fade:32})と描くこともできるし。
new TalkPict(this.illust[0]) と、オプションの指定を無しにすることもできます。

optionが無指定の場合は、TalkPictクラス内にてデフォルトで設定した値が読み込まれます。


この描き方をすると、後で立ち絵機能を追加した場合にも従来までの描き方を損なわずに続行できる。それにゲーム制作で会話の流れを記述するのも理解しやすい形で楽です。

この辺りは古都さんのブログ記事より。

他人に読んでもらうJavaScriptコードを書くために
(デフォルト引数はオブジェクトにするという欄がそうです。

フェードアウトに必要なメソッドについて

    fadeOut(value) { //valueのフレーム数でフェードアウトするメソッド。デフォルトは元々指定してたフェード値に-1を掛けた値
        this.fade = - Math.abs(this.fade);
    }
フェードアウトは単純なメソッドにまとめます。フェードの値をマイナスにするだけです。
    delayClose(value = 500) { //valueミリ秒後にフェードアウトして閉じる
        setTimeout(() => this.fadeOut(), value);
    }
起動から、ちょっと遅らせてフェードアウトするメソッドも用意しました。
これは、例えば表情差分を重ねて表示させる時間を確保するために用います。

    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();
        }
    }
そしてupdate()内にて、フェードインとフェードアウト時のモードによって動きが切り替えられるようにします。 重要な部分として、フェードアウトが完全に終わったら、このクラスを自身で消去することです。
        if(this.fade < 0 && this.opacity <= 0) { // フェードアウトしたらこのオブジェクトを閉じる
            this.close();
        }
表示が消えてもインスタンスとしてオブジェクト自体は残ったままなので、ここでしっかりオブジェクト本体も終了させておきます。

MessageWindow内での操作

メッセージウィンドウクラス内での修正箇所を記述します。メッセージをセットしたときの立ち絵判定に上書きです。 また、全ての会話が終了した時に忘れずに立ち絵もフェードアウトさせるようにしておきます。

    get currentMessage() { return this._currentMessage; }
    set currentMessage(message) { //会話フェーズの移行と、各要素の初期化
        if(message === undefined) { // もう空っぽだったらtextEndイベントを発生させる
            this._currentMessageText = ''; // undefinedのままだと一瞬undefinedが描画されるかもしれないので…
            if(this.talkPict) { this.talkPict.fadeOut(); } //立ち絵は終了時にフェードアウト
            this.dispatchEvent('textEnd'); return;// textEndイベントを発行
        }
        this._currentMessage = message; //↓現在の会話内容が更新される際、一旦表示要素をリセットする。
        this.currentMessageText = this._currentMessage.text.shift(); //テキストの配列から、現在ウィンドウに表示する会話フェーズを取り出す

        //表示する立ち絵についての初期化判定
        if(!this._currentMessage.illust) { //立ち絵がない場合の操作
            this.textMaxWidth = 472; //テキストの最大表示幅、キャラ立ち絵なしのとき472
            if(this.talkPict) {this.talkPict.fadeOut(); this._frameCount = Math.abs(1/this.talkPict.fade); this.talkPict = null;} //前回の立ち絵をフェードアウトさせる
        } 
        else { //立ち絵がある場合
            this.textMaxWidth = 320; //立ち絵ありのとき320
            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.inited = false; //ウィンドウ枠の初期化済み=false
    }
ここでは立ち絵が切り替わる際の条件分けをしています。

今回の立ち絵がない場合、前回の分をフェードアウト。
立ち絵を前回の分と比較して、同じキャラなら表情差分の差し替えにディレイフェードアウトを活用。
違うキャラクターなら、普通にフェードアウト。

それからフェードアウト分のフレームカウントも設定して、テキスト更新までのタイムラグを設けてます。

NPCとの会話でテスト


class NPC_girl1 extends NPC {
    constructor(x, y, name) {
        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.name = name;
        this.illust = [];
        this.illust[0]=new Sprite(assets.get('up_npc_girl0'), 0, 0, 200, 400); //0
        this.illust[1]=new Sprite(assets.get('up_npc_girl1'), 0, 0, 200, 400); //0
    }
    talkStart(talkNumber) { //会話を表示する関数、NPCの拡張クラスではこの関数を上書きして各々の会話内容を設定します。
        let messages;
        switch (talkNumber) {
            default : messages = [
 new Talk(this.name, ['こんにちは。あのーなにしてるんですか? どなたさまですか?', 'わたしは#rおねむ#rよ、#rねむねむ#rよ。まっくらすやぁってぐっすりねるの。'], new TalkPict(this.illust[0], {enter:true}) ),
 new Talk(this.name, ['ひつじがいっぴきー、ひつじがにひきー。ひつじがさんびきー、ひつじがよnZ#<Z#<Z#<Z#<ZZ…',], new TalkPict(this.illust[1], {leave:true}) ),
 new Talk("", ['#<#<#<#<#<※これは#=会話テスト!#.#.'])
]; break;
        }
        const messageWindow = new MessageWindow(messages);
        this.dispatchEvent('openWindow',  new Event(messageWindow));
        this.talkNumber++;
    }
}
では、最後にすみれちゃんのトーク内容を編集して、きちんと動くか確かめてみます。

会話イベント立ち絵&名前
⇒ 立ち絵フェード表示の完成形デモ(スペースキーでアクション)



無事に立ち絵のフェード効果が実装できました(' '*)



これまでのデモの推移を見ていくと、それぞれの実装効果を体感できそうです。


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


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