第04回 自動照準と平行射出
狙う相手と撃ち方を決める
自動攻撃でも、狙う相手や発射する位置によって遊びは変わります。近い敵へ攻撃する、遠くの敵を優先する、正面だけを狙うなど、作品に合う照準を考えられます。
配布版のBoltとFanは、主人公に最も近い活動中の敵へ向けて発射します。発射後の弾は追尾せず、発射時に決めた速度で直進します。
この回では、標的の探索、発射待ち時間、弾の配置をつなげて読みます。敵を探す処理と弾を生成する処理を分けているため、標的の選び方と射出形態をそれぞれ変更できます。
手元で改造しているCompleteを引き続き使います。環境設定や起動方法は配布ファイルを参照してください。
変更したい内容と編集場所
| 変えたい内容 | ファイル・処理 |
|---|---|
| 狙う敵の選び方 | Source/AutoShooter/Objects/EnemyManager.cppのFindNearest |
| 標的を取得する位置とタイミング | Source/AutoShooter/GameModel.cppのFireWeapons |
| 発射可能かどうか | Source/AutoShooter/Objects/Weapon.hのIsReady |
| 発射待ち時間の更新 | Source/AutoShooter/Objects/Weapon.cppのTick・Fired |
| 発射間隔、弾数、弾速 | Weapon.cppのRows |
| 平行弾の生成位置 | Source/AutoShooter/Objects/AttackManager.cppのFireBolt |
| 平行弾列の幅 | Source/AutoShooter/Tuning/GameTuning.hのattack::BoltSpacing |
画面表示やキーの取得は、別の処理が担当しています。FireWeaponsは主人公の位置と武装の状態を使い、敵探索と攻撃生成を呼び出します。
二乗距離で最も近い敵を探す
EnemyManager::FindNearestは、主人公などの基準座標originを受け取り、最も近い敵を返します。
const Enemy* EnemyManager::FindNearest(Vector2 origin) const
{
const Enemy* target = nullptr;
float nearest = std::numeric_limits<float>::max();
for (const auto& enemy : items)
{
if (!enemy.IsActive())
continue;
float distance = LengthSquared(enemy.GetPosition() - origin);
if (distance < nearest)
{
nearest = distance;
target = &enemy;
}
}
return target;
}itemsには非活動の枠も含まれるため、IsActiveで調べてから比較します。探索対象は画面内だけでなく、画面外を含むすべての活動中の敵です。配布版には索敵距離の制限がありません。
LengthSquaredは、ベクトルの長さの二乗を返します。
inline float LengthSquared(Vector2 a)
{
return a.x * a.x + a.y * a.y;
}enemy.GetPosition() - originで座標の差を求め、そのxとyをそれぞれ二乗して足します。
距離は0以上なので、距離を二乗しても大小の順序は変わりません。最も近い相手を選ぶだけなら、平方根を計算せず比較できます。
nearestの初期値にはfloatで表せる最大値を使います。近い敵が見つかるたびにnearestとtargetを更新し、最後に残った敵が探索結果です。同じ二乗距離の敵が複数いる場合は、比較が<なので先に見つけた敵が残ります。
ポインターとnullptr
戻り値のconst Enemy*は、Enemyを指すポインターです。敵のコピーを作る代わりに、EnemyManagerの配列内の敵を参照します。constが付いているため、このポインターを通して敵の状態を変更しません。
&enemyは、その敵の場所を取得する式です。敵が見つからなければtargetはnullptrのままです。nullptrは「指す相手がいない」ことを表します。
発射処理では、標的を使う前に確認します。
if (target == nullptr)
return false;確認後の*targetは、ポインターが指すEnemyを取り出す書き方です。例えばDirectionTo(origin, *target)で、その敵を関数へ渡します。
このポインターは敵管理配列の要素を指しています。発射時の探索結果として使い、弾には敵へのポインターではなく計算した移動速度を保存します。そのため、発射後に敵が移動しても弾の方向は変わりません。
発射待ち時間と生成成否をつなぐ
GameModel.cppのFireWeaponsを確認します。
void GameModel::FireWeapons(float seconds)
{
auto origin = player.GetPosition();
const auto* target = enemies.FindNearest(origin);
for (auto& weapon : weapons)
{
weapon.Tick(seconds);
if (!weapon.IsReady())
continue;
if (attacks.Fire(weapon.GetType(), weapon.GetStats(), origin, target))
{
weapon.Fired();
events.shot = true;
}
}
}配布版では、この関数を呼ぶたびに標的を一度探し、その結果を全武装へ渡します。武装ごとに異なる標的を使う設計なら、探索を呼ぶ位置や選び方をここで変えられます。
auto& weaponは配列内の武装への参照です。TickやFiredで、その武装自身のタイマーを更新します。
Weaponの主な処理は次のとおりです。
bool IsReady() const { return level > 0 && timer <= 0; }void Weapon::Tick(float seconds)
{
timer = std::max(0.0f, timer - seconds);
}
void Weapon::Fired()
{
timer = GetStats().interval;
}Tickで残り時間を減らし、所持済みかつ待ち時間が終わっていれば発射を試みます。生成に成功した場合だけFiredで次の発射間隔を設定します。
BoltやFanは、標的がいなければfalseを返します。タイマーは再設定されないため、待ち時間が0なら、標的が見つかった次の更新で発射を試みます。まだ待ち時間が残っている場合は、その終了を待ちます。
攻撃プールに空きがない場合も生成は失敗します。複数発のうち一部だけ生成できた場合は成功として扱い、発射待ち時間へ進みます。
events.shotは、その更新で発射が成功したという通知です。音声側はこの通知を使えます。弾の生成処理が直接キー入力や音声再生を扱う必要はありません。
標的への方向と直交方向
AttackManager.cppのDirectionToは、発射位置から標的へ向かう方向を求めます。
Vector2 DirectionTo(Vector2 origin, const Enemy& target)
{
auto direction = Normalize(target.GetPosition() - origin);
return LengthSquared(direction) < 0.01f ? Vector2{ 0, -1 } : direction;
}Normalizeで長さを1にそろえます。ほぼ同じ位置などで方向がゼロになる場合は、上向きの{ 0, -1 }を使います。
方向が{ dx, dy }なら、それに直交する方向は{ -dy, dx }です。例えば上向き{ 0, -1 }に対しては、右向き{ 1, 0 }になります。
直交方向へ発射位置をずらし、移動方向は同じにすると、平行な弾列を作れます。
平行弾の配置を読む
AttackManager::FireBoltは、発射位置を横方向へずらして弾を生成します。
bool AttackManager::FireBolt(const Weapon::Stats& stats, Vector2 origin, const Enemy* target)
{
if (target == nullptr)
return false;
auto direction = DirectionTo(origin, *target);
Vector2 side{ -direction.y, direction.x };
bool fired = false;
for (int number = 0; number < stats.count; ++number)
{
float fraction = stats.count > 1
? static_cast<float>(number) / (stats.count - 1) - 0.5f
: 0;
auto offset = side * (fraction * tuning::attack::BoltSpacing);
if (SpawnShot(origin + offset, direction * stats.speed, Weapon::Type::Bolt, stats))
fired = true;
}
return fired;
}countは弾数、speedは弾速です。static_cast<float>は整数をfloatへ変換し、整数同士の割り算にならないようにします。
countが3ならfractionは-0.5、0、0.5です。BoltSpacingが20なら、中心から-10px、0px、10pxに並びます。
この式のBoltSpacingは弾列の両端の幅です。隣同士の間隔ではありません。弾数が増えても両端の幅は同じなので、隣の弾との間隔は狭くなります。1発の場合はfractionを0にして、中央から撃ちます。
すべての弾へ同じdirection × speedを渡すため、弾道は平行です。発射角度を変える扇状攻撃は第06回で扱います。
発射間隔と弾数を変える例
Weapon.cppのRowsで、Boltのレベル1に対応するStatsを探します。配布版は次の値です。
{ 14, 0.65f, 1, 640, 0, 0, 0, 1.5f },列は威力、発射間隔、弾数、弾速、開き角、半径、追加貫通数、寿命です。例えば間隔1秒、3発の平行弾にするなら、この1行を置き換えます。
{ 14, 1.00f, 3, 640, 0, 0, 0, 1.5f },発射間隔が長くなり、1回に生成する数が増えます。弾数が増えても、すべての弾が同じ敵へ当たるとは限りません。敵の大きさや配置、弾列の幅によって結果が変わります。
GameTuning.hのattack名前空間でBoltSpacingを変えると、弾列の幅を変えられます。例えば40pxにする場合は、その定数の1行を置き換えます。
constexpr float BoltSpacing = 40;狙った敵が中央の弾道へ入るか、左右の弾が別の敵へ当たるかを見て、作品に合う射出形態を調整します。
発射待ち時間は更新単位で進みます。固定DeltaTimeの配布版では、設定秒数が更新間隔の整数倍でなければ、次の更新で発射可能になります。単純な1 ÷ intervalは頻度の目安であり、生成失敗なども含む実際の発射回数とは区別します。
射程を持つ自動照準の例
索敵範囲を持たせる場合は、EnemyManager.cppのFindNearestで二乗距離を求めた直後に条件を追加できます。次は360px以内を対象にする例です。
float distance = LengthSquared(enemy.GetPosition() - origin);
if (distance > 360.0f * 360.0f)
continue;
if (distance < nearest)この変更を使うと、活動中の敵がいても、範囲外ならnullptrを返します。BoltとFanはその間、標的が見つかるのを待ちます。
Pulseは標的を必要としないため、この探索範囲だけでは発動を止めません。どの攻撃が標的を使うかは、攻撃方式側で決めています。
遠い敵やHPの低い敵を優先したい場合も、候補の比較部分が入口になります。新しい探索関数を作る場合は、EnemyManager.hの宣言、EnemyManager.cppの定義、FireWeaponsの呼び出しを対応させます。
自分の照準と射出を確かめる
変更したら保存してビルドし、どの敵へ向かって弾が出るか、敵がいないときにどう待つか、発射音と弾の生成がどう対応するかを確認します。
弾は発射後に標的を追わないため、移動中の敵から外れる場合もあります。照準時の狙いと、発射後の移動を分けて見ると、追尾弾や予測射撃へ発展させる際の変更箇所も分かります。
選んだ発射間隔や弾列は、そのまま作品へ引き継ぎます。第05回では、飛んだ弾がどの条件で命中し、HPやゲーム状態へどう反映されるかを扱います。