ASP.NET Core版 対戦型Pengoをつくる(3)の続きです。
Contents
cshtmlファイル
ここではPages\Pengoフォルダ内にgame.cshtmlファイルを作成します。そしてgame.cshtmlには以下のように記述します。
Pages\Pengo\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 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 |
@page @{ ViewData["Title"] = "対戦Pengo"; Layout = "_Layout_none"; string baseurl = "https://lets-csharp.com/samples/2204/aspnetcore-app-zero"; // 公開したいurl } <div class = "display-none"> <img src = "@baseurl/pengo/player1.png" id ="player1"> <img src = "@baseurl/pengo/player2.png" id ="player2"> <img src = "@baseurl/pengo/player3.png" id ="player3"> <img src = "@baseurl/pengo/player4.png" id ="player4"> <img src = "@baseurl/pengo/wall1.png" id ="wall1"> <img src = "@baseurl/pengo/wall2.png" id ="wall2"> <img src = "@baseurl/pengo/fire1.png" id ="fire1"> <img src = "@baseurl/pengo/fire2.png" id ="fire2"> <img src = "@baseurl/pengo/fire3.png" id ="fire3"> <img src = "@baseurl/pengo/fire4.png" id ="fire4"> <img src = "@baseurl/pengo/fire5.png" id ="fire5"> <img src = "@baseurl/pengo/fire6.png" id ="fire6"> </div> <div style = "display:flex;"> <div style="margin-left:20px; margin-top:20px;"> <canvas id="can"></canvas> <br> <p>遊び方</p> <p>移動:↑→↓←キー<br> 卵がある場所でキーをおすと<br> その向こうに壁や卵がない場合はその卵を飛ばすことができます。<br> その向こうに壁や卵がある場合はその卵を破壊します。<br> 「音を出す」にチェックをいれてもゲームに参加していない場合は音はでません。</p> <input type="checkbox" value="音を出す" id="sound-checkbox">音を出す <label>ハンドルネーム</label> <input type="text" id="player-name" maxlength='16' /><br> <input type="button" id="startButton1" value="ゲームスタート" onclick="GameStart()" style="margin-top:15px;margin-bottom:15px;"> <p><a href="./hi-score">トップ30を見る</a></p> <p id = "conect-result"></p> </div> <div style="margin-left:0px; margin-top:20px;"> <div id = "gameStatus"></div> <br> <input type="button" id="startButton2" value="ゲームスタート" onclick="GameStart()" style="margin-top:15px;margin-bottom:15px;"> <div id = "score"></div> <div id = "rest"></div> <br> <div class = "player-info"> <img src = "@baseurl/pengo/player1.png"> <div id = "playerName1" class = "player-name-score"></div> </div> <div class = "player-info"> <img src = "@baseurl/pengo/player2.png"> <div id = "playerName2" class = "player-name-score"></div> </div> <div class = "player-info"> <img src = "@baseurl/pengo/player3.png"> <div id = "playerName3" class = "player-name-score"></div> </div> <div class = "player-info"> <img src = "@baseurl/pengo/player4.png"> <div id = "playerName4" class = "player-name-score"></div> </div> <div class = "player-info"> <img src = "@baseurl/pengo/player1.png"> <div id = "playerName5" class = "player-name-score"></div> </div> <div class = "player-info"> <img src = "@baseurl/pengo/player2.png"> <div id = "playerName6" class = "player-name-score"></div> </div> <div class = "player-info"> <img src = "@baseurl/pengo/player3.png"> <div id = "playerName7" class = "player-name-score"></div> </div> <div class = "player-info"> <img src = "@baseurl/pengo/player4.png"> <div id = "playerName8" class = "player-name-score"></div> </div> <div id = "info"></div> </div> </div> <script src="@baseurl/js/signalr.js"></script> <script src="@baseurl/pengo/app.js"></script> <script> // グローバル変数 const base_url = '@baseurl'; const connection = new signalR.HubConnectionBuilder().withUrl("@baseurl/PengoHub").build(); </script> |
またwwwroot\pengoフォルダ内には以下のファイルを置きます。
player1.png
player2.png
player3.png
player4.png
wall1.png
wall2.png
fire1.png
fire2.png
fire3.png
fire4.png
fire5.png
fire6.png
それから音声ファイルとして以下を準備しました。
kick.mp3
explotion.mp3
dead.mp3
gameover.mp3
notification.mp3
JavaScript部分
次にJavaScriptですが、wwwroot\pengoフォルダ内にapp.jsを作成します。
グローバル変数
wwwroot\pengo\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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
// Canvasのサイズ const CANVAS_WIDTH = 540; const CANVAS_HEIGHT = 540; // キャラクタのサイズ const CHARCTER_SIZE = 32; const can = document.getElementById('can'); const ctx = can.getContext('2d'); // キーが押されているかどうか? let isUpKeyDown = false; let isDownKeyDown = false; let isLeftKeyDown = false; let isRightKeyDown = false; // AspNetCore.SignalRで接続したときのID let connectionID = ''; // プレイヤー、壁、火花のイメージ let imgPlayer1 = document.getElementById('player1'); let imgPlayer2 = document.getElementById('player2'); let imgPlayer3 = document.getElementById('player3'); let imgPlayer4 = document.getElementById('player4'); let imgWall1 = document.getElementById('wall1'); let imgWall2 = document.getElementById('wall2'); let imgFire1 = document.getElementById('fire1'); let imgFire2 = document.getElementById('fire2'); let imgFire3 = document.getElementById('fire3'); let imgFire4 = document.getElementById('fire4'); let imgFire5 = document.getElementById('fire5'); let imgFire6 = document.getElementById('fire6'); // 効果音 let kickSound = new Audio(base_url + '/pengo/kick.mp3'); let explodeSound = new Audio(base_url + '/pengo/explotion.mp3'); let deadSound = new Audio(base_url + '/pengo/dead.mp3'); let gameoverSound = new Audio(base_url + '/pengo/gameover.mp3'); let notificationSound = new Audio(base_url + '/pengo/notification.mp3'); // 現在プレイ中か? let isPlaying = false; // スコアと残機 let score; let rest; // ブロックの座標と消滅までの時間 let blocksX = []; let blocksY = []; let blocksLife = []; // 各プレイヤーの情報 let playersName = []; let playersX = []; let playersY = []; let isPlayersDead = []; let scores = []; let rests = []; let invincibleTimes = []; // 火花 let firesX = []; let firesY = []; let firesLife = []; // 追加されようとしているブロックの座標 let addBlocksX = []; let addBlocksY = []; |
初期化
Canvasのサイズを設定して効果音のボリュームを最適化します。また一部の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 |
function Init() { can.width = CANVAS_WIDTH; can.height = CANVAS_HEIGHT; SetVolume(0.03); let elms = document.getElementsByClassName('player-info'); for(let i = 0; i < elms.length; i++){ elms[i].style.display = 'flex'; elms[i].style.marginTop = '4px'; elms[i].style.marginBottom = '4px'; } elms = document.getElementsByClassName('player-name-score'); for(let i = 0; i < elms.length; i++){ elms[i].style.color = 'white'; elms[i].style.fontWeight = 'bold'; elms[i].style.marginTop = '5px'; elms[i].style.marginLeft = '10px'; } document.getElementById('gameStatus').style.display = 'flex'; } function SetVolume(volume) { explodeSound.volume = volume; deadSound.volume = volume; gameoverSound.volume = volume; notificationSound.volume = volume; kickSound.volume = volume; } |
AspNetCore.SignalRで接続する
ページが読み込まれたらすぐにAspNetCore.SignalRで接続を試みます。そして成功した場合はReceiveConnectedを受信します。そのときの処理を示します。やっていることは接続したときに与えられたconnectionIDを表示させているだけです。
1 2 3 4 5 6 7 8 9 |
connection.start().catch(function (err) { document.getElementById("conect-result").innerHTML = '接続失敗'; }); connection.on("ReceiveConnected", function (result, id) { connectionID = id; document.getElementById("conect-result").innerHTML = `conect-result ${result}:${id}`; console.log(connectionID); }); |
ゲームに参加する
AspNetCore.SignalRで接続されている状態で「ゲームスタート」のボタンを押したらゲームに参加します。ユーザーによってプレイヤー名が設定されていないときはプレイヤーネームは「名無しさん」になります。
1 2 3 4 5 6 7 8 9 10 11 |
function GameStart() { if (connectionID != '') { let playerName = document.getElementById('player-name').value; if (playerName == '') playerName = '名無しさん'; connection.invoke("GameStart", connectionID, playerName).catch(function (err) { return console.error(err.toString()); }); } } |
キー操作への対応
キーが押されたときはサーバーサイドにそれを送信します。
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 |
document.onkeydown = function (e) { // ゲーム開始以降はデフォルトの動作を抑制する if (connectionID != '' && (e.key == "ArrowUp" || e.key == "ArrowDown" || e.key == "ArrowLeft" || e.key == "ArrowRight" || e.key == " ")) e.preventDefault(); // キーが押しっぱなしになっているときの二重送信を防ぐ if (e.key == "ArrowUp" && isUpKeyDown) return; if (e.key == "ArrowDown" && isDownKeyDown) return; if (e.key == "ArrowLeft" && isLeftKeyDown) return; if (e.key == "ArrowRight" && isRightKeyDown) return; if (e.key == "ArrowUp") isUpKeyDown = true; if (e.key == "ArrowDown") isDownKeyDown = true; if (e.key == "ArrowLeft") isLeftKeyDown = true; if (e.key == "ArrowRight") isRightKeyDown = true; // ゲーム中にプレイヤー名を変更可能なのでキーが押されたらそのつどプレイヤー名も送信する // 接続されていない場合はなにもしない if (connectionID != '') { let playerName = document.getElementById('player-name').value; if (playerName == '') playerName = '名無しさん'; connection.invoke("DownKey", e.key, playerName).catch(function (err) { return console.error(err.toString()); }); } } |
キーが離されたときもサーバーサイドにこれを送信します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
document.onkeyup = function (e) { if (e.key == "ArrowUp") isUpKeyDown = false; if (e.key == "ArrowDown") isDownKeyDown = false; if (e.key == "ArrowLeft") isLeftKeyDown = false; if (e.key == "ArrowRight") isRightKeyDown = false; // 接続されていない場合はなにもしない if (connectionID != '') { connection.invoke("UpKey", e.key).catch(function (err) { return console.error(err.toString()); }); } } |
更新処理
更新処理を開始するときはサーバーサイドからReceiveStartUpdateが送られてきます。このときは配列に保存されている更新用のデータを初期化します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
connection.on("ReceiveStartUpdate", function () { isPlaying = false; playersName = []; playersX = []; playersY = []; isPlayersDead = []; scores = []; rests = []; invincibleTimes = []; firesX = []; firesY = []; firesLife = []; }); |
ReceiveUpdateBlocksを受信したらブロックの座標と状態を更新します。サーバーサイドから送られてきたデータを配列に格納します。
1 2 3 4 5 |
connection.on("ReceiveUpdateBlocks", function (xs, ys, lifes) { blocksX = xs.split(','); blocksY = ys.split(','); blocksLife = lifes.split(','); }); |
ReceiveBeforeAddBlocksを受信したら、これから新しいブロックが追加されることを意味しています。このブロックは点滅表示させたいので別の配列に格納します。
1 2 3 4 |
connection.on("ReceiveBeforeAddBlocks", function (xs, ys) { addBlocksX = xs.split(','); addBlocksY = ys.split(','); }); |
ReceiveAfterAddBlocksを受信したらサーバーサイドでは新しいブロックの追加が完了しています。ブロックを点滅表示させる必要はないので配列をクリアします。
1 2 3 4 |
connection.on("ReceiveAfterAddBlocks", function () { addBlocksX = []; addBlocksY = []; }); |
ReceiveUpdatePlayerを受信したらプレイヤーの座標と状態を更新します。この場合も配列にデータを格納します。また自機に関する情報としてプレイ中であることと、スコア、残機を保存しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
connection.on("ReceiveUpdatePlayer", function (key, x, y, name, isdead, scre, rst, invincible) { if (key == connectionID) { isPlaying = true; score = scre; rest = rst; } playersName.push(name); playersX.push(x); playersY.push(y); isPlayersDead.push(isdead); scores.push(scre); rests.push(rst); invincibleTimes.push(invincible); }); |
ReceiveUpdateSparksを受信したら火花の座標と状態が更新されたことを意味しています。この場合も配列にデータを保存します。
1 2 3 4 5 6 7 |
connection.on("ReceiveUpdateSparks", function (xs, ys, lifes) { if (xs != '') { firesX = xs.split(','); firesY = ys.split(','); firesLife = lifes.split(','); } }); |
ゲームステータスの表示
ReceiveUpdateGameStatusを受信したらゲームステータス(ゲームに参加中か?参加できる状態か?)が更新されたことを意味しています。
プレイ中はプレイヤーのイメージとともに「現在(名前)で参戦中!」と表示させます。参加していない場合は参加可能である場合は「参加可能です!」、定員オーバーで参加できないときは「現在満員です。空きが出るまでお待ちください」と表示させます。
またプレイに参加しているときと参加できない場合は「ゲームスタート」ボタンは非表示にして押すことができないようにしています。
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 |
connection.on("ReceiveUpdateGameStatus", function (playerNumber) { let imgUrl = ''; if (playerNumber >= 0) { imgUrl = `${base_url}/pengo/player${Number(playerNumber % 4) + 1}.png`; } let gameStatusElement = document.getElementById('gameStatus'); gameStatusElement.style.fontWeight = 'bold'; gameStatusElement.style.color = 'yellow'; if (playerNumber == -1) gameStatusElement.innerHTML = '参加可能です!'; else if (playerNumber == -2) gameStatusElement.innerHTML = '現在満員です。空きが出るまでお待ちください。'; else gameStatusElement.innerHTML = ` <img src = "${imgUrl}"> <div style = "margin-top:5px; margin-left:10px;">現在 [${GetPlayerName()}] で参戦中</div> `; if (playerNumber == -1) { document.getElementById('startButton1').style.display = 'block'; document.getElementById('startButton2').style.display = 'block'; } else { document.getElementById('startButton1').style.display = 'none'; document.getElementById('startButton2').style.display = 'none'; } }); |
テキストボックスに入力されているプレイヤー名のなかに <や>があるとそのままHTMLのなかに入れてしまうと不都合がおきるのでエスケープ処理が必要です。ここでは <と>だけエスケープしています。
1 2 3 4 5 6 7 8 9 10 |
function GetPlayerName() { let name = document.getElementById('player-name').value; if (name == '') return '名無しさん'; if (name.indexOf('<') != -1) name = name.split('<').join('<'); if (name.indexOf('>') != -1) name = name.split('>').join('>'); return name; } |
描画処理
ReceiveEndUpdateを受信したらサーバーサイドから更新用のデータがすべて送られたことを意味しています。この場合は更新用のデータをつかって描画処理をおこないます。
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 |
let updateCount = 0; connection.on("ReceiveEndUpdate", function () { updateCount++; ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); DrawBlocks(); DrawAddBlocks(); DrawFires(); DrawPlayers(); ShowPlayersInfo(); // プレイ中はスコア、残機数を表示する if (isPlaying) { let scoreElement = document.getElementById('score'); scoreElement.style.fontWeight = 'bold'; scoreElement.style.color = 'white'; scoreElement.style.fontSize = '125%'; scoreElement.innerText = 'Score ' + score; let restElement = document.getElementById('rest'); restElement.style.fontWeight = 'bold'; restElement.style.color = 'white'; restElement.style.fontSize = '125%'; restElement.innerText = '残 ' + rest; } }); |
ブロックを描画する処理を示します。時間の経過でブロックの卵がだんだん明るくなったり暗くなったりさせています。また壊されたブロックはアニメーションさせています。
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 |
function DrawBlocks() { let alpha = (updateCount % 64) / 16; if (alpha < 2) ctx.globalAlpha = 1.0 - alpha / 10; else ctx.globalAlpha = 1.0 - ((4 - alpha) / 10); for (let i = 0; i < blocksX.length; i++) { let imgWall = null; if (blocksLife[i] == 12) imgWall = imgWall1; else if (blocksLife[i] >= 10) imgWall = imgFire1; else if (blocksLife[i] >= 8) imgWall = imgFire2; else if (blocksLife[i] >= 6) imgWall = imgFire3; else if (blocksLife[i] >= 4) imgWall = imgFire4; else if (blocksLife[i] >= 2) imgWall = imgFire5; else if (blocksLife[i] >= 0) imgWall = imgFire5; ctx.drawImage(imgWall, Number(blocksX[i]) + CHARCTER_SIZE, Number(blocksY[i]) + CHARCTER_SIZE, CHARCTER_SIZE + 4, CHARCTER_SIZE + 4); } ctx.globalAlpha = 1.0; // 周囲の壁 for (let i = 0; i < 16; i++) ctx.drawImage(imgWall2, CHARCTER_SIZE * i, 0, CHARCTER_SIZE, CHARCTER_SIZE); for (let i = 0; i < 16; i++) ctx.drawImage(imgWall2, 0, CHARCTER_SIZE * i, CHARCTER_SIZE, CHARCTER_SIZE); for (let i = 0; i < 16; i++) ctx.drawImage(imgWall2, CHARCTER_SIZE * i, CHARCTER_SIZE * 16, CHARCTER_SIZE, CHARCTER_SIZE); for (let i = 0; i <= 16; i++) ctx.drawImage(imgWall2, CHARCTER_SIZE * 16, CHARCTER_SIZE * i, CHARCTER_SIZE, CHARCTER_SIZE); } |
追加されようとしているブロックを描画する処理を示します。追加されようとしているブロックがそれとわかるように点滅させています。
1 2 3 4 5 6 |
function DrawAddBlocks() { for (let i = 0; i < addBlocksX.length; i++) { if (updateCount % 2 == 0) ctx.drawImage(imgWall1, Number(addBlocksX[i]) + CHARCTER_SIZE, Number(addBlocksY[i]) + CHARCTER_SIZE, CHARCTER_SIZE + 4, CHARCTER_SIZE + 4); } } |
火花を描画する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function DrawFires() { for (let i = 0; i < firesX.length; i++) { let imgFire = null; if (firesLife[i] >= 11) imgFire = imgFire1; else if (firesLife[i] >= 9) imgFire = imgFire2; else if (firesLife[i] >= 7) imgFire = imgFire3; else if (firesLife[i] >= 5) imgFire = imgFire4; else if (firesLife[i] >= 3) imgFire = imgFire5; else if (firesLife[i] >= 1) imgFire = imgFire6; if (imgFire != null) ctx.drawImage(imgFire, Number(firesX[i]) + CHARCTER_SIZE, Number(firesY[i]) + CHARCTER_SIZE, 48, 48); } } |
プレイヤーを描画する処理を示します。無敵状態のキャラクタは点滅させています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function DrawPlayers() { for (let i = 0; i < 8; i++) { if (playersX[i] != undefined) { let imgPlayer = null; if (i % 4 == 0) imgPlayer = imgPlayer1; if (i % 4 == 1) imgPlayer = imgPlayer2; if (i % 4 == 2) imgPlayer = imgPlayer3; if (i % 4 == 3) imgPlayer = imgPlayer4; let show = true; if (invincibleTimes[i] > 0 && invincibleTimes[i] % 2 == 0) show = false; if (!isPlayersDead[i] && show) ctx.drawImage(imgPlayer, playersX[i] - 3 + + CHARCTER_SIZE, playersY[i] - 3 + + CHARCTER_SIZE, CHARCTER_SIZE + 6, CHARCTER_SIZE + 6); } } } |
右サイドにプレイヤーの状態を表示させる処理を示します。NPCの場合、スコアと残機という概念はないのでscoreが空文字列の場合は表示させません。プレイヤーの場合は名前だけでなくスコアと残機も表示させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function ShowPlayersInfo() { for (let i = 0; i < 8; i++) { if (playersX[i] != undefined) { let playerNameElement = null; playerNameElement = document.getElementById('playerName' + (i + 1)); let score = ''; if (scores[i] != "") score = ' (' + scores[i] + ' - ' + rests[i] + ')'; if (playerNameElement != null) playerNameElement.innerText = playersName[i] + score; } } } |
新たに参戦するプレイヤーが現れたりゲームオーバーになった場合は全ユーザーに通知されます。それを表示する処理を示します。いつまでも同じ事を表示させたくないので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 |
let messages = []; connection.on("ReceiveNotification", function (str) { messages.push(str); ShowNotification(); if (isPlaying && document.getElementById('sound-checkbox').checked) { notificationSound.currentTime = 0; notificationSound.play(); } setTimeout(() => { if (messages.length > 0) { messages.shift(); ShowNotification(); } }, 3000); }); function ShowNotification() { let infoElement = document.getElementById('info'); infoElement.style.marginTop = '30px'; let message = ""; for (let i = 0; i < messages.length; i++) { message += messages[i] + '<br>'; } infoElement.innerHTML = message; } |
効果音を鳴らす
プレイヤーがブロックを飛ばしたり壊したとき、プレイヤーが撃破されたとき、ゲームオーバーになったときには効果音を鳴らします。
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 |
// プレイヤーがブロックを飛ばしたとき connection.on("ReceiveBlockKick", function () { if (isPlaying && document.getElementById('sound-checkbox').checked) { kickSound.currentTime = 0; kickSound.play(); } }); // プレイヤーがブロックを壊したとき connection.on("ReceiveBlockBreak", function () { if (isPlaying && document.getElementById('sound-checkbox').checked) { explodeSound.currentTime = 0; explodeSound.play(); } }); // プレイヤーが撃破されたとき connection.on("ReceivePlayerDead", function () { if (isPlaying && document.getElementById('sound-checkbox').checked) { deadSound.currentTime = 0; deadSound.play(); } }); // ゲームオーバーになったとき connection.on("ReceiveGameOver", function (id) { if (connectionID == id) { isPlaying = false; document.getElementById('rest').style.fontWeight = 'bold'; document.getElementById('rest').style.color = 'red'; document.getElementById('rest').innerHTML = 'Game Over'; if (document.getElementById('sound-checkbox').checked) { gameoverSound.currentTime = 0; gameoverSound.play(); } } }); |