Skip to content

第03回 画像表示の導入とエラー確認

前回: 第02回 ゲームループの理解と実装 / 次回: 第04回 マップデータの外部化(CSV読込)とスクロール

今回の授業内容

プレイヤー、足場、ゴールの表示例

  • 開発中はプロジェクト内の Assets を直接参照し、ビルド後イベントで出力先にミラーリングする
  • 画像をImageIdで識別し、ImageManagerへ読み込み、サイズ取得、ハンドル所有、解放の処理を集約する
  • プレイヤー、タイル、ゴールの画像を ImageManager 経由で描画する
  • 必須画像の読み込みに失敗した場合、エラーを表示してゲームを停止する
  • 画像の原寸、表示サイズ、命中判定サイズを分離し、命中判定の中心をプレイヤーの足元にそろえる

今回の配布コード

第02回の図形描画を画像描画へ置き換え、ImageManagerで画像読み込みを統一したコードです

配布ファイル一覧
ファイル内容
Source/ImageManager.h画像識別子、画像情報、管理クラスの宣言
Source/ImageManager.cpp画像の一括読み込み、サイズ取得、解放
Source/Game.hImageManagerの所有とプレイヤーの宣言
Source/Game.cppプレイヤー画像の取得と描画位置計算
Source/Stage.h画像管理を受け取るステージの宣言
Source/Stage.cpp識別子によるタイルとゴールの描画
Source/Common.h共通の型と関数
Source/main.cpp初期化失敗時にゲームを開始しない入口
Assets/Images/Player.pngプレイヤー画像
Assets/Images/Tile.png足場タイル画像
Assets/Images/Goal.pngゴール画像

画像の準備

プレイヤー画像の例プレイヤー画像の例

タイル画像の例タイル画像の例

ゴール画像の例ゴール画像の例

今回の授業では、プレイヤーとタイル、ゴールの画像を追加します
画像は自由ですが、添付のものを使用してもらってもかまいません

アセットの配置パス

Assetsフォルダの中に素材の種類ごとのサブフォルダを作る

今後、実行時に読み込む外部ファイルは Assets フォルダへまとめることとします
ソースコードと同じ階層にフォルダを作成してください

ゲームは制作が進むにつれて多様な素材を扱うようになります
素材ファイル追加の度にコピーの指定を行うのは手間ですし、複雑な更新作業はミスの原因になります

今回は素材をすべてAssets の下にまとめ、種類ごとにサブフォルダを作って格納することにします
以下のフォルダ名、ファイル名に固定します

これでコピー対象は Assets フォルダの指定だけで済むようになります

ファイル構成
  • Assets
    • Images
      • Player.png
      • Tile.png
      • Goal.png
    • Maps
    • Sounds
    • Fonts
  • Sources

画像は Assets/Images に配置します
音声やフォントを追加するときも、Assets の下で種類ごとに分類します

コードに書く相対パスは、プログラムを実行するときの作業ディレクトリから見たパスと一致させる必要があります
この教材では、Visual Studioから実行するときの作業ディレクトリを $(ProjectDir) にそろえます
これにより、開発中はプロジェクト内の Assets を直接参照します

ビルド先にAssetsフォルダをミラーリングする

ソースコードと異なり、PNG画像などの素材は通常ビルドの対象ではありません
プロジェクトに Assets フォルダを置いただけでは DebugRelease などの実行ファイルの出力先にコピーされることはありません

今回のコードは Assets/Images/Player.png のような相対パスで画像を読み込みます
Visual Studioからの実行時は $(ProjectDir) を基準に元の Assets を直接読みこみますので、画像やCSVだけを更新した場合でも特に問題はありません

一方、出力先の実行ファイルを直接起動する場合や、成果物を配布する場合には、実行ファイルと同じ場所に Assets が必要です

ファイル構成
  • 実行ファイルの出力先
    • Game.exe
    • Assets
      • Images
        • Player.png
        • Tile.png
        • Goal.png

出力先へ手作業でコピーすると、素材の追加、更新、削除を取りこぼす可能性があります
ここではビルド後イベントで Assets を自動的にミラーリングし、出力先の Assets を自動で更新することにします

Visual Studioのビルド後イベントを使えば、ビルド完了時に任意の処理を実行することができます
フォルダ全体を対象にするため、画像やその他のデータが増えても設定の追加は不要です

