🎮

【Siv3D】関数型×ゲーム開発に挑戦してみた~テトリス編~

2024/10/22に公開

はじめに

こんにちは! 関数型プログラミングや低レイヤーに興味があるMasayaです。以前からやりたかった関数型×ゲーム開発に挑戦してみました。

はじまりは夏に参加させていただいたSiv3D実装会(大阪開催)です。それぞれが自分の作業を進めながら交流するというイベントでした。そこでSiv3Dでテトリスを作るという作業テーマが生まれ、取り組むうちに今回の企画に発展していきました。

実装にかかった期間は1ヶ月、時間にすると25時間ほどです。特にUIシステムの設計にはかなり試行錯誤をしました。

この記事では具体的に関数型をどのようにゲーム開発に応用したのかについて紹介していきます。

作ったもの

実装

https://gist.github.com/M-simplifier/ee448bba107f2ddf646c44c32af41d0f

キーポイント

Data-Event-Render

ゲームを「それを本質的に表すデータ」と「毎フレーム入力されるイベント」と「データを描画するレンダー」として考え、更新関数を「データとイベントを引数に取り次のデータを出力する純粋関数」として設計するやり方を考えました。
こうすることでメインロジックに純粋関数の利点をもたせることができるだけでなく、データとレンダーの分離も実現できます。

// メインループ
while (System::Update())
{
    auto event = Event::getEvent();
    gameData = update(gameData, event);
    render(gameData);
}

宣言的UI

Reactを参考に宣言的な記述ができるよう工夫しました。
UIコンポーネントをコンテナスタイリングによって組み合わせてUIを書いていきます。

void ui(const Area& area, const Font& font, const Data::GameData& gameData) {
    Container::threeColumns(area,
        [&gameData, &font](const Area& area) {leftPanel(area, font, gameData); },
        [&gameData](const Area& area) {centerPanel(area, gameData); },
        [&gameData](const Area& area) {rightPanel(area, gameData); }
    );

    if (gameData.isGameOver) {
        gameOver(area, font, gameData.score);
    }
    else if (gameData.isPause) {
        pause(area, font);
    }
}

実装のポイント

Data

Dataの実装は簡単で、単にゲームを表現するデータを構造体にまとめているだけです。

struct GameData {
    Mino mino;
    Field field;
~中略~
    bool isPause;
    int32 bestScore;
};

Event

Eventも同様です。

struct Event {
    bool restart;
    bool togglePause;
~中略~
    bool fastDown;
    bool hold;
};

このように、イベントごとのフラグをまとめています。
今のフレームのEventを取得するgetEventの実装もかなり単純です。

Event getEvent() {
    Event event{};

    event.restart = KeyR.down();
    event.togglePause = KeyEscape.down();
~中略~
    event.fastDown = KeyDown.pressed();
    event.hold = KeyC.down();

    return event;
}

入力をそれぞれのイベントにマッピングしています。

Render

宣言的UI

Reactライクな記述を目指すため、以下の設計を採用しました。

  • UIはコンポーネントの組み合わせで記述する。
  • 各コンポーネントはAreaとその他固有の情報を引数に取り、呼び出し時に描画を実行する関数として定義する。
    • Areaは描画範囲の位置とサイズを持つデータ。
  • レイアウト用にコンテナが存在する。コンテナはAreaと二つ以上のコンポーネントを引数に取り、各コンポーネントに適切なAreaを振り分け実行させる。
  • コンポーネントの中央揃えなどのスタイリングは、AreaからAreaへの変換関数として定義される。
コンテナの実装例
template<ContainerChild T, ContainerChild B>
void twoRows(const Area& area, const T& top, const B& bottom) {
    Area topArea = Area{ {0, 0}, {area.size.x, area.size.y / 2} }.basedOn(area);
    Area bottomArea = Area{ {0, area.size.y / 2}, {area.size.x, area.size.y / 2} }.basedOn(area);

    top(topArea);
    bottom(bottomArea);
}
スタイリングの実装例
Area bottomAlign(const Area& area, int32 childHeight) {
    return Area{ {0, area.size.y - childHeight}, {area.size.x, childHeight} }.basedOn(area);
}

コンポーネント関数は描画を実行する関数でもあるため、末端のコンポーネントは手続き的に描画処理を書く必要があります。そのため完全には宣言的記述とはなっていません。
当初は各コンポーネントを評価すると抽象的なUIツリーとなり、それを描画システムがレンダーしていくという設計も考えたのですが、実装量を抑えるためにコンポーネント関数が直接描画を実行する形を採用しました。

描画処理の分離

コンポーネントからはDraw名前空間にある関数を呼び出して描画を実行し、Draw名前空間にある関数の内部実装でのみSiv3DのAPIを直接使用するようにしています。
例:(pauseはポーズ画面用のUIコンポーネント)

void pause(const Area& area, const Font& font) {
    Draw::overlay(area.origin, area.size);
    Draw::centeredText(font, Style::center(area, { area.size.x, 1 }).origin, area.size.x, U"PAUSE");
}

振り返り

作ってて本当に楽しかったです。自分が考えている通りの仕組み・設計が実際に動くコードとして具現化されていく過程はまさにプログラミングならではの楽しみだなと感じます。特にUIシステムを自分でしっかり作ったのは初めてだったので、最も時間がかかったと同時に最もワクワクできたポイントでした。

今回のことでC++への理解もかなり深めることができましたし、Siv3Dの便利な書き方(二重のforループを一つのforループにまとめられるところなど)を学ぶ機会にもなりました。

今後の展望

次は同じ方針のもとでRPGを作ってみようと考えています。より複雑なゲームでどの程度このアプローチが通用するのか試してみたいからです。うまく行った場合には、このまま汎用的なゲームエンジンにまとめ上げていくことも視野に入れています。

学習面としては今回のアプローチ方法がパフォーマンス上どのような作用を持っているのかを正確に評価できるようになるため、より低レイヤー方面の知識を強化していきたいと考えています。
関数型周りの様々な技術やアプローチでもっとゲーム開発に応用できるものがないかを詳しく知るため、そちらの学習や調査も深めていく予定です。

終わりに

オブジェクト指向を中心とした考え方が主流のゲーム開発の世界に、関数型の観点からアプローチするのは刺激的でした。次へ繋がる結果となったことも嬉しいです。

ここでは触れていない細かな工夫なども実装に盛り込んでいますので、気になる方はぜひともコード本文に目を通していただければと思います。

それでは、また他の記事でお会いしましょう。

Discussion