Cannon.jsを使ってすいかゲームを作る(1)の続きです。今回はゲーム開始以降の処理を実装します。
Contents
ゲーム開始以降の処理
ここからはゲーム開始以降の処理を示します。
ゲーム開始時の処理
ゲーム開始時の処理を示します。
まず前回のプレイでフィールド上に存在するボールを取り除きます。そのあとスコアとレベルの初期化をおこない、操作ボタンを表示させてスタートボタンを非表示にします。最後に、もし更新処理が停止しているときは update関数を呼び出します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function gameStart(){ for(let i = 0; i < balls.length; i++) balls[i].Remove(); balls = []; score = 0; level = 0; $shot.style.display = 'block'; $left.style.display = 'block'; $right.style.display = 'block'; $startButtons.style.display = 'none'; if(isUpdateStoped){ isUpdateStoped = false; update(); // 更新処理(後述) } } |
ボールを投下する処理
ボールを投下する処理を示します。
投下されたボールが投下開始地点に存在するにもかかわらず、連続投下できてしまうとまずいので連射制限(0.5秒)をしています。またボールがデッドラインよりも上にあるときは投下処理はおこなわれません。
それ以外のときは投下処理をおこないます。nextX, Y_STARTの位置で半径 nextRadius のボールを生成して落下させます。そして次に投下するボールの半径を乱数で決定します。そのあと効果音を鳴らします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function shot(){ if(updateCountAfterShot < shotInterval || isOverDeadLine() || $startButtons.style.display != 'none') return; const ball = new Ball(nextX, Y_START, nextRadius); balls.push(ball); const arr = [15, 20, 25, ]; // 次に落下するのは半径 15, 20, 25 のいずれか const index = Math.floor(Math.random() * arr.length); nextRadius = arr[index]; updateCountAfterShot = 0; shotSound.currentTime = 0; shotSound.play(); } |
isOverDeadLine関数は、デッドラインよりも上にあるボールがあるか調べます。
1 2 3 4 5 6 7 8 9 10 |
function isOverDeadLine(){ balls = balls.filter(_ => !_.IsRemoved); let max = 0; for(let i = 0; i < balls.length; i++){ const ball = balls[i]; if(ball.GetY() + ball.Radius > DEAD_LINE) return true; } return false; } |
更新処理
更新処理を示します。
まず左右の移動ボタンが押下されているときは次に投下するボールのX座標を増減(左右に移動)させます。そのあと cannon_world.step(1 / 10) を実行して 0.1 秒後の状態をシミュレートします。更新処理は 1秒間に60回なので引数も 1 / 60 にすべきなのですが、これだとボールの動きが遅くなってしまうので調整をいれています。
cannon_world.stepを実行したらcheckCollision関数を呼び出して、それぞれのボールの座標がわかるので同じ半径のボールが接触していないか調べます。もし接触している場合は関数内でボールを消滅させる処理がおこなわれます。
そのあと描画処理とゲームオーバー判定をおこないます。該当する場合はゲームオーバー処理をおこないます。そうでない場合は再度 update 関数を呼び出します。ただしボールが消滅した場合は次の更新まで50ミリ秒待機します。連鎖でボールが消えたときにわかりやすくするためですが、あまりわかりやすくなっていません(今後の課題)。
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 |
async function update() { if(isPressLeftKey) nextX -= 2; if(isPressRightKey) nextX += 2; // nextX の値がフィールドの範囲内に収まるように調整 if(nextX - nextRadius < X_MIN + 1) nextX = X_MIN + nextRadius + 1 if(nextX + nextRadius > X_MAX - 1) nextX = X_MAX - nextRadius - 1 cannon_world.step(1 / 10); // 本当は 1 / 60 のはずだが・・・ const isDelete = checkCollision(); // 同じ半径のボールが接触していないか?(後述) if(isDelete){ updateCountAfterDelete = 0; deleteSound.currentTime = 0; deleteSound.play(); } const gameovered = checkGameOver(); // ゲームオーバー判定(後述) updateCountAfterShot++; updateCountAfterDelete++; draw(gameovered); // 描画処理(後述) if(gameovered && !isUpdateStoped) onGameover(); // ゲームオーバー時の処理(後述) else { if(isDelete) await sleep(50); requestAnimationFrame(()=> update()); } async function sleep(ms){ await new Promise(resolve => setTimeout(resolve, ms)); } } |
ボールを消滅させる処理
同じ半径のボールが接触しているときに消滅させて加点する処理を示します。これは半径の和と中心の距離を比較しています。加点は大きな半径のボールが消滅するにつれて加速的に大きくなるようにしています。またスコアとは別にレベルという概念も取り入れています。これはもっとも大きな半径のボールの半径から算出されます。
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 |
function checkCollision(){ let isDelete = false; balls = balls.filter(_ => !_.IsRemoved); for(let i = 0; i < balls.length; i++){ const ball = balls[i]; const otherBalls = balls.filter(_ => _ != ball); const find = otherBalls.filter(_ => check(ball, _) && ball.Radius == _.Radius); // 同じ半径のボールが接触していたら片方のボールを消滅させ他方を大きくし加点する if(find.length > 0){ score += Math.pow(2, getBallLevel(ball.Radius)); // 半径で加点量を変える(後述) ball.Big(); find[0].Remove(); isDelete = true; level = getLevel(); // 現在のレベルは?(後述) break; } } return isDelete; function check(ball1, ball2){ const distance = Math.sqrt(Math.pow(ball1.GetX() - ball2.GetX(), 2) + Math.pow(ball1.GetY() - ball2.GetY(), 2)); if(distance - 2 < ball1.Radius + ball2.Radius) // 2 を引いているのは当たり判定の調整 return true; else return false; } } |
フィールド上に存在するボールの半径からレベルを算出する関数を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function getBallLevel(radius){ return (radius - 15) / 5; } function getLevel(){ balls = balls.filter(_ => !_.IsRemoved); let max = 0; for(let i = 0; i < balls.length; i++){ const ball = balls[i]; const level = getBallLevel(ball.Radius); if(max < level) max = level; } return max; } |
描画処理
描画処理を示します。canvas全体を黒で塗りつぶし、デッドライン、すでに投下されたボール、地面と周囲の壁、次に投下されるボール(投下可能な場合)、スコアの順に描画します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function draw(isGameovered){ clearCanvas(); // 後述 drawDeadLine(); // 後述 for(let i = 0; i < balls.length; i++) balls[i].Draw(); ctx.fillStyle = '#fff'; ctx.fillRect(X_MIN, CANVAS_HEIGHT - Y_MIN, X_MAX - X_MIN, 10); ctx.fillRect(X_MIN - 10, 80, 10, CANVAS_HEIGHT - Y_MIN - 80 + 10); ctx.fillRect(X_MAX, 80, 10, CANVAS_HEIGHT - Y_MIN - 80 + 10); if(!isGameovered && updateCountAfterShot > shotInterval) drawNextBall(); // 後述 drawScore(); // 後述 } |
clearCanvas関数はcanvas全体を黒で塗りつぶす処理をおこないます。
1 2 3 4 5 6 |
function clearCanvas(){ $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); } |
drawDeadLine関数は上部に赤い線を描画します。投下されたボールが動かなくなったときにこの線を超えていたらゲームオーバーです。
1 2 3 4 5 6 7 8 |
function drawDeadLine(){ ctx.beginPath(); const y = CANVAS_HEIGHT - DEAD_LINE; ctx.moveTo(X_MIN, y); ctx.lineTo(X_MAX, y); ctx.strokeStyle = '#f00'; ctx.stroke(); } |
drawNextBall関数は次に投下されるボールを描画します。
1 2 3 4 5 6 7 8 9 10 |
function drawNextBall(){ ctx.beginPath(); const y = CANVAS_HEIGHT - Y_START; ctx.arc(nextX, y, nextRadius, 0, Math.PI * 2); ctx.strokeStyle = getBallColor(nextRadius); ctx.fillStyle = getBallColor(nextRadius); ctx.fill(); drawBallImage(nextX, y, nextRadius, 0); } |
drawScore関数はスコアとレベルを描画します。
1 2 3 4 5 6 7 8 |
function drawScore(){ ctx.fillStyle = '#fff'; ctx.textBaseline = 'top'; ctx.font = 'bold 20px Arial'; ctx.fillText(`Score ${score}`, 40, 10); ctx.fillText(`(Lv. ${level})`, 250, 10); } |
ゲームオーバー判定とゲームオーバー処理
ゲームオーバー判定とゲームオーバー時の処理を示します。
ゲームオーバー判定
ゲームオーバー判定処理を示します。ボールが消えたときに舞い上がりこれが原因でゲームオーバー判定されてしまうこともあったため、以下の方法でゲームオーバー判定をしています。
ボールを投下したりボールが消えてから充分な時間(約1秒強)が経過している
すべてのボールの速度がほぼ 0(実際は 8 以下)
この条件を満たすときにフィールド上に存在するボールのどれかが赤い線を超えていたらゲームオーバーとしています(いい加減な方法かもしれないのですが・・・)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function checkGameOver(){ if(updateCountAfterShot < 120 || updateCountAfterDelete < 150) return false; if(!checkBallsStoped()) return false; return isOverDeadLine(); } function checkBallsStoped(){ for(let i = 0; i < balls.length; i++){ const ball = balls[i]; if(ball.GetVelocity() > 8) return false; } return true; } |
ゲームオーバー時の処理
ゲームオーバー時の処理を示します。
ボールを停止させるために更新処理を停止します。そしてスコアとレベルをサーバーにPOSTしてスコアランキングに記録します。そのあと効果音を鳴らして操作ボタンを非表示にしてスタートボタンを再表示させます。
1 2 3 4 5 6 7 8 9 |
function onGameover(){ isUpdateStoped = true; sendData(); gameoverSound.play(); $shot.style.display = 'none'; $left.style.display = 'none'; $right.style.display = 'none'; $startButtons.style.display = 'block'; } |
スコアとレベルをサーバーにPOSTする処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function sendData() { const $playerName = document.getElementById("player-name"); const phpurl = './save-score.php'; // ハンドルネームの欄になにも入力されていなければ「名無しさん」とする let name = $playerName.value; if(name == '') name = '名無しさん'; $.post(phpurl, { name:name, score:score, level:level, }); } |
スコアランキング
スコアとレベルがサーバーにPOSTされたらテキストファイルに記録する処理を示します。これは PHP を使用しています。
save-score.php
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 |
<?php $MaxCount = 50; // 上位50位まで保存 saveData(); function GetFileName(){ $filename = "../suika-highscore.csv"; return $filename; } // ソートの基準となるキーに対応する値の配列を作成 function createArrayForSort($key_name, $array) { foreach ($array as $key => $value) { $standard_key_array[$key] = $value[$key_name]; } return $standard_key_array; } // 各キーを基準にソートできるように、対応する値の配列を作成 function sortByLevelScore($array) { $level_array = createArrayForSort('level', $array); $score_array = createArrayForSort('score', $array); // level, score, の優先順位で降順ソートする array_multisort($level_array, SORT_DESC, $score_array, SORT_DESC, $array); return $array; } function saveData(){ $stack = array(); // csvファイルが存在するならデータを配列に変換 if(file_exists(GetFileName())){ $allData = file_get_contents(GetFileName()); $lines = explode("\n", $allData); foreach ( $lines as $line ) { $words = explode(",", $line); if($words[0] == "") continue; $newArray = array( 'name'=> $words[0], 'level'=> $words[1], 'score'=> $words[2], 'now'=> $words[3], ); $stack[] = $newArray; } } // 配列に送られてきたデータを追加 $name = ""; $score = 0; $name = $_POST["name"]; $level = $_POST["level"]; $score = $_POST["score"]; $now = date("Y-m-d H:i:s"); // POSTされたデータのなかにカンマがあると困るので置換する $name = str_replace(",", "_", $name); $level = str_replace(",", "_", $level); $score = str_replace(",", "_", $score); // 不適切なデータは処理しない // nameがない、長すぎるなど if(mb_strlen($name) > 32) return; if(mb_strlen($score) > 32) return; if($name == "") return; $newArray = array( 'name'=> $name, 'level'=> $level, 'score'=> $score, 'now'=> $now, ); $stack[] = $newArray; // lavel, socre が大きい順に配列をソート $sorted_array = sortByLevelScore($stack); // 上位からMaxCountだけデータを取得してcsvファイルとして保存する global $MaxCount; $dataCount = count($sorted_array); $str = ""; for($i = 0; $i < $dataCount; $i++){ if($i >= $MaxCount) break; $str .= join(",", $sorted_array[$i]); $str .= "\n"; } file_put_contents( GetFileName(), $str, LOCK_EX ); } |
スコアランキングを表示する
スコアランキングを表示させます。最初に HTML を示します。
ranking.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>スコアランキング:鳩でもわかるすいかゲーム</title> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <link rel="stylesheet" href="./ranking.css"> </head> <body> <div id = "container"> <p>スコアランキング:鳩でもわかるすいかゲーム</p> <p><a href="./">ゲームのページへ</a></p> <div id = "ranking"></div> </div> <script src="./ranking.js"></script> </body> </html> |
ranking.css
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
body { background-color: #000; color: white; } a { color: aqua; font-weight: bold; } a:hover { color: red; } td { border: 1px solid #fff; padding: 2px 10px 2px 10px; } |
ページが読み込まれたときの処理
ページが読み込まれたら fetch API でスコアランキングが記録されているファイルを読み込み、ランキングを表示します。
ranking.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 |
async function getRankingText(){ const f = await fetch('../bakepan-highscore.csv'); const text = await f.text(); console.log(text); return text; } async function createTableTag(){ const text = await getRankingText(); const datas = text.split('\n'); let table = '<table>'; table += `<tr><td>Rank</td><td width="200">Player Name</td><td>Lv</td><td>Score</td><td width="200">Date</td></tr>`; for(let i=0; i<datas.length; i++){ const data = datas[i]; if(data == '') continue; const vs = data.split(','); const name = vs[0]; const level = vs[1]; const score = vs[2]; const date = vs[3]; table += `<tr><td>${i + 1}</td><td>${name}</td><td>${level}</td><td>${score}</td><td>${date}</td></tr>`; } table += '</table>'; return table; } window.onload = async() => { const table = await createTableTag(); document.getElementById('ranking').innerHTML = table; } |