pcat (3) タイルマップ
最初の計画ではまずステージを作ることにしてました。前回は画像を表示しました。画像さえ表示できればステージのオブジェクトの画像を配置して見た目だけのハリボテステージが作れます。なので次はステージの見た目だけを取り繕います。実働版では、単に画像を見せるだけでなく、様々な追加の処理が必要です。例えば、レンガは下から叩いたら壊せるようにする必要があります。はてなブロックは下から叩いたらアイテムが出現したりできるようにする必要があります。特に、衝突処理が一番重要です。衝突処理とはつまり、地面、ブロック、土管などの上にプレイヤーが乗れるようにしたり、めり込まないようにしたりするための処理です。
プレイヤーを作るのは次のステップなので、そういった難しいことは考えずに、まずステージの見た目だけを作ることにします。
画像を用意する
まずブロックの画像を適当に用意します。サイズは32x32ピクセルにします。
1.png - 地面(前回使ったものです)
2.png - 壊せるブロック(レンガ)
3.png - 壊せないブロック
4.png - はてなブロック
適当すぎる気がしますが、一旦これで進めてみます
画像を配置する
次にどのようにしてプログラムでこれらを配置するかを考えます。
いきなりステージ全部をやるのは大変だし、そもそも土管などの画像が足りていないので、まず1画面分を考えてみます。
最初の試みとして、もっとも直感的な方法でやってみます。それは、前回やったように、テクスチャに画像をロードして、必要なだけスプライトを生成してテクスチャをマッピングして、そのスプライトの座標を設定するコードを書くという方法です。
例としてマリオの1-1の最初ブロックがある場面を上の画像を作って1画面分だけ作ってみます。
#include <SFML/Graphics.hpp>
#include <SFML/Graphics/RenderWindow.hpp>
#include <SFML/Graphics/Sprite.hpp>
#include <SFML/Graphics/Texture.hpp>
#include <SFML/Window/Keyboard.hpp>
#include <SFML/Window/VideoMode.hpp>
#include <iostream>
const int TileWidth = 32;
const int TileHeight = 32;
const int MapWidth = 16;
const int MapHeight = 15;
void sprite_setMapPosition(sf::Sprite &sprite, int xmap, int ymap) {
int x = xmap * TileWidth;
int y = ymap * TileHeight;
sprite.setPosition(x, y);
}
int main() {
sf::RenderWindow window{sf::VideoMode{512, 480}, "pcat"};
sf::Texture texture1; // ground
sf::Texture texture2; // weak block
sf::Texture texture3; // hard block
sf::Texture texture4; // unknown block
if (!texture1.loadFromFile("1.png")) return 1;
if (!texture2.loadFromFile("2.png")) return 1;
if (!texture3.loadFromFile("3.png")) return 1;
if (!texture4.loadFromFile("4.png")) return 1;
std::vector<sf::Sprite> ground(MapWidth);
for (size_t i = 0; i < ground.size(); ++i) {
ground[i].setTexture(texture1);
sprite_setMapPosition(ground[i], i, MapHeight - 1);
}
std::vector<sf::Sprite> blocks(7);
blocks[0].setTexture(texture4);
sprite_setMapPosition(blocks[0], 3, 10);
blocks[1].setTexture(texture2);
sprite_setMapPosition(blocks[1], 7, 10);
blocks[2].setTexture(texture4);
sprite_setMapPosition(blocks[2], 8, 10);
blocks[3].setTexture(texture2);
sprite_setMapPosition(blocks[3], 9, 10);
blocks[4].setTexture(texture4);
sprite_setMapPosition(blocks[4], 10, 10);
blocks[5].setTexture(texture2);
sprite_setMapPosition(blocks[5], 11, 10);
blocks[6].setTexture(texture4);
sprite_setMapPosition(blocks[6], 9, 6);
while (window.isOpen()) {
sf::Event event;
while (window.pollEvent(event)) {
if (event.type == sf::Event::Closed) {
window.close();
} else if (event.type == sf::Event::KeyPressed) {
if (event.key.code == sf::Keyboard::Escape) {
window.close();
}
}
}
window.clear(sf::Color{0, 0, 255});
for (auto &s : ground) {
window.draw(s);
}
for (auto &s : blocks) {
window.draw(s);
}
window.display();
}
}
長ったらしいですが大したことはやっていません。一応解説入れておきます。
定数の設定
9行目から12行目で定数を設定しています。
const int TileWidth = 32;
const int TileHeight = 32;
const int MapWidth = 16;
const int MapHeight = 15;
TileWidthとTileHeightは今は画像の幅と高さが何ピクセルかを表します。この名前の意図しているところは、画像をタイルセット、つまりタイル(同じサイズの小さな矩形の絵)の集まり、とみなしたとき、一つのタイルの幅と高さを表しています。しかし、今はまだ一つのタイルを一つのファイルにしています。そのためTileWidthとTileHeightは画像の幅と高さに等しくなります。また正方形なので幅と高さは同じサイズになっています。
MapWidthとMapHeightは画面をタイル状に区切った場合、横と縦にそれぞれいくつのタイルが収納可能かを表しているという見方をすることが出来ます。上のTileWidthとTileHeightではWidthとHeightという単語がピクセル数を表していたのに、こちらではタイルの数を表しているというのは紛らわしいです。Mapという単語がタイルの数によって管理されるということをしっかり意識しておかないと間違えることになるでしょう。今回のような短いプログラムではまだ大丈夫だろうからこのままいきます。
これらの定数を使って1画面のピクセルサイズを計算することも出来ます。
const int ScreenWidth = MapWidth * TileWidth;
const int ScreenHeight = MapHeight * TileHeight;
今回は使わないので定義していません。
スプライトの座標を設定する補助関数
次のsprite_setMapPositionという関数は、画面をタイル状に区切ったときのタイル位置を指定して、そのタイル位置からピクセル座標を計算してスプライトの位置にセットする関数です。やってることは単純で、タイルの位置×タイルのサイズをsf::Sprite::setPositionに渡すというものです。なぜこんなものが必要になるかは少し後で述べます。
ウィンドウの作成
main関数の最初でウィンドウを作成しています。
sf::RenderWindow window{sf::VideoMode{512, 480}, "pcat"};
ウィンドウサイズは見てわかる通り、512x480です。このサイズになんの意味があるかというと、初代ファミコンの出力解像度が256x240で、それを縦横ともに2倍したものとなっています。マリオの実際のゲームの画面を見てみると、横にタイル16枚分、縦に15枚分となっているようです。256/16=16、240/15=16であることから、タイルのサイズは16x16であることが伺えます。今使ってるPCのディスプレイにこの解像度を採用すると小さすぎてやりづらいです。なので画面解像度に相当するウィンドウのクライアント領域のサイズを倍の512x480として、タイルのサイズも2倍して32x32を採用しました。別にファミコンの仕様に合わせる必要もないのですが、雰囲気を近づけてみたいのでそうすることにしました。
2つ目の引数の"pcat"というのはウィンドウのタイトルです。
このコンストラクタが成功するとウィンドウが作成され表示された状態になります。
テクスチャの初期化
23-31行目でテクスチャを作成してファイルから読み込んでいます。
sf::Texture texture1; // ground
sf::Texture texture2; // weak block
sf::Texture texture3; // hard block
sf::Texture texture4; // unknown block
if (!texture1.loadFromFile("1.png")) return 1;
if (!texture2.loadFromFile("2.png")) return 1;
if (!texture3.loadFromFile("3.png")) return 1;
if (!texture4.loadFromFile("4.png")) return 1;
texture1〜4という名前はいい名前とは言えません。そもそもタイル一つごとにテクスチャ一つという設計自体が良い方法ではありません。後で一つのファイルにまとめてタイルマップを使うことを予定しています。まず段階を踏んでいきたいので最もストレートと思われる方法にしておきました。texture1〜4の数字はファイル名と一致させてあります。画像ファイルの読み込みに失敗したら即座に終了するようにしてあります。
スプライトの初期化
33行目から59行目まででスプライトを作成して、テクスチャをセットして、座標を設定しています。スプライトはたくさん必要になります。std::vectorを使って2つのグループ、地面とブロックに分けています。
地面のスプライトは前回やったものとほぼ同じです。位置の設定に上で定義したsprite_setMapPositionという関数を使うように変更しています。縦位置は固定で、横位置をタイル1つ分ずつずらしながら計算してセットしています。
ブロックのスプライトは計算で決めることが出来ないのでベタ書きしています。ここでもsprite_setMapPosition関数をつかっていますが、もし使わないと次のようになります。
std::vector<sf::Sprite>l; blocks(7);
blocks[0].setTexture(texture4);
blocks[0].setPosotion(3 * TileWidth, 10 * TileHeight);
blocks[1].setTexture(texture2);
blocks[1].setPosotion(7 * TileWidth, 10 * TileHeight);
blocks[2].setTexture(texture4);
blocks[2].setPosotion(8 * TileWidth, 10 * TileHeight);
blocks[3].setTexture(texture2);
blocks[3].setPosotion(9 * TileWidth, 10 * TileHeight);
blocks[4].setTexture(texture4);
blocks[4].setPosotion(10 * TileWidth, 10 * TileHeight);
blocks[5].setTexture(texture2);
blocks[5].setPosotion(11 * TileWidth, 10 * TileHeight);
blocks[6].setTexture(texture4);
blocks[6].setPosotion(9 * TileWidth, 6 * TileHeight);
TileWidthとTileHeightの乗算が繰り返し出てきます。この程度ならまだ許容範囲で大差ないように思えるかもしれません。しかし、小さなものでも繰り返しを消していかなければコードの規模が大きくなるにつれて可読性はしだいに下がっていきます。この場合、setPositionの引数の中で重要なのはマップの位置なのですが、その情報が演算の中に埋もれてしまいます。この程度のものを関数にする必要あるのかという問にはイエスと答えます。
メインループ
次のwhileからはメインループです。イベントの処理は前書いた気がするので繰り返さないでおきます。
上で作成した2つのstd::vector<sf::Sprite>、すなわち地面とブロックを全部描画しています。
実行画面
実行すると次のような画面になります。
絵のクオリティを除けば、最初の試みとしてはまずまずだと思うのですが問題はその先にあります。
果たしてこの手法でステージ一つ分記述していくことは可能でしょうか?原理的には可能です。ステージに配置するブロックや土管の座標をすべてハードコーディングしていくだけです。ただ死ぬほど面倒で退屈でメンテナンス性が低くてエラーの発生しやすいコードになるだけです。
もしステージの構成に何らかの規則性を見いだせれば、機械的にそのパターンをコードで表すことができるでしょうが、今のところそういう規則性はありません。オリジナルのマリオのステージ1-1はどう見ても人間の手によって作成されたものに見え、単純な規則は見いだせません。あるいは、ステージを制作する人間の思考を真似たステージ生成器を作るといった手法も考えられますが、今作ろうとしている単純なゲームには似つかわしくない高度な知識が必要になります。
余談ですが、こういったステージをデザインして作成する工程のことを、レベルデザインというそうです。レベルというと日本語のニュアンスだと難易度のように感じられますがこうしたステージのようなものの設計を指すようです。
話を戻して、先にやったストレートな方法で、スプライトのテクスチャと座標をひたすらコードに書き込んでいくというやり方は、不可能ではないがやりたくないです。なので別の方法を取ります。
ステージの構成をデータで表現する
先のコードのスプライトをセットアップしている部分に注目してみます。
blocks[0].setTexture(texture4);
sprite_setMapPosition(blocks[0], 3, 10);
このコードから、描画するという目的だけのためであれば、スプライトをセットアップするには次の2点が必要であり、かつ十分であることが分かります。
- スプライトに貼り付けるテクスチャ
- スプライトのマップ座標
この2点を保持するデータ形式を決めて、そのデータを用意すれば、プログラムで機械的に処理することができるようになるはずです。
まず次のような形式を考えてみます。
// 地面
0 14 1
1 14 1
2 14 1
3 14 1
4 14 1
5 14 1
6 14 1
7 14 1
8 14 1
9 14 1
10 14 1
11 14 1
12 14 1
14 14 1
15 14 1
// ブロック
3 10 4
7 10 2
8 10 4
9 10 2
10 10 4
11 10 2
9 6 4
1列目がマップのx座標、2列めがy座標、3列めがテクスチャ番号です。一応これでもプログラム側で処理することは可能でしょう。しかし、まだ冗長な上間違いやすそうです。もう一つ考えてみます。
................
................
................
................
................
................
.........4......
................
................
................
...4...24242....
................
................
................
1111111111111111
この表現はかなり直感的ではないかと思います。「.」は何もないことを示しています。1〜4の番号はテクスチャ番号です。列位置と行位置がそのままマップの座標に対応しています。この表現が簡潔に見えるのは、マップの座標の情報が番号が置かれた位置、視覚的な位置に埋め込まれているからです。
どちらの方法を取るにせよ、プログラムで読み込んで機械的に処理するので、読み込む側の処理については問題をクリアしているように思えます。問題は作成する方法です。2つ目の方法はまだ現実的な方法に見えなくもないですが、まだまだ大変そうです。
もし、ペイントツールのように実際にゲームで表示する画像を貼り付けながらステージを作成できるのが理想です。そうして、作成されたマップの画像イメージから上記のようなプログラムで扱いやすい形式にして出力させればいいわけです。つまり、そういったマップを作成するためのプログラムを作成すればよいわけです。結局の所、そういうプログラムがあればマップのデータが視覚的に見やすいかどうかはあまり問題でありません。なぜなら、間違いの訂正や作成は作成用のプログラムで行うので直接手動でそのデータを編集することはないからです。もちろん、微調整のため直接手を加えることがないわけではないので編集しやすくてもプラスでこそあれ、困ることはありません。
そういうプログラムを作るのも良いトレーニングになると思いますが、今回はパスします。Tiledというタイルマップエディタを使います。
まず先に使っていた1.png、2.png、3.png、4.pngという画像ファイルを1つの画像にまとめます。
このようなタイルをまとめた画像ファイルのことをタイルセットと呼ぶらしいです。
Tiledを起動して1画面分の新しいマップを作成して、上のタイルセットを読み込んで、マップにペタペタ貼り付けていきます。
1画面分だけ作成しました。それからCSV形式でエクスポートします。エクスポートしたら次のようなデータ得られます。
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,4,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,4,-1,-1,-1,2,4,2,4,2,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
タイル番号は左上から順に振られます。それを利用して、タイルセットは前に利用した番号と一致させるような作りにしておきました。
TiledはCSV以外にもいくつかの形式でエクスポート出来ます。マップが複雑になるとCSVでは力不足です。JSONなどが扱いやすいでしょう。LuaのコードとしてもエクスポートできるのでLuaを使っているなら便利そうです。
ともあれ今回はCSVで十分です。これデータの作成の問題はクリアしました。
あとはこのデータを読み込んで利用できるようにするのみです。
CSVを読み込んでマップを構築する
上のマップデータを利用できるようにするには、まずプログラムがCSV形式を読み込んで、各フィールドの値を取り出せるようにしなければなりません。また、マップデータの各フィールドの行番号と列番号がマップの位置を意味しているため、単に値を取り出すだけでなく行番号と列番号も取り出さなければいけません。
手順としては以下のようにすることにします。
- CSVファイル(pcat3_tilemap.csvという名前にする)をテキストファイルとして読み込む。
- 読み込んだファイルから1行読み込む。このときこれまで読み込んだ行数を数えておく。
- 行をカンマ「,」で分割する。このとき分割して得られたフィールドの数を列数とする。
- 列数が期待するマップの幅であるかどうかをチェックして、マップデータが正しいかどうかを確認する。
- 分割されたフィールドを順に走査していく。
- フィールドの値を調べる。
- フィールドの値が0以上だったなら何らかのタイルが配置されている。この場合、現在の行と列とフィールドの値から、マップ上のアイテムを表現するオブジェクトを生成する。
- 生成したオブジェクトをステージ全体のオブジェクトを管理するコンテナに放り込む。
- 最後のフィールドを処理するまで繰り返す。
- 最後の行を処理するまで2に戻って繰り返す。
- ファイル全体を処理したらファイルを閉じる。
- 読み込んだ行数がマップの期待するマップの高さであるかどうかをチェックして、マップデータが正しいものであるかどうか確認する。
長ったらしくなってしまいましたが、作るのは次の2つ、クラスと関数を一つずつです。
class MapItem;
std::optional<std::vector<MapItem>> makeMapFromCsvFile(const std::string &filename, const sf::Texture &tileset);
MapItemのMapはステージのマップを表しているのであってコンテナ(std::mapなど)ではありません。紛らわしいのでいい名前が浮かんだら変更します。
ここでいくつか設計において選択をしました。
- CSVのフィールドの値からMapItemというオブジェクトを作成する。
- マップ全体を管理するために専用のクラスを用意せずにstd::vectorで管理する。
- マップの作成が成功したかどうかを戻り値のstd::optionalの状態から判定する。
MapItemというクラスを用意するのは、単にフィールドの値、1とか2とかだけを保持しても役に立たないからです。マップの位置が必要です。更にテクスチャ番号を保持するのではなく、スプライトを保持させるようにしたほうが便利です。これらの要求を満たすためにはクラスに詰め込むほうが良いと判断しました。
一方でマップ全体を管理するクラスを用意しないのは、現状では配列でも十分に管理可能だからです。コード量を削減するためにstd::vectorを使うことにしました。この選択には欠点もあります。まずマップの特定のアイテムに直接アクセスする方法がありません。std::vectorを線形に検索して目的のマップ位置を持つものを探さなければなりません。もし二次元配列のようなデータ構造にしてアイテムがないタイルも含めすべてのタイルをそのままミラーリングすればtilemap(10, 8)のような書き方で直接アクセスが可能になっていたでしょう。メリットもあります。アイテムを持たない位置のアイテムを生成しないため空間(メモリ)の節約になります。今作ろうとしてる1画面分のマップには地面とブロック合わせて20個弱しかアイテムを配置しないので検索もさほど遅くならないし、それほど的はずれな選択でもないでしょう。
std::optionalはC++17から追加されたものです。適当にいうと、値が有効であればその値を利用可能にして、有効でなければ無効である状態を保持する型です。関数の戻り値で正常であるか不正であるかのケースに分かれる場合など便利です。エラーを示すためにnullを返していた時代もありましたが現代では不要です。しかし、この決断にはあまり自信がありません。というのは、呼び出し側はstd::optionalが有効な値を保持していることが出来たら、いつまでもstd::optionalの変数のままではなく、直にstd::vector<MapItem>として保持したくなるところですが、そうすると、次のようなコードが必要になります。
auto o = makeMapFromCsvFile("pcat3_mapdata.csv", tileset);
std::vector<MapItem> mapItems;
if (o) {
mapItems = std::move(*o); // あるいは mapItems.swap(*o);
}
何か見苦しいです。もっと素直な方法は、戻り値をboolにして、結果を格納するコンテナを引数として取るようにすることです。これは伝統的な方法です。しかし、個人的にあまりきれいなインターフェイスではないように思えます。もう一つはエラーは例外で処理するようにする方法があります。しかし、ファイルが存在しないなどは例外と呼べるものなのだろうか、予測可能な事態ではないかという疑問がわきます。FileNotFoundExceptionのような例外クラスが存在する環境もありますが、今回は例外は採用しません(そもそも例外の解説をしていなかった気もします)。
マップデータを読み込むコードを書く
まずMapItemから考えることにします。MapItemはクラスにします。最初の試みは次のようなものです。
class MapItem {
public:
MapItem(int tile_index, int map_x, int map_y);
private:
sf::Vector2i map_position_;
int tile_index_;
sf::Sprite sprite_;
};
MapItem::MapItem(int map_x, map_y, int tile_index)
: map_position_{map_x, map_y}, tile_index_{tile_index} {
// テクスチャ(タイルセット)から指定された範囲の領域を計算してスプライトにセットする
// スプライトの位置をセットする
// ...
}
これだけで完結すれば理想的です。コンストラクタでスプライトを初期化してすぐに利用可能な状態にしたいところです。ところが上のコードでは無理です。なぜならスプライトが参照するテクスチャの情報が足りないからです。
そこで、コンストラクタの引数にテクスチャの参照を取るようにしてみます。
class MapItem {
public:
MapItem(int tile_index, int map_x, int map_y, const sf::Texture &tileset, int tile_size_x, int tile_size_y);
...
};
テクスチャを引数に追加しました。さらにタイルの番号から参照するテクスチャの領域を計算するために必要なタイルのサイズ、幅と高さも追加しています。ちょっと長ったらしいです。おそらく、タイルセット1枚に付きいくつものアイテムを生成することになります。そのたびにタイルセットの情報を付加してやらなければならないのは冗長です。そこで、タイルセット1枚に対しそのタイルセットを使用するアイテムを生成するための専用のクラスを作ることにします。名前はMapItemFactoryとします。
class MapItemFactory {
public:
MapItemFactory(const sf::Texture &tileset, sf::Vector2i tile_size);
MapItem create_map_item(int tile_index, int map_x, int map_y) const;
private:
const sf::Texture &tileset_;
sf::Vector2i tile_size_;
};
ファクトリという名前を使ってますがGoFのデザインパターンのFactory MethodやAbstract Factoryを意図したものではありません。
このファクトリを使うと、マップアイテムの作成は次のように書けます。
sf::Texture tileset;
tileset.loadFromFile("pcat3_tileset.png");
MapItemFactory f{tileset, 32, 32};
auto ground0 = f.create_map_item(1, 0, 14);
auto ground1 = f.create_map_item(1, 1, 14);
auto ground2 = f.create_map_item(1, 2, 14);
auto ground3 = f.create_map_item(1, 3, 14);
auto block0 = f.create_map_item(4, 3, 10);
毎回引数にテクスチャを指定するのに比べるとだいぶスッキリしていると思います。大して変わらないという意見もありそうですが聞こえないふりをしておきます。
これらの関数の中身は細々としているだけであまりおもしろいものではないので解説は省略します。
次にmake_map_from_csv_file関数を書きます。関数の引数を少し変更して、先のファクトリオブジェクトを取るようにします。
std::optional<std::vector<MapItem>> make_map_from_csv_file(
const std::string &filename, const MapItemFactory &factory);
この関数は面白いところがあるので、詳しく見ていくことにします。
まずファイルを読み込み用で開く必要があります。C++では入力用のストリームというのがあり、さらにファイル用の入力に特化したストリームがあります。std::ifstreamという名前です。リンク先を見るとわかるのですが、正確にはstd::ifstreamというのはstd::basic_ifstream<char>の別名です。でも単にifstreamといってもだいたい会話は成り立つと思います。とはいえ一応別名であるということは頭の片隅にでも入れておいたほうが良いです。
std::ifstreamはfstreamヘッダで定義されています。なので、使うときはインクルードします。
#include <fstream>
void foo() {
std::ifstream is{"foo.txt"};
if (!is) {
std::cerr << "ファイルが開けない!\n";
return;
}
}
こんな感じです。ファイルを開くときには、コンストラクタの引数にファイル名(パス)をまず指定してオブジェクトを作成します。追加の引数にファイルのモードを指定することも出来ます。特にバイナリモードで開くときは、
std::ifstream is{"foo.bin", std::ios::binary};
のようにします。std::ios::textというのは存在しないようです。binaryを指定しなかったらテキストモードになります。テキストモードとバイナリモードとは何が違うかというと、改行文字の扱いが異なります。バイナリモードのときは改行文字もそのまま扱われますが、テキストモードのときは変換が施される場合があります。詳しくはこちらに書かれています。今回扱うマップデータはCSV形式ですがテキストファイルに違いはないのでバイナリモードは指定しません。
さて、make_map_from_csv_file関数の場合、ファイルのオープンに失敗したら何をするべきかを考えてみます。この関数はデータのファイルが開けなければもうそれ以上何もすることが出来ません。なので即座に関数からリターンしてしまいます。ここでこの関数の戻り値の型がstd::optionalであったことを思い出します。std::optionalの無効な値として使えるstd::nulloptという値が存在するのでそれを返すことにします。
std::ifstream is{filename};
if (!is) {
return std::nullopt;
}
呼び出し側は戻り値が無効であるかチェックすることでマップの構築に失敗したことを知ることが出来ます。
次にオープンしたファイルを1行ずつ処理していきます。1行ずつ処理するには、例えば次のようなコードが使えます。
int row = 0;
std::string line;
while (std::getline(is, line)) {
// 読み込んだ1行を処理する
// ...
++row;
}
std::getlineは第1引数に指定された入力ストリームから1行ごそっととってきて、第2引数に指定されたstd::stringの中にその内容を詰め込んできてくれます。また戻り値は引数に指定された入力ストリームになります。入力ストリームはbool値の文脈で使えますので、whileの条件として使えます。読み込み途中にエラーがなければファイルの終了(EOF)を検出するまでループされます。ファイルの末尾まで行ったらループは終了します。また、行位置が必要なのでrowという変数で行番号をカウントしています。
次に読み込んだ行を処理してフィールドの値を取得します。だいぶ上の方で貼り付けたCSVファイルを使っているなら、最初1行を読み込んだとき、lineという文字列オブジェクトは次のような内容になっているはずです。
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1
このテキストの意味は、画面の一番上のタイル行には何もタイルがセットされていないことを意味します。とにかくこれをカンマで区切って値を検査できるようにします。
問題なのが、文字列をカンマ「,」で区切るにはどうすればよいかということです。残念ながらC++17時点ではC++の標準ライブラリにそのような関数はありません。とはいえstd::vectorとstd::stringを使えば自作するのはさほど難しくありません。今回はもっと楽をすることにします。C++で標準ライブラリについでもっとも広く知られているライブラリにBoostライブラリというものがあります。標準ではないので追加でインストールしなければならないのですが、ぜひ入れておくのがおすすめです。たとえ使わなくてもその方が箔が付きます。もちろん積極的に使うのも楽しいものです。
で、ここではsplitというのを使います。分類としては文字列アルゴリズム(String Algorithms Librarry)というのに含まれるようです。使い方は直感的でシンプルです。
#include <boost/algorithm/string/split.hpp>
int row = 0;
std::string line;
while (std::getline(is, line)) {
std::vector<std::string> fields;
boost::split(fields, line, boost::is_any_of(","));
// fieldsの各要素について処理する...
++row;
}
どの文字を区切り文字とするか指定するためのboost::is_any_ofにちょっと戸惑うかもしれません。ここで指定した文字が区切り文字として使われるということだけ知っていれば利用に困ることはないでしょう。
一応気に留めておきたいのは、CSVの仕様としてカンマを含むテキストは二重引用で囲むことで扱えるなければならないのですが、ここでは考慮していません。Tiledが生成するデータはおそらく数値だけであり、カンマをデータとして含むことはないだろうからです。そのような起こりもしないことに対処するとかなりの労力をさかなければなりません。単に無視してしまいます。ただ、汎用的なCSVを扱うコードとしては不合格であるということは気に留めておきます。
boost::splitのおかげでなんの苦労もせずたった2行でフィールドの値をfieldsという変数に詰め込むことが出来ました。この時点ではまだfieldsの値は文字列です。欲しいのは数値です。なぜなら、MapItemFactory::create_map_item関数の引数に指定するタイルの番号がint型だからです。したがって次にやることは、fieldsの各要素についてループしながらその値を数値に変換していくことです。
文字列を数値に変換するのにはstd::istringstreamが使えるのですが、Boostのboost::lexical_castというのも使えます。直前にBoostを使ったので例としてここでもBoostの方を使ってみます。
try {
for (size_t col = 0; col < fields.size(); ++col) {
auto v = boost::lexical_cast<int>(fields[col]);
...
}
} catch (boost::bad_lexical_cast &e) {
return std::nullopt;
}
見てわかるとおりboost::lexical_castはキャストのようなスタイルで記述出来ます。大事なことは、もし変換元の文字列が指定された型の数値に変換できないときはboost::bad_lexical_castという例外が投げられるということです。例外についてはまだ書いたことがありませんでした。長くなってしまうのでここでは書かないでおきます。ただ try { ... } catch ( ) { ... } で囲んだとき、tryの方で例外が発生すると処理が catch { ... } の方にジャンプすると言うことだけにとどめておきます。ここではキャストに失敗したらstd::nulloptを返すことでマップの構築全体が失敗したということにしてしまいます。
タイルの番号が取得できたのであとはファクトリを使ってマップアイテムを生成して、結果を格納するコンテナに放り込むだけです。
auto v = boost::lexical_cast<int>(fields[col]);
if (v >= 0) {
auto item = factory.create_map_item(v, col, row);
mapItems.push_back(std::move(item));
}
アイテムをstd::moveに渡しています。ちょっと込み入った話になるので省略しますが、実は今の段階ではこれは意味がありません。後々のためにこうしてあります。今は気にしなくて全く問題ありません。
長かったですがmake_map_from_csv_fileはこれで終わりです。一度全体を載せておきます。
std::optional<std::vector<MapItem>> make_map_from_csv_file(
const std::string &filename, const MapItemFactory &factory) {
std::ifstream is{filename};
if (!is) {
return std::nullopt;
}
std::vector<MapItem> mapItems;
int row = 0;
std::string line;
while (std::getline(is, line)) {
std::vector<std::string> fields;
boost::split(fields, line, boost::is_any_of(","));
try {
for (size_t col = 0; col < fields.size(); ++col) {
auto v = boost::lexical_cast<int>(fields[col]);
if (v >= 0) {
auto item = factory.create_map_item(v, col, row);
mapItems.push_back(std::move(item));
}
}
} catch (boost::bad_lexical_cast &e) {
return std::nullopt;
}
++row;
}
return mapItems;
}
この関数を利用するmain関数は次のようになります。
sf::Texture tileset;
tileset.loadFromFile("pcat3_tileset.png");
MapItemFactory map_factory{tileset, {32, 32}};
auto map_items = make_map_from_csv_file("pcat3_tilemap.csv", map_factory);
if (!map_items.has_value()) {
return 1;
}
コード全体を載せるには長くなりすぎたので別のページに貼り付けておきます。
実行結果は先にやったものと変わらないですが次のようになります。
ただこれだけのためになぜこんなに長々と書く必要があったのか、果たして労力に見合うだけの見返りがあるのか、それはまだよくわかってません。
次回は画面をスクロールできるようにして、ステージをまるまる一つ作ってみることにします。そのときに結果が出るはずです。