Code of Poem
Unknown programmer's programming note.

Gameクラス

新しいゲームを作り始めると決めてから10日ほど経ちますがまだ何も作ってません。前回とりあえず決めたことはGameクラスというのを作ってその中にコードを放り込んでいくというやり方で進めるというものでした。ブロック崩しを作ったときはGameクラスのようなシステム全体を管理するクラスは作りませんでした。とりあえず進めてみてうまくいかなくなったらそのとき考えるという進め方でした。

Gameクラスのようなものを基礎にして作り始めると、しっかり設計しないとうまく回らないのではないか、とりあえず進めてみるというやり方は出来ないのではないかのようにも思えます。とりあえず進めてみるというやり方は変えたくないのでそれでは困ります。ここでGameクラスの役割をもう少しはっきりさせておきます。

ブロック崩しのときのように関数の引数と戻り値を使ってゲーム全体を回す方法を使うやり方を諦めたのは、あらゆる関数にgameのようなパラメータが出現して関数のシグネチャが冗長になるのを避けたいからでした。またグローバル変数にgameを保持させるのを止めたのはわざわざそうしなくても、Gameクラスでゲーム全体を表現すればその中にあらゆる状態を保持させることが出来て、わざわざグローバル変数にする必要もないからでした。

ブロック崩しのプログラムで中心的な役割を果たしたのは関数と構造体でした。構造体に状態を保持させ関数に状態を更新したり、その状態を元に描画をする処理を記述していきました。このやり方を止めてGameクラスに置き換えようとしているのはコードが大きくなることが見込まれるからなのですが、基本的な構造は変わりません。Gameクラスのオブジェクトが状態を保持してそれ自身がその状態を元に処理を行うようにしていきます。要はグローバルな空間に何でもかんでもばらまきたくないからです。Gameクラスにメンバ変数として何らかのゲーム内のオブジェクト、例えばplayerを保持させたとして、実のところグローバル変数や関数の引数にしたりした場合とプログラムの構造自体が変わるわけではありません。ただ、playerがGameクラスの中にあることでコードの全体を把握しやすくなり、脳みそにかかる負担が小さくなることを目的としています。何かの知識を前提とした手の込んだ手法を使うつもりはないです。

結論を言うと、Gameクラスを作るようにしても、とりあえず進めてみるというやり方はそのまま変えません。Gameクラスはとにかくコードを放り込むためのクラスです。

Gameクラスの分類

クラスの使い方は様々でしょうが、Stroustrupの本には基本的な機能によって次のような分類がされていました。

プログラミング言語C++ 第4版 P68

今から作ろうとしているGameクラスはこれのどれにも当てはまらなさそうです。再利用など全く考えません。他のクラスのメンバになったりすることはないし、Gameクラスから派生してShootingGameクラスやPlaformGameクラスやRpgGameクラスとして使えるようにするなどは考えられません。

比較のためのコードを挙げてみると、Qtというライブラリを利用するアプリケーション開発のためのIDEであるQt Creatorを使うと、雛形として次のような初期コードを自動で生成します。

#include "mainwindow.h"
#include <QApplication>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    MainWindow w;
    w.show();

    return a.exec();
}

MainWindowというのがユーザーが記述するプログラムのクラスです。このMainWindowというクラスに具体的にどのような処理を行うかを書いていくのが最も初歩的な使い方です。例えばウィンドウのサイズを具体的に500x500とか決めたり、右下にボタンを配置するとか決めたりするコードを記述していきます。MainWindowクラスはQMainWindowクラスというQtが提供するウィンドウの機能を持ったクラスから派生しているので、Stroustrupの上の3つの分類によるとクラス階層内のクラスにということになります。Gameクラスは何ものからも派生することはないので似ているとは言い難いのですが、クラスに記述するのは具体的な処理です。例えばSFMLを使ってウィンドウを400x400にするとかべた書きします。別のクラスの部品として使えるように、コンストラクタの引数などで何か調整できたりするようにすることは基本的には考えません。

もし何も考えずにGameクラスにあらゆる状態と処理を詰め込んでいけば当然一つの巨大なクラスが出来上がります。このような何でもかんでも詰め込んでできあがるのはThe BlobあるいはGod Class (神クラス)と呼ばれるものになるでしょう。実際にどのようなコードになるのか興味はありますが、それでは脳みそにかかる負担を小さくするという目的が達成されません。できるだけ技巧的にはならないように気をつけつつ、ダメなコードになるのが分かりきった部分は最初からそのようには書かないようにしていく必要があります。Gameクラスに関して言うなら、全てをGameクラス一つに放り込むようなことはせず、部品に分割していくことになるかと思います。一方で最初から過剰に完璧な設計にしようなどとは考えません。とりあえず進めてみるというやり方は曲げません。その辺のさじ加減がちょっと難しいところです(まだ何もしてないですが)。

