JavaScriptで箱入り娘を作る(2)の続きです。今回はスコアランキングを表示する機能を追加します。
Contents
逃亡者を公開処刑する鬼仕様?
これまでゲームのランキング表示はやってきましたが、今回は新たな要素を追加します。このゲームは途中でどうにもこうにもなくなりリロードしたくなることがあります。そのような場合、それもランキングに載せることにします。つまり逃亡者を公開処刑する鬼仕様とします。
JavaScriptでブラウザバックを記録する
ブラウザバックしたりリロードしたことはどうやれば記録することができるでしょうか?
beforeunloadイベントを利用します。ただしページにアクセスしてなにもしないでブラウザバックするとこのイベントは発生しないようです。今回のケースでいえばユーザーは[ゲーム開始]ボタンをクリックしたことを前提としているので、この部分は問題ありません。
1 2 3 |
window.addEventListener('beforeunload', function(ev) { // サーバーにデータを送る }); |
サーバーにデータを送る
サーバーに送るデータですが、以下を送ります。
問題の最短手数
プレイヤーが実際に着手した手数
消費時間
ゲームをクリアできたかどうか?
gameStart関数の最後のほうで生成した問題をsolve関数を呼び出し、実際に問題を解くことで最短手数を取得し、これをグローバル変数に保存しておきます。
1 2 3 4 5 6 7 8 |
let optimizedNumberOfSteps = 0; // 問題の最短手数 function gameStart(type){ // 既出部分は省略(JavaScriptで箱入り娘を作る(1)を参照してください) // 問題の最短手数を保存しておく optimizedNumberOfSteps = solve().length; } |
サーバーにデータをPOSTする処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function sendData(){ let playerName = $playerName.value; if(playerName == '') playerName = '名無しのゴンベ'; const phpurl = './save-data.php'; $.post(phpurl, { playerName:playerName, optimizedNumberOfSteps:optimizedNumberOfSteps, numberOfSteps:numberOfSteps, time:time, isGameCleared :isGameCleared, }); } |
ゲームクリアしていないのにブラウザバックしたらサーバーにデータを送ります。ただしゲームを開始するまえにブラウザバックしたときはデータを送りたくないので time > 0 のときだけsendData関数を実行します。
1 2 3 4 |
window.addEventListener('beforeunload', function(ev) { if(!isGameCleared && time > 0) sendData(); }); |
同様にギブアップボタンをクリックしたときもデータを送ります。ただし一手も着手していない場合は送りません(やっていることはカンニンだから公開処刑の対象にしてもいいのだが・・・)。
1 2 3 4 5 6 |
$giveup.addEventListener('click', async() => { if(!isGameCleared && numberOfSteps > 0) sendData(); // 既出部分は省略(JavaScriptで箱入り娘を作る(2)を参照してください) } |
サーバー側での処理
POSTされたデータをPHPで処理します。サーバー上のcsvファイルに追記しているだけです。
save-data.php
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 |
<?php save(); function GetFileName(){ $filename = "./klotski-data.csv"; return $filename; } function save(){ // 必要なデータがPOSTされていない場合はなにもしない if( !isset($_POST['playerName']) || !isset($_POST['optimizedNumberOfSteps']) || !isset($_POST['numberOfSteps']) || !isset($_POST['time']) || !isset($_POST['isGameCleared']) ) return; $playerName = $_POST["playerName"]; $optimizedNumberOfSteps = $_POST["optimizedNumberOfSteps"]; $numberOfSteps = $_POST["numberOfSteps"]; $time = $_POST["time"]; $isGameCleared = $_POST["isGameCleared"]; // csvファイルなのでカンマがある場合は置換する $playerName = str_replace(",", "_", $playerName); // 数字であるべきデータが数字ではない、長過ぎる文字列が送りつけられた場合はなにもしない if( !ctype_digit($optimizedNumberOfSteps) || !ctype_digit($numberOfSteps) || !ctype_digit($time) || mb_strlen($playerName) > 32) return; // 追記する文字列を生成する $now = date("Y-md H:i:s"); $text = "{$now},{$playerName},{$optimizedNumberOfSteps},{$numberOfSteps},{$time},{$isGameCleared},\n"; // 追記する if ($fp = fopen(GetFileName(), "a")){ flock($fp, LOCK_EX); fwrite($fp, $text); flock($fp, LOCK_UN); fclose($fp); } } |
以下はサーバー上のcsvファイルのデータを文字列にして返す処理です。
get-data.php
1 2 3 4 5 6 |
function GetFileName(){ $filename = "./klotski-data.csv"; return $filename; } echo htmlspecialchars(file_get_contents(GetFileName()), ENT_QUOTES, 'UTF-8'); |
ランキングを表示する
ランキングを表示する処理を示します。ランキングページに書かれているように、クリアまでの手数が少ないプレイヤーが上位、同じ手数であれば早く解いたプレイヤーが上位とし、上位20件、リタイアの直近の20件を表示することにします。
HTML部分
ランキングを表示するページを作成します。まずHTMLとCSSを示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>レベル別ランキング:鳩でもわかる箱入り娘</title> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <link rel="stylesheet" href="./ranking.css"> </head> <body> <div id = "container"> <h1 style="font-size: 24px;">鳩でもわかる箱入り娘 レベル別ランキング</h1> <p><a href="./">ゲームのページへ</a></p> <p>クリアまでの手数が少ないプレイヤーが上位です。同じ手数であれば早く解いたプレイヤーが上位です。 リタイアは直近の20件を表示しています。 </p> <div id = "level-ranking"></div> </div> <script src="./ranking.js"></script> </body> </html> |
ranking.css
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 |
body { background-color: #000; color: white; } #container { max-width: 600px; } a { color: aqua; font-weight: bold; } a:hover { color: red; } td { border: 1px solid #fff; padding: 2px 10px 2px 10px; } h1 { font-size: 24px; color: magenta; } h2 { font-size: 18px; color: yellow; } .center { text-align: center; } .right { text-align: right; } .blue { color: aqua; font-weight: bold; } .red { color: magenta; font-weight: bold; } |
Dataクラスの定義
サーバーから送られてきた文字列をランキングとして表示するデータに加工するためにDataクラスの定義します。コンストラクタに引数としてカンマ区切りの文字列が渡されるのでこれを分割して各メンバ変数に格納します。
地味に注意が必要な点として数値が入る変数には文字列としてではなくNumber型に変換してから代入しなければなりません。そうしないと後のソートの処理がうまくいきません。
ranking.js
1 2 3 4 5 6 7 8 9 10 11 |
class Data{ constructor(text){ const vs = text.split(','); this.Date = vs[0]; this.PlayerName = vs[1]; this.OptimizedNumberOfSteps = Number(vs[2]); // 数値が文字列になっているのでNimber型に変換 this.NumberOfSteps = Number(vs[3]); this.Time = Number(vs[4]); this.IsGameCleared = vs[5]; } } |
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。
getAllDatas関数を呼び出してサーバーから文字列を取得、そこからDataオブジェクトの配列を生成します。ここからランキング表示用のHTMLタグを生成してランキングを表示します。
1 2 3 4 5 6 7 |
window.onload = async() => { document.getElementById('level-ranking').innerHTML = 'データを取得しています'; const datas = await getAllDatas(); // 後述 const html = createLevelRankingHTML(datas, 20); // 20件まで表示(後述) document.getElementById('level-ranking').innerHTML = html; } |
サーバーから文字列を取得してDataオブジェクトの配列を生成する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
async function getAllDatas(){ const f = await fetch('./get-data.php'); const text = await f.text(); const texts = text.split('\n'); const datas = []; for(let i=0; i<texts.length; i++){ if(texts[i] == '') continue; datas.push(new Data(texts[i])); } return datas; } |
ランキングを表示するHTMLの生成
Dataオブジェクトの配列からランキング表示用のHTMLを生成する処理を示します。
まず難易度順にDataを分けます。mapのKeyを問題の最短手数、ValueをDataの配列とします。Keyが存在しないならKeyを作り、あるならその配列へDataを追加します。
そのあと分類された各配列のなかのDataをゲームクリアできたものとできていないものにわけます。ゲームクリアできたものは手数が少ない順、同じであれば消費時間が少ない順にソートします。ゲームクリアできなかったものは単に順序を反転させます(直近のものが前にくるようにする)。そしてDataに格納されているデータをつかってHTMLを生成します。
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 |
function createLevelRankingHTML(datas, max){ const map = new Map(); for(let i=0; i<datas.length; i++){ const data = datas[i]; const key = data.OptimizedNumberOfSteps; if(!map.has(key)) map.set(key, []); map.get(key).push(data); } const keys = []; for (const key of map.keys()) keys.push(key); keys.sort((a, b) => b - a); let html = ''; for (const key of keys) { html += `<h2>${key} 手問題</h2>`; const datas = map.get(key); // sortEx関数と getTimeText関数は後述 const clears = datas.filter(data => data.IsGameCleared == 'true'); sortEx(clears, ['NumberOfSteps', 'Time']); html += '<table class = "center">'; html += `<tr><td class = "center">Rank</td><td width="180">Player Name</td><td>手数</td><td width="100">Date</td></tr>`; let count = Math.min(clears.length, max); for(let i=0; i<count; i++){ const data = clears[i]; html += `<tr><td class = "center">${i+1}</td><td>${data.PlayerName}</td><td>${data.NumberOfSteps} 手 で <span class = "blue">クリア</span><br>${getTimeText(data.Time)}</td><td>${data.Date}</td></tr>`; } const retires = datas.filter(data => data.IsGameCleared == 'false'); retires.reverse(); count = Math.min(retires.length, max); for(let i=0; i<count; i++){ const data = retires[i]; html += `<tr><td class = "center">-</td><td>${data.PlayerName}</td><td>${data.NumberOfSteps} 手 で <span class = "red">リタイア</span><br>${getTimeText(data.Time)}</td><td>${data.Date}</td></tr>`; } html += '</table>'; } return html; } |
getTimeText関数は秒数で与えられた引数を ◯分◯秒という形式の文字列に変換します。
1 2 3 4 5 6 7 8 9 |
function getTimeText(time){ let seconds = time % 60; let minutes = Math.floor(time / 60); if(seconds < 10) seconds = '0' + seconds; if(minutes < 10) minutes = '0' + minutes; return `${minutes} 分 ${seconds} 秒`; } |
オブジェクトの配列を複数のキーでソートする
sortEx関数はオブジェクトの配列をattrsに順にソートします。sortEx(clears, [‘NumberOfSteps’, ‘Time’])であればNumberOfStepsが小さい順にソートして、NumberOfStepsが同じならTimeが小さい順にソートします。
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 |
function sortEx(arr, attrs){ arr.sort((a, b) => compareByAttr(a, b, attrs)); function compareByAttr(o1, o2, attrs) { const o1Values = attrs.map(attr => o1[attr]); const o2Values = attrs.map(attr => o2[attr]); return compareArr(o1Values, o2Values); } function zipLongest(...arrays){ const length = Math.max(...(arrays.map(arr => arr.length))); return new Array(length).fill().map((_, i) => arrays.map(arr => arr[i])); } function compareArr(arr1, arr2) { const difference = zipLongest(arr1, arr2).find(([v1, v2]) => v1 !== v2); if (!difference) return 0; if (difference[0] === undefined) return -1; if (difference[1] === undefined) return 1; if (difference[0] > difference[1]) return 1; else return -1; } } |