オンライン対戦型のスネークゲームをつくる(1)の続きです。今回はプレイヤーと餌の当たり判定とスコアの加算減算処理、イベント送信などをおこなうGameクラスとクライアントサイドとのデータ送受信をするHubクラス(ChamatherioHubクラス)の定義をおこないます。
Contents
Gameクラスの定義
Gameクラスを定義します。以下、記事内では名前空間部分は省略します。
1 2 3 4 5 6 7 8 9 10 11 |
namespace Game { public class Game { } } // 以下、記事内では名前空間部分は省略 public class Game { } |
プロパティ
各プロパティを示します。PlayerとFoodのリストです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class Game { public List<Player> Players { private set; get; } public List<Food> Foods { private set; get; } } |
初期化
コンストラクタとフィールド変数、イベントを示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class Game { Random _random = new Random(); public event EventHandler? GotScore; // スコア加算イベント public event EventHandler? LostScore; // スコア減算イベント public event EventHandler? Kill; // 他のプレイヤーを倒した public event EventHandler? GameOvered1; // フィールドの壁や他のプレイヤーに衝突した public event EventHandler? GameOvered2; // 食べると即死する餌を食べてしまった public Game() { Players = new List<Player>(); Foods = new List<Food>(); } } |
最初のユーザーがゲームを開始するとプレイヤーの位置のリセットなどの初期化の処理がおこなわれます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class Game { public void Init() { Players.Clear(); for(int i = 0; i < Const.PLAYER_MAX; i++) Players.Add(new Player(i)); Foods.Clear(); for (int i = 0; i < Const.FOOD_MAX; i++) Foods.Add(new Food()); } } |
更新処理
更新処理では各キャラクターの座標の移動と当たり判定をおこないます。また当たり判定の結果、スコアの加算または減算、イベントの送信がおこなわれます。
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 Game { public void Update() { // 各キャラクターを移動させる foreach(Player player in Players) player.Move(); foreach(Food food in Foods) food.Move(); // 当たり判定 foreach (Player player in Players) { // 無敵状態のときは当たり判定はしない if (player.IsInvincible) continue; // 壁に衝突していないか? bool playerDead = false; if (player.X < Const.CHARACTER_SIZE / 2) playerDead = true; else if (player.X > Const.FIELD_SIZE - Const.CHARACTER_SIZE / 2) playerDead = true; else if (player.Y < Const.CHARACTER_SIZE / 2) playerDead = true; else if (player.Y > Const.FIELD_SIZE - Const.CHARACTER_SIZE / 2) playerDead = true; // 自分の胴体または他のプレイヤー壁に衝突していないか? foreach (Player player2 in Players) { if (player.IsCrash(player2)) { playerDead = true; if(player != player2) OnKill(player2); // 衝突された相手に加点処理をおこなう(後述) break; } } // 壁に衝突した場合と自分の胴体または他のプレイヤーに衝突した場合は // タイプ1の死亡イベントを発生させる if (playerDead) { OnDead1(player); // 後述 continue; } // 餌との当たり判定 Food? food = Foods.FirstOrDefault(food => GetDistance(player, food) < Const.CHARACTER_SIZE); if (food != null) { OnEat(player, food.Type); if (food.Type == 2) continue; // 食べられた餌は初期化して別の位置に移動させる // ただし食べると即死する餌(Food.Type == 2)は移動させずその場に残す food.Init(); } } } // プレイヤーと餌の距離を計算する int GetDistance(Player player, Food food) { return (int)Math.Sqrt(Math.Pow(player.X - food.X, 2) + Math.Pow(player.Y - food.Y, 2)); } } |
イベントの送信
餌を食べたとき効果音を鳴らしたいのでクライアントサイドにイベントを送信します。どのクライアントに送信するかも指定できるように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 25 26 27 28 29 30 31 32 |
public class Game { public void OnEat(Player player, int type) { string connectionId = player.ConnectionId; if (type == 0) { // 少し体長を長くする(最長を超えないようにする) if(player.Length + Const.EXTEND_LENGTH < Const.MAX_LENGTH) player.Length += Const.EXTEND_LENGTH; if (connectionId != "") { // NPCでない場合は加点 player.Score += 100; GotScore?.Invoke(player, new MyEventArgs(connectionId)); } } if (type == 1) { if (connectionId != "") { // NPCでない場合は減点 player.Score -= 500; LostScore?.Invoke(player, new MyEventArgs(connectionId)); } } // 食べると即死する餌を食べたら死亡 if (type == 2) OnDead2(player); } } |
他のプレイヤーを倒したときは1000点を加算します。これも効果音を鳴らしたいのでクライアントサイドにイベントを送信します。
1 2 3 4 5 6 7 8 9 10 11 12 |
public class Game { public void OnKill(Player player) { string connectionId = player.ConnectionId; if (connectionId != "") { player.Score += 1000; Kill?.Invoke(player, new MyEventArgs(connectionId)); } } } |
死亡したときもクライアントサイドにイベントを送信します。食べると即死する餌を食べた場合、0.1秒待機していますが、これはサーバーサイドでは死亡判定されてもクライアントサイドは食べると即死する餌として表示されていない場合があるからです。更新間隔が長いとこういう問題が発生します。ユーザーが「え?何で死ぬの?」と思わないように、確実に食べると即死する餌が表示させるために0.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 |
public class Game { // 壁に衝突した場合と自分の胴体または他のプレイヤーに衝突した場合 ⇒ タイプ1の死亡イベント public void OnDead1(Player player) { string connectionId = player.ConnectionId; if (connectionId != "") GameOvered1?.Invoke(player, new MyEventArgs(connectionId)); player.Init(); } // 食べると即死する餌を食べた場合 ⇒ タイプ2の死亡イベント public void OnDead2(Player player) { string connectionId = player.ConnectionId; if (connectionId != "") GameOvered2?.Invoke(this, new MyEventArgs(connectionId)); Task.Run(async () => { await Task.Delay(100); player.Init(); // Player.Init()によってASP.NET SignalR の接続IDがクリアされるので操作不能になる }); } } |
GotScoreイベント、LostScoreイベント、Killイベント、GameOvered1イベント、GameOvered2イベントの引数で使われるMyEventArgsクラスの定義を示します。EventArgsクラスにASP.NET SignalR の接続IDを追加しているだけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class MyEventArgs : EventArgs { public MyEventArgs(string connectionId) { ConnectionId = connectionId; } public string ConnectionId { private set; get; } } |
Hubクラスの定義
これまで作ってきたオンライン対戦ゲームと同様、ASP.NET SignalRを使用しています。ここではChamatherioHubクラスを定義します。以下、記事内では名前空間部分は省略します。
1 2 3 4 5 6 7 8 9 10 11 |
namespace Game { public class ChamatherioHub : Hub { } } // 以下、記事内では名前空間部分は省略 public class ChamatherioHub : Hub { } |
フィールド変数は以下のとおりです。
1 2 3 4 5 6 7 8 |
public class ChamatherioHub : 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>(); } |
接続時の処理
ユーザーが接続したときの処理を示します。
最初の接続時だけタイマーの初期化の処理とイベントハンドラの追加の処理をおこなっています。そのあと辞書に接続IDとIClientProxyを追加し、クライアントサイドに接続成功とフィールド全体の幅高さを通知しています。
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 ChamatherioHub : Hub { public override async Task OnConnectedAsync() { if (IsFirstConnection) { IsFirstConnection = false; Timer.Interval = 1000 / 18; // 更新間隔はゆっくりめ(サーバーに負荷をかけたくない) Timer.Elapsed += Timer_Elapsed; // イベントハンドラの追加(後述) Game.GotScore += OnGotScore; ; Game.LostScore += OnLostScore; ; Game.Kill += OnKill; Game.GameOvered1 += OnGameOvered1; Game.GameOvered2 += OnGameOvered2; } await base.OnConnectedAsync(); // 辞書に接続IDとIClientProxyを追加 ClientProxyMap.Add(Context.ConnectionId, Clients.Caller); // 一人目のユーザーが接続してきたときだけ初期化の処理と停止していたタイマーのStartをおこなう if (ClientProxyMap.Count == 1) { Game.Init(); Timer.Start(); } // クライアントサイドに接続成功とフィールド全体の幅高さを通知 await Clients.Caller.SendAsync("SendToClientConnectionSuccessful", Context.ConnectionId, Const.FIELD_SIZE); } } |
イベントハンドラを示します。効果音を鳴らすためにクライアントサイドにイベントを送信しています。
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 ChamatherioHub : Hub { private void OnGotScore(object? sender, EventArgs e) { SendEventToClient(e, "GotScore"); // SendEventToClientメソッドは後述 } private void OnLostScore(object? sender, EventArgs e) { SendEventToClient(e, "LostScore"); } private void OnKill(object? sender, EventArgs e) { SendEventToClient(e, "Kill"); } private void OnGameOvered1(object? sender, EventArgs e) { SendEventToClient(e, "GameOvered1"); } private void OnGameOvered2(object? sender, EventArgs e) { SendEventToClient(e, "GameOvered2"); } void SendEventToClient(EventArgs e, string eventName) { MyEventArgs myEventArgs = (MyEventArgs)e; Task.Run(async () => { if (ClientProxyMap.ContainsKey(myEventArgs.ConnectionId)) await ClientProxyMap[myEventArgs.ConnectionId].SendAsync("SendToClient" + eventName); }); } } |
切断時の処理
ユーザーが接続を切断したときの処理を示します。
辞書から接続IDを削除します。プレイ中のユーザーが離脱した場合はPlayerをNPCに戻します。また接続ユーザー数が0になったら更新処理用のタイマーを停止します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class ChamatherioHub : Hub { public override async Task OnDisconnectedAsync(Exception? exception) { await base.OnDisconnectedAsync(exception); // 辞書から接続IDを削除 ClientProxyMap.Remove(Context.ConnectionId); // ユーザーが操作していたPlayerがある場合はNPCに戻す Player? player = Game.Players.FirstOrDefault(player => player.ConnectionId == Context.ConnectionId); if (player != null) player.ConnectionId = ""; // 接続ユーザー数が0になったら更新処理用のタイマーを止める if (ClientProxyMap.Count == 0) Timer.Stop(); } } |
ゲーム開始時の処理
ユーザーがゲームを開始したときの処理を示します。
最大9人でプレイできますが、新規ユーザーが参入しようとしたときはNPCが存在するか調べます。存在する場合はそれをユーザーが操作できるようにします。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 |
public class ChamatherioHub : Hub { public void GameStart(string playerName) { string connectionId = Context.ConnectionId; Player? player = Game.Players.FirstOrDefault(player => player.ConnectionId == ""); if (player != null) { // 最初に見つかったNPCを操作可能にし、ゲーム開始処理が成功した旨を通知する player.GameStart(playerName, connectionId); Task.Run(async () => { if(ClientProxyMap.ContainsKey(connectionId)) await ClientProxyMap[connectionId].SendAsync("SendToClientGameStartSuccessful"); }); } else { // すでに定員オーバーなのでゲーム開始処理が失敗した旨を通知する Task.Run(async () => { if (ClientProxyMap.ContainsKey(connectionId)) await ClientProxyMap[connectionId].SendAsync("SendToClientGameStartFailure"); }); } } } |
更新処理
更新処理をしてクライアントサイドに描画用のデータを送信する処理を示します。
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 ChamatherioHub : Hub { static private void Timer_Elapsed(object? sender, ElapsedEventArgs e) { Task.Run(async () => { if (!Game.Players.Any(player => player.ConnectionId != "")) return; Game.Update(); // クライアントサイドに送信する描画用のデータ(文字列)を生成する StringBuilder sb = new StringBuilder(); for (int i = 0; i < Game.Players.Count; i++) { string name = Game.Players[i].Name; string score = Game.Players[i].Score.ToString(); if(Game.Players[i].ConnectionId == "") score = "-"; string str = $"{name},{score},{Game.Players[i].GetPositionsText()}\t"; sb.Append(str); } string sendText1 = sb.ToString(); sb.Clear(); for (int i = 0; i < Game.Foods.Count; i++) { string str = $"{Game.Foods[i].Type},{Game.Foods[i].X},{Game.Foods[i].Y}"; sb.Append(str + "\t"); } string sendText2 = sb.ToString(); sb.Clear(); try { // 描画用の文字列をクライアントサイドに送信する foreach (string id in ClientProxyMap.Keys) { Player? player = Game.Players.FirstOrDefault(player => player.ConnectionId == id); if (player != null) { string sendText3 = $"{player.X},{player.Y}"; string sendText4 = player.Score.ToString(); await ClientProxyMap[id].SendAsync( "SendToClientUpdate", sendText1, sendText2, sendText3, sendText4); } } } catch { } }); } } |
ユーザーによる操作への対応
ユーザーがボタンを操作してプレイヤーの方向転換をしようとしたときにおこなわれる処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class ChamatherioHub : Hub { public void ChangeDirect(string direct) { // 悪意をもったユーザーが長大なデータが送りつけてくるかもしれないので対策する if (direct.Length > 16) return; Player? player = Game.Players.FirstOrDefault(player => player.ConnectionId == Context.ConnectionId); if (player == null) return; // 方向転換する player.ChangeDirect(direct); } } |