Skip to content

第03回 敵の発生管理と追従 ​

敵の役割を動きと出現で作る ​

敵の速さや耐久力、数、出現する場所を変えると、プレイヤーの動き方も変わります。追い続ける敵、進路を横切る敵、まとまって現れる敵など、作品でどんな相手を登場させたいか考えてください。

この回では、敵1体を扱うEnemyと、敵の集合を扱うEnemyManagerを読みます。既存の行動を使う別の敵を追加し、性能・画像・出現設定を接続する方法も扱います。

第02回で変更した画像やアニメーションは、そのまま使います。配布版と同じ外見へ戻す必要はありません。

配布版の敵を観察する ​

配布版の動画では、通常敵が主人公へ接近し、時間が進むと突進敵も出現します。

通常敵のChaserは、主人公の現在位置へ向かって移動します。突進敵のDasherは追従した後に停止し、決めた方向へ突進します。突進後は休止して追従へ戻ります。

敵は画面外の上下左右から現れます。HPが0になった敵は活動を終え、その場に経験値アイテムが生成されます。

敵の種類と行動方式は、別の列挙型で区別しています。

cpp
enum class Type { Chaser, Dasher, Count };
enum class Behavior { Chase, Dash, Count };

Typeは「どの敵か」、Behaviorは「どう動くか」です。同じChaseを使いながら、HPや画像が異なる敵も作れます。

変更したい内容と編集場所 ​

変えたい内容ファイル・処理
敵種と行動方式の名前Source/AutoShooter/Objects/Enemy.h
敵種ごとの基本性能と使用する行動Source/AutoShooter/Objects/Enemy.cppのRows
敵種ごとの画像・色・表示倍率Source/AutoShooter/Resources/EnemyVisuals.hのRows
時間帯ごとの種類・数・HP・速度Source/AutoShooter/Tuning/EnemyWaves.cppのWaves
同時存在数や突進の時間・速度Source/AutoShooter/Tuning/GameTuning.hのenemy名前空間
空き枠の探索、出現時刻、出現位置Source/AutoShooter/Objects/EnemyManager.cpp
個体の移動と行動状態Source/AutoShooter/Objects/Enemy.cppのUpdateなど

画像の読み込みや座標計算には共通の仕組みを使い、ゲーム側の定義で敵の性能と役割を決めます。既存の行動を使う別の敵なら、生成や描画のループを敵種ごとに増やさず、定義表へ登録できます。

敵1体と敵全体の管理を分ける ​

Enemyが保持するもの ​

Enemyは、座標、HP、速度、半径、敵種、行動状態を保持します。Updateでは、1回分の移動と状態の変化を計算します。

敵が活動中かどうかは、独立したboolではなくstateから判断します。

cpp
bool IsActive() const { return state != State::Defeated; }

Defeatedなら活動を終えた敵です。TakeDamageでHPが0になると、この状態へ変わります。

EnemyManagerが保持するもの ​

EnemyManagerは、敵を固定長配列で保持します。

cpp
std::array<Enemy, tuning::enemy::Capacity> items{};

std::arrayは、要素数を型の一部として指定する配列です。Enemyが要素の型、tuning::enemy::Capacityが要素数です。

GameTuning.hの配布版設定は次の値です。

cpp
constexpr std::size_t Capacity = 160;

160は同時に保持する敵の枠数です。ゲーム全体で生成できる累計数ではありません。敵が倒されると、その枠を次の敵に使います。このように、あらかじめ用意した枠を再利用する仕組みをプールと呼びます。

例えば同時存在数を320へ変える場合は、enemy名前空間のCapacityの1行を置き換えます。

cpp
constexpr std::size_t Capacity = 320;

この定数を参照する敵配列や命中履歴の容量も、ビルド時に変わります。Capacityの変更は出現数の変更とは別です。枠を増やしただけでは出現予定数は増えません。

空き枠へ敵を生成する ​

EnemyManager.cppのSpawnを読みます。

cpp
bool EnemyManager::Spawn(Vector2 position, Enemy::Type type, float playTime, int hp)
{
    Enemy::GetDefinition(type);

    for (std::size_t slot = 0; slot < items.size(); ++slot)
    {
        auto& enemy = items[slot];

        if (enemy.IsActive())
            continue;

        enemy.Spawn(position, type, playTime, hp, nextId++, slot);

        return true;
    }

    return false;
}

itemsを先頭から調べ、活動中の敵はcontinueで飛ばします。活動していない枠が見つかると、Enemy::Spawnで新しい敵として初期化します。

