撃ちに行く

前回までで弾を撃ったり避けたりできるようになりましたが、なにか物足りないところがあると思います。 前回までのAIは弾に当たりそうになるまで動こうとしないのです。 もっと積極的に敵を倒しに行きつつ、避ける。そんなAIを今回は作っていきます。

道をなぞる

今までのAIは特に移動経路のプランというものがなく、弾に当たりそうになったら避けるだけという場当たり的な動きをしていました。 積極的にAIに動いてもらうために、まずは移動経路のプラン通りに動かす、というのをやってみます。

経路に沿って動いてもらうにはどう制御すればよいでしょうか。 シンプルな方法として、経路上を動く点を追うという方法があります。

経路上を動く点を追う 点を追って動くと経路をなぞれる

目標点の動く速さよりも自機の移動速度が速ければ、自機はやがて目標点に追いつきます。 そうすれば目標点を追うように動けば、経路上をなぞるように動くことになります。

では自機が目標点を追うにはどう制御すればいいかというと、これは至ってシンプルです。 移動操作の選択肢の中から一番目標点に近づける選択肢を選べばいいのです。

それでは実際に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
local generateTargetPosition = coroutine.wrap(function()
  local angle = 0
  local angle_max = 240
  local radius = 130
  local offset_y = 200
  local omega = math.pi * 2 / angle_max
  while true do
    angle = (angle + 1) % angle_max
    coroutine.yield(radius * math.cos(omega * angle), radius * math.sin(omega * angle) + offset_y)
  end
end)

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],
      followingCost = 0
    }
  end
  return candidates
end

local function calculateFollowingCost(candidates, player, target_x, target_y)
  for i, cnd in ipairs(candidates) do
    local dx = target_x - (player.x + cnd.dx)
    local dy = target_y - (player.y + cnd.dy)
    cnd.followingCost = math.sqrt(dx^2 + dy^2)
  end
end

local function choosemin(candidates, func)
  local min = 99999999
  local min_i = -1
  for i, cnd in ipairs(candidates) do
    local v = func(cnd)
    if  v < min then
      min = v
      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)
  -- 目標点の位置決定
  local target_x, target_y = generateTargetPosition()
  calculateFollowingCost(candidates, player, target_x, target_y)
  -- 追跡コストが最小の手を選ぶ
  local choice = choosemin(candidates, function (candidate)
    return candidate.followingCost
  end)
  sendKeys(choice.key)
end

色々関数を定義していますが、それは一旦置いておいてmain関数を見ていきます。 やっていることは以下のとおり。

  1. 移動操作の候補を列挙(55行目)
  2. 目標点の位置を決める(57行目)
  3. 各移動操作について、移動後の位置から目標点までの距離(=追跡コスト)を求める。(58行目)
  4. 各移動操作の中から追跡コストが一番小さいやつを選ぶ(60~62行目)

移動操作の候補の列挙(generateCandidates関数)は毎回AIを書くときに出てくるのでお馴染みかと思います。今回はfollowingCostというフィールドを追加しています。

目標点の位置を決めているのが1行目のgenerateTargetPosition関数です。今回はコルーチンとして定義しています。 この関数は半径130pixelの円を240フレームかけて一周するように毎フレームの目標点の座標を計算します。

追跡コストの計算は30行目のcalculateFollowingCost関数で行っています。この関数では移動後の位置から目標点までの距離を計算して、followingCostフィールドに格納しています。

こうして計算した追跡コストを元に、このフレームで採用する移動操作を選ぶのが60~62行目のchoosemin関数の呼び出しです。 前回までのAIではchoose関数を定義していましたが、今回改めてchoosemin関数として定義しました。これは引数funcで受け取った関数を使って、各候補についてfunc(候補)のように呼んで、戻り値が最も小さい候補を返すというものです。 これまでのchoose関数は何かしらの値が最小である候補を返すことが多かったので、「何かしらの値を取り出す」というところだけを引数funcに切り出してみました。 これならchooseminの定義を変えなくても引数funcを変えるだけでいろんな評価値を使った移動候補の選択ができるようになります。 今回は単にfollowingCostを返すだけの関数を渡しているので、followingCostが最も小さい候補を選択します。

ひとしきり解説したので、実際に動かしてみましょう。

このAIには弾を避けるといったロジックが組み込まれていません。なので被弾しまくってすぐに死んでしまいます。