ビルド後イベントでAssetsをミラーリングする

Visual Studioのビルド後イベントにrobocopyコマンドを設定した例Visual Studioのビルド後イベントにrobocopyコマンドを設定した例

robocopyコマンドの設定例robocopyコマンドの設定例

ビルド後イベントで robocopy コマンドを使用し、Assets フォルダを出力先へミラーリングします
robocopy はWindowsに標準で付属するコマンドで、フォルダのコピーや差分の検出を行えます
ここではフォルダの中身をすべてコピーし、コピー元にないファイルはコピー先から削除する /MIR オプションを使ってミラーリングを行います

  1. ソリューションエクスプローラーでプロジェクトを右クリックし、プロパティを開く
  2. 構成プロパティビルド イベントビルド後のイベントを開く
  3. コマンド ラインに次のコマンドを入力する
  4. プロジェクトをビルドし、出力先に Assets フォルダが作成されたことを確認する
ビルド後イベントbat
robocopy "$(ProjectDir)Assets" "$(OutDir)Assets" /MIR
if %ERRORLEVEL% LEQ 7 exit /b 0
exit /b %ERRORLEVEL%

$(ProjectDir) はプロジェクトファイルがある場所、$(OutDir) は実行ファイルの出力先を表すVisual Studioのマクロです

コピー元で削除した素材はコピー先からも削除されるため、過去に削除された素材が出力先に残ることを防げます
一方でコピー先にだけ存在するファイルも削除されるので、コピー先の指定を間違えると大事なファイルを消してしまう危険があります
注意して取り扱ってください

また、robocopy はコピーや差分の検出に成功した場合も終了コード 1 から 7 を返すことがあります
Visual Studioがこれらをビルド失敗として扱わないように、ここでは後ろの2行で 7 以下を成功に変換し、8 以上をエラーとして返しています

/MIRのコピー先に注意

/MIR はコピー元にないファイルをコピー先から削除するオプションです
コピー先を誤って $(OutDir) にしてしまうと、出力先の実行ファイルやDLLまで削除される危険があります
コピー先は必ず $(OutDir)Assets に限定してください

画像だけを更新した場合

開発中に画像だけを更新した場合、ビルドを行っても出力先の Assets が更新されないことがあります
Visual Studioからの実行中は問題になりませんが、配布の際は出力先の Assets が最新になっていることを確認してください

Visual Studioの作業ディレクトリをプロジェクトに揃える

Visual Studioのデバッグ設定で作業ディレクトリにProjectDirが指定されている作業ディレクトリにProjectDirが指定されている

作業ディレクトリを $(ProjectDir) にそろえることで、プロジェクト内の Assets を直接参照できます

通常はデフォルトで $(ProjectDir) になっています
念のために確認しておきましょう

ImageManagerとエラー対応

ImageIdで画像を指定する

これまで画像の管理は個別にハンドルを定義していましたが、扱う画像が増えると管理も煩雑になります
enum で画像の種類を定義し、読み込みや描画の指定を行うようにすれば、画像が増えても個別のハンドル管理は不要になります

ImageIdの定義cpp
// 画像の種類を列挙する
enum class ImageId
{
    Player,
    Tile,
    Goal,

    // 最後は画像の指定ではなく、画像の定義数として扱う
    Count
};

enum利用のテクニック

enum は、定義された名前に自動で数値を割り当てます
値は指定がない限り0から始まり、1ずつ増えていきます
この仕様は配列と相性が良く、ここではCountを最後に置くことで画像の定義数を取得しています

Playerは0、Tileは1、Goalは2、Countは3となります
なおCount自体は画像の指定には使用しない点に注意してください

今回の制作では、画像の読み込み、サイズ取得、ハンドル所有、解放を画像管理クラスImageManagerへ集約します
画像のファイルパスやハンドルはImageManagerに集約し、GameStageは識別子で画像を取得して描画します

画像読み込みの実処理は ImageManager::LoadAll の内部で LoadGraph を呼びます
必須画像を読み込めなければ、初期化失敗としてエラーを表示します

定義表をループして画像を一括で読み込む

