第2回 弾を避ける

前回は左右に動くだけのAIを作りました。 今回は弾を避ける動きを実現したAIを作っていきます。

何を避ける? どう避ける?

色んなオブジェクト
色んなオブジェクト

花映塚にはさまざまなオブジェクトが現れます。

これらのうち、避ける必要があるのはアイテム以外の3つ。すなわち

です。


Q&A: 画像右側のレーザー(アースライトレイ)はEXアタックじゃないの?

違います。EXアタックのゲーム内部での扱いは幾分ややこしいのです。

EXアタックは大きく区分すると以下の3種類があります。

今回のレーザー(魔理沙のアースライトレイ)は2番目に該当します。 つまり画面に写っているレーザーはEXアタックそのものではなく、EXアタックから発生した弾(レーザー)なのです。

実のところEXアタックから発生した、と言うのも正確ではなくて、魔理沙のEXアタックは

  1. 1Pから2PにEXアタックが飛来
  2. EXアタックが不可視な敵を生成。EXアタックそのものはここで消滅
  3. 不可視な敵がレーザーを生成

というステップを踏んで実現されています。


さて、何を避ける必要があるかは分かりましたが、どう避ければいいのかというのが次の疑問になります。 機械的な動きで構わないから全部避け切りたいか、それとも人間的な動きで避けてほしいかといった好みの違いもあるかと思います。 今回は機械的でもいいからとりあえず避けよう、ということで考えていきます。

1フレームの間での移動には、「停止する(移動しない)」、「上に移動する」、「右に移動する」といったようなものがあります。 これらの移動を行った後の状態で弾や敵、EXアタックと衝突するかを調べて、衝突するならばそのような移動はしないようにする、というのが避ける方法として考えられます。

手順としては

  1. 1フレーム後のすべての弾、敵、EXアタックの位置を予測する
  2. 1フレーム後に自機が取り得る位置それぞれについて、弾や敵、EXアタックとの当たり判定を行う
  3. 移動先候補から最も被弾リスクの低いものを選んで移動する

といった流れになります。

1フレーム後の移動先での当たり判定を元に移動先を選ぶ
1フレーム後の移動先での当たり判定を元に移動先を選ぶ

花AI塚では弾や敵などの速度を取得することができます。1フレーム後の弾や敵、EXアタックの位置は

  (1フレーム後の位置) = (現在の位置) + (現在の速度)

で予測できるでしょう。

コードに落とす

それでは今のアイディアをAIのコードに落としていきましょう。 まずはmain関数から考えていきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function main()
  local myside = game_sides[player_side]
  local player = myside.player
  -- 選択する移動操作の候補を生成
  local candidates = generateCandidates(player)
  -- それぞれの移動操作の被弾リスクを計算
  calculateHitRisk(candidates, myside.enemies, player)
  calculateHitRisk(candidates, myside.bullets, player)
  calculateHitRisk(candidates, myside.exAttacks, player)
  -- 被弾リスクが一番低い移動操作を選ぶ
  local choice = choose(candidates)
  -- キー入力として送信
  sendKeys(choice.key)
end

さきほどの手順でいうところの1,2がcalculateHitRisk関数の呼び出しに対応します。 敵、弾、EXアタックのそれぞれについて被弾リスクを計算するので計3回呼び出しています。 手順の3に相当するのがchoose関数の呼び出しです。

ということで今回のAIのキモはcalculateHitRisk関数なのですが、ちょっと複雑なので先に他の関数を見ていきます。

generateCandidates関数は移動操作の候補を生成する関数です。 簡単のため移動操作としては「停止」、「上」「下」「左」「右」高速移動の5種類に絞りました。 各候補には

をフィールドとして持たせています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
local function generateCandidates(player)
  local candidates = {}
  local dxs = {0, 1, -1, 0, 0}
  local dys = {0, 0, 0, 1, -1}
  -- キー入力。停止、→、←、↓、↑
  local keys = {0x0,0x80,0x40,0x20,0x10}
  for i=1, #keys do
    candidates[i] = {
      key = keys[i],
      dx = player.speedFast * dxs[i],
      dy = player.speedFast * dys[i],
      hitrisk = 0
    }
  end
  return candidates
