LoginSignup
14
5

More than 5 years have passed since last update.

phina.jsでモンストっぽいゲームを作る【最終回】

Posted at

はじめに

モンストもどきを作るという試みの第7回となる今回は、敵の追加やプレイヤーと敵の弾との当たり判定を実装します。

GIF.gif

敵の追加

// アセット
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を使って表現しています。

動作確認

[runstantプロジェクトへのリンク]

おわりに

まだゲームとしては成り立っていない部分もありますが、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();
});

14
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
5