読者です 読者をやめる 読者になる 読者になる

フニゲの開発日記

Electronとか...

Creatureてすと

cocos2d-html5

 CreatureシンガポールのKestrel Moon Studiosが3月にリリースした2Dアニメーション製作用のツール。¥11800。

www.youtube.com

 Cocos2d-html5 V4のalphaプレビューで見かけたので試してみたのだが、まだ不具合が多いのであまりお薦めできない。いろいろ痒いところに手が届かない感じがするのだが、手軽にメッシュ変形のアニメーションが作れるのは魅力的だと思う。あんまり他のツール触ってないので比較とかできないんだけど、そのへんは誰か詳しい人にお任せしたい。
 普通はSpineとか使うのか。

 テストとして、Cocos2d-html5 V4のalphaプレビューページにあるCreatureのデモを参考にして、自作のアニメを表示してみた。Creatureは他にも多くのweb用描画エンジン(PixiJS・BabylonJS・ThreeJS )に対応しているし、Cocos2d-xやUnity用のランタイムもある。

f:id:funige:20150503113621p:plain

 ……いやその前にまず、Cocos2d-html5 V4というブランチについて。
 フォーラムのこのへんを見るとわかるのだが、Cocos2d-html5 V4はChukong USAに最近入ったIbonさんが中心のプロジェクトで、Cocos2d-JSではなくCocos2d-html5。webブラウザが対象で、ソースコードもtypescriptで全部書き直したりして、中国本土の本隊(Cocos2d-JS)とはかなり異質なものになっている。
 RicardoさんはCocos2dのV4にportしたいと書いているが、すんなりとは行かないと思う。まあ、オープンソースなんだから、使えるものは使っちゃえばいいよね。

 デモを見ると、V3のjsbでは使えなくなったdraw()メソッドを使ってカスタム描画を行っていることがわかる。個人的には、ここだけでもV4で復活して欲しいところ。

 サンプル

加速度センサーを使う

 加速度センサーはMacBookのブラウザでも普通に使えるし、スマホのブラウザでもだいたい使える。Windowsのノートは何年も触ってないので知らないのだが、たぶん使えると思う。

    ctor:function () {
        this._super();

        var size = cc.director.getWinSize();
        this.sprite = cc.Sprite.create("res/HelloWorld.png");
        this.sprite.setPosition(cc.p(size.width / 2, size.height / 2));
        this.addChild(this.sprite, 0);

        if ('accelerometer' in cc.sys.capabilities) {
            cc.inputManager.setAccelerometerInterval(1/60);
            cc.inputManager.setAccelerometerEnabled(true);
            cc.eventManager.addListener({
                event: cc.EventListener.ACCELERATION,
                callback: function (accelEvent, event) {
                    var target = event.getCurrentTarget();

                    var size = cc.director.getWinSize();
                    var w = size.width;
                    var h = size.height;
                    var x = w * accelEvent.x + w/2;
                    var y = h * accelEvent.y + h/2;
                    target.sprite.setPosition(x, y);
                }
            }, this);
        }
    }

 いつも思うのだが加速度センサーのデータは不安定すぎる。そのままでは使えないので、思い切りクッションを入れると今度はなんだかフワフワした動きになってしまうし。

 正確な傾きを取りたいときはジャイロを使えばいいのだが、Cocos2d-JSではサポートがないので、ネイティブでジャイロを監視するコードを書いて、一定間隔でLocalStorageに書き込んでJavaScript側で値を取り出すとか、そんな感じになるのかな。

リプレイ機能を作る

Cocos2d-JS

 リプレイ機能そのものはまあ、あんまり需要ないかもしれないが、例えばチュートリアルを作るときとか、デモプレイを作るときに、操作の履歴をJSONデータに書き出して簡単に再生できるようにしておくと便利だ。

 チュートリアルは開発の最後に短期間で作らざるを得ないことが多いのだが、このへん最初に作っておかないと、あとで苦労することになると思う。