end

choose関数は移動操作の候補から最も被弾リスクの低いものを返します。

1
2
3
4
5
6
7
8
9
10
11
local function choose(candidates)
  local min_risk = 99999999
  local min_i = -1
  for i, cnd in ipairs(candidates) do
    if cnd.hitrisk < min_risk then
      min_risk = cnd.hitrisk
      min_i = i
    end
  end
  return candidates[min_i]
end

calculateHitRisk関数

calculateHitRisk関数の中身を見る前に、もう一度calculateHitRisk関数を呼び出しているコードを見てみましょう(main関数の7~9行目です)。

calculateHitRisk(candidates, myside.enemies, player)
calculateHitRisk(candidates, myside.bullets, player)
calculateHitRisk(candidates, myside.exAttacks, player)

myside.enemies, myside.bullets, myside.exAttacksはそれぞれ敵、弾、EXアタックを要素に持つ配列です。 それぞれデータ型は異なるのですが、座標や速度、当たり判定を取得する方法は同じです。

myside.enemies[1].x   -- 1番目の敵のX座標
myside.bullets[2].x   -- 2番目の弾のX座標
myside.exAttacks[3].x -- 3番目のEXアタックのX座標

myside.enemies[1].hitBody   -- 1番目の敵の当たり判定
myside.bullets[2].hitBody   -- 2番目の弾の当たり判定
myside.exAttacks[3].hitBody -- 3番目のEXアタックの当たり判定

calculateHitRisk関数の中では座標と速度と当たり判定にしかアクセスしません。なので弾だろうが敵だろうがEXアタックだろうが同じように扱えます。

それではcalculateHitRisk関数を見ていきましょう。 やっていることは

といった感じです。

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
local function adjustX(x)
  return math.max(-136, math.min(x, 136)) -- -136 <= x <= 136
end

local function adjustY(y)
  return math.max(16, math.min(y, 432)) -- 16 <= y <= 432
end

local function calculateHitRisk(candidates, objects, player)
  for i, obj in ipairs(objects) do
    local obj_body = obj.hitBody
    if obj_body ~= nil then
      -- 1フレーム後のオブジェクトの座標を求める
      obj_body.x = obj_body.x + obj.vx
      obj_body.y = obj_body.y + obj.vy
      -- 自機の当たり判定はオブジェクトの当たり判定の種類によって使い分ける
      local player_body
      if obj_body.type == HitType.Circle then
        player_body = player.hitBodyCircle
      else
        player_body = player.hitBodyRect
      end
      -- それぞれの移動操作でぶつかるかどうか調べる
      for j, cnd in ipairs(candidates) do
        player_body.x = adjustX(player.x + cnd.dx)
        player_body.y = adjustY(player.y + cnd.dy)
        if hitTest(player_body, obj_body) then
          cnd.hitrisk = cnd.hitrisk + 1
        end
      end
      -- 座標を元に戻す
      obj_body.x = obj.x 
      obj_body.y = obj.y 
      player_body.x = player.x
      player_body.y = player.y
    end
  end
end

18~22行目の条件分岐では、オブジェクトの当たり判定の種類に応じてplayer\_bodyの中身を変えています。 自機の当たり判定は3種類存在して

となっています。今回はアイテムは関係ないので、オブジェクトの当たり判定が円形かどうかのみで使い分けています。

25~26行目では自機の当たり判定の座標を更新しています。このときadjustX関数やadjustY関数を呼んでいますが、これらは更新後に自機が画面外にはみ出さないように補正するための処理です。 これがないと画面端に追い詰められたときに正しく自機の移動先の予測ができません。

当たり判定処理はhitTest関数を呼び出すことで行われます。この関数は花AI塚によって提供されているものです。

