Unityを使わずにフリスビーを犬に届けよ!を作ってみる(3)の続きです。今回はクライアントサイドにおける処理を実装します。
フリスビーを犬に届けよ!の元ネタ
Unishar-ユニシャー【Unityでのゲーム開発を手助けするメディア】
Contents
cshtml部分
Pages\Frisbee\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 |
@page @{ ViewData["Title"] = "鳩でもわかるUnityを使わずにフリスビーを犬に届けよ!"; Layout = "_Layout_none"; string baseurl = Global.BaseUrl; } <div id="main"> <canvas id="can"></canvas> <p>遊び方</p> <p>↑キー 上昇、 ← → キー 左右の傾きの調整</p> <p>犬がいるところまで赤いフリスビーを運んでください。<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;"> <input type="button" id="showTop30Button1" onclick="location.href='./hi-score'" value="上位30位をチェック"> <p id = "conect-result"></p> </div> <script> let connection = new signalR.HubConnectionBuilder().withUrl("@baseurl/FrisbeeHub").build(); let base_url = "@baseurl"; </script> <script src="https://unpkg.com/three@0.140.2/build/three.min.js"></script> <script src="@baseurl/frisbee/frisbee.js"></script> |
JavaScript部分
グローバル変数と定数
JavaScriptの定数部分を示します。
wwwroot\frisbee\frisbee.js
1 2 3 4 5 6 7 8 9 10 11 |
const startButton1 = document.getElementById('startButton1'); const showTop30Button1 = document.getElementById('showTop30Button1'); const CANVAS_WIDTH = 480; const CANVAS_HEIGHT = 420; // BGMや効果音も再生したいので必要なオブジェクトを生成する const bgm = new Audio(base_url + '/frisbee/bgm.mp3'); const upSound = new Audio(base_url + '/frisbee/up.mp3'); const clearSound = new Audio(base_url + '/frisbee/clear.mp3'); const deadSound = new Audio(base_url + '/frisbee/dead.mp3'); |
シーン、レンダラー、ライト、カメラの作成
ThreeJSを使いますが、その初期化にかんする部分を示します。
ここではシーン、レンダラー、ライト、カメラの作成をしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
let can = document.getElementById('can'); const scene = new THREE.Scene(); can.width = CANVAS_WIDTH; can.height = CANVAS_HEIGHT; const renderer = new THREE.WebGLRenderer({ canvas: can }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(CANVAS_WIDTH, CANVAS_HEIGHT); const light = new THREE.AmbientLight(0xFFFFFF, 0.2); scene.add(light); const directionalLight = new THREE.DirectionalLight(0xFFFFFF, 2); directionalLight.position.set(1, 3, 1); scene.add(directionalLight); const camera = new THREE.PerspectiveCamera(45, CANVAS_WIDTH / CANVAS_HEIGHT); camera.position.set(0, 0, +1000); |
ページが読み込まれたときの初期化
ページが読み込まれたら初期化の処理をおこないます。
ここでやっている初期化はmp3ファイルの再生関係です。[音を出す]にチェックがされていてゲーム中であればBGMをエンドレスで再生したいのですが、この音声ファイルは最後のほうは無音なので50秒再生したら先頭から再生しなおすようにしています。
そのあとAspNetCore.SignalRでサーバーサイドに接続を試みます。うまくいけば成功を示すメッセージが返されます。そのあとスコアを表示するためのテキストフィールドを生成して座標(0, 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 |
window.addEventListener('load', Init); let textField1; function Init() { SetVolumes(0.02); // BGMと効果音のボリュームの設定 // 50秒再生したら先頭から再生しなおす setInterval(() => { if (IsSound()) { if (bgm.currentTime > 50 || bgm.currentTime == 0) { bgm.currentTime = 0; bgm.play(); } } else StopBgm(); }, 500); // 最初の設定は[音を出す]にチェック document.getElementById('sound-checkbox').checked = true; // AspNetCore.SignalRでサーバーサイドに接続する connection.start().catch(function (err) { document.getElementById("conect-result").innerHTML = '接続失敗'; }); let elm = document.getElementById('main'); elm.style.marginLeft = '20px'; elm.style.marginTop = '20px'; elm.style.position = 'relative'; textField1 = document.createElement('div'); textField1.style.position = 'absolute'; textField1.style.transform = `translate(0px, 0px)`; textField1.style.top = 0; textField1.style.left = 0; textField1.innerHTML = ''; textField1.style.color = 'white'; textField1.style.fontSize = '24pt'; elm.appendChild(textField1); } |
mp3ファイルの再生関連
SetVolumes関数は効果音のボリュームを設定します。効果音の大きさに違いがあるのでmp3ファイルによって微調整をいれています。
1 2 3 4 5 6 7 8 9 10 11 |
function SetVolumes(volume) { // 効果音の大きさに違いがあるので調整している let volume2 = volume * 3; if (volume2 > 1) volume2 = 1; deadSound.volume = volume2; clearSound.volume = volume; upSound.volume = volume2; bgm.volume = volume; } |
IsSound関数は効果音を再生する設定になっているかを調べるためのものです。
1 2 3 4 5 6 |
// 現在プレイ中か? let isPlaying = false; function IsSound() { return isPlaying && document.getElementById('sound-checkbox').checked; } |
PlayBgm関数とStopBgm関数はBGMの再生と停止をおこなうためのものです。
1 2 3 4 5 6 7 8 9 |
function PlayBgm() { bgm.currentTime = 0; bgm.play(); } function StopBgm() { bgm.currentTime = 0; bgm.pause(); } |
接続成功時の処理
AspNetCore.SignalRでサーバーサイドへの接続を試みて実際に接続できた場合は、サーバーサイドからSuccessfulConnectionToClientが送信されます。これを受信したらそのときに付与されるIDをグローバル変数に保存しておきます。
1 2 3 4 5 6 |
let connectionID = ''; connection.on("SuccessfulConnectionToClient", function (result, id) { connectionID = id; document.getElementById("conect-result").innerHTML = `conect-result ${result}:${id}`; }); |
接続に成功したらゲームをするために必要な情報(フィールドの形状や自機の位置など)が送られてきます。そのときの処理を示します。
フィールド上に存在する障害物の座標はSendObstaclesToClientで送られてきます。これをうけとったら3Dオブジェクトを生成してシーンに追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// 表示されるX座標の最大値 let maxX; let obstacles = []; connection.on("SendObstaclesToClient", function (xs, ys, widths, heights, max) { const material = new THREE.MeshLambertMaterial({ color: 0x004000 }); let xArray = xs.split(','); let yArray = ys.split(','); let widthArray = widths.split(','); let heightArray = heights.split(','); for (let i = 0; i < xArray.length; i++) { const geometry = new THREE.BoxGeometry(widthArray[i], heightArray[i], 128); let obj = new THREE.Mesh(geometry, material); obj.position.x = xArray[i]; obj.position.y = yArray[i]; obj.position.z = 0; scene.add(obj); obstacles.push(obj); // シーンから取り除くことを考えて配列にも格納しておく } maxX = max; }); |
スタート地点とゴールの座標はSendStartGoalToClientで送られてきます。これをうけとったらスタート地点は3Dオブジェクトを生成し、ゴールは犬の画像でスプライトを生成してシーンに追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
connection.on("SendStartGoalToClient", function (startX, startY, startWidth, startHeight, goalX, goalY, goalWidth, goalHeight) { const startMaterial = new THREE.MeshLambertMaterial({ color: 0xff8000 }); const startGeometry = new THREE.BoxGeometry(startWidth, startHeight, 128); let start = new THREE.Mesh(startGeometry, startMaterial); start.position.x = startX; start.position.y = startY; start.position.z = 0; scene.add(start); let path = base_url + '/frisbee/goal.png'; const goalMaterial = new THREE.SpriteMaterial({ map: new THREE.TextureLoader().load(path), }); const sprite = new THREE.Sprite(goalMaterial); sprite.scale.set(goalWidth, goalHeight, 10); sprite.position.x = goalX; sprite.position.y = goalY; sprite.position.z = 0; scene.add(sprite); }); |
プレイヤーの初期座標はSendPlayerToClientで送られてきます。これをうけとったらフリスビーのような薄い円柱を生成してシーンに追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// プレイヤー3Dオブジェクト let player; connection.on("SendPlayerToClient", function (x, y, radius, thickness) { const material = new THREE.MeshLambertMaterial({ color: 0xff0000 }); const cylgeometry = new THREE.CylinderGeometry(radius, radius, thickness, 128); player = new THREE.Mesh(cylgeometry, material); player.position.x = x; player.position.y = y; player.position.z = 0; scene.add(player); }); |
ゲーム開始のボタンが押されたらサーバーサイドにGameStartを送信します。これに対してサーバーサイドで不具合がおきずに正常に処理がおこなわれたらEventGameStartToClientが返されます。この場合はisPlayingフラグをtrueにして、ゲーム開始ボタンを非表示にし、[音を出す]にチェックがされている場合はBGMも再生します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function GameStart() { if (connectionID != '') { let playerName = document.getElementById('player-name').value; connection.invoke("GameStart", connectionID, playerName).catch(function (err) { return console.error(err.toString()); }); } } connection.on("EventGameStartToClient", function () { startButton1.style.display = 'none'; showTop30Button1.style.display = 'none'; isPlaying = true; if (IsSound()) PlayBgm(); }); |
キー操作への対応
ユーザーがキー操作をしたときにこれをサーバーサイドに送信してフリスビーが移動するようにします。
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 |
// キーが押されているか? let isLeftKeyDown = false; let isRightKeyDown = false; let isUpKeyDown = false; let isDownKeyDown = false; document.onkeydown = function (e) { // ゲーム開始以降はデフォルトの動作を抑制する if (isPlaying && (e.key == "ArrowUp" || e.key == "ArrowDown" || e.key == "ArrowLeft" || e.key == "ArrowRight" || e.key == " ")) e.preventDefault(); // キーが押しっぱなしになっているときの二重送信を防ぐ if (e.key == "ArrowLeft" && isLeftKeyDown) return; if (e.key == "ArrowRight" && isRightKeyDown) return; if (e.key == "ArrowUp" && isUpKeyDown) return; if (e.key == "ArrowDown" && isDownKeyDown) return; if (e.key == "ArrowLeft") isLeftKeyDown = true; if (e.key == "ArrowRight") isRightKeyDown = true; if (e.key == "ArrowUp") isUpKeyDown = true; if (e.key == "ArrowDown") isDownKeyDown = true; // ゲーム中にプレイヤー名を変更可能なのでキーが押されたらそのつどプレイヤー名も送信する // 接続されていない場合はなにもしない if (connectionID != '') { let playerName = document.getElementById('player-name').value; connection.invoke("DownKey", e.key, playerName).catch(function (err) { return console.error(err.toString()); }); } } document.onkeyup = function (e) { if (e.key == "ArrowLeft") isLeftKeyDown = false; if (e.key == "ArrowRight") isRightKeyDown = false; if (e.key == "ArrowUp") isUpKeyDown = false; if (e.key == "ArrowDown") isDownKeyDown = false; // 接続されていない場合はなにもしない if (connectionID != '') { connection.invoke("UpKey", e.key).catch(function (err) { return console.error(err.toString()); }); } } |
更新処理
サーバーサイドで更新処理がおこなわれた場合、クライアントサイドにはUpdateXXXToClientが送信されます。
以下はプレイヤーの座標と表示角度、生存or死亡に関するデータが送られてきたときの処理です。
ここでは座標と回転を設定してカメラの座標も決めています。プレイヤー死亡または非表示にするときはisdead == trueとなります。この場合はカメラに写らない場所にフリスビーを移動させ、カメラもこれを追わないようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
let cameraTargetX = 0; let cameraTargetY = 0; connection.on("UpdatePlayerToClient", function (x, y, angle, isdead) { player.rotation.z = angle; player.position.x = x; player.position.y = y; player.position.z = 0; if (!isdead) { cameraTargetX = x; cameraTargetY = y; } else player.position.x = -1000; }); |
ミス時には火花が散ります。そのときはUpdateSparksToClientが送られてきます。このとき引数が空文字の場合は火花を描画する必要がないことを意味しています。
最初に火花を描画しなければならなくなった場合、火花のスプライトを生成します。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 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 |
// ミス時に描画される火花のスプライト let sparks1 = []; let sparks2 = []; let sparks3 = []; let sparks4 = []; let sparks5 = []; let sparks6 = []; connection.on("UpdateSparksToClient", function (xs, ys, lifes) { // すべての火花のスプライトをカメラに写らない場所に移動 for (let i = 0; i < sparks1.length; i++) sparks1[i].position.x = -10000; for (let i = 0; i < sparks2.length; i++) sparks2[i].position.x = -10000; for (let i = 0; i < sparks3.length; i++) sparks3[i].position.x = -10000; for (let i = 0; i < sparks4.length; i++) sparks4[i].position.x = -10000; for (let i = 0; i < sparks5.length; i++) sparks5[i].position.x = -10000; for (let i = 0; i < sparks6.length; i++) sparks6[i].position.x = -10000; if (xs != '') { let sparkXs = xs.split(','); let sparkYs = ys.split(','); let sparkLifes = lifes.split(','); // 火花のスプライトが生成されていない場合は生成する if (sparks1.length == 0) { const mate1 = new THREE.SpriteMaterial({ map: new THREE.TextureLoader().load(base_url + '/frisbee/spark1.png') }); const mate2 = new THREE.SpriteMaterial({ map: new THREE.TextureLoader().load(base_url + '/frisbee/spark2.png') }); const mate3 = new THREE.SpriteMaterial({ map: new THREE.TextureLoader().load(base_url + '/frisbee/spark3.png') }); const mate4 = new THREE.SpriteMaterial({ map: new THREE.TextureLoader().load(base_url + '/frisbee/spark4.png') }); const mate5 = new THREE.SpriteMaterial({ map: new THREE.TextureLoader().load(base_url + '/frisbee/spark5.png') }); const mate6 = new THREE.SpriteMaterial({ map: new THREE.TextureLoader().load(base_url + '/frisbee/spark6.png') }); for (let i = 0; i < sparkXs.length; i++) { const sprite1 = new THREE.Sprite(mate1); sprite1.scale.set(48, 48, 48); scene.add(sprite1); sparks1.push(sprite1); const sprite2 = new THREE.Sprite(mate2); sprite2.scale.set(48, 48, 48); scene.add(sprite2); sparks2.push(sprite2); const sprite3 = new THREE.Sprite(mate3); sprite3.scale.set(48, 48, 48); scene.add(sprite3); sparks3.push(sprite3); const sprite4 = new THREE.Sprite(mate4); sprite4.scale.set(48, 48, 48); scene.add(sprite4); sparks4.push(sprite4); const sprite5 = new THREE.Sprite(mate5); sprite5.scale.set(48, 48, 48); scene.add(sprite5); sparks5.push(sprite5); const sprite6 = new THREE.Sprite(mate6); sprite6.scale.set(48, 48, 48); scene.add(sprite6); sparks6.push(sprite6); } } for (let i = 0; i < sparkXs.length; i++) { // 火花の寿命に合わせて適切なスプライトを選択し、これを描画する let spark = null; if (sparkLifes[i] > 10) spark = sparks1[i] else if (sparkLifes[i] > 8) spark = sparks2[i] else if (sparkLifes[i] > 6) spark = sparks3[i] else if (sparkLifes[i] > 4) spark = sparks4[i] else if (sparkLifes[i] > 2) spark = sparks5[i] else if (sparkLifes[i] > 0) spark = sparks6[i] if (spark != null) { spark.position.x = sparkXs[i]; spark.position.y = sparkYs[i]; } } } }); |
ステージクリア時に描画される星も同様の処理をおこないます。
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 |
let stars = []; connection.on("UpdateStarsToClient", function (xs, ys) { // すべての星のスプライトをカメラに写らない場所に移動 for (let i = 0; i < stars.length; i++) stars[i].position.x = -10000; if (xs != '') { // 星のスプライトが生成されていない場合は生成する let starXs = xs.split(','); let starYs = ys.split(','); if (stars.length == 0) { const mate = new THREE.SpriteMaterial({ map: new THREE.TextureLoader().load(base_url + '/frisbee/star.png') }); for (let i = 0; i < starXs.length; i++) { const sprite = new THREE.Sprite(mate); sprite.scale.set(48, 48, 48); scene.add(sprite); stars.push(sprite); } } // 表示させたい座標に移動させる for (let i = 0; i < starXs.length; i++) { stars[i].position.x = starXs[i]; stars[i].position.y = starYs[i]; } } }); |
クライアントサイドで更新処理をおこなううえで必要なデータがすべてサーバーサイドから送信されたら、最後にEndUpdateToClientが送信されます。これを受信したらレンダリングをおこないます。
引数のtimeはゲーム開始から経過した時間を文字列にしたものです。これをテキストフィールドに表示させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
connection.on("EndUpdateToClient", function (time) { if (maxX - CANVAS_WIDTH / 2 < cameraTargetX) camera.position.x = maxX - CANVAS_WIDTH / 2; else if (CANVAS_WIDTH / 2 < cameraTargetX) camera.position.x = cameraTargetX; else camera.position.x = CANVAS_WIDTH / 2; // カメラの距離はこのくらいがよさそう camera.position.y = 32 * 14; camera.position.z = 650; camera.lookAt(new THREE.Vector3(camera.position.x, 32 * 7, 0)); renderer.render(scene, camera); // レンダリング textField1.innerHTML = time; }); |
ミス時、ステージクリア時の処理
ミス時、クリア時の処理を示します。サーバーサイドからステージクリア時にはStageClearEventToClient、ミス時にはPlayerDeadEventToClientが送信されるので、これを受信したら適切な処理をおこないます。どちらの場合も効果音を鳴らす設定がされている場合は効果音を鳴らします。
クリア時はタイムトライアルランキングのページに強制的にリダイレクトします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
connection.on("StageClearEventToClient", function () { if (IsSound()) { clearSound.currentTime = 0; clearSound.play(); } setTimeout(() => { window.location = './hi-score'; }, 2000); }); connection.on("PlayerDeadEventToClient", function () { if (IsSound()) { deadSound.currentTime = 0; deadSound.play(); } }); |
ランキングのページの表示
ランキングのページのcshtmlは以下のようになっています。
Pages\Frisbee\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 |
@page @{ Layout = ""; } <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>鳩でもわかるUnityを使わずにフリスビーを犬に届けよ! 上位30位</title> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"> <link rel="stylesheet" href="../css/hiscore.css"> </head> <body> @{ string path = "../hiscore-frisbee.txt"; List<Hiscore> hiscores = TimeTrialManager.Load(path); } <div id = "container"> <div id = "h1">鳩でもわかるUnityを使わずにフリスビーを犬に届けよ! 上位30位</div> <div id = "left"> <input type="button" onclick="location.href='./game'" value="ゲームのページへ戻る" style="margin-bottom:30px;"> <div id = "result" > <table class="table" border="1" id="table"> @{ int num = 0; } @foreach(Hiscore hiscore in hiscores) { num++; TimeSpan span = new TimeSpan(0,0,0,0, (int)hiscore.Score); string time = string.Format("{0:00}分{1:00}秒", span.Minutes, span.Seconds); <tr> <td>@num 位</td> <td>@hiscore.Name</td> <td>@time</td> <td>@hiscore.Time</td> </tr> } @if (num < 30) { @for (num++; num <= 30; num++) { <tr> <td>@num 位</td> <td></td> <td></td> <td></td> </tr> } } </table> </div> </div> <div id = "right" > <!-- 見て欲しいページへのリンクを設置 --> </div> </div> </body> </html> |