JavaScriptで『スペースウォー!』(Spacewar!)をつくる(1)の続きです。前回定義したクラスをつかってゲームを完成させます。
Contents
ページが読み込まれたときの処理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
window.onload = () => { $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); player = new Player(0); enemy = new Player(1); // 効果音のボリュームを初期値にする $volumeRange.value = volume $volumeValue.innerText = volume; // スマホ用の操作ボタンを非表示にする機能は初期状態では off にしておく $hideButtons.checked = false; addEventListeners(); // イベントリスナの追加 showButtons(); // スマホ用の操作ボタンの表示と非表示 update(); // 更新処理の開始 } |
イベントリスナを追加する処理を示します。
STARTボタンがクリックされたときにゲーム開始する処理、スマホ用の操作ボタンがクリックされたときの処理、ボリューム調整用のレンジスライダーを操作したときの処理、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 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 |
function addEventListeners(){ // STARTボタンがクリックされたときにゲーム開始する処理 $start.addEventListener('click', () => { $start.style.display = 'none'; isPlaying = true; }); // スマホ用の操作ボタンがクリックされたときの処理 const arr1 = ['mousedown', 'touchstart']; const arr2 = ['mouseup', 'touchend']; for(let i=0; i<2; i++){ $up.addEventListener(arr1[i], () => onPressUp(true)); $left.addEventListener(arr1[i], () => onPressLeft(true)); $right.addEventListener(arr1[i], () => onPressRight(true)); $shot.addEventListener(arr1[i], () => shot()); $up.addEventListener(arr2[i], () => onPressUp(false)); $left.addEventListener(arr2[i], () => onPressLeft(false)); $right.addEventListener(arr2[i], () => onPressRight(false)); } $up.addEventListener('touchstart', (ev) => ev.preventDefault()); $left.addEventListener('touchstart', (ev) => ev.preventDefault()); $right.addEventListener('touchstart', (ev) => ev.preventDefault()); $shot.addEventListener('touchstart', (ev) => ev.preventDefault()); document.addEventListener('mouseup', () => { onPressUp(false); onPressLeft(false); onPressRight(false); }); // ボリューム調整用のレンジスライダーを操作したときの処理 $volumeRange.oninput = () => { volume = Number($volumeRange.value); $volumeValue.innerText = volume; } // ボリュームのテストボタンがクリックされたとき document.getElementById('volume-test').addEventListener('click', () => { explodeSound.volume = volume; explodeSound.currentTime = 0; explodeSound.play(); }); // PCでキー操作されたとき document.onkeydown = (ev) => { if(isPlaying) ev.preventDefault(); if(ev.code == 'ArrowUp') onPressUp(true); if(ev.code == 'ArrowLeft') onPressLeft(true); if(ev.code == 'ArrowRight') onPressRight(true); if(ev.code == 'Space') shot(); } document.onkeyup = (ev) => { if(ev.code == 'ArrowUp') onPressUp(false); if(ev.code == 'ArrowLeft') onPressLeft(false); if(ev.code == 'ArrowRight') onPressRight(false); } function onPressLeft(press){ pressLeft = press; } function onPressRight(press){ pressRight = press; } // 加速ボタンを押下/離したときに実行される関数 let intervalPressUp = null; function onPressUp(press){ // 押しっぱなしにすると0.25秒おきに加速を繰り返す if(press && !pressUp){ if(player.Accelerate()){ speedupSound.volume = volume; speedupSound.currentTime = 0; speedupSound.play(); intervalPressUp = setInterval(() => { player.Accelerate(); }, 250); } } if(!press && intervalPressUp != null){ clearInterval(intervalPressUp); intervalPressUp = null; } pressUp = press; } // SHOTボタンを押下ときに実行される関数 let soundNumber = 0; function shot(){ if(player.Shot()){ soundNumber++; soundNumber %= shotSounds.length; shotSounds[soundNumber].volume = volume; shotSounds[soundNumber].currentTime = 0; shotSounds[soundNumber].play(); } } } |
showButtons関数は更新時にプレイ時でスマホ用操作ボタンの表示設定がONのときだけスマホ用の操作ボタンを表示させます。それ以外のときはボタンを非表示にします。
1 2 3 4 5 6 7 8 |
function showButtons(){ requestAnimationFrame(showButtons); const $ctrlButtons = document.getElementById('ctrl-buttons'); if(isPlaying && !document.getElementById('hide-buttons').checked) $ctrlButtons.style.display = 'block'; else $ctrlButtons.style.display = 'none'; } |
更新時の処理
更新時の処理を示します。プレイヤーの状態、火球の状態を更新して描画処理をおこないます。
1 2 3 4 5 6 7 |
function update(){ requestAnimationFrame(update); if(isPlaying) playersUpdate(); // 後述 draw(); // 後述 } |
プレイヤー(と火球)の状態を更新する処理を示します。
移動ボタンが押下されているときはその方向に回転させます。そのあとPlayer.Update関数を呼び出しますが、さらにそのあとpullToSun関数を呼び出します。これは太陽の重力を作用させる関数です。敵の動作と更新処理をしたあと、火球が存在する場合はこの更新処理をおこないます。最後に当たり判定をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function playersUpdate(){ if(pressLeft) player.RotateL(); if(pressRight) player.RotateR(); player.Update(); pullToSun(player); // 後述 enemyThink(); // 後述 enemy.Update(); pullToSun(enemy); fireballs.forEach(fireball => fireball.Update()); check(); // 後述 } |
太陽の重力を作用させる処理を示します。
まずプレイヤーと太陽の距離から重力を計算します。重力は距離の2乗に反比例します。そのあとどの方向に作用させるかを求めます。これは太陽の座標とプレイヤーの座標をMath.atan2関数に渡せば求められます。あとは重力をプレイヤーの移動速度に加算します。
1 2 3 4 5 6 7 8 9 10 |
function pullToSun(player){ // 重力の大きさ const distance = Math.sqrt(Math.pow(player.CenterX - CANVAS_WIDTH / 2, 2) + Math.pow(player.CenterY - CANVAS_HEIGHT / 2, 2)); const gravity = 80 / Math.pow(distance, 2); // 方向 const angle = Math.atan2(CANVAS_HEIGHT / 2 - player.CenterY, CANVAS_WIDTH / 2 - player.CenterX); player.VX += gravity * Math.cos(angle); player.VY += gravity * Math.sin(angle); } |
当たり判定と衝突後の処理
当たり判定の処理を示します。
片方がすでに死亡している場合は勝負がついているので当たり判定はしません。当たり判定をするときは太陽に衝突したか?弾丸が敵に命中したか?プレイヤー同士が衝突したか?を調べます。衝突時は死亡したプレイヤーの中心部を中心に爆発を発生させます。そのあと勝った場合、負けた場合、引き分けの処理をおこないます。
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 |
function check(){ // 片方がすでに死亡している場合は勝負がついているので当たり判定はしない if(player.IsDead || enemy.IsDead) return; // 太陽に衝突したか? if(isPlayerCrashSun(player)){ // 後述 player.IsDead = true; explode(player.CenterX, player.CenterY, 1); } if(isPlayerCrashSun(enemy)){ enemy.IsDead = true; explode(enemy.CenterX, enemy.CenterY, 0); } // 弾丸は命中したか? if(isBulletHit(player.Bullets, enemy)){ // 後述 enemy.IsDead = true; explode(enemy.CenterX, enemy.CenterY, 0); } if(isBulletHit(enemy.Bullets, player)){ player.IsDead = true; explode(player.CenterX, player.CenterY, 1); } // 自機と敵が衝突したか? if(isCrashPlayers()){ // 後述 player.IsDead = true; explode(player.CenterX, player.CenterY, 1); enemy.IsDead = true; explode(enemy.CenterX, enemy.CenterY, 0); } if(player.IsDead && enemy.IsDead) // 共倒れ onDraw(); // 後述 else if(enemy.IsDead) // 勝ち onWin(); // 後述 else if(player.IsDead) // 負け onLose(); // 後述 } |
太陽との衝突判定
プレイヤーが太陽に衝突したかどうかを調べる処理を示します。(プレイヤーの中心と太陽の中心との距離)と太陽の半径を比較しているだけです。
1 2 3 4 5 6 |
function isPlayerCrashSun(player){ if(player.IsDead) return false; else return Math.pow(player.CenterX - CANVAS_WIDTH / 2, 2) + Math.pow(player.CenterY - CANVAS_HEIGHT / 2, 2) < Math.pow(SUN_RADIUS, 2); } |
弾丸との衝突判定
isBulletHit関数は第一引数で渡された弾丸の配列が第二引数で渡されたプレイヤーに命中しているかどうかを調べます。
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 |
function isBulletHit(bullets, targetPlayer){ if(targetPlayer.IsDead) return false; // ctx.isPointInPath関数で弾丸がプレイヤーの内部にあるかどうかを調べるために // プレイヤーが存在する位置にパスを作成する ctx.save(); ctx.translate(targetPlayer.CenterX, targetPlayer.CenterY); ctx.rotate(targetPlayer.Angle); ctx.translate(-targetPlayer.CenterX, -targetPlayer.CenterY); let playerNumber = 0; if(targetPlayer == player) playerNumber = 0; else playerNumber = 1; ctx.beginPath(); ctx.moveTo(playerOuterPoints[playerNumber][0].X + targetPlayer.CenterX, playerOuterPoints[playerNumber][0].Y + targetPlayer.CenterY); for(let i = 1; i < playerOuterPoints[playerNumber].length; i++){ ctx.lineTo(playerOuterPoints[playerNumber][i].X + targetPlayer.CenterX, playerOuterPoints[playerNumber][i].Y + targetPlayer.CenterY); } ctx.closePath(); ctx.restore(); // 作成したパス内に弾丸は存在するか調べる for(let i = 0; i < bullets.length; i++){ if(ctx.isPointInPath(bullets[i].X, bullets[i].Y)) return true; } return false; } |
プレイヤー同士が衝突判定
isCrashPlayers関数はプレイヤー同士が衝突したかどうかを調べます。playerInnerPointsで自機のパスを生成して、そのなかの敵機のplayerInnerPointsに含まれる点が存在するかを調べます。
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 isCrashPlayers(){ if(player.IsDead || enemy.IsDead) return false; ctx.save(); ctx.translate(player.CenterX, player.CenterY); ctx.rotate(player.Angle); ctx.translate(-player.CenterX, -player.CenterY); ctx.beginPath(); ctx.moveTo(playerInnerPoints[0][0].X + player.CenterX, playerInnerPoints[0][0].Y + player.CenterY); for(let i = 1; i < playerInnerPoints[0].length; i++){ ctx.lineTo(playerInnerPoints[0][i].X + player.CenterX, playerInnerPoints[0][i].Y + player.CenterY); } ctx.closePath(); ctx.restore(); const arrX = []; const arrY = []; for(let i=0; i<playerInnerPoints[1].length; i++){ const x = Math.cos(enemy.Angle) * playerInnerPoints[1][i].X - Math.sin(enemy.Angle) * playerInnerPoints[1][i].Y + enemy.CenterX; const y = Math.sin(enemy.Angle) * playerInnerPoints[1][i].X + Math.cos(enemy.Angle) * playerInnerPoints[1][i].Y + enemy.CenterY; arrX.push(x); arrY.push(y); } for(let i = 0; i < arrX.length; i++){ if(ctx.isPointInPath(arrX[i], arrY[i])) return true; } return false; } |
勝敗決着後の処理
勝負がついたときの処理を示します。勝ったほうのスコアに1を加算します。3秒後に両プレイヤーの位置をリセットして次のゲームに移れるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function onWin(){ player.Score++; setTimeout(() => { player.Init(); enemy.Init(); }, 3000); } function onLose(){ enemy.Score++; setTimeout(() => { player.Init(); enemy.Init(); }, 3000); } function onDraw(){ setTimeout(() => { player.Init(); enemy.Init(); }, 3000); } |
描画処理
描画処理を示します。
背景を黒で塗りつぶし、プレイヤーと太陽、火球、スコアを描画します。
1 2 3 4 5 6 7 8 9 10 11 12 |
function draw(){ ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); player.Draw(); enemy.Draw(); drawSun(); // 後述 fireballs.forEach(fireball => fireball.Draw()); drawScore(); // 後述 } |
太陽を描画する処理を示します。
1 2 3 4 5 6 7 8 9 10 11 |
function drawSun(){ ctx.beginPath(); ctx.arc(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2, SUN_RADIUS, 0, Math.PI * 2); ctx.shadowColor = '#f00'; ctx.shadowBlur = 24; ctx.fillStyle = '#000'; ctx.fill(); ctx.fill(); ctx.fill(); ctx.shadowBlur = 0; } |
スコアを描画する処理を示します。
1 2 3 4 5 6 |
function drawScore(){ ctx.fillStyle= '#fff'; ctx.textBaseline= 'top'; ctx.font = '24px Arial'; ctx.fillText(`${player.Score} - ${enemy.Score}` , 280, 10); } |
敵の思考ルーチン
敵の思考ルーチンを示します。
攻撃可能のときは攻撃を優先します。攻撃可能でない場合は太陽からの回避行動が必要かどうかを調べます。必要ないなら自機がいる方向に回頭、低速の場合は加速もおこないます。
1 2 3 4 5 6 7 8 9 |
// angle を -πからπになるように変換する function convertAngle(angle){ if(angle > Math.PI) angle -= Math.PI * 2; else if(angle < -Math.PI) angle += Math.PI * 2; return angle; } |
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 |
function enemyThink(){ // 攻撃可能のときは攻撃優先 const angle = enemy.GetAngle(); if(Math.abs(convertAngle(Math.atan2(player.CenterY - enemy.CenterY, player.CenterX - enemy.CenterX) - angle)) < Math.PI / 6){ turnTowardsJiki(); // 自機がいる方向に回頭する(後述) return; } // 太陽の近くにいる場合は回避行動をする if (Math.sqrt(Math.pow(enemy.CenterX - CANVAS_WIDTH / 2, 2) + Math.pow(enemy.CenterY - CANVAS_HEIGHT / 2, 2)) < 100){ // 太陽とは180度逆方向の角度を求め、現在の敵機の角度との差から左右どちらの回転がよいかを判断する const idealAngle = Math.atan2(enemy.CenterY - CANVAS_HEIGHT / 2, enemy.CenterX - CANVAS_WIDTH / 2); if (convertAngle(idealAngle - angle) > 0) enemy.RotateR(); else enemy.RotateL(); // 太陽から遠ざかるのに適した角度であれば加速する(ただし速度の上限あり) if (enemy.GetSpeed() < 1.5 && Math.abs(convertAngle(idealAngle - enemy.GetAngle())) < Math.PI / 6) enemy.Accelerate(); // 射撃可能であれば射撃する if (canAttack()) enemy.Shot(); return; } turnTowardsJiki(); } |
自機の方向に回頭する
turnTowardsJiki関数は自機がいる方向に敵機を回頭させます。また太陽に対して安全な角度であれば加速します。また速度が0.5未満のときも加速させます。射撃が可能であれば射撃もおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function turnTowardsJiki(){ const angle = enemy.GetAngle(); if (convertAngle(Math.atan2(player.CenterY - enemy.CenterY, player.CenterX - enemy.CenterX) - angle) > 0) enemy.RotateR(); else enemy.RotateL(); // 太陽に対して安全な角度なら50%の確率で加速 if (isSafeAngleForSun()){ if (enemy.GetSpeed() < 1.5 && Math.random() < 0.5) enemy.Accelerate(); } else if (enemy.GetSpeed() < 0.5) enemy.Accelerate(); if (canAttack()) // 後述 enemy.Shot(); return true; } |
canAttack関数は敵機から自機を射撃可能かを調べます。自機の方向と敵機の現在の方向のズレが左右60度以内で距離が280未満の場合は射撃可能と判断します。
1 2 3 4 5 6 7 8 |
function canAttack(){ if (Math.sqrt(Math.pow(enemy.CenterX - player.CenterX, 2) + Math.pow(enemy.CenterY - player.CenterY, 2)) < 280){ const abs = Math.abs(enemy.GetAngle() - Math.atan2(player.CenterY - enemy.CenterY, player.CenterX - enemy.CenterX)); if(abs < Math.PI / 3) return true; } return false; } |
太陽に対して安全な角度か?
敵機が太陽に対して安全な角度であるかどうかを調べます。ここでは太陽を背にする角度から左右120度=太陽に対して左右60度以上のズレがあれば安全と判断します。
1 2 3 4 5 6 7 |
function isSafeAngleForSun(){ const angle = enemy.GetAngle(); if (Math.abs(convertAngle(angle - Math.atan2(enemy.CenterY - CANVAS_HEIGHT / 2, enemy.CenterX - CANVAS_WIDTH / 2))) < Math.PI / 3 * 2) return true; else return false; } |