ここまででAIのコードがひと通り揃いました。改めてコード全体を載せておきます。

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
local function generateCandidates(player)
  local candidates = {}
  local dxs = {0, 1, -1, 0, 0}
  local dys = {0, 0, 0, 1, -1}
  -- キー入力。停止、→、←、↓、↑
  local keys = {0x0,0x80,0x40,0x20,0x10}
  for i=1, #keys do
    candidates[i] = {
      key = keys[i],
      dx = player.speedFast * dxs[i],
      dy = player.speedFast * dys[i],
      hitrisk = 0
    }
  end
  return candidates
end

local function adjustX(x)
  return math.max(-136, math.min(x, 136)) -- -136 <= x <= 136
end

local function adjustY(y)
  return math.max(16, math.min(y, 432)) -- 16 <= y <= 432
end

local function calculateHitRisk(candidates, objects, player)
  for i, obj in ipairs(objects) do
    local obj_body = obj.hitBody
    if obj_body ~= nil then
      -- 1フレーム後のオブジェクトの座標を求める
      obj_body.x = obj_body.x + obj.vx
      obj_body.y = obj_body.y + obj.vy
      -- 自機の当たり判定はオブジェクトの当たり判定の種類によって使い分ける
      local player_body
      if obj_body.type == HitType.Circle then
        player_body = player.hitBodyCircle
      else
        player_body = player.hitBodyRect
      end
      -- それぞれの移動操作でぶつかるかどうか調べる
      for j, cnd in ipairs(candidates) do
        player_body.x = adjustX(player.x + cnd.dx)
        player_body.y = adjustY(player.y + cnd.dy)
        if hitTest(player_body, obj_body) then
          cnd.hitrisk = cnd.hitrisk + 1
        end
      end
      -- 座標を元に戻す
      obj_body.x = obj.x 
      obj_body.y = obj.y 
      player_body.x = player.x
      player_body.y = player.y
    end
  end
end

local function choose(candidates)
  local min_risk = 99999999
  local min_i = -1
  for i, cnd in ipairs(candidates) do
    if cnd.hitrisk < min_risk then
      min_risk = cnd.hitrisk
      min_i = i
    end
  end
  return candidates[min_i]
end

function main()
  local myside = game_sides[player_side]
  local player = myside.player
  -- 選択する移動操作の候補を生成
  local candidates = generateCandidates(player)
  -- それぞれの移動操作の被弾リスクを計算
  calculateHitRisk(candidates, myside.enemies, player)
  calculateHitRisk(candidates, myside.bullets, player)
  calculateHitRisk(candidates, myside.exAttacks, player)
  -- 被弾リスクが一番低い移動操作を選ぶ
  local choice = choose(candidates)
  -- キー入力として送信
  sendKeys(choice.key)
end

もっと避けたい

先ほどのAI、実際に動かしてみたでしょうか。 参考までに今回のAIを動かしたリプレイを上げておきます。

プレイを見た感じだと一応弾を避けているといえば避けているのですが、正面から来た弾に対して後ずさりして避けようとしているうちに画面下に追い詰められて被弾する、といったケースが目立っています。

なんでそんなことになるのかはいろいろ考えられます。例えば

といったものが考えられるでしょう。

今回は先読みフレーム数を増やすことで改善していきます。

数フレーム先まで予測する(図は3フレーム先)
数フレーム先まで予測する(図は3フレーム先)

さっきのAIでは各移動操作について1フレーム先だけを予測していましたが、今度のAIでは数フレーム移動操作を続けた時の予測をすることにします。 たとえば「上移動」の操作であれば「上移動」を数フレーム続けた場合の被弾リスクを考えます。

弾などの位置の予測については

(nフレーム後の位置) = (現在の位置) + n * (現在の速度)

のように予測します。

弾などの位置予測は、速度が一定であることを仮定しています。加速したり曲がりくねった動きをする弾であれば当然予測は外れます。 したがって何フレームも先の時点での予測は1フレーム先の予測よりも不正確です。 ということは1フレーム先で被弾するという予測と、10フレーム先で被弾するという予測とでは前者の方が正確という訳です。 被弾リスクを計算するにあたってはこの点も加味しておくべきでしょう。 被弾するのが未来であればあるほどその予測は不正確なので、被弾リスクも低く見積もることにします。 今回はnフレーム先で被弾する場合は

