ASP.NET Core版 ボンバーマンのような対戦型ゲームをつくる(1)の続きです。今回はプレイヤーの動作に関する処理を実装します。
Playerクラスの定義
Playerクラスを定義します。
1 2 3 4 5 6 |
namespace BomberGame { public class Player { } } |
インデントが深くなるので以降は以下のように名前空間を省略します。
1 2 3 |
public class Player { } |
コンストラクタ
コンストラクタを示します。引数はASP.NET SignalRを用いて接続したときに取得されるIDです。PLAYER_MAXはゲーム開始時の残機数、INVINCIBLE_TIMEは無敵状態になれる時間です。これが0より大きいときは無敵です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class Player { const int PLAYER_MAX = 5; const int INVINCIBLE_TIME = 24; static int _nextNpcNumber = 1; int _npcNumber; public Player(string connectionId) { ConnectionID = connectionId; Name = ""; Score = 0; _npcNumber = _nextNpcNumber; _nextNpcNumber++; Rest = PLAYER_MAX; Score = 0; } } |
プロパティ
プレイヤーは15×15のセルの上に存在しますが、CurrentColumnとCurrentRowは現在のセルの位置、NextColumnとNextRowはこれから移動しようとしているセルの位置です。XとYは各プレイヤーが描画されるべきX座標とY座標です。
IsXXXKeyDownプロパティは方向キーが押されているかどうかを管理するものですが、複数の方向キーを同時におすと斜めに移動できるというバグがあることがわかったので、ひとつしかtrueにならないようにしています。
斜め移動できてしまうバグ
各プレイヤーの初期位置は四隅のどこかになりますが、撃破されてしまった場合、初期座標に戻されます。このときResetNumberの値でどこに戻されるかを切り分けるようにします。それ以外のName、Rest、Score、IsDeadの各プロパティはプレイヤーの表示名、残機、スコア、生きているかどうかを示すものです。
それから新しくプレイヤーがゲームに参加したときにNPCのうちひとつが取り除かれ、新たなプレイヤーとなって隅に移動するのですが、これがバグっぽく見えてしまいます。配信者さんもこの現象にちょっと驚いているようです(下の動画では新たにプレイヤーがゲームに参加したことでニワトリが突然左上にワープしたようにみえる。しかも参加したプレイヤーがキー操作をしないとプレイヤー名も表示されない)。
突然キャラクタがワープ?
そこで新しくプレイヤーがゲームに参加したときはそのプレイヤーを点滅させる、新たな参戦者が現れたことを全観戦者に通知するようにしました。これなら違和感はそんなにないと思います。
InvincibleTimeプロパティは新しくプレイヤーがゲームに参加したとき、爆弾によって撃破されてしまったあと隅に移動した場合、INVINCIBLE_TIMEがセットされます。このプロパティが0以上のときはプレイヤーが点滅して当たり判定はなしにします。
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 |
public class Player { public string ConnectionID { private set; get; } public int CurrentColumn { private set; get; } public int CurrentRow { private set; get; } public int NextColumn { private set; get; } public int NextRow { private set; get; } public int X { set; get; } public int Y { set; get; } bool _isUpKeyDown = false; public bool IsUpKeyDown { set { _isUpKeyDown = value; _isDownKeyDown = false; _isLeftKeyDown = false; _isRightKeyDown = false; } get { return _isUpKeyDown; } } bool _isDownKeyDown = false; public bool IsDownKeyDown { set { _isUpKeyDown = false; _isDownKeyDown = value; _isLeftKeyDown = false; _isRightKeyDown = false; } get { return _isDownKeyDown; } } bool _isLeftKeyDown = false; public bool IsLeftKeyDown { set { _isUpKeyDown = false; _isDownKeyDown = false; _isLeftKeyDown = value; _isRightKeyDown = false; } get { return _isLeftKeyDown; } } bool _isRightKeyDown = false; public bool IsRightKeyDown { set { _isUpKeyDown = false; _isDownKeyDown = false; _isLeftKeyDown = false; _isRightKeyDown = value; } get { return _isRightKeyDown; } } public int ResetNumber { private set; get; } string _name = ""; public string Name { set { _name = value; } get { if (ConnectionID != "") return _name; else return String.Format("NPC {0:000}", _npcNumber); } } public int Rest { set; get; } public int Score { set; get; } public bool IsDead { private set; get; } public int InvincibleTime { private set; get; } } |
移動に関する処理
最初にその方向に移動できるかどうかを調べる処理を示します。移動可能かどうかの判定はここではブロックがあるかどうかだけで判断しています。
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 |
public class Player { bool CanMoveUp(int col, int row) { if (!Game.IndestructibleWalls.Any(wall => wall.Column == col && wall.Row == row - 1)) return !Game.Walls.Any(wall => wall.Column == col && wall.Row == row - 1); return false; } bool CanMoveDown(int col, int row) { if (!Game.IndestructibleWalls.Any(wall => wall.Column == col && wall.Row == row + 1)) return !Game.Walls.Any(wall => wall.Column == col && wall.Row == row + 1); return false; } bool CanMoveLeft(int col, int row) { if (!Game.IndestructibleWalls.Any(wall => wall.Column == col - 1 && wall.Row == row)) return !Game.Walls.Any(wall => wall.Column == col - 1 && wall.Row == row); return false; } bool CanMoveRight(int col, int row) { if (!Game.IndestructibleWalls.Any(wall => wall.Column == col + 1 && wall.Row == row)) return !Game.Walls.Any(wall => wall.Column == col + 1 && wall.Row == row); return false; } } |
次に更新処理がおこなわれるときにキーが押されているかどうかで上下左右に移動させる処理を示します。
ここでは最初にXプロパティとYプロパティをGame.CHARACTER_SIZEで割り切れるかどうかを調べています。割り切れるのであればセルからセルの移動は完全に完了している状態です。この状態の場合だけキー入力による移動の開始処理をおこなっています。IsXXXKeyDownプロパティを調べてその方向に移動できるのであれば、_movingXXXフラグをtrueにして移動を開始しています。
_movingXXXフラグがtrueの場合はキー入力による移動の開始処理はおこなわず、すでに開始されている移動処理を継続します。移動処理をおこなった結果、XプロパティとYプロパティがGame.CHARACTER_SIZEで割り切れる場合は移動が完了したということなので_movingXXXフラグをクリアしています。
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 |
public class Player { bool _movingUp = false; bool _movingDown = false; bool _movingLeft = false; bool _movingRight = false; public void UpdatePlayer() { InvincibleTime--; if (X % Game.CHARACTER_SIZE == 0 && Y % Game.CHARACTER_SIZE == 0) { int col = X / Game.CHARACTER_SIZE; int row = Y / Game.CHARACTER_SIZE; // キーが押されていてその方向に移動できる場合だけ移動できる // 無敵状態であれば爆弾がある位置でも移動できる if (IsDownKeyDown && CanMoveDown(col, row) && (InvincibleTime > 0 || !Game.Bombs.Any(bomb => bomb.Column == col && bomb.Row == row + 1))) { NextRow = row + 1; _movingDown = true; } if (IsUpKeyDown && CanMoveUp(col, row) && (InvincibleTime > 0 || !Game.Bombs.Any(bomb => bomb.Column == col && bomb.Row == row - 1))) { NextRow = row - 1; _movingUp = true; } if (IsLeftKeyDown && CanMoveLeft(col, row) && (InvincibleTime > 0 || !Game.Bombs.Any(bomb => bomb.Column == col - 1 && bomb.Row == row))) { NextColumn = col - 1; _movingLeft = true; } if (IsRightKeyDown && CanMoveRight(col, row) && (InvincibleTime > 0 || !Game.Bombs.Any(bomb => bomb.Column == col + 1 && bomb.Row == row))) { NextColumn = col + 1; _movingRight = true; } } if (_movingDown) Y += 4; if (_movingUp) Y -= 4; if (_movingLeft) X -= 4; if (_movingRight) X += 4; if (X % Game.CHARACTER_SIZE == 0 && Y % Game.CHARACTER_SIZE == 0) { _movingDown = false; _movingUp = false; _movingLeft = false; _movingRight = false; CurrentColumn = X / Game.CHARACTER_SIZE; CurrentRow = Y / Game.CHARACTER_SIZE; } } } |
爆弾をセットする処理
爆弾をセットする処理を示します。ここではBombオブジェクトを生成してからGameクラスのSetBombメソッドを呼び出しているだけです。
プレイヤーが次のセルに移動している最中に設置ボタンがおされるかもしれません。そこで爆弾はNextColumnとNextRowの位置にセットします。プレイヤーの移動が完全に完了している状態ではCurrentColumnとNextColumn、CurrentRowとNextRowが一致しているので問題はありません。
1 2 3 4 5 6 7 8 |
public class Player { public void SetBomb() { Bomb bomb = new Bomb(NextColumn, NextRow, this); Game.SetBomb(bomb); } } |
リセットの処理
ゲームに参加したとき、爆発に巻き込まれて死亡したときは初期位置に移動します。そのための処理を示します。
引数なしのResetメソッドが呼び出された場合はすでに保存されているResetNumberプロパティの値が使用されます。
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 |
public class Player { public void Reset(int i) { if (i == 0) { X = 32; Y = 32; ResetNumber = 0; } else if (i == 1) { X = 32 * 13; Y = 32; ResetNumber = 1; } else if (i == 2) { X = 32; Y = 32 * 13; ResetNumber = 2; } else if (i == 3) { X = 32 * 13; Y = 32 * 13; ResetNumber = 3; } else { X = 32; Y = 32; ResetNumber = 0; } _beforeDirect = Direct.None; _movingDown = false; _movingUp = false; _movingLeft = false; _movingRight = false; NextColumn = CurrentColumn = X / Game.CHARACTER_SIZE; NextRow = CurrentRow = Y / Game.CHARACTER_SIZE; IsDead = false; } public void Reset() { Reset(ResetNumber); } } |
死亡時の処理
爆発に巻き込まれて死亡したときの処理を示します。一定時間をあけて初期位置に移動させるとともに一時的に無敵状態にします。またプレイヤーの場合は残機を1減らして残機0になった場合はゲームオーバーの処理をおこなうことができるようにイベントを定義しておきます。
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 class Player { public delegate void GameHandler(Player player); public event GameHandler? GameOverEvent; public void Dead() { IsDead = true; Rest--; System.Timers.Timer timer = new System.Timers.Timer(); timer.Interval = 2000; timer.Elapsed += Timer_Elapsed; timer.Start(); } private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { System.Timers.Timer? t = (System.Timers.Timer ?)sender; if (t != null) { t.Stop(); t.Dispose(); if (ConnectionID == "" || Rest > 0) { Reset(); New(); // 復活時には一時的に無敵にする } else GameOverEvent?.Invoke(this); } } public void New() { InvincibleTime = INVINCIBLE_TIME; } } |
NPCの動作
NPCの動作を決める処理を示します。
まず移動方向の決め方ですが、その方向に移動したとして爆弾の影響から逃れられるかどうかを調べます。また爆弾を設置する場合、そこに爆弾を設置した場合、逃げることができるかどうかも調べます。
CanEscapeBombメソッドでは引数で渡されたdangerMapをコピーして二次元配列を生成して、元の位置に戻らずにある方向に移動して爆弾の影響範囲外に逃げられるかを調べています。
現在位置から壁や爆弾がある場所を通らず繋がったセルを取得して安全な位置にたどり着くことができるかどうかを調べています。一度調べた場所にはチェックをつけて一度調べた場所を何度も調べないようにしています。
1 2 3 4 5 6 7 8 |
public enum Direct { None, Up, Down, Left, Right, } |
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 |
public class Player { bool CanEscapeBomb(int[,] dangerMap, int col, int row, Direct direct, int newBombCol, int newBombRow) { int[,] copiedDangerMap = new int[15, 15]; for (int r = 0; r < 15; r++) { for (int c = 0; c < 15; c++) copiedDangerMap[r, c] = dangerMap[r, c]; } copiedDangerMap[row, col] = -1; if (newBombRow >= 0 && newBombCol >= 0) { copiedDangerMap[newBombRow, newBombCol] = -1; List<Fire> fires = Game.GetFirePositions(new Bomb(newBombCol, newBombRow, this)); foreach(Fire fire in fires) copiedDangerMap[fire.Row, fire.Column] = fire.Distance; } int nCol = col; int nRow = row; if (direct == Direct.Up) nRow = row - 1; if (direct == Direct.Down) nRow = row + 1; if (direct == Direct.Left) nCol = col - 1; if (direct == Direct.Right) nCol = col + 1; // 最初の移動先が安全な場所なら「爆弾から逃げられる」 if (copiedDangerMap[nRow, nCol] == int.MaxValue) return true; Queue<Position> queue = new Queue<Position>(); queue.Enqueue(new Position(nCol, nRow)); copiedDangerMap[nRow, nCol] = -1; while (true) { Position position = queue.Dequeue(); int[] vx = { 0, 0, 1, -1 }; int[] vy = { 1, -1, 0, 0 }; for (int k = 0; k < 4; k++) { int nextcol = position.Column + vx[k]; int nextRow = position.Row + vy[k]; if (nextcol < 0 || nextcol >= 15) continue; if (nextRow < 0 || nextRow >= 15) continue; // 0以下は爆弾、壁またはすでに探索した場所 if (copiedDangerMap[nextRow, nextcol] <= 0) continue; // 連続しているセルのなかに安全な場所があったので「爆弾から逃げられる」 if (copiedDangerMap[nextRow, nextcol] == int.MaxValue) return true; // 連続しているセルをQueueに格納 copiedDangerMap[nextRow, nextcol] = -1; queue.Enqueue(new Position(nextcol, nextRow)); } // 連続している場所をこれ以上取得できないなら終了。「爆弾からは逃げられない」 if (queue.Count == 0) break; } return false; } } |
更新処理をするときにCanEscapeBombメソッドで取得した結果を利用してNPCを移動させる処理を示します。
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 |
public class Player { Direct _beforeDirect = Direct.None; static Random _random = new Random(); public void UpdateNPC(int[,] dangerMap) { InvincibleTime--; if (X % Game.CHARACTER_SIZE == 0 && Y % Game.CHARACTER_SIZE == 0) { int col = X / Game.CHARACTER_SIZE; int row = Y / Game.CHARACTER_SIZE; // できるだけ安全な場所を探す List<Direct> vs1 = new List<Direct>(); List<Direct> vs2 = new List<Direct>(); // 上に移動できるか? if (CanMoveUp(col, row) && CanEscapeBomb(dangerMap, col, row, Direct.Up, -1, -1)) { if (dangerMap[row, col] != int.MaxValue || dangerMap[row - 1, col] == int.MaxValue) { vs1.Add(Direct.Up); if (CanEscapeBomb(dangerMap, col, row, Direct.Up, col, row)) vs2.Add(Direct.Up); } } if (CanMoveDown(col, row) && CanEscapeBomb(dangerMap, col, row, Direct.Down, -1, -1)) { if (dangerMap[row, col] != int.MaxValue || dangerMap[row + 1, col] == int.MaxValue) { vs1.Add(Direct.Down); if (CanEscapeBomb(dangerMap, col, row, Direct.Down, col, row)) vs2.Add(Direct.Down); } } if (CanMoveLeft(col, row) && CanEscapeBomb(dangerMap, col, row, Direct.Left, -1, -1)) { if (dangerMap[row, col] != int.MaxValue || dangerMap[row, col - 1] == int.MaxValue) { vs1.Add(Direct.Left); if (CanEscapeBomb(dangerMap, col, row, Direct.Left, col, row)) vs2.Add(Direct.Left); } } if (CanMoveRight(col, row) && CanEscapeBomb(dangerMap, col, row, Direct.Right, -1, -1)) { if (dangerMap[row, col] != int.MaxValue || dangerMap[row, col + 1] == int.MaxValue) { vs1.Add(Direct.Right); if (CanEscapeBomb(dangerMap, col, row, Direct.Right, col, row)) vs2.Add(Direct.Right); } } Direct direct = Direct.None; if (vs2.Count > 0 && _random.Next(3) == 0) { int r = _random.Next(vs2.Count); direct = vs2[r]; if(Game.Bombs.Count(bomb => bomb.Player == this) < 2) SetBomb(); } else if (vs1.Count > 0) { if (vs1.Count == 1) direct = vs1[0]; else { if (_beforeDirect == Direct.Up) vs1.Remove(Direct.Down); if (_beforeDirect == Direct.Down) vs1.Remove(Direct.Up); if (_beforeDirect == Direct.Left) vs1.Remove(Direct.Right); if (_beforeDirect == Direct.Right) vs1.Remove(Direct.Left); int r = _random.Next(vs1.Count); direct = vs1[r]; } } _movingDown = false; _movingUp = false; _movingLeft = false; _movingRight = false; if (direct == Direct.Down && !Game.Bombs.Any(bomb => bomb.Column == col && bomb.Row == row + 1)) { NextRow = row + 1; _movingDown = true; _beforeDirect = direct; } if (direct == Direct.Up && !Game.Bombs.Any(bomb => bomb.Column == col && bomb.Row == row - 1)) { NextRow = row - 1; _movingUp = true; _beforeDirect = direct; } if (direct == Direct.Left && !Game.Bombs.Any(bomb => bomb.Column == col - 1 && bomb.Row == row)) { NextColumn = col - 1; _movingLeft = true; _beforeDirect = direct; } if (direct == Direct.Right && !Game.Bombs.Any(bomb => bomb.Column == col + 1 && bomb.Row == row)) { NextColumn = col + 1; _movingRight = true; _beforeDirect = direct; } } if (_movingDown) Y += 4; if (_movingUp) Y -= 4; if (_movingLeft) X -= 4; if (_movingRight) X += 4; if (X % Game.CHARACTER_SIZE == 0 && Y % Game.CHARACTER_SIZE == 0) { _movingDown = false; _movingUp = false; _movingLeft = false; _movingRight = false; CurrentColumn = X / Game.CHARACTER_SIZE; CurrentRow = Y / Game.CHARACTER_SIZE; } } } |