ソースコードはGitHubで公開しています。⇒ https://github.com/mi3w2a1/SimpleShooter
前回の簡単なシューティングゲーム 本当に鳩でも分かるC#講座では自機と敵の描画をしました。しかしこのままでは自機を移動させることができないし、弾丸を発射することもできません。そこで今回は自機の移動と弾丸の発射、当たり判定を考えます。
Contents
自機を移動させる
まず移動ですが、これはJikiクラスのMoveLeftやMoveRightをtrueにすることで自動的に移動できるようになります。
キーが押されたらOnKeyDownメソッドが離されたらOnKeyUpメソッドが呼び出されます。そのときに適切なフィールド変数をtrueやfalseにするだけです。
方向キーで移動しますが、弾丸を発射をする動作も実装することにします。Shotという自作メソッドをつくります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
public partial class Form1 : Form { protected override void OnKeyDown(KeyEventArgs e) { if (e.KeyCode == Keys.Left) Jiki.MoveLeft = true; if (e.KeyCode == Keys.Right) Jiki.MoveRight = true; if (e.KeyCode == Keys.Up) Jiki.MoveUp = true; if (e.KeyCode == Keys.Down) Jiki.MoveDown = true; if (e.KeyCode == Keys.Space) Shot(); base.OnKeyDown(e); } protected override void OnKeyUp(KeyEventArgs e) { if (e.KeyCode == Keys.Left) Jiki.MoveLeft = false; if (e.KeyCode == Keys.Right) Jiki.MoveRight = false; if (e.KeyCode == Keys.Up) Jiki.MoveUp = false; if (e.KeyCode == Keys.Down) Jiki.MoveDown = false; base.OnKeyDown(e); } } |
弾丸を発射させる
ではShotメソッドはどうすればいいのでしょうか?
その前に描画すべき弾丸をつくる必要があります。リソースとして読み込んだPNGファイルの一部からBitmapを生成してBurretBitmapに格納します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
public partial class Form1 : Form { Bitmap BurretBitmap = null; Bitmap GetBurretBitmap() { // 弾丸。左上の座標は(41,46)、幅14、高さ14 Rectangle sourceRectange = new Rectangle(new Point(41, 46), new Size(14, 14)); int destWidth = 14; int destHeight = 14; Bitmap bitmap1 = new Bitmap(destWidth, destHeight); Graphics graphics = Graphics.FromImage(bitmap1); graphics.DrawImage(CharactersBitmap, new Rectangle(0, 0, destWidth, destHeight), sourceRectange, GraphicsUnit.Pixel); graphics.Dispose(); return bitmap1; } void GetBitmaps() { CharactersBitmap = Properties.Resources.sprite2; JikiBitmap = GetJikiBitmap(); EnemyBitmap1 = GetEnemyBitmap1(); EnemyBitmap2 = GetEnemyBitmap2(); EnemyBitmap3 = GetEnemyBitmap3(); BurretBitmap = GetBurretBitmap(); } } |
これでBitmapの取得は完了です。次に弾丸を移動させたり描画するためのBurretクラスを考えます。
弾丸の移動と描画のためのBurretクラス
コンストラクタでは使用するBitmap、初期のXY座標、移動量を指定します。移動量として0.5のような値を設定しても問題なく動作するようにX、Y、VX、VYプロパティはdouble型にします。描画するときにint型にキャストします。
弾丸は自機も敵も同じものを使います。本当に鳩でもわかる内容にしたいので、もうちょっと完成度が高いものを目指すのであれば、3Dっぽい縦シューティングゲームをつくる(タイトルの付け方がよろしくない!)を参照してください。
Moveメソッドで弾丸を移動させたあと画面の外に出ている場合は、描画する必要はないのでIsDeadプロパティをtrueにして描画対象から外しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
class Burret { public Burret(Bitmap bitmap, int startX, int startY, double vx, double vy) { Bitmap = bitmap; X = startX; Y = startY; VX = vx; VY = vy; IsDead = false; } public Bitmap Bitmap { get; set; } public double X { get; set; } public double Y { get; set; } public double VX { get; set; } public double VY { get; set; } public bool IsDead { get; set; } public void Move() { X += VX; Y += VY; if (X + Bitmap.Width < 0) IsDead = true; if (Form1.FormClientSize.Width < X) IsDead = true; if (Y + Bitmap.Height < 0) IsDead = true; if (Form1.FormClientSize.Height < Y) IsDead = true; } public void Draw(Graphics graphics) { if (!IsDead) graphics.DrawImage(Bitmap, new Point((int)X, (int)Y)); } } |
Shotメソッドで弾丸を発射
弾丸が表示できるようになったらShotメソッドで弾丸を発射できるようにします。Burretのリストをつくってこのなかにオブジェクトを格納します。弾丸の出現位置はY座標は自機と同じでいいのですが、X座標は自機の中心にならないと不自然です。自機のX座標とJikiBitmapとBurretBitmapの幅から算出しています。それから1発だけではシューティングゲームらしくないので3発を扇形に発射させます。
1 2 3 4 5 6 7 8 9 10 11 |
public partial class Form1 : Form { List<Burret> JikiBurrets = new List<Burret>(); void Shot() { int burretX = Jiki.X + JikiBitmap.Width / 2 - BurretBitmap.Width / 2; JikiBurrets.Add(new Burret(BurretBitmap, burretX, Jiki.Y, 0, -5)); JikiBurrets.Add(new Burret(BurretBitmap, burretX, Jiki.Y, 0.5, -5)); JikiBurrets.Add(new Burret(BurretBitmap, burretX, Jiki.Y, -0.5, -5)); } } |
それから敵も当然弾丸を発射してきます。なので敵の弾丸を格納するリストもつくっておきます。
1 2 3 4 |
public partial class Form1 : Form { List<Burret> EnemyBurrets = new List<Burret>(); } |
Timer_Tickメソッドにおける処理
Timer_Tickメソッドが呼び出されたときにいくつかやることがあります。
自機と自機から発射された弾丸、敵と敵から発射された弾丸の移動、新たに敵をつくるか、自機、敵、弾丸のそれぞれの当たり判定をしなければなりません。
Timer_Tickメソッドのなかがややこしくなるので以下のようにわけました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
public partial class Form1 : Form { private void Timer_Tick(object sender, EventArgs e) { TickCount++; // 自機を移動させる Jiki.Move(); // 弾丸を移動させる MoveBurrets(); foreach (Enemy enemy in Enemies) { // 敵を移動させる enemy.Move(); // 敵に弾丸を発射させる Burret burret = EnemyShot(enemy); if (burret != null) EnemyBurrets.Add(burret); } // 当たり判定 HitJudge(); // 新たに敵をつくる CreateNewEnemy(); Invalidate(); } } |
まずは自機と敵の弾丸を移動させる処理を示します。
1 2 3 4 5 6 7 8 9 10 11 |
public partial class Form1 : Form { void MoveBurrets() { foreach (Burret burret in JikiBurrets) burret.Move(); foreach (Burret burret in EnemyBurrets) burret.Move(); } } |
次に新たに敵をつくるメソッドを示します。
1秒間に4回の割合で乱数を発生させて新しく敵を生成するかを決めます。4分の3の確率で新しく敵をつくります。敵を生成する場合は3種類あるのでどれにするか、これも乱数で決めます。そして出現位置はY座標は一番上ですが、X座標はバラバラにしたほうがいいので、これも乱数で決めます。敵は左右に移動しながら下に移動しますが、最初に移動する方向が右か左かも乱数で決めています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
public partial class Form1 : Form { // 新たに敵をつくる Random Random = new Random(); void CreateNewEnemy() { int r = TickCount % 15; if (r != 0) return; if (Random.Next(4) != 0) { int kind = Random.Next(3); int x = Random.Next(this.Width); bool isRight = Random.Next(2) == 0 ? true : false; if (kind == 0) Enemies.Add(new Enemy(EnemyBitmap1, x, 0, isRight)); if (kind == 1) Enemies.Add(new Enemy(EnemyBitmap2, x, 0, isRight)); if (kind == 2) Enemies.Add(new Enemy(EnemyBitmap3, x, 0, isRight)); } } } |
敵に弾丸を発射させる
敵に弾丸を発射させる処理を示します。弾丸を発射するのは敵一体につき、0.5秒に1回、3分の1の確率です。発射する場合、方向は自機のいる方向にむけて発射します。発射方向を求めるためにTanの逆関数アークタンジェントを使用しています。高校の数学ではTanはほとんど出てきません。いつもsinとcosだけです。だからといって油断していると突然出てくるのがTanです。
Y座標の差をX座標の差で割ったものをTanの逆関数に渡せば発射角度を求めることができます。あとはsinとcosで発射速度を求めてコンストラクタに渡します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public partial class Form1 : Form { Burret EnemyShot(Enemy enemy) { int a = TickCount % 30; if (a != 0) return null; int r = Random.Next(3); if(r == 0) { int x = enemy.X + this.EnemyBitmap1.Width / 2 - BurretBitmap.Width / 2; double angle = Math.Atan2(Jiki.Y - enemy.Y, Jiki.X - enemy.X); double vx = Math.Cos(angle) * 5; double vy = Math.Sin(angle) * 5; return new Burret(BurretBitmap, x, enemy.Y, vx, vy); } return null; } } |
当たり判定
次に当たり判定を考えます。
当たり判定ですが、自機、敵、弾丸が重なっている場合は当たっていると判断します。ではそれらが重なっているかどうかはどうすればわかるでしょうか?
まずふたつの矩形(長方形)が重なっている場合について考えます。ただこの場合、ふたつの矩形が重なっていない場合を考えたほうがよさそうです。
矩形1の右側よりも矩形2の左が右にある場合はあきらかに両者は重なっていません。
同様に、矩形1の下側よりも矩形2の上が下にある場合も両者は重なっていないと断言できます。
矩形2の右側よりも矩形2の左が右にある場合、矩形2の下側よりも矩形1の上が下にある場合も両者は重なっていません。
それ以外であれば両者は重なっていると判断できます。そのためふたつの矩形が重なっているかどうかは以下の方法で判定できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public partial class Form1 : Form { bool IsHit(Rectangle rect1, Rectangle rect2) { if (rect1.Right < rect2.Left) return false; if (rect1.Bottom < rect2.Top) return false; if (rect2.Right < rect1.Left) return false; if (rect2.Bottom < rect1.Top) return false; return true; } } |
では敵と自機が発射した弾丸が命中しているかどうかはどうでしょうか? 敵と弾丸の左上の座標はXプロパティとYプロパティをみればわかります。あとはそれぞれのBitmapの幅と高さがわかればこれを囲む矩形を取得できるので、上記のメソッドで判定できます。
1 2 3 4 5 6 7 8 9 |
public partial class Form1 : Form { bool IsHit(Enemy enemy, Burret burret) { Rectangle rect1 = new Rectangle(enemy.X, enemy.Y, EnemyBitmap1.Width, EnemyBitmap1.Height); Rectangle rect2 = new Rectangle((int)burret.X, (int)burret.Y, BurretBitmap.Width, BurretBitmap.Height); return IsHit(rect1, rect2); } } |
同様に自機と敵の弾丸、自機と敵が接触したかどうかは以下の方法で判定できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public partial class Form1 : Form { bool IsHit(Jiki jiki, Burret burret) { Rectangle rect1 = new Rectangle(jiki.X, jiki.Y, JikiBitmap.Width, JikiBitmap.Height); Rectangle rect2 = new Rectangle((int)burret.X, (int)burret.Y, BurretBitmap.Width, BurretBitmap.Height); return IsHit(rect1, rect2); } bool IsHit(Jiki jiki, Enemy enemy) { Rectangle rect1 = new Rectangle(jiki.X, jiki.Y, JikiBitmap.Width, JikiBitmap.Height); Rectangle rect2 = new Rectangle((int)enemy.X, (int)enemy.Y, EnemyBitmap1.Width, EnemyBitmap1.Height); return IsHit(rect1, rect2); } } |
そこで当たり判定をするHitJudgeメソッドは以下のように書くことができます。
IsHitメソッドが最初にtrueを返す要素を探します。ない場合はnullが返されます。null以外が返されたときは当たっているものが存在するということなので、それをリストのなかから取り除きます。ただしforeach文のなかで取り除くと例外が発生するので、その場合はIsDeadプロパティをtrueにして、ループを出たらIsDeadプロパティがtrueのものを取り除きます。
命中した弾丸だけでなく画面外に飛んでいった弾丸もリストのなかから取り除かないといけないので、ここではそのための処理もしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
public partial class Form1 : Form { void HitJudge() { // 敵と自機から発射した弾丸の当たり判定 foreach (Enemy enemy in Enemies) { Burret burret = JikiBurrets.FirstOrDefault(x => IsHit(enemy, x)); if (burret != null) { JikiBurrets.Remove(burret); enemy.IsDead = true; EnemyDead(enemy); } } Enemies = Enemies.Where(x => !x.IsDead).ToList(); JikiBurrets = JikiBurrets.Where(x => !x.IsDead).ToList(); // すでに自機死亡のときは必要ない if (Jiki.IsDead) return; // 自機と敵から発射した弾丸の当たり判定 Burret enemyBurret = EnemyBurrets.FirstOrDefault(x => IsHit(Jiki, x)); if (enemyBurret != null) { EnemyBurrets.Remove(enemyBurret); Jiki.IsDead = true; JikiDead(); } EnemyBurrets = EnemyBurrets.Where(x => !x.IsDead).ToList(); // 自機と敵そのものの当たり判定 Enemy enemy1 = Enemies.FirstOrDefault(x => IsHit(Jiki, x)); if (enemy1 != null) { Enemies.Remove(enemy1); Jiki.IsDead = true; JikiDead(); } } void EnemyDead(Enemy enemy) { // 敵が死んだときにやりたい処理 } void JikiDead() { // 自機が死んだときにやりたい処理 } } |
移動処理と当たり判定をしてリストから取り除かれなかったものを描画します。自機や敵の上に弾丸が描画されてはおかしいので弾丸を先に描画しています。Jiki.Dead == trueのときは自機は描画されません(現状、敵の弾丸や敵そのものと接触すると消えてしまう)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public partial class Form1 : Form { protected override void OnPaint(PaintEventArgs e) { foreach (Burret burret in JikiBurrets) burret.Draw(e.Graphics); foreach (Burret burret in EnemyBurrets) burret.Draw(e.Graphics); Jiki.Draw(e.Graphics); foreach (Enemy enemy in Enemies) enemy.Draw(e.Graphics); base.OnPaint(e); } } |
これで弾丸が当たったら消滅するようになりました。ただちょっとさみしいです。爆発の描画もあればいいのですが・・・。次回に続きます。
やっとここまで来ました・・・・・が、弾丸が敵機、自機(Spaceキーを押しても)ともに表示されません。自機を動かすとき onkeydown で上下カーソルキーは反応するのでSpaceキーも反応していると思います。ただ、Spaceキーを押し続けても敵機が消えているような気がしません(笑)。起動してしばらくすると自機は消えるのですが、これまた見えない弾丸が当たって消えるのかどうかがわかりません。
※IsHit がオーバーロードされたメソッドであることが最初わかりませんでした。私はDelphiでオーバーロードを使ったことは一度もありませんでしたから(笑)。しかし、勉強になります。私の持っているC#の入門書にはオーバーライドの説明はさすがにありますが、オーバーロードについてまったくありません。
本当に鳩でも分かるC#講座は、本当の初心者には人類でもあっても少し敷居が高い気がしますね(^O^)。
これが完成品です。
https://lets-csharp.com/samples/2111/WinFormsApp-SimpleShooter.zip
ときどき完成したコードを書き写し損ねて「うまく動かない」とか「○○はどこで定義されているのだ?」といわれることがあります。
こちらでも確認してみますが、完成品と比較してどこが間違っているのか探してみてください。
↑
なんという手抜きレス。申し訳ありませんが・・・
お手数をおかけしております。
入力したソースと完成品のソースを見直してもバグを発見するのはむずかしそうなので、ここのソースをもう一度最初からチェックしてみます。それで発見できなかったら完成品のソースの解読に切り替えます。