もし呼び名を付けるとすると「とりあえずのクラス」といったところでしょうか。

SFMLのウィンドウをGameクラスに放り込む

最初のステップとして、前に作ったHello SFMLのコードを元に、SFMLのウィンドウをGameクラスで管理できるようメンバにすることを検討してみます。

// 最初のバージョン
#include <SFML/Graphics.hpp>

class Game {
  sf::RenderWindow window_;
};

int main() { Game game; }

このコードは何も目に見える結果は得られませんが、コンパイルして実行することができます。注目しておきたいのは、sf::RenderWindowのオブジェクトをポインタではなくオブジェクトとして持たせることができるということです。以前のHello SFMLでは

sf::RenderWindow window(sf::VideoMode(200, 200), "Hello SFML!");

のようにコンストラクタの引数でウィンドウのサイズとタイトルを指定しました。実際には4つの引数を取るのですが、3つ目と4つ目にはデフォルト引数が設定されているため省略できます。SFMLのドキュメントを見ると、この形式のコンストラクタを呼び出すと「ウィンドウを作成する」と書かれています。「ウィンドウの作成」が正確にどういう意味なのかまでは書かれていないのですが、動作を確認する限り、表示まで行うと考えて良さそうです。つまりこのコンストラクタ呼び出しから戻ってきた時点でウィンドウが表示された状態になっているということです。

もし、sf::RenderWindowがこの形式のコンストラクタしか持っていないとしたら、必ずオブエジェクトを定義した時点でウィンドウが表示されるのを受け入れるしかありません。その場合、Gameクラスは次のようなコードにする必要があることになります。

// 2番めのバージョン
#include <SFML/Graphics.hpp>

class Game {
public:
  Game() : window_{sf::VideoMode{400, 400}, ":("} {}

private:
  sf::RenderWindow window_;
};

別に今のところこれでも問題なさそうですが、現実的に考えるとウィンドウの表示に先立ってやっておきたいことは画像や音のロードなど色々あるでしょう。それらの処理もやはりGameクラスが起動することになります。もしGameクラスのオブジェクトを定義した時点でウィンドウが作成されてしまうのではその順序を管理するのがいかにも面倒になります。

sf::RenderWindowには引数を取らないコンストラクタであるデフォルトコンストラクタもあります。SFMLのドキュメントによると、sf::RenderWindowのデフォルトコンストラクタは実際にはウィンドウを作成しないので、 create関数で作成するようにと書かれています。

最初のバージョンのclass Game { sf::RenderWindow window_; };のコードについて解説すると、このクラスには明示的にGame::Game()のようなコンストラクタを定義していないので、コンパイラが自動的にデフォルトコンストラクタを生成します。生成されるのは Game::Game() {}のような、本体が空のコンストラクタです。Game game;のようにGameクラスのオブジェクトを作成すると、先のデフォルトコンストラクタが呼び出されます。本体が空だから何もしないという訳ではなく、メンバオブジェクトのデフォルトコンストラクタがクラス定義での宣言順に呼び出されます。つまり、sf::RenderWindowのデフォルトコンストラクタであるsf::RenderWindow::RenderWindow()が呼び出されます。もしsf::RenderWindowにデフォルトコンストラクタがなかったら最初のバージョンのコードはコンパイルできません。

なぜsf::RenderWindowがデフォルトコンストラクタを持つことに意味があるかと言うと、もしウィンドウを作成しないデフォルトコンストラクタを持たず、かつGameクラスのコンストラクタではまだウィンドウを作成したくないという状況だった場合に、Gameクラスが保持するsf::RenderWindowはポインタにするしかなかったからです。

// 3番目のバージョン
#include <SFML/Graphics.hpp>
#include <memory>

class Game {
public:
  void init() {
    window_ = std::make_unique<sf::RenderWindow>(sf::VideoMode{400, 400}, ":-|");
  }

private:
  std::unique_ptr<sf::RenderWindow> window_;
};

int main() {
  Game game;
  game.init();
}

