Skip to content

第08回 画像とリソース管理

前回: 第07回 クラス分割入門 / 次回: 第09回 ゲーム状態

今回の授業内容

  • DXライブラリで画像を読み込む
  • 画像ハンドルをImageManagerで一元管理する
  • プレイヤー、弾、敵の描画を画像へ置き換える
  • 画像がない場合は図形描画へ戻すフォールバックを実装する
  • 任意アセットと必須アセットの違いを整理する

今回の開始状態

第07回終了時点では、プレイヤー、弾、敵がクラスに分かれています
描画はまだ図形だけです

今回の終了時点では、画像がある場合は画像で描画し、画像がない場合は今までの図形で動くようにします

変更するファイル

ファイル変更内容
ImageManager.h / ImageManager.cpp画像の読み込み、取得、解放を追加する
Game.h / Game.cppImageManagerを所有し、初期化と終了処理で呼ぶ
Player.cpp / Bullet.cpp / Enemy.cpp描画時に画像管理を参照する
Assets/Images任意で画像素材を配置する

画像ハンドルをまとめて管理する理由

DXライブラリで画像を表示するには、LoadGraph()で画像ファイルを読み込み、返ってきたハンドル(intの識別番号)をDrawGraph()へ渡します
画像を使う箇所が増えるたびに個別のint変数を用意すると、「この変数はどの画像か」を名前だけで管理することになり、変数が増えるほど対応関係が分かりにくくなります

そこで、画像の種類を列挙型で表し、ハンドルを配列にまとめます

cpp
enum class ImageId
{
    Player,
    Enemy,
    Bullet,
    Background,
    Count
};

Countを末尾に置いているのは、値そのものを使うためではなく、ImageIdが持つ要素数をstatic_cast<int>(ImageId::Count)として取得するためです
新しい画像の種類を追加するときは、Countの直前に列挙子を追加するだけで、配列のサイズも自動的に追従します

ImageManagerは、画像ハンドルを配列で持ちます
読み込みに失敗した画像は-1のまま扱います

cpp
class ImageManager
{
public:
    bool Initialize();
    void Finalize();
    int Get(ImageId id) const;
    bool Has(ImageId id) const;

private:
    static constexpr int imageCount = static_cast<int>(ImageId::Count);

    int handles[imageCount] = {};
};

enum classの値は、そのままでは配列の添字として使えません
ImageIdintへ変換する小さな関数を、ImageManager.cppの無名名前空間に用意します

cpp
namespace
{
    int ToIndex(ImageId id)
    {
        return static_cast<int>(id);
    }
}

無名名前空間に置いているのは、この関数がImageManager.cppの中でだけ使う実装の細部であり、他のファイルから直接呼び出す必要がないためです

Initialize()では、ファイルパスを1か所に集めます

cpp
bool ImageManager::Initialize()
{
    handles[ToIndex(ImageId::Player)] = LoadGraph(L"Assets/Images/Player.png");
    handles[ToIndex(ImageId::Enemy)] = LoadGraph(L"Assets/Images/Enemy.png");
    handles[ToIndex(ImageId::Bullet)] = LoadGraph(L"Assets/Images/Bullet.png");
    handles[ToIndex(ImageId::Background)] = LoadGraph(L"Assets/Images/Background.png");

    return true;
}

LoadGraph()は、読み込みに成功するとハンドルを、失敗すると-1を返します
Get()Has()は、この-1を基準に画像の有無を判定します

cpp
int ImageManager::Get(ImageId id) const
{
    return handles[ToIndex(id)];
}

bool ImageManager::Has(ImageId id) const
{
    return Get(id) != -1;
}

Finalize()では、読み込みに成功した画像だけをDeleteGraph()で解放します

cpp
void ImageManager::Finalize()
{
    for (int i = 0; i < imageCount; ++i)
    {
        if (handles[i] != -1)
        {
            DeleteGraph(handles[i]);
            handles[i] = -1;
        }
    }
}

読み込みに失敗したハンドル(-1)をDeleteGraph()へ渡すと、存在しない画像を解放しようとすることになります
handles[i] != -1という条件は、読み込みに成功した画像だけを対象にするための確認です

任意アセットと必須アセットの違い

