Appearance
第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からの呼び出しとマップの編集に取り組みます。
- ファイルを開く。
- 一行ずつ読み、カンマでセルへ分ける。
- 数値の文字列全体を使ったか、列数とセルの値が正しいかを確かめる。
- プレイヤーとゴールの配置を記録し、地形の行を保存する。
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次元配列へ展開できている - 行ごとに列数が揃っているかを検証し、矩形グリッドの整合性を確認している
- 数値変換エラーや未定義値を検知して安全に中断できる