はじめに
モンストもどきを作るという試みの第7回となる今回は、敵の追加やプレイヤーと敵の弾との当たり判定を実装します。
敵の追加
// アセット
var ASSETS = {
  // 画像
  image: {
    'bg': 'https://rawgit.com/alkn203/piko_strike/master/assets/bg.png',
    'tomapiko': 'https://rawgit.com/phinajs/phina.js/develop/assets/images/tomapiko.png',
    'arrow': 'https://rawgit.com/alkn203/piko_strike/master/assets/arrow.png',
    'dragon': 'https://rawgit.com/alkn203/piko_strike/master/assets/enemy/pipo-enemy021.png',
    'wizard': 'https://rawgit.com/alkn203/piko_strike/master/assets/enemy/pipo-enemy025.png',
    'crash': 'https://rawgit.com/phinajs/phina.js/develop/assets/images/shooting/crash.png',
    'spell': 'https:///rawgit.com/alkn203/piko_strike/master/assets/spell.png',
  },
};
- 新しく魔法使いの敵キャラのアセットを用意します。
- 魔法使いは呪文で攻撃させたいので、その画像も追加します。
/*
 * 呪文クラス
 */
phina.define("Spell", {
  // 継承
  superClass: 'Sprite',
  // コンストラクタ
  init: function() {
    // 親クラス初期化
    this.superInit('spell');
  },
});
- 呪文はSpriteを継承したSpellクラスとして作成します。
敵のデータをパラメータ化する
// 敵データ
var ENEMY_DATA = {
  "dragon": {
    "type": "dragon",
    "life": 200
  },
  "wizard": {
    "type": "wizard",
    "life": 100
  }
};
- 後からメンテナンスが楽になるように、敵のデータは上のようにパラメータ化しておくと、下のように簡単に敵を追加できるようになります。
    // 敵キャラ
    Enemy('dragon').addChildTo(enemyGroup).setPosition(gx.center(), gy.center(-4));
    Enemy('wizard').addChildTo(enemyGroup).setPosition(gx.center(-4), gy.center(-2));
    Enemy('wizard').addChildTo(enemyGroup).setPosition(gx.center(4), gy.center(-2));
敵の攻撃パターンを追加する
 // 敵の攻撃パターン登録
  registEnemyAttack: function() {
    var self = this;
    // 敵の攻撃パターン登録
    this.enemyGroup.children.each(function(enemy) {
      enemy.on('attack', function() {
        // ドラゴン --- 火の玉をランダムな角度に発射
        if (enemy.type === 'dragon') {
        (略)
        }
        // ウィザード --- 呪文を正面に発射
        if (enemy.type === 'wizard') {
          var flow = Flow(function(resolve) {
            var spell = Spell().addChildTo(self.spellGroup).setPosition(enemy.x, enemy.y);
            // tweenerで移動
            spell.tweener.by({y: SCREEN_HEIGHT}, 2000)
                         .call(function() {
                            // 到達後処理
                            spell.remove();
                            resolve();
                         }).play();
          });          
          // 配列に追加
          self.flows.push(flow);
        }
        // 全てのflow実行後
        Flow.all(self.flows).then(function(messages) {
          // ターンチェンジ
          self.changeTurn();
        });
      });
    });
  },
- タイプを元に敵の攻撃パターンを登録します。
- Flowの配列に追加しておけば、それぞれのキャラの攻撃終了のタイミングが違っても、Flow.allで全ての終了を感知したあとに処理ができるので便利です。
プレイヤーのライフ作成
プレイヤーのライフは、モンスターと同じLifeGaugeクラスを継承して作り、画面下に配置します。
    // 画面下部分
    RectangleShape({
      fill: 'gray',
      width: SCREEN_WIDTH,
      height: gy.span(3),
    }).addChildTo(this).setPosition(gx.center(), gy.span(14.5));
    // ライフの背景
    RectangleShape({
      width: gx.span(8),
      height: gy.span(1),
      fill: 'navy',
    }).addChildTo(this).setPosition(gx.center(), gy.span(14.5));
    // プレイヤーのライフ
    this.player.life = LifeGauge({
      width: gx.span(7),
      height: gy.span(1) / 2,
      life: 100,
    }).addChildTo(this).setPosition(gx.center(), gy.span(14.5));
