Code of Poem
Unknown programmer's programming note.

pcat (4) ステージの画面スクロール

前回ステージの最初の1画面分だけ作りました。次はステージ一つ分スタート地点からゴールまでオブジェクトを全部配置していきます。さらに画面をスクロールできるようにしてステージ全体を目で確認できるようにします。

画像とタイルマップの作成

まずタイルセットとなる画像に欠けている土管を追加しておきます。

pcat 4 tileset

画像のサイズは前と同じ160x160ピクセルです。背景は透過してあります。本当なら足場になるブロックも必要なのですが、壊せないブロックで代用しておきます。ファイル名はpcat4_tileset.pngです。

次にTiledでステージ1-1分をマリオのを真似て作ります。

pcat 4 tiled screenshot

これをCSV形式でエクスポートします。貼り付けるにはちょっと表示幅がでかいのでこちら(pcat4_tilemap.csv)においておきます。

main関数のファイルをロードする部分のファイル名を変更します。

int main() {
  ...
  sf::Texture tileset;
  tileset.loadFromFile("pcat4_tileset.png");
  MapItemFactory map_factory{tileset, {32, 32}};
  auto map_items = make_map_from_csv_file("pcat4_tilemap.csv", map_factory);
  if (!map_items.has_value()) {
    return 1;
  }
  ...
}

実行すると次のようになります。

pcat 4 running 1 screenshot

これが起動時の画面になります。ゲームのデザインとしては、初期状態ではブロックを見せずに何もない画面から開始したほうがプレイヤーに対する期待感を高める効果が高いように思えます。つまり、このステージはどんなんだろう?という気持ちが湧く、ということです。しかし、今それやってしまうと初期状態が何もない真っ青な画面になってしまって分かりづらいので意図的に見せてあります。

また一つ注目する点があります。画面に収まらないブロックや地面などが含まれていてもプログラムがちゃんと動いているということです。範囲外アクセスでクラッシュしたりしないです。本来なら画面外のマップアイテムは無視して描画しないようにするべきです。そのようなコードを書かなくても一応は動いています。最終的にはちゃんと自前で処理するべきですが、今はこの動作は都合が良く便利なのでこのまま進めます。

カメラについて考える

右の矢印キーで画面をスクロールできるようにします。左と上下は無視します。というのはオリジナルのマリオに右方向のみスクロールしかないためです。

スクロールできるようにするには、まず現在のマップのどの位置を注目しているかを保持する必要があります。簡略化すると次のようにかけるでしょう。

sf::Vector2i pos{0, 0};
MapItem item;
draw(item.x + pos.x, item.y + pos.y);

要は今どの位置にいるかを表す座標の情報があればいいだけです。上の場合、posは映し出す部分の左上の位置を保持させています。

これを管理するものをカメラ(Camera)という名前にして導入することを検討してみます。ちょうど現実のカメラが注目している点に合わせてそこに映る矩形の領域のみを映し出すように、ゲームのカメラも注目する点に合わせてゲームのスクリーンに映し出す矩形を管理するからです。

適当に次のようなクラスを書いてみました。

class Camera {
 public:
  Camera(const sf::Vector2f &screen_size, const sf::Vector2f &position = {0, 0});
  void move(float dx, float dy);
  sf::Vector2f world_to_screen(const sf::Vector2f &world) const;
  sf::Vector2f screen_to_world(const sf::Vector2f &screen) const;

 private:
  sf::Vector2f screen_size_;
  sf::Vector2f position_;
};

Camera::Camera(const sf::Vector2f &screen_size, const sf::Vector2f &position)
    : screen_size_{screen_size}, position_{position} {}

void Camera::move(float dx, float dy) {
  position_.x += dx;
  position_.y += dy;
}

sf::Vector2f Camera::world_to_screen(const sf::Vector2f &world) const {
  return world - position_;
}

sf::Vector2f Camera::screen_to_world(const sf::Vector2f &screen) const {
  return screen + position_;
}

このクラスの主な使いみちは、

の2つです。screen_to_world関数はおまけです。

さて、このクラスを使ってMapItemを描画するときにカメラの位置を反映させるようにしたいわけですが、一筋縄ではいきません。まず、個々のMapItemのオブジェクトは、カメラのオブジェクトを参照する必要があります。この時点でどのようにしてCameraへの参照するかという厄介な判断に迫られます。

Cameraオブジェクトの参照を仮にMaitItemのメンバとして保持できたとします。ここでもう一つ厄介な問題にぶち当たります。今、MapItemの描画用の関数は次のようになっています。