画像ごとに個別の変数とLoadGraph呼び出しのコードを書き足すのは非効率ですし、コードの可読性も下がります
画像の識別子とファイルパスを定義表へまとめれば、LoadAllで一括読み込みできます
LoadAllは定義表を先頭から順に処理し、読み込んだ結果をImageResourceに保存します

一括読み込みの基本形cpp
// 画像の種類の定義
enum class ImageId
{
    Player,
    Tile,
    Goal,

    // 最後は画像の指定ではなく、画像のIDの定義数として扱う
    Count
};

// 画像の識別子とファイルパスを対応させる構造体
struct ImageDefinition
{
    ImageId id;
    const wchar_t* path;
};

// 画像の識別子とファイルパスの対応表
constexpr ImageDefinition imageDefinitions[] =
{
    { ImageId::Player, L"Assets/Images/Player.png" },
    { ImageId::Tile, L"Assets/Images/Tile.png" },
    { ImageId::Goal, L"Assets/Images/Goal.png" }
};

// 画像の種類の数を定義表の要素数から取得する
const std::size_t imageCount = static_cast<std::size_t>(ImageId::Count);

// 読み込みのコード
for (std::size_t i = 0; i < imageCount; ++i)
{
    const std::size_t index = static_cast<std::size_t>(imageDefinitions[i].id);

    resources[index].handle = LoadGraph(imageDefinitions[i].path);
}

このfor文では、i0から画像数の手前まで1ずつ増やします
imageDefinitions[i]により、定義表の要素を先頭から順番に取り出しています

範囲for文

C++では、配列の全要素を順番に処理する場合、範囲for文でも記述できます
よりモダンな書き方に慣れたい方は、こちらの記述方法も試してください

範囲for文cpp
for (const ImageDefinition& definition : imageDefinitions)
{
    const std::size_t index = static_cast<std::size_t>(definition.id);

    resources[index].handle =
        LoadGraph(definition.path);
}

definitionには、imageDefinitionsの各要素が先頭から順番に入ります
添字や要素数を書く必要がなく、配列全体へ同じ処理を行うことが明確になります
配布コードのLoadAllでは、この範囲for文を使用しています

C++11以降の記法

範囲for文はC++11で導入された記法です
この教材ではC++11以降を前提とします

この節で使用しているenum classstd::arrayconstexprstatic_assertも、C++11で導入された比較的新しい記法です

この構成では、すべての画像に同じ読み込み処理を適用できます
画像を追加するときはImageIdimageDefinitionsへ1項目ずつ追加し、LoadAllのループは変更しません

役割内容
ImageIdプログラム内で画像を指定する識別子
imageDefinitions識別子とファイルパスの対応表
resources読み込んだハンドルや画像サイズの保存先
LoadAll定義表の全要素へ同じ処理を適用するループ

Countは画像そのものではなく、画像数を表すための値です
実際のコードではstatic_assertを使い、ImageIdの数と定義表の要素数が一致していない場合はコンパイルエラーになるようにしています

画像の取り扱い

  1. 初期化時に必須画像を読み込む
  2. 読み込み結果を ImageManager に保存する
  3. -1 が返ったらエラーを表示して false を返す
  4. 初期化に失敗した場合はゲーム本編を開始しない
  5. 描画時は ImageId で画像を取得して DrawExtendGraph で指定サイズに描く

必須素材が欠けている場合は、早い段階で通知してプログラムの動作を停止します

ImageManager.hで画像情報と寿命を管理する

画像ハンドル、幅、高さをImageResourceへまとめます
ImageIdと同じ並びのstd::arrayへ保存するため、呼び出し側は個別のハンドル変数を持ちません

ImageManager.hcpp
#pragma once

#include <array>
#include <cstddef>

enum class ImageId
{
    Player,
    Tile,
    Goal,
    Count
};

struct ImageResource
{
    int handle = -1;
    int width = 0;
    int height = 0;
};

class ImageManager
{
public:
    ImageManager();
    ~ImageManager();

    ImageManager(const ImageManager&) = delete;
    ImageManager& operator=(const ImageManager&) = delete;

    bool LoadAll();
    const ImageResource& Get(ImageId id) const;

private:
    void Release();

    static constexpr std::size_t imageCount =
        static_cast<std::size_t>(ImageId::Count);

    std::array<ImageResource, imageCount> resources = {};
};

デストラクターでReleaseを呼び、読み込んだ画像の解放を行っています

