Skip to content
第15回 CSVから作るステージ

4 / 4 CSVの問題を行と列で報告する

このステップの目的

CSVファイル内の記述ミス(数値以外、未定義値、列数不一致、必須セルの過不足)を行番号・列番号付きで通知し、修正箇所を特定できるエラーハンドリングを実装します。

行・列番号付きのエラーハンドリング

CSVファイルの編集では、数値以外の入力、未定義の数値、行ごとの列数不一致などの記述ミスが発生しやすくなります。
単に読み込み失敗とだけ通知されると、原因行を探す作業に時間を要するため、「何行目の何列目で、どのような問題が生じたか」を通知します。

問題の種類表示例
数値以外行3、列5: 数値を入力してください。
未定義値行4、列8: 未定義のStageCell値です: 37
列数不一致行6、列10: 行によって列数が一致しません。
エラーメッセージの表示例text
Stage01.csv の読み込みに失敗しました。
行4、列8: 未定義のStageCell値です: 37

必須セル(PlayerSpawnとGoal)の個数検証

ステージとして正常に成立するためには、プレイヤーの開始位置(PlayerSpawn)とクリア目標であるゴール(Goal)が、それぞれ1つずつ存在しなければなりません。
読み込み処理の中でそれぞれの出現回数をカウントし、重複や不足を検証します。

Stage.cpp(必須セルのカウントと検証)cpp
int spawnCount = 0;
int goalCount = 0;

// (セル走査ループ内)
if (cell == StageCell::PlayerSpawn)
{
    ++spawnCount;
    if (spawnCount > 1)
        return FailLoad(L"PlayerSpawnが複数存在します。1つだけ配置してください。", lineNumber, columnNumber);
}
else if (cell == StageCell::Goal)
{
    ++goalCount;
    if (goalCount > 1)
        return FailLoad(L"Goalが複数存在します。1つだけ配置してください。", lineNumber, columnNumber);
}

// (全セル走査終了後)
if (spawnCount == 0)
    return FailLoad(L"PlayerSpawnが配置されていません。", 0, 0);

if (goalCount == 0)
    return FailLoad(L"Goalが配置されていません。", 0, 0);

FailLoad による状態のクリーンアップ

エラーを検出した場合は、中途半端に読み込まれた2次元配列や座標変数を Clear() で破棄し、エラーメッセージを lastLoadError_ に保存して false を返します。

Stage.cppcpp
bool Stage::FailLoad(const std::wstring& reason,
    int lineNumber, int columnNumber)
{
    std::wstring message;
    if (lineNumber > 0)
    {
        message = L"行" + std::to_wstring(lineNumber);
        if (columnNumber > 0)
            message += L"、列" + std::to_wstring(columnNumber);
        message += L": ";
    }
    message += reason;

    Clear();
    lastLoadError_ = std::move(message);
    return false;
}

呼び出し元の Game クラスは、この戻り値を見てメッセージボックスやデバッグログにエラー原因を表示し、安全にゲームの初期化を中断します。

チェックリスト
  • CSV内の誤った記述に対して、行番号・列番号・原因を含むメッセージが生成される
  • PlayerSpawn および Goal が0個、または2個以上ある場合にエラーとして検出できる
  • 読み込み失敗時に中途半端なデータが残らないようクリーンアップされている
  • 正常な Stage01.csv ではエラーなくステージが表示されることを確認できる

まとめと次回への引き継ぎ

第15回では、CSVファイルからステージデータを読み込み、列挙型 StageCell と定義表を用いたデータ駆動型のマップ描画システムを構築しました。また、行・列を特定できるエラーハンドリングも備わりました。

次回(第16回)は、画面サイズ(640×480)よりも広いステージに対応するための「ワールド座標系とカメラ(Camera)」を実装します。プレイヤーの移動に合わせた横スクロール処理と、画面外の描画をスキップするカリング処理を学びます。

参考実装

今回追加・変更するファイルの一例です。宣言や補助処理を確認したいときに開いてください。自分のコードへ必要な変更を反映し、既存の調整値や素材をそのまま上書きしないようにします。

Source/Platformer/Game.cpp
Source/Platformer/Game.cppcpp
#include <Windows.h>
#include <string>

#include "DxLib.h"

#include "Config/ApplicationConfig.h"
#include "Platformer/Game.h"
#include "Platformer/Tuning/GameTuning.h"

