第07回 武装データとレベル設計
武装ごとの役割と成長を作る
同じ直進弾でも、威力、発射間隔、弾数、貫通数を変えると、異なる武装として設計できます。強化に伴って性能がどう変わるかも、作品の遊びに関わります。
配布版CompleteにはBolt・Fan・Pulseがあります。この回では、武装を識別するIDと、攻撃の動作を選ぶ方式を分けます。既存の発射処理を使いながら、別の名前や性能を持つ武装を登録できる形にします。
具体例には、直進方式を使うRapidBolt「速射弾」を使います。名前や数値、追加する武装は、自分の作品に合わせて設計できます。手元で変更した画像、攻撃、ルールを引き継いで作業します。
武装の性能と攻撃方式を分ける
配布版のWeapon::Typeは、武装の種類と発射処理の選択を兼ねています。
BoltならFireBolt、FanならFireFanという対応です。このまま武装を増やすと、同じ発射処理を使う場合も武装IDごとの分岐が必要になります。
そこで、Weapon::Typeを武装IDとして残し、Weapon::AttackTypeを追加します。
| 武装ID | 表示名の例 | 攻撃方式 |
|---|---|---|
| Bolt | 狙い撃ち | Straight |
| RapidBolt | 速射弾 | Straight |
| Fan | 扇状発射 | Spread |
| Pulse | 衝撃波 | Wave |
武装IDは「どの武装か」、攻撃方式は「どの処理で攻撃するか」を表します。BoltとRapidBoltは別の武装ですが、どちらもStraightを使います。
名前、色、レベル別性能は武装定義に登録します。発射処理は攻撃方式から選び、生成した攻撃には元の武装IDを渡します。画像や表示は、そのIDから定義を参照できます。
これにより、新しいStraight武装を増やすときも、直進する更新処理や命中判定を武装ごとに複製する必要がありません。
編集する場所と接続の順序
| ファイル | 変更・確認する内容 |
|---|---|
Source/AutoShooter/Objects/Weapon.h | 武装ID、攻撃方式、Definitionの項目 |
Source/AutoShooter/Objects/Weapon.cpp | 既存武装の方式、新武装の性能、定義検査 |
Source/AutoShooter/Objects/AttackManager.h | 発射元IDを受け取る関数宣言 |
Source/AutoShooter/Objects/Attack.h | PulseのID保持と取得関数 |
Source/AutoShooter/Objects/AttackManager.cpp | 方式による分岐とIDの受け渡し |
Source/AutoShooter/Objects/Attack.cpp | 生成時に保存するID |
Source/AutoShooter/Resources/Renderer.cpp | 攻撃画像、色、強化カードの性能表示 |
Source/AutoShooter/GameModel.h・.cpp | 武装配列、初期化、発射、取得候補の確認 |
まず型と関数を宣言し、定義表を登録します。その後、呼び出しと関数本体を対応させ、表示と取得まで確認します。
差分の先頭にある+は追加する行です。+自体は入力しません。置換例では、指定した行や関数を示したコードに置き換えます。自分で調整した性能や発射処理は、その変更を引き継いで接続してください。
性能表と武装レベル
Statsの項目
Weapon::Statsは、攻撃に使う値をまとめた構造体です。
| 項目 | 意味と単位 |
|---|---|
| damage | 1体への1回の命中で減らすHP |
| interval | 発射間隔、秒 |
| count | 1回に生成する弾数 |
| speed | 弾速、px/秒 |
| spread | 扇全体の開き角、rad |
| radius | 衝撃波の最大半径、px |
| penetration | 最初の1体に加えて命中できる敵の数 |
| duration | 弾の寿命、または衝撃波の拡大時間、秒 |
どの値を使うかは攻撃方式で決まります。例えばWaveでは半径と拡大時間を使い、配布版の生成処理ではcountによって衝撃波を増やしません。
レベルと配列の添字
配布版はレベル0が未所持、1〜5が取得済みです。Definitionのlevelsには、レベル1から順に性能を登録します。
std::array<Stats, tuning::MaxWeaponLevel> levels;std::arrayは固定長配列です。StatsをMaxWeaponLevel件保持します。現在の性能はGetStatsで取得します。
Weapon::Stats Weapon::GetStats() const
{
if (level == 0)
return {};
return Weapon::GetDefinition(type).levels[static_cast<std::size_t>(level - 1)];
}配列の添字は0から始まるため、レベル1なら0、レベル5なら4を使います。未所持では全項目0のStatsを返します。
MaxWeaponLevelを変える場合は、各武装のlevelsもその段階数に合わせます。レベルは配列参照だけでなく、強化上限や候補生成にも使われています。
発射後の性能は保持する
ShotやPulseは、生成時に威力や寿命などを保存します。飛んでいる途中で武装を強化しても、その攻撃の性能は変わりません。
一方、描画時の色は保存した武装IDから取得します。攻撃が保持する性能と、武装定義から参照する表示情報を分けて読めます。
1. 武装IDと攻撃方式を宣言する
Weapon.hのTypeで、BoltとFanの間へRapidBoltを追加します。既存コメントを除いた差分です。
enum class Type
{
Bolt,
RapidBolt,
Fan,
Pulse,
Count
};
enum class AttackType { Straight, Spread, Wave };Countは武装ではなく種類数を求める値です。既存の次の宣言が、配列の要素数へ接続しています。
static constexpr std::size_t Count = static_cast<std::size_t>(Type::Count);続いて、Definitionのtypeの直後へattackを追加します。
struct Definition
{
Type type;
AttackType attack;
const wchar_t* name;
unsigned int color;
std::array<Stats, tuning::MaxWeaponLevel> levels;
};構造体の初期値は、このメンバの順に並べます。既存のRowsにもattackの値を追加する必要があります。
Typeは識別用の名前、AttackTypeは処理の選択に使う名前です。異なるenum classにすることで、武装IDと攻撃方式を区別して扱えます。
2. 既存の武装定義へ方式を登録する
Weapon.cppのRowsで、既存3武装の行頭を置き換えます。各武装のlevelsは、手元で設定している値を残します。
Boltの行頭です。
{ Weapon::Type::Bolt, Weapon::AttackType::Straight, L"狙い撃ち", 0x75D8FF, {{Fanの行頭です。
{ Weapon::Type::Fan, Weapon::AttackType::Spread, L"扇状発射", 0xFFCC70, {{Pulseの行頭です。
{ Weapon::Type::Pulse, Weapon::AttackType::Wave, L"衝撃波", 0xC5A0FF, {{名前や色を変更している場合も、その値の前に対応する攻撃方式を入れます。
速射弾の定義例
同じRows内で、Fanの定義の直前へ次の武装を追加します。既存行は挿入位置の目印です。
{ Weapon::Type::RapidBolt, Weapon::AttackType::Straight, L"速射弾", 0x80FF90, {{
{ 8, 0.30f, 1, 640, 0, 0, 0, 1.5f },
{ 10, 0.25f, 1, 640, 0, 0, 0, 1.5f },
{ 12, 0.20f, 1, 680, 0, 0, 0, 1.5f },
{ 14, 0.18f, 1, 680, 0, 0, 1, 1.5f },
{ 16, 0.15f, 1, 720, 0, 0, 1, 1.5f }
}} },
{ Weapon::Type::Fan, Weapon::AttackType::Spread, L"扇状発射", 0xFFCC70, {{この例は単発威力を抑え、短い間隔で撃つ設計です。後半のレベルでは追加貫通数も増やしています。
各レベルで何を変えるかは、武装の役割に合わせて決められます。弾数が増える段階、攻撃範囲が広がる段階など、成長の違いも表で表現できます。
定義の検査を接続する
既存のstatic_assertは、登録数、IDの重複、名前、性能値、命中履歴の容量などを検査します。attackも検査対象へ加えます。
ValidDefinitions内で、registered[index]への代入の直後へ追加します。
registered[index] = true;
if (definition.attack != Weapon::AttackType::Straight &&
definition.attack != Weapon::AttackType::Spread &&
definition.attack != Weapon::AttackType::Wave)
return false;
for (const auto& stats : definition.levels)&&で、どの方式にも一致しないかを判定しています。新しい攻撃方式を実装した場合は、この検査の対応方式にも追加します。
Rowsの記述順は、武装IDの順と一致していなくても参照できます。ByTypeがIDを添字にして並べ直すためです。
for (const auto& definition : Rows)
result[Weapon::Index(definition.type)] = definition;Weapon::Indexはenum classの値を配列の添字に変換します。定義の登録順と、ID順の参照用配列を分ける処理です。
3. 発射元IDを受け取る関数を宣言する
AttackManager.hのprivateにあるFireBolt・FireFan・FirePulseの宣言3行を置き換えます。
bool FireBolt(Weapon::Type type, const Weapon::Stats& stats, Vector2 origin, const Enemy* target);
bool FireFan(Weapon::Type type, const Weapon::Stats& stats, Vector2 origin, const Enemy* target);
bool FirePulse(Weapon::Type type, const Weapon::Stats& stats, Vector2 origin);最初の引数に、発射元の武装IDを追加しています。publicのFireと、privateのSpawnShotはすでにIDを受け取るため、宣言を変更しません。
Pulseにも武装IDを保持させる
Shotはすでに武装IDを保持しています。Pulseにも同じ情報を持たせ、別のWave武装から生成した場合に、その定義を参照できるようにします。
Attack.hのPulseクラスで、Spawnの宣言1行を置き換えます。
void Spawn(Vector2 position, const Weapon::Stats& stats, Weapon::Type type = Weapon::Type::Pulse);最後の引数には既定値があります。省略した呼び出しはPulseとして扱い、AttackManagerからは発射元のIDを明示します。
同じPulseクラスのpublicで、GetDamageの直後へGetTypeを追加します。
int GetDamage() const { return damage; }
Weapon::Type GetType() const { return type; }Pulseのprivateで、positionの直前へメンバを追加します。
private:
Weapon::Type type = Weapon::Type::Pulse;
Vector2 position;同じ名前のメンバがShotにもあります。追加する場所はPulseクラスです。
4. Fireで方式を選び、元の武装IDを渡す
AttackManager.cppのFireを、関数全体で置き換えます。
bool AttackManager::Fire(Weapon::Type type, const Weapon::Stats& stats, Vector2 origin, const Enemy* target)
{
if (Weapon::Index(type) >= Weapon::Count)
throw std::invalid_argument("Weapon ID is outside the registered range.");
switch (Weapon::GetDefinition(type).attack)
{
case Weapon::AttackType::Straight:
return FireBolt(type, stats, origin, target);
case Weapon::AttackType::Spread:
return FireFan(type, stats, origin, target);
case Weapon::AttackType::Wave:
return FirePulse(type, stats, origin);
default:
throw std::invalid_argument("Weapon attack type has no firing method.");
}
}最初に武装IDの範囲を確認します。Type::Countなど、登録範囲外のIDならstd::invalid_argumentを投げ、定義を参照しません。stdexceptのインクルードは既存のものを使います。
範囲内のIDなら、定義のattackで処理を選びます。対応していない攻撃方式も例外として扱います。
ここで分岐に使う値はattackですが、発射関数へ渡すtypeは元の武装IDです。RapidBoltをBoltへ置き換えて渡すわけではありません。
5. 発射関数の定義から生成先へIDを渡す
直進方式
AttackManager.cppのFireBoltを、関数全体で置き換えます。平行配置の仕組みは第04回と同じです。
bool AttackManager::FireBolt(Weapon::Type type, 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, type, stats))
fired = true;
}
return fired;
}SpawnShotの武装IDを、固定のWeapon::Type::Boltから引数のtypeへ変えています。
この関数は、受け取った武装がBoltかRapidBoltかを判定しません。渡された性能から配置を計算し、元のIDを生成先へ引き継ぎます。
扇状方式
FireFanも関数全体で置き換えます。
bool AttackManager::FireFan(Weapon::Type type, 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, type, stats))
fired = true;
}
return fired;
}こちらも固定のFanではなく、受け取ったtypeを渡します。Spread方式を使う別武装にも、同じ処理を使えます。
波状方式
FirePulseを関数全体で置き換えます。
bool AttackManager::FirePulse(Weapon::Type type, const Weapon::Stats& stats, Vector2 origin)
{
for (auto& pulse : pulses)
{
if (pulse.IsActive())
continue;
pulse.Spawn(origin, stats, type);
return true;
}
return false;
}Pulse::Spawnの最後の引数へ武装IDを渡しています。標的なしで発動することや、空き枠を探す処理は変わりません。
6. 攻撃オブジェクトが保存するIDを確認する
Shotは既存の保存処理を使う
SpawnShotは、受け取ったIDをShot::Spawnへ渡します。ここは既存のまま使えます。
shot.Spawn(position, velocity, type, stats);Attack.cppのShot::Spawnには、すでに次の代入があります。
type = weapon;Attack.hのShotには、保存用メンバと取得関数があります。
Weapon::Type GetType() const { return type; }Weapon::Type type = Weapon::Type::Bolt;メンバの初期値がBoltでも、Spawnのたびに発射元のIDへ上書きします。RapidBoltから生成した弾ならRapidBoltが保存されます。
この接続では、Shotの移動やTryHitを武装ごとに増やす必要はありません。
Pulseの定義を変更する
Attack.cppのPulse::Spawnを関数全体で置き換えます。
void Pulse::Spawn(Vector2 start, const Weapon::Stats& stats, Weapon::Type weapon)
{
if (stats.duration <= 0)
throw std::invalid_argument("Pulse duration must be positive.");
type = weapon;
position = start;
age = 0;
duration = stats.duration;
radius = stats.radius;
damage = stats.damage;
hitIds.fill(0);
active = true;
}ヘッダーで指定した既定引数は、定義では繰り返しません。宣言と定義で引数名がtypeとweaponに分かれていても、型と順序が一致していれば同じ関数です。
ここではメンバのtypeと区別するため、引数名をweaponにしています。プール枠を再利用するたびに、元の武装IDと命中履歴を初期化します。
7. 攻撃画像と色を対応させる
Renderer.cppのDrawArenaで、弾の画像を選ぶ次の行を探します。
auto id = shot.GetType() == Weapon::Type::Fan ? SpriteImages::Id::Fan : SpriteImages::Id::Bolt;この1行を、次のコードへ置き換えます。
const auto& definition = Weapon::GetDefinition(shot.GetType());
auto id = definition.attack == Weapon::AttackType::Spread
? SpriteImages::Id::Fan : SpriteImages::Id::Bolt;SpreadならFan画像、StraightならBolt画像を使います。WaveはPulse側の描画で扱います。
同じ弾ループ内で、sprites.Drawの呼び出しを次へ置き換えます。間にあるangleの計算は残します。
sprites.Draw(id, shot.GetPosition(), angle, definition.color);RapidBoltはBolt画像を使い、色はRapidBoltの定義から取得します。画像は方式、色は武装ごとの指定です。
衝撃波のループでは、次の色取得行を探します。
unsigned int color = Weapon::GetDefinition(Weapon::Type::Pulse).color;この1行を置き換えます。
unsigned int color = Weapon::GetDefinition(pulse.GetType()).color;Wave方式を共有する場合も、発射元の武装ごとに色を指定できます。
武装ごとに別画像を使う設計も可能です。その場合は、画像の対応項目を定義に持たせるなどして、第02回の画像登録と接続できます。
8. 強化画面の性能表示を対応させる
Renderer.cppのDrawOverlayでは、選択対象の武装から名前と性能を取得します。
const Weapon::Definition& definition = Weapon::GetDefinition(choice.weapon);
const Weapon& weapon = game.GetWeapon(choice.weapon);
Weapon next = weapon;
Weapon::Stats before = next.GetStats();
next.Upgrade();
Weapon::Stats after = next.GetStats();const Weapon&は、ゲーム内の武装を読み取る参照です。一方、Weapon next = weapon;はコピーを作ります。表示用のコピーだけを強化し、現在値と次の値を比べています。画面を描くだけで本体の武装レベルは変わりません。
名前と色も、すでにchoice.weaponの定義を参照しています。
Text(x + 22, 330, definition.name, definition.color, 1);威力と発射間隔の表示も、その武装のbeforeとafterを使うため、新武装用の分岐を追加する必要はありません。
半径と弾数を切り替える、次の条件を探します。
if (choice.weapon == Weapon::Type::Pulse)条件の1行を置き換えます。
if (definition.attack == Weapon::AttackType::Wave)後ろのTextとelse側は残します。これでWaveには半径、弾を使う方式には弾数や貫通数を表示します。
DrawHudはHP、残り時間、プレイヤーレベル、経験値を表示しており、武装IDごとの分岐はありません。今回の追加で変更する武装表示はDrawOverlayの強化カードです。独自に武装一覧などを追加している場合も、IDからDefinitionを取得すれば、名前や色を参照できます。
9. GameModelからの登録と発射を確認する
GameModel.hの武装配列は、Weapon::Countを使っています。
std::array<Weapon, Weapon::Count> weapons;GameModelのコンストラクターは、定義表にある武装をIDに対応する位置へ登録します。
for (const auto& definition : Weapon::Definitions())
weapons[Weapon::Index(definition.type)] = Weapon(definition.type);TypeとRowsを追加すると、この処理で新武装も登録されます。武装ごとにメンバ変数を追加する形にはなっていません。
FireWeaponsも、配列の武装を順に更新します。
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;
}
}この呼び出しは元から武装IDと性能を渡しているため、そのまま使えます。今回の変更では、そのIDがFireから各発射処理、攻撃オブジェクト、描画まで届くようにしました。
取得候補と開始武装
配布版のBuildChoicesは、最大レベル未満の武装を配列順に最大3件追加します。今回の例の並びなら、Bolt・RapidBolt・Fan・Pulseの順です。
先頭側に候補が3件あると、Pulseまで走査しません。Rowsの記述順を変えても、参照用配列は武装ID順なので、この候補順は変わりません。全候補から選ぶ処理は第08回で扱います。
開始武装はGameModel::Startで指定できます。例えばBoltを所持する行の直後へ次を加えると、速射弾も開始時から所持します。
weapons[Weapon::Index(Weapon::Type::Bolt)].SetLevel(1);
weapons[Weapon::Index(Weapon::Type::RapidBolt)].SetLevel(1);
ChangeState(State::Playing);開始時から使う武装、成長途中で取得する武装など、作品の進行に合わせて設定できます。SetLevelで取得済みにすると、既存の発射ループがその武装も更新します。
武装の成長を調整する
速射弾の例では、レベルが上がると威力と発射頻度が変わり、レベル4から貫通します。攻撃の役割をどう成長させるかに合わせ、levelsを編集します。
弾数を増やす場合は、発射配置の式も関わります。Straightは弾列の両端幅を保ち、Spreadは全開角を保って、その中へcount発を並べます。
威力の差が、敵を倒すまでの命中数に直結しない場合もあります。無傷のHP40の敵に、ほかの攻撃なしで新しい弾を毎回当てるなら、威力20では2回、威力18では3回必要です。一方、HP28ならどちらも2回です。
発射する量の目安には、次の概算を使えます。
damage × count ÷ interval
ただし、外れ弾、敵の残りHPを超えるダメージ、貫通、衝撃波が同時に当てる敵数は表しません。数値と実際の命中を分けて見ることで、武装の得意な場面を調整できます。
自分の武装を動かして確かめる
宣言、定義、呼び出し、表示まで保存してビルドします。構造体へattackを加えた後は、すべての武装定義にその列が必要です。関数の引数を変えた場合も、ヘッダーとcpp、呼び出し側を対応させます。
速射弾の例なら、直進処理を共有しながら、別の発射間隔、威力、色で動くかを確認します。強化カードの名前と次の性能も、登録したlevelsと照合できます。
既存のFanとPulseも、方式に合った生成と表示になっているか見ます。プールに空きがない場合の生成成否や、発射済み攻撃の性能保持は、既存の処理を引き続き使います。
方式分離後に同じ攻撃方式の武装を増やす場合は、TypeとRowsへ登録できます。新しい移動や命中方式を作る場合はAttackTypeと対応する処理を加え、その方式へ武装を割り当てます。武装の種類と動作の種類を分けることで、共有する処理と作品ごとの設定を組み合わせられます。
作った武装と成長設定は、そのまま作品へ引き継ぎます。第08回では、経験値、解放、取得、強化、同時所持制限、候補選出を接続し、どの武装をいつ使えるゲームにするかを扱います。