挟み撃ちのアルゴリズムを研究するために絶対に攻略できないクソゲーをつくります。
まずは普通のパックマンをつくります。
Contents
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 |
<!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 = "./style.css" type = "text/css" media = "all"> </head> <body> <canvas id = "canvas"></canvas> <div id = "controller"> <button class="buttons" id = "up">UP</button> <button class="buttons" id = "left">LEFT</button> <button class="buttons" id = "right">RIGHT</button> <button class="buttons" id = "down">DOWN</button> </div> <button id = "start">START</button> <div id = "config"> <p><input type="checkbox" id = "check-no-powerfoods"><label for="check-no-powerfoods">パワー餌なし</label></p> <p><input type="checkbox" id = "check-disallow-enemy-turnback"><label for="check-disallow-enemy-turnback">敵の折り返しを禁止する</label></p> <p><input type="checkbox" id = "check-hide-buttons-for-sp"><label for="check-hide-button-for-sp">スマホ用操作ボタンを非表示にする</label></p> </div> <script src= "./map.js"></script> <script src= "./index.js"></script> </body> </html> |
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 |
body { background-color: black; padding: 30px; color: white; } #start { display: block; width: 150px; height: 50px; } #controller { width: 360px; height: 240px; position: relative; display: none; } .buttons { height: 70px; width: 120px; position: absolute; background-color: transparent; color: #fff; border-color: #fff; } #up { left: 120px; top: 0px; } #left { left: 0px; top: 80px; } #right { left: 240px; top: 80px; } #down { left: 120px; top: 160px; } #config { width: 360px; } |
グローバル変数と定数
グローバル変数と定数を示します。
index.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 |
// canvasのサイズ const CANVAS_WIDTH = 320; const CANVAS_HEIGHT = 360; // プレイヤーの初期座標 let pHomeX let pHomeY // モンスターの巣にかんする座標 let eHomeCX; // 中心座標 let eHomeCY; let eHomeMinX; // 巣のなかのモンスターの最左座標 let eHomeMaxX; // 最右座 let eHomeMinY; // 最上座標 let eHomeMaxY; // 最下座標 let eHomeFrontX; // 出口から出た直後のX座標 let eHomeFrontY; // 出口から出た直後のY座標 // canvas要素とコンテキスト const $canvas = document.getElementById('canvas'); const ctx = $canvas.getContext('2d'); // ボタン、チェックボックス、DIV要素 const $checkNoPowerfoods = document.getElementById('check-no-powerfoods'); const $checkDisallowEnemyTurnback = document.getElementById('check-disallow-enemy-turnback'); const $checkHideButtonsForSp = document.getElementById('check-hide-buttons-for-sp'); const $controller = document.getElementById('controller'); const $start = document.getElementById('start'); // 描画用のイメージ const enemyImages = []; const ijikeImages = []; const eyeImage = new Image(); const playerImages = []; // 効果音 const bgm = new Audio('./sounds/bgm.mp3'); const clearSound = new Audio('./sounds/clear.mp3'); const deadSound = new Audio('./sounds/dead.mp3'); const getSound = new Audio('./sounds/get.mp3'); let mapImage = null; // 迷路を描画するときのイメージ // プレイヤー、敵、餌などのオブジェクト let player; let enemies = []; let foods = []; let powerFoods = []; let noUpdate = true; // trueのときは更新処理がおこなわれない |
二次元配列 map
通路と餌の位置、プレイヤーと敵の初期位置などをpngファイルとしてまとめたものがこれです。
座標が1ピクセルでは見づらいので見やすくするために太く描画したものがこれです。
この各ピクセルを二次元配列に変換したのがこれです。
この二次元配列 mapを解析して必要な座標を求めます。
Foodクラスの定義
フィールドに存在する餌を描画するためにFoodクラスとPowerFoodクラスを定義します。IsDead == trueなら存在しないのでフィールドから取り除きます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Food { constructor(x, y){ this.X = x; this.Y = y; this.IsDead = false; } } class PowerFood { constructor(x, y){ this.X = x; this.Y = y; this.IsDead = false; } } |
Playerクラスの定義
プレイヤーを移動させ描画するためにPlayerクラスを定義します。
初期化
コンストラクタと初期化の処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Player{ constructor(){ this.X = 0; this.Y = 0; this.CurDirect = 'left'; this.NextDirect = ''; this.IsDead = true; } Init(){ this.X = pHomeX; this.Y = pHomeY; this.IsDead = false; this.NextDirect = 'left' } } |
移動させる処理
プレイヤーを移動させる処理を示します。CurDirectをみてプレイヤーの座標を変更します。そのまえに次の移動方向が移動可能であればCurDirectにセットします。NextDirectの値はユーザーのキー操作で変更されます。
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 |
class Player{ Update(){ // 移動可能であれば進行方向を変更する if(this.NextDirect == 'left'){ if(map[this.Y][this.X-1] > 0) this.CurDirect= 'left'; } if(this.NextDirect == 'right'){ if(map[this.Y][this.X+1] > 0) this.CurDirect= 'right'; } if(this.NextDirect == 'up'){ if(map[this.Y-1][this.X] > 0) this.CurDirect= 'up'; } if(this.NextDirect == 'down'){ if(map[this.Y+1][this.X] > 0) this.CurDirect= 'down'; } // 進行方向に移動させる if(this.CurDirect == 'left'){ if(map[this.Y][this.X-1] > 0) this.X -= 1; if(this.X == 0) // ワープ this.X = 319; } if(this.CurDirect == 'right'){ if(map[this.Y][this.X+1] > 0) this.X += 1; if(this.X == 319) // ワープ this.X = 0; } if(this.CurDirect == 'up'){ if(map[this.Y-1][this.X] > 0) this.Y -= 1; } if(this.CurDirect == 'down'){ if(map[this.Y+1][this.X] > 0) this.Y += 1; } } } |
描画処理
描画処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Player { Draw(){ let image; if(this.CurDirect == 'left') image = playerImages[0]; else if(this.CurDirect == 'up') image = playerImages[1]; else if(this.CurDirect == 'right') image = playerImages[2]; else image = playerImages[3]; ctx.drawImage(image, this.X - CHARACTER_SIZE / 2, this.Y - CHARACTER_SIZE / 2, CHARACTER_SIZE, CHARACTER_SIZE); } } |
Enemyクラスの定義
敵を移動させ描画するためにEnemyクラスを定義します。
初期化
コンストラクタと初期化の処理を示します。
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 |
class Enemy { constructor(type){ this.X = 0; // 現在位置 this.Y = 0; this.Direct = ''; // 移動方向 this.Type = type; // モンスターの識別番号(0~3) this.WaitTime = 0; // これが0になったら巣から外へ出る this.IjikeTime = 0; // これが0より大きければイジケ状態(プレイヤーから追われる状態) this.PositionsToHome = []; // 撃退され巣に戻るための座標の配列 this.ShortestInfo = null; // 挟み撃ちのアルゴリズムで必要(後述) this.Status = ''; // 'wait'(巣のなかで上下に揺れている状態), // 'out'(巣から出ようといている状態), // ''(それ以外) } Init(){ this.IsDead = false; this.IjikeTime = 0; this.PositionsToHome = []; // Typeによって適切な位置に配置する // Type == 0:巣の入り口 Type == 1:巣の中央 Type == 2:巣の左側 Type == 3:巣の右側 this.X = eHomeFrontX; this.Y = eHomeFrontY; if(this.Type != 0) this.Y = eHomeCY; if(this.Type == 2) this.X = eHomeMinX; if(this.Type == 3) this.X = eHomeMaxX; // Type == 0以外はしばらく巣のなかで待機してから出てくるようにする if(this.Type == 0) this.Status = ''; if(this.Type == 1){ this.Status = 'wait'; this.WaitTime = 4; } if(this.Type == 2){ this.Status = 'wait'; this.WaitTime = 8; } if(this.Type == 3){ this.Status = 'wait'; this.WaitTime = 16; } this.Direct = ''; } } |
移動方向に関する処理
CanChangeDirect関数は敵が引数の方向に移動できるかを調べます。
1 2 3 4 5 6 7 8 9 10 11 12 |
class Enemy{ CanChangeDirect(direct){ if(direct == 'left') return this.X == 0 || map[this.Y][this.X - 1] > 0; if(direct == 'right') return this.X == map.length - 1 || map[this.Y][this.X + 1] > 0; if(direct == 'up') return map[this.Y - 1][this.X] > 0; if(direct == 'down') return map[this.Y + 1][this.X] > 0; } } |
GetNextDirects関数は敵が移動できる方向の配列を返します。配列に格納される順番は先頭が現在の移動方向(ただし移動可能な場合)、末尾が現在の移動方向と逆方向(同前)になります。これでとりあえず先頭の要素を取得すれば敵を移動させ続けることが可能です。また敵をUターンさせたくない場合は、末尾を捨てればよいことになります。
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 Enemy{ GetNextDirects(){ let ret = []; if(this.X == 0 || map[this.Y][this.X-1] > 0) ret.push('left'); if(this.X == map[this.Y].length - 1 || map[this.Y][this.X+1] > 0) ret.push('right'); if(map[this.Y-1][this.X] > 0) ret.push('up'); if(map[this.Y+1][this.X] > 0) ret.push('down'); if(ret.filter(_ => _ == this.Direct).length > 0){ ret = ret.filter(_ => _ != this.Direct); ret.unshift(this.Direct); } const rDirect = this.GetReverseDirect(); if(ret.filter(_ => _ == rDirect).length > 0){ ret = ret.filter(_ => _ != rDirect); ret.push(rDirect); } return ret; } } |
GetReverseDirect関数は敵の現在の移動方向と反対の方向を返します。
1 2 3 4 5 6 7 8 9 10 |
class Enemy{ GetReverseDirect(){ const arr = ['left', 'up', 'right', 'down']; const index = arr.findIndex(el => el == this.Direct); if(index != -1) return arr[(index + 2) % 4]; else return 'none'; } } |
Reverse関数は敵を移動方向を反転させます。反転できない場合はランダムで移動方向を決めます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Enemy { Reverse(){ const rDirect = this.GetReverseDirect(); if(this.CanChangeDirect(rDirect)) this.Direct = rDirect; else { const directs = this.GetNextDirects().filter(_ => _ != this.Direct); if(directs.length > 0){ const index = Math.floor(Math.random() * directs.length); this.Direct = directs[index]; } } } } |
ChageIjikeMode関数は敵をイジケ状態に変化させます。そして元来た道を逆走させます。
1 2 3 4 5 6 |
class Enemy { ChageIjikeMode(){ this.IjikeTime = 8 * 60; this.Reverse(); } } |
GetPathToHome関数は撃退され巣に戻る敵が通る座標の配列を返します。
幅優先探索でeHomeFrontまでの最短経路を取得し、そこにいたる座標の配列を取得します。
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 |
class Enemy { GetPathToHome(){ this.PositionsToHome = []; const rowCount = map.length; const colCount = map[0].length; const cost = []; for(let y = 0; y < rowCount; y++){ const arr = []; for(let x = 0; x < colCount; x++) arr.push(10000 * 10000); cost.push(arr); } const from = []; for(let y = 0; y < rowCount; y++){ const arr = []; for(let x = 0; x < colCount; x++) arr.push(null); from.push(arr); } cost[this.Y][this.X] = 0; const xs = []; const ys = []; xs.push(this.X); ys.push(this.Y); while(true){ const x = xs.shift(); const y = ys.shift(); const dx = [0, 0, 1, -1]; const dy = [1, -1, 0, 0]; for(let i=0; i<4; i++){ const newY = y + dy[i]; const newX = x + dx[i]; // 配列の範囲外は対象外 if(newY < 0 || newY >= rowCount || newX < 0 || newX >= colCount) continue; // 移動不可の座標も対象外 if(map[newY][newX] == 0) continue; let newCost = cost[y][x] + 1; // すでに探索した座標も対象外 if(cost[newY][newX] <= newCost) continue; cost[newY][newX] = newCost; xs.push(newX); ys.push(newY); from[newY][newX] = {row:y, col:x}; // どこから来たのかも記録しておく } if(xs.length == 0) break; } // eHomeFrontから逆にたどっていけば最短経路を取得できる let lastY = eHomeFrontY; let lastX = eHomeFrontX; while(true){ const y0 = from[lastY][lastX].row; const x0 = from[lastY][lastX].col; if(y0 == this.Y && x0 == this.X) break; lastY = y0; lastX = x0; this.PositionsToHome.unshift({x:lastX,y:lastY,}); } } } |
更新処理
更新処理を示します。
PositionsToHomeに要素が格納されている場合は撃退されて巣に戻っているときなのでPositionsToHomeの先頭の要素を取り出して、これを敵の現在位置とします。ただし移動速度を通常移動より速くしたいので要素が2つ以上あるときはshift関数を2回呼び出しています。
配列PositionsToHomeが空でも死亡フラグが立っている場合は巣の中心部へ降下させます。中心部まで降下したら死亡フラグをクリアします。イジケ状態から回復させてStatusを’out’にします。これによって敵はすぐに巣の中心部から外に出てきます。
IjikeTimeが0より大きい場合は移動速度を半分にするためIjikeTimeが偶数のときはなにもしません。
Status == ‘wait’のときは巣のなかで上下に移動させます。Y が eHomeMinYまたはeHomeMaxYと同じなら上下の方向転換をします。Y == eHomeCY のときは WaitTime をデクリントします。WaitTime == 0になったらStatusを’out’に変更します。Status == ‘out’のときはまっすぐ上昇すれば巣から出られるときは上昇させ、そうでないときはX座標をeHomeFrontXに向けて1増減させます。
Status == ”のときは完全に巣から出た状態なのでDirectの方向へ現在位置を移動させます。
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 |
class Enemy { Update(){ // 撃退されて巣に戻っているとき if(this.PositionsToHome.length > 0){ let position = null; if(this.PositionsToHome.length >= 2){ this.PositionsToHome.shift(); position = this.PositionsToHome.shift(); } else if(this.PositionsToHome.length == 1){ position = this.PositionsToHome.shift(); } this.X = position.x; this.Y = position.y; if(this.PositionsToHome.length == 0){ this.X = eHomeFrontX; this.Y = eHomeFrontY; } return; } // 撃退されて巣に戻ったあとは巣の中央に移動させる if(this.PositionsToHome.length == 0 && this.IsDead){ this.Y += 2; if(this.Y >= eHomeCY){ this.X = eHomeCX; this.Y = eHomeCY; this.Status = 'out'; this.IsDead = false; this.IjikeTime = 0; } return; } this.IjikeTime--; if(this.IjikeTime > 0){ if(this.IjikeTime % 2 == 0) return; } // 巣のなかで上下に移動させる if(this.Status == 'wait'){ if(this.Y == eHomeMinY) this.Direct = 'down'; if(this.Y == eHomeMaxY) this.Direct = 'up'; if(this.Direct == 'up') this.Y -= 1; else if(this.Direct == 'down') this.Y += 1; else { // どちらにも移動していないときは上に移動させるが、 // 上下移動が互い違いになるようにType == 1のものは下に移動させる this.Direct = 'up'; if(this.Type == 1) this.Direct = 'down'; } if(this.Y == eHomeCY){ this.WaitTime--; if(this.WaitTime <= 0) this.Status = 'out'; } return; } // 巣から出る if(this.Status == 'out'){ if(this.X == eHomeFrontX){ this.Y -= 1; if(this.Y == eHomeFrontY) this.Status = ''; } if(this.X < eHomeFrontX) this.X += 1; if(this.X > eHomeFrontX) this.X -= 1; return; } // すでに巣の外にいるので移動させる if(this.Direct == 'left'){ if(map[this.Y][this.X-1] > 0) this.X -= 1; if(this.X == 0) // ワープ this.X = 319; } if(this.Direct == 'right'){ if(map[this.Y][this.X+1] > 0) this.X += 1; if(this.X == 319) // ワープ this.X = 0; } if(this.Direct == 'up'){ if(map[this.Y-1][this.X] > 0) this.Y -= 1; } if(this.Direct == 'down'){ if(map[this.Y+1][this.X] > 0) this.Y += 1; } } } |
描画処理
敵を描画する処理を示します。
描画時に使用するイメージですが、イジケ状態で3秒以内に元に戻る状態ではijikeImages[0]とijikeImages[1]を交互に使います。それ以外の場合はijikeImages[0]を使います。イジケ状態ではない場合はenemyImages[this.Type]を使います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Enemy { Draw(){ let image = enemyImages[this.Type]; if(this.IjikeTime > 0){ if(this.IjikeTime > 60 * 3 || this.IjikeTime % 60 < 30) image = ijikeImages[0]; else image = ijikeImages[1]; } if(this.IsDead) image = eyeImage; ctx.drawImage(image, this.X - CHARACTER_SIZE / 2, this.Y - CHARACTER_SIZE / 2, CHARACTER_SIZE, CHARACTER_SIZE); } } |
今回はクラスの定義だけになりました。まずは普通のパックマンをつくらないといけないのですが、もう少し時間がかかりそうです。なかなかクソゲーの核心部分にたどりつけませんが、生温かくお見守りください。