Cocos2d-JS 触り始めた3

前回までの作業で操作することでブロックを避けられるようになり、少しゲームっぽくなってきた。
もう少し弄ってみる。


画面外に出たらゲームオーバーにする

まだ上下にはみ出した時にゲームオーバーにならないので、これがゲームオーバーになるようにする。
diff --git a/src/app.js b/src/app.js
index 28cd4bc..db28f83 100644
--- a/src/app.js
+++ b/src/app.js
@@ -156,12 +156,14 @@ var HelloWorldScene = cc.Scene.extend({
     update:function(dt) {
       var myShipBB = this.myShip.getBoundingBox();
       var size = cc.director.getWinSize();
+      if (myShipBB.y+myShipBB.height < 0 || myShipBB.y > size.height) {
+        this.gameover();
+        return;
+      }
       for(var i = this.blocks.length - 1, bl; i >= 0; --i) {
         bl = this.blocks[i];
         if (cc.rectIntersectsRect(myShipBB, bl.getBoundingBox())) {
-          this.unscheduleUpdate();
-          cc.eventManager.removeListener(this);
-          cc.director.runScene(cc.TransitionFade.create(0.5, new HelloWorldScene()));
+          this.gameover();
           return;
         }
         if (bl.x+bl.width < 0) {
@@ -172,4 +174,9 @@ var HelloWorldScene = cc.Scene.extend({
         }
       }
     },
+    gameover:function() {
+      this.unscheduleUpdate();
+      cc.eventManager.removeListener(this);
+      cc.director.runScene(cc.TransitionFade.create(0.5, new HelloWorldScene()));
+    }
 });
自機が完全に画面の外に出たら終わり。

飽きたのでちょっと遊ぶ

動作テストのためにブロックを避け続けていたので、たまにはブロック側から自機を避けてもいい気がしてきた。試しに自機に近づいたブロックが勝手に吹っ飛んでいく処理を書いてストレス解消しようと思う。

判定として「自機に半径rピクセル以上近づいたら」とやりたいところではあるものの円と矩形の衝突判定は複雑で重いので、今回は色々な面から妥協して自機を囲う大きな矩形と接触したら弾くことにする。
diff --git a/src/app.js b/src/app.js
index db28f83..04473b9 100644
--- a/src/app.js
+++ b/src/app.js
@@ -155,14 +155,25 @@ var HelloWorldScene = cc.Scene.extend({
     },
     update:function(dt) {
       var myShipBB = this.myShip.getBoundingBox();
+      var barrierBB = cc.rect(myShipBB.x-40,myShipBB.y-40,myShipBB.width+80,myShipBB.height+80);
       var size = cc.director.getWinSize();
       if (myShipBB.y+myShipBB.height < 0 || myShipBB.y > size.height) {
         this.gameover();
         return;
       }
-      for(var i = this.blocks.length - 1, bl; i >= 0; --i) {
+      for(var i = this.blocks.length - 1, bl, bb; i >= 0; --i) {
         bl = this.blocks[i];
-        if (cc.rectIntersectsRect(myShipBB, bl.getBoundingBox())) {
+        bb = bl.getBoundingBox();
+        if (cc.rectIntersectsRect(barrierBB, bb)) {
+          var vx = (bb.x+bb.width*0.5) - (barrierBB.x+barrierBB.width*0.5);
+          var vy = (bb.y+bb.height*0.5) - (barrierBB.y+barrierBB.height*0.5);
+          var l = 1/Math.sqrt(vx*vx+vy*vy)*4;
+          bl.attr({
+            vx: vx * l,
+            vy: vy * l,
+          });
+        }
+        if (cc.rectIntersectsRect(myShipBB, bb)) {
           this.gameover();
           return;
         }
自機より40ピクセル大きい矩形で判定して、ヒットした場合は自機と反対側の方向へ吹き飛ばすようにした。

これによってブロックは左端からスクリーンアウトする以外の理由で退場するケースが出てくるようになったので、画面外に出た判定処理をもう少しまともに作り直す。
diff --git a/src/app.js b/src/app.js
index 04473b9..ecb688c 100644
--- a/src/app.js
+++ b/src/app.js
@@ -177,10 +177,12 @@ var HelloWorldScene = cc.Scene.extend({
           this.gameover();
           return;
         }
-        if (bl.x+bl.width < 0) {
+        if (bl.x+bl.width < 0 || bl.x > size.width || bl.y+bl.height < 0 || bl.y > size.height) {
           bl.attr({
             x: size.width,
-            y: -bl.height+Math.random()*(size.height+bl.height)
+            y: -bl.height+Math.random()*(size.height+bl.height),
+            vx: -0.5-Math.random()*3,
+            vy: -0.25+Math.random()*0.5
           });
         }
       }
ブロックが表示領域外に出たら各パラメータをセットし直す感じになった。

この挙動を修正すると、今度はブロックの初期位置が横に2画面分あると仮定して見えない位置に配置していたのが、ゲーム開始直後に画面外判定を食らっていきなり同じ位置に集結してしまうようになった。

この問題を解決する方法は色々あるものの、致命的な問題ではないのでひとまず保留にする。一番短絡的な解決方法はブロックの画面外判定を右側だけもう1画面先にすることだと思う。

他にも高い位置から落下をすると結構な速度が出るのでそのタイミングでブロックを弾き飛ばしても接触できてしまうとかの問題もある。

これらの問題は後で考えるとして、取りあえず遊びで作ったこのバリアー機能は自機にバリアーフラグが立っている時だけ有効にするようにしよう。
diff --git a/src/app.js b/src/app.js
index ecb688c..976cd6f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -64,6 +64,7 @@ var HelloWorldLayer = cc.Layer.extend({
 var MyShipLayer = cc.Layer.extend({
     sprite:null,
     vy: 3,
+    barrier:false,
     ctor:function () {
         this._super();
         this.sprite = cc.Sprite.create(res.nc_png);
@@ -164,7 +165,7 @@ var HelloWorldScene = cc.Scene.extend({
       for(var i = this.blocks.length - 1, bl, bb; i >= 0; --i) {
         bl = this.blocks[i];
         bb = bl.getBoundingBox();
-        if (cc.rectIntersectsRect(barrierBB, bb)) {
+        if (this.myShip.barrier && cc.rectIntersectsRect(barrierBB, bb)) {
           var vx = (bb.x+bb.width*0.5) - (barrierBB.x+barrierBB.width*0.5);
           var vy = (bb.y+bb.height*0.5) - (barrierBB.y+barrierBB.height*0.5);
           var l = 1/Math.sqrt(vx*vx+vy*vy)*4;
このバリアーフラグを立てる処理自体はまたの機会、ということで。

タイトル画面をつける

重い腰を上げ、ついにタイトル画面をつけることにする。重かった理由は目新しい要素がないことと、リソースを追加するのが面倒だったから。
diff --git a/main.js b/main.js
index 8569b66..34eecb3 100644
--- a/main.js
+++ b/main.js
@@ -3,7 +3,7 @@ cc.game.onStart = function(){
        cc.view.resizeWithBrowserSize(true);
     //load resources
     cc.LoaderScene.preload(g_resources, function () {
-        cc.director.runScene(new HelloWorldScene());
+        cc.director.runScene(new TitleScene());
     }, this);
 };
 cc.game.run();
diff --git a/res/game_start_normal.png b/res/game_start_normal.png
new file mode 100644
index 0000000..b4212fd
Binary files /dev/null and b/res/game_start_normal.png differ
diff --git a/res/game_start_selected.png b/res/game_start_selected.png
new file mode 100644
index 0000000..4e4c43a
Binary files /dev/null and b/res/game_start_selected.png differ
diff --git a/res/title.jpg b/res/title.jpg
new file mode 100644
index 0000000..d1d1285
Binary files /dev/null and b/res/title.jpg differ
diff --git a/src/app.js b/src/app.js
index 976cd6f..e58c103 100644
--- a/src/app.js
+++ b/src/app.js
@@ -194,3 +194,43 @@ var HelloWorldScene = cc.Scene.extend({
       cc.director.runScene(cc.TransitionFade.create(0.5, new HelloWorldScene()));
     }
 });
+
+var TitleLayer = cc.Layer.extend({
+    ctor:function () {
+        this._super();
+        var size = cc.director.getWinSize();
+        var sprite = cc.Sprite.create(res.title_jpg);
+        sprite.attr({
+            x: size.width * 0.5,
+            y: size.height * 0.5
+        });
+        this.addChild(sprite, 0);
+
+        var gameStartItem = cc.MenuItemImage.create(
+            res.game_start_normal_png,
+            res.game_start_selected_png,
+            function () {
+                cc.director.runScene(cc.TransitionFade.create(0.5, new HelloWorldScene()));
+            }, this);
+        gameStartItem.attr({
+            x: size.width * 0.5,
+            y: 80,
+            anchorX: 0.5,
+            anchorY: 0.5
+        });
+
+        var menu = cc.Menu.create(gameStartItem);
+        menu.x = 0;
+        menu.y = 0;
+        this.addChild(menu, 1);
+        return true;
+    }
+});
+
+var TitleScene = cc.Scene.extend({
+    onEnter:function () {
+        this._super();
+        var layer = new TitleLayer();
+        this.addChild(layer);
+    }
+});
diff --git a/src/resource.js b/src/resource.js
index 00cd775..2f3b84c 100644
--- a/src/resource.js
+++ b/src/resource.js
@@ -2,7 +2,10 @@ var res = {
     HelloWorld_png : "res/HelloWorld.png",
     CloseNormal_png : "res/CloseNormal.png",
     CloseSelected_png : "res/CloseSelected.png",
-    nc_png : "res/nc.png"
+    nc_png : "res/nc.png",
+    title_jpg : "res/title.jpg",
+    game_start_normal_png : "res/game_start_normal.png",
+    game_start_selected_png : "res/game_start_selected.png"
 };

 var g_resources = [
@@ -10,7 +13,10 @@ var g_resources = [
     res.HelloWorld_png,
     res.CloseNormal_png,
     res.CloseSelected_png,
-    res.nc_png
+    res.nc_png,
+    res.title_jpg,
+    res.game_start_normal_png,
+    res.game_start_selected_png

     //plist
リソースとしてタイトル画像、スタートボタンの通常時とタップ中の画像を追加し、それらを使った TitleLayer を作り、それを配置するだけの単純な TitleScene を作った。

ほとんど初期状態の HelloWorldScene と変わらない構成になっている。ボタン画像はこのサイトで適当に作った。

真面目に映える感じのタイトルにするならもう少し動かしたりとかタイトルっぽく振る舞う必要がありそう。

ゲームオーバーの処理も入れる

タイトル画面を作ったついでに、ゲームオーバーの処理も入れてしまうことにする。

この画面はリスタートとタイトル画面に戻ることができるようにする。
diff --git a/res/restart_normal.png b/res/restart_normal.png
new file mode 100644
index 0000000..a1105f7
Binary files /dev/null and b/res/restart_normal.png differ
diff --git a/res/restart_selected.png b/res/restart_selected.png
new file mode 100644
index 0000000..d953a99
Binary files /dev/null and b/res/restart_selected.png differ
diff --git a/res/return_to_title_normal.png b/res/return_to_title_normal.png
new file mode 100644
index 0000000..de11728
Binary files /dev/null and b/res/return_to_title_normal.png differ
diff --git a/res/return_to_title_selected.png b/res/return_to_title_selected.png
new file mode 100644
index 0000000..af5b3c2
Binary files /dev/null and b/res/return_to_title_selected.png differ
diff --git a/src/app.js b/src/app.js
index e58c103..0c185f2 100644
--- a/src/app.js
+++ b/src/app.js
@@ -61,6 +61,62 @@ var HelloWorldLayer = cc.Layer.extend({
     }
 });

+var GameOverLayer = cc.Layer.extend({
+    ctor:function () {
+        this._super();
+        var size = cc.director.getWinSize();
+        var sprite = cc.Sprite.create(res.title_jpg);
+        sprite.attr({
+            x: size.width * 0.5,
+            y: size.height * 0.5,
+            opacity: 0
+        });
+        this.addChild(sprite, 0);
+
+        var restartItem = cc.MenuItemImage.create(
+            res.restart_normal_png,
+            res.restart_selected_png,
+            function () {
+                cc.director.runScene(cc.TransitionFade.create(0.5, new HelloWorldScene()));
+            }, this);
+        restartItem.attr({
+            x: size.width * 0.5,
+            y: 160,
+            anchorX: 0.5,
+            anchorY: 0.5
+        });
+        var returnToTitleItem = cc.MenuItemImage.create(
+            res.return_to_title_normal_png,
+            res.return_to_title_selected_png,
+            function () {
+                cc.director.runScene(cc.TransitionFade.create(0.5, new TitleScene()));
+            }, this);
+        returnToTitleItem.attr({
+            x: size.width * 0.5,
+            y: 80,
+            anchorX: 0.5,
+            anchorY: 0.5
+        });
+
+        var menu = cc.Menu.create([restartItem, returnToTitleItem]);
+        menu.attr({
+          x: 0,
+          y: 0,
+          opacity: 0,
+          enabled: false,
+        });
+        this.addChild(menu, 1);
+        sprite.runAction(cc.FadeIn.create(0.5));
+        menu.runAction(cc.Sequence.create(
+          cc.DelayTime.create(0.5),
+          cc.FadeIn.create(0.5),
+          cc.CallFunc.create(function(){ this.enabled = true; }, menu)
+        ));
+
+        return true;
+    }
+});
+
 var MyShipLayer = cc.Layer.extend({
     sprite:null,
     vy: 3,
@@ -191,7 +247,9 @@ var HelloWorldScene = cc.Scene.extend({
     gameover:function() {
       this.unscheduleUpdate();
       cc.eventManager.removeListener(this);
-      cc.director.runScene(cc.TransitionFade.create(0.5, new HelloWorldScene()));
+
+      var layer = new GameOverLayer();
+      this.addChild(layer);
     }
 });

diff --git a/src/resource.js b/src/resource.js
index 2f3b84c..574ab3e 100644
--- a/src/resource.js
+++ b/src/resource.js
@@ -5,7 +5,11 @@ var res = {
     nc_png : "res/nc.png",
     title_jpg : "res/title.jpg",
     game_start_normal_png : "res/game_start_normal.png",
-    game_start_selected_png : "res/game_start_selected.png"
+    game_start_selected_png : "res/game_start_selected.png",
+    restart_normal_png : "res/restart_normal.png",
+    restart_selected_png : "res/restart_selected.png",
+    return_to_title_normal_png : "res/return_to_title_normal.png",
+    return_to_title_selected_png : "res/return_to_title_selected.png"
 };

 var g_resources = [
@@ -16,7 +20,11 @@ var g_resources = [
     res.nc_png,
     res.title_jpg,
     res.game_start_normal_png,
-    res.game_start_selected_png
+    res.game_start_selected_png,
+    res.restart_normal_png,
+    res.restart_selected_png,
+    res.return_to_title_normal_png,
+    res.return_to_title_selected_png

     //plist
タイトル画面とほとんど一緒。本当はゲームオーバーっぽい画面を出すところだけどリソースを使いまわしてタイトルと同じ画像を出している。

ちょっと違うのは、ボタンが2つあるところと、フェードインエフェクトが付いているところ。そしてフェードインの処理は画像のあとボタンがフェードインするようになっていて、完全に表示されるまでの間は enabledfalse にしておくことで、間違って押すのを防止している。

ゲームオーバー画面はゲームプレイ中にボタンが現れる感じになるので、ゲームオーバーになったことに気付かないまま間違えてボタンを押してしまう可能性がありそうだったのでボタンが有効になるまでの時間を少し遅くした。とはいえ1秒でも足りない気もするので、ちゃんと死亡エフェクトを見せるのが正しい気もする。

あと、この追加によって勘違いによるバグがあったのを見つけたので修正した。
diff --git a/src/app.js b/src/app.js
index 0c185f2..c2fa56f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -165,6 +165,7 @@ var BlockLayer = cc.Layer.extend({
 var HelloWorldScene = cc.Scene.extend({
     myShip:null,
     blocks:null,
+    el:null,
     onEnter:function () {
         this._super();
         var size = cc.director.getWinSize();
@@ -199,11 +200,12 @@ var HelloWorldScene = cc.Scene.extend({
           this.blocks.push(bl);
         }

-        cc.eventManager.addListener({
+        this.el = cc.EventListener.create({
             event: cc.EventListener.TOUCH_ONE_BY_ONE,
             swallowTouches: true,
             onTouchBegan: this.onTap,
-        }, this)
+        });
+        cc.eventManager.addListener(this.el, this);
         this.scheduleUpdateWithPriority(10);
     },
     onTap:function(touch, event) {
@@ -246,7 +248,7 @@ var HelloWorldScene = cc.Scene.extend({
     },
     gameover:function() {
       this.unscheduleUpdate();
-      cc.eventManager.removeListener(this);
+      cc.eventManager.removeListener(this.el);

       var layer = new GameOverLayer();
       this.addChild(layer);
イベントリスナーの削除は cc.eventManager.removeListener を呼んだ時点では失敗していて、シーン自体が入れ替わった時に合わせて削除されていたために成功と勘違いしていた。正しく削除するためには cc.EventListener.create で作成したインスタンスを渡さないといけなかったようだ。

そして Android 版で動かそうとしたところ "Invalid Native Object" というエラーで失敗したため以下の修正を行った。
diff --git a/src/app.js b/src/app.js
index c2fa56f..e625506 100644
--- a/src/app.js
+++ b/src/app.js
@@ -98,7 +98,7 @@ var GameOverLayer = cc.Layer.extend({
             anchorY: 0.5
         });

-        var menu = cc.Menu.create([restartItem, returnToTitleItem]);
+        var menu = cc.Menu.create(restartItem, returnToTitleItem);
         menu.attr({
           x: 0,
           y: 0,
配列のネイティブオブジェクトの処理に失敗したのかもしれないと思い、実装を確認してみたところ可変長引数でも受け取れるような構造になっていたのでそのまま渡すことで動くようになった。

また、致命的な問題ではないもののどうやらメニューのフェードインも上手く動いていないようだ。Cocos2dx との挙動の違いがあるんだろうか。

今回はあまり目新しい変化はなくブロック登場時のバグを注入しただけに見える気もするが、今回までの進捗はこちら