第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フレームでチャージできる量

currentChargeMaxcurrentChargeはともに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の動きとしては

といったことをやるだけです。これだけでもMatchモードのノーマルぐらいならクリアできてしまったりします。


ここまででシューティングゲームのAIらしく、弾を撃つAIができました。いかがでしょうか。

次回はより賢く、できれば人間らしく避けるAIの作り方を紹介する予定ですが、まだサンプルコードを書いてないので予定変更になるかもしれません。 ともあれ、乞うご期待ということで。

もどる