...
var GameLayer = cc.Layer.extend({
    ctor:function () {
        this._super();
        this.scheduleUpdate();

        // 操作を記録
        this.gameLogger = new GameLogger(this);
        this.addChild(this.gameLogger);

        this.gameLogger.doTouchMoved = function (pos) {
            this.player.setPosition(pos); // 自機を移動したとき
        }.bind(this);

        this.gameLogger.doStop = function () {
            cc.log("* replay end *"); // リプレイが終わったとき
            this.gameLogger.init();
        }.bind(this);

        // 自機を表示
        this.player = new cc.Sprite("res/ship.png");
        this.player.setPosition(200, 200);
        this.addChild(this.player);
        
        // ボタンなど表示
        var replayButton = new cc.MenuItemLabel(
            new cc.LabelTTF("[REPLAY]", 24),
            function () { 
                this.gameLogger.replay(); // リプレイボタンを押したらリプレイ開始
                cc.log("* replay *");
            }.bind(this));

        var dumpButton = new cc.MenuItemLabel(
            new cc.LabelTTF("[DUMP]", 24),
            function () { 
                cc.log(this.gameLogger.getLog()); // 履歴をJSONで出力
            }.bind(this));

        dumpButton.y -= 40;
        var menu = new cc.Menu(replayButton, dumpButton);
        this.addChild(menu);
    },

    update:function (dt) {
        this.gameLogger.step();
    },
});

 イベントリスナはGameLoggerの内部で生成して、addEventListenerしている。
 キャッチしたonTouchMovedとかを直接使うのではなく、1/60秒ごとにイベントをバッファに保存して、その履歴を見て自機を動かす処理をGameLoggerのdoTouchMovedなどに書くことにする。

var GameLogger = cc.Node.extend({
    ctor:function (target) {
        this._super();
        this.target = target;
        this.init();

        cc.eventManager.addListener(this.getListener(), target);
    },

    init:function () {
        this.log = {};
        this.mode = "record";
        this.count = 0;
    },

    replay:function () {
        this.pushRecord(this.count, {type:"stop"});
        this.mode = "play";
        this.count = 0;
    },

    step:function () {
        var count = this.count++;
        var record = this.getRecord(count);

        for (var i = 0; i < record.length; i++) {
            var item = record[i];
            switch (item.type) {
            case "touchBegan":
                this.doTouchBegan(item.pos);
                break;
            case "touchMoved":
                this.doTouchMoved(item.pos);
                break;
            case "touchEnded":
                this.doTouchEnded(item.pos);
                break;
            case "touchCancelled":
                this.doTouchCancelled(item.pos);
                break;
            case "stop":
                this.doStop();
                break;
            }
        }
    },

    doTouchBegan:function (pos) {},
    doTouchMoved:function (pos) {},
    doTouchEnded:function (pos) {},
    doTouchCancelled:function (pos) {},
    doStop:function () {},

    getLog:function () {
        return JSON.stringify(this.log);
    },

    getRecord:function (count) {
        if (this.log && this.log[count]) {
            return this.log[count];
        }
        return {};
    },

    pushRecord:function (count, record) {
        if (!this.log[count]) this.log[count] = [];
        this.log[count].push(record);
    },

    onTouchBegan:function (pos) {
        if (this.mode == "record") {
            this.pushRecord(this.count, {type:"touchBegan", pos:pos});
        }
        return true;
    },

    onTouchMoved:function (pos) {
        if (this.mode == "record") {
            this.pushRecord(this.count, {type:"touchMoved", pos:pos});
        }
    },

    onTouchEnded:function (pos) {
        if (this.mode == "record") {
            this.pushRecord(this.count, {type:"touchEnded", pos:pos});
        }
    },

    onTouchCancelled:function (pos) {
        if (this.mode == "record") {
            this.pushRecord(this.count, {type:"touchCancelled", pos:pos});
        }
    },

    getListener:function () {
        this.listener = new cc.EventListener.create({
            event:cc.EventListener.TOUCH_ONE_BY_ONE,
            swallowTouches:true,

            onTouchBegan:function (touch, event) {
                this.onTouchBegan(touch.getLocation());
                return true;
            }.bind(this),

            onTouchMoved:function (touch, event) {
                this.onTouchMoved(touch.getLocation());
            }.bind(this),

            onTouchEnded:function (touch, event) {
                this.onTouchEnded(touch.getLocation());
            }.bind(this),

            onTouchCancelled:function (touch, event) {
                this.onTouchCancelled(touch.getLocation());
            }.bind(this),
        });
        return this.listener;
    }
});

 bindが多くて気持ち悪いのだが、javaScriptだからしょうがないのかな。
 普通cocos2d-jsでゲームを作ってるとbindを使う機会がほとんど無いので、自信が無い。もうちょっときれいに書けると思う。

 この例では1/60秒ごとにupdateが呼ばれることを前提にしているけど、頻繁に処理落ちが起こるような思考型のゲームでは別の仕組みが必要になるだろう。

