第3回 撃つ
前回は(消極的に)弾を避けるAIを作りました。 今回はこのAIに弾を撃ったりチャージアタックを撃ったりする動作を加えていきます。
通常弾を撃つ
花映塚ではZキーを押すと弾が出ます(キーボードfullの場合)。 通常の東方STGならZキー長押しで弾を撃ち続けますが、花映塚の場合長押しするとチャージに入ってしまいます。 なので花映塚で弾を撃ち続けるのは、Zキーを押したり離したりを繰り返す必要があります。
花AI塚のAIに弾を撃たせるときも同じです。弾を撃つには射撃キーを押したり離したりするコードを書くことになります。 Luaではコルーチンを使うことで押したり離したりの繰り返しを比較的スマートに書くことができます。
1
2
3
4
5
6
7
8
9
10
11
| local generateShootKey = coroutine.wrap(function ()
while true do
coroutine.yield(1)
coroutine.yield(0)
end
end)
function main()
local keys = generateShootKey()
sendKeys(keys)
end
|
このAIは通常弾を撃ち続けるだけのAIです。毎フレーム「射撃キーを押す」と「なにも押さない(つまり射撃キーを離す)」を交互に繰り返します。 sendKeys
関数に渡す引数は整数値を指定し、この整数の各bitが押すキーに対応します。射撃キーに対応するのは最下位bitなので、sendKeys(1)
と呼べば射撃キーを押せます。
このAIのキモになるのがgenerateShootKey
関数です。 この関数はただの関数ではなくコルーチンと言われるものになっています。 普通、関数はreturn
で戻り値を返したらそれっきりで、次にその関数を呼んだときはまた関数の最初から実行します。 一方コルーチンの場合はcoroutine.yield
で戻り値を返し、次にその関数を呼んだときはcoroutine.yield
を呼んだ後から関数の実行を続けます。
例えば、1回目にgenerateShootKey
を呼んだときは3行目のcoroutine.yield
で戻り値1を返してきます。2回目に呼んだときには3行目のcoroutine.yield
を呼んだ後から実行を続け、4行目のcoroutine.yield
で戻り値0を返します。3回目に呼んだときは4行目のcoroutine.yield
を呼んだ後から実行を続けます。whileループが2周目に入り、再び3行目のcoroutine.yield
で戻り値1を返します。
この繰り返しでgenerateShootKey
関数は奇数回目の呼び出しでは1を、偶数回目の呼び出しでは0を返します。 こうしてZキーを押したり離したりする操作を実現しています。
チャージアタックを撃つ
花映塚では射撃キー長押しでチャージ、チャージした状態で離すとチャージアタックとなります。 AIにチャージアタックを組み込むにあたっては以下のことが知りたくなると思います。
- 今いくつまでチャージできるか
- 今いくつまでチャージしたか
- 満タンまでチャージするのに何フレームかかるか
これらの情報はPlayer
オブジェクトが持っています。
local player = game_sides[player_side].player
player.currentChargeMax -- 今いくつまでチャージできるか
player.currentCharge -- 今いくつまでチャージしたか
player.chargeSpeed -- 1フレームでチャージできる量
currentChargeMax
とcurrentCharge
はともに0~400までの浮動小数点数の値を取ります。 100以上でC1が、200以上でC2が、300以上でC3が、400でC4が撃てるといった感じです。 満タンまでチャージするのにかかる時間は以下のようにして求まります。
満タンまでチャージするのにかかるフレーム数 = (player.currentChargeMax - player.currentCharge) / player.chargeSpeed
1フレームでチャージできる量がplayer.chargeSpeed
で得られるので、満タンにするのに必要なチャージ量をplayer.chargeSpeed
で割ればOKというわけです。
下のAIはC2以上を撃てる時はチャージしてC2を撃つ、それ以外の時は通常弾を撃つというものです。 チャージするかどうかの判定を13~17行目で行っていて、currentChargeMax
などを見て判断しています。
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
| 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 should_charge = false
if player.currentChargeMax > 200 then -- C2以上チャージできる
if player.currentCharge < 200 then -- まだC2までチャージしてない
should_charge = true
end
end
local keys = 0
if should_charge then
keys = 1 -- 射撃キー押しっぱなしにしとく
else
keys = generateShootKey() -- 射撃キー押したり離したりする
end
sendKeys(keys)
end
|
攻撃するAI
それでは通常弾やチャージアタックを撃つ操作を前回の弾回避AIに組み込んでみましょう。 前回のAIから弄る部分はmain
関数の辺りだけなのでその辺りを抜粋します。
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 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)
local should_charge = false
if player.currentChargeMax > 200 then -- C2以上チャージできる
if player.currentCharge < 200 then -- まだC2までチャージしてない
should_charge = true
end
end
local keys = choice.key
if should_charge then
keys = keys + 1 -- 射撃キー押しっぱなしにしとく
else
keys = keys + generateShootKey() -- 射撃キー押したり離したりする
end
-- キー入力として送信
sendKeys(keys)
end
|
11行目までは前回のAIから変わってないので説明は省きます。 13行目から27行目が今回新たに書いたコードです。 この部分のコードは先ほどのチャージアタックするAIとほぼ同じで、generateShootKey
関数の定義も同じです。
先ほどのチャージアタックのAIと違うのは20行目と22行目、そして24行目です。 これらの変更点は、弾回避AIが選んだキー入力とチャージアタックのAIが選んだキー入力を組み合わせている点です。 弾回避AIが生成するキー入力は移動操作のキーのみです。つまり「上」「下」「右」「左」キーの組み合わせとなっています。 一方、チャージアタックAIが生成するキー入力は「射撃」キーを押すか否かのみです。
冒頭にも述べましたがキー入力の指定はsendKeys
関数に渡す引数の各bitで指定します。 移動系のキーに対応するbitと射撃キーに対応するbitはそれぞれ異なります。よって移動系の操作と射撃系の操作を合わせたキー入力というのはそれぞれのキー入力の|(or オア)を取ったものです。 通常のLuaには|に対応する演算子がないのですが、今回のAIでは22行目や24行目にあるように+で足し算することで|の操作を実現しています。
a | b
の演算は、aとbとで1になっているbitが重複していない場合は
a + b
と同じになります。今回のAIの場合は移動系で射撃系で使うbitが違う(つまり1になるbitが重複しない)のでa | b
の代わりにa + b
が使えるというわけです。
実際に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
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
| 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
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)
-- それぞれの移動操作の被弾リスクを計算
calculateHitRisk(candidates, myside.enemies, player)
calculateHitRisk(candidates, myside.bullets, player)
calculateHitRisk(candidates, myside.exAttacks, player)
-- 被弾リスクが一番低い移動操作を選ぶ
local choice = choose(candidates)
local should_charge = false
if player.currentChargeMax > 200 then -- C2以上チャージできる
if player.currentCharge < 200 then -- まだC2までチャージしてない
should_charge = true
end
end
local keys = choice.key
if should_charge then
keys = keys + 1 -- 射撃キー押しっぱなしにしとく
else
keys = keys + generateShootKey() -- 射撃キー押したり離したりする
end
-- キー入力として送信
sendKeys(keys)
end
|
AIの動きとしては
- 弾に当たりそうなときは避ける
- 通常弾を撃ち続ける
- チャージゲージがC2以上まで溜められるときはチャージしてC2を撃つ
といったことをやるだけです。これだけでもMatchモードのノーマルぐらいならクリアできてしまったりします。
ここまででシューティングゲームのAIらしく、弾を撃つAIができました。いかがでしょうか。
次回はより賢く、できれば人間らしく避けるAIの作り方を紹介する予定ですが、まだサンプルコードを書いてないので予定変更になるかもしれません。 ともあれ、乞うご期待ということで。
もどる