久しぶりにASP.Net Coreでオンライン対戦型のゲームを作ります。内容はスネークゲームです。自分自身の胴体や他のプレイヤーに衝突しないように注意しながら移動する餌を食べて成長させていきます。餌の状態は固定ではなく加点対象になる餌、減点対象になる餌、食べると即死する餌と変化していきます。タイミングを見計らって食べていきましょう。
それでは作成していきましょう。
ところで今回は特別協力者がいます。「れいかちゃま」こと琴音麗華さんです。歌って踊れる声優を目指しているということなので音声データを提供していただきました。
みんなで明日のアイドル「れいかちゃま」を応援しましょう。
れいかちゃま – プロフィール – SHOWROOM(ショールーム)
名前空間はChamatherioとします。
Contents
定数 Constクラスの定義
最初に定数部分を示します。同時プレイできるプレイヤー数の上限、フィールド上に散らばっている餌の数などです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
namespace Chamatherio { public class Const { public const int PLAYER_MAX = 9; // プレイヤー数の上限 public const int FOOD_MAX = 100; // 餌の個数の上限 public const int FIELD_SIZE = 1600; // フィールド全体の幅と高さ public const int INIT_LENGTH = 27; // 最初の長さ public const int MAX_LENGTH = 3000; // 長さの上限 public const int EXTEND_LENGTH = 12; // 伸びる長さ public const int MOVE_SPEED = 4; // 移動速度 public const int CHARACTER_SIZE = 32; // キャラクタの大きさ } } |
座標 Positionクラスの定義
座標管理用のPositionクラスを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
namespace Chamatherio { public class Position { public Position(int x, int y) { X = x; Y = y; } public int X { get; set; } public int Y { get; set; } } } |
Playerクラスの定義
Playerクラスを定義します。以下、記事内では名前空間部分は省略します。
1 2 3 4 5 6 7 8 9 10 11 |
namespace Chamatherio { public class Player { } } // 以下、記事内では名前空間部分は省略 public class Player { } |
プロパティ
各プロパティを示します。
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 |
public class Player { // ASP.NET SignalR の接続ID public string ConnectionId { set; get; } // プレイヤー名 public string Name { get; set; } // プレイヤーの通し番号 public int Number { get; } // スコア public int Score { get; set; } // 初期座標(X) public int InitX { get; } // 初期座標(Y) public int InitY { get; } // 現在のX座標 public int X { get; private set; } // 現在のY座標 public int Y { get; private set; } // X方向移動量の初期値 public int InitVX { get; } // Y方向移動量の初期値 public int InitVY { get; } // X方向の移動量 public int VX { get; private set; } // Y方向の移動量 public int VY { get; private set; } // プレイヤーの長さ public int Length { get; set; } // 無敵状態か?(プレイ開始または復活後のしばらくの間) public bool IsInvincible { get; set; } } |
初期化
フィールド変数とコンストラクタを示します。
各プレイヤーの頭と胴体を生成します。また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 |
public class Player { static Random _random = new Random(); // 頭と胴体の座標のリスト List<Position> _positions = new List<Position>(); // 移動処理をおこなった回数 int _moveCount = 0; // NPCはこの座標で方向転換する Position _leftTop = new Position(0, 0); Position _rightTop = new Position(0, 0); Position _leftBottom = new Position(0, 0); Position _rightBottom = new Position(0, 0); public Player(int number) { ConnectionId = ""; Name = ""; Score = 0; // NPCが方向転換する座標を計算し、フィールド変数に格納する int row = number / 3; int col = number % 3; int speed = Const.MOVE_SPEED; _leftTop = new Position(col * 500 + 64, row * 500 + 64); _rightTop = new Position(_leftTop.X + Const.MOVE_SPEED * 92, _leftTop.Y); _leftBottom = new Position(_leftTop.X, _leftTop.Y + Const.MOVE_SPEED * 92); _rightBottom = new Position(_leftTop.X + speed * 92, _leftTop.Y + speed * 92); // 初期座標と移動量の計算し、フィールド変数に格納する InitX = _leftTop.X; InitY = _leftTop.Y + speed * (number + 1); InitVX = 0; InitVY = -speed; // 頭と胴体の座標をリストに格納する(最長の値が決まっているので先に全部生成する) for (int i = 0; i < Const.MAX_LENGTH; i++) _positions.Add(new Position(X, Y)); IsInvincible = true; // 最初は無敵状態とし当たり判定をしない Number = number; Init(); // 最初と死亡時から復帰するときの初期化(後述) } } |
死亡時から復帰するときの初期化の処理を示します。
コンストラクタ内で取得した初期座標、初期移動量を現在座標と現在移動量にセットします。また長さを初期値にセットします。NPCにConnectionIdは存在しないので空文字列を設定し、プレイヤー名を”NPC XXX”という名前にします。胴体の座標は頭を先頭に初期移動量の反対側にずーっと続くのでそのように設定します。
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 { public void Init() { _moveCount = 0; // 初期座標、初期移動量、初期の長さをセット X = InitX; Y = InitY; VX = InitVX; VY = InitVY; Length = Const.INIT_LENGTH; // 最初は無敵状態とし、当たり判定の対象から外す IsInvincible = true; ConnectionId = ""; Name = "NPC " + _random.Next(1000).ToString(); // リスト内に格納されている胴体の座標を設定する for (int i = 0; i < _positions.Count; i++) { Position pos = _positions[i]; pos.X = X - VX * i; pos.Y = Y - VY * i; } // 5秒経過したら無敵状態を解除し、当たり判定の対象に入れる Task.Run(async () => { await Task.Delay(5000); IsInvincible = false; }); } } |
ゲーム開始時の処理
ユーザーがゲームを開始したときの処理を示します。
現在NPCになっているPlayerオブジェクトのどれかにプレイヤー名とconnectionIdをセットします。また座標は中央とします。同時に複数のユーザーがプレイを開始するかもしれないのでNumberプロパティを参照して、座標を微妙にズラしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Player { public void GameStart(string playerName, string connectionId) { Init(); Name = playerName; ConnectionId = connectionId; Score = 0; // 開始座標は中央とする X = Const.FIELD_SIZE / 2 + Const.CHARACTER_SIZE * 2 * (Number - Const.PLAYER_MAX / 2); Y = Const.FIELD_SIZE / 2; // 最初の移動方向は上向き VX = 0; VY = - Const.MOVE_SPEED; } } |
移動の処理
移動の処理を示します。
現在位置を移動量分変更するのですが、そのあと胴体の座標がつながっています。そこで胴体の座標は前の要素の座標をセットすることにします。なので、処理は後ろの要素から行ないます。
また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 |
public class Player { public void Move() { // 停止しているのであれば処理はおこなわない if (VX == 0 && VY == 0) return; _moveCount++; X += VX; Y += VY; // 胴体の座標は前の要素の座標を使用する for (int i = _positions.Count - 1; i > 0; i--) { _positions[i].X = _positions[i - 1].X; _positions[i].Y = _positions[i - 1].Y; } // 先頭の要素の座標はXプロパティとYプロパティを使う _positions[0].X = X; _positions[0].Y = Y; // NPCの場合は方向転換の処理もする if (ConnectionId == "") { if (X == _leftTop.X && Y == _leftTop.Y) { VX = Const.MOVE_SPEED; VY = 0; } if (X == _rightTop.X && Y == _rightTop.Y) { VX = 0; VY = Const.MOVE_SPEED; } if (X == _rightBottom.X && Y == _rightBottom.Y) { VX = - Const.MOVE_SPEED; VY = 0; } if (X == _leftBottom.X && Y == _leftBottom.Y) { VX = 0; VY = - Const.MOVE_SPEED; } } } } |
衝突判定
自分の胴体または他のプレイヤーに衝突したときの処理を示します。
自分自身との衝突判定は頭の近くにある部分は対象から外します(そうしないと常に衝突と判定されてしまう)。他のプレイヤーとの衝突判定は全身とします。
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 { public bool IsCrash(Player player) { if (this.IsInvincible || player.IsInvincible) return false; int start = 0; if (player == this) { start = (int)(Const.CHARACTER_SIZE * 1.5) / Const.MOVE_SPEED + 1; } for (int i = start; i < player.Length; i++) { int x = player._positions[i].X; int y = player._positions[i].Y; if (Math.Pow(X - x, 2) + Math.Pow(Y - y, 2) < Math.Pow(Const.CHARACTER_SIZE - 4, 2)) return true; } return false; } } |
方向転換の処理
ユーザーの操作によって方向転換するための処理を示します。引数で上下左右の移動方向が渡されるので、それに対応してVXプロパティとVYプロパティを変更しています。
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 |
public class Player { public void ChangeDirect(string direct) { if (direct == "up") { VX = 0; VY = - Const.MOVE_SPEED; } if (direct == "down") { VX = 0; VY = Const.MOVE_SPEED; } if (direct == "left") { VX = - Const.MOVE_SPEED; VY = 0; } if (direct == "right") { VX = Const.MOVE_SPEED; VY = 0; } } } |
座標情報の取得
GetPositionsTextメソッドはクライアントサイドにプレイヤーの座標を送る必要があるのですが、そのときに使う文字列を取得するためのものです。無敵状態のときは点滅させたいので無敵状態のときは2回に1回の割合でしか文字列を取得していません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class Player { public string GetPositionsText() { // 無敵状態のときは点滅させたい if (IsInvincible && _moveCount % 2 == 0) return ""; // X座標とY座標をカンマ区切りでつなげる StringBuilder sb = new StringBuilder(); for (int i = 0; i < Length; i++) { if (i % 2 == 0) sb.Append($"{_positions[i].X},{_positions[i].Y},"); } return sb.ToString(); } } |
Foodクラスの定義
餌になるFoodクラスを定義します。以下、記事内では名前空間部分は省略します。
1 2 3 4 5 6 7 8 9 10 11 |
namespace Chamatherio { public class Food { } } // 以下、記事内では名前空間部分は省略 public class Food { } |
プロパティ
各プロパティを示します。
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 |
public class Food { // 現在のX座標 public int X { private set; get; } // 現在のY座標 public int Y { private set; get; } // 現在のX方向の移動量 public int VX { private set; get; } // 現在のY方向の移動量 public int VY { private set; get; } // 餌のタイプ(0:食べると加点、1:同減点、2:同死亡、) public int Type { private set; get; } } |
初期化
フィールド変数とコンストラクタを示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class Food { static Random _random = new Random(); int _moveCount = 0; int[] _v = { -2, -1, 1, 2 }; public Food() { Init(); // ゲーム開始時に餌の状態が同じにならないように適度にバラけさせる _moveCount = _random.Next(128); Type = _random.Next(3); } } |
餌を初期化する処理を示します。餌をフィールド全体にランダムに配置します。移動方向はX方向、Y方向ともに±1または±2とします。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class Food { public void Init() { X = _random.Next(Const.FIELD_SIZE - Const.CHARACTER_SIZE * 4) + Const.CHARACTER_SIZE * 2; Y = _random.Next(Const.FIELD_SIZE - Const.CHARACTER_SIZE * 4) + Const.CHARACTER_SIZE * 2; VX = _random.Next(_v.Length); VY = _random.Next(_v.Length); _moveCount = 0; Type = 0; } } |
餌を移動させる処理を示します。移動量だけ移動させるのですが、壁にあたったら移動量に -1 を掛けて跳ね返るように移動させます。また128回移動処理をしたら餌の状態を変更します。
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 Food { public void Move() { _moveCount = (_moveCount + 1) % 128; if (_moveCount == 0) Type = (Type + 1) % 3; X += VX; Y += VY; int left = Const.CHARACTER_SIZE / 2; int right = Const.FIELD_SIZE - Const.CHARACTER_SIZE / 2; int top = Const.CHARACTER_SIZE / 2; int bottom = Const.FIELD_SIZE - Const.CHARACTER_SIZE / 2; if (this.X < left) { this.X = left + (left - X); this.VX *= -1; } if (this.X > right) { this.X = right - (X - right); this.VX *= -1; } if (this.Y < top) { this.Y = top + (top - Y); this.VY *= -1; } if (this.Y > bottom) { this.Y = bottom - (Y - bottom); this.VY *= -1; } } } |