サンプル

ネイティブ(Objective-CとかJava)のメソッドを呼び出す

Cocos2d-JS

 このへんはだいぶ楽になった。v2の時と同じようにLocalStorage経由で情報を渡すハックもできるが、もう使うことはないかも知れない。

 例として、iOSのバンドルID(またはAndroidのパッケージ名)を返すメソッドを書いてみる。複数のアプリで同じソースコードを共有していて、アプリによって動作を変えたい時に使えるだろう。
 jsb.reflection.callStaticMethodの引数はjOSとAndroidで違うので、場合分けが必要になる。

var getAppId = function () {
    if (cc.sys.os == cc.sys.OS_IOS) {
        return jsb.reflection.callStaticMethod(
            "RootViewController", "getAppId");
    } else if (cc.sys.os == cc.sys.OS_ANDROID) {
        return jsb.reflection.callStaticMethod(
            "org/cocos2dx/javascript/AppActivity", "getAppId", "()Ljava/lang/String;")
    }
    return null;
};

 iOS側の実装はframework/runtime-src/project.ios_mac/ios/RootViewController.mmに追加した。

...
+ (NSString *)getAppId {
    return [[NSBundle mainBundle] bundleIdentifier];
}

 本当はこれだけでいいと思うのだが、どうも今のバージョン (v3.3) はObjective-Cから文字列を返すところにバグがあるようで、23文字未満の文字列だと正常な値が取得できなかった。しょうがないので、適当な文字で埋めて23文字以上になるようにして返す。

+ (NSString *)getAppId {
//  return [[NSBundle mainBundle] bundleIdentifier]; // うーん……

    NSString *appId = [[NSBundle mainBundle] bundleIdentifier];
    return [NSString stringWithFormat:@"%@------------------", appId];
}

 末尾の余計な"----"はjavaScript側で後で取り除けばいいだろう。

 Androidも同様。frameworks/runtime-src/proj.android/src/org/cocos2dx/javascript/AppActivity.javaを適当に修正して、パッケージ名を返すメソッドを追加する。

...
public class AppActivity extends Cocos2dxActivity {
    private static String appId = null; // 追加

    @Override
    public Cocos2dxGLSurfaceView onCreateView() {
        appId = getApplicationContext().getPackageName(); // 追加

        Cocos2dxGLSurfaceView glSurfaceView = new Cocos2dxGLSurfaceView(this);
        // TestCpp should create stencil buffer
        glSurfaceView.setEGLConfigChooser(5, 6, 5, 0, 16, 8);
        return glSurfaceView;
    }

    public static String getAppId() {
        return appId;
    }
}

ネイティブ環境で動かすときの注意点

Cocos2d-JS

 何となく不安なので、開発中のゲームがネイティブ環境で動くかテスト。

 Full版のcocosコマンドを使って、適当なバンドルIDのプロジェクトを作成する。

$ cocos new -l js --ios-bundleid com.hogere.nativetest nativetest

 ios-bundleidの前の「--」はマイナスが2個。次に、Lite版からソースコードをコピーする。例えばLite版のフォルダが「test」ならば

$ cp test/project.json nativetest/
$ cp -r test/src nativetest/src
$ cp -r test/res nativetest/res

 あとはnativetest/frameworks/runtime-src/project.ios_mac/の下にあるxcodeprojファイルをXCodeで開けばネイティブ環境でのビルドができる。

 ……で、何も無ければ誰も苦労しないのだが、以下トラブルシューティング。

 ネイティブ環境のproject.jsonにはコメントが書けないようだ。よくあるパターンだが

//  "jsList"        : ["src/HelloWrold.js", "src/resource.js"]
    "jsList"        : ["src/MyScene.js", "src/resource.js"]

 とかコメントアウトして前の行を残していると、動かなかった。

 それから、メソッドの引数の数とか間違っていると、web環境では何も言われないのにネイティブ環境で怒られることがある。これはまあ、エラーメッセージに行番号が表示されるので一個ずつ直せばいい。

 あとは「Invalid Native Object」エラー。v2の時にも書いたが、ケースバイケースなので、対処が難しいときもあると思う。先週作ったChipmunk2Dを使ったサンプルでテストをしたのだが、iOS用にビルドした時はうまくのに、mac用にビルドするとこのエラーが出て動かなない。何でかなあ。まあMacで出す予定はないからいいんだけど。

 Androidはまだ試してません。

