Skip to content

第07回 クラス分割入門

前回: 第06回 敵と衝突判定 / 次回: 第08回 画像とリソース管理

今回の授業内容

  • Gameに集まった処理を役割ごとに分ける
  • PlayerBulletEnemyクラスを追加する
  • データと処理を同じ型にまとめる
  • publicprivateで触れる範囲を整理し、クラスを分けた影響を追う
  • 固定配列をstd::arrayへ置き換える
  • 分割前後で動作が変わらないことを確認する

今回の開始状態

第06回終了時点では、Game内にプレイヤー、弾、敵のデータと処理があります
Game.cppは、移動、発射、衝突判定、無敵時間、UI表示までを1つのファイルにまとめて持っている状態です
今回は、それらを別ファイルのクラスとして独立させます

新しい機能を増やす回ではありません
動作を保ったまま、コードの置き場所を整理する回です

なぜここでクラスへ分けるのか

第02回から第06回までは、Gameクラスの中に構造体と配列を直接置いてきました
1つのクラスの中で完結していたため、変数がどこで使われているかを追いやすく、動作を1つずつ確認しながら進められました

その一方で、Gameが持つ役割は回を追うごとに増えています
プレイヤーの移動、弾の発射と管理、敵の生成と管理、衝突判定、HPと無敵時間、スコアと出現間隔のカウントが、すべてGameという1つのクラスに集まっています
このまま音や画像、ゲーム状態を追加していくと、Game.cppはさらに長くなり、「今どの機能を直しているのか」が把握しにくくなります

そこで、意味のあるまとまりごとにPlayerBulletEnemyという別のクラスへ処理を移します
分割の狙いは新しい動作を作ることではなく、同じ動作を保ったまま、変更したい箇所を見つけやすい形にすることです

変更するファイル

ファイル変更内容
Player.h / Player.cppプレイヤーの位置、移動、描画、HP、無敵時間を移す
Bullet.h / Bullet.cpp弾の発射、更新、描画、無効化を移す
Enemy.h / Enemy.cpp敵の出現、更新、描画、無効化を移す
Game.h / Game.cpp各クラスを呼び出す側へ整理し、配列をstd::arrayへ変更する

まずBulletを分ける

最初に、他のオブジェクトから参照されにくく、影響範囲の小さいBulletから分けます
Bullet.hを作り、弾が持つ値と操作をまとめます

cpp
class Bullet
{
public:
    void Spawn(float startX, float startY);
    void Update();
    void Draw() const;

    bool IsActive() const;
    void Deactivate();
    Rect GetRect() const;

private:
    Vec2 position = {};
    float speed = 8.0f;
    bool active = false;
};

positionspeedactiveprivateにします
Game側は、これらの変数へ直接アクセスせず、Spawn()Update()Draw()IsActive()Deactivate()GetRect()という関数を通してのみBulletを操作します
この時点ではGameはまだplayerPositionというVec2を直接持っているため、SpawnBullet()はこれまでどおりの値を渡せます

cpp
void Game::SpawnBullet()
{
    for (Bullet& bullet : bullets)
    {
        if (!bullet.IsActive())
        {
            bullet.Spawn(playerPosition.x, playerPosition.y - 22.0f);
            return;
        }
    }
}

bullet.activeではなくbullet.IsActive()を呼んでいる点に注目します
activeprivateになったことで、Gameは「弾が有効かどうか」という結果だけを受け取り、内部でどんな型を使って管理しているかを知る必要がなくなりました

Playerを分ける

次にPlayerを移します
Player.hには、位置、速度、HP、無敵時間をまとめます

cpp
class Player
{
public:
    void Reset();
    void Update(const Input& input);
    void Draw() const;

    Rect GetRect() const;
    Vec2 GetShotPosition() const;
    bool CanShoot() const;
    void OnShoot();
    void Damage();

    int GetHp() const;
    bool IsAlive() const;

private:
    Vec2 position = {};
    float speed = 4.0f;
    int hp = 3;
    int shotCooldown = 0;
    int invincibleTimer = 0;
};

