クラッシュローラーの敵は2体です。パックマンは4体なので一見、こちらのほうが簡単に思えます。しかし実際にはそうではありません。子供のころ実際にプレイしているところを見たのですが、挟み撃ち攻撃がエグいです。
そこで今回は敵を移動させるのですが、ランダムに移動させるのではなく挟み撃ち攻撃でプレイヤーを捕まえる方法を考えます。
Contents
挟み撃ちのアルゴリズム
まずプレイヤーを捕まえるには敵をプレイヤーがいる座標に移動させる方法を考えないといけません。そのための方法としてダイクストラ法があります。プレイヤーがいる座標までの最短経路を求めてそのとおりに移動させるという方法です。
ところが単純なダイクストラ法を使った追跡アルゴリズムでは2体の敵が重なってしまうと重なりっぱなしです。同じ地点を出発点とする最短経路でプレイヤーを追うわけですから当然そうなってしまいます。
プレイヤーの一番近くにいる敵には最短経路で追跡させ、もうひとつの敵には待ち伏せポイントのようなものをつくってそちらに向かわせるという方法でやってみましたが、うまくいきません。
しかたがないので尊敬するプログラミング配信系YouTuberのひとりであるこの方に質問をしてみました。
質問に対する回答はプレイヤーの左右に「仮想の壁」をつくって考えてみてはどうかと。そこで敵は2体いるので1体はプレイヤーを上または左側から、もう1体には下または右側から捕まえるように移動させることにしました。これだといい感じに挟み撃ちをすることができます。
以下は教えてもらった挟み撃ちのアルゴリズムで作成したテストプログラムです。一部謎の動きもありますが、いい感じに挟み撃ちされて死ぬことができています。
Enemyクラスをつくる
まず敵の移動処理と描画処理をおこなうためのEnemyクラスをつくりたいのですが、そのときにForm1クラスのマップなどのデータを共有できるようにしておこなければなりません。
まずは敵の移動方向を算出したり、現在の敵の座標を管理するためのEnemyクラスをつくってみましょう。
そのまえにEnemyクラスで使用するデータをForm1クラスから取得できるようにメソッドを追加します。
1 2 3 4 5 6 7 |
public partial class Form1 : Form { public int[,] GetMap() { return Map; } } |
初期化
最初にEnemyクラスのコンストラクタ、フィールド変数、プロパティを示します。
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
public class Enemy { public Enemy(int initX, int initY, int id, Form1 form1) { X = InitX = initX; Y = InitY = initY; ID = id; ParentForm = form1; } // Form1のインスタンスからデータを取得する必要があるので保存しておく public Form1 ParentForm { private set; get; } // 敵の初期のX座標 public int InitX { private set; get; } // 敵の初期のY座標 public int InitY { private set; get; } // 敵の現在のX座標 public int X { private set; get; } // 敵の現在のY座標 public int Y { private set; get; } // 敵は生きているか死んでいるか? // プレイヤーはローラーをつかって反撃できます。 public bool IsDead { private set; get; } // 敵を識別するID。1か2か? public int ID { private set; get; } int NumberOfTimesToMove = 64; // 撃退された敵やミス、ステージクリア時に敵を初期位置に戻す public void Reset() { X = InitX; Y = InitY + 16; // 最初は巣のなかにいるように見せるために初期座標より少し下にする EnemyDirect = Direct.Stop; NumberOfTimesToMove = 64; } // Form1クラスのインスタント内のMapを取得する // 一度取得したら同じものを使い回す int[,] GetMap() { if(_map == null) _map = ParentForm.GetMap(); return _map; } int[,] _map = null; // 敵が移動する方向 Direct EnemyDirect = Direct.Stop; // 敵がプレイヤーの位置にたどりつくまでに通らなければならない通路のリスト public List<Position> EnemyPath = new List<Position>(); } |
移動方向を求める
GetDirectメソッドは挟み撃ちでプレイヤーを狙うために移動する方向を取得するためのものです。
ダイクストラ法で最短経路を求めるのですが、敵のIDでプレイヤーをどの方向から捕まえるのかを変えています。それから橋と道路が交差している部分は直進しかできないので注意が必要です。また敵がプレイヤーを捕まえにいく方向が決まっているので、プレイヤーが角にいる場合どうやっても捕まえることができません。その場合の対策としては例外的に全方向から捕まえにいくことができるようにしています。
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
public class Enemy { Direct GetDirect( int targetX, int targetY, ref int minCost, ref List<Position> path, bool isUseBridgeNS, bool isUseBridgeWE) { int[,] map = GetMap(); int h = map.GetLength(0); int w = map.GetLength(1); int[,] costs = new int[h, w]; bool[,] allowPath = new bool[h, w]; // falseの場合は通れない for (int y = 0; y < map.GetLength(0); y++) { for (int x = 0; x < map.GetLength(1); x++) { costs[y, x] = int.MaxValue; allowPath[y, x] = true; } } for (int y = 0; y < map.GetLength(0); y++) { for (int x = 0; x < map.GetLength(1); x++) { if (map[y, x] == Form1.POSITION_BRIDGE_NS_CROSS) { if (isUseBridgeNS) { allowPath[y, x + 1] = false; allowPath[y, x - 1] = false; } else { allowPath[y + 1, x] = false; allowPath[y - 1, x] = false; } } if (map[y, x] == Form1.POSITION_BRIDGE_WE_CROSS) { if (isUseBridgeWE) { allowPath[y + 1, x] = false; allowPath[y - 1, x] = false; } else { allowPath[y, x + 1] = false; allowPath[y, x - 1] = false; } } } } Position[,] from = new Position[h, w]; costs[Y, X] = 0; from[Y, X] = new Position(0, 0); Queue<int> vsX = new Queue<int>(); Queue<int> vsY = new Queue<int>(); vsX.Enqueue(X); vsY.Enqueue(Y); bool isMovementRestrictions1 = true; // ID が 1 の敵はプレイヤーの右側と下側は通行禁止とする bool isMovementRestrictions2 = true; // ID が 1 の敵はプレイヤーの左側と上側は通行禁止とする // プレイヤーは角にいるかもしれないのでチェック if (IsInMap(targetY, targetX)) { // プレイヤーは角にいてその方向からでは捕まえることができない場合は通行禁止解除する if ((map[targetY - 1, targetX] == Form1.POSITION_NONE && map[targetY, targetX - 1] == Form1.POSITION_NONE)) isMovementRestrictions1 = false; if ((map[targetY + 1, targetX] == Form1.POSITION_NONE && map[targetY, targetX + 1] == Form1.POSITION_NONE)) isMovementRestrictions2 = false; } while (true) { int x = vsX.Dequeue(); int y = vsY.Dequeue(); int[] nextXs = { 0, 0, 1, -1 }; int[] nextYs = { 1, -1, 0, 0 }; for (int i = 0; i < 4; i++) { // 現在調査している座標のとなりの点が移動可能であればQueueに追加する int nextX = x + nextXs[i]; int nextY = y + nextYs[i]; // 移動先の候補が配列の範囲外の場合、ワープ処理をおこなう if (nextX < 0) nextX = w - 1; else if (nextY < 0) nextY = h - 1; else if (nextX >= w) nextX = 0; else if (nextY >= h) nextY = 0; // 移動先の候補が通路ではない場合、移動先の候補からはずす if (map[nextY, nextX] == Form1.POSITION_NONE) continue; // 移動先の候補が通行禁止の場合も、移動先の候補からはずす if (!allowPath[nextY, nextX]) continue; // 敵のIDによってプレイヤーを捕まえにいく方向をかえる // これで挟み撃ちを狙っているような敵の動きを実現できる // ただしプレイヤーが角にいる場合は捕まえにいく方向を限定しない if (ID == 1) { // ID が 1 のときはプレイヤーの右側と下側は通行止めとする if ((targetX + 1 == nextX && targetY == nextY) || (targetX == nextX && targetY + 1 == nextY)) { if (isMovementRestrictions1) continue; } } else { // ID が 2 のときはプレイヤーの左側と上側は通行止めとする if ((targetX - 1 == nextX && targetY == nextY) || (targetX == nextX && targetY - 1 == nextY)) { if (isMovementRestrictions2) continue; } } // 移動可能で既存のコストも小さい場合は移動先候補に加える if (costs[y, x] + 1 < costs[nextY, nextX]) { costs[nextY, nextX] = costs[y, x] + 1; vsX.Enqueue(nextX); vsY.Enqueue(nextY); from[nextY, nextX] = new Position(x, y); } } // 移動先候補がなくなったら終了 if (vsX.Count == 0) break; } Position last = from[targetY, targetX]; // プレイヤーの位置に到達するまでのコスト minCost = costs[targetY, targetX]; path = new List<Position>(); while (true) { if (last == null) break; var temp = from[last.Y, last.X]; int tempX = temp.X; int tempY = temp.Y; if (tempX == 0 && tempY == 0) break; last = new Position(tempX, tempY); path.Insert(0, last); } // プレイヤーを捕まえた場合、またはプレイヤーの位置にたどり着けない場合 if (path.Count < 2) { Console.WriteLine(minCost); return Direct.Stop; } int lastX = path[1].X; int lastY = path[1].Y; // 敵の現在位置とプレイヤーを捕まえるために次に移動すべき座標を比較すると // 次の移動方向が決定する if (X < lastX) return Direct.Right; else if (X > lastX) return Direct.Left; else if (Y < lastY) return Direct.Down; else if (Y > lastY) return Direct.Up; else return EnemyDirect; } public Direct GetDirect(int targetX, int targetY, ref int minCost, ref List<Position> path) { minCost = int.MaxValue; // ふたつの橋をそれぞれとおる場合と通らない場合で最小のコストでプレイヤーにたどりつけるルートを探す bool[] isUseBridgeNSs = { true, true, false, false, }; bool[] isUseBridgeWEs = { true, false, true, false, }; Direct direct = Direct.Stop; for (int i = 0; i < isUseBridgeNSs.Length; i++) { int tempMin = 0; List<Position> tempPath = null; Direct tempDirect = GetDirect(targetX, targetY, ref tempMin, ref tempPath, isUseBridgeNSs[i], isUseBridgeWEs[i]); if (minCost > tempMin) { minCost = tempMin; path = tempPath; direct = tempDirect; } } // 敵の移動方向と最短経路とコストが確定する return direct; } } |
敵を移動させるための処理を示します。敵が方向転換をするのは角と丁字路、十字路です。ちなみに「T字路」ではなく「丁字路(ていじろ)」が元々は正しいそうです。
移動させる
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
public class Enemy { // 二次元配列 mapの範囲内か? bool IsInMap(int y, int x) { int[,] map = GetMap(); int h = map.GetLength(0); int w = map.GetLength(1); if (y < 0) return false; if (y >= h) return false; if (x < 0) return false; if (x >= w) return false; return true; } public void Move(int targetX, int targetY) { int[,] map = GetMap(); // 巣のなかにいるときは巣の外に出す if (map[Y, X] == Form1.POSITION_NONE) { // NumberOfTimesToMove > 0のあいだは移動を開始しない if(NumberOfTimesToMove > 0) NumberOfTimesToMove--; // 巣の外へ移動させる if(NumberOfTimesToMove <= 0) Y--; return; } bool canUp = false; bool canRight = false; bool canDown = false; bool canLeft = false; // 敵は、上下左右にそれぞれ移動できるか? if (IsInMap(Y, X) && IsInMap(Y + 1, X) && IsInMap(Y - 1, X) && IsInMap(Y, X + 1) && IsInMap(Y, X - 1)) { canUp = map[Y - 1, X] != Form1.POSITION_NONE && map[Y, X] != Form1.POSITION_BRIDGE_NS_CROSS && map[Y, X] != Form1.POSITION_BRIDGE_WE_CROSS; canRight = map[Y, X + 1] != Form1.POSITION_NONE && map[Y, X] != Form1.POSITION_BRIDGE_NS_CROSS && map[Y, X] != Form1.POSITION_BRIDGE_WE_CROSS; canDown = map[Y + 1, X] != Form1.POSITION_NONE && map[Y, X] != Form1.POSITION_BRIDGE_NS_CROSS && map[Y, X] != Form1.POSITION_BRIDGE_WE_CROSS; canLeft = map[Y, X - 1] != Form1.POSITION_NONE && map[Y, X] != Form1.POSITION_BRIDGE_NS_CROSS && map[Y, X] != Form1.POSITION_BRIDGE_WE_CROSS; } // 敵は停止している場合と角、丁字路、十字路にいるときだけ方向転換できる if ( EnemyDirect == Direct.Stop || canUp && canRight || canRight && canDown || canDown && canLeft || canLeft && canUp) { // 新しい移動方向を取得 int minCost = 0; EnemyDirect = GetDirect(targetX, targetY, ref minCost, ref EnemyPath); } int h = map.GetLength(0); int w = map.GetLength(1); // 移動方向に応じて現在座標から移動する // ワープ処理をしなければならない場合があるので注意する if (EnemyDirect == Direct.Right) { if (X == w - 2) X = 0; else X++; } if (EnemyDirect == Direct.Left) { if (X == 1) X = w - 1; else X--; } if (EnemyDirect == Direct.Down) { if (Y == h - 2) Y = 0; else Y++; } if (EnemyDirect == Direct.Up) { if (Y == 1) Y = h - 1; else Y--; } } } |
Form1クラスにおける処理
Enemyクラスを使うためにForm1クラスに追加すべき処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public partial class Form1 : Form { List<Enemy> Enemies = new List<Enemy>(); // 追加 public Form1() { InitializeComponent(); BackColor = Color.Black; CreateMapFromBitmap(); InitTimer(); InitEnemies(); // 追加した } } |
敵の初期化
最初に敵を初期化します。
敵の初期座標は下記イメージの茶色(Color.FromArgb(128, 0, 0))の部分です。この部分の座標を取得してリストに格納するとともにEnemyオブジェクトを生成してEnemiesに格納します。
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 { List<Position> EnemyStartPositions = new List<Position>(); List<Enemy> Enemies = new List<Enemy>(); void InitEnemies() { Bitmap bitmap = Properties.Resources.maze1; int id = 1; for (int y = 1; y < MapSourceHeight - 1; y++) { for (int x = 1; x < MapSourceWidth - 1; x++) { Color color = bitmap.GetPixel(x, y); if (color == Color.FromArgb(255, 0, 255)) { EnemyStartPositions.Add(new Position(x, y)); Enemies.Add(new Enemy(x, y, id, this)); id++; } } } } } |
敵の移動
Timer.Tickイベントが発生したら敵を移動させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public partial class Form1 : Form { private void Timer1_Tick(object sender, EventArgs e) { MovePlayer(); MoveEnemies(); Invalidate(); } void MoveEnemies() { foreach (Enemy enemy in Enemies) enemy.Move(PlayerX, PlayerY); } } |
敵を描画する処理を示します。いまは実験段階なので「敵」と書いている矩形を表示させるだけです。これはあとで修正します。また橋の上を移動している敵は橋を描画したあともう一度描画しなおします。
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 |
public partial class Form1 : Form { Font EnemyFont = new Font("MS ゴシック", 20); void DrawEnemy(Graphics graphics, Enemy enemy) { if (enemy.ID == 1) graphics.FillRectangle( Brushes.Red, new Rectangle(enemy.X * ExpansionRate + LEFT_MARGIN, enemy.Y * ExpansionRate + TOP_MARGIN, CharactorSize, CharactorSize)); else graphics.FillRectangle( Brushes.Blue, new Rectangle(enemy.X * ExpansionRate + LEFT_MARGIN, enemy.Y * ExpansionRate + TOP_MARGIN, CharactorSize, CharactorSize)); graphics.DrawString("敵", EnemyFont, Brushes.White, new Point(enemy.X * ExpansionRate - 3 + LEFT_MARGIN, enemy.Y * ExpansionRate + TOP_MARGIN)); } void DrawEnemies(Graphics graphics) { foreach (Enemy enemy in Enemies) { DrawEnemy(graphics, enemy); } } void ReDrawEnemies(Graphics graphics) { foreach (Enemy enemy in Enemies) { if (IsInMap(enemy.Y, enemy.X) && (Map[enemy.Y, enemy.X] == POSITION_BRIDGE_EDGE || Map[enemy.Y, enemy.X] == POSITION_BRIDGE_NEAR_EDGE || Map[enemy.Y, enemy.X] == POSITION_BRIDGE_NS || Map[enemy.Y, enemy.X] == POSITION_BRIDGE_WE)) { DrawEnemy(graphics, enemy); } } } } |
敵の描画
オーバーライドしたOnPaintにおける処理部分を示します。デバッグを兼ねて敵の追跡ルートも描画しています。
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 |
public partial class Form1 : Form { protected override void OnPaint(PaintEventArgs e) { DrawRoad(e.Graphics); DrawPlayer(e.Graphics); DrawEnemies(e.Graphics); // 橋を描画する DrawBridgeNS(e.Graphics); DrawBridgeWE(e.Graphics); // 橋の始点ですでに通過した部分が橋を描画する過程で白く描画されている場合は塗りなおす ReDrawBridgeEdgeIfNeed(e.Graphics); // 橋の上(周辺も含む)を通過中のキャラクタは最後に描画する ReDrawPlayerIfNeed(e.Graphics); ReDrawEnemies(e.Graphics); // デバッグを兼ねて敵の追跡ルートを直線で描画する DrawEnemyPath(e.Graphics); base.OnPaint(e); } // 敵の追跡ルートを直線で描画する void DrawEnemyPath(Graphics graphics) { foreach (Enemy enemy in Enemies) { List<Position> path = null; int minCost = 0; enemy.GetDirect(PlayerX, PlayerY, ref minCost, ref path); foreach (Position pos in path) { if (enemy.ID == 3) graphics.FillRectangle( Brushes.Red, new Rectangle(pos.X * ExpansionRate + CharactorSize / 2 + LEFT_MARGIN, pos.Y * ExpansionRate + CharactorSize / 2 + TOP_MARGIN, 2, 2)); else graphics.FillRectangle( Brushes.Blue, new Rectangle(pos.X * ExpansionRate + CharactorSize / 2 + 2 + LEFT_MARGIN, pos.Y * ExpansionRate + CharactorSize / 2 + 2 + TOP_MARGIN, 2, 2)); } } } } |