ASP.NET coreで対戦型『殺しの七並べ』をつくる(2)の続きです。今回はASP.NET Core用のSignalRで使用するHubクラスを定義します。
Contents
Program.csの編集
まずASP.NET Coreで新しいプロジェクトを作成したときに自動生成されるProgram.csを編集します。
Program.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorPages(); builder.Services.AddSignalR(); var app = builder.Build(); app.UseStatusCodePagesWithRedirects(Global.BaseUrl + "/StatusCode404"); app.UseStaticFiles(); app.UseRouting(); app.MapRazorPages(); app.MapHub<KillSeven.GameHub>("/kill-seven-hub"); // これを追加 app.Run(); |
このあとKillSeven.GameHubを定義します。
Hubクラスの定義
1 2 3 4 5 6 7 8 9 10 |
using Microsoft.AspNetCore.SignalR; using System.Text.Json; using System.Timers; namespace KillSeven { public class GameHub : Hub { } } |
以降は名前空間部分を省略して表記します。
1 2 3 |
public class GameHub : Hub { } |
フィールド変数
静的フィールド変数として以下を定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class GameHub : Hub { static bool _isFirstConnection = true; static System.Timers.Timer _timer = new System.Timers.Timer(); // SignalR によって割り当てられる一意のIDとIClientProxyの辞書 static Dictionary<string, IClientProxy> _clientProxies = new Dictionary<string, IClientProxy>(); // クリティカルセクションで排他ロックをするための同期オブジェクト static object _syncClientProxyMap = new object(); static Game? _game = null; } |
接続のイベント処理
接続のイベントが発生したときにおこなわれる処理を示します。
辞書にContext.ConnectionIdとIClientProxyを登録します。そして接続に成功したユーザーに対して”SucceedConnectionToClient”イベントを送信して接続に成功したことを知らせます。
またはじめて実行されたときはタイマーの初期化をおこないます。接続されているユーザーが0から1になったときはタイマーをスタートさせます。
辞書に追加したり削除するときに非同期処理でforeach文を実行すると例外がおきるので、ここがクリティカルセクションになります。そこで排他ロックをかけます。
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 override async Task OnConnectedAsync() { if (_isFirstConnection) { // はじめて実行されたときだけタイマーの初期化をおこなう _isFirstConnection = false; _timer.Interval = 1000; _timer.Elapsed += Timer_Elapsed; // 後述 } await base.OnConnectedAsync(); await Clients.Caller.SendAsync("SucceedConnectionToClient", "接続成功", Context.ConnectionId); // AddとRemoveするときがクリティカルセクション lock (_syncClientProxyMap) _clientProxies.Add(Context.ConnectionId, Clients.Caller); if (_clientProxies.Count == 1) _timer.Start(); } } |
切断のイベント処理
切断のイベントが発生したときにおこなわれる処理を示します。
辞書からContext.ConnectionIdを削除します。通信が切断されたユーザーはこれまでゲームに参加していたプレイヤーかもしれません。そこでGame.RemovePlayerメソッドを実行します。もし現在ゲームに参加しているユーザーが離脱したのであればプレイヤーからも削除する処理が実行されます。そのあとプレイヤーの数を確認します。プレイヤーの数が0の場合(全員が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 GameHub : Hub { public override async Task OnDisconnectedAsync(Exception? exception) { await base.OnDisconnectedAsync(exception); IClientProxy[] clients; lock (_syncClientProxyMap) { if (_clientProxies.ContainsKey(Context.ConnectionId)) _clientProxies.Remove(Context.ConnectionId); clients = _clientProxies.Values.ToArray(); // IClientProxyの配列としてコピーする } if (_game != null) { int playerCount = 0; lock (_game) { // Game.RemovePlayerメソッドを実行することで // 現在ゲームに参加しているユーザーが離脱したのであればプレイヤーからも削除される // 処理をしたあとプレイヤーの数を確認する _game.RemovePlayer(Context.ConnectionId); playerCount = _game.Players.Count(player => player.ConnectionID != ""); } // プレイヤーの数が0になっているならGameオブジェクトは破棄する // この場合はすべての接続ユーザーに対して全員が試合放棄したことを通知する if (playerCount == 0) { _game = null; foreach (IClientProxy client in clients) _ = Task.Run(async () => await client.SendAsync("GameAbandonedByAll")); } } } } |
エントリー時の処理
ユーザーがエントリーボタンをクリックしたときの処理を示します。
プレイヤー名が設定されていない場合は”名無しさん”とします。Gameオブジェクトが存在しない場合は生成してイベントハンドラを追加します。
そのあとGame.AddPlayerメソッドを実行します。ゲーム参加の処理が正常におこなわれた場合は-1以外の値が返されます。この場合はそのユーザーに”SucceedEntryToClient”を送信します。-1が返されたときはゲームに参加する処理がおこなわれなかった場合(NPCが存在しなかった)なので”FailuredEntryToClient”を送信します。
Game.AddPlayerメソッド実行中にGame.Updateメソッドが実行されると例外が発生する可能性があるので、排他ロックをかけます。
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 { public async void Entry(string playerName) { if (playerName == "") playerName = "名無しさん"; if (_game == null) { _game = new Game(); _game.Started += OnGameStarted; // イベントハンドラの追加(後述) _game.Finished += OnGameFinished; _game.PutCard += OnPutCard; _game.DenyCard += OnDenyCard; ; _game.Killed += OnKilled; _game.PlayerPassed += OnPlayerPassed; } int playerNumber = -1; lock (_game) playerNumber = _game.AddPlayer(playerName, Context.ConnectionId); if (playerNumber != -1) await Clients.Caller.SendAsync("SucceedEntryToClient", playerNumber); else await Clients.Caller.SendAsync("FailuredEntryToClient"); } } |
ゲーム開始時の処理
ゲーム開始時の処理を示します。ゲームが開始されたら接続しているユーザー全員に”GameStartedToClient”を送信します。
1 2 3 4 5 6 7 8 9 10 11 12 |
public class GameHub : Hub { private void OnGameStarted(object? sender, EventArgs e) { IClientProxy[] clients; // 排他ロックをかけてそのあいだにIClientProxyの配列としてコピーする lock (_syncClientProxyMap) clients = _clientProxies.Values.ToArray(); foreach (IClientProxy client in clients) Task.Run(() => client.SendAsync("GameStartedToClient")); } } |
プレイヤーが着手しようとしたときの処理
プレイヤーがカードを出そうとしたときにおこなわれる処理を示します。
Game.PlayerPutOutCardメソッドを実行するときにCardのリストに対して要素の追加と削除がおこなわれるため、ここがクリティカルセクションになります。プレイヤーがもっているカードが変化した場合はこれをユーザー全員に通知する必要があるのでSendDataメソッド(後述)でイベントを送信します。
1 2 3 4 5 6 7 8 9 10 11 12 |
public class GameHub : Hub { public void PutOutCard(int suit, int number) { if (_game != null) { lock (_game) _game.PlayerPutOutCard(Context.ConnectionId, suit, number); SendData(); // 後述 } } } |
プレイヤーがもっているカードが変化したときに、これをユーザー全員に通知する処理を示します。
通知する内容は全ユーザー共通のものと各プレイヤー独自のものがあります。ゲームに参加しているプレイヤーの名前、所持するカードや殺されたカードの枚数、順位などは全ユーザー共通です。しかしプレイヤーがもつカードはそのプレイヤー以外には送信しません。これらをjsonに変換してクライアントサイドに送信します。
全ユーザーに”CardsToClient”イベントとともに第一引数には共通のものと、第二引数には各プレイヤー独自のものを渡します。第三引数は手番であればtrue、そうでない場合は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 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 |
public class GameHub : Hub { static void SendData() { if (_game == null) return; List<Player> players; string json; // 全ユーザー共通で送信する文字列 string curPlayerConnectionID = ""; // ConnectionIDと各プレイヤー独自に送信する文字列の辞書 Dictionary<string, string> cardJsonMap = new Dictionary<string, string>(); lock (_game) { int playerNumber = _game.CurPlayerNumber; List<Player> players = _game.Players.ToList(); json = JsonSerializer.Serialize(_game); curPlayerConnectionID = players[playerNumber].ConnectionID; // 各プレイヤーに個別で送る文字列を取得して辞書に格納する // ConnectionIDが空文字列のNPCには送信する必要がない foreach (Player player in players) { string connection = player.ConnectionID; if (connection != "") { string cardJson = JsonSerializer.Serialize(player.Cards.ToArray()); cardJsonMap.Add(connection, cardJson); } } } // 全ユーザーのConnectionIDとIClientProxyの辞書のコピーを取得する KeyValuePair<string, IClientProxy>[] clientProxies; lock (_syncClientProxyMap) clientProxies = _clientProxies.ToArray(); // 送信する // "CardsToClient"イベントの第一引数:全ユーザー共通部分 第二引数:自分のカード 第三引数:手番かどうか? foreach (KeyValuePair<string, IClientProxy> pair in clientProxies) { Task.Run(() => { string json2 = ""; if (cardJsonMap.ContainsKey(pair.Key)) json2 = cardJsonMap[pair.Key]; if (pair.Key == curPlayerConnectionID) Task.Run(() => pair.Value.SendAsync("CardsToClient", json, json2, true)); else Task.Run(() => pair.Value.SendAsync("CardsToClient", json, json2, false)); }); } } } |
着手が成立したときの処理
着手が成立したら効果音を再生します。自分が着手したときとそうでないときの効果音を変えたいため、着手したプレイヤーに対しては”PutCardToClient”イベントを、そうでないユーザーには”OthersPutCardToClient”を送信しています。
着手したプレイヤーに対応するIClientProxyやそれ以外の接続しているユーザー全員のIClientProxyの配列を生成しているときに_clientProxiesに要素が追加されたり削除されると例外が発生するため、排他ロックをかけています。
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 OnPutCard(object? sender, PutCardArgs args) { IClientProxy? client = null; // 着手したプレイヤーに対応するIClientProxy IClientProxy[] otherClients; // それ以外の接続しているユーザー全員のIClientProxyの配列 lock (_syncClientProxyMap) { if (_clientProxies.ContainsKey(args.Player.ConnectionID)) client = _clientProxies[args.Player.ConnectionID]; otherClients = _clientProxies.Values.Where(c => c != client).ToArray(); } Task.Run(() => client?.SendAsync("PutCardToClient")); foreach (IClientProxy clientProxy in otherClients) Task.Run(() => clientProxy.SendAsync("OthersPutCardToClient")); } } |
不正な着手に対する処理
ユーザーが着手できない場所にカードを置こうとした場合は、そのことを該当ユーザーに通知するために”DenyCardToClient”イベントを送信します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class GameHub : Hub { private void OnDenyCard(object game, PutCardArgs args) { IClientProxy? client = null; lock (_syncClientProxyMap) { if (_clientProxies.ContainsKey(args.Player.ConnectionID)) client = _clientProxies[args.Player.ConnectionID]; } Task.Run(() => client?.SendAsync("DenyCardToClient")); } } |
カードが殺されたときの処理
カードが殺されたときは殺されたカードのなかに自分のカードが含まれていたかどうかで効果音を変えたいので、イベントハンドラの引数を調べて、送信先のユーザーのカードが含まれている場合は”KilledOwnToClient”イベント、そうでない場合は”KilledOthersToClient”イベントを送信します。
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 GameHub : Hub { private void OnKilled(object game, KilledArgs args) { List<Player> killedPlayers = args.Players; List<IClientProxy> killedClients = new List<IClientProxy>(); List<IClientProxy> clients = new List<IClientProxy>(); List<KeyValuePair<string, IClientProxy>> clientProxies = new List<KeyValuePair<string, IClientProxy>>(); lock (_syncClientProxyMap) { clientProxies = _clientProxies.ToList(); foreach (KeyValuePair<string, IClientProxy> pair in clientProxies) { if (killedPlayers.Any(player => player.ConnectionID == pair.Key)) killedClients.Add(pair.Value); else clients.Add(pair.Value); } } foreach (IClientProxy client in killedClients) Task.Run(() => client.SendAsync("KilledOwnToClient")); foreach (IClientProxy client in clients) Task.Run(() => client.SendAsync("KilledOthersToClient")); } } |
パスする場合の処理
ユーザーであるプレイヤーが出せるカードがない場合、制限時間以内にカードを出さなかった場合は自動的にパスとなります。この場合はOnPlayerPassedメソッドが呼び出されるので、そのユーザーに対して”PlayerPassedToClient”を送信します。
1 2 3 4 5 6 7 8 9 10 11 12 |
public class GameHub : Hub { private void OnPlayerPassed(object game, PlayerPassedArgs args) { string id = args.Player.ConnectionID; lock (_syncClientProxyMap) { if (_clientProxies.ContainsKey(id)) Task.Run(() => _clientProxies[id].SendAsync("PlayerPassedToClient")); } } } |
ゲームが終了したときの処理
ゲームが終了したらOnGameFinishedメソッドが呼び出されます。引数を調べればゲームの結果がわかるので、これをjsonに変換してすべての接続しているユーザーに”GameFinishedToClient”イベントとともに送信します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class GameHub : Hub { private void OnGameFinished(object? sender, FinishedArgs args) { IClientProxy[] clients; lock (_syncClientProxyMap) clients = _clientProxies.Values.ToArray(); string json = JsonSerializer.Serialize(args.Players); foreach (IClientProxy client in clients) Task.Run(() => client.SendAsync("GameFinishedToClient", json)); } } |
定期的におこなわれる処理
1秒ごとにTimer_Elapsedメソッドが呼び出されます。このときはGame.Updateメソッドを呼び出して、そのあと前述のSendDataメソッドを呼び出してゲームの状態を全ユーザーに送信します。ただしGameオブジェクトが存在しない場合、すでにゲームが終了している場合はなにもしません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class GameHub : Hub { static private void Timer_Elapsed(object? sender, ElapsedEventArgs e) { if (_game != null && _game.IsFinished) _game = null; if (_game == null) return; lock (_game) _game.Update(); SendData(); } } |