ASP.NET coreで対戦型『殺しの七並べ』をつくる(1)の続きです。
今回はゲーム全体を管理するGameクラスを定義します。ゲームは対戦者以外の人も観戦できるようにします。
1 2 3 4 5 6 |
namespace KillSeven { public class Game { } } |
以降は名前空間は省略して記述します。
1 2 3 |
public class Game { } |
Contents
プロパティ
プロパティを示します。
ユーザーの誰かがエントリーボタンをクリックしたらゲームが開始されます。しかしそれ以外のユーザーが参加するかもしれないので、10秒間待機してから開始します。ゲームが開始されて以降もユーザーはゲームに参加することができます。PLAYER_MAXに足りない部分はコンピュータがプレイヤーとなってゲームを進行させます。
1 2 3 4 5 6 7 8 9 10 |
public class Game { public Player[] Players { get; } // ゲームに参加しているPlayerオブジェクト(NPC含む)の配列 public List<Card> Cards { set; get; } // プレイヤーが出したカード public List<Card> DeadCards { set; get; } // 殺されたカード public int SecondsToStart { set; get; } // ゲームがはじまるまでの秒数 public int CurPlayerNumber { set; get; } // 手番のプレイヤーの番号 public int CardsCount { get { return Cards.Count; } } // プレイヤーが出したカードの枚数 public int DeadCardsCount { get { return DeadCards.Count; } } // 殺されたカードの枚数 } |
コンストラクタ
Gameオブジェクトが存在しないときにユーザーの誰かがエントリーボタンをクリックしたらGameオブジェクトが生成されます。ここではコンストラクタでおこなわれる処理を示します。
まず手番があるプレイヤーですが、添え字が0のプレイヤーです。SECONDS_TO_START_MAX秒後にゲームがはじまるのでSecondsToStartプロパティに値をセットします。プレイヤーが出したカードと殺されたカードはゲームが開始されるまえは存在しないのでCardsとDeadCardsには空のリストをセットします。
Playersプロパティに要素数PLAYER_MAXの配列をセットして、そのなかにPlayerオブジェクトを格納します。格納されるオブジェクトは最初はすべてNPCです。人間のユーザーがゲームに参加するときはこれといれかえます。またユーザーがゲームから離脱したらその穴はNPCで埋めます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class Game { int _nextNumberOfNPC = 1; // NPCに通し番号をつけるためのフィールド変数 public Game() { CurPlayerNumber = 0; SecondsToStart = Const.SECONDS_TO_START_MAX; Cards = new List<Card>(); DeadCards = new List<Card>(); Players = new Player[Const.PLAYER_MAX]; for (int i = 0; i < Players.Length; i++) { Players[i] = new Player("NPC " + _nextNumberOfNPC, ""); Players[i].Number = i; _nextNumberOfNPC++; } } } |
プレイヤーの追加と削除
このゲームではユーザーはゲーム開始時でなくてもいつでもプレイに参加し、離脱することができます。なのでGameオブジェクトのなかに新しいプレイヤーを追加したり、離脱されたときの処理が必要です。プレイヤーを追加したり削除する処理を示します。
ゲームに参加しようとしているユーザーはすでに ASP.NET SignalRで使われる接続の一意のID(以下、connectionID)を持っています。これをつかってどのユーザーがどのオブジェクトに対応するのかを管理します。
プレイヤーを追加するときはユーザーが希望するプレイヤー名とconnectionIDを引数とするAddPlayerメソッドが呼び出されます。このときすでに同じconnectionIDをもつプレイヤーがいる場合は二重参加になるので参加を拒否します。それ以外のときはPlayersのなかにNPCがいるか調べます。もし見つかったらそれをプレイヤーとして割り当てます。複数みつかった場合は乱数でそのどれかを割り当てます。
プレイヤーの割り当てはPlayerオブジェクトのConnectionIDプロパティにconnectionIDを、Nameプロパティにプレイヤー名をセットすることでおこなわれます。NPCとプレイを交代するという感じです、
NPCが存在しない場合はユーザーはゲームに参加することはできません。
Playerオブジェクトが割り当てられたときはその添え字を戻り値として返します。処理が拒否されたときやNPCが存在しなかった場合は-1を返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class Game { public int AddPlayer(string playerName, string connectionID) { if (Players.FirstOrDefault(player => player.ConnectionID == connectionID) != null) return -1; Player[] npcs = Players.Where(player => player.ConnectionID == "").ToArray(); if (npcs.Length > 0) { Random random = new Random(); int r = random.Next(npcs.Length); Player player = npcs[r]; player.ConnectionID = connectionID; player.Name = playerName; return Players.ToList().IndexOf(player); } else return -1; } } |
ユーザーがゲームから離脱するときはconnectionIDを引数とするRemovePlayerメソッドが呼び出されます。この場合はそのユーザーに対応するPlayerオブジェクトをNPCに変更します。新しいNPCの名前を生成してNameプロパティにセットし、ConnectionIDプロパティは空文字列をセットします。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class Game { public void RemovePlayer(string connectionId) { Player? player = Players.FirstOrDefault(player => player.ConnectionID == connectionId); if (player != null) { player.ConnectionID = ""; player.Name = "NPC " + _nextNumberOfNPC; _nextNumberOfNPC++; } } } |
ゲーム開始の処理
ゲームが開始されたらまずカードを各プレイヤーに配布する処理をおこないます。そのためにCardオブジェクトを生成してシャッフルする処理をおこないます。
Cardオブジェクトを生成する処理を示します。カードのスート(スペードやハートのマーク)は0~3の整数で表わし、番号はそのまま1~13を使います。CreateCardsメソッドでは二重ループで52枚のカードを生成しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class Game { List<Card> CreateCards() { List<Card> cards = new List<Card>(); for (int i = 0; i < 4; i++) { for (int number = 1; number <= 13; number++) cards.Add(new Card(i, number)); } return cards; } } |
CreateCardsメソッドで生成した52枚のカードは数が連続しているのでシャッフルの処理をおこないます。それがShuffleCardsメソッドです。乱数を生成してCardオブジェクトのフィールド変数Randomに格納します。そしてこの値の順にオブジェクトをソートします。これでシャッフルの処理はおこなわれています。
1 2 3 4 5 6 7 8 9 |
public class Game { List<Card> ShuffleCards(List<Card> cards, Random random) { foreach (Card card in cards) card.Random = random.Next(10000); return cards.OrderBy(_ => _.Random).ToList(); } } |
カードのシャッフルが終わったら各プレイヤーに配布します。普通の7並べではカードを配布してそのあと7をもっている人はそのカードを出してから開始されるのですが、ここではプレイヤーのカードが多くならないように(プレイヤーが持っているカードを表示させるとき面倒くさいという作り手の事情)さきに7のカードを取り除いて残りのカードを配布します。
カードを配布はPlayers[プレイヤー番号].CardsにCardオブジェクトを追加する方式でおこないます。すべて配布したら番号でソートします。
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 Game { void HandOutCards(List<Card> cards) { // まず「7」のカードをテーブルのうえに出す Cards.AddRange(cards.Where(card => card.Number == 7)); // 「7」以外のカード cards = cards.Where(card => card.Number != 7).ToList(); // これを配布する int index = 0; foreach (Card card in cards) { Players[index % Players.Length].Cards.Add(card); card.PlayerNumber = index % Players.Length; index++; } for (int i = 0; i < Players.Length; i++) { Players[i].Cards = Players[i].Cards.OrderBy(card => card.Number).ThenBy(card => card.Suit).ToList(); } } } |
ゲーム終了のチェックと手番の変更
着手が終わったら次のプレイヤーに手番を移します。手番はCurPlayerNumberプロパティの値でわかります。Players[CurPlayerNumber]が手番のプレイヤーに該当するオブジェクトです。
まずゲームは終了している場合は処理は不要です。そうでない場合はCurPlayerNumberをインクリメントしてPlayers.Lengthで割ったときの剰余をセットします。このプレイヤーはすでに上がっているかもしれないのでそれを確認します。もしnextPlayerが上がっていないプレイヤーである場合はこのプレイヤーの持ち時間をSECONDS_TO_HAND_MAXにして処理を終了します。
もしそのプレイヤーが上がっている場合は順位が暫定順位(手持ちのカードがなくなった順位)が確定しているかを調べます。暫定順位が確定している場合は暫定順位を確定させます。すべてのプレイヤーのPlayer.Rankingの最大値が-1のときは誰も上がっていないということなので1位に、それ以外の時は最大値に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 |
public class Game { void ChangeCurPlayerNumber() { while (true) { if (CheckGameFinished()) // ゲームは終了しているか?(後述) break; CurPlayerNumber++; CurPlayerNumber %= Players.Length; // 次のプレイヤーは上がっているか? Player nextPlayer = Players[CurPlayerNumber]; if (nextPlayer.Cards.Count > 0) { nextPlayer.SecondsToHand = Const.SECONDS_TO_HAND_MAX; break; } if (nextPlayer.Cards.Count == 0 && nextPlayer.Ranking < 1) { int max = Players.Max(player => player.Ranking); if (max <= 0) nextPlayer.Ranking = 1; else nextPlayer.Ranking = max + 1; } } } } |
ゲーム終了のチェック
ゲームは終了しているかを調べる処理を示します。
最初は各プレイヤーの暫定順位(手持ちのカードがなくなった順位)には-1がセットされていて、カードがなくなったときに暫定順位が確定します。
実際の順位は1以上なので、すべてのPlayerオブジェクトのRankingプロパティが0より大きな値であればすべてのプレイヤーの暫定順位が確定していることになります。その場合はIsFinishedフラグをtrueにします。またプレイヤーの本当の順位を確定させてFinishedイベントを送信します。
プレイヤーの本当の順位は殺されたカードが少ないほうが上位、同じ場合はさきに上がったほうが上位です。
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 Game { public bool IsFinished = false; // ゲームは終了しているか? public delegate void FinishedHandler(object game, FinishedArgs args); public event FinishedHandler? Finished; bool CheckGameFinished() { if (Players.All(player => player.Ranking > 0)) { // 全員の暫定順位が確定しているならゲームは終了 IsFinished = true; // 殺されたカードが少ないほうが上位、同じ場合はさきに上がったほうが上位なのでそのようにソート Player[] arr = Players.OrderBy(player => player.DeadCards.Count).ThenBy(player => player.Ranking).ToArray(); // 本当の順位をPlayer.TrueRankingプロパティにセット int ranking = 1; foreach (Player player0 in arr) player0.TrueRanking = ranking++; // ゲームが終了して本当の順位もついたので結果をイベントで送信 Task.Run(() => { Finished?.Invoke(this, new FinishedArgs(this, arr)); }); return true; } return false; } } |
イベントハンドラの引数で使われるFinishedArgsクラスの定義を示します。
1 2 3 4 5 6 7 8 9 10 |
public class FinishedArgs : EventArgs { public FinishedArgs(Game game, Player[] players) { Game = game; Players = players; } public Game Game { get; } public Player[] Players { get; } } |
着手によってカードは殺されたか?
カードを出すことで別のカードが殺されたかどうかを調べる処理を示します。
殺すことができるカードは出されたカードの上下左右とこれとつながっているカードです。そこでカードが置かれたら、その上下左右にあるカードとつながっているカード群を取得します。このとき同じカードを何度も調べて無限ループにならないように一度チェックしたカードは記憶しておきます。
出されたカードの上下左右それぞれのカードとつながっているカード群を取得したら、それらのSuitとNumberの最大値と最小値を調べます。もし(Numberの最大値 – Numberの最小値 + 1) * (Suitの最大値 – Suitの最小値 + 1) が取得されたカードの個数と一致したらカードは四角く囲われていることになります。この場合、CheckKillメソッドはそのカードを出した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 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 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
public class Game { public delegate void KilledHandler(object game, KilledArgs args); public event KilledHandler? Killed; List<Player> CheckKill(Card card) { List<Player> killedPlayers = new List<Player>(); bool[,] isChecked = new bool[4, 14]; // すでに出されているカードはチェックの対象外 foreach (Card card0 in Cards) isChecked[card0.Suit, card0.Number] = true; foreach (Card card0 in DeadCards) isChecked[card0.Suit, card0.Number] = true; // いま出されたカードもチェックの対象外 isChecked[card.Suit, card.Number] = true; // 対象になるのはいま出されたカードの上下左右の出されていないカード int[] dx = { 0, 0, -1, 1 }; int[] dy = { -1, 1, 0, 0 }; // 幅優先探索で、いま出されたカードの上下左右にあるカードとつながっているカードを取得する for (int i = 0; i < 4; i++) { Queue<int> numbers = new Queue<int>(); Queue<int> suits = new Queue<int>(); if (card.Suit + dy[i] > 3 || card.Suit + dy[i] < 0 || card.Number + dx[i] > 13 || card.Number + dx[i] < 1) continue; if (isChecked[card.Suit + dy[i], card.Number + dx[i]]) continue; suits.Enqueue(card.Suit + dy[i]); numbers.Enqueue(card.Number + dx[i]); isChecked[card.Suit + dy[i], card.Number + dx[i]] = true; List<Card> cards = new List<Card>(); foreach (Player player in Players) { Card? findCard = player.Cards.FirstOrDefault(card0 => card0.Suit == card.Suit + dy[i] && card0.Number == card.Number + dx[i]); if (findCard != null) { cards.Add(findCard); break; } } while (true) { int suit = suits.Dequeue(); int number = numbers.Dequeue(); int[] dx2 = { 0, 0, -1, 1 }; int[] dy2 = { -1, 1, 0, 0 }; for (int k = 0; k < 4; k++) { int newSuit = suit + dy2[k]; int newNumber = number + dx2[k]; if (newSuit > 3) continue; if (newSuit < 0) continue; if (newNumber > 13) continue; if (newNumber < 1) continue; if (isChecked[newSuit, newNumber]) continue; isChecked[newSuit, newNumber] = true; suits.Enqueue(newSuit); numbers.Enqueue(newNumber); foreach (Player player in Players) { Card? findCard = player.Cards.FirstOrDefault(card0 => card0.Suit == newSuit && card0.Number == newNumber); if (findCard != null) { cards.Add(findCard); break; } } } if (suits.Count == 0) break; } if (cards.Count == 0) continue; int minY = cards.Min(card0 => card0.Suit); int minX = cards.Min(card0 => card0.Number); int maxY = cards.Max(card0 => card0.Suit); int maxX = cards.Max(card0 => card0.Number); if (maxX - minX > 0 && minY == 0 && maxY == 3) continue; if ((maxX - minX + 1) * (maxY - minY + 1) == cards.Count) { foreach (Card item in cards) { DeadCards.Add(item); foreach (Player player in Players) { if (player.Cards.Remove(item)) { player.DeadCards.Add(item); killedPlayers.Add(player); } } } } } return killedPlayers; } } |
プレイヤーの着手時の処理
プレイヤーがカードを出そうとしたときの処理を示します。
まずカードを出すことができるかを調べる処理を示します。出せるカードとはすでに出されているカードと縦横斜めのどれかでつながっているカードです。
1 2 3 4 5 6 7 |
public class Game { public bool CanPutCard(int suit, int number) { return Cards.Any(card => Math.Abs(card.Suit - suit) <= 1 && Math.Abs(card.Number - number) <= 1); } } |
ユーザーであるプレイヤーがカードを出す処理をするときは、そのプレイヤーはゲームに参加しているのかを調べます。そのつぎに出されようとしているカードはプレイヤーによって所持されているのかを調べます。さらに出そうとしているカードは出せるカードなのかを調べます。
出せるカードを出そうとしている場合は、そのCardオブジェクトをPlayer.CardsからGame.Cardsに移動させます。そしてカードが出されたことを示すPutCardイベントの送信します。またこれによって他のカードが殺されたのであればそれを示すKilledイベントの送信をおこないます。そして手番をつぎのプレイヤーに移します。このときにフィールド変数_ignoreUpdateをtrueにして、つぎの更新時はなにも処理をおこなわない(NPCが着手しない)ようにします。
カードを出すことでプレイヤーの手持ちのカードはすべてなくなってしまったかもしれません。この場合は暫定順位をつけます。また出せないカードを出そうとした場合はDenyCardイベントを送信します。
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 Game { public delegate void PutCardHandler(object game, PutCardArgs args); public event PutCardHandler? PutCard; public event PutCardHandler? DenyCard; bool _ignoreUpdate = false; public void PlayerPutOutCard(string connectionID, int suit, int number) { // カードを出そうとしているプレイヤーはゲームに参加しているのか? Player? player = Players.FirstOrDefault(player => player.ConnectionID == connectionID); if (player == null) return; // 出そうとしているカードをプレイヤーは所持しているのか? Card? card = player.Cards.FirstOrDefault(card => card.Suit == suit && card.Number == number); if (card == null) return; // そのカードは出せるカードなのか? if (CanPutCard(card.Suit, card.Number)) { // カードをプレイヤーから卓上に移動 player.Cards.Remove(card); Cards.Add(card); // カードが出されたことを示すPutCardイベントの送信 PutCard?.Invoke(this, new PutCardArgs(this, player)); // カードが殺されたのであればそれを示すKilledイベントの送信 List<Player> killed = CheckKill(card); if (killed.Count > 0) Killed?.Invoke(this, new KilledArgs(this, killed)); // カードを出したことで手持ちのカードがすべてなくなったら暫定順位をつける if (player.Cards.Count == 0) { int max = Players.Max(player => player.Ranking); if (max <= 0) player.Ranking = 1; else player.Ranking = max + 1; } // 手番をつぎのプレイヤーに移す ChangeCurPlayerNumber(); _ignoreUpdate = true; } else { DenyCard?.Invoke(this, new PutCardArgs(this, player)); } } } |
更新処理
アクションゲームとちがってプレイヤーがカードを出さない限り更新処理は必要ないので1秒おきに更新処理をおこないます。
大きくわけて更新時の状態は3つあります。それはゲームが開始される前、ユーザーに手番がある場合、NPCに手番がある場合です。ゲームが終了したら更新処理はおこないません。
NPCに手番がある場合はすぐに次の手を探して着手させますが、ユーザーに次の手番がある場合、着手のタイミングはわかりません。持ち時間があるので1秒ごとにカウントダウンをします。また更新処理は1秒ごとにおこなわれますが、ユーザーの着手のタイミングと重なるとユーザーの着手後すぐにNPCの着手がおこなわれるため、ユーザーの着手がおこなわれたら次の更新処理は1回だけなにもしないようにします(フィールド変数_ignoreUpdateはこのときに必要)。
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 bool IsStarted = false; public void Update() { // ゲームが終了しているならなにもしない if (IsFinished) return; // _ignoreUpdate == trueのときはfalseに変更するだけでなにもしない if (_ignoreUpdate) { _ignoreUpdate = false; return; } // ゲームはまだ開始されていない場合 if (!IsStarted) { UpdateBeforeGameStart(); // 後述 return; } // 手番があるプレイヤーを取得 Player player = Players[CurPlayerNumber]; if (player.ConnectionID != "") // プレーヤーの場合 UpdateOnPlayerTurn(player); // 後述 else // NPCの場合 UpdateOnNpcTurn(player); // 後述 } } |
ゲーム開始前の更新処理
ゲーム開始前の更新処理を示します。
SecondsToStartプロパティが0より大きい場合はデクリメントします。もしSecondsToStartプロパティが0のときはCardの生成、シャッフル、配布の処理をおこないます。そしてフィールド変数IsStartedをtrueに変更します。
手番があるプレイヤーをPlayers[0]にして持ち時間をSECONDS_TO_HAND_MAXにします。そのあとStartedイベントを送信します。
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 { public event EventHandler? Started; void UpdateBeforeGameStart() { if (SecondsToStart > 0) { SecondsToStart--; return; } List<Card> cards = CreateCards(); Random random = new Random(); for (int i = 0; i < 8; i++) cards = ShuffleCards(cards, random); HandOutCards(cards); IsStarted = true; CurPlayerNumber = 0; Players[CurPlayerNumber].SecondsToHand = Const.SECONDS_TO_HAND_MAX; Started?.Invoke(this, new EventArgs()); } } |
NPCに手番があるときの更新処理
NPCに手番があるときの更新処理を示します。この場合は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 29 30 31 32 33 |
public class Game { void UpdateOnNpcTurn(Player npc) { if (npc.Cards.Count > 0) { Card[] cards = npc.Cards.Where(card => CanPutCard(card.Suit, card.Number)).ToArray(); if (cards.Length > 0) { Random random = new Random(); int index = random.Next(cards.Length); npc.Cards.Remove(cards[index]); Cards.Add(cards[index]); PutCard?.Invoke(this, new PutCardArgs(this, npc)); List<Player> killed = CheckKill(cards[index]); if (killed.Count > 0) Killed?.Invoke(this, new KilledArgs(this, killed)); if (npc.Cards.Count == 0) { int max = Players.Max(player => player.Ranking); if (max <= 0) npc.Ranking = 1; else npc.Ranking = max + 1; } } } ChangeCurPlayerNumber(); } } |
プレイヤーに手番があるときの更新処理
ユーザーであるプレイヤーに手番があるときの更新処理を示します。この場合はそのプレイヤーの持ち時間を減らします。持ち時間が0になったら強制的にパス扱いになります。強制パスになった場合はPlayerPassedイベントを送信します。
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 Game { public delegate void PlayerPassedHandler(object game, PlayerPassedArgs args); public event PlayerPassedHandler? PlayerPassed; void UpdateOnPlayerTurn(Player player) { if (player.Cards.Count > 0) { player.SecondsToHand--; if (player.SecondsToHand <= 0) { // 持ち時間が0になったら強制的にパス。手番を次のプレイヤーに移す PlayerPassed?.Invoke(this, new PlayerPassedArgs(this, player)); ChangeCurPlayerNumber(); } else { // 持ち時間が0にならなくても出せるカードが存在しない場合は強制的にパス Card? card = player.Cards.FirstOrDefault(card => CanPutCard(card.Suit, card.Number)); if (card == null) { PlayerPassed?.Invoke(this, new PlayerPassedArgs(this, player)); ChangeCurPlayerNumber(); } } return; } else { // すでに上がっている場合はなにもせず手番を次のプレイヤーに移す ChangeCurPlayerNumber(); } } } |