前回 ASP.NET Coreで3Dっぽいカーレースをつくる(2)の続きです。
Contents
CarRace3dHubクラスの定義する
| 1 2 3 4 5 6 7 8 9 10 11 | using Microsoft.AspNetCore.SignalR; using Zero.Pages.CarRace3d; using CarRace3d; using System.Timers; namespace SignalRChat.Hubs {     public class CarRace3dHub : Hub     {     } } | 
インデントが深くなるので以降、この記事のなかでは
| 1 2 3 | public class CarRace3dHub : Hub { } | 
と書きます。
フィールド変数
まずフィールド変数を示します。ほとんどが静的なものです。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public class CarRace3dHub : Hub {     static Game game = new Game();     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, HubCallerContext> HubCallerContexts = new Dictionary<string, HubCallerContext>();     static List<Player> NPCs = new List<Player>();     static Dictionary<string, Player> Players = new Dictionary<string, Player>();     const int PLAYER_MAX = 7; } | 
接続成功時の処理
接続に成功したときの処理を示します。
接続成功に成功したことをクライアントサイドに通知します。また最初の接続がおこなわれた場合は、NPCを生成します。プレイできる人数の上限は7人なので、それ以外はNPC(non player character)です。そこで6つのPlayerオブジェクトをNPCとして生成します。タイマーのイベントは1秒間に24回発生させます。
そのあと各プレイヤーにサーバサイドから送信するときにIClientProxyが必要になるので辞書に格納しています。またサーバー側から通信を切断する必要が生じる場合もあるのでHubCallerContextも辞書に格納しています。またContext.ConnectionIdからPlayerオブジェクトを取得できるようにこれも辞書に格納しています。
このとき接続されているユーザーが1の場合は停止しているタイマーをスタートさせます。
それから「サーバー側から通信を切断する必要が生じる場合」ですが、大人数でプレイしようとするとサーバーに負荷がかかりすぎるのとクライアントサイドでも描画処理に時間がかかりカクカクしてしまうのでプレイできる人数は7人に限定しています。8人目がアクセスしようとすると別のページにリダイレクトさせて通信を切断する処理をおこないます。
二人目以降がアクセスするときはすでにプレイしている人とNPCが6つあるのですが、このような場合はNPCをひとつ取り除き、そこへ新しいPlayerオブジェクトを追加します。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 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 | public class CarRace3dHub : Hub {     public override async Task OnConnectedAsync()     {         // クライアントサイドに接続に成功した旨を通知する         await Clients.Caller.SendAsync("ReceiveConnected", "接続成功 ゲームの準備をしています", Context.ConnectionId);         if (IsFirstConnection)         {             IsFirstConnection = false;             Timer.Interval = 1000 / 24;             Timer.Elapsed += Timer_Elapsed;             Player npc = new Player(155, 121, 0.6);             NPCs.Add(npc);             npc = new Player(190, 82, 0.9);             NPCs.Add(npc);             npc = new Player(240, 120, 0.7);             NPCs.Add(npc);             npc = new Player(160, 210, 0.9);             NPCs.Add(npc);             npc = new Player(185, 240, 1.0);             NPCs.Add(npc);             npc = new Player(300, 240, 0.7);             NPCs.Add(npc);         }         await Clients.Caller.SendAsync("ReceiveConnected", "接続成功", Context.ConnectionId);         await base.OnConnectedAsync();         ClientProxyMap.Add(Context.ConnectionId, Clients.Caller);         if (ClientProxyMap.Count == 1)             Timer.Start();         if (PLAYER_MAX < NPCs.Count + Players.Count + 1 && NPCs.Count == 0)         {             // 人数オーバー 別のページにリダイレクトさせる             await ClientProxyMap[Context.ConnectionId].SendAsync("ReceiveDenyNewPlayer");             return;         }         HubCallerContexts.Add(Context.ConnectionId, Context);         if (PLAYER_MAX < NPCs.Count + Players.Count + 1 && NPCs.Count > 0)             NPCs.RemoveAt(0);         Player player = new Player(Context.ConnectionId);         Players.Add(Context.ConnectionId, player);         player.Crash += Player_Crash;         player.Recover += Player_Recover;         player.GameOvered += Player_GameOvered;         await SendUpdateToClient(Context.ConnectionId);         await Clients.Caller.SendAsync("ReceiveGameStart");     } } | 
イベントハンドラの定義
クラッシュとそこから回復したときのイベントハンドラを示します。クライアントサイドにその旨を送信します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | public class CarRace3dHub : Hub {     private void Player_Crash(Player player)     {         Task.Run(async () =>         {             await ClientProxyMap[player.ConnectionID].SendAsync(                 "ReceiveCrash", player.ConnectionID             );         });     }     private void Player_Recover(Player player)     {         Task.Run(async () =>         {             await ClientProxyMap[player.ConnectionID].SendAsync(                 "ReceiveRecover", player.ConnectionID             );         });     } } | 
ゲームオーバー時のイベントハンドラを示します。ゲームオーバーになったらそれをクライアントサイドに送信するのですが、他にもおこなわれる処理があります。
ゲームオーバーになったときはゲームオーバーの通知だけでなく、canvas上部に表示させる文字列も送信します。ゲームオーバーになるとプレイできる人数に上限があるので通信を切断します。通信がきれてしまうので「残機 0」の表示がされなくなるのでcanvas上部に表示させる文字列も送信する必要があるのです。
またPlayers辞書から格納されているPlayerオブジェクトを取り除き、これをNPCsリストに追加します。このときにPlayerオブジェクトのプロパティをNPC用に変更できるように、前述のPlayer.ToNPCメソッドを呼び出します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public class CarRace3dHub : Hub {     private void Player_GameOvered(Player player, int score)     {         SaveHiScore(player.Name, score); // 後述         Task.Run(async () =>         {             string id = player.ConnectionID;             await ClientProxyMap[id].SendAsync("ReceivePlayerInfo", Players[id].InfoText1, Players[id].InfoText2);             await ClientProxyMap[id].SendAsync("ReceiveGameOvered", id);             Disconnect(player);             Players.Remove(id);             NPCs.Add(player);             player.ToNPC();         });     } } | 
通信を切断する処理
ゲームオーバーになったときに通信を切断するための処理を示します。HubCallerContexts辞書を検索してみつかったHubCallerContextをAbortします。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public class CarRace3dHub : Hub {     public void Disconnect(string connectionID)     {         if (HubCallerContexts.ContainsKey(connectionID))         {             HubCallerContexts[connectionID].Abort();             HubCallerContexts.Remove(connectionID);         }     }     void Disconnect(Player player)     {         if (HubCallerContexts.ContainsKey(player.ConnectionID))         {             HubCallerContexts[player.ConnectionID].Abort();             HubCallerContexts.Remove(player.ConnectionID);         }     } } | 
切断されたときにおこなわれる処理
切断されたときに呼び出されるOnDisconnectedAsyncメソッドについて示します。辞書から必要なくなったデータを削除しています。
| 1 2 3 4 5 6 7 8 9 10 11 | public class CarRace3dHub : Hub {     public override async Task OnDisconnectedAsync(Exception? exception)     {         await base.OnDisconnectedAsync(exception);         ClientProxyMap.Remove(Context.ConnectionId);         Players.Remove(Context.ConnectionId);         HubCallerContexts.Remove(Context.ConnectionId);     } } | 
キー操作に対応する処理
ユーザーによってキーがおされたときと離されたときの処理を示します。Playerクラス内のフラグをセットしたりクリアします。またキーが押されたときはユーザーが設定しているプレイヤー名もPlayer.Nameプロパティにセットします。ゲームの途中でユーザーが再設定する可能性もあるので、キーが押されるたびにPlayer.Nameプロパティをセットしなおします。
| 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 | public class CarRace3dHub : Hub {     public void DownKey(string key, string name)     {         // 長大なデータが送りつけられるかもしれないので対策         if (key.Length > 16)             return;         if (!Players.ContainsKey(Context.ConnectionId))             return;         Player player = Players[Context.ConnectionId];         player.Name = name;         if (key == "ArrowUp")             player.IsUpKeyDown = true;         if (key == "ArrowDown")             player.IsDownKeyDown = true;         if (key == "ArrowLeft")             player.IsLeftKeyDown = true;         if (key == "ArrowRight")             player.IsRightKeyDown = true;     }     public void UpKey(string key)     {         // 長大なデータが送りつけられるかもしれないので対策         if (key.Length > 16)             return;         if (!Players.ContainsKey(Context.ConnectionId))             return;         Player player = Players[Context.ConnectionId];         if (key == "ArrowUp")             player.IsUpKeyDown = false;         if (key == "ArrowDown")             player.IsDownKeyDown = false;         if (key == "ArrowLeft")             player.IsLeftKeyDown = false;         if (key == "ArrowRight")             player.IsRightKeyDown = false;     } } | 
更新を通知する処理
車の状態をクライアントサイドに通知するための処理を示します。通知するのはXYZ座標、XYZ方向の回転、速度、各プレイヤーの名前です。
| 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 CarRace3dHub : Hub {     static async Task SendUpdateToClient(string id)     {         await ClientProxyMap[id].SendAsync("ReceiveStartUpdate");         foreach (KeyValuePair<string, Player> pair in Players)         {             // 各車のXYZ座標、XYZ方向の回転             Player player = pair.Value;             double x = player.X;             double y = player.Y;             double z = player.Z;             double rx = player.RotationX;             double ry = player.RotationY;             double rz = player.RotationZ;             // クラッシュしている場合は自車なら吹っ飛び             // ライバル車はスピンしているように座標と回転を設定して送信する             int bc = player.BlowingCount;             if (bc > 0)             {                 if (pair.Key == id)                 {                     x += player.IVX_OnCrash * bc;                     y += 0.3 * bc;                     z += player.IVZ_OnCrash * bc;                     rx += 0.5 * bc;                     ry += 0.5 * bc;                     rz += 0.5 * bc;                 }                 else                 {                     ry += Math.PI / 8 * bc;                 }             }             await ClientProxyMap[id].SendAsync(                 "ReceiveUpdate",                 pair.Key, x, y, z, rx, ry, rz, player.Speed, player.Name             );         }         foreach (Player npc in NPCs)         {             double ry = npc.RotationY;             // NPCも同様にクラッシュしているときはスピンしているように座標と回転を設定して送信する             int bc = npc.BlowingCount;             if (bc > 0)                 ry += Math.PI / 8 * bc;             await ClientProxyMap[id].SendAsync(                 "ReceiveUpdate",                 "", npc.X, npc.Y, npc.Z,                 npc.RotationX, ry, npc.RotationZ, npc.Speed, npc.Name             );         }         await ClientProxyMap[id].SendAsync("ReceiveEndUpdate");         await ClientProxyMap[id].SendAsync("ReceivePlayerInfo", Players[id].InfoText1, Players[id].InfoText2);     } } | 
タイマーイベント発生時の処理
タイマーのイベント発生時の処理を示します。プレイヤーとNPCをまとめてGame.OnTimerメソッドに渡します。すると移動処理がおこなわれます。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public class CarRace3dHub : Hub {     static private void Timer_Elapsed(object? sender, ElapsedEventArgs e)     {         Task.Run(async () =>         {             List<Player> allPlayer = Players.Select(_ => _.Value).ToList();             allPlayer.AddRange(NPCs);             Game.OnTimer(allPlayer);             HitCheck(allPlayer);             foreach (string id in ClientProxyMap.Keys)             {                 await SendUpdateToClient(id);             }         });     } } | 
当たり判定
当たり判定の処理を示します。このときクラッシュしてスピンしている車やクラッシュから回復して無敵状態(5秒)にある車との当たり判定はおこないません。当たったようにみえてもすり抜けてしまいます。
| 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 CarRace3dHub : Hub {     static void HitCheck(List<Player> players)     {         foreach (Player player in players)         {             if (player.BlowingCount > 0)                 continue;             if (player.IsInvincible)                 continue;             for (int i = 0; i < players.Count; i++)             {                 Player checkPlayer = players[i];                 // 当然のことながら自分自身との当たり判定はしない                 if (player == checkPlayer)                     continue;                 if (checkPlayer.BlowingCount > 0)                     continue;                 if (checkPlayer.IsInvincible)                     continue;                 // 当たりと判定された場合は両方ともクラッシュさせる                 double d = Math.Pow(player.X - checkPlayer.X, 2) + Math.Pow(player.Z - checkPlayer.Z, 2);                 if (d < 4)                 {                     player.Crush();                     checkPlayer.Crush();                 }             }         }     } } | 
スコアランキング登録の処理
最後にゲームオーバー時にスコアランキングに登録する処理を示します。
まずスコアを管理するクラスを定義します。名前空間がZero.Pagesになっていますが、これはプロジェクト名がZeroになっているからです。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | namespace Zero.Pages.CarRace3d {     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 = "";     } } | 
以下の処理はプレイヤー名とスコアを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 49 50 51 52 53 54 55 56 57 | public class CarRace3dHub : Hub {     public void SaveHiScore(string name, int score)     {         // プレイヤー名は12文字まで。それ以上の長さの文字列が送りつけられた場合は切り詰める         if (name.Length > 12)             name = name.Substring(0, 12);         // プレイヤー名とスコア、時刻はカンマ区切り。         // プレイヤー名のなかにカンマがある場合は別の文字に置換する         // 保存場所が同じディレクトリでないのは間違って上書きアップロードしてしまわないようにするという         // 管理人の個人的な都合による         name.Replace(",", "_");         string path = "../hiscore-car-race.txt";         List<Hiscore> hiscores = new List<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(',');                     Hiscore hiscore = new Hiscore(vs2[0], long.Parse(vs2[1]), vs2[2]);                     hiscores.Add(hiscore);                 }                 catch                 {                 }             }             sr.Close();         }         DateTime dt = DateTime.Now;         string time = String.Format(             "{0}/{1:00}/{2:00} {3:00}:{4:00}:{5:00}",             dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second);         Hiscore newScore = new Hiscore(name, score, time);         hiscores.Add(newScore);         hiscores = hiscores.OrderByDescending(_ => _.Score).Take(30).ToList();         System.Text.StringBuilder sb = new System.Text.StringBuilder();         foreach (Hiscore hiscore in hiscores)         {             sb.Append(hiscore.Name + "," + hiscore.Score.ToString() + "," + hiscore.Time + "\n");         }         System.IO.StreamWriter sw = new StreamWriter(path);         sw.Write(sb);         sw.Close();     } } | 
