第03回 敵の発生管理と追従
敵の役割を動きと出現で作る
敵の速さや耐久力、数、出現する場所を変えると、プレイヤーの動き方も変わります。追い続ける敵、進路を横切る敵、まとまって現れる敵など、作品でどんな相手を登場させたいか考えてください。
この回では、敵1体を扱うEnemyと、敵の集合を扱うEnemyManagerを読みます。既存の行動を使う別の敵を追加し、性能・画像・出現設定を接続する方法も扱います。
第02回で変更した画像やアニメーションは、そのまま使います。配布版と同じ外見へ戻す必要はありません。
配布版の敵を観察する
配布版の動画では、通常敵が主人公へ接近し、時間が進むと突進敵も出現します。
通常敵のChaserは、主人公の現在位置へ向かって移動します。突進敵のDasherは追従した後に停止し、決めた方向へ突進します。突進後は休止して追従へ戻ります。
敵は画面外の上下左右から現れます。HPが0になった敵は活動を終え、その場に経験値アイテムが生成されます。
敵の種類と行動方式は、別の列挙型で区別しています。
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から判断します。
bool IsActive() const { return state != State::Defeated; }Defeatedなら活動を終えた敵です。TakeDamageでHPが0になると、この状態へ変わります。
EnemyManagerが保持するもの
EnemyManagerは、敵を固定長配列で保持します。
std::array<Enemy, tuning::enemy::Capacity> items{};std::arrayは、要素数を型の一部として指定する配列です。Enemyが要素の型、tuning::enemy::Capacityが要素数です。
GameTuning.hの配布版設定は次の値です。
constexpr std::size_t Capacity = 160;160は同時に保持する敵の枠数です。ゲーム全体で生成できる累計数ではありません。敵が倒されると、その枠を次の敵に使います。このように、あらかじめ用意した枠を再利用する仕組みをプールと呼びます。
例えば同時存在数を320へ変える場合は、enemy名前空間のCapacityの1行を置き換えます。
constexpr std::size_t Capacity = 320;この定数を参照する敵配列や命中履歴の容量も、ビルド時に変わります。Capacityの変更は出現数の変更とは別です。枠を増やしただけでは出現予定数は増えません。
空き枠へ敵を生成する
EnemyManager.cppのSpawnを読みます。
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では、上下左右のどの辺を使うかを選び、その辺に沿った座標を乱数で決めます。
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で指定します。配布版の最初の行は次の内容です。
{ 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回だけ生成します。
for (const auto& group : wave.groups)
for (int number = 0; number < group.count; ++number)
Spawn(RandomSpawnPosition(), group.type, playTime);枠が満杯でも出現予定は消化します。生成できなかった分を、空きができた後にまとめて生成する処理にはなっていません。
ウェーブと成長の関係は第09回で扱います。ここでは、自分が変更した設定がどの時間帯へ適用されるかを読み取ります。
HPと速度はどこで決まるか
Enemy.cppのRowsには、敵種ごとの基本性能があります。
{ 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の処理を確認します。
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のウェーブ全体を、次の内容へ置き換えます。
{ 0, 1.00f, {{
{ Enemy::Type::Chaser, 2, 20, 120 },
{ Enemy::Type::Dasher, 0, DasherHp, DasherSpeed }
}} },この例は最初の時間帯にHPと速度を直接指定しています。GameTuning.hのHpやSpeedとは別に、このウェーブの値が使われます。
基本性能を使う設計なら、グループのHPと速度へ0を指定できます。例えばChaserの1行を次へ置き換えます。
{ Enemy::Type::Chaser, 2, 0, 0 },この場合、Enemy.cppの基本定義を参照します。配布版の基本定義はGameTuning.hのHpとSpeedを使うため、その変更が反映されます。
配布版の最初のウェーブにあるHpとSpeedも、同じGameTuning.hの定数を参照しています。一方、30秒以降の18・90などは直接書いた値です。基本値を変えたのに途中から速度が変わらない場合は、ウェーブ側を確認してください。
保存してビルドし、出現する体数、接近の速さ、倒すまでの攻撃回数を観察します。HPと速度の適用は新しく生成される敵が対象です。変更したコードでゲームを開始すると、その設定に基づく敵が現れます。
追従する方向と移動量
Chaseの移動はEnemy::Updateの次の部分です。
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の敵は、次の順で行動します。時間は配布版の設定です。
| 状態 | 時間 | 動作 |
|---|---|---|
| Chasing | 1.4秒 | 主人公を追従する |
| Preparing | 0.4秒 | 停止し、突進方向を保持する |
| Dashing | 0.4秒 | 保持した方向へ突進する |
| Resting | 0.4秒 | 停止し、その後Chasingへ戻る |
Preparingへ入るとき、Enemy::ChangeStateで方向を求めます。
if (state == Enemy::State::Preparing)
direction = Normalize(target - position);突進中は、このdirectionを使います。主人公が移動しても突進方向は追い直しません。
case Enemy::State::Dashing:
position = position + direction * (tuning::enemy::DashSpeed * seconds);
break;ウェーブのspeedは追従時の速度です。突進中の速度はGameTuning.hのDashSpeedを使います。
例えば予告時間を0.7秒へ変えるなら、enemy名前空間のPrepareSecondsの1行を置き換えます。
constexpr float PrepareSeconds = 0.7f;どこへ突進するか確定してから動き出すまでが長くなります。見た目の予告と実際の動作が、作品の敵としてどう見えるか確認してください。
状態の経過時間はstateSecondsに加算します。状態を切り替える際には、余った時間を次の状態へ引き継ぎます。
while (stateSeconds >= StateDuration())
{
float remainder = stateSeconds - StateDuration();
ChangeState(NextState(), target);
stateSeconds = remainder;
}StateDurationは現在の状態の長さ、NextStateは次の状態を返します。各状態の時間は、この更新処理が進められる正の値で設定します。
同じ行動を使う別の敵を追加する
ここでは、Chaseを再利用するHeavyという敵を追加します。HPや画像、出現時期は作品に合わせて設定できます。
1. 敵種のIDを追加する
Enemy.hのTypeを次へ置き換えます。Behaviorは変更しません。
enum class Type { Chaser, Dasher, Heavy, Count };Type::CountからEnemy::Countを求めているため、敵種ごとの配列サイズも変わります。EnemyManagerの枠数Capacityとは別の値です。
2. 基本性能を登録する
Enemy.cppのRowsで、Dasherの定義直後へ追加します。
{ 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の直後へ追加します。
{ 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のグループを追加する例です。既存のグループを残し、その後へ追加します。
{ 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回では、主人公がどの敵を狙い、どの方向へ弾を撃つかを扱います。