
オンライン対戦できるオセロをつくる(1)の続きです。今回はサーバーサイドとクライアントサイドでデータのやりとりをするためのGameHubクラスを定義します。
Contents
準備
Microsoft.AspNetCore.SignalR.Hubクラスを継承してGameHubクラスを定義します。
| 1 2 3 4 5 6 7 8 | using Microsoft.AspNetCore.SignalR; namespace Reversi {     public class GameHub : Hub     {     } } | 
インデントが深くなるので名前空間部分は省略します。
| 1 2 3 | public class GameHub : Hub { } | 
VisualStudioのプロジェクト作成でASP.NET Core Webアプリを選択を選択するとコードが自動生成されるのですが、Program.csに以下のコードを追加します。追加するのは2行だけでそれ以外は既存のコードです。

Program.cs
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorPages(); builder.Services.AddSignalR(); // これを追加 var app = builder.Build(); if (!app.Environment.IsDevelopment()) {     app.UseExceptionHandler("/Error"); } app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); app.MapRazorPages(); app.MapHub<Reversi.GameHub>("/reversi-hub"); // これを追加 app.Run(); | 
接続時の処理
ハブ メソッドの各呼び出しは、新しいハブ インスタンスで実行されるため、変数はすべて静的変数として定義しています。
| 1 2 3 4 5 6 | public class GameHub : Hub {     static object SyncObject = new object(); // 非同期処理でforeachを実行したときに例外が発生しないようにするための同期オブジェクト     static List<Game> Games = new List<Game>(); // 前述のGameクラスのリスト     static Dictionary<string, IClientProxy> ConnectionIdClientPairs = new Dictionary<string, IClientProxy>(); // ASP.NET SignalRで使われる一意の接続IDからIClientProxyを求めるための辞書 } | 
ユーザーが接続したときにおこなわれる処理を示します。
ConnectionIdClientPairsにASP.NET SignalRで使われる一意の接続IDとIClientProxyを登録します。そして現在対戦がおこなわれているゲームの番号とプレイヤー名、エントリーし対戦相手を待っているユーザーがいればそのプレイヤー名を取得してクライアントサイドに送信します。
| 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 GameHub : Hub {     public override async Task OnConnectedAsync()     {         await base.OnConnectedAsync();         string entryString = ""; // エントリーし対戦相手を待っているプレイヤー名         string gamesString = ""; // 現在対戦がおこなわれているゲームの番号とプレイヤー名を取得する         List<string> list = new List<string>();         lock (SyncObject)         {             ConnectionIdClientPairs.Add(Context.ConnectionId, Clients.Caller);             foreach (Game g in Games)             {                 if (g.IsMatched())                     list.Add($"{g.GameNumber}\t{g.Players[0].Name}\t{g.Players[1].Name}");             }             Game? game = Games.FirstOrDefault(_ => !_.IsMatched());             if (game != null)             {                 Player? player = game.GetWaitingPlayer();                 if (player != null)                     entryString = $"<span class ='player-name'>{player.Name}</span> が対戦相手を待っています。";             }         }         gamesString = string.Join("\n", list);         if (ConnectionIdClientPairs.Count == 1)             Timer.Start();         await Clients.Caller.SendAsync("SendToClientSuccessfulConnection", Context.ConnectionId, entryString);         await Clients.Caller.SendAsync("SendToClientUpdateGames", gamesString);     } } | 
切断時の処理
ユーザーがページから離脱したときの処理を示します。
エントリーしているユーザーが離脱したときはエントリーは取り消されたことになるので全ユーザーに対してこれを通知します。また該当するGameオブジェクトもGamesリストから取り除きます。
| 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 | public class GameHub : Hub {     public override async Task OnDisconnectedAsync(Exception? exception)     {         lock (SyncObject)         {             if (ConnectionIdClientPairs.ContainsKey(Context.ConnectionId))             {                 ConnectionIdClientPairs.Remove(Context.ConnectionId);                 Game? game = Games.FirstOrDefault(_ => !_.IsMatched());                 if (game != null)                 {                     Player? player = game.GetWaitingPlayer();                     if (player.ID == Context.ConnectionId)                     {                         Games.Remove(game);                         IClientProxy[] clients = ConnectionIdClientPairs.Select(_ => _.Value).ToArray();                         foreach (IClientProxy client in clients)                         {                             client.SendAsync("SendToClientSuccessfulEntry", $"<span class ='player-name'>{player.Name}</span> がエントリーを取り消しました。", false);                         }                     }                 }             }         }     } } | 
エントリー時の処理
ユーザーがエントリーしたときにおこなわれる処理を示します。
すでに対戦相手の片方が決定しているGameオブジェクトがあるならそのユーザーとマッチング成立となり、Game.SetPlayerメソッドが呼び出され、対局開始となります。そのようなGameオブジェクトがない場合は新たなGameオブジェクトが生成されたあと、イベントハンドラの追加、Game.SetPlayerメソッドの呼び出しが行われ、Gamesリストに追加されます。
| 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 GameHub : Hub {     public void Entry(string playerName)     {         lock (SyncObject)         {             string id = Context.ConnectionId;             Game? game = Games.FirstOrDefault(_ => !_.IsMatched());             if (game != null) // すでに待機ユーザーがいる場合             {                 game.SetPlayer(new Player(id, playerName));             }             else // いない場合             {                 Game newGame = new Game();                 AddEventHandlers(newGame);                 newGame.SetPlayer(new Player(id, playerName));                 Games.Add(newGame);             }         }     } } | 
Gameオブジェクトへのイベントハンドラの追加
Gameオブジェクトにイベントハンドラを追加する処理を示します。
| 1 2 3 4 5 6 7 8 9 10 | public class GameHub : Hub {     void AddEventHandlers(Game game)     {         game.SuccessfulEntry += Game_SuccessfulEntry; // エントリー時         game.Matched += Game_Matched; // マッチング成立時         game.Deny += Game_Deny; // 不正な着手がされた時         game.StatusChanged += Game_StatusChanged; // 盤面の状態が変わった時     } } | 
エントリー成功時の処理
エントリーが成功したときにおこなわれる処理を示します。
エントリーしたユーザーには手続きが完了したこと、それ以外のユーザーにはエントリーしたユーザー名を送信します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public class GameHub : Hub {     private void Game_SuccessfulEntry(Game game, Game.SuccessfulEntryArg e)     {         IClientProxy[] entryClients;         IClientProxy[] othorClients;         lock (SyncObject)         {             entryClients = ConnectionIdClientPairs.Where(_ => _.Key == e.ID).Select(_ => _.Value).ToArray();             othorClients = ConnectionIdClientPairs.Where(_ => _.Key != e.ID).Select(_ => _.Value).ToArray();         }         foreach (IClientProxy client in entryClients)             client.SendAsync("SendToClientSuccessfulEntry", "<span class ='result'>エントリー完了</span>", true);         foreach (IClientProxy client in othorClients)             client.SendAsync("SendToClientSuccessfulEntry", $"<span class ='player-name'>{e.PlayerName}</span> が対戦相手を待っています。", false);     } } | 
マッチング成立時
ユーザーがエントリーした結果、マッチング成立した時におこなわれる処理を示します。
マッチングしたユーザーには先手または後手であることを通知します。それ以外のユーザーにはマッチング成立によって待機ユーザーがいなくなったことを通知します。
| 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 GameHub : Hub {     private void Game_Matched(Game game, Game.MatchedArg e)     {         IClientProxy blackClient;         IClientProxy whiteClient;         IClientProxy[] otherClients;         List<string> list = new List<string>();         lock (SyncObject)         {             blackClient = ConnectionIdClientPairs.FirstOrDefault(_ => _.Key == e.IDs[0]).Value;             whiteClient = ConnectionIdClientPairs.FirstOrDefault(_ => _.Key == e.IDs[1]).Value;             otherClients = ConnectionIdClientPairs.Where(_ => _.Key != e.IDs[0] && _.Key != e.IDs[1]).Select(_ => _.Value).ToArray();             foreach (Game g in Games)             {                 if (g.IsMatched())                     list.Add($"{g.GameNumber}\t{g.Players[0].Name}\t{g.Players[1].Name}");             }         }         blackClient.SendAsync("SendToClientMatched", "マッチングしました。あなたは黒(先手)", 'B');         whiteClient.SendAsync("SendToClientMatched", "マッチングしました。あなたは黒(後手)", 'W');         foreach (IClientProxy client in otherClients)             client.SendAsync("SendToClientMatched", "", 'N');     } } | 
対局時の処理
ユーザーが着手しようとしたときに行われる処理を示します。
対戦中のユーザーが盤上をクリックするとその位置がサーバーサイドに送信されます。着手可能な位置であれば着手の処理がおこなわれ、盤面の状態が変更されたときはGame.StatusChangedイベントが発生します。そうでない場合はGame.Denyイベントが発生します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public class GameHub : Hub {     public void PutStone(int col, int row)     {         if (col < 0 || col >= 8 || row < 0 || row >= 8)             return;         lock (SyncObject)         {             string id = Context.ConnectionId;             Game? game = Games.FirstOrDefault(_ => _.BlackOrWhite(id) == 'B' || _.BlackOrWhite(id) == 'W');             if (game != null)                 game.PutStone(id, col, row);         }     } } | 
ユーザーが不適切な着手をしようとしたときに行われる処理を示します。このときはGame.Denyイベントが送信されてくるので、クライアントサイドに”SendToClientDeny”を送信します。
| 1 2 3 4 5 6 7 8 | public class GameHub : Hub {     private void Game_Deny(Game game, Game.DenyArg e)     {         if (ConnectionIdClientPairs.ContainsKey(e.ID))             ConnectionIdClientPairs[e.ID].SendAsync("SendToClientDeny", e.Reason);     } } | 
盤面の状態が変化したときにおこなわれる処理を示します。
盤面の状態が変化(着手やそれによって石がひっくり返された、残り時間が変化したなど)したら対戦者と観戦者にこれを通知します。また全ユーザーに現在リアルタイムで観戦可能な対局に関する情報を送信します。
| 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 | public class GameHub : Hub {     private void Game_StatusChanged(Game game, Game.StatusChangedArg e)     {         string[] ids = e.IDs;         List<IClientProxy> clients = new List<IClientProxy>(); // 対戦者と観戦者のIClientProxy         IClientProxy[] allClients; // 全ユーザーのIClientProxy         string gamesString = "";         lock (SyncObject)         {             // 対戦者と観戦者の IClientProxy を取得する             foreach (string id in ids)             {                 if (ConnectionIdClientPairs.ContainsKey(id))                     clients.Add(ConnectionIdClientPairs[id]);             }             // 終了したGameオブジェクトはリストから外す             Games = Games.Where(_ => !_.IsFinished).ToList();             // 全ユーザーの IClientProxy と観戦可能な全対局に関する情報に関する文字列を取得する             allClients = ConnectionIdClientPairs.Select(_ => _.Value).ToArray();             List<string> list = new List<string>();             foreach (Game g in Games)                 list.Add($"{g.GameNumber}\t{g.Players[0].Name}\t{g.Players[1].Name}");             gamesString = string.Join("\n", list);         }         foreach (IClientProxy client in clients)             client.SendAsync("SendToClientStatusChanged", e.Json);         foreach (IClientProxy client in allClients)             client.SendAsync("SendToClientUpdateGames", gamesString);     } } | 
観戦に関する処理
ユーザーが現在おこなわれている対戦を観戦しようとしたときにおこなわれる処理を示します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | public class GameHub : Hub {     public void AddWatcher(int gameNumber)     {         lock (SyncObject)         {             // 現在観戦中の対戦があればいったん解除する             foreach (Game g in Games)                 g.RemoveWatcher(Context.ConnectionId);             // 観戦しようとしている対戦に対応するGameオブジェクトがあれば・・・             Game? game = Games.FirstOrDefault(_ => _.GameNumber == gameNumber);             if (game != null)                 game.AddWatcher(Context.ConnectionId);         }     } } | 
ユーザーが過去の対戦を観戦しようとしたときにおこなわれる処理を示します。
| 1 2 3 4 5 6 7 8 9 10 | public class GameHub : Hub {     public void ShowPastMatches()     {         string json = Game.GetJsonPastMatches();         string id = Context.ConnectionId;         if (ConnectionIdClientPairs.ContainsKey(id))             ConnectionIdClientPairs[id].SendAsync("SendToClientGotPastMatches", json);     } } | 
