前回までで弾を撃ったり避けたりできるようになりましたが、なにか物足りないところがあると思います。 前回までの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 |
|
色々関数を定義していますが、それは一旦置いておいてmain
関数を見ていきます。 やっていることは以下のとおり。
移動操作の候補の列挙(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 |
|
変わったのは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 |
|
ロックオンした敵の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 |
|
generateTargetPosition
関数の実装をみていきます。 4行目でtarget_enemy_id
に対応する敵オブジェクトをtarget_enemy
に代入しています。 while
の中のコードは毎フレーム実行されるので、毎フレームtarget_enemy_id
に対応する敵オブジェクトを取得していることになります。
5行目でtarget_enemy
がnilかどうか判定しています。nilなのはtarget_enemy_id
に対応する敵がいなかったときです。具体的には以下のケースがあります。
target_enemy_id
がnilだったとき。すなわち、まだ敵をロックオンしていなかったとき。いずれにせよ、次にロックオンする敵を探します。それを行っているのが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 |
|
道をなぞる(避けながら)のmain
関数から変わったのは25行目から30行目だけです。 ロックオンしている敵がいるとき(= target_enemy
がnilじゃないとき)で、かつその敵と自機とのX軸方向の距離が十分近い時は射撃するというコードになっています。 射撃の操作を実現するためにgenerateShootKey
関数を用いているのは第三回と同じです。
それでは実際にAIを動かしてみましょう。
vs霊夢だとC3で詰むケースがある感じですが、どうにか勝ってます。もう少し大局的な視点で避けたいところですね。
今回は狙った敵を撃つことだけを考えて軌道を決めていますが、大局的に避ける、敵撃破の連鎖を狙う、アイテムを拾うなど様々な要素を考慮した軌道を作ればより人間に近いAIになるかと思います。
花AI塚チュートリアルの定期連載(といってもだいぶ遅延したが)は今回で最後となります。 また何かネタがあれば追加するかもしれません。