auto& enemyは、配列要素への参照です。コピーを作らず、配列内のEnemyそのものを変更します。

slotは配列内の位置です。同じ枠を使っていても別の敵だと区別できるよう、生成ごとにidを変えます。攻撃側はこのIDを使い、同じ敵への重複命中を防いでいます。

空き枠があればtrue、なければfalseを返します。敵オブジェクトを毎回newで確保する処理はありません。固定配列の再利用によって、生成ごとの動的メモリ確保を避けています。実際の処理時間は敵数や攻撃数、描画などにも左右されます。

出現位置と出現予定 ​

画面外の4辺から位置を選ぶ ​

EnemyManager::RandomSpawnPositionでは、上下左右のどの辺を使うかを選び、その辺に沿った座標を乱数で決めます。

cpp
switch (side)
{
case 0: position = { tuning::ArenaLeft - tuning::enemy::SpawnMargin, vertical(random) }; break;
case 1: position = { tuning::ArenaRight + tuning::enemy::SpawnMargin, vertical(random) }; break;
case 2: position = { horizontal(random), tuning::ArenaTop - tuning::enemy::SpawnMargin }; break;
case 3: position = { horizontal(random), tuning::ArenaBottom + tuning::enemy::SpawnMargin }; break;
}

SpawnMarginは画面端から外側へ離す距離です。出現場所を固定する、主人公の進行方向へ配置するなど、場所のルールを変える入口はこの関数です。

乱数はResetでシード42へ戻します。同じシードでも、主人公の操作や撃破によってゲームの状態は変わるため、プレイ結果まで同じになるわけではありません。

時間帯ごとの設定を読む ​

出現予定はEnemyWaves.cppのWavesで指定します。配布版の最初の行は次の内容です。

cpp
{   0, 1.00f, {{
    { Enemy::Type::Chaser, 1, Hp, Speed },
    { Enemy::Type::Dasher, 0, DasherHp, DasherSpeed }
}} },

外側の0は開始秒、1.00fは出現間隔です。内側の各行は敵種、1回の体数、HP、速度です。Dasherの体数は0なので、この時間帯には出現しません。

最初の出現はSpawnDelayの0.5秒後です。その後は出現予定時刻を基準に繰り返し、次のウェーブの開始時刻に達すると設定が切り替わります。

EnemyManager::SpawnWaveでは、敵グループのcount回だけ生成します。

cpp
for (const auto& group : wave.groups)
    for (int number = 0; number < group.count; ++number)
        Spawn(RandomSpawnPosition(), group.type, playTime);

枠が満杯でも出現予定は消化します。生成できなかった分を、空きができた後にまとめて生成する処理にはなっていません。

ウェーブと成長の関係は第09回で扱います。ここでは、自分が変更した設定がどの時間帯へ適用されるかを読み取ります。

HPと速度はどこで決まるか ​

Enemy.cppのRowsには、敵種ごとの基本性能があります。

cpp
{ Enemy::Type::Chaser, Enemy::Behavior::Chase, tuning::enemy::Hp, tuning::enemy::Radius,
    tuning::enemy::Speed, tuning::enemy::Damage, tuning::enemy::Experience, 0xFF6688 },

列は敵種、行動、HP、判定半径、速度、接触ダメージ、経験値、命中効果の色です。

ただし、HPと速度は生成時のウェーブ指定で上書きできます。Enemy::Spawnの処理を確認します。

cpp
const auto& definition = GetDefinition(enemyType);
const auto* group = tuning::enemy::FindGroup(tuning::enemy::WaveAt(playTime), enemyType);

radius = definition.radius;
speed = group != nullptr && group->speed > 0 ? group->speed : definition.speed;

const int waveHp = group != nullptr && group->hp > 0 ? group->hp : definition.hp;
hp = maxHp = health > 0 ? health : waveHp;

const auto* groupは、該当する出現グループを指すポインターです。見つからなければnullptrになります。group->speedは、そのグループのspeedを参照する書き方です。

条件 ? A : Bは、条件が真ならA、偽ならBを使う式です。速度はグループが存在し、そのspeedが正ならウェーブの値、それ以外は基本値を使います。

HPもウェーブの正の値を優先します。さらにSpawnへ正のhealthが渡された場合は、その値を使います。通常のウェーブ生成ではhpの引数を省略するため0となり、ウェーブまたは基本定義から決まります。

