Skip to content

第04回 マップデータの外部化(CSV読込)とスクロール

前回: 第03回 画像表示の導入とエラー確認 / 次回: 第05回 キャラクターアニメーション

今回の授業内容

  • CSVマップの仕様と、fstreamstringstringstream の役割を理解する
  • Stage::LoadFromCsv を実装し、012 の値を地形とゴールへ反映する
  • 読み込み失敗時にエラーを表示し、原因を切り分ける
  • CSVを編集し、コードを変更せずに地形とゴール位置を変更する
  • cameraX を使ってワールド座標を画面座標へ変換し、カメラ追従と端の制限を実装する

今回の配布コード

第03回のImageManagerによる画像描画へ、CSVマップと横スクロールを追加したコードです
第02回から継続するF1デバッグ表示には、カメラ位置とCSV読込状態を追加しています
敵とタイトル画面は、まだ含みません

配布ファイル一覧
ファイル内容
Source/ImageManager.h第03回から継続する画像識別子と管理クラス
Source/ImageManager.cpp画像の一括読み込み、サイズ取得、解放
Source/Game.hcameraXを含むゲームの宣言
Source/Game.cppCSV指定とカメラ追従
Source/Stage.hCSV読み込み関数の宣言
Source/Stage.cppCSV解析とスクロール描画
Source/Common.h共通の型と関数
Source/main.cppDXライブラリの初期化
Assets/Maps/Stage01.csv配布用CSVマップ
Assets/Images/Player.pngプレイヤー画像
Assets/Images/Tile.png足場タイル画像
Assets/Images/Goal.pngゴール画像

ステージの外部データ化

コードを直さずにステージを変更できるようにする

いままでステージのデータはStage.cppのコード内に直接書き込んでいました
今回はデータを変えるだけで地形を変更できるようにステージデータを外部化します
これによって都度コンパイルしなくともステージを調整できるようになり、またステージを編集するための専用ツールなどを使用して、より複雑なステージを作ることも可能になります

CSVとは

CSV(Comma-Separated Values)は、カンマで区切られた値を並べたテキスト形式のデータです
シンプルな構造で、表計算ソフトやテキストエディタで編集できるため、ゲーム用データの保存形式としてよく使われます

CSVの例csv
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,2
0,0,0,0,0,0,0,1,1,1
0,0,0,0,1,1,0,0,0,0
1,1,1,1,1,1,1,1,1,1

CSVファイルの作り方

CSVは単純なテキスト形式なので、テキストエディタで直接編集することが可能です
ExcelやGoogleスプレッドシートなどの表計算ソフトで編集することもできます

ただし、表計算ソフトの設定によっては区切り文字にカンマではなく全角カンマが使われたり、タブ区切りになる場合など、想定外の形式になる場合があります

INFO

タブ区切りのものはTSV(Tab-Separated Values)と呼ばれて区別される場合があります
CSVが値にカンマを含む場合はダブルクォーテーションで囲むなど、本来はより複雑な仕様があります

csv
"1,000", "Tokyo, Japan"
"2,000", "New York, USA"

RFC 4180に仕様がまとめられていますが準拠していないCSVも多く、実務では柔軟な対応が必要です

本来はより厳密なエラーチェックが必要ですが、ここではカンマで区切られた通常のCSVを想定して進めます
読み込み時は簡単なチェックを行い、想定外の形式の場合はエラーとして扱うこととします

授業で使用するCSVの仕様

  • カンマ区切りのテキストファイルとして扱う
  • 0: 空白
  • 1: 固形床
  • 2: ゴール
  • 行数は mapHeight、列数は mapWidth を上限にする

CSVの1行を横方向のタイル列として扱い、上から順に読み込みます

値が 2 のセルはゴール位置として扱います
2 が1つも無い場合はゴール位置が設定されていない=クリア不能のマップとみなし、読み込み失敗として扱います

ゲーム画面に対応するCSVの例csv
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,2
0,0,0,0,0,0,0,1,1,1
0,0,0,0,1,1,0,0,0,0
1,1,1,1,1,1,1,1,1,1

CSV読み込みの実装

Stage.hにCSV読み込み関数を追加する

Initialize にCSVパスを渡せるようにします
LoadFromCsv は成功時に true、失敗時に false を返し、失敗した場合はエラーを表示して初期化を止めます

Stage.hcpp
#pragma once

#include "Common.h"
#include "ImageManager.h"

// CSVマップの読み込み、当たり判定、スクロール描画を担当する
class Stage
{
public:
    // タイルとマップの大きさ。座標計算の基準として各処理から参照する
    static const int tileSize = 32;
    static const int mapWidth = 40;
    static const int mapHeight = 15;

    // csvPathは実行フォルダを基準にしたパスを渡す
    bool Initialize(const wchar_t* csvPath);
    bool LoadFromCsv(const wchar_t* csvPath);

    bool IsSolidTile(int tileX, int tileY) const;
    bool IsSolidAtRect(const Rect& rect) const;

    // cameraXはワールド座標における画面左端の位置
    void Draw(int cameraX, const ImageManager& images) const;