INFO

ImageManagerは自分自身のコピーを禁止しています
コピーを許可すると、別のImageManagerが使用中の画像ハンドルを解放してしまう危険があるためです

ここではコピーコンストラクタの宣言と代入演算子の宣言を = delete とすることでコピーを禁止しています
これもC++11以降で導入された記法です

ImageIdとファイルパスを対応させる

ImageManager.cppでは、識別子とファイルパスを定義表へまとめます
static_assertにより、ImageIdを追加したのに定義表へパスを追加し忘れた場合はコンパイルエラーになります

ImageManager.cppcpp
#include <Windows.h>
#include <string>

#include "DxLib.h"

#include "ImageManager.h"

namespace
{
    struct ImageDefinition
    {
        ImageId id;
        const wchar_t* path;
    };

    constexpr ImageDefinition imageDefinitions[] =
    {
        { ImageId::Player, L"Assets/Images/Player.png" },
        { ImageId::Tile, L"Assets/Images/Tile.png" },
        { ImageId::Goal, L"Assets/Images/Goal.png" }
    };

    static_assert(
        sizeof(imageDefinitions) / sizeof(imageDefinitions[0]) ==
        static_cast<std::size_t>(ImageId::Count));
}

ImageManager::ImageManager()
{
}

ImageManager::~ImageManager()
{
    Release();
}

LoadAllで読み込みとサイズ取得を一括実行する

各画像を読み込み、成功した場合は幅と高さも保存します
途中で失敗した場合は、それ以前に読み込んだ画像を解放してfalseを返します

ImageManager.cppcpp
bool ImageManager::LoadAll()
{
    Release();

    for (const ImageDefinition& definition : imageDefinitions)
    {
        const std::size_t index = static_cast<std::size_t>(definition.id);
        ImageResource& resource = resources[index];
        resource.handle = LoadGraph(definition.path);

        if (resource.handle == -1 ||
            GetGraphSize(resource.handle,
                &resource.width,
                &resource.height) == -1)
        {
            std::wstring message = definition.path;
            message += L" の読み込みまたはサイズ取得に失敗しました。";
            MessageBoxW(nullptr, message.c_str(), L"Asset Load Error", MB_OK);
            Release();
            return false;
        }
    }

    return true;
}

Getで画像情報を取得

Getは識別子に対応する画像情報を返します

ImageManager.cppcpp
const ImageResource& ImageManager::Get(ImageId id) const
{
    return resources[static_cast<std::size_t>(id)];
}

Releaseで画像を解放する

Releaseは有効なハンドルだけを解放し、画像情報を初期値へ戻します
このサンプルでは外部からは使用しないためprivate となっていますが、public にすればステージ切り替え時に画像を解放して再読み込みするなど発展的に使えます

ImageManager.cppcpp
void ImageManager::Release()
{
    for (ImageResource& resource : resources)
    {
        if (resource.handle != -1)
        {
            DeleteGraph(resource.handle);
        }

        resource = {};
    }
}

エラーハンドリング

エラーを検知して原因を特定する

必須ファイルの読み込み失敗を無視すると、後続処理で別のエラーを起こしたり、異常が発生した際の真の原因を見逃す危険があります
問題を早期に発見して対応できるかどうかは、制作全体の生産性を大きく左右します
エラーは見つけ次第通知し、原因の情報を出力して、必要に応じて実行を停止するのがプログラミングの基本です

エラー対応の強制度を考える

エラー対応は常に最優先事項ですが、どの程度強く対応するかは状況に応じて変える必要があります

例えば個人作業なら、エラーを見つけた時点でプログラムを停止させる対応を徹底するのが基本です
問題が後に波及することなく、原因を特定しやすくなるからです

ですが集団作業では、予期しないエラーですべてを止める運用は危険です
強すぎるエラー対応が無関係な他の人の作業を妨げてしまう場合もあります

エラーの修正が完了するまで他の人が作業を進められないような環境では、集団作業は成立しません
こうした環境ではエラーを検知しても通知して記録するだけにとどめ、多少強引でも処理は続行する運用の方が適しています

エラーチェックは常に必要ですが、エラー検知時に即座に処理を停止するのが最善とは限りません
プログラマーには、こうした作業フロー全体を俯瞰した環境構築能力も求められます
全体の生産性を考えて、通知、記録、停止といった対応の強度を選びましょう

