弾幕シューティングゲーム制作その6 敵の制御と弾幕生成の設計
こんにちは!ジェイです。前回は、敵のショットと自キャラとの当たり判定とやられた時の処理まで追加しました。今回は敵の制御と弾幕生成の仕様について考えてみます。
当たり判定を設定しやすいように
その前に、当たり判定をしやすいようにCMoverクラスを変えたので、載せておきます。
具体的には、弾幕のタイプを指定するだけで、当たり判定を追加するコンストラクタと、デフォルト引数を使って、呼び出し側ですべて設定しないでも、よいコンストラクタにしました。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ShootingGame
{
public class CMover
{
public double X { set; get; }
public double Y { set; get; }
protected double L { set; get; }
protected double T { set; get; }
protected double R { set; get; }
protected double B { set; get; }
public int Life { set; get; }
protected int Cnt { set; get; }
// 弾幕用コンストラクタ
public CMover(double x, double y, int life, int range_type)
{
X = x;
Y = y;
Life = life;
Cnt = 0;
switch (range_type)
{
case 0:
L = -10.0;
T = -10.0;
R = 10.0;
B = 10.0;
break;
case 1:
L = -4.0;
T = -4.0;
R = 4.0;
B = 4.0;
break;
case 2:
L = -2.0;
T = -10.0;
R = 2.0;
B = 10.0;
break;
case 3:
L = -5.0;
T = -5.0;
R = 5.0;
B = 5.0;
break;
case 4:
case 5:
case 6:
case 7:
case 8:
case 9:
L = -4.0;
T = -4.0;
R = 4.0;
B = 4.0;
break;
case 10:
L = -1.0;
T = -1.0;
R = 1.0;
B = 1.0;
break;
case 11:
L = -4.0;
T = -4.0;
R = 4.0;
B = 4.0;
break;
case 12:
case 13:
L = -3.0;
T = -3.0;
R = 3.0;
B = 3.0;
break;
case 14:
L = -0.5;
T = -0.5;
R = 0.5;
B = 0.5;
break;
case 15:
L = -4.0;
T = -4.0;
R = 4.0;
B = 4.0;
break;
}
}
public CMover(double x, double y, int life, double l=10.0, double t=10.0, double r=-10.0, double b=-10.0)
{
X = x;
Y = y;
Life = life;
Cnt = 0;
L = l;
T = t;
R = r;
B = b;
}
// 当たり判定処理
public bool Hit(CMover m)
{
return
X + L < m.X + m.R && m.X + m.L < X + R &&
Y + T < m.Y + m.B && m.Y + m.T < Y + B;
}
public bool Hit(CMover m, float l, float t, float r, float b)
{
return
X + l < m.X + m.R && m.X + m.L < X + r &&
Y + t < m.Y + m.B && m.Y + m.T < Y + b;
}
}
}
敵クラスについて
敵クラスもだいぶ変えました。CEnemyクラスは基底クラスとして、 abstractキーワードで、抽象クラスにしてます。 実際にインスタンス化するのは、CEnemyクラスの派生クラスです。この時にMove関数とDraw関数がそれぞれの異なった派生クラスごとに動作するようにオーバーライドしてます。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DxLibDLL;
using System.Windows.Forms;
using static ShootingGame.CShootingGame;
namespace ShootingGame
{
public abstract class CEnemy : CMover
{
protected int Muki;
protected int ImageCnt = 0;
protected int Score = 0;
public CEnemy(double x, double y, double hit_size, int life, int score)
: base(x, y, life, -hit_size/2, -hit_size/2, hit_size/2, hit_size/2)
{
Muki = 1;
Score = score;
}
public virtual void Move()
{
}
public virtual void Draw()
{
}
}
public class CEnemy1 : CEnemy
{
CBulletFactory BulletFactory = null;
int BulletColor; // 弾の色
int BulletType; // 弾の画像の種類
public CEnemy1(double x, double y, double hit_size, int life = 20, int bullet_type = 0, int bullet_color = 0) : base(x, y, hit_size, life, 100)
{
BulletType = bullet_type;
BulletColor = bullet_color;
}
public override void Move()
{
if (Cnt < 60)
{
Y += 2.0;
}
else if (Cnt < 300)
{
if (Cnt % 24 == 0)
{
SGP.Bullet.Add(new CBullet(x: X, y: Y));
}
}
if (Cnt > 300 + 240)
{
Y -= 2.0;
}
ImageCnt = Muki * 3 + (Cnt % 18) / 6;
if (X < 10 + 20 || // 左座標の上限
X > FIELD_MAX_X - 10 + 42 || // 右座標の上限
Y < 0 || // 上座標の上限
Y > FIELD_MAX_Y - 5 + 20) // 下座標の上限
{
Life = 0;
}
++Cnt;
}
public override void Draw()
{
// 敵を描画
DX.DrawRotaGraphF((float)X, (float)Y, 1.0f, 0.0f, SGP.ImageEnemy[ImageCnt], 1);
// 当たり判定を描画
DX.DrawBox((int)(X + L), (int)(Y + T), (int)(X + R), (int)(Y + B), DX.GetColor(0, 0, 255), 1);
// X,Y座標の描画
DX.DrawString(35, 35, SGP.GetPlayerAngle(X, Y).ToString(), DX.GetColor(255, 255, 255));
}
}
}
弾幕制御に必要な関数
弾幕を生成するクラスを作る前に、プレイヤーとの角度を計算する関数をCShootingGame.csに追加しておきます。以下のコード。
// プレイヤーまでの角度を計算する
public double GetPlayerAngle(double x, double y)
{
if (0 < Player.Count)
{
// 一番近くのプレイヤーの要素を取得する
var player = Player.OrderByDescending(p => (p.X-x)*(p.X-x) + (p.Y-y)*(p.Y-y)).FirstOrDefault();
return Math.Atan2(player.Y - y, player.X - x) / PI2;
}
else
{
return 0.0;
}
}
Math.Atan2は(ターゲットのY座標-自分のY座標, ターゲットのX座標-自分のX座標)で角度をラジアンという単位で返してくれます。これをPI2で割っています。なぜでしょう?
ラジアンをPI2で割る理由
まずラジアンをそのままのイメージを表すとこんな感じです。右に弾を打たせたい時は0、下に打たせたい時は3.14/2、左に打たせたい時は3.14、上に打たせたい時は-3.14/2です。ラジアンの値のまま指定すると右を0として3.14で180度を表現できます。 0の右を起点にプラスなら右回転で、マイナスなら左回転します。

