ASP.NET coreで対戦型『スペースウォー!』(Spacewar!)をつくる(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<SpacewarApp.GameHub>("/spacewar-app-hub"); // これを追加 app.Run(); |
このあとSpacewarApp.GameHubを定義します。
Hubクラスの定義
1 2 3 4 5 6 7 8 9 |
using Microsoft.AspNetCore.SignalR; using System.Text.Json; namespace SpacewarApp { public class GameHub : Hub { } } |
以降は名前空間部分を省略して表記します。
1 2 3 |
public class GameHub : Hub { } |
フィールド変数
静的フィールド変数として以下を定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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 List<Game> Games = new List<Game>(); // Gameオブジェクトのリスト static Player? WaitingPlayer = null; // 対戦相手を待っているプレーヤー static int NextGameNumber = 0; // 次に生成されるGameオブジェクトのGameNumber // 非同期でClientProxiesにデータを追加したり削除するとforeach文のなかで例外が発生するので // 追加または削除するデータはいったんリスト内に保存し、 // 実際の加除はforeach文が実行されていないときにおこなう static List<Game> DeletingGames = new List<Game>(); static List<Game> AddingGames = new List<Game>(); static List<string> DeletingConnectionIds = new List<string>(); static List<string> AddingConnectionIds = new List<string>(); static List<IClientProxy> AddingClients = new List<IClientProxy>(); } |
SignalR によって割り当てられる一意のIDから、そのユーザーが対戦しているゲームに対応するGameオブジェクトとPlayerオブジェクトを取得する処理を示します。
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 GameHub : Hub { Game? GetGame(string connectionId) { return Games.FirstOrDefault(game => (game.Player0 != null && game.Player0.ConnectionId == connectionId) || (game.Player1 != null && game.Player1.ConnectionId == connectionId) ); } Player? GetPlayer(string connectionId) { Game? game = GetGame(connectionId); if (game != null) { if (game.Player0 != null && game.Player0.ConnectionId == connectionId) return game.Player0; else if (game.Player1 != null && game.Player1.ConnectionId == connectionId) return game.Player1; return null; } else return null; } } |
接続のイベント処理
接続のイベントが発生したときにおこなわれる処理を示します。
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 { public override async Task OnConnectedAsync() { // 初回時のみタイマーの初期化をおこなう if (IsFirstConnection) { IsFirstConnection = false; Timer.Interval = 1000 / 60; Timer.Elapsed += Timer_Elapsed; } await base.OnConnectedAsync(); // ClientProxiesに追加するConnectionIdとIClientProxyをリストに一時保存する AddingConnectionIds.Add(Context.ConnectionId); AddingClients.Add(Clients.Caller); // タイマーが停止している場合はStartさせる if (!Timer.Enabled) Timer.Start(); // await をつけないと失敗する可能性があるので注意 await Clients.Caller.SendAsync("SucceedConnectionToClient", Context.ConnectionId); } } |
切断のイベント処理
切断のイベントが発生したときにおこなわれる処理を示します。
他のユーザーと対戦中のユーザーが通信を切断した場合は相手に対して試合放棄されたことを通知します。また対戦相手を待っているユーザーが通信を切断した場合も待機しているユーザーがいなくなったことを全ユーザーに通知します。
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 { public override async Task OnDisconnectedAsync(Exception? exception) { if (Global.IsDebug) Console.WriteLine("切断しました:" + Context.ConnectionId); await base.OnDisconnectedAsync(exception); // ClientProxiesから削除するConnectionIdをリストに一時保存する DeletingConnectionIds.Add(Context.ConnectionId); // 対戦中のユーザーが試合放棄した場合の処理 Game? game = GetGame(Context.ConnectionId); // 後述 if (game != null) { // Gamesから削除するGameオブジェクトをリストに一時保存する DeletingGames.Add(game); _ = Task.Run(async () => { if (game.Player0 != null && game.Player1 != null) { Player? winner = null; // 試合放棄しなかった側を勝者とする string loserName = ""; // 試合放棄したプレーヤーの名前 if (game.Player0.ConnectionId == Context.ConnectionId) { winner = game.Player1; loserName = game.Player0.Name; } if (game.Player1.ConnectionId == Context.ConnectionId) { winner = game.Player0; loserName = game.Player1.Name; } // 試合放棄しなかった側に試合放棄を通知する if (winner != null && ClientProxies.ContainsKey(winner.ConnectionId)) await ClientProxies[winner.ConnectionId].SendAsync("ByeWinToClient", $"{loserName}が試合放棄しました。"); // 観戦者に対しても試合放棄があったことを通知する string[] watcherIds = game.WatcherIds.ToArray(); foreach (string id in watcherIds) { if (ClientProxies.ContainsKey(id)) await ClientProxies[id].SendAsync("ByeWinToClient", $"{loserName}が試合放棄しました。"); } } }); } // 待機中のユーザーが通信を切断した場合はWaitingPlayer = nullとする if(WaitingPlayer != null && WaitingPlayer.ConnectionId == Context.ConnectionId) WaitingPlayer = null; } } |
キー操作がおこなわれたときの処理
キー操作がおこなわれたときの処理を示します。
キーが押下されたときの処理を示します。
クライアントサイドから送られてきたプレーヤー名が空文字列の場合は”名無しさん”とします。キー操作で待機中のユーザーや対戦中のユーザーの名前が変更された場合があるので、その場合は変更の処理をおこないます。
対戦中のユーザーがキー操作をおこなったときはユーザーに対応するPlayerオブジェクトを取得し、それぞれに適切なフラグのセット、クリア、メソッドの呼び出しをおこないます。また弾丸の発射処理が正常におこなわれたときはそのユーザーと観戦者に対して効果音を再生するためのイベントを送信します(ただし観戦者に対してはPlayer0による発射時のみ)。
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 |
public class GameHub : Hub { public void DownKey(string keycode, string name) { // 長大なデータが送りつけられるかもしれないので対策 if (keycode.Length > 16 || name.Length > 32) return; // クライアントサイドから送られてきたプレーヤー名が空文字列の場合は"名無しさん"とする name = name.Replace("\t", ""); if (name == "") name = "名無しさん"; if (WaitingPlayer != null && WaitingPlayer.ConnectionId == Context.ConnectionId) WaitingPlayer.Name = name; Player? player = GetPlayer(Context.ConnectionId); if (player == null) return; player.Name = name; if (keycode == "ArrowLeft") player.PressLeft = true; if (keycode == "ArrowRight") player.PressRight = true; if (keycode == "ArrowUp") player.Accelerate(); if (keycode == "Space") { if (player.Shot()) { Clients.Caller.SendAsync("ShotToClient"); // 発射したプレーヤー自身へのイベント送信 if (player.Type == 0) // Player0が発射した場合は観戦者全員にもイベント送信をする { Game? game = GetGame(Context.ConnectionId); if(game != null) { try { string[] watcherIds = game.WatcherIds.ToArray(); foreach (string id in watcherIds) { if (ClientProxies.ContainsKey(id)) ClientProxies[id].SendAsync("ShotToClient"); } } catch { } } } } } } } |
キーが離されたときの処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class GameHub : Hub { public void UpKey(string keycode) { // 長大なデータが送りつけられるかもしれないので対策 if (keycode.Length > 16) return; Player? player = GetPlayer(Context.ConnectionId); if (player == null) return; if (keycode == "ArrowLeft") player.PressLeft = false; if (keycode == "ArrowRight") player.PressRight = false; } } |
エントリー時の処理
ユーザーがエントリーをしたときの処理を示します。
待機ユーザーがいないときはエントリーをしたユーザーが待機ユーザーとなり、対戦相手が見つかるまで待機し続けることになります。この処理が正常におこなわれた場合は”SucceedEntryToClient”イベントをエントリーしたユーザーに送信して処理が正常におこなわれたことを通知します。
すでに待機ユーザーがいる場合はそのユーザーと対戦することになります。エントリーしたユーザーと待機していたユーザーに”SucceedMatchingToClient”イベントを送信してマッチングの成功と対戦がはじまることを通知します。
対戦が始まるときはまずGameオブジェクトを生成します。WaitingPlayerはnullにしてGameオブジェクトに爆発発生時とゲーム終了時のイベントハンドラを追加します。そしてこれをすぐにGamesリストに追加しないで一時保存します(すぐに追加してしまうとforeach文が実行されたときに例外が発生するので、明らかにそうではないタイミングで追加する)。
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 void Entry(string playerName) { playerName = playerName.Replace("\t", ""); if (playerName == "") playerName = "名無しさん"; if (WaitingPlayer == null) { // 待機ユーザーがいないときはエントリーをしたユーザーが待機ユーザーとなる WaitingPlayer = new Player(playerName, Context.ConnectionId, 0); Clients.Caller.SendAsync("SucceedEntryToClient"); } else { // 待機ユーザーがいないときはそのユーザーと対戦することになる Game game = new Game(WaitingPlayer, new Player(playerName, Context.ConnectionId, 1), NextGameNumber++); AddingGames.Add(game); Clients.Caller.SendAsync("SucceedMatchingToClient", game.GameNumber); ClientProxies[WaitingPlayer.ConnectionId].SendAsync("SucceedMatchingToClient", game.GameNumber); WaitingPlayer = null; game.Exploded += Exploded; game.GameFinished += GameFinished; } } } |
爆発発生時のイベントハンドラ
爆発発生時のイベントハンドラを示します。
爆発発生時は対戦しているユーザーと観戦しているユーザー全員に”ExplodedToClient”イベントを送信します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class GameHub : Hub { private void Exploded(object? sender, EventArgs e) { if (sender == null) return; try { Game game = (Game)sender; if (game.Player0 != null && ClientProxies.ContainsKey(game.Player0.ConnectionId)) ClientProxies[game.Player0.ConnectionId].SendAsync("ExplodedToClient"); if (game.Player1 != null && ClientProxies.ContainsKey(game.Player1.ConnectionId)) ClientProxies[game.Player1.ConnectionId].SendAsync("ExplodedToClient"); string[] watcherIds = game.WatcherIds.ToArray(); foreach (string id in watcherIds) ClientProxies[id].SendAsync("ExplodedToClient"); } catch { } } } |
対戦終了時のイベントハンドラ
対戦終了時のイベントハンドラを示します。
対応するGameオブジェクトはGamesリストから削除しますが、すぐには削除しないで削除用のリストに一時保存します。これは例外発生を防ぐためです。次に勝者と敗者に”あなたは勝ちました。”または”あなたは負けました。”の文字列とともに”GameFinishedToClient”イベントを送信します。また観戦しているユーザー全員に対してもPlayer0が勝ったのか負けたのかを示す文字列とともに”GameFinishedToClient”イベントを送信します。
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 GameFinished(Game game, GameFinishedArgs args) { DeletingGames.Add(args.Game); if(ClientProxies.ContainsKey(args.Winner.ConnectionId)) ClientProxies[args.Winner.ConnectionId].SendAsync("GameFinishedToClient", "あなたは勝ちました。", true); // 第三引数は勝利ならtrue if (ClientProxies.ContainsKey(args.Loser.ConnectionId)) ClientProxies[args.Loser.ConnectionId].SendAsync("GameFinishedToClient", "あなたは負けました。", false); try { string[] watcherIds = game.WatcherIds.ToArray(); foreach (string id in watcherIds) { if (!ClientProxies.ContainsKey(id)) continue; if (args.Winner.Type == 0) ClientProxies[id].SendAsync("GameFinishedToClient", $"{args.Winner.Name}は勝ちました。", true); if (args.Loser.Type == 0) ClientProxies[id].SendAsync("GameFinishedToClient", $"{args.Loser.Name}は負けました。", false); } } catch { } } } |
観戦の開始と終了
観戦を開始したり終了する処理を示します。
ユーザーはgameNumberを指定して観戦に参加することができます。参加する場合はgameNumberと同じ値をもつGameオブジェクトのWatcherIdsにConnectionIdを追加し、やめる場合はWatcherIdsからConnectionIdを削除します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class GameHub : Hub { public void WatchGame(int gameNumber) { Game? game = Games.FirstOrDefault(game => game.GameNumber == gameNumber); if(game != null) game.WatcherIds.Add(Context.ConnectionId); } public void StopWatchGame(int gameNumber) { Game? game = Games.FirstOrDefault(game => game.GameNumber == gameNumber); if (game != null) game.WatcherIds.Remove(Context.ConnectionId); } } |
更新時の処理
更新時の処理を示します。更新時にはGamesとClientProxiesでforeach文を実行しますが、その前に要素の追加と削除をおこないます。そのあとGameオブジェクトを更新して全ユーザーにデータ(プレーヤーや弾丸の位置など)を送信します。
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 |
public class GameHub : Hub { static private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { AddingRemovingGames(); // foreach文を実行する前に要素の追加と削除(後述) AddingRemovingConnectionIds(); // foreach文を実行する前に要素の追加と削除(後述) // Gameオブジェクトを更新して・・・ Game[] games = Games.ToArray(); foreach (Game game in games) game.Update(); // クライアントサイドにデータを送信する try { string json = JsonSerializer.Serialize(games); IClientProxy[] clients = ClientProxies.Values.ToArray(); foreach (IClientProxy clientProxy in clients) clientProxy.SendAsync("UpdateGameToClient", json); } catch { } try { SendWaitingPlayer(); // 待機中のユーザー名を送信する } catch { } if (ClientProxies.Count == 0) Timer.Stop(); } } |
foreach文を実行する前に要素の追加と削除をする処理を示します。
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 { static void AddingRemovingGames() { // Gamesに追加するオブジェクト Game[] addingGames = AddingGames.ToArray(); AddingGames.Clear(); foreach (Game game in addingGames) Games.Add(game); // Gamesから削除するオブジェクト Game[] deletingGames = DeletingGames.ToArray(); DeletingGames.Clear(); foreach (Game game in deletingGames) Games.Remove(game); } static void AddingRemovingConnectionIds() { string[] addingConnectionIds = AddingConnectionIds.ToArray(); AddingConnectionIds.Clear(); IClientProxy[] addingClients = AddingClients.ToArray(); AddingClients.Clear(); int len = Math.Min(addingConnectionIds.Length, addingClients.Length); // 両者は同じはずなのだが for (int i = 0; i < len; i++) ClientProxies.Add(addingConnectionIds[i], addingClients[i]); string[] deletingConnectionIds = DeletingConnectionIds.ToArray(); DeletingConnectionIds.Clear(); foreach (string id in deletingConnectionIds) { if (ClientProxies.ContainsKey(id)) ClientProxies.Remove(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 |
public class GameHub : Hub { static void SendWaitingPlayer() { if (WaitingPlayer != null) { string message = WaitingPlayer.Name + " があなたとの対戦を待っています。"; IClientProxy[] clients = ClientProxies.Where(_ => _.Key != WaitingPlayer.ConnectionId).Select(_ => _.Value).ToArray(); IClientProxy client = ClientProxies.FirstOrDefault(_ => _.Key == WaitingPlayer.ConnectionId).Value; foreach (IClientProxy clientProxy in clients) clientProxy.SendAsync("WaitingPlayerToClient", message); if (client != null) client.SendAsync("WaitingPlayerToClient", ""); // エントリーしている本人には空文字列を送る } else { string message = "現在 誰もエントリーしていません。"; IClientProxy[] clients = ClientProxies.Values.ToArray(); foreach (IClientProxy clientProxy in clients) clientProxy.SendAsync("WaitingPlayerToClient", message); } } } |