オンライン対戦型のスネークゲームをつくる(2)の続きです。今回はクライアントサイドの処理を実装します。
cshtml部分
cshtmlファイルに記述する部分を示します。
Pages\chama-ther-io\game.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 |
@page @{ string baseurl = "https://lets-csharp.com/samples/2204/aspnetcore-app-zero"; // アプリを公開するurl Layout = null; string time = DateTime.Now.Ticks.ToString(); } <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>鳩でもわかるオンラインスネークゲーム</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/6.0.1/signalr.js"></script> <link rel = "stylesheet" type = "text/css" href = "@baseurl/chama-ther-io/style.css?@time"> </head> <body> <div id = "container"> <div id = "field"> <div><canvas id="canvas"></canvas></div> <p> 音量: <input type="range" id="volume" min="0" max="1" step="0.01"> <span id="vol_range"></span> <button onclick="playSound()">音量テスト</button> </p> <button id="start" class="button">スタート</button> <button id="up" class="button">UP</button> <button id="left" class="button">Left</button> <button id="right" class="button">Right</button> <button id="down" class="button">DOWN</button> <label>ハンドルネーム</label> <input type="text" id="player-name" maxlength='16' /> <p id = "conect-result"></p> </div><!--/#field--> </div><!--/#container--> <script> let connection = new signalR.HubConnectionBuilder().withUrl("@baseurl/ChamatherioHub").build(); </script> <script src = "@baseurl/chama-ther-io/app.js?@time"></script> </body> </html> |
cssを示します。
wwwroot\chama-ther-io\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 |
body { background-color: #000; color: #fff; font-family: "MS ゴシック"; } #container { width: 360px; } #field { position: relative; overflow: hidden; } .display-none { display: none; } #start { left: 110px; top: 330px; } #up { top: 260px; left: 110px; } #left { top: 330px; left: 0px; } #right { top: 330px; left: 220px; } #down { top: 400px; left: 110px; } .button { position: absolute; background-color: transparent; color: white; width: 130px; height: 60px; } #conect-result{ position: absolute; color: white; left: 10px; top: 400px; } a { font-weight: bold; color: #0ff; } a:hover { color: #f00; } |
その他、キャラクターの描画や効果音に使う音声は wwwroot\chama-ther-io\imagesフォルダとwwwroot\chama-ther-io\soundsフォルダにいれてあります。
JavaScript部分
定数と主なグローバル変数を示します。
wwwroot\chama-ther-io\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 |
// canvasの大きさ const CANVAS_WIDTH = 360; const CANVAS_HEIGHT = 560; // キャラクタの大きさ const CHARACTER_SIZE = 32; // ボタン const $up = document.getElementById('up'); const $down = document.getElementById('down'); const $left = document.getElementById('left'); const $right = document.getElementById('right'); const $start = document.getElementById('start'); // 効果音 const soundGetScore = new Audio('./sounds/get.wav?' + time); const soundKill = new Audio('./sounds/kill.mp3?' + time); const soundLostScore = new Audio('./sounds/lost.wav?' + time); const soundDead1 = new Audio('./sounds/dead1.wav?' + time); const soundDead2 = new Audio('./sounds/dead2.wav?' + time); const soundGameOver = new Audio('./sounds/gameover.mp3?' + time); // ステータスの表示(デバッグ用) const $conectResult = document.getElementById('conect-result'); // 描画用のImageを格納する配列 const playerImages = []; const foodImages = []; // プレイヤーの胴体部分の色 const colors1 = ['#0f0', '#f00', '#00f', '#ff0', '#f0f', '#0ff', '#0f0', '#f00', '#00f']; const colors2 = ['#080', '#800', '#008', '#880', '#808', '#088', '#080', '#800', '#008']; // 描画用 const $canvas = document.getElementById('canvas'); const ctx = $canvas.getContext('2d'); // フィールド全体の大きさ(境界壁の描画で必要) let fieldSize = 0; // 接続ID let connectionID = ''; |
初期化
ページを読み込んだときの処理を示します。
プレイヤー操作用のボタンはゲームが開始されるまで意味をなさないので非表示とします。描画で用いるimageの初期化し、イベントリスナーを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
window.onload = () => { $up.style.display = 'none'; $down.style.display = 'none'; $left.style.display = 'none'; $right.style.display = 'none'; $start.style.display = 'block'; initImages(); // 描画で用いるimageの初期化(後述) addEventListeners(); // イベントリスナーの追加(後述) $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; initVolumes(0.5); // 効果音のボリュームを調整できるようにする(後述) // ASP.NET SignalR で接続する connection.start().catch((err) => { document.getElementById("conect-result").innerHTML = '接続失敗'; }); } |
initImages関数は描画で用いるimageを配列に格納します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function initImages(){ const arr = ['./images/ebi.png', './images/ninjin.png', './images/unko.png']; for(let i = 0; i < arr.length; i++){ const $image = new Image(); $image.src = arr[i] + '?' + time; foodImages.push($image); } for(let i = 0; i < 9; i++){ const $image = new Image(); $image.src = `./images/${i}.png?${time}`; playerImages.push($image); } } |
initVolumes関数は効果音のボリューム調整を可能にする処理をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
function initVolumes(initValue){ const $elemVolume = document.getElementById("volume"); const $elemRange = document.getElementById("vol_range"); $elemVolume.addEventListener('change', () => setVolume($elemVolume.value)); setVolume(initValue); function setVolume(value){ $elemVolume.value = value; $elemRange.textContent = value; soundGetScore.volume = value; soundLostScore.volume = value; soundKill.volume = value; soundDead1.volume = value; soundDead2.volume = value; soundGameOver.volume = value; } } // 効果音のテスト時に実行される function playSound(){ soundKill.currentTime = 0; soundKill.play(); } |
イベントリスナー追加の処理を示します。ボタンをクリックしたり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 |
function addEventListeners(){ $up.addEventListener('mousedown', (ev) => touchButton(ev, 'up')); $down.addEventListener('mousedown', (ev) => touchButton(ev, 'down')); $left.addEventListener('mousedown', (ev) => touchButton(ev, 'left')); $right.addEventListener('mousedown', (ev) => touchButton(ev, 'right')); $up.addEventListener('touchstart', (ev) => touchButton(ev, 'up')); $down.addEventListener('touchstart', (ev) => touchButton(ev, 'down')); $left.addEventListener('touchstart', (ev) => touchButton(ev, 'left')); $right.addEventListener('touchstart', (ev) => touchButton(ev, 'right')); $start.addEventListener('click', (ev) => gameStart(ev)); // PCのキー操作にも対応させる document.addEventListener('keydown', (ev) => { if(ev.code == 'ArrowUp') touchButton(ev, 'up'); if(ev.code == 'ArrowDown') touchButton(ev, 'down'); if(ev.code == 'ArrowLeft') touchButton(ev, 'left'); if(ev.code == 'ArrowRight') touchButton(ev, 'right'); }); // プレイヤー方向転換 function touchButton(ev, button){ // $start.style.display == 'none'であればプレイ中なのでデフォルトの動作を抑止する if($start.style.display == 'none') ev.preventDefault(); connection.invoke("ChangeDirect", button); } } |
ASP.NET SignalR の接続に成功したときの処理を示します。サーバーサイドから”SendToClientConnectionSuccessful”が送信され同時にフィールド全体の大きさと接続IDが送られてくるのでグローバル変数に保存しておきます。
1 2 3 4 5 6 |
connection.on("SendToClientConnectionSuccessful", (id, size) => { connectionID = id; $conectResult.innerHTML = `接続成功:${id}`; fieldSize = Number(size); }); |
通信が切断されたときの処理を示します。
1 2 3 4 |
connection.onclose( () => { connectionID = ''; $conectResult.innerHTML = '通信が切断されました'; }); |
ゲーム開始時の処理
ゲーム開始時の処理を示します。入力されているプレイヤー名をサーバーサイドに送ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function gameStart(ev){ ev.preventDefault(); // 接続されていない場合は処理をおこなわない if(connectionID == '') return; let playerName = document.getElementById('player-name').value; if (playerName == '') playerName = '名無しさん'; connection.invoke("GameStart", playerName).catch((err) => { return console.error(err.toString()); }); } |
ゲーム開始処理が正常に行なわれた場合は”SendToClientGameStartSuccessful”、ゲームに参加できなかった場合は”SendToClientGameStartFailure”がサーバーサイドから送られてくるので、ゲーム開始処理が正常に行なわれた場合はスタートボタンを非表示にしてプレイヤー操作用のボタンを表示させます。ゲームに参加できなかった場合はその旨を表示させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
connection.on("SendToClientGameStartSuccessful", () => { $up.style.display = 'block'; $down.style.display = 'block'; $left.style.display = 'block'; $right.style.display = 'block'; $start.style.display = 'none'; $conectResult.innerHTML = ''; }); connection.on("SendToClientGameStartFailure", () => { $conectResult.innerHTML = '定員オーバーなのでゲームに参加できません'; }); |
描画更新の処理
描画更新の処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 |
connection.on("SendToClientUpdate", (sendText1, sendText2, sendText3, sendText4) => { let position = getMyPosition(sendText3); let centerX = CANVAS_WIDTH / 2; let centerY = CANVAS_HEIGHT / 2; let shiftX = position.x - centerX; let shiftY = position.y - centerY; drawField(shiftX, shiftY); drawFood(sendText2, shiftX, shiftY); drawPlayer(sendText1, shiftX, shiftY); drawScore(sendText4); drawHighScores(sendText1); }); |
サーバーサイドから送られてきたカンマ区切りの文字列を分解してフィールド全体におけるプレイヤーの現在の座標を求めます。そのぶん全体を平行移動して描画すればプレイヤーが中央に表示されるようになります。
1 2 3 4 5 6 7 |
function getMyPosition(sendText){ const arr = sendText.split(','); return { x: Number(arr[0]), y: Number(arr[1]), }; } |
フィールドの壁を描画します。
1 2 3 4 5 6 7 |
function drawField(shiftX, shiftY){ ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); ctx.strokeStyle = '#fff'; ctx.strokeRect(- shiftX, - shiftY, fieldSize, fieldSize); } |
餌を描画します。サーバーサイドからタブ文字区切りで餌の情報が送られてくるので、これを分解して配列にします。さらに餌のX座標とY座標、種類がカンマ区切りで記されているので、これらの情報から餌の描画をおこないます。
餌同士が重なっている場合、食べると即死する餌が見えないと困るので、食べると即死する餌、食べると減点される餌、通常の餌の順に描画します。
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 |
function drawFood(sendText, shiftX, shiftY){ const arr1 = sendText.split('\t'); const ebiXs = []; // 子供が好きなエビフライ const ebiYs = []; const ninjunXs = []; // 子供が嫌いなニンジン const ninjunYs = []; const unkoXs = []; // ウンコw(これは食べ物ではない) const unkoYs = []; // 混在する餌を種類ごとにわける for(let i = 0; i < arr1.length; i++){ if(arr1[i] == '') continue; const arr2 = arr1[i].split(','); if(arr2[0] % 3 == 0){ ebiXs.push(arr2[1] - CHARACTER_SIZE / 2 - shiftX); ebiYs.push(arr2[2] - CHARACTER_SIZE / 2 - shiftY); } if(arr2[0] % 3 == 1){ ninjunXs.push(arr2[1] - CHARACTER_SIZE / 2 - shiftX); ninjunYs.push(arr2[2] - CHARACTER_SIZE / 2 - shiftY); } if(arr2[0] % 3 == 2){ unkoXs.push(arr2[1] - CHARACTER_SIZE / 2 - shiftX); unkoYs.push(arr2[2] - CHARACTER_SIZE / 2 - shiftY); } } // 食べると即死する餌、食べると減点される餌、通常の餌の順に描画 for(let i = 0; i < ebiXs.length; i++) ctx.drawImage(foodImages[0], ebiXs[i], ebiYs[i], CHARACTER_SIZE, CHARACTER_SIZE); for(let i = 0; i < ninjunXs.length; i++) ctx.drawImage(foodImages[1], ninjunXs[i], ninjunYs[i], CHARACTER_SIZE, CHARACTER_SIZE); for(let i = 0; i < unkoXs.length; i++) ctx.drawImage(foodImages[2], unkoXs[i], unkoYs[i], CHARACTER_SIZE, CHARACTER_SIZE); } |
プレイヤーを描画する処理を示します。
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 drawPlayer(sendText, shiftX, shiftY){ const arr1 = sendText.split('\t'); for(let i = 0; i < arr1.length; i++){ if(arr1[i] == '') continue; const arr2 = arr1[i].split(','); const xs = []; const ys = []; for(let i = 2; i < arr2.length - 1; i += 2){ xs.unshift(arr2[i] - shiftX); ys.unshift(arr2[i + 1] - shiftY); } // プレイヤーの胴体を描画(色を2色使う) let b = false; for(let k = 0; k < xs.length; k++){ b = !b; ctx.beginPath(); ctx.arc(xs[k], ys[k], CHARACTER_SIZE / 2, 0, 2 * Math.PI); if(b) ctx.fillStyle = colors1[i]; else ctx.fillStyle = colors2[i]; ctx.closePath(); ctx.fill(); } // プレイヤーの頭を描画 const image = playerImages[i % 7]; const characterSize = CHARACTER_SIZE + 16; ctx.drawImage(image, xs[xs.length - 1] - characterSize / 2, ys[xs.length - 1] - characterSize / 2, characterSize, characterSize); // プレイヤーの近くにプレイヤー名とスコアを描画(NPCの場合はスコアは描画しない) ctx.fillStyle = '#fff'; ctx.font = '16px Arial bold'; ctx.textBaseline = 'top'; ctx.fillText(arr2[0], Number(xs[xs.length - 1]) + 20, Number(ys[xs.length - 1]) + 10); if(arr2[1] != '-') ctx.fillText(arr2[1], Number(xs[xs.length - 1]) + 20, Number(ys[xs.length - 1]) + 30); } } |
ユーザーのスコアを描画します。
1 2 3 4 5 6 |
function drawScore(score){ ctx.fillStyle = '#fff'; ctx.font = '20px Arial bold'; ctx.textBaseline = 'top'; ctx.fillText('SCORE ' + score, 10, 10); } |
現在プレイ中の上位3名のスコアを描画します。
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 drawHighScores(sendText){ const arr1 = sendText.split('\t'); const scoreInfos = []; for(let i = 0; i < arr1.length; i++){ if(arr1[i] == '') continue; const arr2 = arr1[i].split(','); if(arr2[1] != '-'){ const scoreInfo = { name:arr2[0], score:arr2[1], }; scoreInfos.push(scoreInfo); } } // スコアで降順ソートする const result = scoreInfos.sort((a, b) => { return (a.score > b.score) ? -1 : 1; }); ctx.fillStyle = '#fff'; ctx.font = '16px Arial bold'; ctx.textBaseline = 'top'; ctx.fillText('暫定 BEST 3', 150, 500 - 30); // 最大3名のスコアを表示する const count = Math.min(result.length, 3); for(let i = 0; i < count; i++){ const text = `${result[i].score} : ${result[i].name}`; ctx.fillText(text, 150, 500 + 20 * i); } } |
効果音の再生
加点、減点時の効果音を鳴らす処理を示します。加点時は”SendToClientGotScore”、減点時は”SendToClientLostScore”、他のプレイヤーを倒したときは”SendToClientKill”がサーバーサイドから送られてくるので、それぞれに対応した効果音を鳴らします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
connection.on("SendToClientGotScore", () => { soundGetScore.currentTime = 0; soundGetScore.play(); }); connection.on("SendToClientLostScore", () => { soundLostScore.currentTime = 0; soundLostScore.play(); }); connection.on("SendToClientKill", () => { soundKill.currentTime = 0; soundKill.play(); }); |
ゲームオーバー時の処理
ゲームオーバー時の処理を示します。ゲームオーバーになるケースとして(1)壁や自分の胴体、他のプレイヤーに衝突した場合、(2)食べると即死する餌を食べてしまった場合の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 |
connection.on("SendToClientGameOvered1", () => { soundDead1.currentTime = 0; soundDead1.play(); onGameOvered(); }); connection.on("SendToClientGameOvered2", () => { soundDead2.currentTime = 0; soundDead2.play(); onGameOvered(); }); function onGameOvered(){ $up.style.display = 'none'; $down.style.display = 'none'; $left.style.display = 'none'; $right.style.display = 'none'; setTimeout(() => { soundGameOver.currentTime = 0; soundGameOver.play(); }, 1500); setTimeout(() => { $start.style.display = 'block'; $headad1.style.display = 'block'; }, 4500); } |