namespace dxgame::platformer
{
    bool Game::Initialize()
    {
        if (!images_.LoadAll())
            return false;

        constexpr wchar_t StagePath[] = L"Assets/Maps/Stage01.csv";
        if (!stage_.Initialize(StagePath))
        {
            const std::wstring message = std::wstring(StagePath) + L"\n" +
                stage_.GetLastLoadError();
            MessageBoxW(nullptr, message.c_str(), L"Map Load Error", MB_OK);
            return false;
        }

        ResetStage();
        return true;
    }

    void Game::MainLoop()
    {
        // 小数分の待ち時間を次フレームへ持ち越す
        double nextFrame = GetNowCount();

        while (ProcessMessage() == 0)
        {
            input_.Update();
            if (input_.IsHeld(Key::Escape))
                break;

            Update(dxgame::config::DeltaTime);

            ClearDrawScreen();
            Draw();
            ScreenFlip();

            nextFrame += dxgame::config::FrameMilliseconds;
            const int waitMilliseconds =
                static_cast<int>(nextFrame - GetNowCount());

            if (waitMilliseconds > 0)
                WaitTimer(waitMilliseconds);
            else
                nextFrame = GetNowCount();
        }
    }

    void Game::Update(float deltaTime)
    {
        UpdateDebugToggle();
        player_.Update(input_, deltaTime);
    }

    void Game::Draw() const
    {
        DrawBackground();
        stage_.Draw(images_);
        player_.Draw(images_);
        DrawHud();
        DrawDebug();
    }

    void Game::DrawBackground() const
    {
        images_.Draw(ImageId::Background, { 0.0f, 0.0f,
            static_cast<float>(dxgame::config::ScreenWidth),
            static_cast<float>(dxgame::config::ScreenHeight) });
    }

    void Game::DrawHud() const
    {
        const int textColor = GetColor(255, 255, 255);
        DrawString(12, 10, L"LEFT/RIGHT: MOVE", textColor);
        DrawString(12, 30, L"Z: JUMP", textColor);
        DrawString(12, 50, L"F1: DEBUG    ESC: QUIT", textColor);
    }

    void Game::DrawDebug() const
    {
        if (!debugVisible_)
            return;

        const dxgame::Rect playerDrawRect = player_.GetDrawRect();
        DrawBox(static_cast<int>(playerDrawRect.x),
            static_cast<int>(playerDrawRect.y),
            static_cast<int>(playerDrawRect.x + playerDrawRect.width),
            static_cast<int>(playerDrawRect.y + playerDrawRect.height),
            GetColor(80, 240, 255), FALSE);

        const dxgame::Rect playerRect = player_.GetHitbox();
        DrawBox(static_cast<int>(playerRect.x), static_cast<int>(playerRect.y),
            static_cast<int>(playerRect.x + playerRect.width),
            static_cast<int>(playerRect.y + playerRect.height),
            GetColor(255, 80, 80), FALSE);

        const dxgame::Rect goalDrawRect = stage_.GetGoalDrawRect();
        DrawBox(static_cast<int>(goalDrawRect.x),
            static_cast<int>(goalDrawRect.y),
            static_cast<int>(goalDrawRect.x + goalDrawRect.width),
            static_cast<int>(goalDrawRect.y + goalDrawRect.height),
            GetColor(80, 240, 255), FALSE);

        const dxgame::Rect goalRect = stage_.GetGoalTrigger();
        DrawBox(static_cast<int>(goalRect.x), static_cast<int>(goalRect.y),
            static_cast<int>(goalRect.x + goalRect.width),
            static_cast<int>(goalRect.y + goalRect.height),
            GetColor(255, 220, 40), FALSE);

        DrawBox(8, 76, 300, 178, GetColor(0, 0, 0), TRUE);
        const int textColor = GetColor(255, 255, 255);
        const dxgame::Vector2& position = player_.GetPosition();
        DrawFormatString(14, 84, textColor,
            L"pos: %.1f, %.1f", position.x, position.y);
        DrawFormatString(14, 104, textColor, L"csv: %d",
            stage_.IsLoadedFromCsv() ? 1 : 0);
        DrawString(14, 124,
            L"cyan: draw  red: player  yellow: goal", textColor);
    }

    void Game::UpdateDebugToggle()
    {
        if (input_.IsPressed(Key::Debug))
            debugVisible_ = !debugVisible_;
    }

    void Game::ResetStage()
    {
        player_.Reset(stage_.GetPlayerSpawn());
    }

}
Source/Platformer/Game.h
Source/Platformer/Game.hcpp
#pragma once

#include "Platformer/ImageManager.h"
#include "Platformer/Input.h"
#include "Platformer/Player.h"
#include "Platformer/Stage.h"

