ASP.NET Core版 対戦型Pengoをつくる(2)の続きです。
Contents
PengoHubクラスの定義
1 2 3 4 5 6 |
namespace PengoGame { public class PengoHub : Hub { } } |
以降は名前空間を省略して以下のように書きます。
1 2 3 |
public class PengoHub : Hub { } |
フィールド変数
フィールド変数を示します。
1 2 3 4 5 6 7 |
public class PengoHub : Hub { static bool IsFirstConnection = true; static System.Timers.Timer Timer = new System.Timers.Timer(); static Dictionary<string, IClientProxy> ClientProxyMap = new Dictionary<string, IClientProxy>(); } |
接続したときの処理を示します。
接続されたら接続に成功したことをクライアントサイドに伝えます。そしてContext.ConnectionIdをキーにしてClientProxyMapにClients.Callerを登録します。そしてクライアントサイドに現在マップに存在するブロックの座標を送信します。
はじめて接続されたときはタイマーにイベントハンドラを追加して、タイマーをスタートさせます。
接続した結果、ClientProxyMap.Countが1の場合はフィールドを初期化します。さらにフィールドに追加されたブロックのそれぞれに移動を開始したとき、停止したとき、他のプレイヤーを撃破したとき、破壊されたときのイベントハンドラを追加します。この段階ではユーザーはゲームには参加していません。
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 |
public class PengoHub : Hub { public override async Task OnConnectedAsync() { if (IsFirstConnection) { IsFirstConnection = false; Timer.Interval = 1000 / 24; Timer.Elapsed += Timer_Elapsed; } await base.OnConnectedAsync(); await Clients.Caller.SendAsync("ReceiveConnected", "接続成功", Context.ConnectionId); ClientProxyMap.Add(Context.ConnectionId, Clients.Caller); if (ClientProxyMap.Count == 1) { Game.Init(); foreach (Block block in Game.Blocks) { block.BlockStart += Block_BlockStart; block.BlockStoped += Block_BlockStoped; block.BlockBreak += Block_BlockBreak; block.HitPlayer += Block_HitPlayer; } Game.NPCs.Clear(); for (int i = 0; i < Game.PLAYER_MAX; i++) { Player npc = new Player("", i); Game.NPCs.Add(npc); } Timer.Start(); } // 壁をクライアントサイドに送信する string xs = String.Join(",", Game.Blocks.Select(wall => wall.X.ToString()).ToArray()); string ys = String.Join(",", Game.Blocks.Select(wall => wall.Y.ToString()).ToArray()); await Clients.Caller.SendAsync("ReceiveBlocks", xs, ys); } } |
切断されたときの処理を示します。
ClientProxyMapとGame.Playersから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 |
public class PengoHub : Hub { public override async Task OnDisconnectedAsync(Exception? exception) { await base.OnDisconnectedAsync(exception); if (ClientProxyMap.ContainsKey(Context.ConnectionId)) ClientProxyMap.Remove(Context.ConnectionId); if (ClientProxyMap.Count == 0) Timer.Stop(); if (Game.Players.ContainsKey(Context.ConnectionId)) { string playerName = Game.Players[Context.ConnectionId].Name; playerName = playerName.Replace(",", "_").Replace("<", "<").Replace(">", ">"); int resetNumber = Game.Players[Context.ConnectionId].PlayerNumber; Game.Players.Remove(Context.ConnectionId); Player npc = new Player("", resetNumber); npc.Invincible(); Game.NPCs.Add(npc); foreach (string key in ClientProxyMap.Keys) await ClientProxyMap[key].SendAsync("ReceiveNotification", playerName + "が試合放棄しました"); } } } |
イベントハンドラの定義
ブロックが移動を開始したときの処理を示します。すべてのクライアントにReceiveBlockKickを送信します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class PengoHub : Hub { static void Block_BlockStart(Block block) { Task.Run(async () => { foreach (string key in ClientProxyMap.Keys) { await ClientProxyMap[key].SendAsync("ReceiveBlockKick"); } }); } } |
ブロックが停止したときの処理を示します。このときブロックは他のプレイヤーに衝突しているかもしれません。イベントハンドラの第三引数をみれば撃破したプレイヤーの数がわかるので点数計算をして第二引数のプレイヤーのスコアに加算します。
1 2 3 4 5 6 7 8 |
public class PengoHub : Hub { static void Block_BlockStoped(Block block, Player kickPlayer, int hitCount) { if (hitCount > 0) kickPlayer.Score += 400 * (int)Math.Pow(2, hitCount); } } |
プレイヤーがブロックを壊したときの処理を示します。すべてのクライアントにReceiveBlockBreakを送信します。
1 2 3 4 5 6 7 8 9 10 11 |
public class PengoHub : Hub { static void Block_BlockBreak(Block block) { Task.Run(async () => { foreach (string key in ClientProxyMap.Keys) await ClientProxyMap[key].SendAsync("ReceiveBlockBreak"); }); } } |
飛ばれたブロックで他のプレイヤーを撃破したときの処理を示します。
第一引数が撃破したプレイヤー、第二引数が撃破されたプレイヤーです。撃破されたプレイヤーの座標を中心に火花を飛び散らせます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class PengoHub : Hub { static void Block_HitPlayer(Player player, Player hitedPlayer) { if (player != hitedPlayer) { hitedPlayer.Dead(); Game.SetSparks(hitedPlayer.X, hitedPlayer.Y); Task.Run(async () => { foreach (string key in ClientProxyMap.Keys) await ClientProxyMap[key].SendAsync("ReceivePlayerDead"); }); } } } |
ユーザーがゲームに参加したときの処理
ユーザーがゲームに参加しようとしたときの処理を示します。もしNPCが存在するのであれば先頭の要素を取り除き、新しく生成したPlayerオブジェクトをGame.Playersに追加します。またゲームオーバー時の処理ができるようにイベントハンドラも追加します。最後にクライアントサイドから送られてきたプレイヤー名をセットしてすべてのユーザーに参戦した旨を伝えます。
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 |
public class PengoHub : Hub { public void GameStart(string id, string playerName) { if (Game.Players.ContainsKey(id)) return; if (Game.NPCs.Count > 0) { playerName = playerName.Replace(",", "_"); int playerNumber = Game.NPCs[0].PlayerNumber; Game.NPCs.RemoveAt(0); Player player = new Player(id, playerNumber); player.GameOverEvent += Player_GameOverEvent; player.Invincible(); // 参加直後は無敵状態にする player.Name = playerName; Game.Players.Add(id, player); Task.Run(async () => { // クライアントサイドではinnerHTMLを使うのでエスケープ処理をおこなう playerName = playerName.Replace(",", "_").Replace("<", "<").Replace(">", ">"); foreach (string key in ClientProxyMap.Keys) { if (id != key) await ClientProxyMap[key].SendAsync("ReceiveNotification", playerName + "が参戦しました"); if (id == key) await ClientProxyMap[key].SendAsync("ReceiveNotification", playerName + "として参戦しました"); } }); } } } |
更新処理
Timer.Elapsedイベントが発生したときの処理を示します。
ここでやっていることはプレイヤー、NPC、ブロック、火花の位置と状態の更新です。更新されたデータはSendUpdateToClientメソッドでクライアントサイドに送信されます。
それからここではブロックの数が少なくなってきたら新しいブロックを追加する処理もしています。
新しいブロックを追加するときはいきなりブロックが出現するとユーザーが困惑するので、その位置にブロックを点滅させます。そして一定時間が経過したらブロックを配置します。
ブロックが出現する位置はGame.GetAddBlockPositionsメソッドで取得できます。これをReceiveBeforeAddBlocksでクライアントサイドに送信します。その2秒後にイベントハンドラTimer_Elapsed1を呼び出して実際にフィールド上にブロックを追加します。
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 |
public class PengoHub : Hub { // 処理が二重にならないように追加されようとしているブロックを一時的に保存しておく static List<Block> _AddBlocks = new List<Block>(); static private void Timer_Elapsed(object? sender, ElapsedEventArgs e) { Task.Run(async () => { if (Game.Blocks.Count < 40 && _AddBlocks.Count == 0) { _AddBlocks = Game.GetAddBlockPositions(); string addWallsX = String.Join(",", _AddBlocks.Select(wall => wall.X.ToString()).ToArray()); string addWallsY = String.Join(",", _AddBlocks.Select(wall => wall.Y.ToString()).ToArray()); foreach (string key in ClientProxyMap.Keys) await ClientProxyMap[key].SendAsync("ReceiveBeforeAddBlocks", addWallsX, addWallsY); System.Timers.Timer timer = new System.Timers.Timer(); timer.Interval = 2000; timer.Elapsed += Timer_Elapsed1; timer.Start(); } // Game.AllPlayersはPlayerNumber順にソートされたPlayerのリストを返すいる List<Player> players = Game.AllPlayers; foreach (Player player in players) { if(player.ConnectionID != "") player.UpdatePlayer(); else player.UpdateNPC(); } foreach (Spark spark in Game.Sparks) spark.Update(); foreach (Block block in Game.Blocks) block.Update(); string blocksX = String.Join(",", Game.Blocks.Select(wall => wall.X.ToString()).ToArray()); string blocksY = String.Join(",", Game.Blocks.Select(wall => wall.Y.ToString()).ToArray()); string blocksLife = String.Join(",", Game.Blocks.Select(wall => wall.TimeUntilDisappears.ToString()).ToArray()); string sparksX = String.Join(",", Game.Sparks.Select(fire => fire.X.ToString()).ToArray()); string sparksY = String.Join(",", Game.Sparks.Select(fire => fire.Y.ToString()).ToArray()); string sparksLife = String.Join(",", Game.Sparks.Select(fire => fire.TimeToDisappearance.ToString()).ToArray()); foreach (string id in ClientProxyMap.Keys) { await SendUpdateToClient( id, Game.AllPlayers, blocksX, blocksY, blocksLife, sparksX, sparksY, sparksLife ); } }); } } |
ブロックの追加
ゲームの途中でフィールド上にブロックを追加する処理を示します。このときも新しく追加するブロックのイベントハンドラを追加するのを忘れないように注意します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class PengoHub : Hub { private static void Timer_Elapsed1(object? sender, ElapsedEventArgs e) { List<Block> blocks = Game.AddBlocks(); foreach (Block block in blocks) { block.BlockStart += Block_BlockStart; block.BlockStoped += Block_BlockStoped; block.BlockBreak += Block_BlockBreak; block.HitPlayer += Block_HitPlayer; } _AddBlocks.Clear(); Task.Run(async () => { foreach (string key in ClientProxyMap.Keys) await ClientProxyMap[key].SendAsync("ReceiveAfterAddBlocks"); }); } } |
クライアントサイドにプレイヤー、ブロック、火花の位置と状態を送信する処理を示します。
引数からゲームに参加しているかどうかを調べます。参加しているのであればplayerNumberに0以上の値が代入されます。その場合はクライアントサイドにReceiveUpdateGameStatusを送信するときに第一引数としてそのまま渡します。参加していない場合でいまからでも参加可能な場合は-1、定員オーバーで参加できない場合は-2を渡します。
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 PengoHub : Hub { static async Task SendUpdateToClient( string id, List<Player> allPlayers, string blocksX, string blocksY, string blocksLife, string sparksX, string sparksY, string sparksLife ) { if (!ClientProxyMap.ContainsKey(id)) return; // クライアントサイドへのデータ送信開始 await ClientProxyMap[id].SendAsync("ReceiveStartUpdate"); int playerNumber = -1; int loopCount = 0; foreach (Player player in allPlayers) { // 位置、スコア、残機数をクライアントサイドに送信するが、NPCの場合はスコア、残機数は送らない int x = player.X; int y = player.Y; string score = ""; string rest = ""; if (player.ConnectionID != "") { score = player.Score.ToString(); rest = player.Rest.ToString(); } await ClientProxyMap[id].SendAsync( "ReceiveUpdatePlayer", player.ConnectionID, x, y, player.Name, player.IsCatched, score, rest, player.InvincibleTime ); if (player.ConnectionID == id) playerNumber = loopCount; loopCount++; } await ClientProxyMap[id].SendAsync("ReceiveUpdateBlocks", blocksX, blocksY, blocksLife); await ClientProxyMap[id].SendAsync("ReceiveUpdateSparks", sparksX, sparksY, sparksLife); if (playerNumber < 0) { if (Game.NPCs.Count == 0) playerNumber = -2; // 定員オーバーで参加不可 else playerNumber = -1; // まだゲームに参加していない。いまなら参加可能 } await ClientProxyMap[id].SendAsync("ReceiveUpdateGameStatus", playerNumber); await ClientProxyMap[id].SendAsync("ReceiveEndUpdate"); } } |
ゲームオーバー時の処理
ゲームオーバーになったら、全プレイヤー数がGame.PLAYER_MAXである状態を保つためにNPCを生成します。そしてSaveHiscoreメソッドを呼び出して、スコアランキングに登録する必要がある場合は登録の処理をおこないます。最後に全ユーザーにゲームオーバーになった旨を伝えます。
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 |
public class PengoHub : Hub { private void Player_GameOverEvent(Player player) { if (Game.Players.ContainsKey(player.ConnectionID)) { int playerNumber = Game.Players[player.ConnectionID].PlayerNumber; Game.Players.Remove(player.ConnectionID); Player npc = new Player("", playerNumber); Game.NPCs.Add(npc); if(ClientProxyMap.ContainsKey(player.ConnectionID)) { Task.Run(async () => { await ClientProxyMap[player.ConnectionID].SendAsync("ReceiveGameOver", player.ConnectionID); }); SaveHiscore(player); } string playerName = player.Name; playerName = playerName.Replace(",", "_").Replace("<", "<").Replace(">", ">"); foreach (string key in ClientProxyMap.Keys) { Task.Run(async () => { await ClientProxyMap[key].SendAsync("ReceiveNotification", playerName + "がゲームオーバーになりました"); }); } } } } |
スコアランキング登録の処理
スコアランキングに登録する処理を示します。
まずプレイヤー情報とスコアを格納するクラスを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
namespace Zero.Pages.Pengo { 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 = ""; } } |
スコアランキングに関する情報はhiscore-pengo.txtに保存します。まずファイルからデータを読み出してHiscoreオブジェクトのリストを生成します。最後にゲームオーバーになったプレイヤーのスコアを追加してスコアが大きい順に並べます。上位から30個取れば上位30位のプレイヤー名とスコアがわかります。これをサイドhiscore-pengo.txtに保存します。
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 |
public class PengoHub : Hub { void SaveHiscore(Player player) { string path = "../hiscore-pengo.txt"; List<Zero.Pages.Pengo.Hiscore> hiscores = new List<Zero.Pages.Pengo.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(','); Zero.Pages.Pengo.Hiscore hiscore = new Zero.Pages.Pengo.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); hiscores.Add(new Zero.Pages.Pengo.Hiscore(player.Name, player.Score, time)); hiscores = hiscores.OrderByDescending(x => x.Score).ToList(); if (hiscores.Count > 30) hiscores = hiscores.Take(30).ToList(); System.Text.StringBuilder sb = new System.Text.StringBuilder(); foreach (Zero.Pages.Pengo.Hiscore hiscore in hiscores) { sb.Append(String.Format("{0},{1},{2}\n", hiscore.Name, hiscore.Score, hiscore.Time)); } System.IO.StreamWriter sw = new StreamWriter(path); sw.Write(sb.ToString()); sw.Close(); } } |
キー操作への対応
ユーザーがキー操作をしたときの処理を示します。ゲームに参加しているのであればクライアントサイドから送られてきたプレイヤー名をPlayer.Nameにセットします。そしてキーの押下状態に応じてPlayer.IsXXXKeyDownプロパティをセットしたりクリアします。
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 PengoHub : Hub { public void DownKey(string key, string name) { // 長大なデータが送りつけられるかもしれないので対策 if (key.Length > 16) return; if (!Game.Players.ContainsKey(Context.ConnectionId)) return; Player player = Game.Players[Context.ConnectionId]; player.Name = name.Length > 16 ? name.Substring(0, 16) : name; player.Name = player.Name.Replace(",", "_"); if (key == "ArrowUp") player.IsUpKeyDown = true; else if (key == "ArrowDown") player.IsDownKeyDown = true; else if (key == "ArrowLeft") player.IsLeftKeyDown = true; else if (key == "ArrowRight") player.IsRightKeyDown = true; } public void UpKey(string key) { // 長大なデータが送りつけられるかもしれないので対策 if (key.Length > 16) return; if (!Game.Players.ContainsKey(Context.ConnectionId)) return; Player player = Game.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; } } |
スコアランキングを表示する
スコアランキングを表示させる処理を示します。
ゲームのページと同じフォルダにhi-score.cshtmlをつくります。
Pages\Pengo\hi-score.cshtml
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
@page @{ Layout = ""; } <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>対戦型Pengo 上位30位</title> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"> <link rel="stylesheet" href="../css/hiscore.css"> <link rel="apple-touch-icon" href="https://lets-csharp.com/wp-content/themes/cool_black/images/apple-touch-icon.png"> <link rel="apple-touch-icon-precomposed" href="https://lets-csharp.com/wp-content/themes/cool_black/images/apple-touch-icon.png"> <link rel="icon" href="https://lets-csharp.com/wp-content/themes/cool_black/images/apple-touch-icon.png"> <link rel="shortcut icon" type="image/x-icon" href="https://lets-csharp.com/wp-content/themes/cool_black/favicon.ico"> </head> <body> @{ string path = "../hiscore-pengo.txt"; List<Bomber.Hiscore> hiscores = new List<Bomber.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(','); Bomber.Hiscore hiscore = new Bomber.Hiscore(vs2[0], long.Parse(vs2[1]), vs2[2]); hiscores.Add(hiscore); } catch { } } sr.Close(); } } <div id = "container"> <div id = "h1">鳩でもわかる対戦型pengo 上位30位</div> <div id = "left"> <p><a href="./game">⇒ 対戦型pengoのページへ戻る</a></p> <div id = "result" > <table class="table" border="1" id="table"> @{ int num = 0; } @foreach(Bomber.Hiscore hiscore in hiscores) { num++; <tr> <td>@num 位</td> <td>@hiscore.Name</td> <td>@hiscore.Score</td> <td>@hiscore.Time</td> </tr> } @if (num < 30) { @for (num++; num <= 30; num++) { <tr> <td>@num 位</td> <td></td> <td></td> <td></td> </tr> } } </table> </div> </div> <div id = "right" > <!-- 見て欲しいページへのリンク --> </div> </div> </body> </html> |