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

2 / 4 CSVを行と列に分解する

このステップの目的

std::filesystem::path を用いて安全にCSVファイルを開き、各行のカンマ区切りテキストを2次元のセル配列へと展開する処理を実装します。

日本語パスを安全に扱う std::filesystem::path

Windows環境では、ユーザー名やフォルダ名に日本語(全角文字)が含まれている場合があり、従来の const char* では文字コードの不一致によってファイルが開けない原因になります。
C++17の std::filesystem::path を利用し、ワイド文字列(L"Assets/Maps/Stage01.csv")でパスを渡すことで、日本語を含むパスでも安全にファイルを読み込めます。

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_;
    };
}

行ごとの読み込みとカンマ分割処理

この回では、厳密な入力検査を含むローダーは参考実装から利用します。読み込みの順序を追い、Gameからの呼び出しとマップの編集に取り組みます。

  1. ファイルを開く。
  2. 一行ずつ読み、カンマでセルへ分ける。
  3. 数値の文字列全体を使ったか、列数とセルの値が正しいかを確かめる。
  4. プレイヤーとゴールの配置を記録し、地形の行を保存する。

CSV読み込みは、std::getline による「行単位の読み込み」と、std::stringstream による「カンマ単位の分割」の2段階で行います。

CSVローダーの参考実装
Stage.cppcpp
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;
}

矩形グリッドの整合性検証

ステージデータは矩形グリッド(長方形)であることを前提とします。
行によって列数が異なると、後の衝突判定や描画処理で配列外参照の原因になります。
最初の行の列数を expectedWidth として記録し、全行の列数が揃っていることを検証します。

入力検査を試す

CSVをコピーしてから、10を10xへ変える、行末へカンマを付ける、プレイヤー開始セル2を消す、という変更を一つずつ試します。各変更で読み込みが失敗し、原因を確認できることを確かめてから元へ戻します。std::stoiの変換結果だけでは10xの末尾を検出できないため、処理した文字数の確認を残します。

チェックリスト
  • std::filesystem::path を用いて日本語パスでも安全にCSVを開ける
  • std::getline とカンマ区切り分割を用いて2次元配列へ展開できている
  • 行ごとに列数が揃っているかを検証し、矩形グリッドの整合性を確認している
  • 数値変換エラーや未定義値を検知して安全に中断できる

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