Skip to content

第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を受け取り、最も近い敵を返します。

cpp
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は、ベクトルの長さの二乗を返します。

cpp
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は「指す相手がいない」ことを表します。

発射処理では、標的を使う前に確認します。

cpp
if (target == nullptr)
    return false;

確認後の*targetは、ポインターが指すEnemyを取り出す書き方です。例えばDirectionTo(origin, *target)で、その敵を関数へ渡します。

このポインターは敵管理配列の要素を指しています。発射時の探索結果として使い、弾には敵へのポインターではなく計算した移動速度を保存します。そのため、発射後に敵が移動しても弾の方向は変わりません。

発射待ち時間と生成成否をつなぐ ​

GameModel.cppのFireWeaponsを確認します。

cpp
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の主な処理は次のとおりです。

cpp
bool IsReady() const { return level > 0 && timer <= 0; }
cpp
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は、発射位置から標的へ向かう方向を求めます。

cpp
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は、発射位置を横方向へずらして弾を生成します。

cpp
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を探します。配布版は次の値です。

cpp
{ 14, 0.65f, 1, 640, 0, 0, 0, 1.5f },

列は威力、発射間隔、弾数、弾速、開き角、半径、追加貫通数、寿命です。例えば間隔1秒、3発の平行弾にするなら、この1行を置き換えます。

cpp
{ 14, 1.00f, 3, 640, 0, 0, 0, 1.5f },

発射間隔が長くなり、1回に生成する数が増えます。弾数が増えても、すべての弾が同じ敵へ当たるとは限りません。敵の大きさや配置、弾列の幅によって結果が変わります。

GameTuning.hのattack名前空間でBoltSpacingを変えると、弾列の幅を変えられます。例えば40pxにする場合は、その定数の1行を置き換えます。

cpp
constexpr float BoltSpacing = 40;

狙った敵が中央の弾道へ入るか、左右の弾が別の敵へ当たるかを見て、作品に合う射出形態を調整します。

発射待ち時間は更新単位で進みます。固定DeltaTimeの配布版では、設定秒数が更新間隔の整数倍でなければ、次の更新で発射可能になります。単純な1 ÷ intervalは頻度の目安であり、生成失敗なども含む実際の発射回数とは区別します。

射程を持つ自動照準の例 ​

索敵範囲を持たせる場合は、EnemyManager.cppのFindNearestで二乗距離を求めた直後に条件を追加できます。次は360px以内を対象にする例です。

cpp
 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やゲーム状態へどう反映されるかを扱います。