std::unique_ptrに目がいったしまったとしても、詳しく知らなかったら今のポイントはそこではないので、あまり気にしないで下さい。簡単に言うとスマートポインタの一つで、排他的な所有権を持つことを保証します。つまりメモリの破棄を他の誰でもない自分が行うということを保証します。スマートポインタについてはまた別の機会に書きたいと思います。

これでもまあそんなに悪くはないような気がしますが、最初のバージョンの方がいくらかシンプルで良い気がします。最初のバージョンのwindow_はGameクラスのオブジェクトとして存在しているので、Gameクラスの解体時に自動的に破棄されます。どちらにせよ選択肢があるのはいいことです。

今回は最初のバージョンを使うことにして、ウィンドウの作成までのコードを書いておきます。

// 最初のバージョンにウィンドウ作成までを追加
#include <SFML/Graphics.hpp>

class Game {
public:
  void init() { window_.create(sf::VideoMode{400, 400}, ":)"); }

private:
  sf::RenderWindow window_;
};

int main() {
  Game game;
  game.init();
}

一応これでGameクラスにSFMLのウィンドウを放り込むという目標は達成したので次に進みます。

Gameクラスにメインループを加える

以前どこかでメインループは次のような処理を行うと書きました。

ループ:
  入力を処理する...
  ゲームを更新する...
  描画する...

これらをそのまんまGameクラスに追加してみます。

#include <SFML/Graphics.hpp>

class Game {
public:
  void init() { window_.create(sf::VideoMode{400, 400}, ":)"); }

  void main_loop() {
    while (1) {
      process_input();
      update();
      render();
    }
  }

private:
  void process_input() {}
  void update() {}
  void render() {}

private:
  sf::RenderWindow window_;
};

int main() {
  Game game;
  game.init();
  game.main_loop();
}

単にwhileループで無限にループしているだけでなのでこのままでは終了できません。他にも気になるところを挙げておきます。

こんなところでしょうか。結構多いです。順番にやっていきます。

ウィンドウの✘ボタンがクリックされたらループを抜けるようにする

✘ボタンのクリックはウィンドウのイベントとして取得することが出来ます。イベントの処理をどこに書くかについてprocess_input関数の中に書けないかを考えてみます。✘ボタンのクリックはユーザーの入力とみなしてもさほど違和感はないですが、ウィンドウのイベント全てを入力とみなすのにはやや抵抗があります。SFMLが検出するイベントはほぼユーザーからの入力に関するものです。一部ウィンドウがフォーカスされたときのイベントやフォーカスを失った時に発生するイベントなんかは入力として扱うのは不適切かもしれません。process_inputの名前をprocess_eventsにすると違和感は解消されます。最終的にはゲームが本当に扱う入力の処理と、ウィンドウのイベントを処理する部分は分離するのが良いでしょう。今の段階では名前をproces_eventsに変えるという応急処置で対処します。

void process_events() {
  sf::Event event;
  while (window_.pollEvent(event)) {
    if (event.type == sf::Event::Closed) {
      window_.close();
    }
  }
}

sf::EventについてはSFMLのドキュメントよりもヘッダーも見たほうが理解が進むかと思います。より丁寧な解説もありました。 pollEvent関数はsf::Windowから継承されたものです。pollEventというは名前はおそらくSDLのSDL_PollEvent関数に習ってつけられたものではないでしょうか。使い方もSDLのイベントの処理と似ているので、もし以前書いたSDLのイベント処理コードに慣れていたら何をやっているかはすぐに想像できると思います。

もしウィンドウに何かイベントが発生したらeventのtypeというデータメンバにそのイベントの種類が入れられて返ってきます。✘ボタンが押されたら、あるいはAlt+F4などとにかくウィンドウが閉じられようとしたとき、sf::Event::Closedという値が入っていますので、もしそうだったら実際にウィンドウを閉じるためにclose関数を呼び出します。

即座にウィンドウを閉じてしまうのはちょっと乱暴な処理にも思えます。ウィンドウを閉じる前に何かやり残していることがありそうな気がします。今の段階ではとりあえずこのままにしておきます。

close関数を呼び出しでウィンドウは閉じられます。しかし、まだまだwhileループの中から抜け出ていません。whileの条件は1なのでウィンドウが閉じられようが爆発しようが関係なくループし続けます。興味深いのはウィンドウが閉じられていてもpollEventによるイベント処理はそのまま動作し続けるところです。クラッシュしたりしません。

ともかく、ウィンドウが閉じられたらwhileループを終了してメインループから抜け出るようにします。

void main_loop() {
  while (window_.isOpen()) {
    process_input();
    update();
    render();
  }
}

