ASP.NET Core版 ボンバーマンのような対戦型ゲームをつくる(2)の続きです。
Contents
BomberHubクラスを定義する
1 2 3 4 5 6 7 8 9 10 |
using Microsoft.AspNetCore.SignalR; using BomberGame; using System.Timers; namespace SignalRChat.Hubs { public class BomberHub : Hub { } } |
以降は名前空間は省略して書きます。
1 2 3 |
public class BomberHub : Hub { } |
フィールド変数
1 2 3 4 5 6 7 8 9 10 |
public class BomberHub : Hub { static bool IsFirstConnection = true; static System.Timers.Timer Timer = new System.Timers.Timer(); static Dictionary<string, IClientProxy> ClientProxyMap = new Dictionary<string, IClientProxy>(); static Dictionary<string, Player> Players = new Dictionary<string, Player>(); static List<Player> NPCs = new List<Player>(); } |
接続時と切断時の処理
接続時と切断時に行なわれる処理を示します。
接続されたらその旨をクライアントサイドに送信します。最初の1回だけTimer.ElapsedとGame.BombExplodedにイベントハンドラ追加の処理をおこないます。
ユーザーが接続したらクライアントサイドにデータを送信できるように、辞書 ClientProxyMapにClients.Callerを追加します。
それから後述しますが接続しているユーザーが0になった場合はサーバーへの負荷をへらすためタイマーを停止させます。そこで接続することで接続ユーザーが0から1になった場合はタイマーを起動させるとともに、NPC4体を初期の位置に戻し、破壊されたブロックを元に戻します。
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 class BomberHub : Hub { public override async Task OnConnectedAsync() { if (Global.IsDebug) Console.WriteLine("接続しました:" + Context.ConnectionId); if (IsFirstConnection) { // 最初の1回だけおこなわれる処理 IsFirstConnection = false; Timer.Interval = 1000 / 24; Timer.Elapsed += Timer_Elapsed; Game.BombExploded += Game_BombExploded; } await base.OnConnectedAsync(); await Clients.Caller.SendAsync("ReceiveConnected", "接続成功", Context.ConnectionId); ClientProxyMap.Add(Context.ConnectionId, Clients.Caller); // 接続することで接続ユーザーが0から1になった場合はタイマーを起動 if (ClientProxyMap.Count == 1) { Game.Init(); NPCs.Clear(); for (int i = 0; i < 4; i++) { Player npc = new Player(""); NPCs.Add(npc); npc.Reset(i); } Timer.Start(); } // 壁をクライアントサイドに送信する { string xs = String.Join(",", Game.Walls.Select(wall => wall.X.ToString()).ToArray()); string ys = String.Join(",", Game.Walls.Select(wall => wall.Y.ToString()).ToArray()); await Clients.Caller.SendAsync("ReceiveWalls", xs, ys); } // 破壊できない壁をクライアントサイドに送信する { string xs = String.Join(",", Game.IndestructibleWalls.Select(wall => wall.X.ToString()).ToArray()); string ys = String.Join(",", Game.IndestructibleWalls.Select(wall => wall.Y.ToString()).ToArray()); await Clients.Caller.SendAsync("ReceiveIndestructibleWalls", xs, ys); } } } |
切断時の処理を示します。
通信が切断されたら登録されているユーザーを辞書 ClientProxyMapから削除します。またプレイ中のユーザーが離脱したらPlayers辞書からも削除します。そして新しいNPCを生成してこれまでプレイをしていたユーザーにとっての初期位置を設定します。
また試合放棄?があった場合はゲームに参加しているユーザーだけでなくすべての観戦しているユーザーにこれを通知します。このときプレイヤー名にHTMLでエスケープすべき文字(とりあえず < と >)が含まれている可能性があるので置換しています。
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 |
public class BomberHub : Hub { public override async Task OnDisconnectedAsync(Exception? exception) { await base.OnDisconnectedAsync(exception); if (ClientProxyMap.ContainsKey(Context.ConnectionId)) ClientProxyMap.Remove(Context.ConnectionId); // 接続しているユーザーが0になったらタイマーを停止させる if (ClientProxyMap.Count == 0) Timer.Stop(); if (Players.ContainsKey(Context.ConnectionId)) { string playerName = Players[Context.ConnectionId].Name; playerName = playerName.Replace(",", "_").Replace("<", "<").Replace(">", ">"); int resetNumber = Players[Context.ConnectionId].ResetNumber; Players.Remove(Context.ConnectionId); Player npc = new Player(""); npc.Reset(resetNumber); npc.New(); NPCs.Add(npc); foreach (string key in ClientProxyMap.Keys) await ClientProxyMap[key].SendAsync("ReceiveNotification", playerName + "が試合放棄しました"); } } } |
ユーザーがゲームに参加したときの処理
観戦中のユーザーがゲームに参加したときの処理を示します。
この場合はNPCをひとつだけ除去して新しいプレイヤーを追加します。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 BomberHub : Hub { public void GameStart(string id, string playerName) { if (Players.ContainsKey(id)) return; if (NPCs.Count > 0) { playerName = playerName.Replace(",", "_"); Game.CrearBombs(); int resetNumber = NPCs[0].ResetNumber; NPCs.RemoveAt(0); Player player = new Player(id); player.GameOverEvent += Player_GameOverEvent; player.Reset(resetNumber); player.New(); player.Name = playerName; Players.Add(id, player); Task.Run(async () => { playerName = playerName.Replace(",", "_").Replace("<", "<").Replace(">", ">"); foreach (string key in ClientProxyMap.Keys) { if (id != key) await ClientProxyMap[key].SendAsync("ReceiveNotification", playerName + "が参戦しました"); if (id == key) await ClientProxyMap[key].SendAsync("ReceiveNotification", playerName + "として参戦しました"); } }); } } } |
タイマーイベント発生時の処理
タイマーのイベントが発生したときの処理を示します。
プレイヤーとNPC、爆弾、火花の状態を更新して当たり判定をおこないます。そしてクライアントサイドに描画処理に必要なデータを送信する準備をします。そのあとクライアントサイドにデータを送信するためのSendUpdateToClientメソッドを呼び出します。
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 |
public class BomberHub : Hub { static private void Timer_Elapsed(object? sender, ElapsedEventArgs e) { Task.Run(async () => { List<Player> players = Players.Select(_ => _.Value).ToList(); foreach (Player player in players) player.UpdatePlayer(); if (NPCs.Count > 0) { int[,] dangerMap = Game.CreateDangerMap(); foreach (Bomb bomb in Game.Bombs) dangerMap[bomb.Row, bomb.Column] = -1; foreach (Player player in NPCs) { if (!player.IsDead) player.UpdateNPC(dangerMap); } } foreach (Bomb bomb in Game.Bombs) bomb.Update(); foreach (Fire fire in Game.Fires) fire.Update(); foreach (DeadFire fire in Game.DeadFires) fire.Update(); await HitCheck(players, NPCs); string bombsX = String.Join(",", Game.Bombs.Select(bomb => bomb.X.ToString()).ToArray()); string bombsY = String.Join(",", Game.Bombs.Select(bomb => bomb.Y.ToString()).ToArray()); string firesX = String.Join(",", Game.Fires.Select(fire => fire.X.ToString()).ToArray()); string firesY = String.Join(",", Game.Fires.Select(fire => fire.Y.ToString()).ToArray()); string firesLife = String.Join(",", Game.Fires.Select(fire => fire.TimeToDisappearance.ToString()).ToArray()); string deadFiresX = String.Join(",", Game.DeadFires.Select(fire => fire.X.ToString()).ToArray()); string deadFiresY = String.Join(",", Game.DeadFires.Select(fire => fire.Y.ToString()).ToArray()); string deadFiresLife = String.Join(",", Game.DeadFires.Select(fire => fire.TimeToDisappearance.ToString()).ToArray()); string brokenWallsX = String.Join(",", Game.BrokenWalls.Select(fire => fire.X.ToString()).ToArray()); string brokenWallsY = String.Join(",", Game.BrokenWalls.Select(fire => fire.Y.ToString()).ToArray()); // 送信する破壊された壁のデータを取得できたらリスト内のデータはクリアする Game.ClearBrokenWalls(); foreach (string id in ClientProxyMap.Keys) { await SendUpdateToClient( id, bombsX, bombsY, firesX, firesY, firesLife, deadFiresX, deadFiresY, deadFiresLife, brokenWallsX, brokenWallsY ); } }); } } |
更新時のデータを送信する
クライアントサイドにデータを送信するSendUpdateToClientメソッドを示します。
クライアントサイドへのデータ送信開始を知らせるReceiveStartUpdateを通知したあと、プレイヤー、爆弾、火花、破壊された壁の位置と状態を送信します。そして観戦しているユーザーの状態(参戦中か?参戦していないなら参戦は可能か?)を送信します。最後にすべてのデータを送信したことを知らせるReceiveEndUpdateを送信します。
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 |
public class BomberHub : Hub { static async Task SendUpdateToClient( string id, string bombsX, string bombsY, string firesX, string firesY, string firesLife, string deadFiresX, string deadFiresY, string deadFiresLife, string brokenWallsX, string brokenWallsY ) { if (!ClientProxyMap.ContainsKey(id)) return; // クライアントサイドへのデータ送信開始 await ClientProxyMap[id].SendAsync("ReceiveStartUpdate"); foreach (KeyValuePair<string, Player> pair in Players) { Player player = pair.Value; double x = player.X; double y = player.Y; // プレイヤーの位置をクライアントサイドに送信する await ClientProxyMap[id].SendAsync( "ReceiveUpdatePlayer", pair.Key, x, y, player.Name, player.IsDead, player.Score, player.Rest, player.InvincibleTime ); } foreach (Player player in NPCs) { double x = player.X; double y = player.Y; // 位置をクライアントサイドに送信する await ClientProxyMap[id].SendAsync( "ReceiveUpdatePlayer", "", x, y, player.Name, player.IsDead, "", "", player.InvincibleTime ); } await ClientProxyMap[id].SendAsync("ReceiveUpdateBombs", bombsX, bombsY); await ClientProxyMap[id].SendAsync("ReceiveUpdateFires", firesX, firesY, firesLife); await ClientProxyMap[id].SendAsync("ReceiveUpdateDeadFires", deadFiresX, deadFiresY, deadFiresLife); await ClientProxyMap[id].SendAsync("ReceiveUpdateBrokenWalls", brokenWallsX, brokenWallsY); string gameStatus; bool canGameStart = false; if (Players.ContainsKey(id)) gameStatus = "現在 参戦中!"; else if (NPCs.Count == 0) gameStatus = "現在満員です。空きが出るまでお待ちください。"; else { gameStatus = "参加可能です!"; canGameStart = true; } await ClientProxyMap[id].SendAsync("ReceiveUpdateGameStatus", gameStatus, canGameStart); // クライアントサイドへのデータ送信終了 await ClientProxyMap[id].SendAsync("ReceiveEndUpdate"); } } |
当たり判定
当たり判定の処理を示します。ライバルが爆死したとき、それが自分が仕掛けた爆弾であれば1000点(990+10)、そうでない場合は10点を追加します。
さらにGame.SetDeadFireメソッドを呼び出して周囲にとぶ火花をセットします。そのあとPlayer.Deadメソッドを呼び出して再生またはゲームオーバーの処理を行なわせます。また爆死時に爆発音を鳴らすことができるようにクライアントサイドにReceivePlayerDeadを送信します。
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 |
public class BomberHub : Hub { static async Task HitCheck(List<Player> players, List<Player> npcs) { List<Player> allPlayer = new List<Player>(); allPlayer.AddRange(players); allPlayer.AddRange(npcs); List<Fire> fires = Game.Fires.Where(fire => fire.TimeToDisappearance == Game.TIME_UNTIL_EXPLOSION_DISAPPEARS - 1).ToList(); bool isPlayerDead = false; foreach (Player player in allPlayer) { if (player.IsDead || player.InvincibleTime > 0) continue; List<Fire> hitFires = fires.Where(fire => fire.Column == player.CurrentColumn && fire.Row == player.CurrentRow).ToList(); if (hitFires.Count > 0) { List<Player> hitPlayers = new List<Player>(); foreach (Fire fire in hitFires) { if (fire.Player != player && !hitPlayers.Any(p => p == fire.Player)) fire.Player.Score += 990; hitPlayers.Add(fire.Player); } foreach (Player otherPlayer in allPlayer) { if (otherPlayer != player) otherPlayer.Score += 10; } Game.SetDeadFire(player.X, player.Y); player.Dead(); isPlayerDead = true; } } if (isPlayerDead) { foreach (var client in ClientProxyMap) await client.Value.SendAsync("ReceivePlayerDead"); } } } |
爆弾が爆発した時の処理
爆弾が爆発したときの効果音を鳴らすために、Game.BombExplodedイベント発生時にはクライアントサイドにReceiveBombExplodedを送信します。そのための処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 |
public class BomberHub : Hub { private void Game_BombExploded() { Task.Run(() => { foreach (var client in ClientProxyMap) { client.Value.SendAsync("ReceiveBombExploded"); } }); } } |
ゲームオーバー時の処理
ゲームオーバーになったときの処理を示します。
ゲームオーバー時にはPlayers辞書からプレイヤーを取り除き、新しいNPCを生成します。そしてそのときに新しいNPCがどこに出現するかを伝えるためにPlayer.ResetNumberプロパティを取得して、これをつかって新しいNPCのResetメソッドの引数とします。
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 |
public class BomberHub : Hub { private void Player_GameOverEvent(Player player) { if (Players.ContainsKey(player.ConnectionID)) { int resetNumber = Players[player.ConnectionID].ResetNumber; Players.Remove(player.ConnectionID); Player npc = new Player(""); npc.Reset(resetNumber); NPCs.Add(npc); if(ClientProxyMap.ContainsKey(player.ConnectionID)) { Task.Run(async () => { await ClientProxyMap[player.ConnectionID].SendAsync("ReceiveGameOver", player.ConnectionID); }); SaveHiscore(player); } string playerName = player.Name; playerName = playerName.Replace(",", "_").Replace("<", "<").Replace(">", ">"); foreach (string key in ClientProxyMap.Keys) { Task.Run(async () => { await ClientProxyMap[key].SendAsync("ReceiveNotification", playerName + "がゲームオーバーになりました"); }); } } } } |
ゲームオーバーになったとき、30位以内に入っているのであればスコアランキングに登録します。そのための処理を示します。
まずスコア管理用のクラスを示します。前回の戦車対戦ゲームで定義したものとまったく同じですが、今度変更するかもしれないので別に定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
namespace Zero.Pages.Bomber { public class Hiscore { public Hiscore(string name, long score, string time) { Name = name; Score = score; Time = time; } public string Name = ""; public long Score = 0; public string Time = ""; } } |
実際に必要であればスコアランキングに登録する処理を示します。カンマ区切りのテキストファイルにプレイヤー名とスコア、時刻を書き込みます。
まず既存のファイルがあるか確認し、ある場合は読み出してHiscoreオブジェクトのリストを作成します。そのあとゲームオーバーになったプレイヤーのHiscoreオブジェクトを追加してスコア順にソートします。そして上位30位を取り出します。最後にこれを文字列に変換してファイルに書き込みます。
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 BomberHub : Hub { void SaveHiscore(Player player) { string path = "../hiscore-bomber.txt"; List<Zero.Pages.Bomber.Hiscore> hiscores = new List<Zero.Pages.Bomber.Hiscore>(); if (System.IO.File.Exists(path)) { System.IO.StreamReader sr = new StreamReader(path); string text = sr.ReadToEnd(); string[] vs1 = text.Split('\n'); foreach (string str in vs1) { try { string[] vs2 = str.Split(','); Zero.Pages.Bomber.Hiscore hiscore = new Zero.Pages.Bomber.Hiscore(vs2[0], long.Parse(vs2[1]), vs2[2]); hiscores.Add(hiscore); } catch { } } sr.Close(); } DateTime now = DateTime.Now; string time = String.Format("{0}-{1:00}-{2:00} {3:00}:{4:00}:{5:00}", now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second); hiscores.Add(new Zero.Pages.Bomber.Hiscore(player.Name, player.Score, time)); hiscores = hiscores.OrderByDescending(x => x.Score).ToList(); if (hiscores.Count > 30) hiscores = hiscores.Take(30).ToList(); System.Text.StringBuilder sb = new System.Text.StringBuilder(); foreach (Zero.Pages.Bomber.Hiscore hiscore in hiscores) { sb.Append(String.Format("{0},{1},{2}\n", hiscore.Name, hiscore.Score, hiscore.Time)); } System.IO.StreamWriter sw = new StreamWriter(path); sw.Write(sb.ToString()); sw.Close(); } } |
キー操作への対応
最後にキー操作がされたときにサーバーサイドで処理できるようにする処理を示します。
ゲーム中にプレイヤー名を変更しても対応できるように、キー操作されるたびに対応したPlayerオブジェクトのNameプロパティに名前をセットしています。そのさいスコアランキングを保存するためのテキストファイルがカンマ区切りなので、プレイヤー名にカンマ(”,”)がある場合は他の文字に置き換えています。
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 BomberHub : Hub { public void DownKey(string key, string name) { // 長大なデータが送りつけられるかもしれないので対策 if (key.Length > 16) return; if (!Players.ContainsKey(Context.ConnectionId)) return; Player player = Players[Context.ConnectionId]; player.Name = name.Length > 16 ? name.Substring(0, 16) : name; player.Name = player.Name.Replace(",", "_"); if (key == "ArrowUp") player.IsUpKeyDown = true; else if (key == "ArrowDown") player.IsDownKeyDown = true; else if (key == "ArrowLeft") player.IsLeftKeyDown = true; else if (key == "ArrowRight") player.IsRightKeyDown = true; else if (key == " ") player.SetBomb(); } public void UpKey(string key) { // 長大なデータが送りつけられるかもしれないので対策 if (key.Length > 16) return; if (!Players.ContainsKey(Context.ConnectionId)) return; Player player = Players[Context.ConnectionId]; if (key == "ArrowUp") player.IsUpKeyDown = false; if (key == "ArrowDown") player.IsDownKeyDown = false; if (key == "ArrowLeft") player.IsLeftKeyDown = false; if (key == "ArrowRight") player.IsRightKeyDown = false; } } |
Program.csに以下を追加しておきます。
Program.cs
1 2 |
// 場所はapp.Run();の手前あたり app.MapHub<BomberHub>("/BomberHub"); |
スコアランキングの表示
スコアランキングを表示させるページをつくります。場所はゲームのページと同じ階層です。
/css/hiscore.cssの内容は クライアントサイドの処理 ASP.NET Coreで3Dっぽいカーレースをつくる(4)と同じです。
Pages\Bomber\hi-score.cshtml
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 |
@page @{ Layout = ""; } <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>対戦型ボンバーマン 上位30位</title> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"> <link rel="stylesheet" href="../css/hiscore.css"> </head> <body> @{ string path = "../hiscore-bomber.txt"; List<Bomber.Hiscore> hiscores = new List<Bomber.Hiscore>(); if (System.IO.File.Exists(path)) { System.IO.StreamReader sr = new StreamReader(path); string text = sr.ReadToEnd(); string[] vs1 = text.Split('\n'); foreach (string str in vs1) { try { string[] vs2 = str.Split(','); Bomber.Hiscore hiscore = new Bomber.Hiscore(vs2[0], long.Parse(vs2[1]), vs2[2]); hiscores.Add(hiscore); } catch { } } sr.Close(); } } <div id = "container"> <div id = "h1">鳩でもわかる対戦型ボンバーマン 上位30位</div> <div id = "left"> <p><a href="./game">⇒ 対戦型ボンバーマンのページへ戻る</a></p> <div id = "result" > <table class="table" border="1" id="table"> @{ int num = 0; } @foreach(Bomber.Hiscore hiscore in hiscores) { num++; <tr> <td>@num 位</td> <td>@hiscore.Name</td> <td>@hiscore.Score</td> <td>@hiscore.Time</td> </tr> } @if (num < 30) { @for (num++; num <= 30; num++) { <tr> <td>@num 位</td> <td></td> <td></td> <td></td> </tr> } } </table> </div> </div> <div id = "right" > <div id = "h2">ここには見て欲しいページへのリンクを設置する</div> <!-- ほかの自作ゲームのページへのリンクを設置する --> </div> </div> </body> </html> |