pcat (5) 猫と地面
ステージの見た目だけ出来ました。とはいってもまだハリボテ状態で、そこにキャラクターを配置しても地面やブロックの上に乗ったり、下から突き上げてブロックを壊したりすることは出来ません。次の大きな目標は作成したステージをキャラクターが動き回れるようにすることです。そのためにやらないといけないことはたくさんあります。見せかけだけのステージのオブジェクトに当たり判定をもたせるようにしたり、壊れるブロックやはてなブロックのギミックを加えたりしないといけません。何よりもまずキャラクターを配置しないといけません。
キャラクターはこのゲームの場合猫です。キャラクターはおおまかに次のような仕組みを持たないといけません。
- 猫のスプライトを表示させる。
- アニメーションを加える。
- 上キーでジャンプできるようにする。
- 左右キーで横方向に移動できるようにする。
- 地面やブロックの上に乗れるようにする。
全部一気にやると大変なので少しずつ消化していきます。今回はこの内「地面やブロックの上に乗れるようにする」というのをやります。
大地に立つ
「地面やブロックの上に乗れるようにする」とは、逆にいうと「地面やブロックの上でなければ下に落ちていく」ことでもあります。つまり、通常時には下方向へ落ちていく力が常に加えられていて、地面やブロックの上にいるときだけその力と支える力が釣り合って安定した状態で上に乗っているように見えることとなります。
何も物理現象をシミュレーションする物理エンジンを作ろうとしているわけではありません。とりあえずは小手先のテクニックで、無理やりそのように動作するように作っていくことにします。
まずブロックは置いといて、地面の上に立つことだけを考えます。というより、地面もブロックも正方形のタイルからできていて、それらが連続してつながっているだけで、どちらか一方をやれば同じようにしてもう一方もできるようになります。
極度に簡略したケースを考えてみます。今、仮に画面下に地面を作ったとします。そしてその地面にちょうど接するように猫を配置します。これでも一応地面の上に経っているように見えます。しかし、右方向に歩いていって地面のないところまで来たとします。そのときは、下に落ちていかなければなりません。ですが、この簡略化した場合では下方向への力はかかっていなくて、さらに自分の足元に地面があるかどうかさえも感知していません。なので下に落ちていくことはありません。
やるべきことは、猫には常に下方向へ引き寄せられる力がかかっていて、下に何もなければそのまま下に落ちていくようにすることです。そして、地面は、猫が上に乗ったらそれ以上猫が下に落ちていかないように支えるようにしなければなりません。これを実現するには、地面を構成するタイルから生成されたすべてのオブジェクト(前回までののコードではMapItemとしていました)と猫で、当たり判定を行い、当たっていると判定されたらちょうど地面に接するように猫の位置を調整する処理を行います。簡単に言うと、地面をすり抜けたり地面にめり込んだりしないようにします。
雛形のコード
前回のコードに追加していくと複雑になってしまうので、まっさらな状態に一歩ずつ追加しながら作っていくことにします。まず、雛形としてSFMLのウィンドウを表示するだけのコードを書きます。
#include <SFML/Graphics.hpp>
// 画面の幅と高さ 面倒なのでグローバル空間に置く
const int ScreenWidth = 600;
const int ScreenHeight = 400;
void setup() {
// ここに初期設定を書く
}
void update(float dt) {
// ここにフレーム更新の処理を書く
}
void draw(sf::RenderTarget &target) {
// ここに描画処理を書く
}
void cleanup() {
// ここに後始末を書く
}
int main() {
// レンダリング可能なウィンドウを生成
sf::RenderWindow window{sf::VideoMode{ScreenWidth, ScreenHeight}, "pcat5"};
window.setFramerateLimit(60); // フレームレートを60に制限する
sf::Clock clock; // NOTE: タイマーは生成後即座にスタートする
setup();
// メインループ
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) {
// キーボードのキーが押されたときの処理
switch (event.key.code) {
case sf::Keyboard::Escape:
case sf::Keyboard::Return:
// EscまたはEnterが押されたらウィンドウを閉じて終了する
window.close();
break;
default:
break;
}
}
}
// タイマーをリセットして再スタートする
// また戻り値として前回のリセットからの経過時間を取得できる
sf::Time elapsed = clock.restart();
update(elapsed.asSeconds()); // 経過時間をfloatの秒として使い、フレーム更新
window.clear(sf::Color::Blue); // 全体を青でクリアして背景色とする
draw(window); // ウィンドウをレンダリングターゲットとして描画処理を行う
window.display(); // 表示する
}
cleanup();
}
解説の代わりとしてコメントを多めに入れてあります。通常はこのような冗長なコメントは不要です。
このコードを書いたファイルをmain.cppとすると、GCCでこのコードをコンパイルするには、例えば次のようにします。
g++ -std=c++17 -Wall -o pcat5_empty_window main.cpp -lsfml-graphics -lsfml-window -lsfml-system
エラーがなければ pcat5_empty_window という実行ファイルが生成されます。次のコマンドで実行できます。
./pcat5_empty_window
実行すると青で塗りつぶされたウィンドウが表示されます。
3つの関数setup()、update()、draw()にコードを追加していきます。引数には猫や地面といったゲームの要素あるいは状態を表すものが含まれていません。もし関数の引数を変更しないのであれば、これはそれらをグローバル変数で管理することを意味していて、実際にそのように意図しています。本番のコードではこのようなことはしません。試行錯誤するためのスケッチ用のコードなので、ロジックを単純化して目的の部分が際立つようにあえてそのようにします。
猫と地面の見た目だけ作る
先のコードに猫と地面を配置します。単純化するために猫は●、地面は■とします。
まずクラス定義です。
class Cat : public sf::Drawable {
public:
Cat(/* 初期位置など */);
void update(float dt);
protected:
void draw(sf::RenderTarget &target, sf::RenderStates states) const override;
// あと色々追加する…
};
class Ground : public sf::Drawable {
public:
Ground();
void update(float dt);
protected:
void draw(sf::RenderTarget &target, sf::RenderStates states) const override;
// あと色々追加する…
};
Cat *g_cat;
Ground *g_ground;
void setup() {
g_cat = new Cat{/* 初期位置など */};
g_ground = new Ground;
// groundに要素を追加
// g_ground->add(something) ...
}
void update(dt) {
assert(g_cat != nullptr); // cassertをインクルードしておく必要がある
assert(g_ground != nullptr);
g_cat->update(dt);
g_ground->update(dt);
}
void draw(sf::RenderTarget &target) {
assert(g_cat != nullptr);
assert(g_ground != nullptr);
target.draw(g_cat);
target.draw(g_ground);
}
void cleanup() {
assert(g_cat != nullptr);
assert(g_ground != nullptr);
delete g_ground; // わざとsetupと逆の順番にデリートしているが、現時点では動作に影響はない
g_ground = nullptr;
delete g_cat;
g_cat = nullptr;
}
CatとGroundに共通の基底クラスを設ける方が自然かもしれません。今回は猫1匹、地面1セットしか使わないので用意しません。また、先に書いたようにロジックを単純化するためにグローバル変数の生ポインタに保持させるようにしています。変数名g_xxxxのg_はグローバル変数であることを強調しています。sf::Drawableを継承して、関数drawをオーバーライドしているところについては後で書きます。
コメントに書いてあるように、これで全てではなくここから色々追加していきます。Groundは地面を表す長方形を一つで持たせるのではなく、固定されたタイルサイズ(仮に40x40とします)の正方形をいくつかまとめて持つようにします。
まず最初の目標は、おおよそ画面中央あたりに猫を配置して、画面下部に地面となる正方形を並べることです。そのために必要な要素を洗い出しておきます。
Cat
- Catは自分がどの位置にいるのかを知っている必要があるので、sf::Vector2i をメンバ変数として持たせる
- Catは描画されるときに、現在位置に●として現れるようにするために、draw関数でsf::CircleShapeを使って描画する。なおsf::CircleShapeはメンバ変数として持たせる。
- Catのコンストラクタで初期位置、●の大きさなどを設定できるようにする
- Catは今後位置を取得変更できるようにする必要があるので、この時点で位置を取得変更できるようにしておく。
Ground
- Groundは複数のタイル化されたパーツで構成される。つまり、std::vector<Block>のような入れ物をメンバ変数として持たせる。
- Groundを構成するブロックを追加できるようにする。add()のようなメンバ関数をもたせる。
- Ground自体は位置を持たない。要素のブロックが位置を持ち、その位置はGroundとの相対位置ではなく画面上の位置(絶対位置)とする。
- Groundは描画されるときに、要素のブロックすべてを■として現れるようにするために、sf::RectangleShapeを使って描画する。
だいたいこんな感じでしょうか。
Catのコード
まずはCatの方から作っていくことにします。
class Cat : public sf::Drawable {
public:
Cat(const sf::Vector2f &position);
void update(float dt);
sf::Vector2f position() const { return position_; }
void set_position(float x, float y);
protected:
void draw(sf::RenderTarget &target, sf::RenderStates states) const override;
private:
sf::Vector2f position_;
sf::CircleShape shape_;
};
Cat::Cat(const sf::Vector2f &position) : position_{position} {
shape_.setRadius(20.0);
shape_.setFillColor(sf::Color::Black);
shape_.setPosition(position_.x, position_.y);
}
void Cat::update(float dt) { shape_.setPosition(position_.x, position_.y); }
void Cat::draw(sf::RenderTarget &target, sf::RenderStates states) const {
target.draw(shape_);
}
void Cat::set_position(float x, float y) {
position_.x = x;
position_.y = y;
}
上のコードで気になる点があります。
- 位置の情報が二重化している。Cat::position_として直接保持するものと、Cat::shape_が保持するものでダブっている。
- set_positionではshape_の位置情報を更新していない。不一致が生じている。その代わりにupdateで更新するようにしている。
というところです。Catはposition_を直接持たないようにして、shape_のものを参照するようにすることも可能です。しかし、そうすることが本当に正しいのか疑問が残ります。コードを書いていたときshape_に期待していたのは円を描画してもらうことだけであり、位置の情報を持っていたのはいってみればたまたまであったわけです。別の言い方をすれば位置の情報はCatに直接結びつけたいところを、shape_の持っているものを再利用できるからと言って中に埋め込んでしまうもの、できるだけロジックを単純にしたいという方針に沿わないのです。ちょっとこじつけっぽいですがそういう理由で、ダブってしまっているところには目をつぶります。
sf::Drawable
sf::Drawableは前回までのコードでも使っていました。ここらで一度ちゃんとみておこうと思います。
SFMLのリファレンスからDrawable.hppへのリンクをたどることが出来ます。そこによればsf::Drawableの定義はだいたい次のようになっています。
class SFML_GRAPHICS_API Drawable {
public:
virtual ~Drawable() {}
protected:
friend class RenderTarget;
virtual void draw(RenderTarget& target, RenderStates states) const = 0;
};
読みやすいように変更を加えてあります。
まず目につくのはSFML_GRAPHICS_APIというclassとクラス名の間にある見慣れない記述です。これはこのクラスをライブラリとしてインポートあるいはエクスポートするために必要なものです。環境に依って必要になる宣言の仕方が異なるため、マクロを使ってコンパイル時に環境に適したテキストに置き換えられています。例えばGCCのバージョン4以降だと、エクスポート時、つまりSFML自体をビルドするときには次のように置き換えられます。
#define SFML_API_EXPORT __attribute__ ((__visibility__ ("default")))
そしてSFMLを利用する側では次のように置き換えられます。
#define SFML_API_IMPORT __attribute__ ((__visibility__ ("default")))
WindowsのMicrosoftのコンパイラでは次のように置き換えられます。
#define SFML_API_EXPORT __declspec(dllexport)
#define SFML_API_IMPORT __declspec(dllimport)
マクロやこのインポートとエクスポートに関する機能は今は関心の対象ではないのでこれ以上追求しないでおきます。むしろ余計な情報でした。
次はvirtual ~Drawable() {}
のところに注目してみます。以前書いたのですが、これは仮想デストラクタです。このDrawableを継承したクラスのオブジェクトをDrawableのポインタを介してdeleteするときにデストラクタが仮想関数になっていないと悲劇が起こります。そのためそのようなポインタを介してポリモーフィックな利用が考えられる場合はデストラクタはvirtualキーワードをつけて仮想関数にしなければなりません。
次はfriend宣言について見てみます。クラスX定義の中でfriend 別のクラス
あるいはfriend 別の関数
と書くと、対象となったクラスや関数は、クラスXのアクセス指定子がどのようになっていようが関係なくすべてのメンバにアクセスできるようになります。C++の良い習慣として、不必要にクラスの内部構造を外部に見せないというものがあり、そのためにアクセス指定子public、protected、privateを注意深く設定します。それに対してfriendをつけるとたちどころにルールが守られなくなってしまい苦労が水の泡です。friendは切れ味の良すぎる刃物なので注意深く扱う、なるべくなら使わないようにするという風潮があります。
では、なぜここで使われているのか、使う必要があるのかというと、draw関数がprotectedとなっているからです。今仮にDrawableを継承したクラスがあるとします。そしてそのクラスのオブジェクトを生成したとします。そのとき、そのオブジェクトに対してdrawを呼び出されるようなことはしてほしくないわけです。
class X : public Drawable {
protected:
virtual void draw(RenderTarget& target, RenderStates states) const { /* ... */ }
};
void foo() {
sf::Drawable *p = new X;
p->draw(/*...*/); // これを禁止したい
}
しかし、一方でRenaderTargetはその内部では呼び出せるようにする必要があります。そうでなければこのdraw関数は全く役に立ちません。
void bar(sf::RenderTarget &target) {
sf::Drawable *p = new X;
target.draw(*p); // おそらく内部でDrawbleのdraw関数を呼び出している
// RenderTargetにはdraw関数へのアクセスを許可したい
}
しかし、一方でRenderTarget以外には呼び出せるようにしたくない…ということで堂々巡りになってしまいます。これを打ち破るためにfriendが使われています。
同時に「なぜprivateでなくprotectedなのか」という点が気になったかもしれません。これは正確な理由が思いつきません。そのままの意味で取ると、Drawableを継承するクラスの別の関数からdrawできるようにするため、ということですが、なぜそうする必要があるのかはもう少し深いところまでSFMLの内部を調べていかないといけないでしょう。今回はここで打ち切っておきます。
次が一番重要なポイントです。draw関数の宣言を注意深く見ると、頭にvirtual
が、末尾に= 0
とついていることに気が付かれたと思います。このように宣言されたメンバ関数は純粋仮想関数 (pure virtual function)と呼ばれます。純粋仮想関数の大体の意味は「定義を持たなくても良い仮想関数」といったところです。本当は定義を持つことも出来ますが混乱するのを避けるために持たないケースだけを考えます。関数の定義を持たないとは、言い換えれば関数の宣言だけである、ということです。
次のようなコードを考えてみます。
class X {
public:
// 純粋ではない仮想関数
// 関数の本体は定義しないままにしておく
virtual void foo();
};
class X1 : public X {
public:
void foo() override {}
};
int main() {
X1 x1;
x1.foo();
}
このコードはリンクエラーになります。
/usr/bin/ld: /tmp/ccFVYkCj.o:(.rodata._ZTI2X1[_ZTI2X1]+0x10): undefined reference to `typeinfo for X'
collect2: エラー: ld はステータス 1 で終了しました
fooを純粋仮想関数にしてみます。
class X {
public:
// 純粋仮想関数
// 関数の本体は定義しないままにしておく
virtual void foo() = 0;
};
...
今度はエラーなくビルドできます。といった具合に、純粋仮想関数を使うとこのクラスを継承するクラスにその関数の実装を任せて、自分は関数が存在することのみを知らせることが出来ます。別の言い方をするとインターフェイスのみを宣言していることになります。だから何なの?ということになると思うのですが、ちょうど今見てきたようなsf::Drawbleのような使い方が可能になります。sf::RenderTargetのリファレンスとsf::Drawableのリファレンスを注意深く見比べてみると、sf::RenderTarget::draw関数が引数に要求しているのはsf::Drawble(の参照)です。sf::RenderTargetはsf::Drawbleがどのように実際に描画を行うかは関知していません。ただdrawを呼び出せればいいのです。そしてsf::Drawableの側では、この純粋仮想関数であるdrawをオーバーライドしてどのように描画するかを定義します。よく使われる用語では実装と呼ばれます。例えばsf::CircleShapeはsf::Drawableを実装しているなどといいます。
このようにして、sf::RenderTergetはsf::Drawbleを継承してdraw関数をオーバーライドしているクラスのオブジェクトであれば、何でも、全く知らない型のクラスであってその要件さえ満たせば受け付けることができるようになります。
あともう一つだけ重要な点があって、純粋仮想関数を一つでも含むクラスは抽象クラスと呼ばれます。抽象クラスはそのクラスのオブジェクトを作成することが出来ません。sf::Drawableも抽象クラスです。抽象クラスについて書いていたら長くなりすぎるので今回はここで終わっておきます。
sf::Drawableについてまとめておきます。
- sf::RenderTarget::draw関数で描画できるようにするには、sf::Drawableを継承して、draw関数をオーバーライドする。
- sf::Drawable::draw関数は純粋仮想関数(pure virtual function)である。
- sf::Drawbleは純粋仮想関数を持つので抽象クラスである。
Groundのコード
話を戻して地面の方を見ておきます。
class Ground : public sf::Drawable {
public:
void update(float dt);
void add(float x, float y, float w, float h);
protected:
void draw(sf::RenderTarget &target, sf::RenderStates states) const override;
private:
std::vector<sf::RectangleShape> blocks_;
};
void Ground::update(float dt) {}
void Ground::draw(sf::RenderTarget &target, sf::RenderStates states) const {
for (auto &shape : blocks_) {
target.draw(shape);
}
}
void Ground::add(float x, float y, float w, float h) {
sf::RectangleShape r;
r.setSize({w, h});
r.setPosition(x, y);
r.setFillColor(sf::Color::Green);
blocks_.push_back(r);
}
今は何も書くことが思いつきません。後日追記します。
CatとGroundを初期化するsetup関数を用意します。
void setup() {
g_cat = new Cat{{ScreenWidth / 2.0, ScreenHeight / 2.0}};
g_ground = new Ground;
int s = 40;
for (int i = 1; i < 9; ++i) {
g_ground->add(i * s, ScreenHeight - s, s, s);
}
for (int i = 10; i < (ScreenWidth / s) - 1; ++i) {
g_ground->add(i * s, ScreenHeight - s, s, s);
}
}
実行すると次のようになります。
全く面白くない画面です。
本当なら地面の上に立つところまでやりたかったのですが長くなってしまったので一旦終了します。