前回までに作成したオンラインゲームにランキング機能をつけます。
Contents
ランキング機能をつける
ひとりでプレイしているところをYouTubeで公開して、「他の人がプレイしてくれないと正しく動いているのか検証できません。我こそは…という人がいたらお願いします」と概要欄に書いていたらさっそくアクセスしてくれた人がいました。ありがとうございました。
公開してからわかった問題点
実際に複数人(2人だけだけど)でやってみて思ったことは、
プレイしている人数がわかるようにする
ランキング機能をつける
このような機能をつけたほうがよさそうです。ただプレイしている人数がわかるようにしてしまうと閑古鳥が鳴いていることもわかってしまうという困った問題もおきます。
まあそれはいいとして今回はランキング機能を追加します。
プレーヤーに名前を登録してもらう
まずはプレーヤーに名前を登録してもらいましょう。
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <meta name="robots" content="noindex"> <title>SNAKE-GAME</title> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script> <script src="./socket.io/socket.io.js"></script> <style> .container { width : 650px; margin: 0 auto 0 auto; } #form { margin-bottom: 10px; } </style> </head> <body> <div class="container"> <!-- 一応、ゲームの説明をしておく --> <p>スリザリオ(Slither.io)っぽいオンラインゲームを作ってみました。スリザリオとちがって<span style="color:#ff0000;font-weight:bold;">自分自身に衝突してもミス</span>となります。</p> <!-- ここにフォームをつける --> <form id ="form"> <label for="name" id="name-label" name="name-label">名前:</label> <input type="text" id="name" name="name"> <button type="button" id="start-button" name="start-button" onclick="start()">Start</button> <input type="checkbox" id="ranking" name="ranking" value="1" checked="checked">ランキングを表示する </form> <!-- 追加ここまで --> <canvas id = "can"></canvas> </div> <script type="text/javascript" src="./snake-game.js"></script> </body> </html> |
ボタンがクリックされたらstart関数が実行されます。これまではページにアクセスしたらすぐにサーバーにclient_to_server_firstが送られ、すぐにゲームに参戦することになっていましたが、今回はstart関数が実行されないとゲームに参加することはできません。
client_to_server_firstイベントではフォームに入力した自分の名前もサーバーにおくります。これでランキングに自分の名前が表示されるようになります。
またゲームに参加したらボタンは非表示にします。何回もボタンをおされると同じIDをもつプレーヤーが大量生産され、しかも実際に操作できるのはひとつだけという問題点があるです。ゲームオーバーになったあと再度ゲームをする場合はF5キーを押してください。
snake-game/snake-game.js
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function start(){ // プレーヤーの名前をサーバーにおくる let name = document.getElementById('name'); let nameText = name.value; snake_game.emit("client_to_server_first", {value : nameText}); name.style.display = 'none'; // ボタンは非表示 ゲームの途中で名前を変更できないのでテキストボックスも非表示 let start_button = document.getElementById('start-button'); start_button.style.display = 'none'; let name_label = document.getElementById('name-label'); name_label.style.display = 'none'; } |
SnakeGame_Playerクラスにプレーヤーの名前を保存する
ランキングではゲーム中のプレーヤー限定のランキングとゲームオーバーになったプレーヤーも含めたランキングの2種類をつくります。
サーバー側ではすでにゲームオーバーになったものも含む上位者を格納する配列を用意します。
app.js
1 2 |
// ゲームオーバーになったものも含む上位者を格納する配列 let highRankigPlayers = []; |
SnakeGame_Playerクラスにプレーヤーの名前を保存する関数を追加します。
app.js
1 2 3 4 5 |
class SnakeGame_Player{ SetPlayerName(playerName){ this.PlayerName = playerName; } } |
そしてclient_to_server_firstイベントでクライアントからデータ(フォームに入力された名前)をうけとったらSnakeGame_PlayerクラスのSetPlayerName関数を呼び出して名前をつけます。
app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class SnakeGame { static ClientToServerFirst(snakeGame, socket, data){ let id = socket.id; let player = new SnakeGame_Player(0, 0); player.ID = id; // プレーヤーに名前をつける let playerName = data.value; if(playerName == '') playerName = '名無しさん'; player.SetPlayerName(playerName); SnakeGame.Players.push(player); snakeGame.to(id).emit('server_to_client_id', {value : id}); } } |
ランキングの変動に対応させる
ランキングが変更されるとすればプレーヤーの誰かが得点したときです。これに該当するのがCheckEatFood関数内で餌を食べたと判定されたときです。
餌を食べた場合はこれまでのプレーヤーの上位者の配列に自分を追加します。このときすでに自分が登録されているかもしれないので追加するまえに自分を取り除きます。自分を追加したら得点(自分自身の長さ)でソートし、上位10名だけ取り出します。これで上位者10名を知ることができます。
app.js
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 |
class SnakeGame_Player{ CheckEatFood(){ let isEat = false; // 餌を食べた場合はtrueになる SnakeGame.Foods.forEach(food => { let distance2 = Math.pow(this.PosX - food.X, 2) + Math.pow(this.PosY - food.Y, 2); if(distance2 - Math.pow(this.Radius + food.Radius, 2) < 0){ food.Dead = true; isEat = true; this.Length += food.Radius * 2; // 餌を食べることで自機が長くなるだけでなく太くもなるが、 // あまり太くなりすぎないようにする if(this.Radius < 24) this.Radius += 0.1; } }); SnakeGame.Foods = SnakeGame.Foods.filter(food => !food.Dead); for(let i=0; i<SnakeGame.FoodsMax - SnakeGame.Foods.length; i++) SnakeGame.CreateNewFood(); // 餌を食べた場合はランキング情報が変わるかもしれない if(isEat){ highRankigPlayers = highRankigPlayers.filter(x => x.ID != this.ID); highRankigPlayers.push(this); highRankigPlayers.sort(function(a,b){ if(a.Length<b.Length) return 1; if(a.Length > b.Length) return -1; return 0; }); let newhighRankigPlayers = []; for(let i=0; i<10; i++){ if(highRankigPlayers.length > i) newhighRankigPlayers.push(highRankigPlayers[i]); } highRankigPlayers = newhighRankigPlayers; } } } |
ランキングの変動をクライアント側に伝える
ランキングを表示させるにはクライアントにランキング情報を伝えないといけないのでSnakeGame_Dataクラスにそのためのメンバを追加します。
app.js
1 2 3 4 5 6 7 8 9 10 11 |
class SnakeGame_Data{ constructor(){ this.Players = []; this.Foods = []; this.Field = new SnakeGame_Field( SnakeGame_Player.MinPosX, SnakeGame_Player.MinPosY, SnakeGame_Player.MaxPosX, SnakeGame_Player.MaxPosY); this.HighRankigPlayers = []; // これを追加 } } |
あとはserver_to_client_objectsイベントで上位者10名をクライアントに送信すればクライアント側でうまくやってくれます。
app.js
1 2 3 4 5 6 7 8 9 10 11 |
class SnakeGame { static Interval(snakeGame, socket){ SnakeGame.Players.forEach(player => player.Move()); let data = new SnakeGame_Data(); data.Players = SnakeGame.Players; data.Foods = SnakeGame.Foods; data.HighRankigPlayers = highRankigPlayers; // この部分追加 snakeGame.emit('server_to_client_objects', {value : data}); } } |
クライアント側でランキング情報の表示する
クライアント側でserver_to_client_objectsイベントによってデータを受信したときに新しく作成したDrawRankig関数でランキング情報を表示させます。
DrawRankig関数に渡す引数は、第一引数は現在プレイ中のプレーヤー全部、第二引数はプレイ中ではないものも含めた上位10名です。ただしランキングがつねに画面右側に表示されるのはうっとうしいかもしれないのでチェックボックスで表示と非表示を変えることができるようにします。
snake-game/snake-game.js
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 |
// 最後に2行追加するだけ snake_game.on("server_to_client_objects", function(data){ let dataFromServer = data.value; con.fillStyle = "#008000"; con.fillRect(0, 0, can.width, can.height); let player = dataFromServer.Players.find(player => player.ID == id); if(player != null){ translationX = can.width/2 - player.PosX; translationY = can.height/2 - player.PosY; } con.fillStyle = "#000000"; con.fillRect( dataFromServer.Field.MinPosX + translationX, dataFromServer.Field.MinPosY + translationY, dataFromServer.Field.MaxPosX - dataFromServer.Field.MinPosX, dataFromServer.Field.MaxPosY - dataFromServer.Field.MinPosY); dataFromServer.Players.forEach(snake => { DrawSnake(snake); }); dataFromServer.Foods.forEach(food => { DrawFood(food); }); DrawPlayerStatus(player); // これを追加 if(document.getElementById('ranking').checked) DrawRankig(dataFromServer.Players, dataFromServer.HighRankigPlayers); }); |
ランキング情報は現在プレイ中の上位7名、ゲームオーバーになった人も含めて上位10名を表示させます。
DrawRankig関数ですが、以下のようなものになります。
snake-game/snake-game.js
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 |
function DrawRankig(players, highRankigPlayers){ // 現在プレイ中のプレーヤーを長さ順にソートする players.sort(function(a,b){ if(a.Length<b.Length) return 1; if(a.Length > b.Length) return -1; return 0; }); con.fillStyle = "#ffffff"; // 現在プレイ中の上位7名を表示 let y = 40; let x = 500; con.font = 'bold 12px "MS ゴシック"'; con.fillText('プレイ中の上位者', x, y); y += 30; for(let i=0; i<7; i++){ if(players.length > i) { let player = players[i]; con.font = '12px "MS ゴシック"'; con.fillText(`${player.PlayerName} : ${player.Length}`, x, y); } else con.fillText('------', x, y); y += 20; } y += 40; // 現在プレイ中でないものの含めて上位10名を表示 con.font = 'bold 12px "MS ゴシック"'; con.fillText('総合ランキング', x, y); y += 30; for(let i=0; i<10; i++){ con.font = '12px "MS ゴシック"'; if(highRankigPlayers.length > i) { let player = highRankigPlayers[i]; con.fillText(`${player.PlayerName} : ${player.Length}`, x, y); } else con.fillText('------', x, y); y += 20; } } |
あとプレーヤーの描画をするときに名前も表示させることにしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function DrawSnake(snake){ let len = snake.TrajectoryX.length; // プレーヤーの胴体を描画する処理 // プレーヤーの頭部を描画する処理 // プレーヤーの頭部の近くに名前を表示する let last = len - 1; con.fillStyle = "#ffffff"; con.font = 'bold 16px "MS ゴシック"'; con.fillText( snake.PlayerName, snake.TrajectoryX[last] + translationX, snake.TrajectoryY[last] + translationY, ); } |
ランキング情報をデータベースに保存する
これで一応、ランキングを表示させることができるのですが、メンテナンスでサーバーを停止した場合、現在プレイ中でないものを含む上位10名の情報は消えてしまいます。そこで消えてしまわないようにSQLiteに保存してサーバーが起動したら読み込んでデータが消えないようにします。
やるならSnakeGame_PlayerクラスのなかでCheckEatFood関数を実行した結果、highRankigPlayersの内容が変更されたときでしょうか? ただこれは頻繁におきるので、もっとデータベースにデータを保存する回数を減らしたいと考えたくなります。そこで誰かがゲームオーバーになったときにhighRankigPlayersの内容をSQLiteに保存することにします。
プレイヤーが死亡したら死亡したプレーヤーがいた位置に餌を配置する関数がSnakeGame_Playerクラスのなかにあるのですが、この関数の最後にhighRankigPlayersの内容をSQLiteに保存すればデータベースの更新作業の回数を減らせます。
app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// プレイヤー死亡後、プレーヤーがいた位置に餌を配置する関数 class SnakeGame_Player { ChangeNewFoods(){ for(let i=0; i<this.Length; i += 8) SnakeGame.Foods.push(new SnakeGame_Food(this.TrajectoryX[i], this.TrajectoryY[i])); // ここでhighRankigPlayersの内容をSQLiteに保存する this.SaveHighPlayersToSQLite(); } SaveHighPlayersToSQLite(){ // どうすればよいか? } } |
上位プレーヤーのデータを読み出す
最初にアプリケーションが開始されたらSQLiteに保存されている上位プレーヤーのデータを読み出して配列 highRankigPlayersに読み込む処理を示します。
まずNode Package Managerでsqlite3をインストールします。
1 |
npm install sqlite3 |
app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
const sqlite3 = require("sqlite3"); class SnakeGame { constructor(socket_io){ let snakeGame = socket_io.of('/snake-game').on('connection', function (socket){ socket.on('client_to_server_first', (data) => SnakeGame.ClientToServerFirst(snakeGame, socket, data)); socket.on('client_to_server_keydown', (data) => SnakeGame.ClientToServerKeydown(snakeGame, socket, data)); socket.on('client_to_server_keyup', (data) => SnakeGame.ClientToServerKeyup(snakeGame, socket, data)); socket.on('disconnect', () => SnakeGame.Disconnect(snakeGame, socket)); }); setInterval(() => SnakeGame.Interval(snakeGame), 1000/60); this.InitFoods(); // これを追加 this.LoadHiscorePlayers(); } } |
追加したLoadHiscorePlayers関数を示します。最初にアプリケーションを開始したときにはテーブルがつくられていないので作ります。テーブル名はtable_highscoresにします。次にデータが保存されていたら読み出します。このときdb.serialize(() => ・・・})とやらないとテーブルが作成されるまえにデータの読み込みが開始されるというエラーになります。db.serialize(() => ・・・})のなかでテーブルを作成した後にデータを読み込みの処理がおこなわれるようにします。もっともテーブル作成処理がおこなわれたときはデータは存在しませんが・・・。
db.all(“select * from table_highscores”, callback)のなかでデータの読み出しと読み出したデータのソート、上位10名のプレーヤーを 配列 highRankigPlayersに格納する処理をしています。これらはコールバック関数のなかでやらないとうまくいきません。
app.js
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 |
class SnakeGame { LoadHiscorePlayers(){ const db = new sqlite3.Database("./snakegame-highscores.sqlite3"); db.serialize(() => { // 最初はテーブルがつくられていないので作る db.run(`create table if not exists table_highscores( id integer primary key autoincrement, player_name text, score integer, createtime datetime)`); // データを読み込む highRankigPlayers = []; // 空になっているはずなのだが・・・ let newhighRankigPlayers = []; db.all("select * from table_highscores", (error, rows) => { rows.forEach(player => { let hiScorePlayer = new SnakeGame_Player(0, 0); hiScorePlayer.Dead = true; hiScorePlayer.Length = player.score; hiScorePlayer.PlayerName = player.player_name; newhighRankigPlayers.push(hiScorePlayer); }); // ソートされていないかもしれないのでソートする newhighRankigPlayers.sort(function(a,b){ if(a.Length<b.Length) return 1; if(a.Length > b.Length) return -1; return 0; }); // 上位10名のプレーヤーを 配列 highRankigPlayersに格納する for(let i=0; i<10; i++){ if(newhighRankigPlayers.length > i){ highRankigPlayers.push(newhighRankigPlayers[i]); } } }); // コールバック関数の外でデータのソートをしようとしても // SQLiteからデータが読み込まれる前に処理が終わってしまうので意味をなさない db.close(); }); } } |
ランキング情報を保存するタイミング
次にプレーヤーが死亡したときに配列highRankigPlayersに格納されているデータをSQLiteに保存する処理を考えます。
最初にhighscores.dbに格納されているデータを削除して、そのあと配列highRankigPlayers内に格納されている上位プレーヤーのデータを保存します。
app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class SnakeGame_Player { SaveHighPlayersToSQLite(){ const db = new sqlite3.Database("./snakegame-highscores.sqlite3"); db.serialize(() => { db.run("delete from table_highscores"); db.run('begin transaction transaction1'); for(let i=0; i < highRankigPlayers.length; i++){ let name = highRankigPlayers[i].PlayerName; let score = highRankigPlayers[i].Length; db.run(`insert into table_highscores(player_name, score) values('${name}',${score})`); } db.run('commit transaction transaction1'); db.close(); }); } } |