tmlib.js

[tmlib.js]tmlibで学ぶ、イベント駆動型のクラス設計

More than 3 years have passed since last update.

tmlibで学ぶ、イベント駆動型のクラス設計

なんて難しそうなこといってますが…ゲーム開発をやっていて、後で即死するパターンとかそういうののメモです。

まずはモデルとなるソースコードを貼ります。
ゲームの動作を書くGameSceneクラスと、今回はサンプルとしてエフェクトを書くEffectクラスを作ります。仕様を追加する際に、汎用性のある(というか、ある程度の仕様変更に耐えられる)Effectクラスを作っていきます。

var SCREEN_WIDTH  = 640;
var SCREEN_HEIGHT = 960;
var SCREEN_CENTER_X = SCREEN_WIDTH/2;
var SCREEN_CENTER_Y = SCREEN_HEIGHT/2;

tm.main(function() {
    var app = tm.display.CanvasApp("#world");
    app.resize(SCREEN_WIDTH, SCREEN_HEIGHT);
    app.fitWindow();
    app.replaceScene(GameScene());
    app.run();
});


tm.define("GameScene", {
    superClass: "tm.app.Scene",

    init: function() {
        var self = this;
        self.superInit();

        self.fromJSON({
            children: {
                "label": {
                    type: "tm.display.Label",
                    init: ["タッチするとエフェクトが出るよ"],
                    x: SCREEN_CENTER_X,
                    y: 800,
                }
            },
        });

        // エフェクト作成
        self.onpointingstart = function () {
            Effect().addChildTo(self);
        };
    },

    update: function () {
        var self = this;
        // 3秒だったらゲームオーバー
        if (self.app.frame > 30*2) {
            self.gameOver();
            self.update = function () {};
        }
    },
    // ゲームオーバーシーンへの遷移など
    gameOver: function () {
        var self = this;
        tm.display.Label("GAME OVER")
            .setPosition(SCREEN_CENTER_X, SCREEN_CENTER_Y)
            .setFontSize(40)
            .addChildTo(self);
    },
});

tm.define("Effect", {
    superClass: "tm.display.CanvasElement",

    init: function () {
        var self = this;
        self.superInit();
        self.startEffect();
    },

    startEffect: function () {
        var self = this;
        // 10個作る
        (10).times(function (i) {
            var effect = tm.display.RectangleShape(30,30)
                .addChildTo(self)
                .setPosition(SCREEN_CENTER_X, SCREEN_CENTER_Y);
            effect.tweener.clear()
                .to({
                    x: tm.util.Random.randint(-100, SCREEN_WIDTH+100),
                    y: tm.util.Random.randint(-100, SCREEN_HEIGHT+100),
                    alpha: 0,
                }, 600)
                .call(function () {
                    effect.remove();
                });
        });
    },
});

動作するサンプルはこちら

新しい仕様を追加する

ゲームオーバー前にエフェクトを表示して、ゲームオーバーを実行したい(させてほしい)という仕様を追加することになったとします。どう実装するでしょうか?

一番ダメなパターン

(動作するサンプルはこちら)

    // GameSceneクラス
    update: function () {
        var self = this;
        // 3秒だったらゲームオーバー
        if (self.app.frame > 30*2) {
            Effect(self).addChildTo(self);
            self.update = function () {};
        }
    },
    // Effectクラス
    init: function (scene) {
        var self = this;
        self.gameScene = scene;
        self.superInit();
        self.startEffect();
    },

    startEffect: function () {
        var self = this;
        var counter = 10;
        // 10個作る
        (counter).times(function (i) {
            var effect = tm.display.RectangleShape(30,30)
                .addChildTo(self)
                .setPosition(SCREEN_CENTER_X, SCREEN_CENTER_Y);
            effect.tweener.clear()
                .to({
                    x: tm.util.Random.randint(-100, SCREEN_WIDTH+100),
                    y: tm.util.Random.randint(-100, SCREEN_HEIGHT+100),
                    alpha: 0,
                }, 600)
                .call(function () {
                    effect.remove();
                    --counter;
                    if (counter <= 0) {
                        self.gameScene.gameOver();
                    }
                });
        });
    },

GameSceneクラスのインスタンスごと、Effectのコンストラクタに渡して保持しちゃってます。100%後悔します。もはやオブジェクト指向やり直したら?といわれるレベルです。他人がコードを見たとき追いかけることすら困難になります。相互参照です。でもたまに見ます。(ごめんなさい)

これももちろんダメ

言わずもがーなー。

