【クソゲープロジェクト】前提として普通のパックマンをつくる(2)の続きです。ようやくメインコンテンツです。挟み撃ちのアルゴリズムで絶対に逃げられないパックマンをつくる方法を考えます。
単純に最短経路を採用するだけではモンスターが列をなして動くだけで挟み撃ちにはなりません。そこで以下のように考えます。
(1) もっとも近い敵には最短経路でプレイヤーを追跡する。
(2) もっとも近い敵が最後にプレイヤーを捕まえるときどの方向から捕まえるかをしらべて、その他の敵にはそれとは別の方向から捕まえるための最短経路を考える。
(3) これを繰り返す。もし適切な行動が存在しない敵はランダムで移動方向を考える。
最短経路の計算
何度も最短経路を計算すると計算量が増えるのでマップを4分の1に縮小したものを考えます。これで計算量をだいぶ減らせます。
これは4分の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 |
let _miniMap = null; function getMiniMap(){ if(_miniMap != null) return _miniMap; const rowCount = map.length; const colCount = map[0].length; const miniRowCount = Math.floor(rowCount / 4); const miniColCount = Math.floor(colCount / 4); const miniMap = []; for(let y = 0; y < miniRowCount; y++){ const arr = []; for(let x = 0; x < miniColCount; x++) arr.push('#'); // とおれない miniMap.push(arr); } for(let y = 0; y < rowCount; y++){ for(let x = 0; x < colCount; x++){ if(map[y][x] > 0){ let row = Math.floor(y / 4); let col = Math.floor(x / 4); miniMap[row][col] = ' '; } } } _miniMap = miniMap; return miniMap; } |
ShortestInfoクラスの定義
ShortestInfoクラスを定義します。どの方向から捕まえるかで最短経路とそのコストを取得して格納できるようにします。プレイヤーの位置によってはその方向からは捕まえることができない場合があります。その場合はFromXXXCost は 9999、FromXXXPath配列の要素は空です。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class ShortestInfo { constructor(){ this.FromTopCost = 9999; this.FromLeftCost = 9999; this.FromRightCost = 9999; this.FromBottomCost = 9999; this.FromTopPath = []; this.FromLeftPath = []; this.FromRightPath = []; this.FromBottomPath = []; } } |
最短経路に情報の収集
それぞれの敵のプレイヤーまでの最短経路に関する情報を取得する処理を示します。
プレイヤーと敵はminiMap上であればどの座標に存在するかを調べます。そのあとプレイヤーがいる座標は通れないことにしてその上下左右の座標への最短経路を計算します。これで各敵の最大4通りの最短経路が取得できます(実際に4通りの最短経路が存在するのはプレイヤーが交差点にいるときだけ。ほとんどの場合は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 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 132 133 134 135 136 |
function getShortestPath(enemy){ const miniMap = getMiniMap(); const rowCount = miniMap.length; const colCount = miniMap[0].length; const maxCost = 9999; // miniMap に対応する座標を求める const enemyY = Math.floor(enemy.Y / 4); const enemyX = Math.floor(enemy.X / 4); const playerY = Math.floor(player.Y / 4); const playerX = Math.floor(player.X / 4); const cost = []; for(let y = 0; y < rowCount; y++){ const arr = []; for(let x = 0; x < colCount; x++) arr.push(maxCost); 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[enemyY][enemyX] = 0; const xs = []; const ys = []; xs.push(enemyX); ys.push(enemyY); // プレイヤーがいる座標は通れないことにしてその上下左右の座標への最短経路を計算する 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]; let newX = x + dx[i]; if(newX < 0) newX = colCount -1; if(newX >= colCount) newX = 0; if(newY < 0 || newY >= rowCount) continue; if(miniMap[newY][newX] != ' ') continue; if(newY == playerY && newX == playerX) 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; } // プレイヤーの上下左右の座標への最短経路を計算してShortestInfoオブジェクト内に格納する let shortestInfo = new ShortestInfo(); for(let i = 0; i < 4; i++){ let lastY; let lastX; if(i == 0){ // プレイヤーの左側の座標 lastY = playerY; lastX = playerX - 1; } if(i == 1){ // プレイヤーの上側の座標 lastY = playerY - 1; lastX = playerX; } if(i == 2){ // プレイヤーの右側の座標 lastY = playerY; lastX = playerX + 1; } if(i == 3){ // プレイヤーの下側の座標 lastY = playerY + 1; lastX = playerX; } // プレイヤーのとなりはワープした向こう側にある場合 if(lastX <= -1) lastX = colCount - 1; if(lastX >= colCount) lastX = 0; // 最短経路のコストを記録する if(i == 0) shortestInfo.FromLeftCost = cost[lastY][lastX]; if(i == 1) shortestInfo.FromTopCost = cost[lastY][lastX]; if(i == 2) shortestInfo.FromRightCost = cost[lastY][lastX]; if(i == 3) shortestInfo.FromBottomCost = cost[lastY][lastX]; const arr = []; // 最短経路そのものを求めて記録する while(true){ if(from[lastY][lastX] == null) break; const y0 = from[lastY][lastX].row; const x0 = from[lastY][lastX].col; if(y0 == enemyY && x0 == enemyX) break; lastY = y0; lastX = x0; arr.unshift({x:lastX,y:lastY}); } if(i == 0) shortestInfo.FromLeftPath = arr; if(i == 1) shortestInfo.FromTopPath = arr; if(i == 2) shortestInfo.FromRightPath = arr; if(i == 3) shortestInfo.FromBottomPath = arr; } enemy.ShortestInfo = shortestInfo; } |
getShortestPathToPower関数はプレイヤーの現在位置から引数で渡されたパワー餌までの最短経路のコストを計算します。
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 |
function getShortestPathToPower(powerFood){ const miniMap = getMiniMap(); const rowCount = miniMap.length; const colCount = miniMap[0].length; const maxCost = 9999; const playerY = Math.floor(player.Y / 4); const playerX = Math.floor(player.X / 4); const powerFoodY = Math.floor(powerFood.Y / 4); const powerFoodX = Math.floor(powerFood.X / 4); const cost = []; for(let y = 0; y < rowCount; y++){ const arr = []; for(let x = 0; x < colCount; x++) arr.push(maxCost); cost.push(arr); } cost[playerY][playerX] = 0; const xs = []; const ys = []; xs.push(playerX); ys.push(playerY); 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]; let newX = x + dx[i]; if(newX < 0) newX = colCount -1; if(newX >= colCount) newX = 0; if(newY < 0 || newY >= rowCount) continue; if(miniMap[newY][newX] != ' ') continue; if(newY == playerY && newX == playerX) continue; let newCost = cost[y][x] + 1; if(cost[newY][newX] <= newCost) continue; cost[newY][newX] = newCost; xs.push(newX); ys.push(newY); } if(xs.length == 0) break; } return cost[powerFoodY][powerFoodX]; } |
最短経路から敵の行動を決定する
最短経路から敵が次に移動すべき方向を取得する処理を示します。
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 |
function getDirectFromPath(enemy, paths){ const enemyX = Math.floor(enemy.X / 4); const enemyY = Math.floor(enemy.Y / 4); if(paths == null || paths.length == 0) return 'none'; if(enemyX == paths[0].x){ if(enemyY < paths[0].y) return 'down'; else return 'up'; } else if(enemyY == paths[0].y){ if(enemyX < paths[0].x){ if(Math.abs(enemyX - paths[0].x) < 10) return 'right'; else return 'left'; } else if(enemyX > paths[0].x){ if(Math.abs(enemyX - paths[0].x) < 10) return 'left'; else return 'right'; } } } |
enemiesThink関数の修正
enemiesThink関数を定義しなおします。
最短経路に関する情報を取得したあとどの方向から捕まえるかでそれぞれのコストと最初に動くべき方向を調べてオブジェクトに格納します。
そのあともっともプレイヤーの近くにいる敵には最短経路で追跡させます。それ以外の敵には別の方向から捕まえるための最短経路が短い敵に追跡させます。行動がすでに決定した敵と捕まえる方向が確定したオブジェクトは除外して対象がなくなるまでこれを繰り返します。
イジケ状態のときやプレイヤーの近くにパワー餌がある場合は逆襲を避けるためにプレイヤーからは離れる行動を取らせます。ただしプレイヤーとその近くにあるパワー餌があっても敵がもっと近くに接近している場合は逃げずに追跡させます(もちろん敵がイジケ状態であってはならない)。
移動方向がきまってもマップを4分の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 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 |
function enemiesThink(){ // 最短経路に関する情報を取得 for (let i = 0; i < 4; i++) getShortestPath(enemies[i]); // どの方向から捕まえるかでそれぞれのコストと最初に動くべき方向を調べてオブジェクトに格納する let arr = []; for (let i = 0; i < 4; i++){ arr.push({ enemy: enemies[i], firstDirect: getDirectFromPath(enemies[i], enemies[i].ShortestInfo.FromLeftPath), lastDirect: 'left', length: enemies[i].ShortestInfo.FromLeftPath.length, }); arr.push({ enemy: enemies[i], firstDirect: getDirectFromPath(enemies[i], enemies[i].ShortestInfo.FromTopPath), lastDirect: 'up', length: enemies[i].ShortestInfo.FromTopPath.length, }); arr.push({ enemy: enemies[i], firstDirect: getDirectFromPath(enemies[i], enemies[i].ShortestInfo.FromRightPath), lastDirect: 'right', length: enemies[i].ShortestInfo.FromRightPath.length, }); arr.push({ enemy: enemies[i], firstDirect: getDirectFromPath(enemies[i], enemies[i].ShortestInfo.FromBottomPath), lastDirect: 'down', length: enemies[i].ShortestInfo.FromBottomPath.length, }); } let enemy = null; let lastDirect = 'dummy'; while(true) { // 最短経路でソート(ただし行動がすでに決定した敵と捕まえる方向が確定したオブジェクトは除外する) // 対象が存在しなくなるまで繰り返す。 arr = arr.filter(_ => _.length > 0 && _.enemy != enemy && _.lastDirect != lastDirect).sort((a, b) => a.length - b.length); if(arr.length == 0) break; lastDirect = arr[0].lastDirect; enemy = arr[0].enemy; // この場合は敵の位置はかわらないので移動方向を決める処理もしていない if(enemy.IjikeTime > 0 && enemy.IjikeTime % 2 == 0) continue; // 敵からプレイヤーまでの最短経路のコスト(1)と // プレイヤーとその近くにあるパワー餌の最短経路のコスト(2)を比較 // 敵がイジケ状態ではなく、近くにパワー餌がない、または(1)< (2)のときはプレイヤーを追跡する // そうでない場合は敵は逃げる(arr[0].firstDirect以外を選択する) const neerPowerFoods = powerFoods.filter(_ => Math.abs(_.X - player.X) < 100 && Math.abs(_.Y - player.Y) < 100); const isTrackPlayer = enemy.IjikeTime <= 0 && (neerPowerFoods.length == 0 || getShortestPathToPower(neerPowerFoods[0]) > arr[0].length); const firstDirect = arr[0].firstDirect; if(isTrackPlayer){ let isSet = false; // 移動方向がきまったらその方向に移動できるか確認して移動可能ならEnemy.Directを変更 // その方向に移動できない場合は方向転換はしない。壁に突き当たっている場合は移動できる方向の第一候補を選択する if(enemy.CanChangeDirect(firstDirect)){ if(!$checkDisallowEnemyTurnback.checked){ enemy.Direct = firstDirect; isSet = true; } else { // 反転不可オプションの場合はenemy.GetReverseDirect() != firstDirectであることを確認する if(enemy.GetReverseDirect() != firstDirect){ enemy.Direct = firstDirect; isSet = true; } } } if(!isSet) { // 移動すべき方向に移動できない場合: // Enemy.GetNextDirects関数は現在の進行方向に移動可能ならその方向を、 // 壁に突き当たっている場合は移動できる方向を第一の要素として返す const directs = enemy.GetNextDirects(); if(directs.length > 0) enemy.Direct = directs[0]; } } else{ const directs = enemy.GetNextDirects().filter(_ => _ != firstDirect && _ != enemy.GetReverseDirect()); if(directs.length > 0) enemy.Direct = directs[0]; } } // 上記の方法で適切な移動方向を取得できない場合は、現在の進行方向に移動可能ならその方向を、 // すでに壁に突き当たっている場合は移動できる方向を設定する for(let i = 0; i < 4; i++) { let enemy = enemies[i]; // この場合は敵の位置はかわらないので移動方向を決める処理もしていない if(enemy.IjikeTime > 0 && enemy.IjikeTime % 2 == 0) continue; if(!enemy.CanChangeDirect(enemy.Direct)){ const directs = enemy.GetNextDirects(); if(directs.length > 0) enemy.Direct = directs[0]; } } } |
プレイした感想
実際にプレイしてみた感想。[パワー餌なし]だと[敵の折り返しを禁止する]オプションを有効にしても無理ゲーです。パワー餌があると敵が自分から逃げてくれる場合があるのでやりやすく感じる場合が間々あります。