Unityを使わずにフリスビーを犬に届けよ!を作ってみる(2)の続きです。今回はAspNetCore.SignalRにおける処理を実装します。
フリスビーを犬に届けよ!の元ネタ
Unishar-ユニシャー【Unityでのゲーム開発を手助けするメディア】
Contents
FrisbeeHubクラスを定義する
1 2 3 4 5 6 7 8 9 |
using Microsoft.AspNetCore.SignalR; using System.Timers; namespace Frisbee { public class FrisbeeHub : Hub { } } |
以降は名前空間を省略して以下のように書きます。
1 2 3 |
public class FrisbeeHub : Hub { } |
接続時の処理
接続時におこなわれる処理を定義します。
AspNetCore.SignalRでサーバーサイドに接続したときに付与されるIDをキーにしてFrisbeeGameオブジェクトとIClientProxyオブジェクトの辞書を定義します(GamesとClientProxyMap)。またタイマーを使って更新処理をおこなうので静的フィールド変数としてTimerを定義します。
最初にOnConnectedAsyncメソッドが呼び出されたときだけタイマーの初期化をおこないます。また接続ユーザーが0の状態から1になったときは停止しているタイマーをスタートさせます。
新しくユーザーが接続したときはFrisbeeGameオブジェクトを生成します。このときFrisbeeGameクラスのコンストラクタ内で自動的に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 26 27 28 29 30 31 32 33 34 |
public class FrisbeeHub : 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, FrisbeeGame> Games = new Dictionary<string, FrisbeeGame>(); public override async Task OnConnectedAsync() { if (IsFirstConnection) { IsFirstConnection = false; Timer.Interval = 1000 / FrisbeeGame.UPDATES_PER_SECOND; Timer.Elapsed += Timer_Elapsed; } await base.OnConnectedAsync(); ClientProxyMap.Add(Context.ConnectionId, Clients.Caller); FrisbeeGame game = new FrisbeeGame(); game.Player.SetConnectionId(Context.ConnectionId); // IDをセット game.Player.DeadEvent += Player_PlayerDeadEvent; game.Player.StageCleared += Player_StageCleared; Games.Add(Context.ConnectionId, game); if (ClientProxyMap.Count == 1) Timer.Start(); await SendMap(Context.ConnectionId); await Clients.Caller.SendAsync("SuccessfulConnectionToClient", "接続成功", Context.ConnectionId); } } |
切断時の処理
切断時の処理を示します。
Context.ConnectionIdを調べてClientProxyMapとGamesからこれをキーとするオブジェクトが辞書に登録されているのであれば削除します。またこれによって接続されているユーザー数が0になるのであればタイマーを停止します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class FrisbeeHub : Hub { public override async Task OnDisconnectedAsync(Exception? exception) { await base.OnDisconnectedAsync(exception); if (ClientProxyMap.ContainsKey(Context.ConnectionId)) ClientProxyMap.Remove(Context.ConnectionId); if (Games.ContainsKey(Context.ConnectionId)) Games.Remove(Context.ConnectionId); if (ClientProxyMap.Count == 0) Timer.Stop(); try { System.GC.Collect(); } catch { Console.WriteLine("GC.Collect失敗"); } } } |
マップ上のデータを送信する
新しいユーザーが接続された場合、クライアントサイドにマップの状態(障害物、スタート地点、ゴールの位置などの情報)を送信します。そのための処理を示します。
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 FrisbeeHub : Hub { static async Task SendMap(string connectionId) { if (!ClientProxyMap.ContainsKey(connectionId) || !Games.ContainsKey(connectionId)) return; FrisbeeGame game = Games[connectionId]; // 障害物の座標をクライアントサイドに送信する string xs = String.Join(",", game.Obstacles.Select(o => o.CenterX.ToString())); string ys = String.Join(",", game.Obstacles.Select(o => o.CenterY.ToString())); string widths = String.Join(",", game.Obstacles.Select(o => o.Width.ToString())); string heights = String.Join(",", game.Obstacles.Select(o => o.Height.ToString())); await ClientProxyMap[connectionId].SendAsync("SendObstaclesToClient", xs, ys, widths, heights, game.MaxX); // スタート地点とゴールの座標をクライアントサイドに送信する await ClientProxyMap[connectionId].SendAsync( "SendStartGoalToClient", game.Start.CenterX, game.Start.CenterY, game.Start.Width, game.Start.Height, game.Goal.CenterX, game.Goal.CenterY, game.Goal.Width, game.Goal.Height); // プレイヤーの初期座標をクライアントサイドに送信する string playerX = game.Player.X.ToString(); string playerY = game.Player.Y.ToString(); await ClientProxyMap[connectionId].SendAsync( "SendPlayerToClient", game.Player.X, game.Player.Y, FrisbeeGame.PLAYER_PADIUS, FrisbeeGame.PLAYER_THICKNESS); } } |
ゲーム開始時の処理
ゲームが開始されたときにおこなわれる処理を示します。
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 FrisbeeHub : Hub { public void GameStart(string id, string playerName) { if (!ClientProxyMap.ContainsKey(id) || !Games.ContainsKey(id)) return; // ゲームオーバーになっていないのであればなにもしない if(!Games[id].Player.IsGameOver) return; // 対応するFrisbeeGameオブジェクトを取得してFrisbeeGame.GameStartメソッドを実行する // そのときプレイヤー名もセットする Games[id].GameStart(); Games[id].Player.Name = playerName; // たしかにゲームスタートの処理がおこなわれたことをクライアントサイドに伝える Task.Run(async () => { if (ClientProxyMap.ContainsKey(id)) await ClientProxyMap[id].SendAsync("EventGameStartToClient"); }); } } |
更新時の処理
更新時の処理を示します。
FrisbeeGame.Updateメソッドを呼び出して各オブジェクトの状態を更新します。そのあとSendUpdateToClientメソッドを呼び出してクライアントサイドにデータを送信します。
そのあとプレイヤーの位置、火花や星が発生していればその位置して送信します。最後にゲーム開始からの経過時間を取得し、クライアントサイドでの更新処理に必要なデータはすべて送信した旨を伝えるEndUpdateToClientとともにこれを送信します。
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 |
public class FrisbeeHub : Hub { static private void Timer_Elapsed(object? sender, ElapsedEventArgs e) { Task.Run(async () => { foreach (FrisbeeGame game in Games.Values) game.Update(); foreach (string id in ClientProxyMap.Keys) await SendUpdateToClient(id); }); } static async Task SendUpdateToClient(string id) { if (!ClientProxyMap.ContainsKey(id) || !Games.ContainsKey(id)) return; // 対応するPlayerオブジェクトを取得 Player player = Games[id].Player; // プレイヤーの位置をクライアントサイドに送信する string x = player.X.ToString(); string y = player.Y.ToString(); await ClientProxyMap[id].SendAsync( "UpdatePlayerToClient", player.X, player.Y, player.Angle, player.IsDead); // 火花が発生していればその位置をクライアントサイドに送信する List<Spark> sparks = Games[id].Sparks; string sparkXs = String.Join(",", sparks.Select(spark => spark.X).ToArray()); string sparkYs = String.Join(",", sparks.Select(spark => spark.Y).ToArray()); string sparkLifes = String.Join(",", sparks.Select(spark => spark.Life).ToArray()); await ClientProxyMap[id].SendAsync("UpdateSparksToClient", sparkXs, sparkYs, sparkLifes); // 星が発生していればその位置をクライアントサイドに送信する List<Spark> stars = Games[id].Stars; string starXs = String.Join(",", stars.Select(star => star.X).ToArray()); string starYs = String.Join(",", stars.Select(star => star.Y).ToArray()); await ClientProxyMap[id].SendAsync("UpdateStarsToClient", starXs, starYs); // ゲーム開始からの経過時間を取得し、クライアントサイドに送信する string time = ""; if (!player.IsGameOver) { TimeSpan ts = player.GetElapsedTime(); time = string.Format("{0:00}:{1:00}:{2:00}", ts.Minutes, ts.Seconds, ts.Milliseconds / 10); } // 必要なデータはすべてクライアントサイドに送信した旨を送信する await ClientProxyMap[id].SendAsync("EndUpdateToClient", time); } } |
ミス時の処理
ミス時のイベントハンドラの部分を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class FrisbeeHub : Hub { private void Player_PlayerDeadEvent(object? sender, EventArgs e) { SendEventToClient(sender, "PlayerDeadEventToClient"); } void SendEventToClient(object? sender, string sendString) { Player? player = (Player?)sender; if (player == null) return; if (ClientProxyMap.ContainsKey(player.ConnectionId)) { Task.Run(async () => { if (ClientProxyMap.ContainsKey(player.ConnectionId)) await ClientProxyMap[player.ConnectionId].SendAsync(sendString); }); } } } |
ステージクリア時の処理
ステージクリア時のイベントハンドラの部分を示します。
TimeTrialManager.Saveメソッドを呼び出していますが、TimeTrialManagerクラスはこのあと示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class FrisbeeHub : Hub { private void Player_StageCleared(object? sender, EventArgs e) { SendEventToClient(sender, "StageClearEventToClient"); Player? player = (Player?)sender; if (player != null) { string path = "../hiscore-frisbee.txt"; TimeTrialManager.Save(path, player.Name, player.GetElapsedTime()); } } } |
TimeTrialManagerクラスを示します。
これまでスコアランキングを表示させるための処理とほとんど同じです。このゲームはタイムトライアルなのでスコアのかわりにクリアまでにかかった時間を保存しています。また消費時間が少ないほうが上位なのでその部分がこれまでのものと違うだけです。
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 |
public class TimeTrialManager { public static void Save(string filePath, string playerName, TimeSpan span, int maxCount = 30) { List<Hiscore> hiscores = new List<Hiscore>(); if (System.IO.File.Exists(filePath)) { System.IO.StreamReader sr = new StreamReader(filePath); string text = sr.ReadToEnd().Replace("\r", ""); string[] vs1 = text.Split('\n'); foreach (string str in vs1) { try { string[] vs2 = str.Split(','); Hiscore hiscore = new 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); int milliseconds = (int)span.TotalMilliseconds; hiscores.Add(new Hiscore(playerName, milliseconds, time)); hiscores = hiscores.OrderBy(x => x.Score).ToList(); if (hiscores.Count > maxCount) hiscores = hiscores.Take(maxCount).ToList(); System.Text.StringBuilder sb = new System.Text.StringBuilder(); foreach (Hiscore hiscore in hiscores) sb.Append(String.Format("{0},{1},{2}\n", hiscore.Name, hiscore.Score, hiscore.Time)); System.IO.StreamWriter sw = new StreamWriter(filePath); sw.Write(sb.ToString()); sw.Close(); } public static List<Hiscore> Load(string filePath) { List<Hiscore> hiscores = new List<Hiscore>(); if (System.IO.File.Exists(filePath)) { System.IO.StreamReader sr = new StreamReader(filePath); string text = sr.ReadToEnd().Replace("\r", ""); string[] vs1 = text.Split('\n'); foreach (string str in vs1) { try { string[] vs2 = str.Split(','); Hiscore hiscore = new Hiscore(vs2[0], long.Parse(vs2[1]), vs2[2]); hiscores.Add(hiscore); } catch { } } sr.Close(); } return hiscores; } } |
ユーザーのキー操作に対応する部分の処理を示します。ここもほとんど同じです。ゲームによって使用するキーが異なるため、その部分にだけ違いがあります。
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 |
public class FrisbeeHub : Hub { public void DownKey(string key, string name) { if (key.Length > 16) return; if (!Games.ContainsKey(Context.ConnectionId)) return; Player player = Games[Context.ConnectionId].Player; // プレイヤー名のなかにスコアランキングに登録できない文字があれば置き換える player.Name = name.Length > 16 ? name.Substring(0, 16) : name; player.Name = player.Name.Replace(",", "_"); if (key == "ArrowLeft") player.IsLeftKeyDown = true; else if (key == "ArrowRight") player.IsRightKeyDown = true; else if (key == "ArrowUp") { player.IsUpKeyDown = true; if(!player.IsDead) ClientProxyMap[Context.ConnectionId].SendAsync("MoveUpToClient"); } } public void UpKey(string key) { if (key.Length > 16) return; if (!Games.ContainsKey(Context.ConnectionId)) return; Player player = Games[Context.ConnectionId].Player; if (key == "ArrowLeft") player.IsLeftKeyDown = false; else if (key == "ArrowRight") player.IsRightKeyDown = false; else if (key == "ArrowUp") player.IsUpKeyDown = false; } } |
最後にProgram.csにFrisbee.FrisbeeHubを追加します。
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(); app.UseStatusCodePagesWithRedirects(Global.BaseUrl + "/StatusCode404"); app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); app.MapRazorPages(); app.MapHub<Frisbee.FrisbeeHub>("/FrisbeeHub"); // これを追加 app.Run(); |