クソゲーに魂を!プロジェクト(5)の続きです。今回はクライアントサイドにおける処理を定義します。
Contents
cshtml部分
cshtml部分とCSSを示します。
Pages\fire-snake\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 51 52 53 54 |
@page @{ string baseUrl = "https://lets-csharp.com/samples/2204/aspnetcore-app-zero"; // 公開したいurl Layout = null; } <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>新プロジェクト クソゲーに魂を!(火を吐くイモ虫ゲーム @SnakeGame4.Constant.Ver)</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/6.0.1/signalr.js"></script> <link rel="stylesheet" type="text/css" href="./style.css"> </head> <body> <div id = "container"> @Html.Raw(adText) <p class="ml"><a href="https://lets-csharp.com/" class="go-links">HOME</a> >> <a href="https://lets-csharp.com/app-samples/2024/08/game-links/" class="go-links">ゲームリンク集</a></p> <p id="conect-result"></p> <div id="field"> <canvas id="canvas"></canvas> <div id="map"><canvas id="map-canvas"></canvas></div> <div id="info"></div> <div id="player-name-outer"> <p class="center"> <button id="start" class="button2">スタート</button> </p> <p class="center"> <label>ハンドルネーム</label> <input type="text" id="player-name" maxlength='16' /> </p> <p class="center">PCは←→で左右の旋回ができます。</p> </div> <button id="left" class="button1">左旋回</button> <button id="right" class="button1">右旋回</button> <button id="shot" class="button1">発射</button> </div><!--/#field--> <div id="ranking"></div> <div id="volume-controller"></div> </div><!--/#container--> <script> let connection = new signalR.HubConnectionBuilder().withUrl("@baseurl/fire-snake-hub").build(); </script> <script src="./app.js" charset="utf-8"></script> </body> </html> |
wwwroot\fire-snake\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 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 |
@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; height: 520px; } #canvas { display:block; position: absolute; left: 0px; top: 0px; } #map { position: absolute; width: 120px; left: 20px; top: 400px; } #info { position: absolute; width: 220px; left: 160px; top: 400px; } .display-none { display: none; } #left, #right { top: 250px; } #left { left: 10px; display: none; } #right { left: 190px; display: none; } #shot { top: 330px; left: 100px; display: none; } .button1 { position: absolute; background-color: transparent; color: white; width: 160px; height: 70px; } .button2 { width: 280px; height: 60px; font-weight: bold; text-align: center; border: none; font-size: 16px; background-color: #1e90ff; border-radius: 100vh; color: white; cursor: pointer; text-decoration: none; margin-bottom: 10px; } .button2:hover { background-color: #87ceeb; } .center { text-align: center; } #player-name-outer { position: absolute; top: 80px; border: #f00 solid 0px; width: 100%; display: block; } .mt { margin-top: 50px; } #player-name { width: 200px; } #volume-controller { margin-top: 20px; margin-bottom: 20px; } .go-links { font-weight: bold; text-align: center; border: none; font-size: 16px; background-color: #1e90ff; padding: 6px 24px; border-radius: 100vh; color: white; cursor: pointer; text-decoration: none; margin-top: 10px; } .ml { margin-left: 10px; } |
グローバル変数と定数
グローバル変数と定数を示します。
wwwroot\fire-snake\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 71 72 73 |
let CANVAS_WIDTH = 360; let CANVAS_HEIGHT = 400; const getElement = (id) => document.getElementById(id); // ボタン const $start = getElement('start'); const $left = getElement('left'); const $right = getElement('right'); const $shot = getElement('shot'); const $goRankingPage = getElement('go-ranking-page'); const $goHowToPlay = getElement('go-how-to-play'); // div要素 const $container = getElement('container'); const $field = getElement('field'); const $map = getElement('map'); const $info = getElement('info'); const $playerNameOuter = getElement('player-name-outer'); const $playerName = getElement('player-name'); const $conectResult = getElement('conect-result'); const $headad1 = getElement('head-ad1'); // canvasとcontext const $canvas = getElement('canvas'); const ctx = $canvas.getContext('2d'); const $mapCanvas = getElement('map-canvas'); const mapCtx = $mapCanvas.getContext('2d'); // キーが押下されていることを示すフラグ let isLeftKeyDown = false; let isRightKeyDown = false; let isUpKeyDown = false; let isSpaceKeyDown = false; const connectionIDs = []; // ASP.NET SignalRで使われる一意の接続ID let isPreventDefault = false; // デフォルトの動作を抑止する let isGameovered = true; // 現在ゲームオーバーの状態 let playerID = 0; // ユーザーが操作しているPlayerの状態 let playerX = 0; let playerY = 0; let playerLength = 0; let fieldRadiusMax = 0; // フィールドの半径 let fieldRadius = 0; let ping = 0; // PING値 let rcvBytes = 0; // サーバーから受信したデータのバイト数 let drawCount = 0; // 描画された回数 const playerMap = new Map(); // 描画するオブジェクト const bulletsMap = new Map(); const foodsMap = new Map(); const ignoreHitCheckSet = new Set(); // 当たり判定が無効になっているPlayerID let rate = 1; // 描画の倍率 const colors = ['#f00', '#0f0', '#f0f', '#0ff', '#880', ]; // スネークの色 const faceImage1 = new Image(); // スネークの顔、弾丸、餌のイメージ const faceImage2 = new Image(); const bulletImage1 = new Image(); const bulletImage2 = new Image(); const foodImage = new Image(); // 効果音 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 soundWin = new Audio('./sounds/win.mp3'); const soundShot = new Audio('./sounds/shot.mp3'); const sounds = [soundGameOver, soundKill, soundMiss, soundWin, soundShot, bgm]; |
各クラスの定義
Playerと弾丸、餌を描画するためのデータをうけとったあと実際に描画処理をおこなうことができるように各クラスを定義しておきます。
Playerクラス
コンストラクタの引数はPlayerID、初期座標、初期の移動方向、PlayerName、ユーザーかNPCかを示すbool変数です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class Player { constructor(id, x, y, angle, name, isPlayer){ this.ID = id; this.Bodies = []; // 身体を構成する円(Bodyオブジェクト)の配列 this.Rotating = ''; // 左右どちらに旋回中か? this.Length = 32; this.Speed = 1.5; this.Radius = (Math.log10(this.Length * 2) * 4); this.Name = name; this.X = x; // 頭部の座標 this.Y = y; this.Angle = angle; this.Bodies.unshift(new Body(this.X, this.Y)); this.Score = 0; this.KillCount = 0; this.IsPlayer = isPlayer == 'true' ? true : false; } } |
Init関数は現在の体長と旋回方向、身体の各オブジェクトの座標からスネークを描画できるようにデータをセットするためのものです。
1 2 3 4 5 6 7 8 9 10 |
class Player { Init(length, rotating, xs, ys){ this.SetLength(length); this.Rotating = rotating; for(let i=0; i< xs.length; i++) this.Bodies.push(new Body(xs[i], ys[i])); if(this.Bodies.length > this.Length) this.Bodies.length = this.Length; } } |
Add関数は新しい円を追加することでスネークが移動したように見せかけるためのものです。オブジェクトに現在の頭の座標と進行方向、旋回状態が記録されているのでこれらから新しい頭の位置を計算して円を追加しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Player { Add(){ if(this.Rotating == 'left') this.Angle -= 0.06; if(this.Rotating == 'right') this.Angle += 0.06; this.X += Math.cos(this.Angle) * this.Speed; this.Y += Math.sin(this.Angle) * this.Speed; this.Bodies.unshift(new Body(this.X, this.Y)); if(this.Bodies.length > this.Length) this.Bodies.length = this.Length; } } |
SetLength関数は体長を変化させます。体長で太さも変化します。
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 |
class Player { SetLength(length){ this.Length = length; this.Radius = Math.min(Math.log10(this.Length * 2) * 4, 16); } } クライアントサイドで描画に使う値とサーバーサイドに保存されている値にズレが生じる場合があるので、ときどきサーバー側から確認用のデータが送信されます。Fix関数はこれを受信したときに修正処理をおこなうためのものです。 <pre class="lang:default decode:true " > class Player { Fix(x, y, angle){ x -= Math.cos(this.Angle) * this.Speed; y -= Math.sin(this.Angle) * this.Speed; this.X = x; this.Y = y; this.Angle = angle; if(this.Rotating == 'left') this.Angle += 0.06; if(this.Rotating == 'right') this.Angle -= 0.06; this.Bodies[0].X = x; this.Bodies[0].Y = y; } } |
Bodyオブジェクトにはスネークの身体にあたる円の中心座標が格納されます。
1 2 3 4 5 6 |
class Body { constructor(x, y){ this.X = x; this.Y = y; } } |
Bulletクラス
弾丸は発射されたら同じ速度で飛び続けるので初期座標と進行方向、タイプ、寿命が与えられたらクライアントサイドで処理をすることができます(当たり判定があったらサーバーサイドからその旨通知されるので消滅させる処理をすればよい)。
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 |
class Bullet { constructor(x, y, vx, vy, type, life){ this.X = x; this.Y = y; this.VX = vx; this.VY = vy; this.Type = type; this.Life = life; } Move(){ this.X += this.VX; this.Y += this.VY; this.Life--; } Draw(shiftX, shiftY){ const size = 24 * rate; const image = this.Type == 1 ? bulletImage1 : bulletImage2; if(this.Life < 10) ctx.globalAlpha = 0.1 * this.Life / 2; ctx.drawImage(image, this.X * rate - shiftX - size / 2, this.Y * rate - shiftY - size / 2, size, size); ctx.globalAlpha = 1; } Fix(x, y, vx, vy, life){ this.VX = vx; this.VY = vy; this.X = x - this.VX; this.Y = y - this.VY; this.Life = life; } } |
Foodクラス
餌の場合もだいたい同じように処理をすることができます。
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 |
class Food { constructor(x, y, vx, vy){ this.X = x; this.Y = y; this.VX = vx; this.VY = vy; } Move(){ this.X += this.VX; this.Y += this.VY; } Draw(shiftX, shiftY){ const size = 24 * rate; ctx.drawImage(foodImage, this.X * rate - shiftX - size / 2, this.Y * rate - shiftY - size / 2, size, size); } Fix(x, y, vx, vy){ this.VX = vx; this.VY = vy; this.X = x - this.VX; this.Y = y - this.VY; } } |
ページが読み込まれたときの処理
ページが読み込まれたときにおこなわれる処理を示します。
ページが読み込まれたらPlayerNameを入力するテキストボックスを表示して過去に入力した文字列があるのであればそれをローカルストレージから読み出してこれを表示します。そのあと描画にもちいるイメージの初期化、イベントリスナの追加、ボリューム調整用のレンジスライダーの初期化、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 |
window.onload = () => { $conectResult.innerHTML = `接続しようとしています`; changeLayout(); // 端末がPCかスマホかでレイアウトを変える $playerNameOuter.style.display = 'block'; const savedName = localStorage.getItem('hatodemowakaru-player-name'); if(savedName) $playerName.value = savedName; initImages(); // 後述 addEventListeners(); // 後述 initVolumeController('volume-controller', sounds); // 後述 setInterval(() => { // BGMをエンドレスで再生する if(bgm.currentTime >= 100) bgm.currentTime = 0; }, 500); connection.start().catch((err) => { $conectResult.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 37 38 39 40 41 42 43 44 45 46 47 |
function changeLayout(){ if(window.outerWidth > 800) // ウィンドウ幅でPCによるアクセスかどうかを調べている layoutPC(); else layoutSP(); } function layoutPC(){ CANVAS_WIDTH = 600; // 360 CANVAS_HEIGHT = 500; rate = 1.4; $container.style.width = '780px'; $field.style.height = '520px'; $field.style.border = '0px solid #f00'; $map.style.left = '640px'; $map.style.top = '250px'; $info.style.left = '600px'; $info.style.top = '0px'; $playerNameOuter.style.top = '40px'; $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; } function layoutSP(){ CANVAS_WIDTH = 360; CANVAS_HEIGHT = 400; $container.style.width = '360px'; $field.style.height = '520px'; $map.style.left = '20px'; $map.style.top = '400px'; $info.style.left = '160px'; $info.style.top = '400px'; $playerNameOuter.style.top = '80px'; $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; } |
描画にもちいるイメージを初期化します。
1 2 3 4 5 6 7 8 |
function initImages(){ faceImage1.src = './images/face1.png'; faceImage2.src = './images/face2.png'; bulletImage1.src = './images/fire.png'; bulletImage2.src = './images/bullet.png'; foodImage.src = './images/food.png'; } |
イベントリスナを追加する処理を示します。スタートボタンを押下したらゲームスタート、ボタンやキーを押下しているあいだはその方向に移動処理がおこなわれるようにしているだけです。
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 |
function addEventListeners(){ $start.addEventListener('click', (ev) => 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) => { ev.preventDefault(); connection.invoke('TurnLeft', arr2[i]); }); $right?.addEventListener(arr1[i], (ev) => { ev.preventDefault(); connection.invoke('TurnRight', arr2[i]); }); $shot?.addEventListener(arr1[i], (ev) => { ev.preventDefault(); connection.invoke('Shot'); }); } document.addEventListener('keydown', (ev) => { if (isPreventDefault) { // プレイ中は矢印キーを押下したときスクロールがおきないようにする 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("Shot"); } if (ev.code == 'Space' && !isSpaceKeyDown) { isSpaceKeyDown = true; connection.invoke("Shot"); } }); 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') { isUpKeyDown = false; } if (ev.code == 'Space') { isSpaceKeyDown = false; } }); $canvas.addEventListener('mousemove', (ev) => { connection.invoke("TurnByMouse", Math.atan2(ev.layerY - CANVAS_HEIGHT / 2, ev.layerX - CANVAS_WIDTH / 2)); }); $canvas.addEventListener('mousedown', (ev) => connection.invoke('Shot')); document.oncontextmenu = () => false; // 右クリックでコンテキストメニューがでないようにする $playerName.oncontextmenu = (ev) => { // ただしテキストボックスは別 ev.stopPropagation(); return true; } } |
レンジスライダーでボリューム調整できるようにする処理を示します。この部分はいつもと同じです。
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 |
function initVolumeController(elementId, sounds){ let volume = 0.3; 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による接続が成功した時におこなわれる処理を示します。
接続に成功したらサーバーサイドから”SendToClientConnectionSuccessful”とフィールドを描画するためのデータが送信されるので描画に必要なデータをグローバル変数に保存しておきます。
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 |
let intervalID = null; connection.on("SendToClientConnectionSuccessful", (id, pID, radius, playersText, foodsText, bulletsText) => { connectionIDs.push(id); $conectResult.innerHTML = `接続完了`; if(connectionIDs.length > 1){ // 再接続されたとき const cur = connectionIDs[connectionIDs.length - 1]; const old = connectionIDs[connectionIDs.length - 2]; connection.invoke("OnReConnected", cur, old); } else { playerID = pID; fieldRadiusMax = radius; playerX = fieldRadiusMax; playerY = fieldRadiusMax; } // Player、弾丸、餌の情報をいったんリセットする playerMap.clear(); foodsMap.clear(); bulletsMap.clear(); // 受信した文字列からPlayerオブジェクトを生成してMapに格納する // 各Playerごとに '(ID)\t(X座標)\t(Y座標)\t(進行方向)\t(PlayerName)\t //(ユーザーがNPCか)\t(体長)\t(旋回方向)\t(身体のX座標)\t(身体のY座標)'が改行区切りで送られてくる const players = playersText.split('\n'); for (let index = 0; index < players.length; index++) { const arr = players[index].split('\t'); const playerID = Number(arr[0]); const x = Number(arr[1]); const y = Number(arr[2]); const angle = Number(arr[3]); const name = arr[4]; const isPlayer = arr[5]; const length = Number(arr[6]); const rotating = arr[7]; playerMap.set(playerID, new Player(playerID, x, y, angle, name, isPlayer)); const xs = []; const ys = []; for(let i=0; i<arr.length; i++){ xs.push(Number(arr[i * 2 + 8])); ys.push(Number(arr[i * 2 + 1 + 8])); } const player = playerMap.get(playerID); player.Init(length, rotating, xs, ys) } // 受信した文字列からFoodオブジェクトを生成してMapに格納する // 餌ごとに '(ID),(X座標),(Y座標),(X方向の移動速度),(Y方向の移動速度), ... const foods = foodsText.split(','); for(let i=0; i<foods.length; i += 5){ const ID = Number(foods[i]); const x = Number(foods[i + 1]); const y = Number(foods[i + 2]); const vx = Number(foods[i + 3]); const vy = Number(foods[i + 4]); if(!foodsMap.has(ID)) foodsMap.set(ID, new Food(x, y, vx, vy)); } // 受信した文字列からBulletオブジェクトを生成してMapに格納する // 弾丸ごとに '(ID),(X座標),(Y座標),(X方向の移動速度),(Y方向の移動速度),(タイプ),(寿命) ... const bullets = bulletsText.split(','); for(let i=0; i<bullets.length; i += 7){ const ID = Number(bullets[i]); const x = Number(bullets[i + 1]); const y = Number(bullets[i + 2]); const vx = Number(bullets[i + 3]); const vy = Number(bullets[i + 4]); const type = Number(bullets[i + 5]); const life = Number(bullets[i + 6]); bulletsMap.set(ID, new Bullet(x, y, vx, vy, type, life)); } if(intervalID == null){ intervalID = setInterval(() => { // 1秒おきにPING値を取得する const now = new Date(); const time = now.getTime(); connection.invoke('Ping', time); }, 1000); } }); |
通信が切れてしまったときの再接続時の処理
通信が切れてしまったあと再接続されたときにおこなわれる処理を示します。
なんらかの原因で通信が切れてしまったときは0.1秒後に再接続を試みます。再接続に失敗したときはさらに1秒待ってからもう一度再接続を試みます。それでもダメならなにもせず接続失敗を伝えるメッセージを表示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
connection.onclose( () => { if($conectResult != null){ $conectResult.innerHTML = '通信が切断されました(0.1秒後に再接続を試みます)'; connectionRestart(); } }); async function connectionRestart(){ await sleep(100); connection.start().catch(async(err) => { $conectResult.innerHTML = '接続失敗(1秒後にもう一度接続を試みます)'; await sleep(1000); connection.start().catch((err) => { $conectResult.innerHTML = '接続失敗'; }); }); } async function sleep(ms){ new Promise(resolve => setTimeout(resolve, ms)); } |
ゲーム開始時の処理
ユーザーがゲームを開始しようとしたときにおこなわれる処理を示します。
テキストボックスに入力されている文字列をPlayerNameとしてサーバーサイドに送信します。この処理が成功したときはサーバーサイドから”SendToClientGameStartSuccessful”が送信されます。
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); } |
ゲーム開始の処理が成功したときにおこなわれる処理を示します。
isGameoveredフラグをクリアしてPlayerName入力用のテキストボックスを非表示にします。端末がスマホのときは操作用のボタンを表示します。そのあとBGMを再生します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
connection.on("SendToClientGameStartSuccessful", () => { isPreventDefault = true; // 矢印キー操作時のデフォルトの動作(スクロール)を抑止する isGameovered = false; $playerNameOuter.style.display = 'none'; if(window.outerWidth <= 640){ $left.style.display = 'block'; $right.style.display = 'block'; $shot.style.display = 'block'; } bgm.currentTime = 0; bgm.play(); $conectResult.innerHTML = 'PLAY'; }); |
ゲームオーバー時の処理
ゲームオーバー時におこなわれる処理を示します。
isGameoveredフラグをセットしてスマホでプレイしていた場合は操作用ボタンを非表示にします。またBGMを停止してミス時の効果音を鳴らします。しばらく待機したあとゲームオーバーの効果音とゲームスタート用のボタン、PlayerName入力用のテキストボックスを再表示させます。
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 |
connection.on("SendToClientGameOvered", () => { isGameovered = true; $left.style.display = 'none'; $right.style.display = 'none'; $shot.style.display = 'none'; connection.invoke("TurnLeft", false); connection.invoke("TurnRight", false); // スマホだと操作用のボタンが非表示になってしまうので明示的にフラグを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'; bgm.pause(); soundGameOver.currentTime = 0; soundGameOver.play(); }, 2000); }); |
効果音の再生
サーバーサイドからイベントが送信されたときはそれに応じた効果音を鳴らします。
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("MatchBegun", () => { if (!isGameovered) { bgm.currentTime = 0; bgm.play(); } else $playerNameOuter.style.display = 'block'; playerMap.clear(); foodsMap.clear(); bulletsMap.clear(); }); // 弾丸を発射した connection.on("SendToClientShoted", () => { soundShot.currentTime = 0; soundShot.play(); }); // 敵を倒した connection.on("SendToClientKillPlayer", () => { soundKill.currentTime = 0; soundKill.play(); }); // すべての敵を倒して優勝した connection.on("SendToClientWin", () => { setTimeout(() => { soundWin.currentTime = 0; soundWin.play(); }, 500); }); |
サーバーサイドから送信された”SendToClientPing”を受信したときはPing値を計算してグローバル変数に格納しておきます。
1 2 3 4 5 |
connection.on("SendToClientPing", (time) => { const now = new Date(); const time2 = now.getTime(); ping = time2 - time; }); |
更新と描画の処理
更新と描画の処理を示します。
更新に必要なデータをサーバーサイドから受信したときはこれを解析して描画用のオブジェクトを更新します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
connection.on("SendToClientUpdate", (updateText) => { rcvBytes += getStringByteCount(updateText); // 受信バイト数 const arr = updateText.split('<\n>'); fieldRadius = Number(arr[0]); const playersText = arr[1]; const foodsText = arr[2]; const bulletsText = arr[3]; updatePlayers(playersText); // 後述 updateFoods(foodsText); // 後述 updateBullets(bulletsText); // 後述 draw(); // 後述 }); function getStringByteCount(str) { return new Blob([str]).size } |
Playerの更新
受信した文字列から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 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 |
function updatePlayers(playersText){ const keys = playerMap.keys(); for(let key of keys) playerMap.get(key).Add(); // 進行方向にスネークを移動させる if(playersText == "") return; // 新しく生成されたPlayerが存在する場合 const arr = playersText.split('\n'); let text = arr[0]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 6){ const playerID = Number(arr2[i]); const angle = Number(arr2[i + 1]); const x = Number(arr2[i + 2]); const y = Number(arr2[i + 3]); const name = arr2[i + 4]; const isPlayer = arr2[i + 5]; playerMap.set(playerID, new Player(playerID, x, y, angle, name, isPlayer)); } } // 旋回を開始または停止したPlayerが存在する場合 text = arr[1]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 2){ const playerID = Number(arr2[i]); const ev = arr2[i + 1]; const player = playerMap.get(playerID); if(player == null) continue; if(ev == 's') player.Rotating = ''; if(ev == 'l') player.Rotating = 'left'; if(ev == 'r') player.Rotating = 'right'; } } // 体長が変化したPlayerが存在する場合 text = arr[2]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 2){ const playerID = Number(arr2[i]); const length = Number(arr2[i + 1]); const player = playerMap.get(playerID); if(player != null) player.SetLength(length) } } // スコアが変化したPlayerが存在する場合 text = arr[3]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 2){ const playerID = Number(arr2[i]); const score = Number(arr2[i + 1]); const player = playerMap.get(playerID); if(player != null) player.Score = score; } } // 倒した敵の数が変化したPlayerが存在する場合 text = arr[4]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 2){ const playerID = Number(arr2[i]); const kill = Number(arr2[i + 1]); const player = playerMap.get(playerID); if(player != null) player.KillCount = kill; } } // 消滅したPlayerが存在する場合 text = arr[5]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i++) playerMap.delete(Number(arr2[i])); } // 当たり判定を無効にすべきPlayerが存在する場合 text = arr[6]; ignoreHitCheckSet.clear(); if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i++) ignoreHitCheckSet.add(Number(arr2[i])); } // Player.Fix関数を実行しなければならない場合 text = arr[7]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 4){ const playerID = Number(arr2[i]); const x = Number(arr2[i + 1]); const y = Number(arr2[i + 2]); const angle = Number(arr2[i + 3]); const player = playerMap.get(playerID); if(player != null) player.Fix(x, y, angle); } } } |
Bulletの更新
受信した文字列からBulletオブジェクトを更新する処理を示します。
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 |
function updateBullets(bulletsText){ // 存在する弾丸の移動処理をおこなう const values = bulletsMap.values(); for (let bullet of values) bullet.Move(); const arr = bulletsText.split('\n'); // 新しく発射された弾丸が存在する場合 let text = arr[0]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 7){ const ID = Number(arr2[i]); const x = Number(arr2[i + 1]); const y = Number(arr2[i + 2]); const vx = Number(arr2[i + 3]); const vy = Number(arr2[i + 4]); const type = Number(arr2[i + 5]); const life = Number(arr2[i + 6]); const bullet = new Bullet(x, y, vx, vy, type, life); bulletsMap.set(ID, bullet); } } // 消滅した弾丸が存在する場合 text = arr[1]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i++){ const ID = Number(arr2[i]); bulletsMap.delete(ID); } } // Bullet.Fix関数を実行しなければならない場合 text = arr[2]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 6){ const ID = Number(arr2[i]); const x = Number(arr2[i + 1]); const y = Number(arr2[i + 2]); const vx = Number(arr2[i + 3]); const vy = Number(arr2[i + 4]); const life = Number(arr2[i + 5]); const bullet = bulletsMap.get(ID); if(bullet != null) bullet.Fix(x, y, vx, vy, life); } } } |
Foodの更新
受信した文字列からFoodオブジェクトを更新する処理を示します。
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 |
function updateFoods(foodsText){ // 存在する餌の移動処理をおこなう const values = foodsMap.values(); for (let food of values) food.Move(); if(foodsText == '') return; const arr = foodsText.split('\n'); // 新しく生成された餌が存在する場合 let text = arr[0]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 5){ const ID = Number(arr2[i]); const x = Number(arr2[i + 1]); const y = Number(arr2[i + 2]); const vx = Number(arr2[i + 3]); const vy = Number(arr2[i + 4]); const food = new Food(x, y, vx, vy); foodsMap.set(ID, food); } } // 移動方向が変更された餌が存在する場合 text = arr[1]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 5){ const ID = Number(arr2[i]); const x = Number(arr2[i + 1]); const y = Number(arr2[i + 2]); const vx = Number(arr2[i + 3]); const vy = Number(arr2[i + 4]); const food = foodsMap.get(ID); if(food != null){ food.X = x; food.Y = y; food.VX = vx; food.VY = vy; } } } // 消滅した餌が存在する場合 text = arr[2]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i++){ const ID = Number(arr2[i]); foodsMap.delete(ID); } } // Food.Fix関数を実行しなければならない場合 text = arr[3]; if(text != ''){ const arr2 = text.split('\t'); for(let i=0; i<arr2.length; i += 5){ const ID = Number(arr2[i]); const x = Number(arr2[i + 1]); const y = Number(arr2[i + 2]); const vx = Number(arr2[i + 3]); const vy = Number(arr2[i + 4]); const food = foodsMap.get(ID); if(food != null) food.Fix(x, y, vx, vy); } } } |
描画
更新されたオブジェクトを描画する処理を示します。
まず自機の頭部が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 36 |
function draw() { drawCount++; ctx.fillStyle = '#000'; ctx?.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // 全体をどれだけズラして描画するか? const px = playerX; const py = playerY; const shiftX = px * rate - CANVAS_WIDTH / 2; const shiftY = py * rate - CANVAS_HEIGHT / 2; // playerX、playerYを更新しておく const player = playerMap.get(playerID); if(player != null){ playerLength = player.Length; playerX = player.X; playerY = player.Y; } drawField(shiftX, shiftY); // フィールドの境界線を描画(後述) for (let bullet of bulletsMap.values()) bullet.Draw(shiftX, shiftY); for (let food of foodsMap.values()) food.Draw(shiftX, shiftY); // スネークの身体が発光しているっぽくする const level = (Math.abs(drawCount % 120 - 60)) / 60; // 0~1.0 const shadowBlur = 16 * level + 4; // スネークの身体と頭を描画する drawBodies(shiftX, shiftY, shadowBlur); // 後述 drawHeads(shiftX, shiftY); // 後述 } |
フィールドの境界線を描画する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 |
function drawField(shiftX, shiftY){ ctx.strokeStyle = '#fff'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(fieldRadiusMax * rate - shiftX, fieldRadiusMax * rate - shiftY, fieldRadius * rate, 0, Math.PI * 2); ctx.shadowBlur = 16; ctx.shadowColor = '#fff'; ctx.stroke(); ctx.shadowBlur = 0; ctx.lineWidth = 1; } |
スネークの身体と頭を描画する処理を示します。
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 drawBodies(shiftX, shiftY, shadowBlur){ const keys = playerMap.keys(); for(let key of keys){ if(ignoreHitCheckSet.has(key) && drawCount % 2 == 1) continue; const bodies = playerMap.get(key).Bodies; for(let i=bodies.length-1; i>=0; i--){ if(i % 2 != 0) continue; const body = bodies[i]; const x = body.X * rate- shiftX; const y = body.Y * rate - shiftY; const radius = playerMap.get(key).Radius; if (x > -10 && x < CANVAS_WIDTH + 10 && y > -10 && y < CANVAS_HEIGHT + 10){ ctx.beginPath(); ctx.arc(x, y, radius * rate, 0, Math.PI * 2); ctx.strokeStyle = '#fff'; ctx.stroke(); ctx.shadowBlur = 0; if (i % 4 == 0) { ctx.beginPath(); ctx.arc(x, y, radius * rate, 0, Math.PI * 2); const idx = key % colors.length; ctx.shadowBlur = shadowBlur; ctx.shadowColor = colors[idx]; ctx.fillStyle = colors[idx]; ctx.fill(); ctx.shadowBlur = 0; } else { ctx.beginPath(); ctx.arc(x, y, radius * rate, 0, Math.PI * 2); ctx.shadowBlur = 0; ctx.fillStyle = '#fff'; ctx.fill(); ctx.shadowBlur = 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 |
function drawHeads(shiftX, shiftY){ const keys = playerMap.keys(); for(let key of keys){ const player = playerMap.get(key); const bodies = player.Bodies; const head = bodies[0]; const x = head.X * rate - shiftX; const y = head.Y * rate - shiftY; if (x > -10 && x < CANVAS_WIDTH + 10 && y > -10 && y < CANVAS_HEIGHT + 10){ // Playerの頭の近くにPlayerName、スコア等を描画する ctx.fillStyle = '#fff'; ctx.font = 'bold 16px Arial'; ctx.textBaseline = 'top'; if(player.Length > playerLength) ctx.fillStyle = '#f00'; else if(player.Length < playerLength) ctx.fillStyle = '#0f0'; else ctx.fillStyle = '#ff0'; if(player.IsPlayer) ctx.font = 'bold 16px Arial'; else ctx.font = '16px Arial'; ctx.fillText(player.Name, x + 10 * rate, y); ctx.fillText(`Length ${player.Length}`, x + 10 * rate, y + 20); ctx.fillText(`Kill ${player.KillCount}`, x + 10 * rate, y + 40); if(player.IsPlayer) ctx.fillText(`Score ${player.Score.toLocaleString()}`, x + 10 * rate, y + 60); // 顔のイメージを進行方向に応じて回転した状態で描画する const faceImage = key == playerID ? faceImage1 : faceImage2; ctx?.save(); ctx?.translate(x, y); ctx?.rotate(player.Angle - Math.PI / 2); ctx?.translate(-x, -y); const radius = (player.Radius + 16) * rate; ctx.drawImage(faceImage, x - radius/2, y - radius/2, radius, radius); ctx?.restore(); } } } |
バトルが終了したら勝者に関する情報を描画する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
connection.on("SendToClientMatchIsFinished", (winnerName, bonus) => { rcvBytes += getStringByteCount(winnerName); rcvBytes += getStringByteCount(bonus); bgm.pause(); // バトルは終了しているのでBGMは止める ctx.fillStyle = '#000'; ctx?.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); if(window.outerWidth > 800) ctx.font = 'bold 32px Arial'; else ctx.font = 'bold 18px Arial'; ctx.fillStyle = '#fff'; const text1 = `勝者は ${winnerName} です`; ctx.fillText(text1, (CANVAS_WIDTH - ctx.measureText(text1).width) / 2, 100); if(bonus > 0){ const text2 = `BONUS ${bonus.toLocaleString()} Points`; ctx.fillText(text2, (CANVAS_WIDTH - ctx.measureText(text2).width) / 2, 160); } // ボタン類で勝者情報がうまく見えないのでこのときは開始ボタンなどを非表示にする $playerNameOuter.style.display = 'none'; }); |
フィールドの状態を表示する
“SendToClientUpdateFieldStatus”を受信したらフィールドの状態(生存するPlayer数や自機の座標、残り時間など)をページ上に表示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
connection.on("SendToClientUpdateFieldStatus", (fieldStatusText) => { rcvBytes += getStringByteCount(fieldStatusText); if(fieldStatusText == '') return; const arr = fieldStatusText.split(','); let alivePlayerCount = Number(arr[0]); // 生存するPlayer数 let aliveNpcCount = Number(arr[1]); const remainingTime = Number(arr[2]); // 残り時間 // 自機の座標、Ping値もあわせて表示する let text = `X = ${(playerX - 800).toLocaleString()} / ${(fieldRadius).toLocaleString()}<br>Y = ${(playerY - 800).toLocaleString()} / ${(fieldRadius).toLocaleString()}<br>`; text += `Player = ${alivePlayerCount}, NPC = ${aliveNpcCount}<br>`; text += `Ping = ${ping} ms<br>Rcv = ${(Math.ceil(rcvBytes / 1024)).toLocaleString()} KB`; if(window.outerWidth <= 800) $conectResult.innerHTML = `残り時間 ${remainingTime}`; else text = `<span style = "color:#0ff; font-size: 20px; font-weight:bold;">残り時間 ${remainingTime}</span><br><br>${text}`; $info.innerHTML = `${text}`; }); |
レーダーへの描画
“SendToClientUpdateRadar”を受信したらレーダーに各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 |
connection.on("SendToClientUpdateRadar", () => { const allPlayers = []; const values = playerMap.values(); for(let player of values) allPlayers.push(player); mapCtx.fillStyle = '#000'; mapCtx.fillRect(0, 0, 100, 100); mapCtx.beginPath(); mapCtx.arc(50, 50, 50 * fieldRadius / fieldRadiusMax, 0, Math.PI * 2); mapCtx.fill(); mapCtx.strokeStyle = '#888'; mapCtx.stroke(); allPlayers.forEach(_ => { const x = Number(Math.floor(_.X)) / (fieldRadiusMax * 2) * 100; const y = Number(Math.floor(_.Y)) / (fieldRadiusMax * 2) * 100; let r = 0; if(_.ID == playerID){ // 自機 mapCtx.fillStyle = '#0f0'; r = 3; } else if(_.IsPlayer){ // 敵(ユーザー) mapCtx.fillStyle = '#f00'; r = 3; } else if(!_.IsPlayer){ // 敵(NPC) mapCtx.fillStyle = '#fff'; r = 2; } mapCtx.beginPath(); mapCtx.arc(x, y, r, 0, Math.PI * 2); mapCtx.fill(); }); }); |
“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 |
connection.on("SendToClientUpdatePlayersStatus", () => { const players = []; const values = playerMap.values(); for(let player of values){ if(player.IsPlayer) players.push(player); } 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, Score)</td>'; rankingTableHTML += '</tr>'; let rank = 0; players.forEach(_ => { rank++; const name = _.Name.split('<').join('<').split('>').join('>'); rankingTableHTML += '<tr>'; rankingTableHTML += `<td>${rank}</td>`; rankingTableHTML += `<td>${name}</td>`; rankingTableHTML += `<td>(${Math.floor(_.X)}, ${Math.floor(_.Y)}, ${_.Score})</td>`; rankingTableHTML += '</tr>'; }); rankingTableHTML += '</table>'; document.getElementById('ranking').innerHTML = rankingTableHTML; }); |