(1/2)^n

だけ被弾リスクを加算することにします。これだとnが大きい(より未来の予測である)ほど加算するリスクが小さくなります。

改善のアイディアとしては以上でまとまったのですが、このコードを素直にコードに落とすと実は問題があります。 というのは計算量が大きくなってしまうのです。

例えば今、画面上に弾や敵などが合わせて200個あったとして、移動操作の選択肢が「停止」、「上」「下」「左」「右」高速移動の5種類、5フレーム先まで予測するとしたら当たり判定を行う回数は

200個 * 5種類 * 5フレーム = 5000回

になります。これだけの回数だと環境によっては処理落ちが発生してしまいます。 1画面に弾、敵、EXアタック合わせて200個以上という状況は割と起こりうるので対処すべきでしょう。

これに対する対策としては、自機の近くにある弾のみについて予測を行うというのが考えられます。 数フレームの間に自機に当たりそうな弾というのは普通自機の近くにある弾です。 弾が200個あるとしても大抵は画面一面に広がっているので、自機の近くの弾だけならそこまで多くはなく、したがって先読みの必要な弾を減らすことができます。

コードに落とす(2回目)

それでは実際に改善アイディアをコードに落としていきます。 予測に関するコードだけを変えるので、calculateHitRisk関数を書き換えればOKです。

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
local function calculateHitRisk(candidates, objects, player)
  for i, obj in ipairs(objects) do
    -- 1フレーム後のオブジェクトの座標を求める
    local obj_body = obj.hitBody
    if obj_body ~= nil then
      -- 自機の当たり判定はオブジェクトの当たり判定の種類によって使い分ける
      local player_body
      if obj_body.type == HitType.Circle then
        player_body = player.hitBodyCircle
      else
        player_body = player.hitBodyRect
      end
      if shouldPredict(obj_body, player_body) then
        for frame=1,5 do
          obj_body.x = obj_body.x + frame * obj.vx
          obj_body.y = obj_body.y + frame * obj.vy
          -- それぞれの移動操作でぶつかるかどうか調べる
          for j, cnd in ipairs(candidates) do
            player_body.x = adjustX(player.x + frame * cnd.dx)
            player_body.y = adjustY(player.y + frame * cnd.dy)
            if hitTest(player_body, obj_body) then
              cnd.hitrisk = cnd.hitrisk + (1/2)^frame
            end
          end
          -- 座標を元に戻す
          obj_body.x = obj.x 
          obj_body.y = obj.y 
          player_body.x = player.x
          player_body.y = player.y
        end
      end
    end
  end
end

さっきのAIの場合と較べると、14行目に1つfor文が増えてます。これが何フレーム先まで予測するかのループになっていて、今回5フレーム先まで予測しています。 当たり判定の座標を更新するコードが変わって、vxcnd.dxframeを掛けるようになりました。 また、22行目の被弾リスクを加算するコードも単に1を足すのではなく(1/2)^frameを足すようになっています。

これらの予測は13行目のshouldPredict関数の呼び出しがtrueを返すときだけ行われます。 この関数の呼び出しで行っているのが、自機の近くのオブジェクトかどうかの判定です。 ということでshouldPredict関数を見ていきましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
local function shouldPredict(obj_body, player_body)
  local prev_width, prev_height, prev_radius
  local size_rate = 10
  if obj_body.type ~= HitType.Circle then
    prev_width = player_body.width
    prev_height = player_body.height
    player_body.width = prev_width * size_rate
    player_body.height = prev_height * size_rate
  else
    prev_radius = player_body.radius
    player_body.radius = prev_radius * size_rate
  end
  local ret = hitTest(obj_body, player_body)
  if obj_body.type ~= HitType.Circle then
    player_body.width = prev_width
    player_body.height = prev_height
  else
    player_body.radius = prev_radius
  end
  return ret
end

自機の近くにあるオブジェクトかどうかを調べるために、一時的に自機の当たり判定のサイズをsize_rate(=10)倍にして当たり判定処理を行い、衝突していたら自機の近くにあると判定します。

