ASP.NET Core と ASP.NET SignalR でオンライン対戦できるオセロをつくります。「オセロ」「Othello」という名称は株式会社オセロの登録商標であり、メガハウスが専用使用権を有しています。以降はリバーシ(Reversi)と呼ぶことにします。
対戦者のペアができれば同時に複数の対戦ができるものとし、対戦者以外のユーザーは現在行われている対戦や過去の対戦を観戦できるようなものをつくります。
着手しないといつまで経っても終わらないので1手20秒という制限時間を設けます。残り5秒を切ったら着手候補点を表示させます。
クライアントサイドへのデータの送信は1秒間に1回とし、そんなに頻繁にデータを送信するわけではないので json で送ることにします。送信するのは、対局の識別ID、盤上の状態、対局者の名前、どちらの手番か、残り時間などです。
ではさっそくつくっていきましょう。名前空間はReversiとします。
Contents
Playerクラスの定義
コンストラクタの引数 id はASP.NET SignalRで使われる一意の接続IDです。playerNameはユーザーが自由につけることができる名前ですが、< や > があると HTML上ではうまく表示されなくなるのでここでエスケープ処理をしています。また接続IDはセキュリティ上、クライアントサイドに送信しないほうがよいので[JsonIgnore]属性をつけて json への変換対象から除外しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
using System.Text.Json.Serialization; namespace Reversi { public class Player { public Player(string id, string playerName) { ID = id; string str = playerName.Replace("\n", "").Replace("\r", "").Replace("\t", " "); Name = str.Replace("<", "<").Replace(">", ">"); } public string Name { private set; get; } [JsonIgnore] public string ID { private set; get; } } } |
Historyクラスの定義
着手の履歴を閲覧できるようにHistoryクラスとDataクラスを定義します。
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 |
namespace Reversi { public class History { public char Turn { get; } // 手番 public int Row { get; } // 着手点 public int Col { get; } public string[] Stones { get; } // 盤上の状態 public History(char turn, int row, int col, string[] stones) { Turn = turn; Row = row; Col = col; Stones = new string[8]; for (int i = 0; i < 8; i++) Stones[i] = stones[i]; } } public class Data { public string Name { set; get; } // 各対局を識別するための一意のID public string[] Names { set; get; } // 対局者名 public int[] Scores { set; get; } // 獲得した石数 public char Winner { set; get; } // 勝者 public History[] Histories { set; get; } public Data(Game game) { Names = game.Players.Select(_ => _.Name).ToArray(); Scores = game.Scores; Winner = game.Winner; Histories = game.Histories.ToArray(); DateTime dt = DateTime.Now; Name = $"{dt.Year}{dt.Month:00}{dt.Day:00}{dt.Hour:00}{dt.Minute:00}{dt.Second:00}{(game.GameNumber % 10):00}"; } public Data() { } } } |
Gameクラスの定義
Gameクラスを定義します。
以降は名前空間の部分を省略します。
1 2 3 4 5 6 7 8 9 10 11 12 |
using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Unicode; using System.Text.Encodings.Web; // JsonSerializer.Serializeを使うので namespace Reversi { public class Game { } } |
1 2 3 |
public class Game { } |
プロパティとコンストラクタ
プロパティとコンストラクタを示します。
オブジェクトに通し番号をつけ、これと現在の時刻を組み合わせて一意のIDを生成します。
盤上の状態は8個の長さ8の文字列で表します。Nはなにもない部分、Bは黒石、Wは白石、bは黒の手番のときに着手可能な部分、wなら白の手番のときに着手可能な部分です。
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 |
public class Game { public int GameNumber { private set; get; } // 通し番号 public string[] Stones { private set; get; } // 盤上の状態 public char Turn { private set; get; } // 現在の手番('B':黒、'W':白、'N':どちらでもない) public bool IsAccepted { private set; get; } // 着手が成立した public Player?[] Players { private set; get; } // プレイヤー情報 public int[] Times { private set; get; } // 残り時間 public bool IsPassForced { private set; get; } // 着手可能な場所がないので強制パスとなった public int[] Scores { private set; get; } // ゲームが終了時に双方が獲得した石数 public bool IsFinished { private set; get; } // ゲームは終了したか? public char Winner { private set; get; } // 勝者('B':黒、'W':白、'D':引き分け) // 観戦者のASP.NET SignalRで使われる一意の接続ID [JsonIgnore] public List<string> WatcherIDs { private set; get; } // 残り時間を減算するためのタイマー System.Timers.Timer Timer = new System.Timers.Timer(); const int MaxTime = 20; static int _nextGameNumber = 0; static Random _random = new Random(); // 先手後手はランダムに決める // 着手と盤上の変遷を記録するリスト List<History> Histories = new List<History>(); // 過去の対戦データを保存するファイルのパス public const string FilePath = "../match-data/reversi.json"; static JsonSerializerOptions SerializeOptions = new JsonSerializerOptions { Encoder = JavaScriptEncoder.Create(UnicodeRanges.All), WriteIndented = false, }; } |
コンストラクタを示します。プロパティに初期値を設定しています。
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 |
public class Game { public Game() { Turn = 'N'; // 対局はまだ開始されていないのでどちらの手番でもない Players = new Player[2]; Times = new int[2]; Scores = new int[2]; WatcherIDs = new List<string>(); IsFinished = false; Winner = 'N'; // 勝者は当然のことながら不明である Players[0] = null; // この段階では対戦者は未定なので null Players[1] = null; Times[0] = MaxTime; Times[1] = MaxTime; Timer.Interval = 1000; Timer.Elapsed += Timer_Elapsed; // 盤上の初期配置 Stones = new string[8]; for (int i = 0; i < 8; i++) Stones[i] = "NNNNNNNN"; Reverse(3, 3, 'B'); Reverse(4, 4, 'B'); Reverse(3, 4, 'W'); Reverse(4, 3, 'W'); _nextGameNumber++; GameNumber = _nextGameNumber; // ファイルを保存するディレクトリがないならつくっておく if (!Directory.Exists("../match-data")) Directory.CreateDirectory("../match-data"); } } |
ReverseメソッドはStones[row][col]をchageToに変更します。
1 2 3 4 5 6 7 8 9 |
public class Game { void Reverse(int row, int col, char chageTo) { char[] vs = Stones[row].ToArray(); vs[col] = chageTo; Stones[row] = new string(vs); } } |
イベントの定義
イベントを定義します。
SuccessfulEntryイベントはユーザーによるエントリーが成功したときに送信されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class Game { public event SuccessfulEntryHandler SuccessfulEntry; public delegate void SuccessfulEntryHandler(Game game, SuccessfulEntryArg e); public class SuccessfulEntryArg : EventArgs { public SuccessfulEntryArg(Player player) { ID = player.ID; PlayerName = player.Name; } public string ID { get; } public string PlayerName { get; } } } |
Matchedイベントは対戦を開始するにあたってふたりの対局者が決まったときに送信されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class Game { public event MatchedHandler Matched; public delegate void MatchedHandler(Game game, MatchedArg e); public class MatchedArg : EventArgs { public MatchedArg(Game game) { IDs = new string[] { game.Players[0].ID, game.Players[1].ID }; PlayerNames = new string[] { game.Players[0].Name, game.Players[1].Name }; GameNumber = game.GameNumber; } public string[] IDs { get; } public string[] PlayerNames { get; } public int GameNumber { get; } } } |
StatusChangedイベントは盤上の状態や残り時間の変動がおきたときに送信されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public class Game { public event StatusChangedHandler StatusChanged; public delegate void StatusChangedHandler(Game game, StatusChangedArg e); public class StatusChangedArg : EventArgs { public StatusChangedArg(Game game) { List<string> list = new List<string>(); list.Add(game.Players[0].ID); list.Add(game.Players[1].ID); list.AddRange(game.WatcherIDs); IDs = list.ToArray(); Json = JsonSerializer.Serialize(game, SerializeOptions); } public string[] IDs { get; } public string Json { get; } } } |
Denyイベントはユーザーが着手不能な場所に着手しようとしたときに送信されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class Game { public event DenyHandler Deny; public delegate void DenyHandler(Game game, DenyArg e); public class DenyArg : EventArgs { public DenyArg(string id, string reason) { ID = id; Reason = reason; } public string ID { get; } public string Reason { get; } } } |
対局開始までの処理
対戦を希望するユーザーはページ上のエントリーボタンを押下して相手がエントリーしてくるのを待ちます。
IsMatchedメソッドはマッチングが成立しているかどうかを返します。GetWaitingPlayerメソッドはマッチングが成立していないオブジェクトであって対戦希望者が片方だけ決まっているときにそのPlayerオブジェクトを返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public class Game { public bool IsMatched() { if (Players[0] != null && Players[1] != null) return true; else return false; } public Player? GetWaitingPlayer() { if (Players[0] != null && Players[1] == null) return Players[0]; if (Players[0] == null && Players[1] != null) return Players[1]; return null; } } |
ユーザーがエントリーボタンを押下したらマッチングが成立していないGameオブジェクトがあるか探します。もしあればそのオブジェクトのSetPlayerメソッドが呼び出されてマッチングが成立します。なければ新しいGameオブジェクトが生成されたあとそのSetPlayerメソッドが呼び出され、対戦相手を待つことになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class Game { public async void SetPlayer(Player player) { bool ok = false; if (Players[0] == null) // 対局者が未定であればセットする { Players[0] = player; ok = true; } else if (Players[1] == null) { Players[1] = player; ok = true; } if (ok) { if (IsMatched()) // ふたりの対局者が存在するなら対局開始 GameStart(); else SuccessfulEntry?.Invoke(this, new SuccessfulEntryArg(player)); } } } |
対局開始時におこなわれる処理を示します。
まずどちらが先手後手になるのかを決めます(50%の確率でPlayersを入れ替えるだけ)。そのあとマッチング成立のイベントを送信して1秒待機後に対局を開始します。
対局が開始されたら、手番を黒にして残り時間の減算を開始します。盤上の状態をStatusChangedイベントで送信します。そのあとHistoriesに現在の盤上の状態を格納します。また黒の着手可能点を Stonesプロパティ内に記録します(後述)。
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 Game { void GameStart() { int r = _random.Next(100); if (r < 50) { Player p0 = Players[0]; Player p1 = Players[1]; Players[0] = p1; Players[1] = p0; } Matched?.Invoke(this, new MatchedArg(this)); await Task.Delay(1000); Turn = 'B'; Timer.Start(); StatusChanged?.Invoke(this, new StatusChangedArg(this)); Histories.Add(new History('N', -1, -1, Stones)); // 履歴に初期状態を格納する CheckPossiblePositions('B'); // 黒の着手可能点をマークする } } |
着手可能点を調べる
GetReversibleStonesメソッドは(row, col)に着手したときにひっくり返される石がある位置のリストを取得します。これが返すリストが空でなければ着手可能であることになります。
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 |
public class Game { List<(int Row, int Col)> GetReversibleStones(char turn, int row, int col) { List<(int Row, int Col)> res = new List<(int Row, int Col)>(); if (Stones[row][col] == 'B' || Stones[row][col] == 'W') return res; int[] dr = { +1, +1, +0, -1, -1, -1, +0, +1 }; int[] dc = { +0, -1, -1, -1, +0, +1, +1, +1 }; List<(int Row, int Col)> positions = new List<(int Row, int Col)>(); for (int d = 0; d < 8; d++) { int r = row; int c = col; while (true) { r += dr[d]; c += dc[d]; if (r < 0 || r >= 8 || c < 0 || c >= 8 || (Stones[r][c] != 'B' && Stones[r][c] != 'W')) { positions.Clear(); break; } if (Stones[r][c] == turn) break; positions.Add((Row: r, Col: c)); } res.AddRange(positions); positions.Clear(); } return res; } } |
CanPutStoneメソッドは着手可能な場所があるか(強制パスにならないか)を調べます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class Game { bool CanPutStone(char turn) { for (int r = 0; r < 8; r++) { for (int c = 0; c < 8; c++) { var tp = GetReversibleStones(turn, r, c); if (tp.Count > 0) return true; } } return false; } } |
CheckPossiblePositionsメソッドはStonesプロパティの着手可能点に相当する文字を ‘b’ または ‘w’ に置き換えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class Game { void CheckPossiblePositions(char turn) { char c = 'N'; if (turn == 'B') c = 'b'; if (turn == 'W') c = 'w'; for (int row = 0; row < 8; row++) { for (int col = 0; col < 8; col++) { var tp = GetReversibleStones(turn, row, col); if (tp.Count > 0) Reverse(row, col, c); } } } } |
ClearPossiblePositionsメソッドはStonesプロパティに書き込まれていた ‘b’ や ‘w’ を ‘N’ に戻します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class Game { void ClearPossiblePositions() { List<(int Row, int Col)> positions = new List<(int Row, int Col)>(); for (int row = 0; row < 8; row++) { for (int col = 0; col < 8; col++) { if (Stones[row][col] == 'b' || Stones[row][col] == 'w') Reverse(row, col, 'N'); } } } } |
着手と終局判定の処理
着手と終局判定の処理を示します。
BlackOrWhiteメソッドは引数としてASP.NET SignalRで使われる一意の接続IDが渡されたときにそれが先手なのか後手なのかを示す文字を返します。
1 2 3 4 5 6 7 8 9 10 11 |
public class Game { public char BlackOrWhite(string id) { if (Players[0] != null && Players[0].ID == id) return 'B'; if (Players[1] != null && Players[1].ID == id) return 'W'; return 'N'; } } |
IsFinishメソッドは終局しているかどうかを調べます。すべてのマスが石で埋まっていたり、双方が着手不能であれば終局です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Game { bool IsFinish() { int blank = 0; for (int r = 0; r < 8; r++) { for (int c = 0; c < 8; c++) { if (Stones[r][c] != 'B' && Stones[r][c] != 'W') blank++; } } if (blank == 0 || (!CanPutStone('B') && !CanPutStone('W'))) return true; else return false; } } |
Jugdeメソッドは終局時にそれぞれの石の数を数えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Game { (int Black, int White) Jugde() { int black = 0; int white = 0; for (int r = 0; r < 8; r++) { for (int c = 0; c < 8; c++) { if (Stones[r][c] == 'B') black++; if (Stones[r][c] == 'W') white++; } } return (Black: black, White: white); } } |
PutStoneAsyncメソッドはユーザーが着手可能な場所に着手しようとしたときに着手したあと、0.5秒後に挟まれている石をひっくり返します。着手時に一時的にIsAcceptedプロパティをtrueにして着手が受理されたことをStatusChangedイベントで送信します。同時にStonesプロパティに書き込まれていた ‘b’ または ‘w’ を ‘N’ に戻し、減算されていた残り時間を最大値に戻します。また盤面の履歴を保存します。
そのあと終局判定をします。終局していない場合は相手番が着手可能であれば手番を変更し、そうでない場合は強制パスとしてひきつづき着手したプレイヤーの手番とします。強制パスの場合はユーザーにわかるようにIsPassForcedフラグをtrueにしてからStatusChangedイベントを送信します。
終局している場合は、IsFinishedフラグをtrueにし、どちらがどれだけ勝ったのかがわかるようにしてからStatusChangedイベントを送信します。そのあと履歴をファイルに保存し、すべてのユーザーが閲覧できるようにします。
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 |
public class Game { async Task<bool> PutStoneAsync(char turn, int col, int row) { char nextTurn = turn == 'B' ? 'W' : 'B'; List<(int Row, int Col)> positions = GetReversibleStones(turn, row, col); if (positions.Count > 0) { Timer.Stop(); ClearPossiblePositions(); char[] vs = Stones[row].ToArray(); vs[col] = turn; Stones[row] = new string(vs); IsAccepted = true; StatusChanged?.Invoke(this, new StatusChangedArg(this)); IsAccepted = false; Times[0] = MaxTime; Times[1] = MaxTime; Turn = 'N'; await Task.Delay(500); foreach((int Row, int Col) tp in positions) Reverse(tp.Row, tp.Col, turn); Histories.Add(new History(turn, row, col, Stones)); bool isFinish = IsFinish(); bool isPass = !CanPutStone(nextTurn); if (!isFinish && !isPass) { // 相手の手番に Turn = nextTurn; CheckPossiblePositions(nextTurn); IsPassForced = false; Timer.Start(); StatusChanged?.Invoke(this, new StatusChangedArg(this)); } if (isPass) { // 相手が着手不能のとき Turn = turn; CheckPossiblePositions(turn); IsPassForced = true; Timer.Start(); StatusChanged?.Invoke(this, new StatusChangedArg(this)); } if (isFinish) { Timer.Stop(); // 双方が着手不能のとき var ret = Jugde(); Scores[0] = ret.Black; Scores[1] = ret.White; if (Scores[0] > Scores[1]) Winner = 'B'; if (Scores[0] < Scores[1]) Winner = 'W'; if (Scores[0] == Scores[1]) Winner = 'D'; IsFinished = true; StatusChanged?.Invoke(this, new StatusChangedArg(this)); Data data = new Data(this); SaveData(data); // 履歴の保存(後述) } return true; } else { return false; } } } |
ユーザーが着手しようとしたとき PutStoneメソッドが呼び出されます。
引数として渡されたASP.NET SignalRで使われる一意の接続IDをもつプレイヤーが対局者であり、自分の手番であり、引数で指定された位置が着手可能である場合のみ上記の着手の処理がおこなわれます。自分の手番でなかったり、着手不能な場所に着手しようとしたときはDenyイベントが着手できない理由とともに送信されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class Game { public async void PutStone(string id, int col, int row) { char bOrW = BlackOrWhite(id); if (bOrW == 'B' || bOrW == 'W') { if (Turn == bOrW) { if (await PutStoneAsync(Turn, col, row) == false) Deny?.Invoke(this, new DenyArg(id, "そこへは置けません")); } else Deny?.Invoke(this, new DenyArg(id, "あなたの手番ではありません")); } } } |
残り時間を減算させる処理を示します。
自分の手番で残り時間が0になったら時間切れで負けです。この場合は時間切れにならなかったプレイヤーが勝者であり、対局履歴をファイルに保存します。そうでない場合は残り時間を減算します。どちらの場合もStatusChangedイベントを送信してクライアントサイドに状態の変化があったことを知らせます。
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 |
public class Game { private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (Turn == 'B') Times[0]--; if (Turn == 'W') Times[1]--; if (Times[0] <= 0 || Times[1] <= 0) { IsFinished = true; Turn = 'N'; Winner = Times[0] <= 0 ? 'W' : 'B'; Timer.Stop(); StatusChanged?.Invoke(this, new StatusChangedArg(this)); Data data = new Data(this); SaveData(data); } else { if (Turn != 'N') StatusChanged?.Invoke(this, new StatusChangedArg(this)); } } } |
対局履歴の読み出しと保存
対局履歴の読み出しの処理を示します。FilePathに保存されているjsonファイルを読み出してDataのリストに変換します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Game { static List<Data> LoadData() { if (File.Exists(FilePath)) { StreamReader sr = new StreamReader(FilePath, System.Text.Encoding.UTF8); string json = sr.ReadToEnd(); sr.Close(); List<Data>? ret = JsonSerializer.Deserialize<List<Data>>(json); if (ret != null) return ret; else return new List<Data>(); } else return new List<Data>(); } } |
保存する処理を示します。
FilePathに保存されているjsonファイルを読み出したあと新しい対局データを追加します。そして新しい順にソートして先頭100件のみを保存します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class Game { static object SyncObject = new object(); static void SaveData(Data data) { lock (SyncObject) { List<Data> datas = LoadData(); datas.Add(data); datas = datas.OrderByDescending(_ => _.Name).Take(100).ToList(); StreamWriter sw = new StreamWriter(FilePath, false, System.Text.Encoding.UTF8); sw.Write(JsonSerializer.Serialize(datas, SerializeOptions)); sw.Close(); } } } |
GetJsonPastMatchesメソッドはjsonファイルから読み出した文字列を返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class Game { public static string GetJsonPastMatches() { lock (SyncObject) { if (File.Exists(FilePath)) { StreamReader sr = new StreamReader(FilePath, System.Text.Encoding.UTF8); string str = sr.ReadToEnd(); sr.Close(); return str; } else return "[]"; } } } |
観戦者の追加と削除
現在おこなわれている対局を観戦しているユーザーを追加したり削除する処理を示します。ここでやっているのはWatcherIDsプロパティに対し、引数として渡されたASP.NET SignalRで使われる一意の接続IDを追加したり削除しているだけです。
1 2 3 4 5 6 7 8 9 10 11 12 |
public class Game { public void AddWatcher(string id) { WatcherIDs.Add(id); } public void RemoveWatcher(string id) { WatcherIDs.Remove(id); } } |