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 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, white:false}; //追記
        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.white ?
                context.fillColor(`rgba(255,255,255,${this.contextAllFilter.opacity})`) : //this.contextAllFilter.white=trueなら白のフィルター
                context.fillColor(`rgba(0,0,0,${this.contextAllFilter.opacity})`); //this.contextAllFilter.white=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.white = option.white ? true : false; //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, white:false}; //追記
        this.addTween( new Tween(this.contextAllFilter, 'opacity', 1, 0, 333, 'easeInSine') ); //追記

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

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

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

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

そしてシーンのアップデートの最後にフィルターを描画するメソッドを加えます。 これは、塗りつぶしカラーの透明度をthis.contextAllFilter.opacityから読み取って、値を代入。その色で画面全体を塗りつぶしているプログラムです。this.contextAllFilter.white=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.white = option.white ? true : false; //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={})機能の導入
  • ゲームアプリ化、パッケージング処理
  • メニュー画面の実装など?

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

では、BGMと効果音に続きます。
BGMと効果音を再生する

すぺしゃるさんくす

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

https://sbfl.net/

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

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