shotCooldownは第05回でGameが直接持っていた連射カウンターです
「次に撃てるかどうか」はプレイヤー自身の状態なので、Playerへ移し、CanShoot()OnShoot()という関数越しに扱います

cpp
bool Player::CanShoot() const
{
    return shotCooldown <= 0;
}

void Player::OnShoot()
{
    shotCooldown = 8;
}

shotCooldownを減らす処理は、invincibleTimerと同じくUpdate()の中で行います

cpp
if (shotCooldown > 0)
    --shotCooldown;

Update()は入力を使うため、Inputconst参照で受け取ります

cpp
void Player::Update(const Input& input)
{
    Vec2 move = {};

    if (input.IsDown(KEY_INPUT_LEFT) || input.IsDown(KEY_INPUT_A))
        move.x -= 1.0f;
    if (input.IsDown(KEY_INPUT_RIGHT) || input.IsDown(KEY_INPUT_D))
        move.x += 1.0f;
    if (input.IsDown(KEY_INPUT_UP) || input.IsDown(KEY_INPUT_W))
        move.y -= 1.0f;
    if (input.IsDown(KEY_INPUT_DOWN) || input.IsDown(KEY_INPUT_S))
        move.y += 1.0f;

    position.x += move.x * speed;
    position.y += move.y * speed;

    if (position.x < 18.0f) position.x = 18.0f;
    if (position.x > screenWidth - 18.0f) position.x = screenWidth - 18.0f;
    if (position.y < 32.0f) position.y = 32.0f;
    if (position.y > screenHeight - 24.0f) position.y = screenHeight - 24.0f;

    if (invincibleTimer > 0)
        --invincibleTimer;
}

第06回までGameが直接持っていたplayerPositionは、Playerprivatepositionに置き換わりました
Gameplayer.positionへ直接アクセスできなくなるため、他のクラスから位置を使いたい場所には、目的に合わせた取得用の関数を用意します
弾の発射位置は「プレイヤーの少し上」という決まった計算なので、その計算ごとPlayer側へ持たせます

cpp
Vec2 Player::GetShotPosition() const
{
    return { position.x, position.y - 22.0f };
}

Bulletを分けたときのコードに戻る

Playerpositionprivateのクラスへ変えたことで、先ほど「まずBulletを分ける」で書いたSpawnBullet()はビルドが通らなくなります
playerPositionという変数自体がGameから消えるためです
この関数を、player.GetShotPosition()を使う形へ書き直します

cpp
void Game::SpawnBullet()
{
    const Vec2 shotPosition = player.GetShotPosition();

    for (Bullet& bullet : bullets)
    {
        if (!bullet.IsActive())
        {
            bullet.Spawn(shotPosition.x, shotPosition.y);
            return;
        }
    }
}

この手戻りは失敗ではありません
あるクラスの変数をprivateにすると、その変数を直接使っていた呼び出し側すべてに影響が及びます
クラスを分けるたびに「このクラスが持っていた値を、他のどこが使っていたか」を確認する習慣をつけることが、この回の目的の1つです

無敵時間とダメージ処理も、第06回でGameに書いた内容をそのままPlayerへ移します

cpp
void Player::Damage()
{
    if (invincibleTimer > 0)
        return;

    --hp;
    invincibleTimer = 90;
}

Damage()の内部で無敵時間を判定するようにしたことで、Game側は「敵と重なったらplayer.Damage()を呼ぶ」というだけで済み、無敵中かどうかを気にする必要がなくなります
判定条件を関数の中に閉じ込めることも、クラス分割の効果の1つです

Enemyを分ける

最後にEnemyを分けます
敵は、出現位置と速度をSpawn()で受け取る形にします

cpp
void Enemy::Spawn(float startX, float startY, float moveSpeed)
{
    position = { startX, startY };
    speed = moveSpeed;
    active = true;
}

BulletPlayerと同じ流れで、Game.hにあった敵の構造体と配列、生成・更新・描画の処理をEnemy.hEnemy.cppへ移します

配列をstd::arrayへ変える