実装手順

GameクラスにImageManagerを追加する

Game は個別の画像ハンドルを持たず、ImageManager を1つだけ保持します
初期化時は images.LoadAll() で必要画像を一括読み込みします

Game.hcpp
#include "Common.h"
#include "ImageManager.h" // 追加
#include "Stage.h"

// ... Playerの定義は変更しない

class Game
{
    // ... 既存の宣言は変更しない

private:
    Rect PlayerRect() const;
    Rect PlayerImageRect() const; // 追加

    // ... 既存の宣言は変更しない

private:
    // ... 既存の定数は変更しない

    ImageManager images; // 追加
    Stage stage;

    // ... 既存のメンバーは変更しない
};

既存のGame.hに以下が追加されます

  • ImageManager.hのインクルード
  • PlayerImageRect()の宣言
  • ImageManager 型のメンバー変数 images

ImageManager::LoadAllで画像を一括読み込みする

LoadAll は画像定義表を順番に処理し、LoadGraphGetGraphSize を内部で実行します
必須画像を読み込めない場合は false を返します

Game.cppcpp
bool Game::Initialize()
{
    if (!images.LoadAll()) return false;
    if (!stage.Initialize()) return false;

    StartPlay();
    return true;
}

足元中央を基準に描画位置を計算する

当たり判定の左上と画像の左上を同じ位置にすると、画像サイズを差し替えたときに足元が床からずれます
横方向は中央、縦方向は下端をそろえることで、画像サイズが変わっても当たり判定の足元と見た目を一致させます

Game.cppcpp
Rect Game::PlayerImageRect() const
{
    const int drawX = player.x + player.width / 2 - playerDrawWidth / 2;
    const int drawY = player.y + player.height - playerDrawHeight;

    return { drawX, drawY, playerDrawWidth, playerDrawHeight };
}

読み込んだプレイヤー画像を描画する

DrawExtendGraph で描画する際は、PlayerImageRect で計算した表示矩形を使います
移動と衝突判定はプレイヤー本体の矩形を使い、描画は PlayerImageRect で計算した位置を使います

初期化時に画像はすべて読み込み済みですので、ハンドルの有効性チェックは不要です

Game.cppcpp
void Game::DrawPlayer() const
{
    const ImageResource& image = images.Get(ImageId::Player);
    const Rect imageRect = PlayerImageRect();
    DrawExtendGraph(
        imageRect.x, imageRect.y,
        imageRect.x + imageRect.width,
        imageRect.y + imageRect.height,
        image.handle, TRUE);
}

Stageは画像ハンドルを持たず識別子で取得する

ステージの描画は Stage の責務です
Stage は画像の所有や読み込みを担当せず、描画時に ImageManager から必要画像を取得します
これにより、描画責務とリソース管理責務を最初から分離できます
ここからのStage.hStage.cppGame.cppの変更は一組として行います

Stage.hcpp
#include "Common.h"
#include "ImageManager.h" // 追加

class Stage
{
public:
    // ... 列挙型と定数は変更しない

    bool Initialize();

    // ... 当たり判定の宣言は変更しない

    void Draw(const ImageManager& images) const;

    // ... GetGoalの宣言は変更しない

private:
    void DrawTile(int screenX, int screenY, const ImageManager& images) const;
    void DrawGoal(const ImageManager& images) const;

    // ... 既存のメンバーは変更しない
};

クラス全体を置き換えるのではなく、ImageManager.hのインクルードと、画像を受け取る3つの関数宣言を変更します

ステージの描画を関数にまとめる

ステージの構成要素はタイルとゴールです
それぞれの描画を関数にまとめ、Stage::Draw から呼び出すようにします

タイル画像の描画は DrawTile にまとめます

Stage.cppcpp
void Stage::DrawTile(int screenX, int screenY, const ImageManager& images) const
{
    DrawExtendGraph(
        screenX, screenY,
        screenX + tileSize, screenY + tileSize,
        images.Get(ImageId::Tile).handle, TRUE);
}

ゴール画像の描画は DrawGoal にまとめます

Stage.cppcpp
void Stage::DrawGoal(const ImageManager& images) const
{
    DrawExtendGraph(
        goal.x, goal.y,
        goal.x + goal.width, goal.y + goal.height,
        images.Get(ImageId::Goal).handle, TRUE);
}