namespace dxgame::platformer
{
    // 入力、プレイヤー、ステージ、描画順を調整する
    class Game
    {
    public:
        // 必須画像、音声、CSVを読み込む
        bool Initialize();
        // ウィンドウ終了まで更新と描画を繰り返す
        void MainLoop();

    private:
        void Update(float deltaTime);
        void Draw() const;
        void DrawBackground() const;
        void DrawHud() const;
        void DrawDebug() const;
        void UpdateDebugToggle();
        void ResetStage();

        Input input_;
        ImageManager images_;
        Stage stage_;
        Player player_;
        bool debugVisible_ = false;
    };
}
Source/Platformer/Player.cpp
Source/Platformer/Player.cppcpp
#include "Platformer/ImageManager.h"
#include "Platformer/Input.h"
#include "Platformer/Player.h"
#include "Platformer/Tuning/GameTuning.h"

namespace dxgame::platformer
{
    void Player::Reset(const dxgame::Vector2& position)
    {
        position_ = position;
    }

    void Player::Update(const Input& input, float deltaTime)
    {
        float direction = 0.0f;
        if (input.IsHeld(Key::Left) && !input.IsHeld(Key::Right))
            direction = -1.0f;
        else if (input.IsHeld(Key::Right) && !input.IsHeld(Key::Left))
            direction = 1.0f;

        position_.x += direction *
            tuning::player::MoveSpeed * deltaTime;
    }

    void Player::Draw(const ImageManager& images) const
    {
        images.Draw(ImageId::Player, GetDrawRect());
    }

    dxgame::Rect Player::GetHitbox() const
    {
        return tuning::player::Hitbox.MovedBy(position_);
    }

    dxgame::Rect Player::GetDrawRect() const
    {
        return tuning::player::DrawRect.MovedBy(position_);
    }

    const dxgame::Vector2& Player::GetPosition() const
    {
        return position_;
    }
}
Source/Platformer/Stage.cpp
Source/Platformer/Stage.cppcpp
#include <algorithm>
#include <cmath>
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>

#include "Config/ApplicationConfig.h"
#include "Platformer/Stage.h"

namespace dxgame::platformer
{
    namespace
    {
        struct TileDefinition
        {
            StageCell cell;
            ImageId imageId;
            bool isSolid;
        };

        constexpr TileDefinition tileDefinitions[] =
        {
            { StageCell::Brick, ImageId::Tile, true },
            { StageCell::Moss, ImageId::TileMoss, true }
        };

        const TileDefinition* FindTileDefinition(StageCell cell)
        {
            for (const TileDefinition& definition : tileDefinitions)
            {
                if (definition.cell == cell)
                    return &definition;
            }
            return nullptr;
        }

        bool TryParseStageCell(int value, StageCell& cell)
        {
            cell = static_cast<StageCell>(value);
            if (cell == StageCell::Empty ||
                cell == StageCell::Goal ||
                cell == StageCell::PlayerSpawn)
            {
                return true;
            }

            return FindTileDefinition(cell) != nullptr;
        }
    }

    bool Stage::Initialize(const std::filesystem::path& csvPath)
    {
        loadedFromCsv_ = LoadFromCsv(csvPath);
        return loadedFromCsv_;
    }

    void Stage::Draw(const ImageManager& images) const
    {
        for (int y = 0; y < mapHeight_; y++)
        {
            for (int x = 0; x < mapWidth_; x++)
            {
                const StageCell cell = cells_[y][x];
                if (cell == StageCell::Empty)
                    continue;

                DrawTile(x * TileSize, y * TileSize, cell, images);
            }
        }

        const dxgame::Rect goalDrawRect = GetGoalDrawRect();
        images.Draw(ImageId::Goal, goalDrawRect);
    }

    dxgame::Rect Stage::GetGoalDrawRect() const
    {
        return tuning::goal::DrawRect.MovedBy(goalPosition_);
    }

    dxgame::Rect Stage::GetGoalTrigger() const
    {
        return tuning::goal::Trigger.MovedBy(goalPosition_);
    }

    const dxgame::Vector2& Stage::GetPlayerSpawn() const
    {
        return playerSpawnPosition_;
    }

    int Stage::GetWorldWidth() const
    {
        return mapWidth_ * TileSize;
    }

    int Stage::GetWorldHeight() const
    {
        return mapHeight_ * TileSize;
    }

    int Stage::GetMapWidth() const
    {
        return mapWidth_;
    }

    int Stage::GetMapHeight() const
    {
        return mapHeight_;
    }

    bool Stage::IsLoadedFromCsv() const
    {
        return loadedFromCsv_;
    }

    const std::wstring& Stage::GetLastLoadError() const
    {
        return lastLoadError_;
    }