プレイヤーと敵の攻撃との当たり判定
プレイヤーと敵の攻撃との当たり判定は、hitTestEnemyAttackという関数を用意して、メインシーンのupdateで行います。
  // 敵の攻撃との当たり判定
  hitTestEnemyAttack: function() {
    var player = this.player;
    this.fireGroup.children.some(function(fire) {
      if (!fire.hitted && player.hitTestElement(fire)) {
        fire.hitted = true;
        player.damage(10);
        return true;  
      }
    });
    this.spellGroup.children.some(function(spell) {
      if (!spell.hitted && player.hitTestElement(spell)) {
        spell.hitted = true;
        player.damage(10);
        return true;  
      }
    });
  },
- 敵の弾とプレイヤーが接触している間ダメージを受けつづけるとすぐにライフが減るので、hittedというフラグを付けて、一つの弾につき1回しかヒットしないようにしています。
ダメージエフェクト
  // ダメージ処理
  damage: function(value) {
    this.life.value -= value;
    // 点滅させる
    this.tweener.fadeOut(100).fadeIn(100).fadeOut(100).fadeIn(100).play();
  },
- 敵の時と同様に、プレイヤークラスにdamageメソッド作って処理を行っていますが、ダメージを受けた時の点滅をtweenerを使って表現しています。
動作確認
おわりに
まだゲームとしては成り立っていない部分もありますが、phina.jsを使ってこのタイプのゲームも作れることがお分かり頂けたかと思います。一連のチュートリアルが、phina.jsでゲームを作る方にとって少しでも参考になれば幸いです。
全体コード
コード表示
// グローバルに展開
phina.globalize();
// アセット
var ASSETS = {
  // 画像
  image: {
    'bg': 'https://rawgit.com/alkn203/piko_strike/master/assets/bg.png',
    'tomapiko': 'https://rawgit.com/phinajs/phina.js/develop/assets/images/tomapiko.png',
    'arrow': 'https://rawgit.com/alkn203/piko_strike/master/assets/arrow.png',
    'dragon': 'https://rawgit.com/alkn203/piko_strike/master/assets/enemy/pipo-enemy021.png',
    'wizard': 'https://rawgit.com/alkn203/piko_strike/master/assets/enemy/pipo-enemy025.png',
    'crash': 'https://rawgit.com/phinajs/phina.js/develop/assets/images/shooting/crash.png',
    'spell': 'https:///rawgit.com/alkn203/piko_strike/master/assets/spell.png',
  },
};
// 定数
var SCREEN_WIDTH = 640;
var SCREEN_HEIGHT = 960;
var PLAYER_SPEED = 50;
var FRICTION = 0.98;
var PLAYER_ATTACK_POWER = 20;
// 敵データ
var ENEMY_DATA = {
  "dragon": {
    "type": "dragon",
    "life": 200
  },
  "wizard": {
    "type": "wizard",
    "life": 100
  }
};
/*
 * メインシーン
 */