void MapItem::draw(sf::RenderTarget &target, sf::RenderStates states) const {
  target.draw(sprite_, states);
}

このMapItem::draw関数のシグネチャに注目するとconstとなっています。この関数はsf::Drawableのdraw関数をオーバーライドしたものでこの関数をオーバーライドすると、renderTarget::draw関数の引数として渡すことで、描画できるというものです。具体的には、main関数のメインループで次のようにしています。

for (const auto &item : map_items.value()) {
  window.draw(item);
}

このスタイルは維持したいので、MapItemがsf::Drawableを継承するという形は崩したくありません。すると、MapItem::draw関数からconstを取り除くのは無理です。constであると何が問題であるかと言うと、sprite_といのはsf::Spriteのオブジェクトでメンバ変数として保持しているので、メンバ変数の非const関数は使えないということです。したがって、次のように書くことは出来ません。

void MapItem::draw(sf::RenderTarget &target, sf::RenderStates states) const {
  sprite_.setPosition(camera_.world_to_scree(position_)); // const関数なのでsetPositionが使えない!
  target.draw(sprite_, states);
}

この問題はちょっと厄介です。簡単な解決策としてはupdate関数のようなものを用意して、メインループを回すたびにカメラからスクリーン座標を計算してスプライトの座標を更新するという方法が考えられます。しかし、動かない地面やブロックといったマップアイテムにupdate関数を用意するのは決断が早すぎる気がします。

なにかいい方法はないかと調べていたところ、ビューという素晴らしい機能がSFMLには用意されていました。

ビューを使う

SFMLにはビューというのがあります。その名もsf::Viewです。

このビュークラスを使うとものすごく簡単にスクロールが実現できます。使い方も極めて直感的です。前回のコードからmain関数をちょっと書き換えるだけでスクロールできるようになります。

#include <SFML/Graphics.hpp>
#include <SFML/Graphics/RenderWindow.hpp>
#include <SFML/Graphics/Sprite.hpp>
#include <SFML/Graphics/Texture.hpp>
#include <SFML/Graphics/View.hpp>
#include <SFML/Window/Keyboard.hpp>
#include <SFML/Window/VideoMode.hpp>
#include <iostream>

#include "camera.h"
#include "mapitem.h"

const int TileWidth = 32;
const int TileHeight = 32;
const int MapWidth = 16;
const int MapHeight = 15;
const int ScreenWidth = 512;   // 32 * 16
const int ScreenHeight = 480;  // 32 * 15

int main() {
  sf::RenderWindow window{sf::VideoMode{ScreenWidth, ScreenHeight}, "pcat"};

  sf::Texture tileset;
  tileset.loadFromFile("pcat4_tileset.png");
  MapItemFactory map_factory{tileset, {TileWidth, TileHeight}};
  auto map_items = make_map_from_csv_file("pcat4_tilemap.csv", map_factory);
  if (!map_items.has_value()) {
    return 1;
  }

  sf::View view{sf::FloatRect{0, 0, ScreenWidth, ScreenHeight}};
  window.setView(view);

  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();
        } else if (event.key.code == sf::Keyboard::Right) {
          // 右矢印キーが押されたら10ピクセル分スクロールする
          view.move(10, 0);
          window.setView(view);
        }
      }
    }

    window.clear(sf::Color{0, 0, 255});

    for (const auto &item : map_items.value()) {
      window.draw(item);
    }

    window.display();
  }
}

ビューの初期化には2つの方法が用意されています。

前者は、要はレンダリングする左上の座標と幅と高さを指定するもので、今回はそちらを使いました。プラットフォームゲームでは前者のほうが使いやすいです。上から見下ろすRPGのようなものでは後者の方が使いやすいかと思われます。

一つ気をつけなければいけないのが window.setView(view) としたとき、このwindowというかレンダーターゲットは、viewへの参照を保持するのではなくコピーを保持するということです。そのためview.move関数のようなビューを更新する関数を実行してもwindowには反映されません。再度 window.setView(view) を呼び出す必要があります。呼び出さなければ反映されません。ビューは軽いクラスなのでコピーは安価で、毎フレームsetViewしても大丈夫だそうです(とチュートリアルに書かれていました)。

これを実行すると次のようになります。

キー入力をイベントで取得しているのでカクつきますが、スクロールは出来てます。

ビューのおかげでわずか6行の修正でスクロールが出来ましたヽ(^o^)丿