生成後の個体は、そのHPと速度を保持します。次のウェーブへ進んでも、すでにいる敵の性能は変わりません。

出現数・速度・HPを変える例 ​

自分の敵をどう登場させたいかに合わせ、適用中の設定を変更します。ここでは開始直後から通常敵が2体ずつ、HP20・速度120で現れる例を示します。

EnemyWaves.cppで開始秒0のウェーブ全体を、次の内容へ置き換えます。

cpp
{   0, 1.00f, {{
    { Enemy::Type::Chaser, 2, 20, 120 },
    { Enemy::Type::Dasher, 0, DasherHp, DasherSpeed }
}} },

この例は最初の時間帯にHPと速度を直接指定しています。GameTuning.hのHpやSpeedとは別に、このウェーブの値が使われます。

基本性能を使う設計なら、グループのHPと速度へ0を指定できます。例えばChaserの1行を次へ置き換えます。

cpp
{ Enemy::Type::Chaser, 2, 0, 0 },

この場合、Enemy.cppの基本定義を参照します。配布版の基本定義はGameTuning.hのHpとSpeedを使うため、その変更が反映されます。

配布版の最初のウェーブにあるHpとSpeedも、同じGameTuning.hの定数を参照しています。一方、30秒以降の18・90などは直接書いた値です。基本値を変えたのに途中から速度が変わらない場合は、ウェーブ側を確認してください。

保存してビルドし、出現する体数、接近の速さ、倒すまでの攻撃回数を観察します。HPと速度の適用は新しく生成される敵が対象です。変更したコードでゲームを開始すると、その設定に基づく敵が現れます。

追従する方向と移動量 ​

Chaseの移動はEnemy::Updateの次の部分です。

cpp
if (GetBehavior() == Behavior::Chase)
{
    position = position + Normalize(target - position) * (speed * seconds);

    return;
}

Vector2はxとyを組にした2次元ベクトルです。target - positionで、敵から主人公へ向かう差を求めます。

Normalizeは向きの長さを1にそろえる処理です。方向へ速度と経過秒数を掛けることで、1回分の移動量になります。

新しい座標 = 現在の座標 + 方向 × 毎秒の速度 × 経過秒数

速度120px/秒、経過時間1/60秒なら、移動量の長さは2pxです。正規化しているため、主人公までの距離が長くても、それだけで移動速度は上がりません。

この追従処理には障害物を避ける経路探索はありません。毎回、現在の主人公へ向かう方向を計算します。

突進行動の状態を読む ​

Dashの敵は、次の順で行動します。時間は配布版の設定です。

状態時間動作
Chasing1.4秒主人公を追従する
Preparing0.4秒停止し、突進方向を保持する
Dashing0.4秒保持した方向へ突進する
Resting0.4秒停止し、その後Chasingへ戻る

Preparingへ入るとき、Enemy::ChangeStateで方向を求めます。

cpp
if (state == Enemy::State::Preparing)
    direction = Normalize(target - position);

突進中は、このdirectionを使います。主人公が移動しても突進方向は追い直しません。

cpp
case Enemy::State::Dashing:
    position = position + direction * (tuning::enemy::DashSpeed * seconds);
    break;

ウェーブのspeedは追従時の速度です。突進中の速度はGameTuning.hのDashSpeedを使います。

例えば予告時間を0.7秒へ変えるなら、enemy名前空間のPrepareSecondsの1行を置き換えます。

cpp
constexpr float PrepareSeconds = 0.7f;

どこへ突進するか確定してから動き出すまでが長くなります。見た目の予告と実際の動作が、作品の敵としてどう見えるか確認してください。

状態の経過時間はstateSecondsに加算します。状態を切り替える際には、余った時間を次の状態へ引き継ぎます。

cpp
while (stateSeconds >= StateDuration())
{
    float remainder = stateSeconds - StateDuration();
    ChangeState(NextState(), target);
    stateSeconds = remainder;
}

StateDurationは現在の状態の長さ、NextStateは次の状態を返します。各状態の時間は、この更新処理が進められる正の値で設定します。

同じ行動を使う別の敵を追加する ​

ここでは、Chaseを再利用するHeavyという敵を追加します。HPや画像、出現時期は作品に合わせて設定できます。

1. 敵種のIDを追加する ​

Enemy.hのTypeを次へ置き換えます。Behaviorは変更しません。

cpp
enum class Type { Chaser, Dasher, Heavy, Count };