この授業のプレイヤー・敵・弾・背景の画像は、任意アセットとして扱います
画像が見つからなくてもInitialize()trueを返し、ゲームは図形描画のまま起動します
これは、受講者が画像素材を用意していない段階でも、ゲームロジックの実装と確認を止めずに進められるようにするための設計です

一方で、ゲームの続行に必要な必須データ(たとえば、後の授業で扱うステージのCSVデータなど)が読み込めない場合は、話が異なります
必須データの読み込みに失敗した状態でゲーム本編を始めると、存在しない配置情報を参照してしまい、原因の分かりにくい不具合につながります
必須データを扱う授業では、読み込みに失敗した時点でInitialize()falseを返し、Game::Initialize()falseのときはMainLoop()を呼ばないという、第01回で確認した仕組みをそのまま使います
「読み込めなくても動かせるもの」と「読み込めなければ止めるべきもの」を区別することも、リソース管理の一部です

描画側を差し替える

各クラスのDraw()は、ImageManagerを参照で受け取る形へ変更します
Player::Draw()には、第06回で追加した無敵時間の点滅判定がすでにあるため、その判定を残したまま画像描画への分岐を追加します

cpp
void Player::Draw(const ImageManager& images) const
{
    if (invincibleTimer > 0 && (invincibleTimer / 4) % 2 == 0)
        return;

    if (images.Has(ImageId::Player))
    {
        DrawGraph(
            static_cast<int>(position.x - 16),
            static_cast<int>(position.y - 16),
            images.Get(ImageId::Player),
            TRUE
        );
        return;
    }

    DrawTriangle(
        static_cast<int>(position.x),
        static_cast<int>(position.y - 18),
        static_cast<int>(position.x - 16),
        static_cast<int>(position.y + 14),
        static_cast<int>(position.x + 16),
        static_cast<int>(position.y + 14),
        cyan,
        TRUE
    );
}

点滅判定を関数の先頭に置くことで、「無敵中は描かない」「画像があれば画像」「なければ図形」という3つの分岐が、上から順に読める形になります
画像がある場合は画像を描き、ない場合はこれまでの図形描画へ戻します
BulletEnemyDraw()も同じ考え方で、ImageManagerを受け取り、Has()の結果で分岐する形に変更します

Gameから画像管理を渡す

GameImageManagerを所有し、初期化・終了処理・描画時に各クラスへ渡します

cpp
bool Game::Initialize()
{
    images.Initialize();
    player.Reset();
    return true;
}

void Game::Finalize()
{
    images.Finalize();
}
cpp
player.Draw(images);

for (const Bullet& bullet : bullets)
    bullet.Draw(images);

imagesを値ではなくconst参照で渡しているのは、画像データそのものをコピーするのではなく、Gameが持つ1つのImageManagerを各クラスが読み取り専用で参照するためです
PlayerBulletが独自に画像を読み込むわけではない、という所有関係もここで確認しておきます

動作確認

  1. 画像なしで実行し、図形描画のまま動くことを確認する
  2. Assets/Images/Player.pngを追加し、プレイヤーだけ画像になることを確認する
  3. ファイル名を一時的に変えて、図形描画へ戻ることを確認する
  4. 敵に当たって無敵時間に入ったとき、画像描画でも点滅が続くことを確認する
  5. ImageManager::Finalize()で読み込んだ画像を解放していることを確認する

よくある問題

  • 画像が表示されない場合は、実行時の作業ディレクトリからAssets/Imagesが見えるか確認する
  • パスの大文字小文字が、ファイル名と一致しているか確認する
  • Draw()の引数を変えたら、呼び出し側もすべて修正する
  • ToIndex()ImageManager.cpp以外から直接呼ぼうとしてエラーになる場合は、無名名前空間の中でだけ有効な関数であることを確認する

今回の完成条件

  • ImageManagerで画像をまとめて管理できる
  • 画像がある場合だけ画像描画へ切り替えられる
  • 画像がない場合でもゲームを実行できる
  • 任意アセットと必須アセットで、読み込み失敗時の扱いを変える理由を説明できる

まとめ

今回は、画像素材を任意で使える構成にしました
次回は、タイトル、プレイ中、結果画面をゲーム状態として整理します