isOpen関数を使ってウィンドウが閉じらているかどうかチェックできます。これは便利です。ちょっと気になるのは、今のままの作りだとウィンドウが閉じられたそのフレームはupdate関数とrender関数がそれぞれ一度だけ実行されてしまう点です。実害はないでしょうがあまり気持ちのいいものではないです。またいつか直すものと記憶しておきます。

一応これでプログラムを終了できるようになりました。

背景を何かの色で塗りつぶすようにする

sf::RenderWindowにはclearという関数があります。注目しておきたいのはsf::Windowのメンバ関数ではないという点です。この関数はsf::RenderTargetから継承したものです。sf::RenderWindowはsf::Windowだけでなくsf::RenderTargetも継承しています。多重継承です。SFMLのドキュメントのダイアグラムを見るとイメージしやすいです。そんなに複雑じゃないです。

sf::RenderWindow::clear関数を使えば塗りつぶしは簡単に出来ます。描画に関する処理はrender関数の中で行うことにします。

void render() {
  window_.clear({0, 0, 255});
  window_.display();
}

青で塗りつぶすようにしました。sf::RenderWindow::clear関数の引数はsf::Colorクラスです。sf::Colorクラスのコンストラクタには(red, green, blue, alpha)の4つを引数に取るものがあります。この内alphaにはデフォルトとして255(完全にに不透明)が設定されているので省略できます。上の{0, 0, 255}という書き方はColor{0, 0, 255}とした場合と同じです。clearの引数がsf::Colorなのは明らかなのでそのような書き方が出来ます。また、clear関数の引数にもデフォルトとして黒が設定されていて引数省略することが出来ます。window_.clear();と書いた場合黒で塗りつぶされます。

display関数はsf::Windowから継承したものです。この関数を呼び出すことでwindowに描画されたものが画面に表示されます。ウィンドウに描画するというのは以前のSDLでの経験からすると奇妙に感じられます。しかし、このウィンドウはsf::RenderTargetから派生しているsf::RenderWindowであり、clear関数などは内部的にはおそらくsf::RenderTargetのバックバッファに描画しています。そしてdisplay関数はそのバックバッファと入れ替えることで画面に反映させているものではないかと推測します。

何も描画されないのはつまらないので●を描画する

簡単そうですがあちこち書き換えないといけないです。まずGameクラスに円を表現するオブジェクトを持たせます。

class Game {
...
private:
  sf::RenderWindow window_;
  sf::CircleShape circle_;
};

sf::CircleShapeというクラスが名前のまんま、円の図形を描画するのに使えます。次にinit関数で円の設定をします。Gameクラスのコンストラクタでやってもいいのですが、ウィンドウより先に初期化するのもおかしな感じがするのでウィンドウより後にするためにinit関数の中でやります。

class Game {
public:
  void init() {
    window_.create(sf::VideoMode{400, 400}, ":)");
    circle_.setRadius(50);
    circle_.setOrigin(circle_.getRadius(), circle_.getRadius());
    circle_.setPosition(window_.getSize().x / 2, window_.getSize().y / 2);
    circle_.setFillColor({0, 255, 255, 128});
  }
...
};

半径50の円をウィンドウの中央に配置するためのコードです。分かりづらいのがsetOrigin関数でしょうか。上のコードでもしsetOrigin関数を省略すると、円はウィンドウの中央ではなく、右下よりに描画されます。setPosition関数で位置を設定しているのですが、値はウィンドウのサイズの半分(200, 200)です。デフォルトでは円の原点が円を囲む矩形の左上隅(0, 0)になっています。したがって、circle_の位置を(200, 200)としたとき、円を囲む矩形の左上隅がウィンドウの中央(200, 200)に位置するようになり、円の中心は(200+50, 200+50)になるので、画面中央より左下に位置することになります。setOrigin関数には円の中心が原点となるように円を囲む矩形の左上からの距離となる(50, 50)をセットして調整しています。

最後に描画するコードをrender関数に追加します。

class Game {
...
private:
  void render() {
    window_.clear({0, 0, 255});
    window_.draw(circle_);
    window_.display();
  }
};

追加したのはsf::RenderWindow::draw関数の呼び出しだけです。位置や色はcircle_自身が持っているので何も追加の情報は必要ありません。このdraw関数はsf::RenderWindowの目玉と言って良いのではないかと思います。引数として取るのは抽象クラスである sf::Drawableで、sf::CircleShapeはsf::Drawableの派生クラスです。他にもいくつかSFMLが用意しているsf::Drawableの派生クラスがあり、原始的なゲームを作るには事足ります。必要ならばsf::Drawableの派生クラスとして独自のクラスを作ることでdraw関数で描画することができるようになります。これはかなり柔軟で使いやすいのではないかと思います。