PI2で割る場合
PI2で割る場合も右を0の起点としてプラスなら右回転で、マイナスなら左回転するのは同じです。
右に弾を打たせたい時は0、下に打たせたい時は0.25、左に打たせたい時は0.5、上に打たせたい時は-0.25です。PI2で割ると右を0として0.5で180度を表現でき、1.0で360度を表現できます。PI2で割った方が圧倒的にわかりやすいですよね?なのでわざわざ単位を変換してます。

弾クラスの整理
弾クラスもデフォルト引数を使って、最低限座標だけ引数に渡せば弾を発射できるように書き直しました。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DxLibDLL;
using static ShootingGame.CShootingGame;
using System.Windows.Forms;
namespace ShootingGame
{
public class CBullet : CMover
{
int GraphType, Color, Rotate;
double Yaw, VYaw;// 回転角度
double Angle, Speed;
double AngleRate, SpeedRate;
public CBullet(double x, double y, double speed=3.0, double angle=0, double speed_rate=0.0, double angle_rate=0.0,
int graph_type=0, int color=0, int rotate=0) : base(x, y, 1, graph_type)
{
Angle = angle;
AngleRate = angle_rate;
Speed = speed;
SpeedRate = speed_rate;
GraphType = graph_type;
Color = color;
Rotate = rotate;
Yaw = 0.0;
VYaw = 0.0;
SGP.PlaySound(0);
}
public void Move()
{
//弾を回転させる
if (Rotate == 1) Yaw += VYaw;
//角度をラジアン単位に変換する
double rad = Angle * PI2;
// 角度と速度を使って、座標を更新する
X += Speed * Math.Cos(rad);
Y += Speed * Math.Sin(rad);
// 角度に角速度を加算する
Angle += AngleRate;
// 速度に加速度を加算する
Speed += SpeedRate;
}
public void Draw()
{
DX.DrawRotaGraphF((float)X, (float)Y, 1.0, (Angle + 0.25f + Yaw) * PI2, SGP.ImageBullet[GraphType][Color], 1);
//DX.DrawBox((int)(X + L), (int)(Y + T), (int)(X + R), (int)(Y + B), DX.GetColor(0, 0, 255), 1);
}
}
}
敵と弾幕の制御について
今の設計では、CEnemyの中で移動と弾の発射の処理を同時に行っています。
このまま作っていくと問題が発生してしまいます。どんな問題でしょうか?
結論からいうと、このままCEnemyクラスを増やして敵の行動パターンと弾幕のパターンを同じ、CEnemyクラスに含んだままにしておくと、敵の行動パターン×弾幕のパターンの数だけ爆発的にクラスが増えてしまいます。ここで思いつく解決策が以下です。
- ラムダ式を使って弾幕生成関数部分のみを入れ替える
- CEnemyクラスの中に弾幕生成クラスを作って、弾幕の制御はそちらにまかせる
この方法をとることによって例えば、敵の行動3パターンで弾幕も3パターンだとすると前の方法だと9つCEnemyの派生クラスを定義してやらなければ、ならなかったのですが、3+3でCEnemyクラス3つ、弾幕生成クラス3つずつ増やせばよくなります。
弾幕生成クラスを作る
ではさっそく弾幕生成クラスのCBulletFactoryを作ります。新規追加でファイル名をCBulletFactory.csと名前を付けます。
using System.Text;
using System.Threading.Tasks;
using static ShootingGame.CShootingGame;
namespace ShootingGame
{
public abstract class CBulletFactory
{
protected int Cnt, BulletType, BulletColor;
public CBulletFactory(int bullet_type, int bullet_color)
{
Cnt = 0;
BulletType = bullet_type;
BulletColor = bullet_color;
}
public virtual void Move(double x, double y) { }
}
public class CAimBulletFactory : CBulletFactory
{
public CAimBulletFactory(int bullet_type, int bullet_color) : base(bullet_type, bullet_color)
{
}
public override void Move(double x, double y)
{
if (Cnt % 24 == 0)
{
SGP.Bullet.Add(new CBullet(x:x, y:y, speed:2.5, angle:SGP.GetPlayerAngle(x, y), graph_type:BulletType, color:BulletColor));
}
Cnt++;
}
}
}
CBulletFactoryクラス自体はインスタンス化することはないので、abstractキーワードで、抽象クラスにしてます。実際に使うにはCBulletFactoryクラスをCEnemyに持たせておいて、EnemyBulletPatternという変数を見て、 CBulletFactoryの派生クラスのインスタンスを生成して、CEnemyのMove関数内でCBulletFactoryの派生クラスのオーバーライドされたMove関数がよばれて、弾幕を制御します。
敵クラスで弾幕生成クラスを使う
準備ができたので、敵クラスで弾幕生成クラスを使って、実際に弾を発射してみましょう。追加した部分はハイライトしてあります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DxLibDLL;
using System.Windows.Forms;
using static ShootingGame.CShootingGame;
namespace ShootingGame
{
public abstract class CEnemy : CMover
{
protected int Muki;
protected int ImageCnt = 0;
protected int Score = 0;
public CEnemy(double x, double y, double hit_size, int life, int score)
: base(x, y, life, -hit_size/2, -hit_size/2, hit_size/2, hit_size/2)
{
Muki = 1;
Score = score;
}
public virtual void Move()
{
}
public virtual void Draw()
{
}
}
public class CEnemy1 : CEnemy
{
CBulletFactory BulletFactory = null;
int BulletColor; // 弾の色
int BulletType; // 弾の画像の種類
bool BulletDischarge; // 弾幕発射フラグ
public CEnemy1(double x, double y, double hit_size, int life = 20, int bullet_type = 0, int bullet_color = 0, int bullet_pattern = 0, bool bullet_discharge = false) : base(x, y, hit_size, life, 100)
{
BulletType = bullet_type;
BulletColor = bullet_color;
BulletDischarge = bullet_discharge;
CreateBulletFactory(bullet_pattern);
}
public override void Move()
{
if (Cnt < 60)
{
Y += 2.0;
}
else if (Cnt < 300)
{
//if (Cnt % 24 == 0)
//{
// SGP.Bullet.Add(new CBullet(x: X, y: Y));
//}
BulletDischarge = true;
}
if (Cnt > 300 + 240)
{
BulletDischarge = false;
Y -= 2.0;
}
ImageCnt = Muki * 3 + (Cnt % 18) / 6;
if (X < 10 + 20 || // 左座標の上限
X > FIELD_MAX_X - 10 + 42 || // 右座標の上限
Y < 0 || // 上座標の上限
Y > FIELD_MAX_Y - 5 + 20) // 下座標の上限
{
Life = 0;
}
if (BulletFactory != null && BulletDischarge)
{
BulletFactory.Move(X, Y);
}
++Cnt;
}
public override void Draw()
{
// 敵を描画
DX.DrawRotaGraphF((float)X, (float)Y, 1.0f, 0.0f, SGP.ImageEnemy[ImageCnt], 1);
// 当たり判定を描画
DX.DrawBox((int)(X + L), (int)(Y + T), (int)(X + R), (int)(Y + B), DX.GetColor(0, 0, 255), 1);
// X,Y座標の描画
DX.DrawString(35, 35, SGP.GetPlayerAngle(X, Y).ToString(), DX.GetColor(255, 255, 255));
}
public void CreateBulletFactory(int bullet_pattern)
{
switch(bullet_pattern)
{
case 0:
BulletFactory = new CAimBulletFactory(0, 0);
break;
}
}
}
}
これで敵の行動パターンはCEnemyの派生クラスで、弾の発射パターンは、BulletFactoryの派生クラスで制御できるようになりました。bullet_patternの引数でどのBulletFactoryの派生クラスを選ぶか選択できます。
実行結果
今回のプロジェクトをダウンロードする。 プロジェクトに画像は同梱してませんのでこちらからダウンロードしてください。画像を置く階層はデバッグモードで実行するなら、bin/Debugでリリースモードで実行するならbin/Releaseです。
これで弾幕と敵の制御を切り離して実行できるようになりました。次回は敵のデータをファイルから読み込んで、出現させる処理を追加します。