Cocos2d-JS 3.4beta0

Cocos2d-JS

 3.4で3D関係のモジュールやテストが追加された模様。
 ただし、iOSAndroidなどのネイティブ環境でしか動かない。ブラウザ環境では「近い将来3Dをサポートする予定はない」そうで……。

 3DやるならUnityを使えば、って意見は正しいと思うんだけど、ちょっとだけ3Dが欲しいという場面が結構あるので、そういう時にきっと役に立つだろう。

cc.ScrollViewでページング

Cocos2d-JS

 ゲームのステージ選択でよく使う、ページ単位で横スクロールするビューです。

 昔使ってた物を書き直したのですが、このへんはv2の時とあんまり変わってないですね。もうちょっと簡単に書けそうな気がするけど、まあ動けばいいのです。
 onTouchEndedでスクロール位置を見て、適当な位置にスクロールするアニメーションをセットします。

var SlideView = cc.ScrollView.extend({
    ctor:function (size, numPages) {
        var container = new cc.LayerColor(
            cc.color.WHITE, numPages * size.width, size.height);

        this._super(size, container);
        this.setBounceable(false);
        this.setDirection(cc.SCROLLVIEW_DIRECTION_HORIZONTAL);
        this.numPages = numPages;

        cc.eventManager.addListener(slideListener, this);
        container.setPosition(0, 0); // 最初はいちばん左のページを表示する
    },

    getOffset:function () {
        return -this.getContentOffset().x;
    },

    getPage:function () {
        return Math.round(this.getOffset() / this.getViewSize().width);
    },

    moveToPage:function (page) {
        this.scheduleOnce(function () {
            var x = -page * this.getViewSize().width;
            this.stopAllActions();
            this.getContainer().runAction(cc.moveTo(0.1, cc.p(x, 0)));
        }, 0.01);
    },
});

var slideListener = cc.EventListener.create({
    event: cc.EventListener.TOUCH_ONE_BY_ONE,
    swallowTouches: true,

    onTouchBegan: function (touch, event) {
        var slideView = event.getCurrentTarget();
        slideView.page0 = slideView.getPage();
        slideView.offset0 = slideView.getOffset();
        return true;
    },

    onTouchEnded: function (touch, event) {
        var slideView = event.getCurrentTarget();
        var offset = slideView.getOffset();
        var page = slideView.getPage();

        if (page == slideView.page0) {
            var d = 50 // 50ピクセル以上ドラッグしたら左右のページへ移動するように

            if (slideView.page0 > 0) {
                if (offset - slideView.offset0 < -d) {
                    slideView.moveToPage(slideView.page0 - 1);
                    return;
                }
            }
            if (slideView.page0 < slideView.numPages - 1) {
                if (offset - slideView.offset0 > d) {
                    slideView.moveToPage(slideView.page0 + 1);
                    return;
                }
            }
        }
        slideView.moveToPage(page);
    },
});

 cc.ScrollViewのsetBounceableは、バウンスのアニメーションと自前のアニメーションが干渉してうまく動かなかったので、とりあえずfalseにしています。

 SlideViewの使い方は以下のようになります。サイズとページ数を指定して初期化したあとgetContainerでコンテナを取得して、各ページの内容を貼り付けています。

var GameLayer = cc.Layer.extend({
    ctor:function () {
        this._super();

        var size = cc.size(700, 400);
        var slideView = new SlideView(size, 3); // 3枚をページング
        slideView.setPosition(50, 25);
        this.addChild(slideView);

        var container = slideView.getContainer();

        // 各ページの内容は適当に……
        var page0 = new SlidePage(size, 0);
        page0.setPosition(0, 0);
        container.addChild(page0);

        var page1 = new SlidePage(size, 1);
        page1.setPosition(size.width, 0);
        container.addChild(page1);

        var page2 = new SlidePage(size, 2);
        page2.setPosition(size.width * 2, 0);
        container.addChild(page2);
    },
});

var SlidePage = cc.Layer.extend({
    ctor:function (size, page) {
        this._super();

        var label = new cc.LabelTTF("PAGE " + page, "Arial", 24);
        label.setPosition(size.width/2, size.height-50);
        label.setColor(cc.color.BLACK);
        this.addChild(label);
    }
});

サンプル