でもまずは用意されているものを使っていって不十分に感じるようになったときにそうすることにします。

で、とりあえず●の描画は出来ました。

キーボードの方向キーで●が移動するようにする

まずどのようにしてキーボードの入力を取得するかを考えます。SDLのときにやったのと同じような方法が2つすぐに見つかりました。

  1. イベントのKeyPressedとKeyReleasedを処理する。
  2. sf::Keyboard::isKeyPressed関数を使う。

今回は1のイベントの方を採用することにします。KeyPressedとKeyReleasedは定数値(正確には列挙体の列挙子)です。✘ボタンが押されたときsf::Eventのオブエジェクトのデータメンバtypeにsf::Event::Closedがセットされたように、キーが押されたときにtypeにsf::Event::KeyPressedがセットされ、キーが離されたときにtypeにsf::Event::KeyReleasedがセットされています。さらに、evenのtypeがKeyPressedとKeyReleasedのときには、event.key.codeに押されたあるいは離されたキーのコードが入っています。キーのコードはsf::Keyboard::Keyという列挙体で定義されています。例えば、Aというキーかどうか調べるにはevent.key.codeがsf::Keyboard::Key::A(一部省略してsf::Keyboad::AとしてもOKです)と等しいかどうかチェックします。

void process_events() {
  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::Left: break;  // 左に移動するように調整する
      case sf::Keyboard::Right: break; // 右に移動するように調整する
      case sf::Keyboard::Up: break;    // 上に移動するように調整する
      case sf::Keyboard::Down: break;  // 下に移動するように調整する
      default: break;
      }
    } else if (event.type == sf::Event::KeyReleased) {
      switch (event.key.code) {
      case sf::Keyboard::Left: break;  // 左への移動を中止するように調整する
      case sf::Keyboard::Right: break; // 右への移動を中止するように調整する
      case sf::Keyboard::Up: break;    // 上への移動を中止するように調整する
      case sf::Keyboard::Down: break;  // 下への移動を中止するように調整する
      default: break;
      }
    }
  }
}

上のコードは冗長です。同じようなswitch文が2回出てきています。そこはすぐあとで直すとして、まずGameクラスにキーの入力があったことを何らかの状態として保持するようにする必要がありますので、メンバ変数として移動の向きを表す2Dベクトルを持たせます。そして移動の量を調整するために速さを表すスカラーもメンバ変数として持たせます。そしてそれぞれinit関数で適当な値に初期化します。

class Game {
public:
  void init() {
    ...
    move_dir_ = {0, 0};
    move_scale_ = 0.02f;
  }

  ...

private:
  sf::RenderWindow window_;
  sf::CircleShape circle_;
  sf::Vector2f move_dir_;
  float move_scale_;
};

ベクトルについてはブロック崩しのところで軽く触れましたので、今回は繰り返し触れないでおきます。SFMLにはベクトルのクラスが用意されています。ただし、あまり機能は豊富ではありません。特にブロック崩しのときと同じような作りにしたいのですが正規化する機能がないのでそれだけはすぐあとで書くことにします。スカラーとの乗算はありますので自作しなくても大丈夫です。

移動の速さを表すmove_scale_の値を0.02fという謎の値にしているのは、実際に動かしてみてこの程度で丁度よかったからというだけです。move_scale_の値の意味は「1フレーム当たりの移動量」です。もし100FPSだったら1秒間に2単位動くことになるでしょう。かなり小さめな値になっている理由は、現時点ではフレームレートの調整を行っていないため、ものすごい速さでループが回るためもっと高いFPSになっているからです。

移動の向きを状態として保持することが出来たので、先のキー入力のところで向きを更新する処理を追加することにします。加えて冗長だったswitch文も一つにまとめるように手を加えます。

  void process_events() {
  sf::Event event;
  while (window_.pollEvent(event)) {
    if (event.type == sf::Event::Closed) {
      window_.close();
    } else if (event.type == sf::Event::KeyPressed ||
               event.type == sf::Event::KeyReleased) {
      int d = event.type == sf::Event::KeyPressed ? 1 : 0;
      switch (event.key.code) {
      case sf::Keyboard::Left:
        move_dir_.x = -d;
        break;
      case sf::Keyboard::Right:
        move_dir_.x = d;
        break;
      case sf::Keyboard::Up:
        move_dir_.y = -d;
        break;
      case sf::Keyboard::Down:
        move_dir_.y = d;
        break;
      default:
        break;
      }
    }
  }
}

