19-2:Sceneのアニメーション推移

前回はTweenクラスを導入して、アニメーションの動きを表現できるようになりました。これをシーンの切り替えにも応用したい。

実はこれまでシーンの推移を編集したことがありません。これ重要項目なので、改めて向き合ってみる。
古都さんのフレームワークに最初からあった、タイトルからメインシーンに切り替えるコードを理解して、シーンの推移アニメーションを追加できるまでを目標とします。

Sceneをどう切り替えてる?

(2021.11.13更新)

まず、ゲームを動かす大元のエンジン(engine.js)の中に在るSceneクラスの記述を確認してみます。

engine.js >> Sceneクラスの確認


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

//〜〜〜〜〜〜〜

    //Sceneを変更するメソッド
    changeScene(newScene) {//Sceneを変更します。ただScene自身がSceneを変更することはできないので、Actorの場合と同じようにイベントだけ発生させて、処理は上位のGameクラスに任せます
        this.dispatchEvent('changescene', new Event(newScene));
    }

大元のクラスを見ると
changeScene(newScene)
で、シーンが変更できることが分かります。

main.js >> TitleSceneの確認


class TitleScene extends Scene {
    constructor() {
        super('タイトル', screenCanvasWidth, screenCanvasHeight, null);
        const title = new TextLabel(100, 200, 'RPG製作',"white", 25);
        this.open(title);
    }

    update(context, input, touch, gameInfo) {
        super.update(context, input, touch, gameInfo);
        if(input.getKeyDown(' ') || touch.touch) {
            this.changeScene(new MainScene);
        }
    }
}
で、ゲーム内容を記述する方にあるTitleScene(Sceneの拡張クラス)の表記はこれ。
スペースか画面タッチが押されたときにメインシーンに移り変わるコードを描いてます。
その中のシーンの切り替えは、このような書式です。
            this.changeScene(new MainScene);

Sceneの拡張クラスで、あらかじめ様々なシーンを用意しておいて
this.changeScene(new Scene);の記述で切り替えられる仕組みのようです。

では、このような感じでタイトルシーン、メインシーン、クリアシーンと3つの場面を作り、また最初のタイトルに戻る動作を施してみたいと思います。

engine.js >> Sceneクラスに追記


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

//〜〜〜〜〜〜〜

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

    //メッセージウィンドウを生成してシーンに追加する関数
    messageOpen(messages, callback) { 
        const messageWindow = new MessageWindow(messages, callback);
        this.open(messageWindow);
    }
}

main.js >> 各Sceneのコード


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);
        super(x, y, sprite, hitArea, ['npc']);
        this.name = name;
        this.flag = 0; //選択肢のフラグ
        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); //1
    }
    talkStart(talkNumber) { //会話を表示する関数、NPCの拡張クラスではこの関数を上書きして各々の会話内容を設定します。
        let messages=[''];
        let callback=()=>{};
        if( this.flag < 1 ) { //フラグによって会話を切り替えることもできる
          switch (this.talkNumber) { //番号によって、会話を別の内容で用意できる記述です
              default : messages = [ //ここに会話イベントを登録しておく
                  {name:this.name, text:['こんにちは。あのー、せんたくもんだいです。', 'クイズ出すから答えてね。'], img:this.illust[0], enter:true },
                  {name:this.name, text:'火で燃やすと何になる?', img:this.illust[1] },
                  {select:[ //ここから選択肢を表示
                      new Select('はい', () => {
                          messages.unshift('はいが選ばれました。'); this.flag += 1; //.unshiftでメッセージを割り込み追加
                          messages.push( {name:this.name, text:'灰#[1,はい]ですね〜、あなたは正解をえらびました!!#.#.', img:this.illust[0], leave:true} ); //メッセージを最後尾に追加
                      }),
                      new Select('いいえ', () => {
                          messages.unshift('いいえが選ばれました。'); this.flag = 0; //メッセージを割り込み追加する
                          messages.push( {name:this.name, text:'残念、あなたは不正解です。', img:this.illust[0], leave:true} ); //.pushでメッセージを配列の後ろに追加する
                      }),
                  ]},
              ];
              callback = () => {
                  this.talkNumber++;
              }
              break;
          }//switch
        }
        else if(this.flag === 1) {
            messages = {name:this.name, text:'おめでとう! 全問正解だよ!#.#.', img:this.illust[0]};
            callback = () => {this.dispatchEvent('gameClear') }
        }
        this.messageOpen(messages, callback);
    }
}


