Slither.io (スリザリオ)のようなオンラインゲームを作りたい(3)の続きです。今回はゲーム全体の処理をおこなうGameクラスを定義します。
Contents
フィールド変数
Gameクラスのフィールド変数を示します。コンストラクタでおこなわれる処理はありません。
_playersDicはASP.NET SignalRで使われる一意の接続IDから対応するPlayerオブジェクトを高速に求めるための辞書です。_dicSentDataはクライアントサイドに同じCircleオブジェクトの情報を何度も送信しないようにするために送信済みのCircleオブジェクトのIDを格納しておくための辞書です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class Game { public List<Player> Players = new List<Player>(); // Player(NPCを含む)オブジェクトのリスト Random _random = new Random(); // 乱数生成用 CirclesMap _circlesMap = new CirclesMap(); // 当たり判定用のCirclesMap long _updateCount = 0; // 更新回数 // ASP.NET SignalRで使われる一意の接続IDとPlayerオブジェクトの辞書 Dictionary<string, Player> _playersDic = new Dictionary<string, Player>(); // PlayerIDとCircleオブジェクトのIDの辞書 Dictionary<long, HashSet<long>> _dicSentData = new Dictionary<long, HashSet<long>>(); public Game() // コンストラクタですることはとくにない { } } |
初期化
ユーザーがひとりしかいない状態でゲームを開始するときはゲームオブジェクトを初期化します。そのときにおこなわれる処理を示します。
初期化でおこなわれる処理は以下のとおりです。
CirclesMapに格納されているCircleオブジェクトの回収
CirclesMapの初期化
Playerオブジェクトが格納されているリストと辞書のクリア
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 |
public class Game { public void Init() { _dicSentData.Clear(); // 送信データのクリア // CirclesMapに格納されているCircleオブジェクトの回収 int cx = Constant.FIELD_RADUUS; int cy = Constant.FIELD_RADUUS; int w = Constant.FIELD_RADUUS * 2; int h = Constant.FIELD_RADUUS * 2; List<Circle> circles = _circlesMap.GetNearCircles(cx, cy, w, h); foreach (Circle circle in circles) Circle.Push(circle); _circlesMap = new CirclesMap(); Players.Clear(); _playersDic.Clear(); InitNpcs(); // NPCと餌の初期化(後述) InitFoods(); } } |
NPCを初期化する処理を示します。
完全にフィールドに含まれているセルを取得したあとセルをキーに乱数を値にした辞書を生成します。そのあと値でソートして先頭からN個とればランダムにセルを選択することができます。選択したセルの中心にNPCを配置します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public class Game { void InitNpcs() { List<Cell> cells = _circlesMap.CellsCompleteInsideField.ToList(); Dictionary<Cell, int> dic = new Dictionary<Cell, int>(); foreach (Cell cell in cells) dic.Add(cell, _random.Next(10000)); cells = dic.OrderBy(_ => _.Value).Select(_ => _.Key).ToList(); for (int i = 0; i < Constant.PLAYERS_MIN_COUNT; i++) { Player npc = new Player("", "", _circlesMap); Players.Add(npc); int x = cells[i].Col * Constant.CELL_SIZE + Constant.CELL_SIZE / 2; int y = cells[i].Row * Constant.CELL_SIZE + Constant.CELL_SIZE / 2; npc.Init(x, y); } } } |
餌を初期化する処理を示します。
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 Game { void InitFoods() { int centerX = Constant.FIELD_RADUUS; int centerY = Constant.FIELD_RADUUS; int count = 0; while (count < Constant.FOODS_INIT_COUNT) { // とりあえず乱数で座標を求めてフィールドの内部(境界から40以上離れている)であればOK int x = _random.Next(Constant.FIELD_RADUUS * 2); int y = _random.Next(Constant.FIELD_RADUUS * 2); if (Math.Sqrt(Math.Pow(x - centerX, 2) + Math.Pow(y - centerY, 2)) < Constant.FIELD_RADUUS - 40) { Circle circle = Circle.Create(); circle.X = x; circle.Y = y; circle.Radius = 2; circle.PlayerID = -1; _circlesMap.AddCircle(circle); count++; } } } } |
プレイヤーの追加と削除
新しいプレイヤー(NPCではない)をゲームに追加する処理を示します。ここでは新しいPlayerオブジェクトを生成してリストと辞書に追加しています。またプレイヤーが出現した直後に敵にぶつかって死なないように出現させるセルは他のプレイヤーやNPCがいないセルを選んでいます。そのようなセルが存在しない場合は中央に出現させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class Game { public Player AddPlayer(string connectionId, string playerName) { Player player = new Player(connectionId, playerName, _circlesMap); Players.Add(player); _playersDic.Add(connectionId, player); _dicSentData.Add(player.PlayerID, new HashSet<long>()); List<Cell> cells = _circlesMap.GetCellsNoPlayerNoNpc(); if (cells.Count > 0) { int r = _random.Next(cells.Count); int x = cells[r].Col * Constant.CELL_SIZE + Constant.CELL_SIZE / 2; int y = cells[r].Row * Constant.CELL_SIZE + Constant.CELL_SIZE / 2; player.Init(x, y); } else player.Init(Constant.FIELD_RADUUS, Constant.FIELD_RADUUS); } } |
ユーザーがページにアクセスしたときにフィールド上の餌や移動する他のプレイヤーを表示させるために新しいPlayerオブジェクトを追加する処理をしめします。
1 2 3 4 5 6 7 8 9 10 11 12 |
public class Game { public Player AddPlayerAsDemo(string connectionId) { Player player = new Player(connectionId, "", _circlesMap); Players.Add(player); _playersDic.Add(connectionId, player); _dicSentData.Add(player.PlayerID, new HashSet<long>()); player.Demo(); return player; } } |
これはASP.NET SignalRで使われる一意の接続IDから対応するPlayerオブジェクトを取得する処理です。
1 2 3 4 5 6 7 8 9 10 |
public class Game { public Player? GetPlayer(string connectionId) { if (_playersDic.ContainsKey(connectionId)) return _playersDic[connectionId]; else return null; } } |
ユーザーが離脱したときにPlayerオブジェクトを削除する処理を示します。
Playerオブジェクト内のCircleオブジェクトを回収してからリストと辞書からPlayerオブジェクトを削除します。そのあと残った総プレイヤー数によっては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 |
public class Game { public void RemovePlayer(string connectionId) { Player? player = GetPlayer(connectionId); if (player != null) { Players.Remove(player); List<Circle> circles = player.Circles.ToList(); // Circleオブジェクトの回収とCirclesMapからの削除 foreach (Circle circle in circles) { _circlesMap.RemoveCircle(circle); Circle.Push(circle); } // リストと辞書からPlayerオブジェクトを削除する player.Circles.Clear(); _playersDic.Remove(connectionId); _dicSentData.Remove(player.PlayerID); // 総プレイヤー数によってはNPCを再生成して追加する if (Players.Count < Constant.PLAYERS_MIN_COUNT) { Player npc = new Player("", "", _circlesMap); Players.Add(npc); List<Cell> cells = _circlesMap.GetCellsNoPlayer(); int r = _random.Next(cells.Count); int x = cells[r].Col * Constant.CELL_SIZE + Constant.CELL_SIZE / 2; int y = cells[r].Row * Constant.CELL_SIZE + Constant.CELL_SIZE / 2; npc.Init(x, y); } } } } |
更新処理
更新時におこなわれる処理は以下のとおりです。
当たり判定(2回に1回)
セル内の餌の数を調べて0のときは追加する(1分に1回)
不要なPlayerオブジェクト(NPC)の除去
次回更新処理の最初に座標が変更されたCircleオブジェクトのリストをクリアする
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 class Game { public void Update() { _updateCount++; // 次回更新処理の最初に座標が変更されたCircleオブジェクトのリストをクリアする List<Circle> positionUpdatedCircles = _circlesMap.GetPositionUpdatedCircles(); foreach (Circle circle in positionUpdatedCircles) circle.IsPositionUpdated = false; positionUpdatedCircles.Clear(); // 更新フラグもクリア // Playerの状態の更新 foreach (Player player in Players) player.Update(_updateCount); // 当たり判定(2回に1回) if(_updateCount % 2 == 0) HitCheck(); // 定義は後述 // セル内の餌の数を調べて0のときは追加する(1分に1回) if (_circlesMap.GetCirclesCount() < Constant.CIRCLES_MAX_COUNT && _updateCount % (60 * 60) == 0) _circlesMap.AddFoods(); // 不要なPlayerオブジェクト(NPC)の除去 Players = Players.Where(_ => !_.IsNotRequired).ToList(); } } |
当たり判定
当たり判定では以下の処理をおこないます。
頭部が餌や他のプレイヤーに接触しているか調べる
他のプレイヤーに接触することなく餌に接触していたら餌を食べる
(この場合はPlayer.Scoreをインクリメントして餌に相当するCircleオブジェクトを回収する)
他のプレイヤーに接触していた場合は死亡処理
(この場合は接触した相手のPlayer.KillCountをインクリメントする。それがNPCでない場合はイベントを発生させる)
16更新に1回の割合で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 |
public class Game { public delegate void KillEventHandler(object sender, string id); public event GameOveredEventHandler KillEvent; void HitCheck() { foreach (Player player in Players) { if (player.Circles.Count == 0) continue; // フィールドの境界線に衝突していたら死亡 if (IsOutOfField(player)) { PlayerDead(player); // 死亡時の処理(後述) continue; } // 頭部が餌や他のプレイヤーに接触しているか調べる List<Circle> circles = _circlesMap.HitCheck(player.Circles.First.Value); if (circles.Count > 0) { // 餌に接触していて他のプレイヤーには接触していない if (!circles.Any(_ => _.PlayerID >= 0)) { foreach (Circle circle in circles) { _circlesMap.RemoveCircle(circle); player.Score += 1; Circle.Push(circle); } } else // 他のプレイヤーに接触したので死亡 { Player? killer = circles.First(_ => _.PlayerID >= 0).Player; if (killer != null) { killer.KillCount++; if(killer.ConnectionId != "") KillEvent?.Invoke(this, killer.ConnectionId); } PlayerDead(player); } } if (player.ConnectionId == "" && _updateCount % 16 == 0) { if (player.Circles.First == null) continue; int ret = _circlesMap.NpcThink(player.Circles.First.Value, player.Angle); if (ret > 0) player.NpcTurnRightCount += ret; if (ret < 0) player.NpcTurnLeftCount += Math.Abs(ret); } } } } |
プレイヤーがフィールドの境界線に衝突しているかどうかを調べる処理を示します。頭部の中心とフィールドの中心間の距離に頭部の半径を加えたものがフィールドの半径を超えていたらフィールドの境界線に衝突したと判定します。
1 2 3 4 5 6 7 8 9 10 11 12 |
public class Game { bool IsOutOfField(Player player) { int cx = Constant.FIELD_RADUUS; int cy = Constant.FIELD_RADUUS; if(Math.Pow(player.HeadX - cx, 2) + Math.Pow(player.HeadY - cy, 2) > Math.Pow(Constant.FIELD_RADUUS - player.Radius, 2)) return true; else return false; } } |
死亡処理
死亡時は以下の処理がおこなわれます。
Player.Circlesに格納されていたCircleオブジェクトはすべて回収し、CirclesMapからも忘れず削除する
遺体?の周辺に餌を生成して配置する
死亡したのがNPCの場合は別の場所で復活させる(NPC+プレイヤーの総数によっては復活させずに除去)
ユーザーの場合はGameOveredEventイベントの送信
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 Game { public delegate void GameOveredEventHandler(object sender, string id); public event GameOveredEventHandler GameOveredEvent; public void PlayerDead(Player player) { // Player.Circlesに格納されていたCircleオブジェクトはすべて回収 // CirclesMapからも削除する int i = 0; List<Circle> circles = player.Circles.ToList(); foreach (Circle circle in circles) { i++; double x = circle.X; double y = circle.Y; _circlesMap.RemoveCircle(circle); Circle.Push(circle); // 遺体の周辺に餌を生成して配置する if (i % 4 == 0) { Circle food = Circle.Create(); food.Radius = 2; food.PlayerID = -1; food.X = x + _random.Next(8) - 4; food.Y = y + _random.Next(8) - 4; _circlesMap.AddCircle(food); } } player.Circles.Clear(); // 死亡したプレイヤーのPlayer.Circlesをクリア if (player.ConnectionId == "") // NPC死亡の場合は別の場所で復活 { if (Players.Count <= Constant.PLAYERS_MIN_COUNT) { // NPCの復活場所を決める(周辺にプレイヤーがいない場所ならどこでもOK) List<Cell> cells = _circlesMap.GetCellsNoPlayer(); if (cells.Count > 0) { int r = _random.Next(cells.Count); int x = cells[r].Col * Constant.CELL_SIZE + Constant.CELL_SIZE / 2; int y = cells[r].Row * Constant.CELL_SIZE + Constant.CELL_SIZE / 2; player.Init(x, y); } else player.Init(Constant.FIELD_RADUUS, Constant.FIELD_RADUUS); } else player.IsNotRequired = true; } else // ユーザーの場合はGameOveredEventイベントの送信 { GameOveredEvent?.Invoke(this, player.ConnectionId); // スコアランキングに登録(後述) ScoreRanking.Add(player.PlayerName, (int)player.Score, player.KillCount); } } } |
クライアントサイドに送信するデータの取得
ここからはクライアントサイドに送信するデータを取得する処理を示します。
ユーザーの状態
以下はユーザーが担当するプレイヤーの状態(現在位置、playerID、体長)をカンマ区切りの文字列に変換したものを取得するメソッドです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class Game { public string GetStringForUpdateMyStatus(Player? player) { if (player == null) return ""; int x = (int)player.HeadX; int y = (int)player.HeadY; long playerID = player.PlayerID; int score = (int)player.Score; return $"{playerID},{score},{x},{y}"; } } |
フィールドの状態
以下はプレイヤーとNPC、餌の総数、存在するCircleオブジェクトの総数をカンマ区切りの文字列に変換したものを取得するメソッドです。
1 2 3 4 5 6 7 8 9 10 11 |
public class Game { public string GetStringForUpdateFieldStatus() { int playersCount = Players.Count(_ => _.ConnectionId != ""); int npcsCount = Players.Count(_ => _.ConnectionId == ""); int foodsCount = _circlesMap.GetFoodsCount(); int circlesCount = _circlesMap.GetCirclesCount(); return $"{playersCount},{npcsCount},{foodsCount},{circlesCount}"; } } |
Circleオブジェクトの状態
CircleToStringメソッドは引数として渡されたCircleオブジェクトの座標などをタブ文字区切りの文字列に変換したものを返します。クライアントサイドに送る文字数を減らすために餌ならCircleオブジェクトのIDと座標(整数部分だけ)のみ、スネークの身体を構成するオブジェクトはこれに加えて半径とプレイヤーID、そのCircleオブジェクトは頭から何番目かと体長を示す整数のみを文字列に変換するものとし、頭部のみ倒したプレイヤーの数とプレイヤー名も加えた文字列を送ることにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class Game { string CircleToString(Circle circle) { List<string> list = new List<string>(); list.Add(circle.ID.ToString()); list.Add(circle.IsPositionUpdated ? "true" : "false"); list.Add(((int)circle.X).ToString()); list.Add(((int)circle.Y).ToString()); if (circle.Player != null && !circle.IsPositionUpdated) { list.Add(((int)circle.Radius).ToString()); list.Add(circle.PlayerID.ToString()); list.Add((circle.Player.HeadNumber - circle.Number - 1).ToString()); list.Add(((int)circle.Player.Score).ToString()); if (circle.IsHead == true) { list.Add(circle.Player.KillCount.ToString()); list.Add(circle.Player.PlayerShortName); } } return string.Join("\t", list); } } |
GetStringForUpdateCirclesメソッドは上記のCircleToStringメソッドで変換された文字列を改行文字で連結させた文字列を取得するのですが、前回更新時と比較して追加すべきものと削除すべきものにわけて取得します。削除すべきものはすでにフィールド上からは消えているので必要な情報はCircleオブジェクトのIDだけです。
まずCirclesMap.GetNearCirclesメソッドで自分が存在する座標を中心にゲーム画面を表示させるcanvasの幅と高さ分を加えた周囲のCircleオブジェクトを取得します。次に取得された情報がすでにクライアントサイドに送信されたかを調べます。これは_dicSentDataを調べればわかります。もしplayerIDのキーが存在しない場合ははじめての送信なのでキーを生成します。そのあとCiecleオブジェクトを文字列に変換したものを送信して、そのIDを辞書に登録します。
2回目以降は前回送信したものが辞書に記録されているので送る前に送信済みかどうかを調べます。CirclesMap.GetNearCirclesメソッドで取得したオブジェクトが辞書に登録されていない場合は新しく生成されたオブジェクトなので送信しなければならないし、取得されたCircleオブジェクトのなかに辞書に登録されているIDをもつオブジェクトが存在しない場合はフィールド上から消滅したということなので削除された旨をクライアントサイドに送信しなければなりません。
また送信済みではあるけどCircle.IsPositionUpdatedフラグがtrueになっているオブジェクトは座標の整数部分が変更されたということなので、これも座標が変更されたという情報を付加して送信しなければなりません。
最後に追加されたCircleオブジェクトを改行文字で連結した文字列と削除されたCircleオブジェクトの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 |
public class Game { public void GetStringForUpdateCircles(Player? player, ref string addCirclesText, ref string removeCirclesText) { if (player == null) return; // プレイヤー周辺のCircleオブジェクトを取得 List<Circle> lists = _circlesMap.GetNearCircles(player.HeadX, player.HeadY, 360 + 40, 400 + 40); long playerID = player.PlayerID; if (!_dicSentData.ContainsKey(playerID)) _dicSentData.Add(playerID, new HashSet<long>()); HashSet<long> before = _dicSentData[playerID]; HashSet<long> after = new HashSet<long>(); // 取得されたCircleオブジェクトのIDをHashSetに格納する foreach (Circle circle in lists) after.Add(circle.ID); // 辞書から取得されたHashSet内にIDが存在しない場合は // 前回更新時には存在しないCircleオブジェクトが出現したことになるのでその旨を送信する // また辞書から取得されたHashSet内にIDが存在する場合でも // IsPositionUpdatedフラグがセットされているなら座標が変更されたことになるのでその旨を送信する List<string> addList = new List<string>(); foreach (Circle circle in lists) { if (!before.Contains(circle.ID)) addList.Add(CircleToString(circle)); // add else if (circle.IsPositionUpdated) addList.Add(CircleToString(circle)); // updated } // 辞書から取得されたHashSet内にIDが存在するが、 // afterには存在しない場合はCircleオブジェクトが消滅したことになるのでその旨を送信する List<string> removeList = new List<string>(); foreach (var id in before) { if (!after.Contains(id)) removeList.Add(id.ToString()); // remove } _dicSentData[playerID] = after; addCirclesText = string.Join("\n", addList); removeCirclesText = string.Join(",", removeList); } } |
ゲーム画面の下部には現在プレイ中のプレイヤーの情報(名前、スコア、位置)を表示させます。GetStringForUpdatePlayersStatusメソッドはそのために必要な文字列を取得するためのメソッドです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public class Game { public string GetStringForUpdatePlayersStatus() { List<Player> players = Players.Where(_ => _.ConnectionId != "" && _.Circles.Count > 0).OrderByDescending(_ => _.Score).ToList(); List<string> list = new List<string>(); foreach (Player player in players) { string[] vs = new string[5]; vs[0] = player.PlayerID.ToString(); vs[1] = player.PlayerShortName; vs[2] = ((int)player.Score).ToString(); vs[3] = ((int)player.HeadX).ToString(); vs[4] = ((int)player.HeadY).ToString(); list.Add(string.Join("\t", vs)); } return string.Join("\n", list); } } |
スコアランキングへの登録
ゲームオーバーになったらスコアランキングに登録します。
スコアを管理するHiscoreクラスを定義します。プレイヤー名とスコア、倒した敵の数、時刻をひとつのデータにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class Hiscore { public Hiscore(string name, int score, int killCount, string date) { Name = name; Score = score; KillCount = killCount; Date = date; } public string Name { get; } public int Score { get; } public int KillCount { get; } public string Date { get; } } |
ランキングデータをファイルに書き込んだり読み出すScoreRankingクラスを定義します。ファイルに書き込んでいる最中にファイルからの読み出しの処理がおこなわれる可能性があるので同期オブジェクトで排他ロックをかけています。
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 |
public class ScoreRanking { static string FilePath = "./hiscore-snake-game.txt"; static int MaxCount = 50; static string syncObject = "snake-hiscore-sync"; public ScoreRanking() { } public static void Add(string playerName, int score, int killCount) { lock (syncObject) { List<Hiscore>? hiscores = null; if (System.IO.File.Exists(FilePath)) { System.IO.StreamReader sr = new StreamReader(FilePath); string json = sr.ReadToEnd(); sr.Close(); hiscores = System.Text.Json.JsonSerializer.Deserialize<List<Hiscore>>(json); } if (hiscores == null) hiscores = new List<Hiscore>(); DateTime now = DateTime.Now; string date = $"{now.Year}-{now.Month:00}-{now.Day:00}"; hiscores.Add(new Hiscore(playerName, score, killCount, date)); List<Hiscore> saves = new List<Hiscore>(); List<Hiscore> scores = hiscores.OrderByDescending(_ => _.Score).Take(MaxCount).ToList(); List<Hiscore> killCounts = hiscores.OrderByDescending(_ => _.KillCount).Take(MaxCount).ToList(); saves.AddRange(scores); saves.AddRange(killCounts); saves = saves.Distinct().ToList(); { string json = System.Text.Json.JsonSerializer.Serialize(saves); System.IO.StreamWriter sw = new StreamWriter(FilePath, false, System.Text.Encoding.UTF8); sw.Write(json); sw.Close(); } } } public static List<Hiscore> Load() { lock (syncObject) { List<Hiscore>? hiscores = null; if (System.IO.File.Exists(FilePath)) { System.IO.StreamReader sr = new StreamReader(FilePath); string json = sr.ReadToEnd(); sr.Close(); hiscores = System.Text.Json.JsonSerializer.Deserialize<List<Hiscore>>(json); } if (hiscores == null) hiscores = new List<Hiscore>(); return hiscores; } } } |