ここまでの4回では、Bullet bullets[maxBullets]のような生配列を使ってきました
生配列は仕組みが単純で、要素数が固定であることが宣言からそのまま読み取れるという利点があります
一方で、要素数を取得する手段がなく、関数へ渡すときにポインタへ変換されてしまうため、配列の範囲を渡し忘れるといった間違いが起こりやすいという弱点もあります

クラスへ分割してオブジェクトの操作が関数越しになった今回、配列自体も標準ライブラリのstd::arrayへ置き換えます

cpp
#include <array>

std::array<Bullet, maxBullets> bullets = {};
std::array<Enemy, maxEnemies> enemies = {};

std::array<Bullet, maxBullets>は「要素数がmaxBulletsに固定されたBulletの配列」を表す型です
生配列と同じくスタック上に確保され、要素数の情報を実行時に増減させることはできません
その点でstd::vectorのような可変長コンテナとは異なり、この授業で使ってきた「上限を決めて使い回す」というオブジェクトプールの考え方をそのまま引き継げます
違いは、bullets.size()で要素数を取得できることと、範囲外アクセスをat()で検出できることです
for (Bullet& bullet : bullets)という範囲for文の書き方は、生配列のときと変わりません

Gameの役割を整理する

分割後のGameは、細かい移動や描画ではなく、全体の呼び出し順を管理します

cpp
player.Update(input);

if ((input.IsDown(KEY_INPUT_Z) || input.IsDown(KEY_INPUT_SPACE)) && player.CanShoot())
{
    SpawnBullet();
    player.OnShoot();
}

for (Bullet& bullet : bullets)
    bullet.Update();

for (Enemy& enemy : enemies)
    enemy.Update();

CheckCollisions();

shotCooldown <= 0という条件はplayer.CanShoot()に、shotCooldown = 8という代入はplayer.OnShoot()に置き換わりました
Gameは連射間隔の具体的な値を知らなくても、「撃てるか」「撃った」という2つの事実だけをやり取りすれば動作します

衝突判定では、それぞれのクラスからRectを受け取ります

cpp
if (IsOverlapping(enemy.GetRect(), bullet.GetRect()))
{
    enemy.Deactivate();
    bullet.Deactivate();
    ++score;
}

プレイヤーとの衝突も同じ形にそろえます

cpp
if (enemy.IsActive() && IsOverlapping(enemy.GetRect(), player.GetRect()))
{
    enemy.Deactivate();
    player.Damage();
}

Gameのコードを読み返すと、「誰が」「何を」しているかが関数名だけで追えることが分かります
座標の計算式や無敵時間の判定条件など、実装の詳細は各クラスの内部に隠れています

動作確認

  1. Bulletだけを分けた時点でビルドして、弾が出ることを確認する
  2. Playerを分けた時点でSpawnBullet()の書き直しが必要になることを確認し、修正後にビルドが通ることを確認する
  3. Playerを分けた時点でビルドして、移動、HP表示、無敵時間の点滅を確認する
  4. Enemyを分けた時点でビルドして、敵生成と衝突を確認する
  5. 配列をstd::arrayへ変更した後、bullets.size()maxBulletsと一致することを確認する
  6. 分割前と同じ操作で同じ結果になることを確認する

よくある問題

  • .cppを追加した後は、Visual Studioプロジェクトへ登録されているか確認する
  • privateにした変数へGameから直接アクセスしている箇所は、関数経由に直す
  • あるクラスの変数をprivateにした直後は、その変数を使っていた他の箇所でビルドエラーが出ないか必ず確認する
  • 循環インクルードが起きた場合は、必要なヘッダーだけをインクルードしているか確認する
  • std::arrayに変更した後、<array>のインクルードを忘れるとコンパイルエラーになる

今回の完成条件

  • PlayerBulletEnemyを別ファイルへ分けられる
  • publicprivateの役割を説明できる
  • あるクラスをprivate化した影響が、そのクラスを使う側のコードにどう波及するかを説明できる
  • 生配列とstd::arrayの違いを説明できる
  • 分割前後でゲームの動作を維持できる

まとめ

今回は、増えてきた処理をクラスへ分け、配列をstd::arrayへ置き換えました
次回は、図形描画を画像描画へ置き換えるため、画像管理を追加します