class MainScene extends Scene {
    constructor() {
        super('メイン', 800, 600, null);

        this.add(global.player);
        const npc1 = new NPC_girl1(150, 100,'すみれ');
        this.add(npc1);

        npc1.addEventListener('gameClear', () => {
            this.changeScene(new ClearScene);
        });
    }
}

class TitleScene extends Scene {
    constructor() {
        super('タイトル', screenCanvasWidth, screenCanvasHeight, null);
        const title = new TextLabel(100, 200, 'RPG製作',"white", 25);
        this.open(title);
    }

    update(context, input, touch, gameInfo) {
        super.update(context, input, touch, gameInfo);
        if(input.getKeyDown(' ') || touch.touch) {
            this.changeScene(new MainScene);
        }
    }
}

class ClearScene extends Scene {
    constructor() {
        super('ゲームクリア!', screenCanvasWidth, screenCanvasHeight, null);
        const title = new TextLabel(100, 200, 'ゲームクリア!',"white", 25);
        this.open(title);
        const messages = ['ゲームをクリアしました!#.#.', 'タイトル画面に戻ります!#.#.#.'];
        const callback = () => { this.changeScene(new TitleScene); }
        this.messageOpen(messages, callback);
    }

    update(context, input, touch, gameInfo) {
        super.update(context, input, touch, gameInfo);
    }
}

class TextLabel extends WindowUI { 
    constructor(x, y, text, color="#555", size=16) {
        super(['textLabel']);
        this.x = x;
        this.y = y;
        this.text = text;
        this.color = color;
        this.size = size;
    }
    render(context) {
        context.font(`${this.size}px serif`);
        context.fillColor(`${this.color}`);
        context.fillText(this.text, this.x, this.y);
    }
}


class RolePlayingGame extends Game {
    constructor() {
        super('RPG製作', 60);
        const titleScene = new TitleScene();
        this.changeScene(titleScene);
    }
}


デモ内では、女の子からクイズを出されて正解だったら、次に話しかけたときにクリア画面に移る!という感じでコードを用意しました。 クリア画面の最後でスペースキーを押すと、タイトルに戻ります。
シーンの推移確認
⇒ シーン切り替えのデモを見る



シーンの切り替えについては慣れたら問題なさそう。。
次は切り替えと同時にTweenアニメーションで画面をフェードアウト、フェードイン表示できたら良いだろうなと思うのです。

考えてみましょう。

シーンのフェードイン・アウト表示の手順

  • シーンにブラックアウト(ホワイトアウト)要素をもたせる
  • ブラックアウトの透明度(opacity)を0で設定する
  • 現在のシーンが切り替わる時、Tweenを介して全塗りの透明度(opacity)を0⇒1に変化、画面表示をフェードアウト
  • Tweenの変化が終わったら、シーンを切り替える
  • 新しいシーンに切り替わったら、Tweenを介して全塗りの透明度(opacity)を1⇒0に変化、画面にフェードイン表示


手順としては、画面を黒全塗り(或いは白全塗り)要素を追加して、基本は透明な状態(opacity=0)で描画させておく(' '*)
シーンの切り替えメソッドが動き出したら、Tweenアニメーションで全塗りのopacityを徐々に変化させ、 透明度(opacity)が終わりの値になったら、シーンを切り替える。

という感じで良いのではないかな?と思います。そのようにコードを記述してみます。

Sceneクラスのメソッドを改造する箇所


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

        this.name = name;
        this.backgroundImage = assets.get(bgImageName); //背景画像の指定、nullの場合は黒く塗りつぶす

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

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

        //当たり判定の最適化処理に必要なクラス
        this._qTree = new LinearQuadTreeSpace(this.width, this.height);
        this._detector = new CollisionDetector();

        this.actors = [];//Actorたちを保持する
        this._releasedActors = [];
        this.windowsUI = [];//(メッセージ)ウィンドウを保持する
        this._closedWindowsUI = [];
        this.tweens = [];// アニメーションTweenを保持する

