ASP.NET Core版 対コンピュータ対戦できるぷよぷよをつくる(3)の続きです。今回はクライアントサイドにおける処理です。
Contents
HTML部分
Pages\Puyo内に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 |
@page @{ ViewData["Title"] = "鳩でもわかる「ぷよぷよ」ならぬ「ぴよぴよ」"; Layout = "_Layout_none"; string baseurl = Global.BaseUrl; // Global.BaseUrlは公開したいディレクトリ(最後の / は不要) // 例:"https://lets-csharp.com/samples/2204/aspnetcore-app-zero" // ドメイントップで公開する場合は空文字列でよい。 } <div class="display-none"> @for(int i = 0; i < 5; i++) { @for(int k = 0; k < 20; k++) { if (k < 10) { <img src="@(baseurl)/puyo/puyo-images/@(i+1)-0@(k).png" alt="" id="type@(i+1)-0@(k)" /> } else { <img src="@(baseurl)/puyo/puyo-images/@(i+1)-@(k).png" alt="" id="type@(i+1)-@(k)" /> } } } <img src="@baseurl/puyo/puyo-images/wall.png" alt="" id="wall" /> </div> <div id="container"> <canvas id="can"></canvas> <br> <div style = "margin-left:60px;"> <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()"> <input type="button" id="showTop30Button1" onclick="location.href='./hi-score'" value="上位30位をチェック"> <p>遊び方</p> <p>左右の移動 ← → 急速落下 ↓<br> 左回転 Z 右回転 X</p> <p id = "conect-result"></p> </div> </div> <script> let connection = new signalR.HubConnectionBuilder().withUrl("@baseurl/PuyoHub").build(); let base_url = '@baseurl'; </script> <script src="@baseurl/puyo/puyo.js"></script> |
wwwrootディレクトリのなかにpuyoディレクトリを作成し、そのなかにpuyo-imagesディレクトリを作成して以下の画像を配置します。ぴよぴよの素材。BGMと効果音につかう音声ファイルは適当なところからダウンロードしてください。鳩でもわかるC#管理人は魔王魂からダウンロードしました。使えそうなmp3ファイルをダウンロードしたらwwwroot\puyoディレクトリ内に配置してください。
JavaScript部分
wwwroot\puyo\puyo.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 |
const CANVAS_WIDTH = 880; const CANVAS_HEIGHT = 450; let can = document.getElementById('can'); let ctx = can?.getContext('2d'); // 現在プレイ中か? let isPlaying = false; // BGMと効果音につかうmp3ファイル let bgm = new Audio(base_url + '/puyo/bgm.mp3'); let rotateSound = new Audio(base_url + '/puyo/rotate.mp3'); let downSound = new Audio(base_url + '/puyo/down.mp3'); let playerRensaSound = new Audio(base_url + '/puyo/rensa.mp3'); let cpuRensaSound = new Audio(base_url + '/puyo/cpu-rensa.mp3'); let falledSound = new Audio(base_url + '/puyo/falled.mp3'); let falledHeavySound = new Audio(base_url + '/puyo/falled-heavy.mp3'); let offsetSound = new Audio(base_url + '/puyo/offset.mp3'); let clearSound = new Audio(base_url + '/puyo/clear.mp3'); let gameoverSound = new Audio(base_url + '/puyo/gameover.mp3'); // ボタンの表示・非表示の操作用(プレイ中はボタンを非表示にする) let startButton1 = document.getElementById('startButton1'); let showTop30Button1 = document.getElementById('showTop30Button1'); // ぷよの描画に使うオブジェクトを配列に格納する let type1Images = []; for(let i = 0; i < 10; i++){ if(i < 10) type1Images.push(document.getElementById(`type1-0${i}`)); else type1Images.push(document.getElementById(`type1-${i}`)); } let type2Images = []; for(let i = 0; i < 10; i++){ if(i < 10) type2Images.push(document.getElementById(`type2-0${i}`)); else type2Images.push(document.getElementById(`type2-${i}`)); } let type3Images = []; for(let i = 0; i < 10; i++){ if(i < 10) type3Images.push(document.getElementById(`type3-0${i}`)); else type3Images.push(document.getElementById(`type3-${i}`)); } let type4Images = []; for(let i = 0; i < 10; i++){ if(i < 10) type4Images.push(document.getElementById(`type4-0${i}`)); else type4Images.push(document.getElementById(`type4-${i}`)); } let type5Images = []; for(let i = 0; i < 10; i++){ if(i < 10) type5Images.push(document.getElementById(`type5-0${i}`)); else type5Images.push(document.getElementById(`type5-${i}`)); } let wallImage = document.getElementById('wall'); |
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。適当にレイアウトを調整し、canvasのサイズを変更します。
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 |
window.addEventListener('load', Init); function Init() { // レイアウトを調整 let elmcontainer = document.getElementById('container'); if(elmcontainer != null){ elmcontainer.style.marginTop = '10px'; elmcontainer.style.marginLeft = '20px'; } // ゲーム開始用のボタンのサイズを調整してから表示 if(startButton1 != null){ startButton1.style.marginTop = '15px'; startButton1.style.marginBottom = '15px'; } // canvasのサイズ変更 can.width = CANVAS_WIDTH; can.height = CANVAS_HEIGHT; // canvasの背景を黒に ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); SetVolumes(0.02); // ボリューム調整 InitBgm(); // チェックボックスをオンに document.getElementById('sound-checkbox').checked = true; // AspNetCore.SignalRで接続する connection.start().catch(function (err) { document.getElementById("conect-result").innerHTML = '接続失敗'; }); } |
BGMと効果音のボリュームを調整する処理を示します。
1 2 3 4 |
function SetVolumes(volume) { bgm.volume = volume; // 他も同様にvolumeプロパティを調整する } |
チェックボックスがオンでプレイ中のときだけ音を鳴らします。
1 2 3 |
function IsSound() { return isPlaying && document.getElementById('sound-checkbox').checked; } |
BGMをエンドレスで鳴らす処理を示します。BGMに使うmp3ファイルによっては単純にエンドレスで再生すると違和感がある場合があります。以下は設定の例です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function InitBgm() { // 0.5秒おきにどこまで再生しているのかを確認して適切な処理をする // BGMの末尾が変な終わり方をしているので適切な場所(79秒?)で最初から再生しなおす // 設定の例 setInterval(() => { if (IsSound()) { if (bgm.currentTime == 0) { // 最初は最初から再生する bgm.play(); } if (bgm.currentTime > 111) { // 再生開始から111秒経過したら6秒地点に戻って繰り返し再生する bgm.currentTime = 6; bgm.play(); } } else StopBgm(); }, 500); } |
BGMを停止する処理を示します。bgm.currentTime = 0とすることでプレイ中にチェックボックスがオンにされると0.5秒後に先頭から再生されます。
1 2 3 4 |
function StopBgm() { bgm.pause(); bgm.currentTime = 0; } |
接続成功時
接続に成功したときの処理を示します。”SuccessfulConnectionToClient”とともにconnectionIDとぷよのカラムと行の最大値がサーバーサイドから送られてくるので、これを保存しておきます。
1 2 3 4 5 6 7 8 9 10 |
let connectionID = ''; let colMax = 0; let rowMax = 0; connection.on("SuccessfulConnectionToClient", function (result, id, _rowMax, _colMax) { connectionID = id; rowMax = _rowMax; colMax = _colMax; document.getElementById("conect-result").innerHTML = `conect-result ${result}:${id}`; }); |
ゲーム開始の処理
ゲーム開始のボタンが押されたらサーバーサイドに”GameStart”とともにプレイヤー名と接続成功時にconnectionIDに保存しておいた文字列を送信します。
1 2 3 4 5 6 7 8 |
function GameStart() { if (connectionID != '') { let playerName = document.getElementById('player-name').value; connection.invoke("GameStart", connectionID, playerName).catch(function (err) { return console.error(err.toString()); }); } } |
ゲーム開始の処理が正常におこなわれたときはサーバーサイドから”EventGameStartToClient”が送信されます。このときはゲームオーバーになるまでボタンを非表示にします。またプレイ中であることを示すisPlayingフラグをセットします。
1 2 3 4 5 6 7 |
connection.on("EventGameStartToClient", function () { startButton1.style.display = 'none'; if (showTop30Button1 != null) showTop30Button1.style.display = 'none'; isPlaying = true; }); |
キー操作時の処理
サーバーサイドにキーが押下されたことを知らせるために”SendKey”を送信するのですが、キーを押しっぱなしにすると短時間で連続して送信されてしまいます。そこでキーをいったん離してからでないとキー操作に応答しないようにフラグで管理します。
またゲーム中でもプレイヤー名は変更可能です。なのでキーが押されたらそのつどプレイヤー名も送信します。connectionIDが空文字のときは接続されていない状態なのでなにもしません。またゲーム中にDownキーを押下したらスクロールが発生するのでそれを抑止するために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 |
document.onkeydown = function (e) { if(isLeftKeyDown || isRightKeyDown || isDownKeyDown || isZKeyDown || isXKeyDown) return; let key = ''; if (e.code == 'ArrowLeft') { isLeftKeyDown = true; key = 'L'; } if (e.keyCode == 38) { } if (e.code == 'ArrowRight') { isRightKeyDown = true; key = 'R'; } if (e.code == 'ArrowDown') { isDownKeyDown = true; key = 'D'; } if (e.code == 'KeyZ') { isZKeyDown = true; key = 'Z'; } if (e.code == 'KeyX') { isXKeyDown = true; key = 'X'; } let playerName = document.getElementById('player-name').value; // ゲーム中にプレイヤー名を変更可能なのでキーが押されたらそのつどプレイヤー名も送信する // 接続されていない場合はなにもしない if (connectionID != '') { connection.invoke("SendKey", key, playerName).catch(function (err) { return console.error(err.toString()); }); } // Downキーを押下したらスクロールが発生するのでプレイ中はこれを抑止する if(isPlaying && e.code == 'ArrowDown') return false; } |
キーが離されたときの処理を示します。ここでやっているのはキーが押下されたときにセットされたフラグをクリアしているだけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
document.onkeyup = function (e) { if (e.code == 'ArrowLeft') { isLeftKeyDown = false; } if (e.code == 'ArrowRight') { isRightKeyDown = false; } if (e.code == 'ArrowDown') { isDownKeyDown = false; } if (e.code == 'KeyZ') { isZKeyDown = false; } if (e.code == 'KeyX') { isXKeyDown = 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 |
// プレイヤーの固定または落下中のぷよの位置、種類、連鎖数が格納された配列 let playerFixedRows = []; let playerFixedCols = []; let playerFixedTypes = []; let playerFixedRensas = []; let playerFallingRows = []; let playerFallingCols = []; let playerFallingTypes = []; // CPU側の固定または落下中のぷよの位置、種類、連鎖数が格納された配列 let cpuFixedRows = []; let cpuFixedCols = []; let cpuFixedTypes = []; let cpuFixedRensas = []; let cpuFallingRows = []; let cpuFallingCols = []; let cpuFallingTypes = []; // 次回、次々回に降ってくるぷよの種類 let playerNextMainSub = []; let playerNextNextMainSub = []; let cpuNextMainSub = []; let cpuNextNextMainSub = []; // スコア let playerScore = 0; let cpuScore = 0; // これから落下しようとしているおじゃまぷよの数 let playerYokoku = 0; let cpuYokoku = 0; |
サーバーサイドからはカンマ区切りの整数が文字列として送られてくるので、配列に変換してグローバル変数に保存します。
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 |
connection.on("FallingPuyoUpdatedToClient", function (isPlayer, rowsText, colsText, typesText) { if (isPlayer) { playerFallingRows = rowsText.split(','); playerFallingCols = colsText.split(','); playerFallingTypes = typesText.split(','); } else{ cpuFallingRows = rowsText.split(','); cpuFallingCols = colsText.split(','); cpuFallingTypes = typesText.split(','); } }); connection.on("FixedPuyoUpdatedToClient", function (isPlayer, rowsText, colsText, typesText, rensasText) { if(isPlayer){ playerFixedRows = rowsText.split(','); playerFixedCols = colsText.split(','); playerFixedTypes = typesText.split(','); playerFixedRensas = rensasText.split(','); } else{ cpuFixedRows = rowsText.split(','); cpuFixedCols = colsText.split(','); cpuFixedTypes = typesText.split(','); cpuFixedRensas = rensasText.split(','); } }); connection.on("SendNextToClient", function (isPlayer, mainSub, nextMainSub, nextNextMainSub) { if(isPlayer){ playerNextMainSub = nextMainSub.split(','); playerNextNextMainSub = nextNextMainSub.split(','); } else{ cpuNextMainSub = nextMainSub.split(','); cpuNextNextMainSub = nextNextMainSub.split(','); } }); connection.on("ScoreUpdatedToClient", function (isPlayer, score, yokoku) { if (isPlayer) { playerScore = score; playerYokoku = yokoku; } else { cpuScore = score; cpuYokoku = yokoku; } }); connection.on("OffsetToClient", function () { if (IsSound()) { offsetSound.currentTime = 0; offsetSound.play(); } }); |
サーバーサイドから”EndUpdatedToClient”が送信されたら保存しておいたグローバル変数を利用して描画処理をおこないます。Update関数については後述します。
1 2 3 |
connection.on("EndUpdatedToClient", function () { Update(); // 後述 }); |
描画処理
GetImageFromPuyo関数はぷよの種類と連鎖数から適切なイメージを取得するためのものです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function GetImageFromPuyo(puyoType, rensaCount){ let index = 0; if(rensaCount == undefined) index = 0; else index = rensaCount; if(puyoType == 1) return type1Images[index]; else if(puyoType == 2) return type2Images[index]; else if(puyoType == 3) return type3Images[index]; else if(puyoType == 4) return type4Images[index]; else if (puyoType == -1) return type5Images[index]; else return null; } |
落下中のぷよや固定されたぷよ、ネクストぷよ、スコアなどを描画する処理を示します。
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 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
// Col == 0 Row == 0 にあるぷよを描画するとき、左または上からこれだけ離れた座標に描画する const marginLeftPlayer1 = 50; const marginTopPlayer1 = 10; const marginLeftPlayer2 = 400; const marginTopPlayer2 = 10; function DrawFallingPuyo(isPlayer){ let marginLeft = marginLeftPlayer1; let marginTop = marginTopPlayer1; let fallingTypes = playerFallingTypes; let fallingRows = playerFallingRows; let fallingCols = playerFallingCols; if (!isPlayer){ marginLeft = marginLeftPlayer2; marginTop = marginTopPlayer2; fallingTypes = cpuFallingTypes; fallingRows = cpuFallingRows; fallingCols = cpuFallingCols; } if(fallingRows.length <= 1) return; for (let i = 0; i < fallingRows.length; i++) { if(Number(fallingCols[i]) < 0 || Number(fallingRows[i]) < 1) continue; let x = (Number(fallingCols[i]) + 1) * 28 + marginLeft; let y = (Number(fallingRows[i]) + 1) * 28 + marginTop; let img = GetImageFromPuyo(fallingTypes[i]); if(img !=undefined) ctx.drawImage(img, x, y); } } function DrawFixedPuyo(isPlayer) { let marginLeft = marginLeftPlayer1; let marginTop = marginTopPlayer1; let fixedPuyoRows = playerFixedRows; let fixedPuyoCols = playerFixedCols; let fixedPuyoTypes = playerFixedTypes; let fixedRensas = playerFixedRensas; if (!isPlayer){ marginLeft = marginLeftPlayer2; marginTop = marginTopPlayer2; fixedPuyoRows = cpuFixedRows; fixedPuyoCols = cpuFixedCols; fixedPuyoTypes = cpuFixedTypes; fixedRensas = cpuFixedRensas; } if(fixedPuyoTypes.length <= 1) return; for (let i = 0; i < fixedPuyoRows.length; i++) { let img = GetImageFromPuyo(fixedPuyoTypes[i], fixedRensas[i]); if(fixedPuyoRows[i] == 0) continue; let x = (Number(fixedPuyoCols[i]) + 1) * 28 + marginLeft; let y = (Number(fixedPuyoRows[i]) + 1) * 28 + marginTop; if(img != null) ctx.drawImage(img, x, y); } } function DrawWalls(isPlayer){ let marginLeft = marginLeftPlayer1; let marginTop = marginTopPlayer1; if (!isPlayer){ marginLeft = marginLeftPlayer2; marginTop = marginTopPlayer2; } for(let i = 0; i<=colMax + 1; i++){ let y = (rowMax + 1) * 28 + marginTop; let x = i * 28 + marginLeft; ctx.drawImage(wallImage, x, y, 28, 28); } for(let i = 1; i< rowMax + 1; i++){ let y = (i + 1) * 28 + marginTop; let x = marginLeft; ctx.drawImage(wallImage, x, y, 28, 28); } for(let i = 1; i< rowMax + 1; i++){ let y = (i + 1) * 28 + marginTop; let x = (colMax + 1) * 28 + marginLeft; ctx.drawImage(wallImage, x, y, 28, 28); } } function DrawNextPuyo(isPlayer){ let marginLeft = marginLeftPlayer1; let marginTop = marginTopPlayer1; let nextMainSub = playerNextMainSub; let nextNextMainSub = playerNextNextMainSub; if (!isPlayer){ marginLeft = marginLeftPlayer2; marginTop = marginTopPlayer2; nextMainSub = cpuNextMainSub; nextNextMainSub = cpuNextNextMainSub; } if(nextMainSub.length <= 1) return; let y = 64; let x = marginLeft + 250; let image = GetImageFromPuyo(nextMainSub[1]); ctx.drawImage(image, x, y); y += 28; image = GetImageFromPuyo(nextMainSub[0]); ctx.drawImage(image, x, y); y += 48; image = GetImageFromPuyo(nextNextMainSub[1]); ctx.drawImage(image, x, y); y += 28; image = GetImageFromPuyo(nextNextMainSub[0]); ctx.drawImage(image, x, y); } function DrawScore(isPlayer) { let marginLeft = marginLeftPlayer1; let marginTop = marginTopPlayer1; let score = playerScore; let yokoku = playerYokoku; if (!isPlayer) { marginLeft = marginLeftPlayer2; marginTop = marginTopPlayer2; score = cpuScore; yokoku = cpuYokoku; } let text = '(' + yokoku + ')'; ctx.fillStyle = '#0ff'; ctx.font = 'bold 32px MS ゴシック'; ctx.fillText(score, marginLeft + 20, marginTop + 30); ctx.fillStyle = '#f00'; ctx.font = 'bold 20px MS ゴシック'; ctx.fillText(text, marginLeft + 160, marginTop + 20); ctx.fillStyle = '#000'; } |
Update関数は上記の関数を呼び出して描画処理をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function Update(){ ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); DrawWalls(true); DrawFixedPuyo(true); DrawFallingPuyo(true); DrawNextPuyo(true); DrawWalls(false); DrawFixedPuyo(false); DrawFallingPuyo(false); DrawNextPuyo(false); DrawScore(true); DrawScore(false); } |
ゲームオーバー時の処理
ゲームオーバーになったらサーバーサイドから”GameOveredToClient”が送信されます。このときは非表示にしていたゲーム開始のためのボタンを再表示させるとともに、BGMを停止してゲームオーバー時の効果音を鳴らします。
1 2 3 4 5 6 7 8 9 10 11 12 |
connection.on("GameOveredToClient", function () { startButton1.style.display = 'block'; if (showTop30Button1 != null) showTop30Button1.style.display = 'block'; if (IsSound()) { gameoverSound.currentTime = 0; gameoverSound.play(); } isPlaying = false; StopBgm(); }); |
効果音を鳴らす処理
その他の効果音を鳴らす処理を示します。
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 |
// ステージクリア時 connection.on("StageClearedToClient", function () { if (IsSound()) { clearSound.currentTime = 0; clearSound.play(); } }); // 回転時 connection.on("RotatedToClient", function () { console.log("RotatedToClient"); if (IsSound()) { rotateSound.currentTime = 0; rotateSound.play(); } }); // 急速落下したぷよが着地したとき connection.on("DownedToClient", function () { if (IsSound()) { downSound.currentTime = 0; downSound.play(); } }); // プレイヤーの連鎖発生時 connection.on("PlayerRensaToClient", function () { if (IsSound()) { playerRensaSound.currentTime = 0; playerRensaSound.play(); } }); // コンピュータ側の連鎖発生時 connection.on("CpuRensaToClient", function () { if (IsSound()) { cpuRensaSound.currentTime = 0; cpuRensaSound.play(); } }); // おじゃまぷよが落ちる直前の効果音 connection.on("OjamaFalledToClient", function (isPlayer, ojamaCount) { if (IsSound()) { if (isPlayer) { if (ojamaCount > 6) { falledHeavySound.currentTime = 0; falledHeavySound.play(); } else { falledSound.currentTime = 0; falledSound.play(); } } } }); |