Type::CountからEnemy::Countを求めているため、敵種ごとの配列サイズも変わります。EnemyManagerの枠数Capacityとは別の値です。

2. 基本性能を登録する ​

Enemy.cppのRowsで、Dasherの定義直後へ追加します。

cpp
 { Enemy::Type::Dasher, Enemy::Behavior::Dash, tuning::enemy::DasherHp, tuning::enemy::DasherRadius,
     tuning::enemy::DasherSpeed, tuning::enemy::DasherDamage, tuning::enemy::DasherExperience, 0xFFC86A }
,{ Enemy::Type::Heavy, Enemy::Behavior::Chase, 60, 26, 55, 16, 4, 0x8090FF }

この例はHP60、半径26px、速度55px/秒、接触ダメージ16、経験値4です。行動はChaseなので、既存の追従処理を使います。

定義の列はEnemy.hのDefinitionで確認できます。追加行の先頭のカンマは、前の定義との区切りです。

3. 画像と表示を登録する ​

EnemyVisuals.hのenemy_visuals::Rowsで、Dasherの直後へ追加します。

cpp
 { Enemy::Type::Dasher, SpriteImages::Id::Enemy, 0xFFC16C, 1.25f, 72, 64, 48 }
,{ Enemy::Type::Heavy, SpriteImages::Id::Enemy, 0x8090FF, 1.50f, 84, 72, 56 }

この例では手元のEnemy画像を再利用し、色と倍率を変えます。画像を分ける場合は、第02回の方法でSpriteImages::IdとDefinitionsへ登録し、その画像IDをここへ指定します。

scaleの1.50は表示倍率です。判定半径26pxとは独立しています。影やHPバーの寸法・位置も、この表示定義で決めます。

4. 出現するウェーブへ登録する ​

EnemyWaves.cppで開始秒0のウェーブへHeavyのグループを追加する例です。既存のグループを残し、その後へ追加します。

cpp
 {   0, 1.00f, {{
     { Enemy::Type::Chaser, 1, Hp, Speed },
     { Enemy::Type::Dasher, 0, DasherHp, DasherSpeed }
    ,{ Enemy::Type::Heavy, 1, 0, 0 }
 }} },

HPと速度は0なので、Heavyの基本性能を使います。定義を追加しただけでは出現せず、この出現表への登録で生成されます。

groupsはEnemy::Count件の固定長配列です。Heavyを登録しないウェーブでは、増えた要素は既定値の未使用枠になります。出現させたい時間帯のグループへ登録できます。

5. ビルドして登場を確認する ​

敵種、基本性能、表示、出現の4か所を保存してビルドします。基本性能や表示の登録不足、IDの重複などはstatic_assertで検査します。

開始秒0の例なら、最初の出現予定からHeavyも生成されます。見た目、接近速度、攻撃を受ける距離、倒した後の経験値を、設定と対応させて確認してください。

この追加ではEnemyManagerの生成ループや、Enemy::UpdateのChase処理を変更していません。異なる敵を同じ処理へ登録することで、挙動の共通部分を再利用できます。

新しい行動を作る場合の入口 ​

追従や突進とは異なる行動を作る場合は、Enemy.hのBehaviorへ方式を追加し、Enemy.cppのUpdateへその更新処理を実装します。ValidDefinitionsには使用可能な行動を検査する条件もあるため、新方式を登録対象へ加えます。

敵種のTypeを増やすことと、行動のBehaviorを増やすことは別の変更です。複数の敵種へ同じ新行動を割り当てることもできます。

継続して使う時間や方向などの値が必要なら、Enemyのメンバへ持たせ、Spawnで初期化します。段階を切り替える行動なら、State、ChangeState、StateDuration、NextStateも接続箇所になります。

生成したい敵の役割から、必要な動作を決めてください。敵数、出現位置、性能、表示、行動を別々に設定できるため、それぞれを組み合わせて作品へ反映できます。

自分の作品で動きを確かめる ​

敵を変更したら、狙った役割がプレイ中に表れているか確かめます。速い敵が追い立てる、耐久力の高い敵が進路に残る、予告のある突進を横移動で避けるなど、主人公の行動との関係を見ます。

設定と違う動きに見える場合は、基本性能とウェーブのどちらを使っているか、追従速度と突進速度のどちらを見ているかを確認してください。表示倍率と判定半径の違いも、接触の印象に影響します。

作った敵と外見は、以降も使います。第04回では、主人公がどの敵を狙い、どの方向へ弾を撃つかを扱います。