//追記
        //Sceneをフェードイン・アウト表示するための描画要素
        this.contextAllFilter = {opacity:0, black:true}; //追記
        this.addTween( new Tween(this.contextAllFilter, 'opacity', 1, 0, 320, 'easeInSine') ); //追記

    }

//〜〜〜〜〜〜〜省略

    update(context, input, touch, elapsed_ms) {//updateメソッドではシーンに登録されているActor全てを更新し、当たり判定を行い、描画します。描画の前に一度画面全体をクリアするのを忘れないで下さい。
        this.infomation.currentTime +=  elapsed_ms/1000; //経過時間を加算(s)
        this.infomation.elapsed_ms = elapsed_ms; //前回フレームからの経過時間(ms)を記録
        this.tweens.forEach((tween) => tween.update(elapsed_ms)); //登録されたアニメーションを更新する

        //タッチ座標の設定。タッチした画面座標にスクローラー要素を反映させる
        touch.actorX = touch.x + this.scroller.x;
        touch.actorY = touch.y + this.scroller.y;

        //ここから各オブジェクトの状態を更新
        if(this.actors[0]) { //アクターが存在する場合
            this.actors.forEach((actor) => actor.update(this.infomation, input, touch)); //Actorの動きを更新する
            this._ScrollerIsNotOutOfScreen(); //スクローラーが画面外にはみ出さないように
            this._qTree.clear(); //ここから当たり判定に必要なメソッド
            this.actors.forEach((a) => this._qTree.addActor(a)); //hittest四分木空間に各アクターを振り分け
            this._hitTest();//当たり判定を処理する
            this._disposeReleasedActors();//役目を終えたActorをシーンから降ろす
        }
        if(this.infomation.playerIsActive === null) { this.infomation.playerIsActive = true; } //メッセージウィンドウ終了時にプレイヤー操作可能にするタイミングは此処
        if(this.windowsUI[0]) { //ウィンドウが存在する場合
            this.windowsUI.forEach((windowUI) => windowUI.update(this.infomation, input, touch));
            this._disposeClosedWindowsUI();//役目を終えたウィンドウを閉じる
        }

        //ここから描画メソッド
        this._clearRendering(context); //画面の描画をリセット
        this.actors.forEach((obj) => obj.render(context, this.scroller)); //各アウターのrender()メソッドを呼び出して、描画します
        this.windowsUI.forEach((obj) => obj.render(context)); //各ウィンドウUIのrender()メソッドを呼び出して、描画します。
//追記
        if(this.contextAllFilter.opacity > 0) {
            this.contextAllFilter.black ?
                context.fillColor(`rgba(0,0,0,${this.contextAllFilter.opacity})`) : //this.contextAllFilter.black=trueなら黒のフィルター
                context.fillColor(`rgba(255,255,255,${this.contextAllFilter.opacity})`); //this.contextAllFilter.black=falseなら白のフィルター
            context.beginPath();
            context.rect(0, 0, screenCanvasWidth, screenCanvasHeight);
            context.fill();
        }

    }

//〜〜〜〜〜〜〜省略

//追記
    //ここから制作用のメソッド
    changeScene(newScene, option={}) {//Sceneを変更。ただしScene自身がSceneを変更することはできないので、Actorの場合と同じようにイベントだけ発生させて、実際の処理は上位のGameクラスに任せます
        //ここからフェードアウト処理
        const duration = option.duration ? option.duration : 320;
        const easing = option.easing ? option.easing : 'easeOutSine';
        this.contextAllFilter.black = option.white ? false : true; //option.white:true=ホワイトアウト, デフォルトはfalse
        const contextFilterTween = new Tween(this.contextAllFilter, 'opacity', 0, 1, duration, easing);
        this.addTween(contextFilterTween);
        //フェード処理ここまで

        //フェード終了後にシーンを変更する
        const timeOut = option.timeOut ? option.timeOut : duration;
        //切り替わるまでのtimeOut(ms)が指定されてないなら、フェードアウト終了時点でシーンを切り替え
        setTimeout( () => this.dispatchEvent('changescene', new Event(newScene)), timeOut);
    }
}

おおよそ緑の文字で書いてるところが、追記を加えた部分です。
解説を試みますと...