道をなぞる(避けながら)

さきほどのAIに弾などを避けるロジックを組込みましょう。 第二回で弾を避けるAIを作ったので、そのときに作った関数を再利用します。

main関数の定義を以下のように変更しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function main()
  local myside = game_sides[player_side]
  local player = myside.player
  -- 移動候補
  local candidates = generateCandidates(player)
  -- 目標点の位置決定
  local target_x, target_y = generateTargetPosition()
  -- 追尾コスト計算
  calculateFollowingCost(candidates, player, target_x, target_y)
  -- それぞれの移動操作の被弾リスクを計算
  calculateHitRisk(candidates, myside.enemies, player)
  calculateHitRisk(candidates, myside.bullets, player)
  calculateHitRisk(candidates, myside.exAttacks, player)
  -- 総合的なコストが最小の手を選ぶ
  local choice = choosemin(candidates, function (candidate)
    return candidate.hitrisk * 10000 + candidate.followingCost
  end)
  sendKeys(choice.key)
end

変わったのは10行目から13行目でcalculateHitRisk関数を呼び出しているところ、そして16行目のコスト計算の式が変わっているところです。

calculateHitRisk関数は第二回のときに作った関数で、各移動方向について数フレーム先に被弾するリスクを計算します。 計算したリスクはcandidatesの各要素のhitriskフィールドに格納します。

16行目のコスト計算では、計算式を

被弾リスク * 10000 + 追跡コスト

のように変更し、被弾リスクを考慮した上で目標点を追うようにしました。 係数については適当に選んでいますが、被弾リスクを最小にすることを重視し、被弾リスクの少ない移動操作がいくつかあるときには追跡コストの小さい方を選ぶ、というように考えて被弾リスクの係数を大きめに設定しました。

それでは実際にAIを動かしてみましょう。

敵を倒さないままにしているのでなかなか軌道に沿って動くのが難しい感じですが、なんとなく軌道に沿って避けてる感じがします。

撃ちに行く

積極的に自機を動かすための準備が整ったので、これを攻撃につなげていきます。

積極的に攻撃しに行くAIを作るのですが、おおよそのアイデアは以下のとおりです。

ロックオンした敵を追いかけるという操作ではさきほどの目標点の追跡が使えます。 generateTargetPosition()がロックオンした敵の座標を返せば実現できそうです。

敵をロックオンするという操作については実装上注意が必要です。 というのもどの敵をロックオンしたのかを記憶する必要があるからです。 ロックオンした敵のオブジェクトを変数に保存すればいいと思うかもしれませんが、花AI塚の実装の都合上、それではうまくいきません。 敵や弾のオブジェクトをフレームをまたいで保存することができないのです。 その代わりそれぞれのオブジェクトには一意にidが振られていて、これはフレームをまたいでも同一の敵なら同一のidが振られていることが保証されているので、これを保存することにします。

移動の軌跡の制御については目標点の動かし方を変えればできるので、さきほどのAIから書き換えるのはgenerateTargetPosition関数だけでおおよそ済みます。 あとはロックオンした敵を撃つところをmain関数に追加すればOKです。

まずはロックオンした敵を保存するための変数を用意しておきます。ついでに便利関数も定義しておきます。

1
2
3
4
5
6
7
8
9
10
11
local target_enemy_id = nil
local target_enemy = nil

local function findEnemyById(enemies, id)
  for i, enemy in ipairs(enemies) do
    if enemy.id == id then
      return enemy
    end
  end
  return nil
end

ロックオンした敵のidを変数target_enemy_idに保存します。変数target_enemyは別に必須ではないですが、実装の効率のために用意しました。この変数には毎フレームtarget_enemy_idに対応する敵のオブジェクトを入れます。 関数findEnemyByIdは名前の通り、idに対応する敵オブジェクトを返す関数です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
local generateTargetPosition = coroutine.wrap(function()
  while true do
    local myside = game_sides[player_side]
    target_enemy = findEnemyById(myside.enemies, target_enemy_id)
    if target_enemy == nil then -- 次にロックオンする敵を探す
      target_enemy = choosemin(myside.enemies, function(enemy)
        -- 幽霊とか擬似的な敵やボスはなるべく無視
        if enemy.isSpirit or enemy.isPseudoEnemy or enemy.isBoss then
          return 99999999
        end
        return (enemy.x - myside.player.x)^2
      end)
    end
    if target_enemy ~= nil then -- ロックオンした敵がいる
      target_enemy_id = target_enemy.id
      coroutine.yield(target_enemy.x, target_enemy.y + 200)
    else -- ロックオンしてない
      target_enemy_id = nil
      coroutine.yield(myside.player.x, myside.player.y)
    end
  end
end)