    bool Stage::LoadFromCsv(const std::filesystem::path& csvPath)
    {
        Clear();

        std::ifstream file(csvPath);
        if (!file.is_open())
            return FailLoad(L"ファイルを開けません。");

        bool foundGoal = false;
        bool foundPlayerSpawn = false;
        int expectedWidth = -1;
        std::string line;
        int y = 0;

        while (std::getline(file, line))
        {
            const int lineNumber = y + 1;
            if (line.empty())
                return FailLoad(L"空行は使用できません。", lineNumber, 1);
            if (line.back() == ',')
            {
                const int columnNumber =
                    static_cast<int>(std::count(
                        line.begin(), line.end(), ',')) + 1;
                return FailLoad(L"空のセルは使用できません。",
                    lineNumber, columnNumber);
            }

            std::stringstream lineStream(line);
            std::string cell;
            int x = 0;
            std::vector<StageCell> row;

            while (std::getline(lineStream, cell, ','))
            {
                const int columnNumber = x + 1;
                int value = 0;
                std::size_t parsedLength = 0;
                try
                {
                    value = std::stoi(cell, &parsedLength);
                }
                catch (const std::invalid_argument&)
                {
                    return FailLoad(L"整数を入力してください。",
                        lineNumber, columnNumber);
                }
                catch (const std::out_of_range&)
                {
                    return FailLoad(L"整数の範囲を超えています。",
                        lineNumber, columnNumber);
                }

                if (parsedLength != cell.size())
                {
                    return FailLoad(L"整数以外の文字が含まれています。",
                        lineNumber, columnNumber);
                }

                StageCell csvCell = StageCell::Empty;
                if (!TryParseStageCell(value, csvCell))
                {
                    return FailLoad(
                        L"未定義のStageCell値です: " +
                        std::to_wstring(value),
                        lineNumber, columnNumber);
                }

                StageCell mapCell = StageCell::Empty;
                if (csvCell == StageCell::Goal)
                {
                    if (foundGoal)
                    {
                        return FailLoad(L"Goalは1つだけ配置してください。",
                            lineNumber, columnNumber);
                    }

                    goalPosition_ = {
                        x * static_cast<float>(TileSize) + TileSize * 0.5f,
                        y * static_cast<float>(TileSize) + TileSize * 0.5f
                    };
                    foundGoal = true;
                }
                else if (csvCell == StageCell::PlayerSpawn)
                {
                    if (foundPlayerSpawn)
                    {
                        return FailLoad(
                            L"PlayerSpawnは1つだけ配置してください。",
                            lineNumber, columnNumber);
                    }

                    playerSpawnPosition_ = {
                        x * static_cast<float>(TileSize) + TileSize * 0.5f,
                        y * static_cast<float>(TileSize) + TileSize * 0.5f
                    };
                    foundPlayerSpawn = true;
                }
                else
                {
                    mapCell = csvCell;
                }

                row.push_back(mapCell);
                x++;
            }

            if (row.empty())
                return FailLoad(L"セルがありません。", lineNumber, 1);

            if (expectedWidth < 0)
            {
                expectedWidth = static_cast<int>(row.size());
            }
            else if (static_cast<int>(row.size()) != expectedWidth)
            {
                const int actualWidth = static_cast<int>(row.size());
                const int errorColumn = actualWidth < expectedWidth
                    ? actualWidth + 1
                    : expectedWidth + 1;
                return FailLoad(
                    L"列数が一致しません。必要: " +
                    std::to_wstring(expectedWidth) + L"、実際: " +
                    std::to_wstring(actualWidth),
                    lineNumber, errorColumn);
            }

            cells_.push_back(std::move(row));
            y++;
        }

        if (y == 0)
            return FailLoad(L"CSVに行がありません。");
        if (!foundGoal)
            return FailLoad(L"Goalがありません。");
        if (!foundPlayerSpawn)
            return FailLoad(L"PlayerSpawnがありません。");

        mapWidth_ = expectedWidth;
        mapHeight_ = y;
        return true;
    }

    bool Stage::FailLoad(const std::wstring& reason,
        int lineNumber, int columnNumber)
    {
        std::wstring message;
        if (lineNumber > 0)
        {
            message = L"行" + std::to_wstring(lineNumber);
            if (columnNumber > 0)
                message += L"、列" + std::to_wstring(columnNumber);
            message += L": ";
        }
        message += reason;

        Clear();
        lastLoadError_ = std::move(message);
        return false;
    }

    void Stage::Clear()
    {
        cells_.clear();
        goalPosition_ = {};
        playerSpawnPosition_ = {};
        mapWidth_ = 0;
        mapHeight_ = 0;
        loadedFromCsv_ = false;
        lastLoadError_.clear();
    }

