ASP.NET Core版 対戦型のマインスイーパーをつくる(1)の続きです。
1 2 3 4 5 6 7 8 9 |
using Microsoft.AspNetCore.SignalR; using System.Timers; namespace MinesweeperGame { public class MinesweeperHub : Hub { } } |
と書くべきところを名前空間を省略して
1 2 3 |
public class MinesweeperHub : Hub { } |
と書くことにします。
Contents
接続時の処理
接続されたときはReceiveConnectedを送信して、接続に成功したことをクライアントサイドに伝えます。また新たなユーザーが接続したとき、接続しているのはそのユーザーだけだったというときは、Game.Initメソッドを呼び出して地雷が埋め込まれているマップを生成しなおします。
そのあとマップの状態とプレイヤーの名前とスコアを文字列に変換してクライアントサイドに送信します。
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 |
public class MinesweeperHub : Hub { static Dictionary<string, IClientProxy> ClientProxyMap = new Dictionary<string, IClientProxy>(); public override async Task OnConnectedAsync() { await Clients.Caller.SendAsync("ReceiveConnected", "接続成功", Context.ConnectionId); await base.OnConnectedAsync(); ClientProxyMap.Add(Context.ConnectionId, Clients.Caller); if(ClientProxyMap.Count == 1) Game.Init(); // プレイヤー名とスコアをカンマ区切りの文字列にして取得する string names = ""; string scores = ""; GetPlayerInfos(ref names, ref scores); // プレイヤー名とスコアをクライアントに送信する await Clients.Caller.SendAsync("ReceiveScores", names, scores); // マップの状態をクライアントに送信する await Clients.Caller.SendAsync("ReceiveDraw", Game.RowMax, Game.ColMax, Game.GetStringFromMap(null)); // 開かれていないセルの数をクライアントに送信する await Clients.Caller.SendAsync("ReceiveUnopenedCellCount", Game.GetUnopenedCellCount()); } } |
送信するプレイヤー名とスコアをカンマ区切りの文字列にして取得する処理を示します。
プレイヤー名をスコアが高い順に表示させたいのでソートしています。またクライアントサイドではhtmlにそのまま埋め込むのでエスケープ処理をおこなっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class MinesweeperHub : Hub { void GetPlayerInfos(ref string names, ref string scores) { if (Game.Players.Count == 0) { names = ""; scores = ""; return; } List<Player> players = Game.Players.Values.OrderByDescending(_ => _.Score).ToList(); names = String.Join(",", players.Select(p => p.Name).ToArray()); names = names.Replace("<", "<"); names = names.Replace(">", ">"); scores = String.Join(",", players.Select(p => p.Score).ToArray()); } } |
切断時の処理
切断時の処理を示します。
プレイ中のユーザーが離脱またはなんらかの理由で通信が切れてしまった場合、Game.PlayersからPlayerオブジェクトを取り除かなければなりません。また接続しているクライアントが格納されているClientProxyMapからも削除します。そのあと全ユーザーにプレイヤーがひとりいなくなったことを通知するためにReceiveScoresを送信します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class MinesweeperHub : Hub { public override async Task OnDisconnectedAsync(Exception? exception) { await base.OnDisconnectedAsync(exception); if (Game.Players.ContainsKey(Context.ConnectionId)) Game.Players.Remove(Context.ConnectionId); if (ClientProxyMap.ContainsKey(Context.ConnectionId)) ClientProxyMap.Remove(Context.ConnectionId); if (Game.Players.Count > 0) { string names = ""; string scores = ""; GetPlayerInfos(ref names, ref scores); await Clients.All.SendAsync("ReceiveScores", names, scores); } } } |
ゲームに参加するときの処理
ユーザーがゲームに参加するときの処理を示します。
Game.Playersのなかに第一引数をキーとする値が見つかった場合は、現在プレイ中なので二重に参加する処理がおこなわれないようにしています。
それ以外のときはPlayerオブジェクトを生成してGame.Playersのなかに格納しています。そしてたしかにそのユーザーがゲームに参加したことを伝えるためにReceiveGameStartedを送信します(これは参加したユーザーのみ)。そのあと全ユーザーにゲームに参加しているプレイヤー名とスコアの表示を更新するためにReceiveScoresを送信します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class MinesweeperHub : Hub { public async Task GameStart(string connectionId, string playerName) { if (Game.Players.ContainsKey(connectionId)) return; Player player = new Player(connectionId, playerName); Game.Players.Add(connectionId, player); string names = ""; string scores = ""; GetPlayerInfos(ref names, ref scores); await Clients.Caller.SendAsync("ReceiveGameStarted"); await Clients.All.SendAsync("ReceiveScores", names, scores); } } |
セルを開く処理
プレイヤーがセルを開こうとしたときの処理を示します。
まずゲームに参加していないユーザーがクリックした場合は無視します。またクライアントサイドのcanvas内であってもセルが存在しない部分をクリックした場合も無視します。
ゲーム中にプレイヤーが自分の名前を変更しているかもしれないので、そのつど引数を受け取ってPlayer.Nameにセットしています。もし名前欄が空欄の場合はPlayerクラス内で「名無しさん」という名前がつけられます(前のページ参照)。
開こうとしている場所に自分で立てた旗がある場合はクリアします。そしてまだ開けられていないセルで地雷が埋まっていないセルであればプレイヤーのスコアを加算してGame.IsMapOpen[row, col]をtrueにします。そのあとそのクライアントにReceiveOpenを送信します。
地雷が埋まっているセルを開こうとした場合は一発ゲームオーバーです。Game.Playersから該当するPlayerオブジェクトを取り除き、そのユーザーにはReceiveGameOverを送信します。
いずれの場合も全ユーザーのディスプレイの描画内容を変更しなければなりません。そこで全ユーザーに残りのセルの数、全プレイヤーの名前とスコアを文字列に変換して送信します(ReceiveUnopenedCellCountとReceiveScores)。
セルの状態も送信しなければなりませんが、プレイヤーによって旗を立てている場所が違うので注意が必要です。Game.GetStringFromMapメソッドにPlayerを渡して送信用の適切なデータを取得します。
またプレイヤーがセルを開いた結果、残りのセル数が0になる場合があります。この場合はステージクリアとなり、3秒後に新しいマップが生成され表示されます。
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 MinesweeperHub : Hub { public async Task OpenCell(string name, int col, int row) { if (!Game.Players.ContainsKey(Context.ConnectionId)) return; Player player = Game.Players[Context.ConnectionId]; player.Name = name; if (col >= 0 && col < Game.ColMax && row >= 0 && row < Game.RowMax) { // 旗を立てているセルは開けることができない(先に旗を取り除くこと) if (player.Flags.Any(f => f.Column == col && f.Row == row)) return; // 地雷が埋まっていないセルを開こうとしたとき if (Game.Map[row, col] != -1 && !Game.IsMapOpen[row, col]) { Game.IsMapOpen[row, col] = true; AddScore(player, col, row); // スコアを加算 await Clients.Caller.SendAsync("ReceiveOpen", player.Score, row, col); // セルを開いた結果、ステージクリアになっているかもしれない(後述) await CheckClear(player); } // 地雷が埋まっているセルを開こうとしたときは一発ゲームオーバー if (Game.Map[row, col] == -1) { // 必要ならスコアランキングに登録(後述) SaveHiscore(player); if (Game.Players.ContainsKey(Context.ConnectionId)) Game.Players.Remove(Context.ConnectionId); await Clients.Caller.SendAsync("ReceiveGameOver", row, col); } // どちらの場合も全ユーザーにセルの状態、残りのセル数等を送信する string names = ""; string scores = ""; GetPlayerInfos(ref names, ref scores); await Clients.All.SendAsync("ReceiveScores", names, scores); await Clients.All.SendAsync("ReceiveUnopenedCellCount", Game.GetUnopenedCellCount()); foreach (var pair in ClientProxyMap) { Player? player0 = null; if (Game.Players.ContainsKey(pair.Key)) player0 = Game.Players[pair.Key]; await ClientProxyMap[pair.Key].SendAsync("ReceiveDraw", Game.RowMax, Game.ColMax, Game.GetStringFromMap(player0)); } } } void AddScore(Player player, int col, int row) { player.Score += (Game.Map[row, col] + 1) * 10; } } |
ステージクリア時の処理
セルを開いた結果、Game.GetUnopenedCellCount() == 0になった場合はステージクリアです。この場合は3秒後にTimer_Elapsedを実行するタイマーをセットします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class MinesweeperHub : Hub { async Task CheckClear(Player player, int col, int row) { if (Game.GetUnopenedCellCount() == 0) { await Clients.All.SendAsync("ReceiveCleared"); System.Timers.Timer timer = new System.Timers.Timer(); timer.Interval = 3000; timer.Elapsed += Timer_Elapsed; timer.Start(); } } } |
イベントハンドラTimer_Elapsedが実行されたら第一引数として渡されたタイマーを停止してDisposeします。
そのあと新しいマップをつくります。さらに全ユーザーが立てた旗をクリアします。そして全ユーザーにマップの状態と残りのセル数を伝えるためにReceiveDrawとReceiveUnopenedCellCountを送信します。
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 class MinesweeperHub : Hub { private void Timer_Elapsed(object? sender, ElapsedEventArgs e) { if (sender == null) return; System.Timers.Timer t = (System.Timers.Timer)sender; t.Stop(); t.Dispose(); Game.Init(); foreach (Player player in Game.Players.Values) player.Flags.Clear(); Task.Run(async() => { foreach (var client in ClientProxyMap.Values) { await client.SendAsync("ReceiveDraw", Game.RowMax, Game.ColMax, Game.GetStringFromMap(null)); await client.SendAsync("ReceiveUnopenedCellCount", Game.GetUnopenedCellCount()); } }); } } |
旗を立てる処理
旗を立てるときの処理を示します。
この場合もセルを開くときと同様、ゲームに参加していないユーザーがクリックした場合は無視します。またクライアントサイドのcanvas内であってもセルが存在しない部分をクリックした場合も無視します。
ゲーム中にプレイヤーが自分の名前を変更しているかもしれないので、そのつど引数を受け取ってPlayer.Nameにセットしています。そのあとPlayer.Flagsを調べて、その場所に旗が立っていない場合は旗を立て、すでに立っている場合は取り除きします。そしてReceiveDrawを送信します。
新しく立てられた旗を描画する必要があるのはそのプレイヤーだけなので送信するのはそのユーザーだけです。
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 MinesweeperHub : Hub { public async Task SetFlag(string name, int col, int row) { if (!Game.Players.ContainsKey(Context.ConnectionId)) return; Player player = Game.Players[Context.ConnectionId]; player.Name = name; if (col >= 0 && col < Game.ColMax && row >= 0 && row < Game.RowMax) { // すでに開いているセルには旗は立てられない // 他のプレイヤーが開いたセルに自分で立てている旗は除去できる if (!Game.IsMapOpen[row, col] && !player.Flags.Any(f => f.Column == col && f.Row == row)) player.Flags.Add(new Position(col, row)); else player.ClearFlag(col, row); await Clients.Caller.SendAsync("ReceiveDraw", Game.RowMax, Game.ColMax, Game.GetStringFromMap(player)); } } } |
スコアランキングへの登録
まずスコアを格納するクラスを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
namespace Zero.Pages.MinesweeperGame { 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 MinesweeperHub : Hub { void SaveHiscore(Player player) { string path = "../hiscore-minesweeper.txt"; List<Zero.Pages.MinesweeperGame.Hiscore> hiscores = new List<Zero.Pages.MinesweeperGame.Hiscore>(); if (File.Exists(path)) { 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.MinesweeperGame.Hiscore hiscore = new Zero.Pages.MinesweeperGame.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.MinesweeperGame.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.MinesweeperGame.Hiscore hiscore in hiscores) { sb.Append(String.Format("{0},{1},{2}\n", hiscore.Name, hiscore.Score, hiscore.Time)); } StreamWriter sw = new StreamWriter(path); sw.Write(sb.ToString()); sw.Close(); } } |
スコアランキングを表示させる
スコアランキングを表示する処理を示します。
まずゲームを表示するページと同じディレクトリにhi-score.cshtmlをつくります。
内容はタイトルとスコアランキングの情報が書かれているテキストファイルのパスが違うだけで、AspNetCore.SignalRにおける処理 ASP.NET Core版 ボンバーマンのような対戦型ゲームをつくる(3)の終わりのほうに書かれているものとほとんど同じです。
Pages\Minesweeper\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 |
@page @{ Layout = ""; } <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>対戦MinesweeperGame 上位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-minesweeper.txt"; List<Tank.Hiscore> hiscores = new List<Tank.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(','); Tank.Hiscore hiscore = new Tank.Hiscore(vs2[0], long.Parse(vs2[1]), vs2[2]); hiscores.Add(hiscore); } catch { } } sr.Close(); } } <div id = "container"> <div id = "h1">鳩でもわかるMinesweeperGame 上位30位</div> <div id = "left"> <p><a href="./game">⇒ 対戦Minesweeperゲームのページへ戻る</a></p> <div id = "result" > <table class="table" border="1" id="table"> @{ int num = 0; } @foreach(Tank.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> </div> </body> </html> |