int d = event.type == sf::Event::KeyPressed ? 1 : -1;のところは三項演算子あるいは条件演算子と呼ばれるものによる条件式を使っています。x ? a : b という式が評価されるとその値はxがtrueならa、falseならbとなります。式なので結果を変数に代入することが出来ます。ifを使って書くことも出来ます。

int d;
if (event.type == sf::Event::KeyPressed) {
  d = 1;
} else {
  d = -1;
}

条件式を使うかどうかは嗜好によるところもありますが、個人的にはこの部分は条件式を使った方が読みやすく感じます。

==による比較演算の結果はboolですが、boolからintへの変換はtrueなら1にfalseなら0になるので次のように書くことも出来ます。

int d = event.type == sf::Event::KeyPressed;

しかし、ひと目で0が入るのか1が入るのか判断できるかどうかを考えると、あまり良いコードには思えません。

さらに条件式を使ってswitch文を排除してしまうような書き方もできますがそれはやりすぎだと思うのでやりません。ただ、このif (..||..)の中の条件でevent.typeをチェックした直後にすぐまた条件式でevent.typeに対して判定を行っている部分はまだ冗長に感じられるのは確かです。

ついでなのでもう一つ気がかりなこと言うと、input_eventsというイベントを処理するべき関数の中で移動の向きというある種のゲームの状態を更新してしまっているところもなんか引っかかります。できるなら状態の更新はupdate関数の中で行いたいところです。頑張ればなにか良い方法が思いつくかもしれませんが、今はこれ以上模索しないでおきます。

もう一つ注意することとして、KeyPressedイベントなのですが、このイベントはキーが押された瞬間に1回だけ発生するのではなく、キーが押され続けていれば繰り返し発生します。一方KeyReleaseイベントはキーが離されたその瞬間に1回だけ発生します。最初KeyPressイベントは1回だ発生するものと勘違いして次のようなコードを書いていました。

// ダメなコード
int d = event.type == sf::Event::KeyPressed ? 1 : -1;
switch (event.key.code) {
case sf::Keyboard::Left:
  move_dir_.x -= d;
  break;
case sf::Keyboard::Right:
  move_dir_.x += d;
  break;
case sf::Keyboard::Up:
  move_dir_.y -= d;
  break;
case sf::Keyboard::Down:
  move_dir_.y += d;
  break;
default:
  break;
}

このコードではうまくいきません。上キーをしばらく押していると、キーを離しても上に移動し続けることになります。

次にupdate関数で移動の向きを元に●の位置を更新します。

void update() { circle_.move(normalized(move_dir_) * move_scale_); }

move関数(std::moveではないです)は与えられた分の量だけ現在の位置からの相対位置に移動します。引数として2Dベクトルであるsf::Vector2fを渡していて、これが移動量です。別の言い方をするとオフセットとともいいます。sf::Vector2fにはスカラーとの掛け算が定義されていて、演算子*を適用することが出来ます。移動の向きを正規化した2Dベクトルに速さを掛けたので移動量が得られます。ブロック崩しでは、ボールの位置を整数値で保持していたのでroundで四捨五入する必要があったのですが、sf::CircleShapeなどは位置を浮動小数点数の値で保持しているので整数型への変換はなく、そのままセットしても問題はおきないでしょう。

最後に正規化する関数を書いておきます。

#include <cmath>
#include <limits>

sf::Vector2f normalized(const sf::Vector2f &v) {
  float magnitude = std::sqrt(v.x * v.x + v.y * v.y);
  if (magnitude < std::numeric_limits<float>::epsilon()) {
    return {0, 0};
  }
  return {v.x / magnitude, v.y / magnitude};
}

以前作ったものよりも少し複雑になっています。引数のベクトルがxとy両方ともほぼ0になることがあります。キーが入力されてなくて移動していなかった場合そうなります。そのとき、ベクトルの大きさもほぼ0になります。そうすると0で割り算をすることになってしまい結果はNaN (Not a Number)という特別な値になり浮動小数点数値の通常の演算ができなくなってしまいます。なので、ベクトルの大きさがほぼ0だった場合は特別にxとyがともに0であるゼロベクトルを返すことにしています。なぜブロック崩しでは必要なかった(というか見落としていた)かというと、ブロック崩しではボールが常に移動の向きを持っていて0になることがなかったので問題が発現しなかったからです。今回は移動の向きが0になりうるので絶対必要です。

