Posted at

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

More than 1 year has passed since last update.


はじめに

モンストもどきを作るという試みの第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();
});