    Rect GetGoal() const;
    bool IsLoadedFromCsv() const;

private:
    void ClearMap();
    void SetGoalTile(int tileX, int tileY);
    void DrawTile(int screenX, int screenY, const ImageManager& images) const;
    void DrawGoal(int cameraX, const ImageManager& images) const;

private:
    // 読み込んだCSVのタイル情報とゴール位置
    int tiles[mapHeight][mapWidth] = {};
    Rect goal = { 0, 0, tileSize, tileSize };
    bool loadedFromCsv = false;
};

CSVを読めなければ初期化失敗にする

LoadFromCsv の戻り値を loadedFromCsv に保存します
CSVはステージそのものなので、読み込みに失敗した場合はエラーを表示して false を返し、ゲームを開始しません

Stage.cppcpp
bool Stage::Initialize(const wchar_t* csvPath)
{
    loadedFromCsv = LoadFromCsv(csvPath);
    if (!loadedFromCsv)
    {
        const wchar_t* failedPath = (csvPath != nullptr) ? csvPath : L"(null)";
        std::wstring message = std::wstring(failedPath) + L" を読み込めません。";
        MessageBoxW(nullptr,
            message.c_str(),
            L"Map Load Error", MB_OK);
        return false;
    }

    return true;
}

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

CSV読み込みコード(1/2):1行ずつ読む

ファイルの読み込みには <fstream>、ワイド文字のパスを扱うために <filesystem>、1行をカンマ区切りに分解する処理には <sstream> を使います
std::filesystem を使うため、Visual StudioのC++言語標準はC++17以降に設定します
ファイルを開けない場合は false を返します

Stage.cppcpp
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>

bool Stage::LoadFromCsv(const wchar_t* csvPath)
{
    std::ifstream file{ std::filesystem::path(csvPath) };
    if (!file.is_open()) return false;

    ClearMap();

    bool foundGoal = false;
    std::string line;
    int y = 0;

    while (y < mapHeight && std::getline(file, line))
    {
        std::stringstream lineStream(line);
        std::string cell;
        int x = 0;

        // 次のスライドの処理でcellを数値に変換する
        y++;
    }

    return foundGoal;
}

CSV読み込みコード(2/2):タイルとゴールへ反映する

std::getline(lineStream, cell, ',') でカンマ区切りの値を取り出し、std::stoi で文字列から数値へ変換します
変換できない値は 0 として扱います

Stage.cppcpp
while (x < mapWidth && std::getline(lineStream, cell, ','))
{
    int value = 0;

    try { value = std::stoi(cell); }
    catch (...) { value = 0; }

    if (value == 1)
    {
        tiles[y][x] = 1;
    }
    else if (value == 2)
    {
        tiles[y][x] = 0;
        if (!foundGoal)
        {
            SetGoalTile(x, y);
            foundGoal = true;
        }
    }

    x++;
}

ClearMapとSetGoalTileで読み込み前に初期化する

ClearMap はすべてのタイルを空白に戻し、SetGoalTile はタイル座標をピクセル座標へ変換します
CSVを読む前に初期化して、前のデータが残らないようにします

Stage.cppcpp
void Stage::ClearMap()
{
    for (int y = 0; y < mapHeight; y++)
    {
        for (int x = 0; x < mapWidth; x++)
        {
            tiles[y][x] = 0;
        }
    }

    goal = { (mapWidth - 2) * tileSize,
        (mapHeight - 3) * tileSize, tileSize, tileSize };
}

void Stage::SetGoalTile(int tileX, int tileY)
{
    goal.x = tileX * tileSize;
    goal.y = tileY * tileSize;
    goal.width = tileSize;
    goal.height = tileSize;
}

読み込み失敗を通知して処理を止める

CSVはステージ本体なので、読めない場合はエラーにします
エラー文には不足しているファイル名を含め、ファイルの配置とパスを修正してから再実行できるようにします

Stage.cppcpp
if (!loadedFromCsv)
{
    const wchar_t* failedPath = (csvPath != nullptr) ? csvPath : L"(null)";
    std::wstring message = std::wstring(failedPath) + L" を読み込めません。";
    MessageBoxW(nullptr,
        message.c_str(),
        L"Map Load Error", MB_OK);
    return false;
}

Gameの初期化時にCSVのパスを渡す

CSVファイルの場所を Stage に渡します
完成サンプルでは Assets/Maps/Stage01.csv を使います
日本語を含むパスも扱えるように、ワイド文字列を示す L を付けて渡します
パスが違う場合はエラーを表示してゲームを開始しません

Game.cppcpp
bool Game::Initialize()
{
    if (!images.LoadAll()) return false;
    if (!stage.Initialize(L"Assets/Maps/Stage01.csv")) return false;

    StartPlay();
    return true;
}

カメラとスクロールの実装

マップの表示位置を操作する

CSVの読み込みで、一画面に収まりきらない大きなサイズのマップも表示できるようになりました
今度はプレイヤーの動きに追従して表示をスクロールさせる機構が必要です

まず、スクロールは画面表示だけの問題です
ですのでプレイヤーやタイルの位置は絶対座標(ワールド座標)で管理し、描画時にだけスクロール量を引いて画面座標へ変換するのが簡単です

