第09回 ウェーブと成長計画
出現する敵と成長の流れを作る
敵をいつ、何体出し、どのくらいの強さにするかによって、主人公が移動する空間や経験値の集まり方が変わります。武装の解放や強化と組み合わせ、自分のゲームの展開を作ります。
第03回で追加した敵、第07回の武装、第08回の取得条件も、手元の設定を引き継ぎます。ここでいうビルドは、所持する武装と強化段階の組み合わせです。実行ファイルを作るビルドとは使う意味が異なります。
配布版の戦闘画面
画像は配布版の見本です。敵の密度や攻撃の広がりは、出現表、武装性能、取得状況、主人公の行動が重なって生まれます。
変更する場所
| 内容 | ファイル・処理 |
|---|---|
| 時間帯ごとの出現設定 | Source/AutoShooter/Tuning/EnemyWaves.cppのWaves |
| 敵種ごとの基本性能・経験値 | Source/AutoShooter/Objects/Enemy.cppのRows |
| 初期定数、同時存在数、クリア時刻 | Source/AutoShooter/Tuning/GameTuning.h |
| 出現時刻と生成の実行 | Source/AutoShooter/Objects/EnemyManager.cpp |
| 武装性能と成長 | Source/AutoShooter/Objects/Weapon.cpp |
| 必要経験値・取得条件 | GameTuning.h、Weaponの定義、GameModel.cpp |
| 敵の画像と表示倍率 | Source/AutoShooter/Resources/EnemyVisuals.h |
EnemyManagerは出現予定に沿って敵を生成します。どの時間帯にどの敵を出すかは、ゲーム側の表で決めます。既存の敵を別の時刻に登場させるだけなら、敵の移動処理を書き換える必要はありません。
ウェーブ表の構造
ウェーブは、ある開始時刻から次の開始時刻まで使う出現設定です。EnemyWaves.hの構造体を読みます。
struct EnemySpawnGroup
{
Enemy::Type type = Enemy::Type::Count;
int count = 0;
int hp = 0;
float speed = 0;
};
struct EnemyWaveDefinition
{
float startSeconds;
float interval;
std::array<EnemySpawnGroup, Enemy::Count> groups;
};EnemySpawnGroupは、敵種ごとの設定です。typeが種類、countが1回の出現予定数、hpとspeedが生成時に使う性能です。
EnemyWaveDefinitionは、開始時刻、出現間隔、敵グループの配列をまとめます。groupsの大きさはEnemy::Countなので、敵種を増やすと登録できる枠も増えます。
未使用枠はType::Count、count・hp・speedが0の既定値です。これは、どの敵も生成しない空のグループとして扱います。
配布版の出現計画
配布版のWavesは6行です。手元で変更している場合は、自分の表と照合してください。
| 経過時間 | 間隔 | 1回の通常敵/突進敵 | HP・通常/突進 | 速度・通常/突進の追従 |
|---|---|---|---|---|
| 0〜30秒 | 1秒 | 1/0体 | 14/28 | 80/70px/秒 |
| 30〜60秒 | 1秒 | 2/0体 | 18/32 | 90/70px/秒 |
| 60〜90秒 | 1秒 | 3/1体 | 28/42 | 100/80px/秒 |
| 90〜120秒 | 1秒 | 4/2体 | 40/60 | 110/90px/秒 |
| 120〜150秒 | 0.75秒 | 6/2体 | 60/90 | 120/100px/秒 |
| 150〜180秒 | 0.5秒 | 8/4体 | 90/140 | 140/110px/秒 |
最初の行のHPと速度は、表の数値を直接書いているのではなく、GameTuning.hの定数を参照しています。
{ 0, 1.00f, {{
{ Enemy::Type::Chaser, 1, Hp, Speed },
{ Enemy::Type::Dasher, 0, DasherHp, DasherSpeed }
}} },HpやSpeedを変更すると、この最初のウェーブにも反映されます。30秒以降の18・90などは明示した値なので、基本定数を変えてもその値を使います。
各時間帯の終わりは含みません。30秒なら開始30秒の行です。最後の行はGameTuning.hのPlaySecondsまで使います。
突進敵のspeedは追従中の速度です。突進中はGameTuning.hのDashSpeedを使います。
HP・速度と基本定義の関係
Enemy::Spawnは、出現する敵種と、その時刻のウェーブを参照します。
const auto& definition = GetDefinition(enemyType);
const auto* group = tuning::enemy::FindGroup(tuning::enemy::WaveAt(playTime), enemyType);
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;該当グループのHPや速度が正なら、その値を使います。0ならEnemy.cppの基本定義へ戻るため、例えば次の行は、その敵種の基本性能を使って3体生成する設定です。
{ Enemy::Type::Chaser, 3, 0, 0 },0はHP0や速度0の敵を作る指定ではありません。停止する敵などを表現する場合は、このフォールバック規則と行動処理を合わせて変更できます。
Spawnへ渡すhealthに正の値がある場合は、HPについてさらにその値を優先します。通常のウェーブ生成はこの引数を省略し、0を使います。
半径、接触ダメージ、経験値、行動方式は、このウェーブ表では上書きしません。Enemy.cppの敵種定義を使います。
生成した敵は出現時の性能を保持します。次の時間帯へ進んでも、画面に残っている敵のHPや速度が一斉に変わるわけではありません。
出現予定を進める処理
EnemyManagerは、次の出現予定時刻をnextSpawnTimeに保持します。ResetではSpawnDelayへ戻し、配布版は0.5秒から始めます。
EnemyManager.cppのUpdateで、出現予定を処理する部分です。
while (playTime < tuning::PlaySeconds && nextSpawnTime <= playTime)
{
const float scheduledTime = static_cast<float>(nextSpawnTime);
const auto index = tuning::enemy::WaveIndex(scheduledTime);
const auto& wave = tuning::enemy::WaveAtIndex(index);
SpawnWave(scheduledTime);
const double end = index + 1 < tuning::enemy::WaveCount()
? tuning::enemy::WaveAtIndex(index + 1).startSeconds
: tuning::PlaySeconds;
nextSpawnTime = std::min(nextSpawnTime + wave.interval, end);
}現在時刻までに到達した出現予定を処理します。適用するウェーブは、予定時刻scheduledTimeから選びます。
次の予定は、そのウェーブのintervalを加えて求めます。ただし、次のウェーブの開始時刻を超えないようにendで制限します。その境界へ達した次の生成では、新しい行の設定を使います。
例えば開始0秒・間隔1秒なら、最初は0.5秒、次は1.5秒と進みます。29.5秒の後は次の境界である30秒になり、30秒の出現は新しいウェーブを使います。
この実装は、毎回intervalを減算して0へ戻すタイマーではありません。whileで予定を処理するため、playTimeが複数の予定を越えた更新では、条件を満たす間、複数回生成します。ただしplayTimeがクリア時刻以上なら、このループへ入りません。
生成後は、すべての敵を今回のsecondsで一度更新します。大きなsecondsで出現予定を複数処理しても、途中の移動や攻撃を予定時刻ごとに細分して再現する処理ではありません。
予定数と実際の敵数
SpawnWaveは、グループごとにcount回の生成を試みます。
const auto& wave = tuning::enemy::WaveAt(playTime);
for (const auto& group : wave.groups)
for (int number = 0; number < group.count; ++number)
Spawn(RandomSpawnPosition(), group.type, playTime);敵の空き枠が足りなければ、その分は生成されません。出現予定は進むため、空いた後へ生成失敗分を持ち越しません。
同時存在数はGameTuning.hのenemy::Capacityです。出現表のcountは1回の予定数、Capacityは保持できる敵数なので、別々に設定します。
画面内に残る数は、生成数から撃破数などを差し引いた結果です。間隔を半分にしても、画面の敵数が常に2倍になるわけではありません。武装の命中、敵のHP、枠の空きも関わります。
出現間隔と経験値供給を変える例
EnemyWaves.cppで、開始30秒の行を探します。配布版は次の設定です。
{ 30, 1.00f, {{
{ Enemy::Type::Chaser, 2, 18, 90 },
{ Enemy::Type::Dasher, 0, 32, 70 }
}} },例えば間隔を1.5秒へ延ばすなら、このウェーブ全体を置き換えます。
{ 30, 1.50f, {{
{ Enemy::Type::Chaser, 2, 18, 90 },
{ Enemy::Type::Dasher, 0, 32, 70 }
}} },この予定時刻方式では、30秒以上60秒未満の出現は、間隔1秒なら30回、1.5秒なら20回です。毎回2体なら予定数は60体と40体になります。
すべて生成され、倒され、回収された場合、通常敵の経験値が1なら、この時間帯に出した敵から得る量も60と40です。実際の取得量は、撃破と回収の状況で変わります。
敵を減らすと移動できる空間が増える場合があります。一方、経験値が減って強化や解放が遅れる場合もあります。次のウェーブの開始時刻は変えていないため、そのときの武装構成との組み合わせで遊びが変わります。
出現する敵種と強さを変える例
同じ開始30秒の行で、通常敵と突進敵を組み合わせる例です。
{ 30, 1.20f, {{
{ Enemy::Type::Chaser, 2, 18, 90 },
{ Enemy::Type::Dasher, 1, 32, 70 }
}} },この場合は1回に合計3体を生成し、追従する敵の中へ突進の予告が加わります。突進敵の経験値は基本定義を参照するため、敵種の組み合わせは経験値供給にも影響します。
独自の敵種を使う場合は、同じgroupsへそのTypeを登録します。第03回のHeavyを追加しているなら、次のように基本性能で出す例が書けます。
{ Enemy::Type::Chaser, 2, 18, 90 },
{ Enemy::Type::Dasher, 1, 32, 70 }
,{ Enemy::Type::Heavy, 1, 0, 0 }この例を使うウェーブにはHeavy用の配列枠が必要です。groupsはEnemy::Countに連動するため、Typeを追加した構成ではその枠を使えます。
HPを増やすと、敵が画面に残る時間も変わり得ます。例えば無傷のHP40へ威力20の弾だけを当てるなら2命中ですが、HP60なら3命中です。ほかの武装、貫通、範囲攻撃が重なる実際のプレイでは、単体への必要命中数だけでは処理できる敵数を決められません。
時間帯を追加する例
ウェーブを追加すると、敵の種類や密度が切り替わる時点を増やせます。
EnemyWaves.cppのWaves宣言で、配列要素数を追加後の行数へ変更します。6行から7行にする場合の宣言部分は次の形です。
constexpr std::array<EnemyWaveDefinition, 7> Waves{{例えば開始30秒と60秒の間に、45秒の行を追加します。開始60秒の行は挿入位置の目印です。
{ 45, 0.80f, {{
{ Enemy::Type::Chaser, 2, 24, 95 },
{ Enemy::Type::Dasher, 1, 36, 75 }
}} },
{ 60, 1.00f, {{この追加で、開始30秒の行は45秒まで、追加した行は60秒まで使います。敵の行動コードや画像登録は、既存のものを再利用します。
ValidWavesとIsValidWaveは、開始時刻の並び、正の間隔、敵IDの重複、体数、HP、速度などを検査します。現在の規則では最初の開始時刻は0、各ウェーブの合計体数は正、間隔はその時間帯の長さ以下です。
無人の休止時間などを設計する場合は、出現表を読む処理と、この検査規則を対応させて変更できます。クリア時間を変える場合も、PlaySecondsと各開始時刻の関係を合わせます。
解放時期とウェーブを組み合わせる
第08回の例では、解放条件にプレイヤーレベルを使っています。ウェーブはゲーム内時間で進むため、両者は別の基準です。
「突進敵が増える頃に範囲攻撃を使えるようにしたい」と考えた場合、解放レベルだけでなく、そこまでに得られる経験値、回収のしやすさ、表示される候補、取得に使う所持枠も関わります。
解放は取得の保証ではありません。候補へ出ても別の強化を選ぶことができ、所持上限によって新規取得できない場合もあります。その選択を含めて、どの構成で敵の群れと戦えるかを作ります。
敵の出現数を増やす、経験値量を変える、必要経験値を変える、開始武装を変えるなど、成長へ働きかける箇所は分かれています。狙う遊びに合わせて、それぞれを接続できます。
密度と画面表現を合わせる
敵数が増えると、移動経路だけでなく、予告、HPバー、攻撃画像、経験値アイテムの見え方も変わります。
突進方向が読めるか、主人公がどこにいるか、攻撃が敵へ届いているかを、作った素材で確かめます。画像の大きさや色はEnemyVisuals.h、攻撃の表示はRendererや画像定義で調整できます。判定半径や出現数とは独立して変更できます。
攻撃で倒す速度が上がると、敵の密度が下がる一方で、経験値の表示が増えることもあります。PickupManagerは容量が埋まると値を合算するため、アイテムの個数だけで獲得可能な経験値量は判断できません。
自分の展開を遊んで確かめる
保存してビルドし、敵種が切り替わる場面、武装を取得する場面、群れが増える場面を遊びます。どの時刻を見るかは、自分が作った展開に合わせられます。
表示タイマーは残り時間で、出現表は経過時間です。PlaySecondsが180なら「180秒−残り秒数」が経過時間の目安になります。表示は整数秒なので、境界付近を正確に調べる場合はplayTimeとnextSpawnTimeを確認できます。
UpgradeやPausedの更新ではゲーム内時間が進みません。配布版は固定DeltaTimeで更新するため、処理負荷で実時間に対する進行が遅くなった場合も、ウェーブの基準はゲーム内時間です。
終盤までに全武装が最大レベルになるとは限りません。手元の取得条件や出現計画で、どんな武装構成が育ち、どんな場面を越えられるかを確かめます。生存だけでなく、攻撃の使い分けや移動の選択が、作りたい遊びになっているかを見られます。
調整した出現表と成長ルールは、そのまま作品へ引き継ぎます。第10回では、独自ルールの実装と、遊びながらの改善を扱います。