Stage::Drawから描画関数を呼ぶ

Stage::DrawImageManagerを受け取るように変更します
床タイルの座標をピクセル座標へ変換してDrawTileへ渡し、最後にゴールを描画します

Stage.cppcpp
void Stage::Draw(const ImageManager& images) const
{
    // マップ全体を走査し、床タイルだけを描画する
    for (int y = 0; y < mapHeight; y++)
    {
        for (int x = 0; x < mapWidth; x++)
        {
            if (tiles[y][x] != TileSolid)
                continue;

            DrawTile(x * tileSize, y * tileSize, images);
        }
    }

    DrawGoal(images);
}

GameからImageManagerをStageへ渡す

Stage::Drawを呼ぶ側も、Gameが所有しているimagesを渡すように変更します

Game.cppcpp
void Game::DrawPlaying() const
{
    stage.Draw(images);
    DrawPlayer();
    DrawHud();
    DrawDebug();
}

よくあるつまずき:パスと作業ディレクトリ

  • Visual Studioの作業ディレクトリを $(ProjectDir) に設定していない
  • Assets フォルダがプロジェクトファイルと同じ階層にない
  • 出力先から直接起動する際、ビルド後のミラーリングが行われていない
  • 作業ディレクトリから見た相対パスと、想定している相対パスが違う
  • Player.pngplayer.png の大文字小文字が違う
  • 拡張子が .png ではなく .jpg などになっている
  • 画像サイズがプレイヤー判定サイズと大きく違い、見た目と判定がずれている

実習1: プレイヤー画像と読み込みエラーを確認する

  1. Assets/Images/Player.png を配置する
  2. Visual Studioの作業ディレクトリが $(ProjectDir) に設定されていることを確認する
  3. ビルド後イベントを設定し、出力先の Assets/Images に画像がミラーリングされることを確認する
  4. ImageIdImageManager を追加する
  5. LoadAll の中で LoadGraphGetGraphSize を実行する
  6. 読み込み失敗時にパス付きエラーを表示して false を返す
  7. PlayerImageRect で足元中央をそろえて描画する
  8. 画像が表示されることを確認する
  9. 画像名を一時的に変えてから実行し、エラー表示で停止することを確認する
  10. 32x32以外の画像へ差し替えても、見た目の足元と当たり判定の位置関係が保たれることを確認する

実習2: Stageの表示を画像へ置き換える

  1. Assets/Images/Tile.pngAssets/Images/Goal.png を配置する
  2. StageImageManager を受け取って描画するようにする
  3. DrawTileDrawGoal を識別子ベースで実装する
  4. 画像ありで描画されることを確認する
  5. 画像不足時にゲームが開始されないことを確認する

発展課題:素材を差し替える

  • 画像サイズを32x32にそろえる
  • プレイヤーの見た目と判定サイズの差を調整する
  • ゴール画像を分かりやすい見た目に差し替える
  • フォルダ名やファイル名を定数としてまとめる
  • 読み込み失敗時に、どのファイルが足りないか分かるメッセージにする

今回の到達目標

  • プレイヤー画像が表示できる
  • タイル画像が表示できる
  • ゴール画像が表示できる
  • ビルド時に Assets フォルダを実行ファイルの出力先へコピーできる
  • 画像不足時にエラーを表示して停止できる
  • 移動、ジャンプ、床衝突、ゴール判定が前回と同じように動く

まとめ

今回は、画像描画と読み込みエラーの確認を導入しました

  • ImageIdImageManager で画像読み込みを一元管理する形としました
  • 画像ファイルの読み込み失敗時はパス付きメッセージを表示して起動しない形を徹底しました
  • 画像サイズと当たり判定サイズを分離し、足元中央を基準に描画位置を決定するようにしました
  • 読み込んだ画像でプレイヤー、タイル、ゴールの描画を置き換えました

付録: ドット絵エディター Edge3 の紹介

Edge3は、アニメーションに強いドット絵エディターです
操作は直感的で、アニメーションの作成やスプライトシートの出力も可能です

Edge3の使い方は授業では扱いません
公式サイトのドキュメントやチュートリアルを参照してください

公式サイト: https://edge3.takabosoft.com/