Sceneクラスのconstructor()内の追記する箇所

        //Sceneをフェードイン・アウト表示するための描画要素
        this.contextAllFilter = {opacity:0, black:true}; //追記
        this.addTween( new Tween(this.contextAllFilter, 'opacity', 1, 0, 320, 'easeInSine') ); //追記

まずは、シーンにブラックアウト(ホワイトアウト)するのに必要な要素を定めます。 this.contextAllFilter(表示画面全てにフィルター掛けます)の文字列で定義したオブジェクトに、透明度のopacity、黒塗りか白塗りかのblack:true ? falseという要素を持たせています。 そしていきなりフェードイン表示に関する記述です。constructor()内の記述はシーンが切り替わった直後に走るメソッドなので、まずここに記します!

new Tween(this.contextAllFilter, 'opacity', 1, 0, 24, 'easeInSine')の記述の意味は、前回のTweenコラムに書いてるので察してください。

Sceneクラスのupdate()内の追記する箇所

//追記
        if(this.contextAllFilter.opacity > 0) {
            this.contextAllFilter.black ?
                context.fillColor(`rgba(0,0,0,${this.contextAllFilter.opacity})`) : //this.contextAllFilter.black=trueなら黒のフィルター
                context.fillColor(`rgba(255,255,255,${this.contextAllFilter.opacity})`); //this.contextAllFilter.black=falseなら白のフィルター
            context.beginPath();
            context.rect(0, 0, screenCanvasWidth, screenCanvasHeight);
            context.fill();
        }

そしてシーンのアップデートの最後にフィルターを描画するメソッドを加えます。 これは、塗りつぶしカラーの透明度をthis.contextAllFilter.opacityから読み取って、値を代入。その色で画面全体を塗りつぶしているプログラムです。this.contextAllFilter.black=true ? falseで、ブラックアウトかホワイトアウトかを判別しています。

透明度が0の場合は描画をスキップできる(無駄な動作をしない)ようにif()で囲っています。

SceneクラスのchangeScene()メソッドの改造

    //ここから制作用のメソッド
    changeScene(newScene, option={}) {//Sceneを変更。ただしScene自身がSceneを変更することはできないので、Actorの場合と同じようにイベントだけ発生させて、実際の処理は上位のGameクラスに任せます
        //ここからフェードアウト処理
        const duration = option.duration ? option.duration : 320;
        const easing = option.easing ? option.easing : 'easeOutSine';
        this.contextAllFilter.black = option.white ? false : true; //option.white:true=ホワイトアウト, デフォルトはfalse
        const contextFilterTween = new Tween(this.contextAllFilter, 'opacity', 0, 1, duration, easing);
        this.addTween(contextFilterTween);
        //フェード処理ここまで

        //フェード終了後にシーンを変更する
        const timeOut = option.timeOut ? option.timeOut : duration;
        //切り替わるまでのtimeOut(ms)が指定されてないなら、フェードアウト終了時点でシーンを切り替え
        setTimeout( () => this.dispatchEvent('changescene', new Event(newScene)), timeOut);
    }

最後にシーンを切り替える時のメソッドを変更してフェードアウト・アニメーションの追記、さらにフェードアウト後に遅れてシーンが切り替わるよう、setTimeout(callback, timeOut)というメソッドを使ってtimeOutミリ秒後にシーンが変更されるようなコードにしています。

なお、changeScene(newScene, option={})に代入できる値option={}の中には、経過時間やeasingの種類、ホワイトアウトにする?などのオプション値を記述できるようにし、シーンの状況に応じて使い分けられるよう工夫してみました。


エラーやつづりの間違いなどを片付けながら、ここまででやっとシーン推移のアニメーションを実装することができました!

シーンの推移確認
⇒ シーン推移アニメーションのデモを見る



改良前とくらべると、ゲーム画面の与える印象が段違いです。すばらしいアップデートでした。


こうしてjavascriptでRPGを作るためのゲームフレームワークを構築してるのですが、残る作業は以下ほどか?

  • BGMと効果音の導入
  • セーブ(localStorage={})機能の導入
  • ゲームアプリ化、パッケージング処理
  • メニュー画面の実装など?


くらいでしょうか。土台のゴールが見えてきつつあります。




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

すぺしゃるさんくす

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


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