以上の変更を行った結果、AI全体のコードは以下のようになります。

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
local function generateCandidates(player)
  local candidates = {}
  local dxs = {0, 1, -1, 0, 0}
  local dys = {0, 0, 0, 1, -1}
  -- キー入力。停止、→、←、↓、↑
  local keys = {0x0,0x80,0x40,0x20,0x10}
  for i=1, #keys do
    candidates[i] = {
      key = keys[i],
      dx = player.speedFast * dxs[i],
      dy = player.speedFast * dys[i],
      hitrisk = 0
    }
  end
  return candidates
end

local function adjustX(x)
  return math.max(-136, math.min(x, 136)) -- -136 <= x <= 136
end

local function adjustY(y)
  return math.max(16, math.min(y, 432)) -- 16 <= y <= 432
end

local function shouldPredict(obj_body, player_body)
  local prev_width, prev_height, prev_radius
  local size_rate = 10
  if obj_body.type ~= HitType.Circle then
    prev_width = player_body.width
    prev_height = player_body.height
    player_body.width = prev_width * size_rate
    player_body.height = prev_height * size_rate
  else
    prev_radius = player_body.radius
    player_body.radius = prev_radius * size_rate
  end
  local ret = hitTest(obj_body, player_body)
  if obj_body.type ~= HitType.Circle then
    player_body.width = prev_width
    player_body.height = prev_height
  else
    player_body.radius = prev_radius
  end
  return ret
end

local function calculateHitRisk(candidates, objects, player)
  for i, obj in ipairs(objects) do
    -- 1フレーム後のオブジェクトの座標を求める
    local obj_body = obj.hitBody
    if obj_body ~= nil then
      local player_body
      if obj_body.type == HitType.Circle then
        player_body = player.hitBodyCircle
      else
        player_body = player.hitBodyRect
      end
      if shouldPredict(obj_body, player_body) then
        for frame=1,5 do
          obj_body.x = obj_body.x + frame * obj.vx
          obj_body.y = obj_body.y + frame * obj.vy
          -- 自機の当たり判定はオブジェクトの当たり判定の種類によって使い分ける
          -- それぞれの移動操作でぶつかるかどうか調べる
          for j, cnd in ipairs(candidates) do
            player_body.x = adjustX(player.x + frame * cnd.dx)
            player_body.y = adjustY(player.y + frame * cnd.dy)
            if hitTest(player_body, obj_body) then
              cnd.hitrisk = cnd.hitrisk + (1/2)^frame
            end
          end
          -- 座標を元に戻す
          obj_body.x = obj.x 
          obj_body.y = obj.y 
          player_body.x = player.x
          player_body.y = player.y
        end
      end
    end
  end
end

local function choose(candidates)
  local min_risk = 99999999
  local min_i = -1
  for i, cnd in ipairs(candidates) do
    if cnd.hitrisk < min_risk then
      min_risk = cnd.hitrisk
      min_i = i
    end
  end
  return candidates[min_i]
end

function main()
  local myside = game_sides[player_side]
  local player = myside.player
  -- 選択する移動操作の候補を生成
  local candidates = generateCandidates(player)
  -- それぞれの移動操作の被弾リスクを計算
  calculateHitRisk(candidates, myside.enemies, player)
  calculateHitRisk(candidates, myside.bullets, player)
  calculateHitRisk(candidates, myside.exAttacks, player)
  -- 被弾リスクが一番低い移動操作を選ぶ
  local choice = choose(candidates)
  -- キー入力として送信
  sendKeys(choice.key)
end

このAIはさっきのAIと較べてもう少し頑張って避けてくれます。とはいえまだ追い詰められてしまうことも多々ありますが。


ここまででそれなりに弾を避けるAIが作れるようになったと思いますが、いかがだったでしょうか。

そういえばシューティングゲームだというのに今までのAIは1発も弾を撃ってない気がします。 なのでなので次回は弾を撃つAIを作っていこうかと思います(予定)。

もどる