第2回 弾を避ける
前回は左右に動くだけのAIを作りました。 今回は弾を避ける動きを実現したAIを作っていきます。
何を避ける? どう避ける?
花映塚にはさまざまなオブジェクトが現れます。
- 弾
- 自然に降ってくるものやチャージアタック、ボスが撒き散らしていくものなどがあります
- 敵
- EXアタック
- 白弾をある程度消したりすると相手陣側に送られる攻撃です。通常雪の結晶のようなエフェクトとともに現れます
- アイテム
これらのうち、避ける必要があるのはアイテム以外の3つ。すなわち
です。
Q&A: 画像右側のレーザー(アースライトレイ)はEXアタックじゃないの?
違います。EXアタックのゲーム内部での扱いは幾分ややこしいのです。
EXアタックは大きく区分すると以下の3種類があります。
- 当たり判定を持っていて弾のように振る舞うもの
- 霊夢、咲夜、妖夢、うどんげ、チルノ、てゐ、射命丸、幽香
- 自身は当たり判定を持たず、弾源として振る舞うもの
- ダメージを与えないが当たり判定を持つもの
- 映姫のもやもや。弾との当たり判定処理を行い、白弾が通過すると新たな弾を発生させる。
- メディの毒霧。自機との当たり判定処理を行い、当たっていれば自機の移動速度を下げる。
今回のレーザー(魔理沙のアースライトレイ)は2番目に該当します。 つまり画面に写っているレーザーはEXアタックそのものではなく、EXアタックから発生した弾(レーザー)なのです。
実のところEXアタックから発生した、と言うのも正確ではなくて、魔理沙のEXアタックは
- 1Pから2PにEXアタックが飛来
- EXアタックが不可視な敵を生成。EXアタックそのものはここで消滅
- 不可視な敵がレーザーを生成
というステップを踏んで実現されています。
さて、何を避ける必要があるかは分かりましたが、どう避ければいいのかというのが次の疑問になります。 機械的な動きで構わないから全部避け切りたいか、それとも人間的な動きで避けてほしいかといった好みの違いもあるかと思います。 今回は機械的でもいいからとりあえず避けよう、ということで考えていきます。
1フレームの間での移動には、「停止する(移動しない)」、「上に移動する」、「右に移動する」といったようなものがあります。 これらの移動を行った後の状態で弾や敵、EXアタックと衝突するかを調べて、衝突するならばそのような移動はしないようにする、というのが避ける方法として考えられます。
手順としては
- 1フレーム後のすべての弾、敵、EXアタックの位置を予測する
- 1フレーム後に自機が取り得る位置それぞれについて、弾や敵、EXアタックとの当たり判定を行う
- もし衝突しているならば、その位置の被弾リスクを上げる
- 移動先候補から最も被弾リスクの低いものを選んで移動する
といった流れになります。
花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種類に絞りました。 各候補には
- キー入力の値
- X方向およびY方向の移動量
- 被弾リスク(初期値0)
をフィールドとして持たせています。
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フレーム後のオブジェクトの座標を求める
- 自機の移動操作それぞれについて
- 1フレーム後の自機の座標を求める
- 1フレーム後の座標でオブジェクトと自機の当たり判定を行う
- ↑で衝突していたら、この移動操作の被弾リスクを+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
| 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種類存在して
- player.hitBodyRect
- 矩形の当たり判定やレーザーとの当たり判定処理に使う当たり判定
- player.hitBodyCircle
- 円形の当たり判定との当たり判定処理に使う当たり判定
- player.hitBodyForItem
となっています。今回はアイテムは関係ないので、オブジェクトの当たり判定が円形かどうかのみで使い分けています。
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を動かしたリプレイを上げておきます。
プレイを見た感じだと一応弾を避けているといえば避けているのですが、正面から来た弾に対して後ずさりして避けようとしているうちに画面下に追い詰められて被弾する、といったケースが目立っています。
なんでそんなことになるのかはいろいろ考えられます。例えば
- 回避する方向の選び方が悪い
- 弾の移動方向と直角に避ければ後ずさりしないで済むはず
- 先読みフレーム数が少ない
- 1フレームだけじゃなくてもっと先まで読めば追い詰められる前に避け方を変えそう
といったものが考えられるでしょう。
今回は先読みフレーム数を増やすことで改善していきます。
さっきの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フレーム先まで予測しています。 当たり判定の座標を更新するコードが変わって、vx
やcnd.dx
にframe
を掛けるようになりました。 また、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を作っていこうかと思います(予定)。
もどる