ASP.NET Core版 対人対戦できるぷよぷよをつくる(8)の続きです。前回はぷよぷよの棋譜をデータとしてサーバー上に保存できるようにしました。今回はそれを閲覧できるようにします。
Contents
cshtml部分
Pages\Puyo-Matchフォルダ内にgame-records.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 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 |
@page @model PuyoMatch.LoginModel @{ Layout = ""; string baseurl = "webアプリとして公開したいurl ドメイントップで公開するのであれば空文字列でよい"; <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>鳩でもわかるオンライン対戦型「ぴよぴよ」Ver 1.0 - 鳩でもわかるASP.NET Core</title> <style> body { background-color: #000; color: #fff; font-family: "MS ゴシック"; line-height: 150%; } #container { width: 800px; } .display-none { display: none; } .button { width: 100px; } #game-record-controler { display:none; } a { color: #00bfff; } a:hover { color: #f00; } .bold { font-weight: bold; } </style> <script src="@baseurl/js/signalr.js"></script> </head> <body> <div id="container"> <div style="margin-left:60px;"> <p class="bold"><a href="./game">⇒ 「ぷよぷよ」ならぬ「ぴよぴよ」のページへ戻る</a></p> <p id="notify"></p> <span id="waiting-player-info"></span> </div> <div class="display-none"> @for (int i = 0; i < 5; i++) { @for (int k = 0; k < 20; k++) { if (k < 10){ <img src="@(baseurl)/puyo-match/puyo-images/@(i+1)-0@(k).png" alt="" id="type@(i+1)-0@(k)" /> } else { <img src="@(baseurl)/puyo-match/puyo-images/@(i+1)-@(k).png" alt="" id="type@(i+1)-@(k)" /> } } } <img src="@baseurl/puyo-match/puyo-images/wall.png" alt="" id="wall" /> </div> <!-- /.display-none --> <div style="margin-left:60px; color:#ff0000" id="errer"> <p>エラー:通信が切れました。</p> </div> <div style="margin-left:60px"> <p style="color:#0ff; font-weight:bold">使い方</p> <p id="how-to-use-records"></p> <div id="second"></div> </div> <div><canvas id="can"></canvas></div> <div style="margin-left:60px;"> <input type="checkbox" id="sound-checkbox" class="display-none"> <div id="game-record-controler"> <input type="button" value="前" id="prev" onclick="Prev()"> <input type="button" value="次" id="next" onclick="Next()"> <input type="button" value="やめる" id="end" onclick="End()"> </div> <div id="game-records"></div> <p id="notify1"> 現在 <span id="game-count"></span> の対戦がおこなわれています。<br> </p> <p id="conect-result"></p> </div> </div><!-- / #container --> <script> let connection = new signalR.HubConnectionBuilder().withUrl("@baseurl/PuyoMatchHub").build(); let base_url = '@baseurl'; </script> <script src="@baseurl/puyo-match/puyo-match.js"></script> </body> </html> |
puyo-match.jsはこれまで使ってきた、ASP.NET Core版 対人対戦できるぷよぷよをつくるの記事のものと同じです。ここに棋譜を閲覧するために必要な処理を追加していきます。
PuyoMatchHubクラスに機能を追加する
そのまえにここでもAspNetCore.SignalRを使ったサーバーサイドへの接続、サーバーサイドから送信されたデータを受信して処理をおこなっていくので、PuyoMatchHubクラスにそのための機能を追加します。
名前空間は省略して書きます。
1 2 3 4 5 6 7 8 9 10 |
using Microsoft.AspNetCore.SignalR; using System.Timers; using System.Text; namespace PuyoMatch { public class PuyoMatchHub : Hub { } } |
1 2 3 |
public class PuyoMatchHub : Hub { } |
閲覧できる棋譜の一覧を取得する
サーバーサイドへの接続が成功したら、まず閲覧できる棋譜の一覧を表示させます。GetRecordsメソッドはサーバー上に保存されているファイルのなかで棋譜が保存されているものを探します。もし見つかったらファイル名とファイルの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 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 |
public class PuyoMatchHub : Hub { public void GetRecords() { if (!Directory.Exists("../puyo-match-data/")) { Clients.Caller.SendAsync("SuccessfulGetRecords", "記録されているデータはありません"); return; } string[] paths = Directory.GetFiles("../puyo-match-data/", "*.txt", System.IO.SearchOption.TopDirectoryOnly); StringBuilder sb = new StringBuilder(); sb.Append("<table>"); sb.Append("<tr>"); sb.Append("<td style=\"padding-right:20px;\">" + $"勝ち" + "</td>"); sb.Append("<td style=\"padding-right:20px;\">" + $"負け" + "</td>"); sb.Append("<td style=\"padding-right:20px;\">" + $"決着時刻" + "</td>"); sb.Append("<td>" + "</td>"); sb.Append("</tr>"); List<string> list = new List<string>(paths); paths = list.OrderBy(_ => _).ToArray(); foreach (string path in paths) { string? line = ""; try { StreamReader sr = new StreamReader(path); line = sr.ReadLine(); sr.Close(); } catch { continue; } string str = path.Replace("../puyo-match-data/", ""); // ファイル名は "20221209-173327.txt"のような名前なので、ここから対戦日時が取得できる string[] strings = str.Split('-'); List<char> chars = strings[0].ToList(); chars.Insert(6, '-'); chars.Insert(4, '-'); string str1 = new string(chars.ToArray()); chars = strings[1].Take(6).ToList(); chars.Insert(4, ':'); chars.Insert(2, ':'); string str2 = new string(chars.ToArray()); str2 = str2.Replace(".txt", ""); // "2022-12-09 17:33:27"のような文字列が取得できる string str3 = ""; string str4 = ""; if (line != null) { // line は 2人のプレイヤー名と勝者がタブ文字で連結された文字列である // 文字列を分割して3番目の文字列が"0"なら最初に書かれているプレイヤーが勝者 // "1"なら2番目に書かれているプレイヤーが勝者である strings = line.Split('\t'); if (strings[2] == "0") { str3 = strings[0]; str4 = strings[1]; } else { str3 = strings[1]; str4 = strings[0]; } // プレイヤー名は16文字だが半角文字であればよいが、全角文字の場合は8文字に切り詰める // バイト数が16を超えていれば文字数としては16文字以内であるが、全角文字があると判断して // 8文字に切り詰める。 Encoding shiftjisEnc = Encoding.UTF8; if (shiftjisEnc.GetByteCount(str3) > 16) { // 16バイト以上で文字列が8文字以上なら8文字に切り詰める if(str3.Length > 8) str3 = str3.Substring(0, 8); } if (shiftjisEnc.GetByteCount(str4) > 16) { if (str4.Length > 8) str4 = str4.Substring(0, 8); } } // クライアントサイドに送信する文字列を生成する // 生成する文字列はボタン表示用のものとボタンをクリックしたときに実行される関数(引数も含む) // プレイヤー名の文字列の長さでレイアウトが崩れないように表形式にする sb.Append("<tr>"); sb.Append("<td style=\"padding-right:20px;\">" + $"{str3}" + "</td>"); sb.Append("<td style=\"padding-right:20px;\">" + $"{str4}" + "</td>"); sb.Append("<td style=\"padding-right:20px;\">" + $"{str1} {str2}" + "</td>"); sb.Append("<td>" + $" <input type=\"button\" class = \"button\" onclick=\"GetRecord('{str}')\" value=\"分析する\">" + "</td>"); sb.Append("</tr>"); } sb.Append("</table>"); // 表を表示させるための文字列が生成されクライアントサイドに送信する Clients.Caller.SendAsync("SuccessfulGetRecords", sb.ToString()); } } |
サーバーからファイルのデータを取得する
棋譜閲覧ページで[分析する]ボタンがクリックされたら、その棋譜のデータ(ファイルに保存されている文字列全部)をクライアントサイドに送信します。このとき別のユーザーが同じタイミングでファイルの文字列を読み込もうとすると例外が発生します。なので例外処理をしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class PuyoMatchHub : Hub { public void GetRecord(string fileName) { try { string str = ""; StreamReader sr = new StreamReader("../puyo-match-data/" + fileName); str = sr.ReadToEnd(); sr.Close(); str = str.Replace("\r", ""); Clients.Caller.SendAsync("SuccessfulGetRecord", str); } catch { Clients.Caller.SendAsync("SuccessfulGetRecords", "ファイルの読み込みに失敗しました。"); System.Threading.Thread.Sleep(1000); GetRecords(); } } } |
JavaScriptの処理
まずユーザーがページにアクセスしたときはAspNetCore.SignalRでサーバーサイドへの接続がおこなわれます。これに成功したらサーバーサイドではGetRecordsメソッドが呼び出されます。棋譜閲覧ページ以外では <div id = “game-records”> </div> が存在しないのでなにもおきません。棋譜閲覧ページの場合だけ、GetRecords関数が実行されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
let connectionID = ''; let colMax = 0; let rowMax = 0; //@ts-ignore connection.on("SuccessfulConnectionToClient", function (result, id, rowmax, colmax) { connectionID = id; rowMax = rowmax; colMax = colmax; //@ts-ignore document.getElementById("conect-result").innerHTML = `conect-result ${result}:${id}`; // 棋譜閲覧ページの場合だけ、この部分が実行される let gameRecords = document.getElementById('game-records'); if (gameRecords != null) GetRecords() }); function GetRecords(){ connection.invoke("GetRecords").catch(function (err) { return console.error(err.toString()); }); } |
GetRecords関数が実行され、サーバーサイドから棋譜一覧を表示するための文字列が送られてきたら、これを <div id = “game-records”> </div> 内にこれを挿入します。これによって過去の対戦情報が表示されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
connection.on("SuccessfulGetRecords", function (str) { // 過去の対戦情報が表示するために不必要なものは非表示にする can.style.display = 'none'; // 過去の対戦情報を表示させる let gameRecords = document.getElementById('game-records'); if (gameRecords != null) gameRecords.innerHTML = str; // 一手前や後に移動させるボタンはこの段階では不必要なので非表示にする let gameRecordControler = document.getElementById('game-record-controler'); if (gameRecordControler != null) gameRecordControler.style.display = 'none'; // 棋譜をみるためにどうすればいいかユーザーに説明する文章を表示させる let howToUseRecords = document.getElementById('how-to-use-records'); if(howToUseRecords != null) howToUseRecords.innerHTML = '[分析する]を選択するとその対戦の記録をみることができます。勝者と敗者、決着時刻から分析したいものを選んでください。'; }); |
棋譜を再生する
棋譜を見るためのボタンのタグは以下のような形式になっています。
1 |
<input type="button" onclick="GetRecord('20221207-200858.txt')" value="分析する"> |
ボタンをクリックすると、この場合は 引数’20221207-200858.txt’が渡されてGetRecord関数が実行されます。
GetRecord関数は以下のようになっています。サーバーサイドのGetRecordメソッドを呼び出すようになっています。
1 2 3 4 5 |
function GetRecord(str){ connection.invoke("GetRecord", str).catch(function (err) { return console.error(err.toString()); }); } |
サーバーサイドのGetRecordメソッドを呼び出して処理が正常におこなわれると、クライアントサイドに”SuccessfulGetRecord”が送信されます。
1 2 3 |
connection.on("SuccessfulGetRecord", function (str) { // 後述 }); |
このときにおこなう処理では独自に定義したStateクラスを使用します。そこでStateクラスを先に示します。
1 2 3 4 5 6 7 8 9 10 |
class State{ constructor(){ this.Time = ''; this.FieldPuyos0 = ''; this.FieldPuyos1 = ''; this.NextPuyos = ''; this.Scores = ''; this.Yokokus = ''; } } |
次にサーバーサイドから送信された”SuccessfulGetRecord”を受信したときの処理を示します。
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 |
let states = []; let statesIndex = 0; let winner = 0; connection.on("SuccessfulGetRecord", function (str) { // canvasを表示する // canvasのサイズを調整してなにも表示されていない状態にする can.style.display = 'block'; isThisFirstPlayer = true; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // 外周の枠を描画する DrawWalls(true); DrawWalls(false); // 局面を前後に移動させるボタンが必要なので、これを表示する let gameRecordControler = document.getElementById('game-record-controler'); if (gameRecordControler != null) gameRecordControler.style.display = 'block'; // 棋譜を閲覧しているときは、棋譜一覧は必要ないので非表示にする // またgameRecords != nullになるのはこのページだけなので他のページで以下の処理はおこなわれない let gameRecords = document.getElementById('game-records'); if (gameRecords != null){ gameRecords.innerHTML = ''; // 引数で渡された文字を分割して配列に格納する states = []; let strs1 = str.split('\n\n\n'); // 最初の1行は2人のプレイヤー名と勝者('0'なら最初に名前が書かれているプレイヤー、そうでないなら後者) let players = strs1[0].split('\t'); playerName0 = players[0]; playerName1 = players[1]; winner = Number(players[2]); for(let i = 1; i < strs1.length; i++){ let strs2 = strs1[i].split('\n\n'); // 各局面の状態 let state = new State(); state.Time = strs2[0]; // 対戦開始からの経過時間 state.FieldPuyos0 = strs2[1]; // プレイヤー1のフィールドの状態 state.FieldPuyos1 = strs2[2]; // プレイヤー1のフィールドの状態 state.NextPuyos = strs2[3]; // 両プレイヤーのネクストぷよ state.Scores = strs2[4]; // 両プレイヤーのスコア state.Yokokus = strs2[5]; // 両プレイヤーの予告ぷよ states.push(state); } } statesIndex = -1; // 最初は-1 [次]ボタンがクリックされると初手の状態が表示される // ユーザーに対して操作方法を説明する let howToUseRecords = document.getElementById('how-to-use-records'); if(howToUseRecords != null) howToUseRecords.innerHTML = `いずれかのプレイヤーが「着地」「連鎖」「おじゃまぷよが落ちてきた直後」のときのぷよの状態を保存しています。 [次][前]を選択するとひとつ次の状態や前の状態に移動することができます。矢印キーの←は[前]、→は[次]に対応しています。`; // 下のようにある[分析する]ボタンをクリックするとフィールドの状態を表示している部分がみえない // そこでボタンがクリックされたら自動で一番上までスクロールする scrollTo(0, 0); }); |
棋譜を閲覧しているユーザーが[前][次]のボタンをクリックしたときの処理を示します。statesIndexが配列の範囲外にならないようにチェックをしてからstatesIndexの値をインクリメントまたはデクリメントしています。そのあとUpdate2関数を呼び出して再描画の処理に必要なグローバル変数の設定をしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function Next(){ // 棋譜を表示させる文字列の最後に空白行が残っているため、 // statesIndex + 2 == states.length である場合がデータの最後尾である if(statesIndex + 2 < states.length){ statesIndex++; UpdateRecord(); } } function Prev(){ // statesIndex が 負数になってはならないのでチェックをしている if(statesIndex - 1 >= 0){ statesIndex--; UpdateRecord(); } } function End(){ // 棋譜閲覧を終了したら canvasは非表示にして、再度 棋譜一覧を表示させる GetRecords(); } |
Update2関数はstates配列から描画の処理に必要なデータを取得してグローバル変数に格納しています。フィールド上のぷよを描画するためのデータの更新は別関数(UpdateFieldPuyos関数)でおこなっています。
3本先取なので第1戦以外のときはゲームが開始されたときのスコアは0ではありません。ひとつのゲームを分析するのであれば両プレイヤーのスコアは0から始まったほうがよいのではないでしょうか? そこでグローバル変数のinitScore0とinitScore1に格納して、その差を利用してスコアを算出しています。
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 |
let initScore0 = 0; let initScore1 = 0; function UpdateRecord(){ let state = states[statesIndex]; let second = document.getElementById('second'); if(second != null) second.innerText = state.Time + ' 秒'; UpdateFieldPuyos(state); // 後述 let nextPuyos = state.NextPuyos.split(','); let nextPuyos0 = nextPuyos[0].split('_'); let nextPuyos1 = nextPuyos[1].split('_'); // nextMainSub0とnextNextMainSub1は既存のグローバル変数 nextMainSub0 = []; nextNextMainSub0 = []; for(let i = 0; i < 2; i++){ nextMainSub0[i] = nextPuyos0[i]; nextMainSub1[i] = nextPuyos1[i]; } for(let i = 0; i < 2; i++){ nextNextMainSub0[i] = nextPuyos0[i+2]; nextNextMainSub1[i] = nextPuyos1[i+2]; } // initScore0とinitScore1は既存のグローバル変数 let scores = state.Scores.split(','); if(statesIndex == 0){ initScore0 = Number(scores[0]); initScore1 = Number(scores[1]); } // score0とscore1は既存のグローバル変数 if(statesIndex + 2 < states.length){ score0 = Number(scores[0]) - initScore0; score1 = Number(scores[1]) - initScore1; } else { score0 = winner == 0 ? "WIN" : "LOSE"; score1 = winner == 1 ? "WIN" : "LOSE"; } // yokoku0とyokoku1は既存のグローバル変数 let yokokus = state.Yokokus.split(','); yokoku0 = Number(yokokus[0]); yokoku1 = Number(yokokus[1]); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); DrawWalls(true); DrawWalls(false); if(statesIndex >= 0){ // 新しく定義した関数 DrawFieldPuyo2(true); DrawFieldPuyo2(false); // 既存の関数 DrawNextPuyo(true); DrawNextPuyo(false); // 既存の関数 DrawScore(true); DrawScore(false); } } |
UpdateFieldPuyos関数はフィールド上のぷよを描画するためのデータを更新します。必要なデータはfieldPuyosInfos配列に格納されます。
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 |
let fieldPuyosInfos = []; function UpdateFieldPuyos(state) { let fieldPuyosString = []; fieldPuyosString[0] = state.FieldPuyos0; fieldPuyosString[1] = state.FieldPuyos1; fieldPuyosInfos = []; for (let i = 0; i < 2; i++) { let fieldPuyosLine = fieldPuyosString[i].split('\n'); let puyoInfos2 = []; for (let row = 0; row < rowMax; row++) { let fieldPuyosInfos = fieldPuyosLine[row].split(','); let puyoInfos1 = []; for (let col = 0; col < colMax; col++) { let fieldPuyoInfo = fieldPuyosInfos[col].split('_'); let puyoInfo = []; puyoInfo.push(fieldPuyoInfo[0]); puyoInfo.push(fieldPuyoInfo[1]); puyoInfos1.push(puyoInfo); } puyoInfos2.push(puyoInfos1); } fieldPuyosInfos.push(puyoInfos2); } } |
フィールド上のぷよを描画する処理を示します。
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 |
function DrawFieldPuyo2(isFirstPlayer) { let marginLeft; let marginTop; let index = 0; if ((isFirstPlayer && isThisFirstPlayer) || (!isFirstPlayer && !isThisFirstPlayer)) { marginLeft = marginLeftPlayer1; marginTop = marginTopPlayer1; index = 0; } else { marginLeft = marginLeftPlayer2; marginTop = marginTopPlayer2; index = 1; } for (let row = 0; row < rowMax; row++) { for (let col = 0; col < colMax; col++) { let x = (col + 1) * 28 + marginLeft; let y = (row + 1) * 28 + marginTop; let puyoInfo = fieldPuyosInfos[index][row][col]; let img = GetImageFromPuyo(puyoInfo[0], puyoInfo[1]); if (img != undefined && img != null) { if (row > 0 && img != undefined && img != null) ctx.drawImage(img, x, y, 28, 28); else if (row == 0) { ctx.drawImage(img, x, y, 28, 28); ctx.fillRect(x, y - 10, 28, 28); } } } } } |