フレームレートを60FPSにする

今回もまた手抜きをします(いつになったら真面目に取り組むのだろう…)。ウィンドウクラスにsetFramerateLimit関数というものが存在します。使い方は極めて簡単で、例えばwiddow_.setFramerateLimit(60);とどこかに書くと、window_.display()のところで60FPSになるように調整を行ってくれます。しかし、ドキュメントに書かれているように完璧ではありません。内部的にはsf::sleepをすることによって調整しているだけのようなので、sleepの精度はOSに依存しあまり正確でもありません。なので、精密なゲームを作る場合には利用を控えて独自に作る必要が出くるかと思われます。今は精密さは求めていないので堂々とsetFramerateLimit関数を使います。

60FPSに制限するためにsetFramerateLimit(60)と書くのですが、その場所はinit関数ではなくmain_loop関数のwhileループに入る直前に書くことにします。

void main_loop() {
  window_.setFramerateLimit(60);
  while (window_.isOpen()) {
    process_events();
    update();
    render();
  }
}

追加したハイライト部分の変更だけでrender関数の中で呼び出しているwindow_.displayに制限がかかり最大60FPSになるよう調整が入ります。

setFramerateLimit関数によってフレームレートを1秒間あたり60フレームに制限をかけたので、理想的には全てのフレームが1/60秒、つまり0.01666..秒(およそ16ミリ秒)になることが期待されます。しかし、先に書いたとおりsetFramerateLimit関数は完璧ではありませんし、もし処理に時間がかかりすぎて60FPSを下回る場合、あるフレームで処理が1/60秒より長くかかってしまった場合、処理に乱れが発生してしまいます。

例えば、60FPSを期待しているのに、処理に30FPSである1/30秒の時間がかかってしまっていたとします。移動の速さが仮に100だったとすると、60FPSのときは100x60で1秒間に600移動します。30FPSのときは100x30で300しか移動しません。ここまで極端に遅くなることは現時点ではないかもしれませんが、setFramerateLimit関数が完璧でないのでわずかな誤差が出ることを想定しておく必要があります。そこで、前回のフレーム処理にかかった時間を計測して、update関数にかかった時間を渡して、update関数はその時間をもとに調整を行うようにします。

SFMLには時間を扱う便利なユーティリティクラスが用意されています。時間を表現するのはsf::Timeクラスで、時間を計測するのはsf::Clockクラスです。C++の標準ライブラリにも時間を扱う chronoライブラリがありますが、SFMLのものの方が使い方が簡単でSFMLとの相性も良いです。なのでSFMLのを使います。

void main_loop() {
  window_.setFramerateLimit(60);
  sf::Clock clock;
  while (window_.isOpen()) {
    auto elapsed_time = clock.restart();
    process_events();
    update(elapsed_time.asSeconds());
    render();
  }
}

sf::Clockのオブジェクトを作成すると、すぐに自動的に計測が開始されます(時計が回り始めます)。whileループの先頭でrestart関数を呼んでいます。restart関数は時計を0にリセットすると同時に、これまでかかった時間、オブジェクトが最初に作成されたときからか前回のリセットからかの経過時間を戻り値として返してくれます。戻り値の型は上ではautoとしていますが、実際にはst::Time型です。sf::Timeには時間を足したり引いたり比較したりする便利な関数が色々用意されています。秒単位での時間の値を返してくれるasSeconds関数は今ちょうど欲しかったものです。asSeconds関数はfloatの値で返してくれます。もし60FPSを維持できていたら、0.01666..に近い値が入っているでしょう。その値をupdate関数に渡しています。

update関数も前フレームからの経過時間を使って移動を調整するようにします。

void update(float delta_time) {
  circle_.move(normalized(move_dir_) * move_scale_ * delta_time);
}

引数にはdelta_time(デルタタイム)という名前がよく使われますので慣習に合わせています。世にも不思議なことに前回からの経過時間を秒として表現した値をかけてやるだけでうまくいきます。数字の魔法です。しかし、もう移動の速さはフレーム単位の移動量ではなく秒単位の移動量に修正する必要があります。この速さに秒をかけているので速さ✕時間で経過した時間だけの移動量、つまりそのフレームの移動すべき量が得られます。移動の速さはinit関数でセットしていました。今のままでは小さすぎるので適当な値に修正します。

