lumino trail

ゲームエンジン作ってます。Github:https://github.com/lriki/Lumino Twitter:https://twitter.com/lriki8

開発状況 2019 #9

関数型のゲームフレームワークってどうなんだろう?

少し前からアクションゲームを作り始めています。

f:id:lriki:20190930215536g:plain

アクションゲームと言えばRPGと並ぶ人気ジャンルで、開発ツールも色々あります。

ただ今回は Lumino の供養も兼ねていたりするので、必要なだけの機能を持ったアクションゲームのシステムをスクラッチで作ることにしました。

ステートマシンという魔物

アクションゲームと言えば超巨大な状態遷移モデルであり古くは大量の switch-case を書き下さざるを得ず、慎重に慎重を重ねてシステムを作っていました。

しかし、最近は次の要素をつなげて作ることが多いそうです。

  • アクション自体を定義する「ノード」
    • "歩く", "ジャンプ" など
  • アクション間の遷移条件を定義する「トランジション
    • ボタンが押されたら "ジャンプ" へ遷移する、など

なるほど良さそうです。

でも実際にこれでキャラクターの取りうるアクションをすべてつなげるとどう見えるんだろう?switch-case よりは簡単に扱えるんだろうか?

いろいろツールを見てみると、ジャンプしたり攻撃したりダメージ受けたりを網羅するとこんな感じになるみたいです。

f:id:lriki:20190930213653p:plain

・・・これをメンテするのはちょっと無理かな。

プログラマ出身としては switch-case の方がまだメンテしやすいと思う・・・。おとなしく枯れた手法を使うべきか。

ちなみにこの例では、同じノード間で矢印がたくさん出ているところがありますが、「壁に接触したら」「地面に接触したら」「坂に接触したら」みたいな遷移条件を個別に指定する必要があるらしいです。

関数型というアプローチ

最近仕事で Web 系のシステムを作っていて、React を学ぶ機会がありました。

React は状態遷移を上手く(数学的に)単純化することに成功しているフレームワークかなと思っていて、何とかこの単純さをゲームシステムに持ってこれないかなぁと、ここ最近試行錯誤しています。

基本はこんな感じ。

  • 状態遷移に関係するすべてのパラメータを "ActionState" というひとつのクラスにまとめておく。
  • "ActionStateをもとにした遷移条件" と "動作" をまとめて "Action" として定義しておく。
  • Action は、常に ActionState を監視する "PassiveAction" と、プレイヤーやAIの入力判断を受けてアドホック的に実行する "AdhocAction" がある。
    • PassiveAction は ActionState が少しでも変わったら定義されている Action の条件をすべて評価してゆき、"優先度" の最も高い Action を遷移先として採用する。(落下、被ダメなど)
    • AdhocAction は PassiveAction の遷移処理に割り込む形で評価・実行される。より優先度の高い PassiveAction が実行されていた場合などはリジェクトされたりする。(ジャンプ、攻撃など)

(Adhocが入ると関数型としてはグレーか・・・?)

目標

ある Action を作るとき、

  • その Action に遷移したときに「必ず成り立っていなければならない条件」
  • その Action の振る舞い
  • 優先度

だけを考えれば良い、ところまで持っていきたい。(もう箱と線をつなぐのはイヤです・・・)

もちろん状態と振る舞いの分離や副作用の排除は徹底的に。これは大前提以前の約束事。

これで Action の正当性、再利用性、保守性を上げていきたい。なぁ・・・。

実装の雰囲気

ActionState は↓こんな感じ。

class ActionState
{
    Vector2 movingTargetPosition;   // アクターが移動するべき位置。停止中は 0
    Vector2 velocity;               // アクターの速度
    Direction direction;            // アクターの向き
    bool isOnGround;               // true の場合は地面に立っている, false の場合は空中にいる
}

Action は↓こんな感じ。これは "走り" の Action。

class Action_Run : public Action
{
    // "走り" への遷移条件
    bool checkPossibleTransition(const ActionState* newState) override
    {
        // 地上にいる & 移動先が指定されている
        return newState->isOnGround && newState->movingTargetPosition != 0.0f;
    }

    // 動作本体
    void onUpdate(Actor* actor, const ActionState* currentState, int frameIndex) override
    {
        // actor の走りモーションを再生したり。
        // currentState->movingTargetPosition が 0 でなければ、その方向に actor の速度を向けたり。
    }
}

アクションを登録するところは↓こんな感じ。後ろの方が優先度が高く、同一優先度は許可しません。

passiveActions.add(new Action_Idle()); // 待機
passiveActions.add(new Action_Run()); // 走り
passiveActions.add(new Action_Rise());    // 上昇 (ジャンプ)
passiveActions.add(new Action_Fall());    // 落下

こんな風に準備しておいてからボタン入力のところで次のようにすると、"走り" へ遷移できます。

if (Input::isPressed(InputButtons::Right)) {
    // →が押されていたら、Actor の少し右側を移動先とする
    actionState->movingTargetPosition.x = player->position().x + 1;
}

AIの場合は↓こんな感じ。

// player の右側へ移動
enemy->actionState->movingTargetPosition.x = player->position().x + 1;

もうひとつ例を。落下は↓こんな感じ。物理エンジンの結果を受けて isOnGround や velocity が変更されたらこれに遷移します。

class Action_Fall : public Action
{
    // "落下" への遷移条件
    bool checkPossibleTransition(const ActionState* newState) override
    {
        // 空中にいる & 速度が下向き(ちなみにジャンプ直後などの上昇中は符号反転)
        return !newState->isOnGround && newState->velocity.y <= 0.0f;
    }

    // 動作本体
    void onUpdate(Actor* actor, const ActionState* currentState, int frameIndex) override
    {
        // 0フレーム目、actor の落下モーションを再生したり。
        // currentState->movingTargetPosition が 0 でなければ、その方向に actor の速度を向けたり。
    }
}

ひとまず冒頭の絵みたいに上手く動いていますが、スーパーオレオレメソッドなのでまだ色々詰める必要が出てくるかもしれません。

Rendering モジュール更新

Lumino に話を移します。

Effekseer の低レイヤーグラフィックスをお手伝いしている一環で、Metal や DX12 も勉強しないとなぁ・・・な気持ちになってきているので、試しに Lumino を Metal に乗っけてみるか、なことを考えていました。

Vulkan 対応したことでモダンAPIに必要なリソースの取り回しはわかってきたのですが、Metal や DX12 対応で似たようなコードを何千も量産しなければならないのはちょっと辛い。

というところでコードの共通化を考えたいのですが、上手いこと共通化するには 0.8.0 までで公開してきた Graphics モジュールの API を変えないとならない・・・。

Lumino 使ってるのは多分自分だけなので変えるのはいいのですが、そのインパクトをバッチリ受けるのは Graphics の上に載って3Dシーンのレンダリングを担当する Rendering モジュールでした、ということでリファクタリングしてました。

スプライトの頂点バッファをリングバッファ使っていたりしたのをやめて、CommandList の Begin 前にすべてのグラフィックスリソースを fix させて、リソースの転送のために RenderPass が途切れないような仕組みに変更しています。

メモリ使用量が1.5倍くらいになるけど、コードがスッキリ&描画速度1.5倍くらいになる・・・かな?(実装中)