カメラ位置を表す変数を用意する

プレイヤーの位置とスクロールを完全に同期させると、画面の動きが激しくなりすぎて操作しにくくなってしまいます
ゲームでは、ある程度の余裕をもってプレイヤーに追従するカメラを設けて、表示位置を制御するのが一般的です

ここではワールド座標系で「画面の左端がどこにあるか」を表す値としてcameraX を用意します
プレイヤーが右へ進むと cameraX も増え、同じワールド座標でも画面上では左へ流れて見えます

ワールド座標と表示座標の関係は以下の通りです

  • ワールド座標: ステージ全体で共通の位置
  • 画面座標: 実際に表示する位置
  • 変換式: screenX = worldX - cameraX

プレイヤーを追従しつつ、マップ端で止める

カメラはプレイヤー中心へ追従させます
ただし左端より外側や、右端より外側は表示できないため、Clamp で範囲を制限します

Game.cppcpp
void Game::UpdateCamera()
{
    const int worldWidth = Stage::mapWidth * Stage::tileSize;
    const int target = player.x - screenWidth / 2;
    cameraX = Clamp(target, 0, worldWidth - screenWidth);
}

target は「プレイヤーがほぼ中央に来るカメラ位置」です
マップ左端付近では 0 に固定され、右端付近では worldWidth - screenWidth に固定されます

描画時はワールド座標からcameraXを引く

ステージ描画では、cameraX を使ってワールド座標を画面座標へ変換します
また、画面の外のタイルを描画しても無意味ですので、表示範囲に見える列だけを描画するようにします

Stage.cppcpp
void Stage::Draw(int cameraX, const ImageManager& images) const
{
    // カメラ内に見える列だけを描画する
    const int firstTileX = cameraX / tileSize;
    const int lastTileX = (cameraX + 640) / tileSize + 1;

    for (int y = 0; y < mapHeight; y++)
    {
        for (int x = firstTileX; x <= lastTileX; x++)
        {
            if (x < 0 || x >= mapWidth) continue;
            if (tiles[y][x] == 0) continue;

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

    DrawGoal(cameraX, images);
}

DrawPlayer 側も同様に screenX = imageRect.x - cameraX で描画するため、
プレイヤー、タイル、ゴールの全部が同じ基準でスクロールします

デバッグ表示でcameraXを確認する

スクロールが不自然な場合は、F1 のデバッグ表示で cameraX を確認してみましょう

  • 左端では cameraX0 から減らない
  • 中央付近ではプレイヤー移動に合わせて増減する
  • 右端では worldWidth - screenWidth で止まる

読み込み結果の利用

CSVをゲーム内の構造へ変換する

  1. CSVファイルを開く
  2. 1行ずつ文字列として読む
  3. カンマで分割する
  4. 文字列を数値へ変換する
  5. 1 は床として tiles に入れる
  6. 2 はゴール位置として goal に反映する
  7. 失敗時はエラーを表示して初期化を止める

実習

実習1: 短い検証マップを読み込む

  1. Assets/Maps/Stage01.csv を作成する
  2. Stage::LoadFromCsv を実装する
  3. Stage::Initialize(L"Assets/Maps/Stage01.csv") を呼ぶ
  4. CSVの 1 の位置を変えて床が変わるか確認する
  5. CSVの 2 の位置を変えてゴールが変わるか確認する

実習2: 読み込み失敗時に停止することを確認する

  • CSVファイル名を一時的に変える
  • パスを間違えた状態で起動する
  • エラー表示でゲームが開始されないことを確認する
  • デバッグ表示や画面表示で、CSV読込成功かどうかを確認する
  • 確認後、正しいファイル名に戻す

よくあるつまずき:CSVのテキスト形式

  • Excelで保存した形式がCSVではない
  • カンマではなく全角カンマが入っている
  • 行数や列数が想定と大きく違う
  • 実行フォルダから見た Assets/Maps/Stage01.csv が存在しない
  • ゴール値 2 が入っておらず、読み込み成功扱いにならない

今回の到達目標

  • CSVから床タイルを読み込める
  • CSVからゴール位置を読み込める
  • CSV変更がゲーム画面に反映される
  • CSVが読めない場合はエラーを表示して停止できる
  • cameraX でワールド座標を画面座標へ変換できる
  • カメラ追従とマップ端の制限を説明できる
  • 012 の意味を説明できる

まとめ

今回は、マップをCSVファイルから読み込みました

  • ステージの形をコード外のデータとして扱いました
  • fstreamstringstream でCSVを読みました
  • 読込失敗時にエラーを表示して停止する処理を入れました
  • CSV編集だけで、地形とゴール位置を変更できるようにしました
  • cameraX で横スクロールを実装し、カメラの追従と端制限を行いました

付録: マップエディタ Tiled の紹介

Tiledは、2Dゲーム用のマップを作成するための無料のマップエディタです
タイルマップを視覚的に編集でき、CSV形式やJSON形式でエクスポートできます
これにより、ゲーム内のマップデータを簡単に管理できます

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

公式サイト: https://www.mapeditor.org/