    void Stage::DrawTile(int screenX, int screenY, StageCell cell,
        const ImageManager& images) const
    {
        const TileDefinition* definition = FindTileDefinition(cell);
        if (definition == nullptr)
            return;

        images.Draw(definition->imageId, {
            static_cast<float>(screenX), static_cast<float>(screenY),
            static_cast<float>(TileSize), static_cast<float>(TileSize)
        });
    }

}
Source/Platformer/Stage.h
Source/Platformer/Stage.hcpp
#pragma once

#include <filesystem>
#include <string>
#include <vector>

#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"
#include "Platformer/ImageManager.h"
#include "Platformer/Tuning/GameTuning.h"

namespace dxgame::platformer
{
    // ステージCSVの各整数値と意味を1か所で定義する
    // 数値は外部ファイル形式との約束なので、追加後も既存値を変更しない
    // 外部ファイル形式の定義値のため、追加後も既存値を変更しない
    enum class StageCell : int
    {
        Empty = 0,
        // 1~9: 進行・ギミック
        Goal = 1,
        PlayerSpawn = 2,
        // 10~19: 通常地形・障害物
        Brick = 10,
        Moss = 11,
        // 20~29: トラップ、90~99: 敵配置のために空けておく
    };

    // CSVマップ、地形判定、ゴールを管理する
    class Stage
    {
    public:
        static constexpr int TileSize = tuning::stage::TileSize;

        // 実行フォルダーを基準にCSVを読み込み、失敗理由を保持する
        // 実行フォルダを基準にCSVを読み込み、失敗理由を保持する
        bool Initialize(const std::filesystem::path& csvPath);

        // タイルとゴールを描画する
        void Draw(const ImageManager& images) const;

        dxgame::Rect GetGoalDrawRect() const;
        dxgame::Rect GetGoalTrigger() const;
        const dxgame::Vector2& GetPlayerSpawn() const;
        int GetWorldWidth() const;
        int GetWorldHeight() const;
        int GetMapWidth() const;
        int GetMapHeight() const;
        bool IsLoadedFromCsv() const;
        const std::wstring& GetLastLoadError() const;

    private:
        bool LoadFromCsv(const std::filesystem::path& csvPath);
        bool FailLoad(const std::wstring& reason,
            int lineNumber = 0, int columnNumber = 0);
        void Clear();
        void DrawTile(int screenX, int screenY, StageCell cell,
            const ImageManager& images) const;

        std::vector<std::vector<StageCell>> cells_;
        dxgame::Vector2 goalPosition_ = {};
        dxgame::Vector2 playerSpawnPosition_ = {};
        int mapWidth_ = 0;
        int mapHeight_ = 0;
        bool loadedFromCsv_ = false;
        std::wstring lastLoadError_;
    };
}
Source/Platformer/Tuning/GameTuning.h
Source/Platformer/Tuning/GameTuning.hcpp
#pragma once

#include "Config/ApplicationConfig.h"
#include "DxGame/Rect.h"

namespace dxgame::platformer::tuning
{
    // 距離はピクセル、時間は秒、速度はピクセル/秒、
    // 加速度と重力はピクセル/秒²を基本単位とする。
    // 加速度と重力はピクセル/秒²を基本単位とする
    namespace player
    {
        // 中心座標を基準にした表示範囲と地形判定範囲
        // 画像本来の大きさとは独立して調整する
        inline constexpr Rect DrawRect =
            Rect::Centered(32.0f, 48.0f).MovedBy({ 0.0f, -8.0f });
        inline constexpr Rect Hitbox = Rect::Centered(24.0f, 30.0f);

        // 第16回までは一定速度で左右へ移動する
        inline constexpr float MoveSpeed = 240.0f;

    }

    namespace stage
    {
        // タイル画像の元サイズとは独立した、ワールド上の1マスの大きさ
        inline constexpr int TileSize = 32;

    }

    namespace enemy
    {
        // 敵の中心座標を基準にした表示範囲と命中範囲
        inline constexpr Rect DrawRect = Rect::Centered(32.0f, 32.0f);
        inline constexpr Rect Hitbox = Rect::Centered(28.0f, 28.0f);
    }

    namespace goal
    {
        // ゴール画像の表示範囲と、クリア成立に使う範囲
        inline constexpr Rect DrawRect = Rect::Centered(32.0f, 32.0f);
        inline constexpr Rect Trigger = Rect::Centered(20.0f, 28.0f);
    }
}

ゲームプログラミング実践