Slither.io (スリザリオ)のようなオンラインゲームを作りたい(5)の続きです。今回はクライアントサイドの処理を実装します。
Contents
cshtmlファイルとCSSについて
Index.cshtmlとCSSを示します。
Index.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 |
@page @{ Layout = null; } <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>鳩でもわかるスネークゲーム - 鳩でもわかるASP.NET Core</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/6.0.1/signalr.js"></script> <link rel="stylesheet" type="text/css" href="/snake-game/style.css"> </head> <body> <div id = "container"> <p id="conect-result"></p> <div id="field"> <div> <canvas id="canvas"></canvas> </div> <div> <div id="map"><canvas id="map-canvas"></canvas></div> <div id="info"></div> </div> <button id="start" class="button">スタート</button> <button id="left" class="button">左旋回</button> <button id="right" class="button">右旋回</button> <button id="dash" class="button">ダッシュ</button> </div><!--/#field--> <div id="ranking"></div> <p>PCは←→で左右の旋回ができます。</p> <div id="player-name-outer"> <label>ハンドルネーム</label> <input type="text" id="player-name" maxlength='16' /> <p><a href="./hi-score">トップ30を見る</a></p> </div> <div id="volume-controller"></div> </div><!--/#container--> <script> let connection = new signalR.HubConnectionBuilder().withUrl("@baseurl/snake-game-hub").build(); </script> <script src="/snake-game/app.js" charset="utf-8"></script> </body> </html> |
style.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 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 |
@charset "utf-8"; body { background-color: #000; color: #fff; font-family: "MS ゴシック"; line-height: 1.5; } #container { width: 360px; margin: 0 auto 0 auto; } #field { position: relative; overflow: hidden; } #canvas { display:block; } #map { float: left; width: 120px; } #info { float: right; width: 220px; } .display-none { display: none; } #start { left: 100px; top: 230px; } #left, #right { top: 250px; } #left { left: 10px; display: none; } #right { left: 190px; display: none; } #dash { top: 330px; left: 100px; display: none; } .button { position: absolute; background-color: transparent; color: white; width: 160px; height: 70px; } #player-name-outer { margin-top: 20px; } #volume-controller { margin-top: 20px; margin-bottom: 20px; } #hide-buttons { margin-top: 20px; } |
グローバル変数と定数
グローバル変数と定数は以下のとおりです。
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 44 45 46 47 48 49 50 51 52 53 |
const windowWidth = window.outerWidth; const CANVAS_WIDTH = 360; const CANVAS_HEIGHT = 400; // DOM要素 const $info = document.getElementById('info'); const $playerNameOuter = document.getElementById('player-name-outer'); const $playerName = document.getElementById('player-name'); const $conectResult = document.getElementById('conect-result'); // ボタン const $start = document.getElementById('start'); const $left = document.getElementById('left'); const $right = document.getElementById('right'); const $dash = document.getElementById('dash'); // canvas とコンテキスト const $canvas = document.getElementById('canvas'); const ctx = $canvas.getContext('2d'); const $mapCanvas = document.getElementById('map-canvas'); const mapCtx = $mapCanvas.getContext('2d'); // 描画に使うイメージ const fillImages1 = []; const fillImages2 = []; const fillImages3 = []; // 自機を移動させるキーは押下されているかどうか? let isLeftKeyDown = false; let isRightKeyDown = false; let isUpKeyDown = false; let isSpaceKeyDown = false; let connectionID = ''; let isPreventDefault = false; // 矢印キーを押下したときデフォルトの動作を抑止するか? let isGameovered = true; let mapCircles = new Map(); // 描画すべきCircleオブジェクトを格納する辞書 let playerID = 0; // 自身のプレイヤーID let playerX = 0; // 自身の頭部が存在する座標 let playerY = 0; let playerLength = 0; // 自身の体長 let fieldRadius = 0; // 自身の太さ // 効果音 const bgm = new Audio('./sounds/bgm.mp3'); const soundMiss = new Audio('./sounds/miss.mp3'); const soundKill = new Audio('./sounds/kill.mp3'); const soundGameOver = new Audio('./sounds/gameover.mp3'); const sounds = [soundGameOver, soundKill, soundMiss, bgm]; let volume = 0.3; // ボリューム |
ページが読み込まれたときの処理
ページが読み込まれたときは以下の処理がおこなわれます。
テキストボックスに以前に使ったプレイヤー名があるならこれを設定する
描画に使うイメージの初期化
イベントハンドラの追加
ボリュームコントローラーの初期化
BGMをエンドレスで再生できるようにする
ASP.NET SignalRでサーバーに接続する
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 |
window.onload = () => { $conectResult.innerHTML = `接続しようとしています`; $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; // テキストボックスに以前に使ったプレイヤー名があるならこれを設定する const savedName = localStorage.getItem('hatodemowakaru-player-name'); if(savedName) $playerName.value = savedName; initImages(); // 描画に使うイメージの初期化 addEventListeners(); initVolumeController('volume-controller', sounds); const $hideButtons = document.getElementById('hide-buttons'); setInterval(() => { if(bgm.currentTime >= 100) bgm.currentTime = 0; }, 500); connection.start().catch((err) => { document.getElementById("conect-result").innerHTML = '接続失敗'; }); } |
これは画像ファイルを読み込んで描画用のイメージを配列に格納する処理です。
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 initImages(){ const imagefiles1 = [ './images/fillf00.png', './images/fill0f0.png', './images/fillf0f.png', './images/fill0ff.png', ]; const imagefiles2 = [ './images/fillfff.png', ]; const imagefiles3 = [ './images/food_f00.png', './images/food_0f0.png', './images/food_ff0.png', './images/food_0ff.png', './images/food_f0f.png', ]; imagefiles1.forEach(_ => { const image = new Image(); image.src = _; fillImages1.push(image); }); imagefiles2.forEach(_ => { const image = new Image(); image.src = _; fillImages2.push(image); }); imagefiles3.forEach(_ => { const image = new Image(); image.src = _; fillImages3.push(image); }); } |
イベントリスナを追加する処理を示します。ここではスタートボタンを押下したとき、PCのキー操作、操作用のボタンを操作したときの処理を追加しています。
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 |
function addEventListeners(){ $start.addEventListener('click', (ev) => gameStart()); // gameStart関数は後述 const arr1 = ['mousedown', 'touchstart', 'mouseup', 'touchend']; const arr2 = [true, true, false, false]; for (let i = 0; i < 4; i++) { $left?.addEventListener(arr1[i], (ev) => { connection.invoke('TurnLeft', arr2[i]); }); $right?.addEventListener(arr1[i], (ev) => { connection.invoke('TurnRight', arr2[i]); }); $dash?.addEventListener(arr1[i], (ev) => { connection.invoke('Dash', arr2[i]); }); } document.addEventListener('keydown', (ev) => { if (isPlaying) { if (ev.code == 'ArrowLeft' || ev.code == 'ArrowRight' || ev.code == 'ArrowUp' || ev.code == 'ArrowDown' || ev.code == 'Space') ev.preventDefault(); } if (ev.code == 'ArrowLeft' && !isLeftKeyDown) { isLeftKeyDown = true; connection.invoke("TurnLeft", true); } if (ev.code == 'ArrowRight' && !isRightKeyDown) { isRightKeyDown = true; connection.invoke("TurnRight", true); } if (ev.code == 'ArrowUp' && !isUpKeyDown) { isUpKeyDown = true; connection.invoke("Dash", true); } if (ev.code == 'Space' && !isSpaceKeyDown) { isSpaceKeyDown = true; connection.invoke("Dash", true); } }); document.addEventListener('keyup', (ev) => { if (ev.code == 'ArrowLeft') { connection.invoke("TurnLeft", false); isLeftKeyDown = false; } if (ev.code == 'ArrowRight') { connection.invoke("TurnRight", false); isRightKeyDown = false; } if (ev.code == 'ArrowUp') { connection.invoke("Dash", false); isUpKeyDown = false; } if (ev.code == 'Space') { connection.invoke("Dash", false); isSpaceKeyDown = 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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
function initVolumeController(elementId, sounds){ const savedVolume = localStorage.getItem('hatodemowakaru-volume'); if(savedVolume) volume = Number(savedVolume); const $element = document.getElementById(elementId); const $div = document.createElement('div'); const $span1 = document.createElement('span'); $span1.innerHTML = '音量'; $div?.appendChild($span1); const $range = document.createElement('input'); $range.type = 'range'; $div?.appendChild($range); const $span2 = document.createElement('span'); $div?.appendChild($span2); $range.addEventListener('input', () => { const value = $range.value; $span2.innerText = value; volume = value / 100; setVolume(); }); $range.addEventListener('change', () => localStorage.setItem('hatodemowakaru-volume', volume.toString())); setVolume(); $span2.innerText = Math.round(volume * 100); $span2.style.marginLeft = '16px'; $range.value = volume * 100; $range.style.width = '250px'; $range.style.verticalAlign = 'middle'; $element?.appendChild($div); const $button = document.createElement('button'); $button.innerHTML = '音量テスト'; $button.style.width = '120px'; $button.style.height = '45px'; $button.style.marginTop = '12px'; $button.style.marginLeft = '10px'; $button.addEventListener('click', () => { sounds[0].currentTime = 0; sounds[0].play(); }); $element?.appendChild($button); function setVolume(){ for(let i = 0; i < sounds.length; i++) sounds[i].volume = volume; } } |
接続成功時の処理
サーバーに接続できた場合、ASP.NET SignalRで使われる一意の接続IDとフィールドの半径が送信されるので、これをグローバル変数に保存しておきます。
1 2 3 4 5 6 |
connection.on("SendToClientConnectionSuccessful", (id, radius) => { connectionID = id; $conectResult.innerHTML = `接続完了`; fieldRadius = radius; }); |
ゲーム開始の処理
ゲーム開始時におこなわれる処理を示します。テキストボックスに入力されている文字列をローカルストレージに保存するとともにサーバーサイドのGameStartメソッドを呼び出します。
1 2 3 4 5 6 7 8 |
function gameStart(){ let playerName = $playerName.value; if (playerName == '') playerName = '名無しさん'; localStorage.setItem('hatodemowakaru-player-name', playerName); connection.invoke("GameStart", playerName); } |
上記の処理が成功したらサーバーサイドからSendToClientGameStartSuccessfulイベントが送信されます。このイベントを受信したときは以下のような処理がおこなわれます。
isGameoveredフラグのクリア
矢印キーを押下したときのデフォルトの動作を抑止するisPreventDefaultフラグのセット
ゲーム開始ボタンとプレーヤー名入力用のテキストボックスの非表示
ディスプレイ幅が620ピクセル以下のときは自機操作用のボタンの表示
BGMの再生開始
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
connection.on("SendToClientGameStartSuccessful", () => { mapCircles = new Map(); isPreventDefault = true; isGameovered = false; $start.style.display = 'none'; $playerNameOuter.style.display = 'none'; if(windowWidth <= 620){ $left.style.display = 'block'; $right.style.display = 'block'; $dash.style.display = 'block'; } bgm.currentTime = 0; bgm.play(); $conectResult.innerHTML = `PLAY`; }); |
各イベント時の処理
SendToClientUpdateFieldStatusとともにプレイヤーやNPC、餌の総数が送られてくるので、これをゲーム画面の下部に表示させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
connection.on("SendToClientUpdateFieldStatus", (fieldStatusText) => { if(fieldStatusText == '') return; const arr = fieldStatusText.split(','); const playerCount = Number(arr[0]); const npcCount = Number(arr[1]); const foodCount = Number(arr[2]); const circlesCount = Number(arr[3]); let text = `X = ${playerX.toLocaleString()} / ${(fieldRadius * 2).toLocaleString()}<br>Y = ${playerY.toLocaleString()} / ${(fieldRadius * 2).toLocaleString()}<br>`; text += `Player = ${playerCount}, NPC = ${npcCount}<br>Food = ${foodCount.toLocaleString()}<br>CirclesCount = ${circlesCount}`; $info.innerHTML = `${text}`; }); |
SendToClientUpdateMyStatusとともに自機の状態が送られてくるので、これをグローバル変数に格納しておきます。これはプレイヤーを描画するときに必要な情報です。
1 2 3 4 5 6 7 8 9 |
connection.on("SendToClientUpdateMyStatus", (playerText) => { if (playerText != '') { const arr = playerText.split(','); playerID = arr[0]; playerLength = arr[1]; playerX = arr[2]; playerY = arr[3]; } }); |
他のプレイヤーを倒したときはSendToClientKillPlayerイベントが送信されるので効果音を鳴らします。
1 2 3 4 |
connection.on("SendToClientKillPlayer", () => { soundKill.currentTime = 0; soundKill.play(); }); |
なんらかの原因で通信が切れてしまったときは通信が切断された旨を表示します。
1 2 3 4 5 6 |
connection.onclose( () => { connectionID = ''; if($conectResult != null){ $conectResult.innerHTML = '通信が切断されました'; } }); |
描画処理
描画にかんする処理を示します。
まずcanvas上に大量の円を描画するためCircleクラスを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Circle { constructor(){ this.ID = 0; // 一意のID this.X = 0; // 座標 this.Y = 0; this.Radius = 0; // 半径 this.PlayerID = 0; // プレイヤーのID this.NumberFromHead = 0; // 頭から何番目か? this.PlayerLength = 0; // 長さ this.KillCount = 0; // 倒した他のプレイヤーの数 this.PlayerName = ''; // プレイヤー名 } } |
サーバーサイドからSendToClientUpdateCirclesイベントが送信されてきたらcanvasに新しく追加する円と消去する円を調べてmapCircles辞書内に格納されているCiecleオブジェクトを更新します。
removeCirclesTextは削除するCiecleオブジェクトのIDがカンマ区切りで格納されています。addCirclesTextは追加するCiecleオブジェクトの情報が改行文字とタブ文字区切りで格納されています。これを配列に分解してmapCircles辞書を更新します。
また円が追加されることによってそのCiecleオブジェクトが各プレイヤーの先頭から何番目なのかが変わります。なので削除すべきオブジェクトを削除したらそのあとでCircle.NumberFromHeadをインクリメントしています。
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 |
connection.on("SendToClientUpdateCircles", (addCirclesText, removeCirclesText) => { if (removeCirclesText != ''){ const arr = removeCirclesText.split(','); for (let i = 0; i < arr.length; i++) { const key = Number(arr[i]); mapCircles.delete(key); } } for (let circle of mapCircles.values()) circle.NumberFromHead++; if (addCirclesText != ''){ const arr1 = addCirclesText.split('\n'); for (let i = 0; i < arr1.length; i++) { const text = arr1[i]; const arr2 = text.split('\t'); const key = Number(arr2[0]); if (!mapCircles.has(key)) { const circle = new Circle(); circle.ID = Number(arr2[0]), circle.X = Number(arr2[2]), circle.Y = Number(arr2[3]), // 餌ではなくプレイヤーの身体の一部なら半径やPlayerIDに関する情報があるはず circle.Radius = arr2[4] != undefined ? Number(arr2[4]) : 2, circle.PlayerID = arr2[5] != undefined ?Number(arr2[5]) : -1, circle.NumberFromHead = arr2[6] != undefined ? Number(arr2[6]) : -1, circle.PlayerLength = arr2[7] != undefined ? Number(arr2[7]) : -1, // プレイヤーの頭部であれば倒したプレイヤーの数やプレイヤー名が存在するはず circle.KillCount = arr2[8] != undefined ? Number(arr2[8]) : -1, circle.PlayerName = arr2[9] != undefined ? arr2[9] : '*', mapCircles.set(key, circle); } else { // arr2[1] == 'true' ならオブジェクトの座標が更新されたことになるので更新する if (arr2[1] == 'true') { const circle = mapCircles.get(key); circle.X = Number(arr2[2]); circle.Y = Number(arr2[3]); } } } } draw(); // 辞書の内容で描画処理をおこなう }); |
辞書の内容で描画処理をおこなう処理をしめします。
自機を中央に描画するために全体の座標をどれだけずらすのかを計算する
フィールドを表す円を描画する
circleオブジェクトを辞書から取り出して生成順にソートする
circleオブジェクトが餌なのかプレイヤーの頭なのかそれ以外の部分なのかを調べて描画する
ただし頭は最後にまとめて描画し、近くにプレイヤー名と体長などの情報も表示する
circleオブジェクトを生成順にソートするのは餌でプレイヤーが上書きされないようにするためです。
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 |
function draw() { ctx.fillStyle = '#000'; ctx?.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); let shiftX = playerX - CANVAS_WIDTH / 2; let shiftY = playerY - CANVAS_HEIGHT / 2; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(fieldRadius - shiftX, fieldRadius - shiftY, fieldRadius, 0, Math.PI * 2); ctx.stroke(); const circles = []; for (let circle of mapCircles.values()) circles.push(circle); circles.sort((a, b) => a.ID - b.ID); const heads = []; circles.forEach(_ => { let isHead = _.PlayerID >= 0 && _.NumberFromHead == 0; let isBody = _.PlayerID >= 0 && _.NumberFromHead > 0; let isFood = _.PlayerID < 0; const x = _.X - shiftX; const y = _.Y - shiftY; const radius = isHead || isBody ? _.Radius : 2; if (x > -10 && x < CANVAS_WIDTH + 10 && y > -10 && y < CANVAS_HEIGHT + 10) { if (isFood) { let idx = _.ID % fillImages3.length; ctx.drawImage(fillImages3[idx], x - 16, y - 16, 32, 32); drawCount++; } else if (isBody) { if (_.PlayerLength > 30) { if (_.NumberFromHead % 2 == 0) { if (_.NumberFromHead % 8 == 0 || _.NumberFromHead % 8 == 6) { let idx = _.PlayerID % fillImages1.length; ctx.drawImage(fillImages1[idx], x - radius, y - radius, radius * 2, radius * 2); } else { ctx.drawImage(fillImages2[0], x - radius, y - radius, radius * 2, radius * 2); } } } else { if (_.NumberFromHead % 4 == 0 || _.NumberFromHead % 4 == 3) { let idx = _.PlayerID % fillImages1.length; ctx.drawImage(fillImages1[idx], x - radius, y - radius, radius * 2, radius * 2); } else { let idx = _.PlayerID % fillImages2.length; ctx.drawImage(fillImages2[0], x - radius, y - radius, radius * 2, radius * 2); } } } else if (isHead) { let idx = _.PlayerID % fillImages1.length; const head = { x: x, y: y, radius: radius, image: fillImages1[idx], playerName: _.PlayerName, playerID: _.PlayerID, length: _.PlayerLength, killCount: _.KillCount, }; heads.push(head); } } }); heads.forEach(_ => { ctx.drawImage(_.image, _.x - _.radius - 0.5, _.y - _.radius - 0.5, _.radius * 2 + 1, _.radius * 2 + 1); ctx.fillStyle = '#fff'; ctx.font = '16px Arial bold'; ctx.textBaseline = 'top'; if (_.playerID == playerID) { ctx.fillText(_.playerName, CANVAS_WIDTH / 2 + 10, CANVAS_HEIGHT / 2 + 10); ctx.fillText(`Length ${_.length}`, CANVAS_WIDTH / 2 + 10, CANVAS_HEIGHT / 2 + 30); ctx.fillText(`Kill ${_.killCount}`, CANVAS_WIDTH / 2 + 10, CANVAS_HEIGHT / 2 + 50); } else { ctx.fillText(_.playerName, _.x + 10, _.y + 10); ctx.fillText(`Length ${_.length}`, _.x + 10, _.y + 30); ctx.fillText(`Kill ${_.killCount}`, _.x + 10, _.y + 50); } }); } |
他のプレイヤーの状態とレーダーの描画
他のプレイヤーがどこにいるのかがわかるように座標と体長を表示させます。また左下部分にレーダーを描画して視覚的にわかるようにします。
まずプレイヤーの情報を操作するためにPlayerクラスを定義します。
1 2 3 4 5 6 7 8 9 |
class Player { constructor(id, name, score, x, y){ this.ID = id; this.Name = name; this.Score = score; this.X = x; this.Y = y; } } |
SendToClientUpdatePlayersStatusイベントとともに文字列が送られてくるのでここから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 |
connection.on("SendToClientUpdatePlayersStatus", (playersStatusText) => { const players = []; if(playersStatusText != ''){ const arr = playersStatusText.split('\n'); arr.forEach(_ => { const arr2 = _.split('\t'); players.push(new Player(Number(arr2[0]), arr2[1], Number(arr2[2]), Number(arr2[3]), Number(arr2[4]))); }); } players.sort((a, b) => b.Score - a.Score); let rankingTableHTML = ''; rankingTableHTML += '<table>'; rankingTableHTML += '<tr>'; rankingTableHTML += '<td width="30"></td>'; rankingTableHTML += '<td width="150">Player Name</td>'; rankingTableHTML += '<td>(X, Y, Length)</td>'; rankingTableHTML += '</tr>'; let rank = 0; players.forEach(_ => { rank++; rankingTableHTML += '<tr>'; rankingTableHTML += `<td>${rank}</td>`; rankingTableHTML += `<td>${_.Name}</td>`; rankingTableHTML += `<td>(${_.X}, ${_.Y}, ${_.Score})</td>`; rankingTableHTML += '</tr>'; }); rankingTableHTML += '</table>'; document.getElementById('ranking').innerHTML = rankingTableHTML; mapCtx.fillStyle = '#000'; mapCtx.fillRect(0, 0, 100, 100); mapCtx.beginPath(); mapCtx.arc(50, 50, 50, 0, Math.PI * 2); mapCtx.fill(); mapCtx.strokeStyle = '#888'; mapCtx.stroke(); players.forEach(_ => { const x = _.X / (fieldRadius * 2) * 100; const y = _.Y / (fieldRadius * 2) * 100; if(_.ID == playerID) mapCtx.fillStyle = '#f00'; else mapCtx.fillStyle = '#fff'; mapCtx.beginPath(); mapCtx.arc(x, y, 3, 0, Math.PI * 2); mapCtx.fill(); }); }); |
ゲームオーバー時の処理
ゲームオーバー時にはSendToClientGameOveredが送信されます。
このときは以下の処理をおこないます。
サーバーサイドへの旋回、ダッシュの取り消しを送信する
操作用ボタンが押下されているかどうかを示すフラグのクリア
BGMの停止
ミスを示す効果音の再生
またその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 |
connection.on("SendToClientGameOvered", () => { isGameovered = true; $left.style.display = 'none'; $right.style.display = 'none'; $dash.style.display = 'none'; connection.invoke("TurnLeft", false); connection.invoke("TurnRight", false); connection.invoke("Dash", false); isLeftKeyDown = false; isRightKeyDown = false; isUpKeyDown = false; isSpaceKeyDown = false; bgm.pause(); soundMiss.currentTime = 0; soundMiss.play(); $conectResult.innerHTML = `GAME OVER`; setTimeout(() => { isPreventDefault = false; $playerNameOuter.style.display = 'block'; $start.style.display = 'block'; bgm.pause(); soundGameOver.currentTime = 0; soundGameOver.play(); }, 2000); }); |
ランキングページの表示
最後にランキングページを表示させる処理を示します。
Gameクラスの定義 Slither.ioもどきを作る(4)で定義したScoreRankingクラスのLoadメソッドでHiscoreのリストを取得して長さとKill数でソートした結果をそれぞれ表示させるだけです。
hi-score.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 |
@page @{ Layout = null; } <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>スコアランキング</title> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" type="text/css" href="/snake-game/ranking-style.css"> </head> <body> <div id = "container"> <h1>スコアランキング</h1> <input type="button" id = "back" onclick="location = './'" value="戻る"> <h2>Length 順</h2> <table class="ranking-table"> <thead> <tr><td scope="col" rowspan="2"></td><td scope="col" colspan="3">Player Name</td></tr> <tr><td scope="col">Length - Kill</td><td scope="col">Date</td></tr> </thead> <tbody> @{ int num = 0; List<SnakeGame.Hiscore> hiscores = SnakeGame.ScoreRanking.Load(); List<SnakeGame.Hiscore> hiscores1 = hiscores.OrderByDescending(_ => _.Score).ThenByDescending(_ => _.KillCount).ToList(); hiscores1 = hiscores1.Take(20).ToList(); } @foreach (SnakeGame.Hiscore hiscore in hiscores1) { num++; string score = $"{hiscore.Score:#,0}"; <tr><td scope="col" rowspan="2">@num</td><td scope="col" colspan="3">@hiscore.Name</td></tr> <tr><td scope="col">@score - @hiscore.KillCount</td><td scope="col">@hiscore.Date</td></tr> } </tbody> </table> <h2>Kill数 順</h2> <table class="ranking-table"> <thead> <tr><td scope="col" rowspan="2"></td><td scope="col" colspan="3">Player Name</td></tr> <tr><td scope="col">Kill - Length</td><td scope="col">Date</td></tr> </thead> <tbody> @{ num = 0; List<SnakeGame.Hiscore> hiscores2 = hiscores.OrderByDescending(_ => _.KillCount).ThenByDescending(_ => _.Score).ToList(); hiscores2 = hiscores2.Take(20).ToList(); } @foreach (SnakeGame.Hiscore hiscore in hiscores2) { num++; string score = $"{hiscore.Score:#,0}"; <tr><td scope="col" rowspan="2">@num</td><td scope="col" colspan="3">@hiscore.Name</td></tr> <tr><td scope="col">@hiscore.KillCount - @score</td><td scope="col">@hiscore.Date</td></tr> } </tbody> </table> </div> </body> </html> |
ranking-style.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 |
@charset "utf-8"; body { background-color: #000; color: #fff; font-family: "MS ゴシック"; line-height: 1.5; } #container { width: 360px; margin: 0 auto 0 auto; padding-top: 20px } h1 { font-size: 20px; } h2 { font-size: 16px; } #back { width: 100px; height: 50px; margin-bottom: 20px } td { border: 1px solid rgb(160 160 160); padding: 8px 10px; text-align: center; } .ranking-table { margin-bottom: 30px; width: 100%; } |