phina.define("MainScene", {
  // 継承
  superClass: 'DisplayScene',
  // コンストラクタ
  init: function() {
    // 親クラス初期化
    this.superInit();
    var gx = this.gridX;
    var gy = this.gridY;
    // 背景
    Sprite('bg').addChildTo(this).setPosition(gx.center(), gy.center());
    // 矢印
    var arrow = Sprite('arrow').addChildTo(this).hide();
    arrow.alpha = 0.75;
    // プレイヤー
    this.player = Player().addChildTo(this).setPosition(gx.center(), gy.span(12));
    // 敵グループ
    var enemyGroup = DisplayElement().addChildTo(this);
    // 火の玉グループ
    this.fireGroup = DisplayElement().addChildTo(this);
    // 呪文グループ
    this.spellGroup = DisplayElement().addChildTo(this);
    // 敵キャラ
    Enemy('dragon').addChildTo(enemyGroup).setPosition(gx.center(), gy.center(-4));
    Enemy('wizard').addChildTo(enemyGroup).setPosition(gx.center(-4), gy.center(-2));
    Enemy('wizard').addChildTo(enemyGroup).setPosition(gx.center(4), gy.center(-2));
    // 画面下部分
    RectangleShape({
      fill: 'gray',
      width: SCREEN_WIDTH,
      height: gy.span(3),
    }).addChildTo(this).setPosition(gx.center(), gy.span(14.5));
    // ライフの背景
    RectangleShape({
      width: gx.span(8),
      height: gy.span(1),
      fill: 'navy',
    }).addChildTo(this).setPosition(gx.center(), gy.span(14.5));
    // プレイヤーのライフ
    this.player.life = LifeGauge({
      width: gx.span(7),
      height: gy.span(1) / 2,
      life: 100,
    }).addChildTo(this).setPosition(gx.center(), gy.span(14.5));
    var self = this;
    this.flows = [];
    // 矢印
    arrow.setPosition(this.player.x, this.player.y);
    // ターン
    this.turn = 'player';
    // メッセージ
    this.massage = Label().addChildTo(this).hide();
    // シーン全体から参照できるようにする
    this.arrow = arrow;
    this.enemyGroup = enemyGroup;
    // 敵の攻撃パターン登録
    this.registEnemyAttack();
  },
  // 敵の攻撃パターン登録
  registEnemyAttack: function() {
    var self = this;
    // 敵の攻撃パターン登録
    this.enemyGroup.children.each(function(enemy) {
      enemy.on('attack', function() {
        // ドラゴン --- 火の玉をランダムな角度に発射
        if (enemy.type === 'dragon') {
          (8).times(function(i) {
            // flow作成
            var flow = Flow(function(resolve) {
              var fire = Fire().addChildTo(self.fireGroup).setPosition(enemy.x, enemy.y);
              // 到達地点を計算
              var deg = Random.randint(0, 360);
              var x = Math.cos(Math.degToRad(deg)) * SCREEN_WIDTH;
              var y = Math.sin(Math.degToRad(deg)) * SCREEN_HEIGHT;
              // tweenerで移動
              fire.tweener.by({x: x, y: y}, 2000)
                          .call(function() {
                            // 到達後処理
                            fire.remove();
                            resolve();
                          }).play();
            });
            // 配列に追加
            self.flows.push(flow);
          });
        }
        // ウィザード --- 呪文を正面に発射
        if (enemy.type === 'wizard') {
          var flow = Flow(function(resolve) {
            var spell = Spell().addChildTo(self.spellGroup).setPosition(enemy.x, enemy.y);
            // tweenerで移動
            spell.tweener.by({y: SCREEN_HEIGHT}, 2000)
                         .call(function() {
                            // 到達後処理
                            spell.remove();
                            resolve();
                         }).play();
          });          
          // 配列に追加
          self.flows.push(flow);
        }
        // 全てのflow実行後
        Flow.all(self.flows).then(function(messages) {
          // ターンチェンジ
          self.changeTurn();
        });
      });
    });
  },
  // ターン変更処理
  changeTurn: function() {
    var massage = this.massage;
    // プレイヤー or 敵のターン
    massage.text = this.turn === 'player' ? 'ENEMY TURN' : 'PLAYER TURN';
    // ラベルのキャンバス幅
    var width = massage.calcCanvasWidth();
    massage.x = SCREEN_WIDTH + width / 2;
    massage.y = this.gridY.center();
    massage.show();
    
    var self = this;
    // 右から左に流れるアニメーション
    massage.tweener.clear()
                   .to({x: this.gridX.center()}, 500, "easeOutCubic")
                   .wait(200)
                   .to({x: -(width / 2)}, 500, "easeInCubic")
                   .call(function(){
                     // ターン変更
                     if (self.turn === 'player') {
                       self.setInteractive(false);
                       self.turn = 'enemy';
                       self.attackPlayer();
                     }
                     else {
                       self.setInteractive(true);
                       self.turn = 'player';
                     }
                   });
  },
  // 敵の攻撃処理
  attackPlayer: function() {
    this.enemyGroup.children.each(function(enemy) {
      enemy.flare('attack');
    }); 
  },
  // 毎フレーム更新処理
  update: function() {
    var velocity = this.player.physical.velocity;
    // 画面端反射処理
    this.reflectScreen();
    // 敵との反射処理
    this.reflectEnemy();
    // 敵の攻撃との当たり判定
    this.hitTestEnemyAttack();
    // 一定速度を下回ったら強引に停止させる
    if (velocity.length() > 0 && velocity.length() < 2) {
      velocity.set(0, 0);
      // ターンチェンジ
      this.changeTurn();
    }
  },
  // 画面端との反射処理
  reflectScreen: function() {
    var velocity = this.player.physical.velocity;
    var player = this.player;
    // 画面端反射処理
    if (velocity.x < 0 && player.left < 0) velocity.x *= -1;
    if (velocity.x > 0 && player.right > SCREEN_WIDTH) velocity.x *= -1;
    if (velocity.y < 0 && player.top < 0) velocity.y *= -1;
    if (velocity.y > 0 && player.bottom > this.gridY.span(13)) velocity.y *= -1;
  },
  // 敵との反射処理
  reflectEnemy: function() {
    var velocity = this.player.physical.velocity;
    var player = this.player;
    // 敵との当たり判定
    this.enemyGroup.children.each(function(enemy) {
      enemy.accessories.each(function(accessory) {
        if (accessory.id && accessory.hitTest(player.collider)) {
          if (velocity.x > 0 && accessory.id === 'left') velocity.x *= -1;
          if (velocity.x < 0 && accessory.id === 'right') velocity.x *= -1;
          if (velocity.y > 0 && accessory.id === 'top') velocity.y *= -1;
          if (velocity.y < 0 && accessory.id === 'bottom') velocity.y *= -1;
          // 敵のライフを減らす
          enemy.damage(PLAYER_ATTACK_POWER);
        }  
      });
      
    });
  },
  // 敵の攻撃との当たり判定
  hitTestEnemyAttack: function() {
    var player = this.player;
    this.fireGroup.children.some(function(fire) {
      if (!fire.hitted && player.hitTestElement(fire)) {
        // 一度当たったフラグ
        fire.hitted = true;
        // プレイヤーダメージ処理
        player.damage(10);
        return true;  
      }
    });
    this.spellGroup.children.some(function(spell) {
      if (!spell.hitted && player.hitTestElement(spell)) {
        spell.hitted = true;
        player.damage(10);
        return true;  
      }
    });
  },
  // タッチ開始時処理
  onpointstart: function(e) {
    // タッチ位置を記録
    this.startPos = Vector2(e.pointer.x, e.pointer.y);
    // 矢印表示
    this.arrow.setPosition(this.player.x, this.player.y).show();
    // 縦を0に縮小
    this.arrow.scaleY = 0;
  },
  // タッチ移動時処理
  onpointmove: function(e) {
    // 矢印の方向を求める
    var pos = Vector2(e.pointer.x, e.pointer.y);
    this.arrow.rotation = this.getDegree(this.startPos, pos) + 90;
    // 距離に応じて矢印を拡大縮小
    var distance2 = Vector2.distanceSquared(this.startPos, pos);
    this.arrow.scaleY = distance2 / 10000;
  },
  // タッチ終了時処理
  onpointend: function(e) {
    // 画面タッチを無効にする
    this.setInteractive(false);
    // 矢印非表示
    this.arrow.hide();
    // 矢印の角度から方向を決める
    var deg = this.arrow.rotation - 90;
    // 角度からベクトルを求めてプレイヤーに設定
    this.player.physical.velocity = Vector2().fromDegree(deg, PLAYER_SPEED);
    // 摩擦をかけて徐々に減速させる
    this.player.physical.friction = FRICTION;
  },
  // 2点間の角度を求める
  getDegree: function(from, to) {
    return Math.radToDeg(Math.atan2(from.y - to.y, from.x - to.x));
  },
});
/*
 * プレイヤークラス
 */