(動作するサンプルはこちら)

    // GameSceneクラス
    update: function () {
        var self = this;
        // 3秒だったらゲームオーバー
        if (self.app.frame > 30*2) {
            Effect().startEffect(self).addChildTo(self);
            self.update = function () {};
        }
    },
    // Effectクラス
    startEffect: function (scene) {
        var self = this;
        var counter = 10;
        // 10個作る
        (counter).times(function (i) {
            var effect = tm.display.RectangleShape(30,30)
                .addChildTo(self)
                .setPosition(SCREEN_CENTER_X, SCREEN_CENTER_Y);
            effect.tweener.clear()
                .to({
                    x: tm.util.Random.randint(-100, SCREEN_WIDTH+100),
                    y: tm.util.Random.randint(-100, SCREEN_HEIGHT+100),
                    alpha: 0,
                }, 600)
                .call(function () {
                    effect.remove();
                    --counter;
                    if (counter <= 0) {
                        scene.gameOver();
                    }
                });
        });

        return self;
    },

コンストラクタでエフェクトの実行を行うのではなく、直接Scene側から呼び出すようにしています。それ自体はいいのですが、これもGameSceneを引数で渡して、Effect側でGameSceneの関数を呼び出しています。さっきよりはまだマシですけど、これも汎用性ないっす。というか、GameSceneクラスのgameOver関数を、汎用的に作るべきEffectクラスで呼ぶような構造はやめた方がいいです。他のプロジェクトで使いたいとか無理になるので。

だいたい正解?

イベントを発火してキャッチする構造がなかったりしたら使います。callbackです。これで相互参照無くなりました。

(動作するサンプルはこちら)

    // GameSceneクラス
    update: function () {
        var self = this;
        // 3秒だったらゲームオーバー
        if (self.app.frame > 30*2) {
            Effect().addChildTo(self).startEffect(function () {
                self.gameOver();
            });
            self.update = function () {};
        }
    },
    // Effectクラス
    startEffect: function (callback) {
        var self = this;
        var counter = 10;
        // 10個作る
        (counter).times(function (i) {
            var effect = tm.display.RectangleShape(30,30)
                .addChildTo(self)
                .setPosition(SCREEN_CENTER_X, SCREEN_CENTER_Y);
            effect.tweener.clear()
                .to({
                    x: tm.util.Random.randint(-100, SCREEN_WIDTH+100),
                    y: tm.util.Random.randint(-100, SCREEN_HEIGHT+100),
                    alpha: 0,
                }, 600)
                .call(function () {
                    effect.remove();
                    --counter;
                    if (counter <= 0) {
                        callback();
                    }
                });
        });

        return self;
    },

この考え方をtmlibで覚えました

callbackと同じ考え方です。引数にすら縛られないので、さらに汎用性高し。tmlibだとイベントを管理しているのでこっちの方がいいです。

(動作するサンプルはこちら)

    // GameSceneクラス
    update: function () {
        var self = this;
        // 3秒だったらゲームオーバー
        if (self.app.frame > 30*2) {
            Effect().addChildTo(self).startEffect().on("effectend", function () {
                self.gameOver();
            });
            self.update = function () {};
        }
    },
    // Effectクラス
    startEffect: function () {
        var self = this;
        var counter = 10;
        // 10個作る
        (counter).times(function (i) {
            var effect = tm.display.RectangleShape(30,30)
                .addChildTo(self)
                .setPosition(SCREEN_CENTER_X, SCREEN_CENTER_Y);
            effect.tweener.clear()
                .to({
                    x: tm.util.Random.randint(-100, SCREEN_WIDTH+100),
                    y: tm.util.Random.randint(-100, SCREEN_HEIGHT+100),
                    alpha: 0,
                }, 600)
                .call(function () {
                    effect.remove();
                    --counter;
                    if (counter <= 0) {
                        self.fire(tm.event.Event("effectend"));
                        // 以下でもOK
                        // self.flare("effectend");
                    }
                });
        });

        return self;
    },

かなり影響を受けた考え方だったので備忘録としてまとめてみました。
tmlibにマジ感謝。YO YO

最後に

イベントの発火はcounterで数えて実行していますが、こういう風に書くとよりかっこいいです。

    // Effectクラス
    startEffect: function () {
        var self = this;
        var counter = 10;
        // 10回 flow.passが呼ばれたら実行される
        var flow = tm.util.Flow(counter, function () {
            self.fire(tm.event.Event("effectend"));
            // 以下でもOK
            // self.flare("effectend");
        });

        // 10個作る
        (counter).times(function (i) {
            var effect = tm.display.RectangleShape(30,30)
                .addChildTo(self)
                .setPosition(SCREEN_CENTER_X, SCREEN_CENTER_Y);
            effect.tweener.clear()
                .to({
                    x: tm.util.Random.randint(-100, SCREEN_WIDTH+100),
                    y: tm.util.Random.randint(-100, SCREEN_HEIGHT+100),
                    alpha: 0,
                }, 600)
                .call(function () {
                    effect.remove();
                    flow.pass();
                });
        });

        return self;
    },