void init() {
  ...
  move_dir_ = {0, 0};
  move_scale_ = 100.0f;
}

これで矢印キーを押し続けると1秒あたり100ピクセル移動するようになりました。

メインループはこれで終了です。

終了処理を加える

ウィンドウが閉じられてメインループを抜けた後に、後始末をします。と言っても今のところ何もすることがありません。一応形だけ取り繕うために空の処理を追加しておきます。

class Game {
public:
  ...
  void cleanup() {}
  ...
};

int main() {
  Game game;
  game.init();
  game.main_loop();
  game.cleanup();
}

起動用の関数を追加する

main関数でinit、main_loop、cleanupと決まった手順で呼び出さなければいけないのは見苦しいです。順番を入れ替えるとおかしなことになります。runという起動用のインターフェイスだけを公開にして、他の関数はその中で呼び出すことにします。

class Game {
public:
  void run() {
    init();
    main_loop();
    cleanup();
  }

private:
  void init() { ... }
  void main_loop() { ... }
  void cleanup() { ... }

  ...
};

int main() {
  Game game;
  game.run();
}

これで完成にします。

全てをまとめる

今までのコードを全部貼り付けておきます。Gameクラスは別のヘッダとソースにファイル分けるほうが良いのですが見づらくなると思われるので一つのファイルにまとめたような形で貼り付けておきます。

#include <SFML/Graphics.hpp>
#include <cmath>
#include <iostream>
#include <limits>
using std::cout, std::endl;

sf::Vector2f normalized(const sf::Vector2f &v) {
  float magnitude = std::sqrt(v.x * v.x + v.y * v.y);
  if (magnitude < std::numeric_limits<float>::epsilon()) {
    return {0, 0};
  }
  return {v.x / magnitude, v.y / magnitude};
}

class Game {
public:
  void run() {
    init();
    main_loop();
    cleanup();
  }

private:
  void init() {
    window_.create(sf::VideoMode{400, 400}, ":)");
    circle_.setRadius(50);
    circle_.setOrigin(circle_.getRadius(), circle_.getRadius());
    circle_.setPosition(window_.getSize().x / 2, window_.getSize().y / 2);
    circle_.setFillColor({0, 255, 255, 128});
    move_dir_ = {0, 0};
    move_scale_ = 100.0f;
  }

  void main_loop() {
    window_.setFramerateLimit(60);
    sf::Clock clock;
    while (window_.isOpen()) {
      auto elapsed_time = clock.restart();
      process_events();
      update(elapsed_time.asSeconds());
      render();
    }
  }

  void cleanup() {}

  void process_events() {
    sf::Event event;
    while (window_.pollEvent(event)) {
      if (event.type == sf::Event::Closed) {
        window_.close();
      } else if (event.type == sf::Event::KeyPressed ||
                 event.type == sf::Event::KeyReleased) {
        int d = event.type == sf::Event::KeyPressed ? 1 : 0;
        switch (event.key.code) {
        case sf::Keyboard::Left:
          move_dir_.x = -d;
          break;
        case sf::Keyboard::Right:
          move_dir_.x = d;
          break;
        case sf::Keyboard::Up:
          move_dir_.y = -d;
          break;
        case sf::Keyboard::Down:
          move_dir_.y = d;
          break;
        default:
          break;
        }
        cout << "KEY " << d << endl;
      }
    }
  }

  void update(float delta_time) {
    circle_.move(normalized(move_dir_) * move_scale_ * delta_time);
    cout << "⊿T " << delta_time << endl;
  }

  void render() {
    window_.clear({0, 0, 255});
    window_.draw(circle_);
    window_.display();
  }

private:
  sf::RenderWindow window_;
  sf::CircleShape circle_;
  sf::Vector2f move_dir_;
  float move_scale_;
};

int main() {
  Game game;
  game.run();
}

実行してみます。

あまり面白いものではないです。とりあえずSFMLを使うに当たっての最初のステップはクリアできたものとしておきます。

コーディングスタイルについて

ちょっと前からGeanyのclang-formatのプラグインを使うようになったので最初の頃とコーディングのスタイルが変わっています。.clang-formatというファイルにオプションを記述することで最初のコーディングスタイルに合わせることも簡単にできるのですが(多くの場合そうするでしょう)、そうしないことにしました。気分が変わらなければ、しばらく何も設定せずにデフォルトのLLVMのスタイルを使っていくことにします。これよる変更点と気になる点は以下のとおりです。

全てオプションで変更可能です。