JavaScriptでパズドラっぽいゲームをつくります。
Contents
HTML部分
HTML部分を示します。それから効果音の音量調整に関する部分は毎度お決まりの書き方なのでHTMLのなかに書いています。
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>パズドラもどき</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> #start-button{ width: 180px; height: 50px; } </style> </head> <body> <div id = "start"> <button id = "start-button" onclick="gameStart()">スタート</button> <p>音量: <input type="range" id="volume" min="0" max="1" step="0.01"> <span id="vol_range"></span> <button onclick="playSound()">音量テスト</button> </p> </div> <canvas id = "can"></canvas> <script> // 効果音の音量調整に関する部分(毎度お決まりの書き方なのでこっちに書いた) const moveSound = new Audio('./move.mp3'); const deleteSound = new Audio('./maou_se_magic_wind02.mp3'); const attackSound = new Audio('./magical25.mp3'); const damageSound = new Audio('./damage.mp3'); const winSound = new Audio('./win.mp3'); const gameoverSound = new Audio('./gameover.mp3'); let $elemVolume = document.getElementById("volume"); let $elemRange = document.getElementById("vol_range"); $elemVolume.addEventListener("change", function(){ setVolume($elemVolume.value); }, false); function setVolume(value){ $elemVolume.value = value; $elemRange.textContent = value; deleteSound.volume = value; attackSound.volume = value; damageSound.volume = value; winSound.volume = value; gameoverSound.volume = value; } function playSound(){ attackSound.currentTime = 0; attackSound.play(); } setVolume(0.05); </script> <script src='./index.js'></script> </body> </html> |
JavaScript部分
主な定数を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const DROP_SIZE = 60; // ドロップのサイズ const ROW_MAX = 6; // フィールド上に縦に並んでいるドロップの数 const COL_MAX = 6; // フィールド上に横に並んでいるドロップの数 const TYPE_MAX = 5; // ドロップの種類 const DROPS_MARGIN_LEFT = 20; // 左上に表示されるドロップのX座標 const DROPS_MARGIN_TOP = 170; // 左上に表示されるドロップのY座標 const WIDTH = 400; // canvasの幅 const HEIGHT = 550; // canvasの高さ const INIT_TIME_LIMIT = 10000; // ドロップを入れ替えることができる制限時間の初期値 |
Dropクラスの定義
ドロップを描画するためにDropクラスを定義します。
IsInside関数は引数として渡された座標がドロップの内部かどうかを返すものです。2種類あるのは1種類だけだと斜めに移動する処理がうまくいかないからです。角の近くにマウスがあるときは反応しないようにするためにIsInside関数とは別にIsInside2関数を定義しています。
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 |
class Drop{ constructor(row, col){ let x = DROP_SIZE * col + DROPS_MARGIN_LEFT; let y = DROP_SIZE * row + DROPS_MARGIN_TOP; this.X = x; this.Y = y; this.Type = 0; this.colors = ['#f00','#00f','#0c0','#ff0','#c0c',]; //ドロップの色 this.Moving = false; // 回転移動しているドロップは描画しない } // 固定しているドロップは何行何列目に存在するか? GetPosition(){ let col = Math.round((this.X - DROPS_MARGIN_LEFT) / DROP_SIZE); let row = Math.round((this.Y- DROPS_MARGIN_TOP) / DROP_SIZE); return { Row:row, Col:col}; } Draw(){ if(this.Moving || this.Y < DROPS_MARGIN_TOP) return; ctx.fillStyle = this.colors[this.Type]; ctx.strokeStyle = this.colors[this.Type]; ctx.beginPath(); ctx.arc(this.X + DROP_SIZE / 2, this.Y + DROP_SIZE/2, 30 - 2, 0, 2 * Math.PI); ctx.stroke(); ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 4; ctx.stroke(); } IsInside(x, y){ if(x < this.X) return false; if(y < this.Y) return false; if(x > this.X + DROP_SIZE) return false; if(y > this.Y + DROP_SIZE) return false; return true; } IsInside2(x, y){ return this.IsInside(x, y) && !this.IsCorner(x, y); } // 引数で渡された座標がドロップの内部である場合、その座標は角に該当するか? IsCorner(x, y){ let isLeft = false; let isRight = false; let isTop = false; let isBottom = false; if(x < this.X + DROP_SIZE * 0.25) isLeft = true; if(this.X + DROP_SIZE * 0.75 < x) isRight = true; if(y < this.Y + DROP_SIZE * 0.25) isTop = true; if(this.Y + DROP_SIZE * 0.75 < y) isBottom = true; if(isLeft && isTop) return true; if(isLeft && isBottom) return true; if(isRight && isTop) return true; if(isRight && isBottom) return true; return false; } // 同じプロパティをもつ別のオブジェクトを生成する createCopy(){ let copyDrop = new Drop(); copyDrop.X = this.X; copyDrop.Y = this.Y; copyDrop.Type = this.Type; return copyDrop; } } |
初期化の処理
ページが読み込まれたら初期化をおこないます。ドロップと敵のイメージを初期化したら、ゲームに必要なグローバル変数を初期化します。
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 |
let $can = document.getElementById('can'); let ctx = $can.getContext('2d'); $can.width = WIDTH; $can.height = HEIGHT; let $start = document.getElementById('start'); let enemyHP = 1000; // 双方のHP let hp = 1000; let drops = []; // 表示されているドロップの配列 let movingDrops = []; // 移動のアニメーションがおこなわれているドロップの配列 let maxTimeLimit = INIT_TIME_LIMIT; // ドロップを移動できる制限時間 let stage = 0; // 現在のステージ(最初は0) let $enemy = null; // 現在の敵 let $enemies = []; // 全部の敵 // 敵の描画に使う画像ファイル let imageFiles = ['./enemy01.png', './enemy02.png', './enemy03.png', './enemy04.png']; let ignoreMove = false; // このフラグがtrueのときは移動処理をおこなわない let timeLimit = 0; // ドロップを移動できる残り時間 let score = 0; // スコア let playing = false; // 現在プレイ中か? window.onload = () => { initDraps(); initEnemies(); init(); } function init(){ stage = 0; maxTimeLimit = INIT_TIME_LIMIT; $enemy = $enemies[stage % $enemies.length]; enemyHP = 1000; hp = 1000; ignoreMove = false; score = 0; } function initDraps(){ drops = []; for(let row = 0; row < ROW_MAX; row++){ for(let col = 0; col < COL_MAX; col++){ let drop = new Drop(row, col); drop.Type = Math.floor(Math.random() * TYPE_MAX); drops.push(drop); } } } function initEnemies(){ $enemies = []; for(let i=0; i<imageFiles.length; i++){ const $enemy = new Image(); $enemy.src = imageFiles[i]; $enemies.push($enemy); } } |
更新処理
更新処理が行なわれたときの描画処理にかんする部分を示します。1000/ 60秒おきにドロップやその他ゲームで必要な情報を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 |
setInterval(() => { // 黒で背景を塗りつぶす ctx.fillStyle = '#000'; ctx.fillRect(0, 0, WIDTH , HEIGHT); // 固定された、または落下中のドロップを描画 drops.forEach(drop => drop.Draw()); // 回転移動しているドロップを描画 movingDrops.forEach(drop => drop.Draw()); ctx.font = '20px MS ゴシック bold'; ctx.textBaseline = 'top'; ctx.fillStyle = '#fff'; // スコア、両者のHPを描画 ctx.fillStyle = '#fff'; ctx.fillText(score, 20, 20); ctx.fillStyle = '#0ff'; ctx.fillText('あなた', DROPS_MARGIN_LEFT, 50); ctx.fillText(hp, DROPS_MARGIN_LEFT, 80); ctx.fillStyle = '#f00'; ctx.fillText('敵', WIDTH - 60, 50); ctx.fillText(enemyHP, WIDTH - 70, 80); // 敵のイメージを描画 if($enemy != null) ctx.drawImage($enemy, -20 + (DROPS_MARGIN_LEFT + WIDTH - $enemy.width) / 2, 30); // ドロップを移動できる残り時間を描画(ゲームオーバー時や移動不可の時間帯は描画しない) ctx.fillStyle = '#ff0'; if(timeLimit > 0 && hp > 0) ctx.fillText(timeLimit, DROPS_MARGIN_LEFT, 130); }, 1000/ 60); |
ゲーム開始時の処理
ゲームが開始されたら[スタート]ボタンを非表示にしてスコアやHP、制限時間を初期化してカウントダウンを開始します。タイマーを止めるときに必要なのでsetInterval関数の戻り値を保存しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
let timerMoveDrops = null; // タイマーを止めるときに必要 function gameStart(){ playing = true; $start.style.display = 'none'; init(); timeLimit = maxTimeLimit; timerMoveDrops = setInterval(() => { timeLimit -= 50; if(timeLimit <= 0){ clearTimeout(timerMoveDrops); onFinishMoveDrops(); // 後述(制限時間が過ぎたらドロップを消す処理をおこなう) } }, 50); } |
ドロップをクリックしたときの処理
ドロップをクリックしたときの処理を示します。
クリックされた地点のcanvas上の座標を求め、それがドロップの内部であるならそのドロップをグローバル変数 clickedDropに格納します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
let clickedDrop = null; $can.addEventListener('mousedown', (ev) =>{ if(ignoreMove || hp <= 0 || !playing) return; // クリックされた地点のcanvas上の座標を求める let canvasY = $can.getBoundingClientRect().top; let canvasX = $can.getBoundingClientRect().left; let x = ev.clientX - canvasX; let y = ev.clientY - canvasY; // ドロップの内部であるならそのドロップをグローバル変数に格納する let clickeds = drops.filter(drop => drop.IsInside(x, y)); if(clickeds.length == 0) return; clickedDrop = clickeds[0]; }); |
ドロップを移動させる
ドロップが選択されている状態でマウスを移動したときの処理を示します。
clickedDropとは異なるドロップのうえにマウスポインタがある場合はそれと位置を入れ替えます。また6×6のドロップが存在する矩形からマウスポインタが外れた場合は移動処理を強制的に終了させます。これは隣り合ったドロップ以外との入れ替えがおきる不具合を防ぐための苦肉の策です。
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 |
$can.addEventListener('mousemove', (ev) =>{ if(ignoreMove || hp <= 0 || !playing) return; if(clickedDrop == null) return; let canvasY = $can.getBoundingClientRect().top; let canvasX = $can.getBoundingClientRect().left; let x = ev.clientX - canvasX; let y = ev.clientY - canvasY; // 6×6のドロップが存在する矩形以外の部分にマウスポインタが移動した場合は移動の処理は強制終了 let maxX = DROPS_MARGIN_LEFT + DROP_SIZE * COL_MAX; let maxY = DROPS_MARGIN_TOP + DROP_SIZE * ROW_MAX; if(x < DROPS_MARGIN_LEFT || maxX < x || y < DROPS_MARGIN_TOP || maxY < y){ timeLimit = 0; clearTimeout(timerMoveDrops); onFinishMoveDrops(); // 後述 return; } // マウスポインタがある位置にドロップが存在しない、clickedDropと同じ場合はなにもしない let mouseOvered = drops.filter(drop => drop.IsInside2(x, y)); if(mouseOvered.length == 0 || clickedDrop == mouseOvered[0]) return; // マウスポインタがある位置のドロップとclickedDropの位置を入れ換える swap(clickedDrop, mouseOvered[0]); }); |
ドロップを入れ替える処理を示します。
ドロップを入れ替えは瞬時に行なわれるのではなく、回転運動をするようなアニメーションをさせます。そのため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 |
async function swap(drop1, drop2){ // 回転時の描画をするための別のオブジェクトを生成する let copy1 = drop1.createCopy(); let copy2 = drop2.createCopy(); movingDrops.push(copy1); movingDrops.push(copy2); // もとのオブジェクトはすぐに座標を入れ替える let oldX = drop1.X; let oldY = drop1.Y; drop1.X = drop2.X; drop1.Y = drop2.Y; drop2.X = oldX drop2.Y = oldY // アニメーションが終わるまで非表示 drop1.Moving = true; drop2.Moving = true; // 回転の中心を求める let centerX = (copy1.X + copy2.X) / 2; let centerY = (copy1.Y + copy2.Y) / 2; // 回転の半径と開始の角座標 let r = Math.sqrt(Math.pow(centerX - copy1.X, 2) + Math.pow(centerY - copy1.Y, 2)); let ang = Math.atan2(copy1.Y - copy2.Y, copy1.X - copy2.X); // アニメーション開始 let count = 4; for(let i=0; i < count; i++){ await sleep(50); let v = Math.PI / count * (i + 1); let x1 = r * Math.cos(ang + v) + centerX; let y1 = r * Math.sin(ang + v) + centerY; copy1.X = x1; copy1.Y = y1; let x2 = r * Math.cos(ang + Math.PI + v) + centerX; let y2 = r * Math.sin(ang + Math.PI + v) + centerY; copy2.X = x2; copy2.Y = y2; } drop1.Moving = false; drop2.Moving = false; // 回転処理が終わったらmovingDropsからは削除 movingDrops = movingDrops.filter(drop => drop != copy1 && drop != copy2); } // 引数のあいだだけ待機する async function sleep(ms){ await new Promise(resolve => { setTimeout(() => resolve(''), ms); }) } |
ドロップを消す処理
マウスボタンが離された場合、制限時間が終了した場合はドロップが3個以上並んでいる部分を消し、上から新しいドロップを落とします。また点数と敵へのダメージを計算して敵の攻撃を行なわせ、勝敗判定をおこないます。
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 |
$can.addEventListener('mouseup', async(ev) =>{ if(ignoreMove || hp <= 0 || !playing) return; if(clickedDrop == null) return; await onFinishMoveDrops(); }); async function onFinishMoveDrops(){ timeLimit = 0; if(ignoreMove || hp <= 0) return; ignoreMove = true; // 処理中はマウスが操作されてもドロップの移動処理はおこなわない clickedDrop = null; // ドロップを消しコンボ数をカウントする let totalComboCount = 0; while(true){ // ドロップを消し空間を上にあるドロップで埋める // この処理をドロップが消えなくなるまで繰り返す let comboCount = await deleteDrops(); await downDrops(); totalComboCount += comboCount; if(comboCount == 0) break; } // 攻撃時の効果音を鳴らす attackSound.currentTime = 0; attackSound.play(); // 点数計算と敵へのダメージを計算する if(totalComboCount > 0){ await sleep(500); let add = Math.round(totalComboCount * 70 * Math.pow(1.1, totalComboCount)); score += add; enemyHP -= add; if(enemyHP < 0) enemyHP = 0; } // 敵の攻撃 await enemyAttack(); // 勝敗判定(どちらかのHPが0になっていないか?) await Jude(); ignoreMove = false; // 次のドロップ移動可能制限時間のカウントダウンを開始する timeLimit = maxTimeLimit; timerMoveDrops = setInterval(() => { timeLimit -= 50; if(timeLimit <= 0){ clearTimeout(timerMoveDrops); onFinishMoveDrops(); } }, 50); } |
ドロップを消す処理をおこなうために必要なDropオブジェクトの二次元配列を返す関数を定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function createDropsMap(){ let dropMap = []; for(let row = 0; row < ROW_MAX; row++){ dropMap[row] = []; for(let col = 0; col < COL_MAX; col++){ dropMap[row][col] = null; } } for(let i = 0; i < drops.length; i++){ let pos = drops[i].GetPosition(); dropMap[pos.Row][pos.Col] = drops[i]; } return dropMap; } |
これはドロップを消す処理をおこなうときにすでにチェック済みかどうかを記憶しておくためのbool型変数の二次元配列を生成する関数です。最初はすべてfalseで初期化しておきます。
1 2 3 4 5 6 7 8 9 10 |
function createCheckMap(){ let checkMap = []; for(let row = 0; row < ROW_MAX; row++){ checkMap[row] = []; for(let col = 0; col < COL_MAX; col++) checkMap[row][col] = false; } return checkMap; } |
縦または横に3つ以上並んでいるドロップを消す処理を示します。4つ以上並んでいる場合、二重に処理がおこなわれないようにしています。
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 |
async function deleteDrops(){ let dropMap = createDropsMap(); let comboCount = 0; let hCheckMap = createCheckMap(); let vCheckMap = createCheckMap(); // 消えるドロップがあるか確認 for(let row = 0; row < ROW_MAX; row++){ for(let col = 0; col < COL_MAX; col++){ let type = dropMap[row][col].Type; let deleteDropsList = []; // 横方向の確認 if(!hCheckMap[row][col]){ let arr = []; for(let i = 0; col + i < COL_MAX; i++){ if(type == dropMap[row][col + i].Type){ arr.push(dropMap[row][col + i]); hCheckMap[row][col + i] = true; } else break; } if(arr.length >= 3) deleteDropsList.push(arr); } // 縦方向の確認 if(!vCheckMap[row][col]){ let arr = []; for(let i = 0; row + i < ROW_MAX; i++){ if(type == dropMap[row + i][col].Type){ arr.push(dropMap[row + i][col]); vCheckMap[row + i][col] = true; } else break; } if(arr.length >= 3) deleteDropsList.push(arr); } // コンボ数を調べる comboCount += deleteDropsList.length; // 時間差をおいてドロップを消していく for(let i = 0; i < deleteDropsList.length; i++){ await sleep(500); for(let k = 0; k < deleteDropsList[i].length; k++) drops = drops.filter(drop => deleteDropsList[i][k] != drop); // ついでに効果音も鳴らす deleteSound.currentTime = 0; deleteSound.play(); } } } return comboCount; } |
消えたドロップを詰める処理を示します。Dropオブジェクトの二次元配列を取得してnullの部分があったら上にあるDropオブジェクトを下に移動させます。一番上は新しいDropオブジェクトを生成してこれで埋めます。
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 |
async function downDrops(){ let dropMap = createDropsMap(); while(true){ let downDrops = []; for(let row = ROW_MAX - 1; row >= 0; row--){ for(let col = 0; col < COL_MAX; col++){ if(dropMap[row][col] == null && row > 0){ // nullの部分は上にあるDropオブジェクトがあるならそれを下に移動させる // 下に移動させるオブジェクトがあるなら配列に格納する if(dropMap[row-1][col] != null) downDrops.push(dropMap[row-1][col]); dropMap[row][col] = dropMap[row-1][col]; dropMap[row-1][col] = null; } else if(dropMap[row][col] == null && row == 0){ // 一番上の行は新しいDropオブジェクトを生成して埋める // これを下に移動させるオブジェクトとして配列に格納する let newDrop = new Drop(-1, col); newDrop.Type = Math.floor(Math.random() * TYPE_MAX); downDrops.push(newDrop); drops.push(newDrop); dropMap[row][col] = newDrop; } } } // 配列に格納したオブジェクトを実際に下に移動させる for(let i = 0; i < 4; i++){ await sleep(50); downDrops.forEach(drop => drop.Y += DROP_SIZE / 4); } // 移動対象がないなら処理は終了 if(downDrops.length == 0) break; } } |
ドロップを消す処理が終わったら敵に攻撃させる処理をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
async function enemyAttack(){ if(enemyHP > 0){ await sleep(1000); damageSound.currentTime = 0; damageSound.play(); hp -= Math.floor(Math.random() * 300); if(hp < 0) hp = 0; draw(); } } |
双方の攻撃が終わったら勝敗判定をおこないます。HPが0になっている側の負けです。
プレイヤー勝利の場合はステージクリア時の効果音を鳴らしてステージ数を1増やします。敵のイメージを新しいものに変更して、ドロップを移動できる制限時間の上限を変更し、双方のHPを初期値に変更します。これでJude関数が終了したら新しいステージが開始されます。
プレイヤー敗北の場合は、ゲームオーバーの効果音を鳴らしてゲーム再開用のボタンを表示させ、playingフラグを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 |
async function Jude(){ if(enemyHP <= 0){ $enemy = null; // 勝利の効果音を鳴らす winSound.currentTime = 0; winSound.play(); await sleep(2000); // ステージを1増やす stage++; // 敵のイメージを新しいものに変更 $enemy = $enemies[stage % $enemies.length]; // 双方のHPを初期値に変更 enemyHP = 1000; hp = 1000; // ドロップを移動できる制限時間の上限を変更する if(maxTimeLimit > 7999) maxTimeLimit -= 2000; else if(maxTimeLimit > 3999) maxTimeLimit -= 1000; else maxTimeLimit -= 500; if(maxTimeLimit < 800) maxTimeLimit = 800; } if(hp <= 0){ // プレイヤー敗北時 await sleep(1000); gameoverSound.currentTime = 0; gameoverSound.play(); playing = false; $start.style.display = 'block'; } } |