第06回 扇状・波状の攻撃
攻撃の広がり方を作る
同じ威力でも、どこへ、どのように届く攻撃かによって使い方が変わります。正面へ並ぶ弾、左右へ広がる弾、発生地点から拡大する衝撃波を読み、自分の作品の攻撃へつなげます。
配布版にはBolt・Fan・Pulseがあります。次の画像は配布版の見本です。
配布版のFanレベル1
Fanは、照準方向を中心に弾の向きを変えて生成します。生成後の各弾は直進します。
配布版のPulseレベル1
Pulseは発動地点に中心を残し、時間とともに半径を広げます。画像は輪の形ですが、命中判定は現在半径までの円内を対象にします。
手元で作った外見や照準、判定を使って改造を続けます。
生成・更新・命中を分けて読む
| 処理 | 担当 |
|---|---|
| 発射間隔、威力、弾数、角度、半径などの定義 | Source/AutoShooter/Objects/Weapon.cpp |
| 発射位置・速度の計算とプールへの生成 | Source/AutoShooter/Objects/AttackManager.cpp |
| 弾の移動、寿命、衝撃波の経過時間 | Source/AutoShooter/Objects/Attack.cpp |
| 攻撃ごとの命中判定と命中履歴 | Attack.cppのTryHit |
| 敵との組み合わせとダメージ適用 | Source/AutoShooter/GameModel.cpp |
| 攻撃画像と表示 | Source/AutoShooter/Resources/Renderer.cpp |
AttackManagerは攻撃を生成し、各攻撃のUpdateを呼びます。敵との組み合わせを調べてダメージを適用するのはGameModelです。
既存の動作を使い、威力や弾数を変えるなら性能定義を編集します。軌道や判定の仕方を変えるなら、攻撃の生成・更新・命中処理へ進みます。
| 攻撃 | 生成時に決めるもの | 生成後の更新 | 命中判定 |
|---|---|---|---|
| Bolt | 横へずらした位置、共通の速度 | Shotの直進と寿命 | 移動線分と敵 |
| Fan | 共通の位置、弾ごとに回転した速度 | Shotの直進と寿命 | 移動線分と敵 |
| Pulse | 中心、最大半径、拡大時間 | Pulseの経過時間 | 現在半径の円と敵 |
BoltとFanは、同じShotを異なる初期条件で生成しています。発射形態を変えても、直進や命中処理は再利用できます。
定義値と攻撃の動作
Weapon::Statsには次の項目があります。
struct Stats
{
int damage = 0;
float interval = 0;
int count = 0;
float speed = 0;
float spread = 0;
float radius = 0;
int penetration = 0;
float duration = 0;
};countは弾数、speedは弾速、spreadは扇全体の開き角です。radiusは衝撃波の最大半径、durationは弾の寿命または衝撃波の拡大時間を表します。
どの値を使うかは攻撃処理で決めています。例えば配布版のPulseはcount回の生成ループを持たないため、countを増やすだけでは衝撃波の数は増えません。
攻撃を生成するときには、その時点の威力や寿命などを攻撃オブジェクトへ保存します。発射後に武装レベルが上がっても、すでに存在する攻撃の性能は変わりません。
Fanの扇状発射
ラジアンと全開角
角度の単位にはラジアンを使います。半回転の180度がπラジアン、1回転は2πラジアンです。
Fanのspreadは、中心から片側の角度ではなく、左右両端の弾の間の全開角です。spreadが0.50なら、両端は中心から-0.25、+0.25ラジアンに置かれます。
AttackManager::FireFanを確認します。
bool AttackManager::FireFan(const Weapon::Stats& stats, Vector2 origin, const Enemy* target)
{
if (target == nullptr)
return false;
auto direction = DirectionTo(origin, *target);
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 shotDirection = Rotate(direction, stats.spread * fraction);
if (SpawnShot(origin, shotDirection * stats.speed, Weapon::Type::Fan, stats))
fired = true;
}
return fired;
}3発ならfractionは-0.5、0、0.5です。それぞれにspreadを掛け、Rotateで標的方向を回転します。すべて同じoriginから発射しますが、速度の向きが異なります。
奇数発なら中央方向の弾が含まれます。偶数発なら中央を挟んで配置し、中央方向そのものの弾はありません。1発の場合はfractionを0にして、標的方向へ撃ちます。
回転後も同じShotを使う
RotateはMath.hの関数です。
inline Vector2 Rotate(Vector2 a, float angle)
{
return {
a.x * std::cos(angle) - a.y * std::sin(angle),
a.x * std::sin(angle) + a.y * std::cos(angle)
};
}std::cosとstd::sinはラジアンの角度を受け取ります。回転した方向へspeedを掛け、その速度をShotへ渡します。
生成後はBoltと同じShot::Updateで直進します。Fanという理由で、飛行中にさらに角度が広がる処理をしているわけではありません。進む向きが異なるため、時間とともに弾同士の距離が広がります。
Fanの角度や弾数を変える例
Weapon.cppのRowsで、Fanのレベル1を探します。配布版は次の1行です。
{ 8, 1.10f, 3, 460, 0.50f, 0, 0, 1.1f },全開角を0.90ラジアンにする例では、この1行を置き換えます。
{ 8, 1.10f, 3, 460, 0.90f, 0, 0, 1.1f },3発の角度は、中心から-0.45、0、+0.45ラジアンになります。弾数を維持して広げると、同じ距離での弾間隔が広がります。
例えば同じ全開角で5発にするなら、次の定義です。
{ 8, 1.10f, 5, 460, 0.90f, 0, 0, 1.1f },角度は-0.45、-0.225、0、+0.225、+0.45ラジアンになります。弾の生成・更新・命中処理を追加せず、countとspreadから配置を作れます。
保存してビルドし、Fanを取得した状態で発射を見ます。左右への到達範囲だけでなく、中央の弾道や敵の大きさとの関係も確かめてください。広げることで実際に命中が増えるかは、敵の位置や距離によって変わります。
Pulseの生成と中心位置
Pulseは標的を必要としません。AttackManager::FirePulseは、空いている枠を探して1個生成します。
bool AttackManager::FirePulse(const Weapon::Stats& stats, Vector2 origin)
{
for (auto& pulse : pulses)
{
if (pulse.IsActive())
continue;
pulse.Spawn(origin, stats);
return true;
}
return false;
}originは発動時の主人公の位置です。Pulse::Spawnで中心と性能を保存します。
void Pulse::Spawn(Vector2 start, const Weapon::Stats& stats)
{
if (stats.duration <= 0)
throw std::invalid_argument("Pulse duration must be positive.");
position = start;
age = 0;
duration = stats.duration;
radius = stats.radius;
damage = stats.damage;
hitIds.fill(0);
active = true;
}生成後のUpdateはpositionを変更しません。そのため、主人公が移動しても中心は発動地点に残ります。
主人公に追従する範囲攻撃を作るなら、中心位置を更新する接続が必要です。現在のPulse::Updateは経過秒数だけを受け取るため、標的座標を渡す引数や呼び出し側も合わせて設計できます。
経過時間から半径を広げる
Attack.hで現在半径を求めています。
float GetRatio() const { return age / duration; }
float GetRadius() const { return radius * GetRatio(); }ageが0なら半径0、durationの半分なら最大半径の半分、durationに達すると最大半径になります。
Attack.cppのPulse::Updateです。
void Pulse::Update(float seconds)
{
if (!active)
return;
if (age >= duration)
{
active = false;
return;
}
age = std::min(duration, age + seconds);
}ageはdurationまでに制限します。最大半径へ達した更新では、まだ活動中なので、その後に最大半径で判定します。次のUpdateで非活動になります。
durationは最大半径へ達するまでの時間です。配布版は直線的に拡大するため、半径の増加速度は最大半径 ÷ durationになります。
拡大の速さを途中で変えるなら、GetRadiusで使う比率の式が入口です。一定速度、最初は速く後から遅くするなど、半径と時間の対応を変えられます。
輪の画像と円内の命中判定
Pulse::TryHitを確認します。
bool Pulse::TryHit(const Enemy& enemy)
{
if (!active || !enemy.IsActive())
return false;
if (Length(enemy.GetPosition() - position) > GetRadius() + enemy.GetRadius())
return false;
auto& hitId = hitIds.at(enemy.GetSlot());
if (hitId == enemy.GetId())
return false;
hitId = enemy.GetId();
return true;
}中心間距離が現在半径と敵半径の和以下なら、位置の条件を満たします。画像の輪の線だけとの接触ではなく、円内に入っている敵も対象です。
命中履歴は敵のプール枠ごとにIDを保存します。auto& hitIdは、その枠の記録への参照です。同じIDならすでに当てた敵なので、再びダメージを与えません。
同じ枠へ新しい敵が生成された場合はIDが変わるため、別の敵として命中できます。1個のPulseが同じ個体へ与えるダメージは1回ですが、別のPulseからは再び命中できます。
配布版のPulseには、敵を押し戻す処理はありません。敵のHPを減らし、倒れた場合は活動を終了させます。押し戻しを加えるなら、命中時に方向と移動量を敵側へ渡す処理を作れます。
Pulseの半径と拡大時間を変える例
Weapon.cppのRowsでPulseのレベル1を探します。配布版は次の値です。
{ 12, 2.20f, 1, 0, 0, 110, 0, 0.5f },最大半径160px、拡大時間0.5秒にするなら、この1行を置き換えます。
{ 12, 2.20f, 1, 0, 0, 160, 0, 0.5f },半径の増加速度は220px/秒から320px/秒になります。到達範囲と広がる速さの両方が変わる例です。
最大半径160pxへ1秒かけて広げるなら、次の値です。
{ 12, 2.20f, 1, 0, 0, 160, 0, 1.0f },保存してビルドし、Pulseを取得して動きを確認します。発動後に主人公を動かすと、中心が発動地点に残ることも分かります。
画像は現在半径の2倍を幅と高さにして描くため、半径の変更に合わせて表示も広がります。表示の透明な余白や輪の形は、円形判定そのものとは別です。
攻撃プールと生成できる数
AttackManagerはShotとPulseのプールを別々に持ちます。容量はGameTuning.hのattack名前空間にあります。
constexpr std::size_t ShotCapacity = 256;
constexpr std::size_t PulseCapacity = 8;弾数や寿命、発射頻度を変えると、同時に存在する攻撃数も変わります。容量も作品の設定として変更できます。
SpawnShotは1発ごとに空き枠を探します。例えば5発中3発だけ生成できた場合も、FireFanはtrueを返し、武装は次の発射待ち時間へ進みます。角度は元の5発の配置で計算するため、一部だけ生成された弾列は左右対称にならない場合があります。
Pulseは空き枠へ1個生成できればtrue、空きがなければfalseです。生成に成功したときだけ発射待ち時間を設定する流れは、第04回と共通です。
新しい攻撃へつなげる
既存のShotを使うなら、発射位置と速度の作り方を変えて、全周射出や異なる弾列を作れます。飛行中に曲がる弾なら、Shotの更新で速度や位置を変える処理が必要です。
別の状態を持つ攻撃を作る場合は、Attack.h・Attack.cppに攻撃の型と処理を用意し、AttackManagerへ保持・生成・更新・リセットを接続します。さらにGameModelの命中処理とRendererの表示をつなぎます。関数を追加する場合は、ヘッダーの宣言、呼び出し、cppの定義を対応させます。
配布版のAttackManager::Fireは、武装IDから直接Bolt・Fan・Pulseへ分岐しています。同じ攻撃方式を使う別武装を増やす設計は、第07回で扱います。
その際、FireBoltとFireFanが生成する弾へ固定の武装IDを渡していること、Pulseが発射元の武装IDを保持していないことも接続箇所になります。移動処理を再利用するだけでなく、画像や色、性能表示まで、新しい武装へ対応させます。
自分の攻撃を確かめる
攻撃を実行し、発射位置、弾の向き、広がる時間、命中する領域、消えるタイミングを確認します。見た目だけでなく、敵のHPバーや撃破、命中効果も合わせて見られます。
Fanの角度を広げた結果や、Pulseの中心を残す使い方は、敵の配置と主人公の動きによって変わります。自分の作品でどんな攻撃として使いたいかを考え、定義と処理の両方から調整してください。
作った攻撃と設定は次の制作へ引き継ぎます。第07回では、攻撃方式と武装ごとの性能を分け、独自武装の定義へつなげます。