phina.define("Player", {
  // 継承
  superClass: 'Sprite',
  // コンストラクタ
  init: function() {
    // 親クラス初期化
    this.superInit('tomapiko');
    // コライダー
    this.collider;
  },
  // ダメージ処理
  damage: function(value) {
    this.life.value -= value;
    // 点滅させる
    this.tweener.fadeOut(100).fadeIn(100).fadeOut(100).fadeIn(100).play();
  },
});
/*
 * 敵クラス
 */
phina.define("Enemy", {
  // 継承
  superClass: 'Sprite',
  // コンストラクタ
  init: function(name) {
    // データ抽出
    var data = ENEMY_DATA[name];
    // 親クラス初期化
    this.superInit(name);
    // 敵タイプ
    this.type = data.type;
    // ライフゲージ
    this.life = LifeGauge({
      width: this.width * 0.8,
      height: this.height / 10,
      life: data.life,
    }).addChildTo(this);
    // ライフゲージの位置
    this.life.y = this.height / 2 - this.life.height * 2;
    var self = this;
    // ライフが無くなった時の処理
    this.life.on('empty', function() {
      self.remove();  
    });
    
    var s1 = this.width * 0.8;
    var s2 = this.width / 10;
    var half = this.width / 2;
    // コライダー
    var top = Collider().attachTo(this).setSize(s1, s2).offset(0, -half);
    top.id = 'top';
    var bottom = Collider().attachTo(this).setSize(s1, s2).offset(0, half);
    bottom.id = 'bottom';
    var left = Collider().attachTo(this).setSize(s2, s1).offset(-half, 0);
    left.id = 'left';
    var right = Collider().attachTo(this).setSize(s2, s1).offset(half, 0);
    right.id = 'right';
  },
  // ダメージ処理
  damage: function(value) {
    this.life.value -= value;
    // 点滅させる
    this.tweener.fadeOut(100).fadeIn(100).fadeOut(100).fadeIn(100).play();
  },
});
/*
 * 火の玉クラス
 */
phina.define("Fire", {
  // 継承
  superClass: 'Sprite',
  // コンストラクタ
  init: function() {
    // 親クラス初期化
    this.superInit('crash', 64, 64);
    // フレームインデックス指定
    this.setFrameIndex(15);
  },
});
/*
 * 呪文クラス
 */
phina.define("Spell", {
  // 継承
  superClass: 'Sprite',
  // コンストラクタ
  init: function() {
    // 親クラス初期化
    this.superInit('spell');
  },
});
/*
 * ライフゲージクラス
 */
phina.define("LifeGauge", {
  // 継承
  superClass: 'Gauge',
  // コンストラクタ
  init: function(param) {
    // 親クラス初期化
    this.superInit({
      width: param.width,
      height: param.height,
      fill: 'red',
      stroke: 'silver',
      gaugeColor: 'limegreen',
      maxValue: param.life,
      value: param.life,
    });
    // 値変化アニメーションの時間
    this.animationTime = 500;
  },
});
/*
 * メイン処理
 */
phina.main(function() {
  // アプリケーションを生成
  var app = GameApp({
    title: 'Piko Strike',
    // メインシーンから開始
    startLabel: 'main',
    // アセット読み込み
    assets: ASSETS,
  });
  // 実行
  app.run();
});