generateTargetPosition関数の実装をみていきます。 4行目でtarget_enemy_idに対応する敵オブジェクトをtarget_enemyに代入しています。 whileの中のコードは毎フレーム実行されるので、毎フレームtarget_enemy_idに対応する敵オブジェクトを取得していることになります。

5行目でtarget_enemyがnilかどうか判定しています。nilなのはtarget_enemy_idに対応する敵がいなかったときです。具体的には以下のケースがあります。

いずれにせよ、次にロックオンする敵を探します。それを行っているのが6行目から12行目のコードです。 ここでは自機とのX軸方向の距離(= 自機の射軸との距離)が最も近い敵を探して、この敵をtarget_enemyにしています。 ただ、ここでは敵の種類に応じて処理を分岐しています。8行目のif文では、敵が幽霊やボス、擬似的な敵(※)である場合はすごく離れた位置にいることにして、なるべくロックオンしないようにしています。これらの敵は倒すのに時間がかかったり、そもそも倒すべきではない敵だったりするのでこのように処理しています。

※擬似的な敵とはなにかについては花AI塚のリファレンスや第二回のQ&Aを参照。要は弾幕生成のためにシステム上存在する見えない敵。

14行目からは現在ロックオンしている敵がいるかどうかで分岐しています。 いずれの場合もtarget_enemy_idを更新して、現在のフレームで追尾する目標点の座標を返します。

ロックオンしている敵がいる場合は、その敵のidをtarget_enemy_idに記憶します。 目標点はその敵の位置の200pixel下としています。200という数字は適当ですが、敵の前へ行くことを考えてこのように設定しました。 敵の位置をそのまま目標点にすると敵と衝突してしまいます。

ロックオンしている敵がいない場合は、target_enemy_idをnilにします。 目標点は自機の現在の位置をそのまま返しています。今の位置からなるべく動かないようにするという訳です。

ここまでで敵を追尾するところまではできました。あとはロックオンした敵を撃つところを実装します。

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
local generateShootKey = coroutine.wrap(function ()
  while true do
    coroutine.yield(1)
    coroutine.yield(0)
  end
end)

function main()
  local myside = game_sides[player_side]
  local player = myside.player
  -- 移動候補
  local candidates = generateCandidates(player)
  -- 目標点の位置決定
  local target_x, target_y = generateTargetPosition()
  -- 追尾コスト計算
  calculateFollowingCost(candidates, player, target_x, target_y)
  -- それぞれの移動操作の被弾リスクを計算
  calculateHitRisk(candidates, myside.enemies, player)
  calculateHitRisk(candidates, myside.bullets, player)
  calculateHitRisk(candidates, myside.exAttacks, player)
  -- 総合的なコストが最小の手を選ぶ
  local choice = choosemin(candidates, function (candidate)
    return candidate.hitrisk * 10000 + candidate.followingCost
  end)
  -- 狙ってる敵と軸があったときに撃つ
  if target_enemy ~= nil and (target_enemy.x - player.x)^2 < 32^2 then
    sendKeys(choice.key + generateShootKey())
  else
    sendKeys(choice.key)
  end
end

道をなぞる(避けながら)main関数から変わったのは25行目から30行目だけです。 ロックオンしている敵がいるとき(= target_enemyがnilじゃないとき)で、かつその敵と自機とのX軸方向の距離が十分近い時は射撃するというコードになっています。 射撃の操作を実現するためにgenerateShootKey関数を用いているのは第三回と同じです。

それでは実際にAIを動かしてみましょう。

vs霊夢だとC3で詰むケースがある感じですが、どうにか勝ってます。もう少し大局的な視点で避けたいところですね。

今回は狙った敵を撃つことだけを考えて軌道を決めていますが、大局的に避ける、敵撃破の連鎖を狙う、アイテムを拾うなど様々な要素を考慮した軌道を作ればより人間に近いAIになるかと思います。

花AI塚チュートリアルの定期連載(といってもだいぶ遅延したが)は今回で最後となります。 また何かネタがあれば追加するかもしれません。

もどる