ゲームを分解する「初期設計」の考え方

ゲームを分解する「初期設計」の考え方

---
ゲームを分解する「初期設計」の考え方

はじめに

株式会社QualiArtsでUnityエンジニアをしている井上です。

本記事では、ゲームのクライアント部分を対象に、プログラムの初期設計に関する実践手法を紹介していきます。特に、コーディングエージェントの普及により、この手法は大きな恩恵をもたらすようになったため、ぜひ皆さんの参考に役立てていただければ幸いです。

初期設計は大きな影響を与える

従来の開発では、規模や期間に合わせて、どのくらいスケーリングを考慮して初期投資するか決定していました。 例えば、1週間のゲームジャムと数年間の商業開発では、同じゲームでも作り方が変わってきます。

ゲームジャムと商業開発

みなさんがゲームジャムに参加するとき、とにかく期限に間に合わせることを考え、「動けば良い」という基準でコードを書き始めると思います。

一方、商業開発ではそういきません。たとえ同じゲームでも、それは世界中のいろんな人の目に触れ、手に届き、意見が飛び交います。ブランドや信頼を背負っているため、品質を保証した上でリリースすることや、お客様のサポートに最善を尽くすことも重要になります。そしてもちろん、継続的にコンテンツを楽しんでいただくため、イベントや便利機能といった追加実装も必要になってきます。

そのため、開発初期に時間をかけてでも、しっかりとした基盤を構築しておく戦略が鉄板となります。

モック開発とスケーリング

しかし、実は商業開発の現場でも早くゲームを動かすことを求められがちです。しっかりした基盤を構築するにはそれ相当の時間が必要になるので、結局ゲームジャムのようなコードを書いてしまうことも少なくありません。このようなコードは動くまでは早いですが、機能追加もしづらければ不具合調査もしづらいため、負債としてサービスのクローズまで残り続けます。

しかし近年、コーディングエージェントの普及により、基盤構築のコストが下がってきました。そして、よく考えられた設計の基盤があれば、コーディングエージェントが自律的に実験を繰り返し、機能追加、パフォーマンス改善、不具合調査をより正確に行えるようになります。

つまり、初期設計が開発に与える影響は、非常に大きなものになりつつあります。

そこで本記事では、Unityにおけるインゲームのロジック/基盤コードを、デバッグ容易性・検証容易性の観点で分解する話に焦点を当て、開発現場における設計アイデアを事例ベースで紹介していきます。

「取り外せること」と「再現できること」

私たちは普段から、わかりやすい目的をもとに設計を決めています。目的は、実行時パフォーマンスを追求する、レベルデザインの試行回数を増やす、デザイナーだけで完結できるようにするなど、状況によりさまざまです。

その中で、どのような状況でも高い恩恵をもたらしてくれる観点があります。それは、

  • 「取り外せること」と「再現できること」

です。

「取り外せること」

取り外し可能な設計

「取り外せること」とは、ゲームを構成する要素が、それ単体で実行可能だったり、オフにできたりすることです。

例えば、音の再生だけできるようにする、描画だけできるようにする、スコアの計算だけできるようにする、といった工夫を開発初期から考えておきます。 こうすることで、パフォーマンスのボトルネックがどこにあるのか特定したり、必要な部分だけを取り出してシミュレータを作ったりといった応用が効くようになります。

「再現できること」

再現可能な設計

「再現できること」とは、入力が同じならば、いつどんな時でも同じ出力が保証されることです。

ゲームというのは複雑に見えますが、実は思っているよりも単純なルールで結果が決まっています。例えば、ジャンプするだけの単純なランゲームを考えてみましょう。

ランゲーム

プレイヤーは自動で画面右側に進んでいき、ジャンプボタンを押して障害物を避けていきます。障害物にぶつかるとゲームオーバーとなり、進んだ分が最終スコアとなります。 ここで、プレイヤーの挙動とステージ構成は固定という前提を追加しましょう。するとどうでしょう、 「開始から何秒後にジャンプ入力をしたか」だけで最終スコアが決まる、単純なゲームになってしまいました。

こうなると、「開始から何秒後にジャンプ入力をしたか」のログがあれば、まったく同じ状況を再生することができます。リプレイ機能を作ることもできますし、一時停止してバグを調査することもできます。 このように、ゲームのあらゆる結果を単純化し、同じ状況を再現できるようにしておく、といった工夫も作り初めの段階で考えています。

再現可能なモジュール

この2つの観点を組み合わせた初期設計をすることで、どんな時に何が起きるのかを、欲しい部分だけシミュレーションできるようになり、幅広い状況にすぐ対応できるようになります。つまり、「再現可能なモジュール」に分解してゲームを構築することが、開発現場に恩恵をもたらしているのです。 (ちなみに、ここでのモジュールとは「この部分のコード」というニュアンスで、C#やUnityにある何かの機能を具体的に指しているわけではありません。強いて言えば、1つのディレクトリにまとめることが多いです。)

従来であればこの手法は、単純なデータの変換を延々と実装する面倒な作業が必要でした。しかし現在は、コーディングエージェントを使うことで、この面倒さから解放されることができるようになりました。さらに、この方法によって作られたモジュールは、ツールとして単体で実行することもできます。これにより、コーディングエージェントによる検証の自律実行も可能にしています。

ここからは、複雑なゲームをどのように単純なモジュールへ分割し、実装していくのかを紹介します。

未来の悲劇を対策する

ゲームをモジュールへ分解するとき、私たちは未来の開発現場でどういう事態になるかを想像します。 何ができると救われるでしょうか。アクションゲームの例を考えてみましょう。プレイヤーは、入力状態に応じてさまざまなモーションを出します。

複雑なモーション構成

すると、このようなケースがありそうです。

  • モーションが100種類になる
  • 「モーションAが発動してほしいのに、なぜかモーションBが発動する」とバグ報告を受ける
  • 「モーションだけを鑑賞できるギャラリー機能を作りたい」と機能追加を受ける
  • 「ボスの体力設定のため、特定の装備での最高DPSを調べたい」と依頼を受ける

この内容から、「コントローラー入力」に対して「発動するモーション」を決定するモジュールがあると役立ちそうです。 大量のモーションに対してはテストを書くことで挙動の担保ができますし、コントローラー入力のログをレポートするようにしておけばバグの再現もできます。ギャラリー機能では、このモジュールを繋がなければ良いだけです。

このように、現実的に起きそうな要望を想像したうえで、 「なにを単独で検証したいか」「なにを単独で無効化したいか」を具体化していき、「入力に対して、いつ何を出力してほしいか」まで落とし込んでいくことで、適切なモジュールの粒度を決めていきます。

アクションゲームをモジュール化した例

あとはこれを繰り返していきます。 特に、内部ロジックや制御の流れが複雑な部分を、優先してモジュールで包んでいきます。

  • 「キャラクターの受けたイベント」に対して、「HPなどのステータス」を更新する
  • 「キャラクターと照準の位置」に対して、「弾の発射角度」を決定する
  • 「時間の経過」に対して、「ワールド内の時間変化イベント」を発行する

あとは、単体で使えそうなものもモジュールにしておくと良いでしょう。 音声や描画などの外部システムが担当してくれる部分や、モジュール同士を束ねる部分は、無理して切り離す必要はありません。あくまで、現実的に単体で使えると便利なラインで区切っていきます。

  • キャラクターのモーションを再生するモジュール
  • ヒットSEを再生するモジュール

モジュールは一旦ざっくり切っておき、途中で大きくなりすぎたら分解したり、逆に統合したりするケースもあります。大事なのは「現実問題をベースに考えること」で、それに即わないオーバーエンジニアリングは避けるべきです。

ゲームを数学へ落とし込む

ここまでで、モジュールの役割が明確になりました。では、ここからその中身を実装していきましょう。 私たちは、以下の4点に着目することで、軸を明確にしながら実装を進めています。

契約モジュールの使われ方
概念モジュールに登場するモノの定義
戦略モジュールの処理の全体像
外部ツールモジュールから使う外部のAPI

1. 契約

モジュールがいつどんな入力を受け取り、どんな出力をするかを決めます。 外部からアクセスされる変数や関数と、それに伴うデータ型を書いておきます。

この例では、先ほどの「コントローラー入力」に対して「発動するモーション」を決定するモジュールのAPIを示しています。

// モジュール本体
public class MotionTimeline
{
    // 初期化する
    public void Reset() { /* */ }

    // 入力データを蓄積する
    public void AddInput(GamepadInput input) { /* */ }

    // 特定時刻まで時間を進行して、入力データを処理し、発生したモーションを返す
    // 過去に戻すことはできない
    public MotionEvent[] Play(float time) { /* */ }
}

// コントローラー入力
public class GamepadInput
{
    // 入力された時間
    public float Time { get; init; }
    // 入力されたボタンの種類
    public ButtonType ButtonType { get; init; }
    // trueなら押された、falseなら離された
    public bool IsDown { get; init; }
}

// 発生したモーション
public class MotionEvent
{
    // モーションが発動した時間
    public float TriggeredTime { get; init; }
    // 発動したモーションの種類
    public MotionType TriggeredMotion { get; init; }
}

使用するときのイメージはこんな感じです。

private MotionTimeline _timeline;

// コントローラの入力があったときに実行される
void OnInput(InputEvent e)
{
    // 入力データを作る
    var input = new GamepadInput
    {
        Time = e.time,
        ButtonType = GetButtonType(e),
        IsDown = GetIsDown(e)
    };

    // 入力イベントを蓄積する
    _timeline.AddInput(input);
}

// 毎フレーム実行される
void OnEveryFrame()
{
    // タイムラインの内部時刻を進める
    var motionEvents = _timeline.Play(Time.currentTime);

    // motionEventsを使っていろんな処理を行う
    PlayAnimation(motionEvents);
    PlaySE(motionEvents);
}

例なのでだいぶ簡略化していますが、モーションを「コントローラーの各ボタンをいつ押した/離したか」の履歴だけで決定するようにしてみました。入力を AddInput() で蓄積し、Play()内部的な時間をシークさせることにより、入力を時間順に処理して発動するモーションを確定します。この例では戻り値を返して出力としていますが、インターフェースを介して外部の関数を呼んでも良いです。

ここで、違和感を抱いた方もいるはずです。なぜ、単にコントローラ入力に対してモーションを返すだけではダメなのでしょうか。

public class MotionTimeline
{
    // 入力を処理し、発生したモーションを返す
    public MotionEvent[] Play(GamepadInput input) { /* */ }
}

それは、内部処理で時間を参照してしまうと、関数を呼んだタイミングによって結果が変わってしまうからです。これでは毎回同じ出力にならないため、仮想の時間をモジュール内部で管理し、それに沿ってモーションを確定させているわけです。

さて、APIが決まると想定動作も決まり、テストケースを列挙できるようになります。

[Test]
public void MotionTimeline_HoldingButtonAFor1Seconds_SuperAttackTriggered()
{
    var timeline = new MotionTimeline();
    
    // 1秒でAボタンを押し始め、2秒で離す
    timeline.AddInput(new GamepadInput { Time = 1f, ButtonType = ButtonType.A, IsDown = true });
    timeline.AddInput(new GamepadInput { Time = 2f, ButtonType = ButtonType.A, IsDown = false });

    // 3秒まで進める
    var motions = timeline.Play(3f);

    // スーパーアタックが出るはず
    Assert.That(motions.Length, Is.EqualTo(1));
    Assert.That(motions[0].TriggeredMotion, Is.EqualTo(MotionType.SuperAttack));
}

テストコードを書くことで、あらゆるコントローラー入力のパターンに対して、どのモーションが発動するかを保証できるようになりました。 「出力が何によって決定されるのか」を見定めることで、単純な問題に落とし込むことができます。

2. 概念

モジュールが扱う概念、つまり登場人物を定義します。具体的には、データモデルとなるクラスやインターフェース、列挙型などです。

例えば、先ほどのシステムでは、コントローラーの各ボタンに対して入力状態を保持しておく必要があります。 そのため、GamepadButtonState でボタンの入力状態を表せるようにしてみました。これをリストで持っておき、コントローラー入力に応じて更新していきます。

// ボタンの入力状態
public class GamepadButtonState
{
    // ボタンの種類
    public ButtonType ButtonType { get; init; }
    // 押されているかどうか
    public bool IsDown { get; init; }
    // 押下/非押下が継続している時間
    public float ContinueTimeRange { get; init; }
}

同様に、「攻撃中にAボタンを押すと強攻撃が出る」のようなモーション連携があるなら、プレイヤーが発動中のモーションも保持しておく必要があります。

// プレイヤーの状態
public class PlayerState
{
    // 現在再生中のモーション
    public MotionType CurrentMotion { get; init; }
    // 現在のモーション開始からの経過時間
    public float CurrentMotionElapsedTime { get; init; }
}

武器によってアクションが変わる場合は、IWeapon のように武器を定義し、情報を外部から渡してもらうべきでしょう。 そのほか、バフによって特殊なアクションが可能だったり、ストーリー演出中で一部アクションが制限されたりすることもあるでしょう。そういった外部の影響要因も、全部定義していきます。

// 武器の情報
public interface IWeapon
{
    // 溜め攻撃できるか
    bool CanChargeAttack { get; }
    // 連射できるか
    bool CanRapidAttack { get; }
    // クールタイム
    float CooldownTime { get; }
}

ここまでは構造の話でしたが、ここからは処理本体の話をしていきます。

3. 戦略

コードを書き始める前に、実装の軸を見つけると安定します。 モジュールが扱う概念の中から、数学的な問題へ落とし込めそうな部分を見つけてみましょう。私の場合、主に以下の要素に注目することが多いです。

  • グラフやツリー、スタックなどのデータ構造
  • 探索や最適化問題などのアルゴリズム
  • 空間と写像

先ほどまでの例で言うと、モーションの派生関係はモーションという「状態」と、コントローラー入力という「遷移」を組み合わせたグラフで表すことができます。(Unityを触ったことがあれば、AnimatorControllerを想像すると分かりやすいかもしれません)

// モーションのグラフ
public class MotionGraph
{
    // 構成するすべての状態
    public MotionNode[] Nodes { get; init; }
}

// 状態(モーション)
public class MotionNode
{
    // モーションの種別
    public MotionType Type { get; init; }
    // すべての遷移先
    public MotionEdge[] Edges { get; init; }
}

// 遷移
public class MotionEdge
{
    // 遷移条件となるボタン
    public ButtonType ButtonType { get; init; }
    // 遷移条件が「押す」「長押し」「離す」のどれか
    public ButtonActionType ActionType { get; init; }
}

すると、このグラフを基準に、今いる状態から出ている遷移をすべてチェックすれば良い、という大枠の構造が決まります。 このように、数学的要素に当てはめていくことで、先に実装の核が固まっていき、ブレや手戻りが少なくなります。

4. 外部ツール

もう一つ、モジュールの実装が大きく左右される要因があります。それは、外部ツールです。 描画や音声を包んでいるモジュールでは、UnityやCRIWAREといった外部ツールが提供しているAPIを使用する場面があります。

このとき、どのAPIをどう使うのか、事前に調査・実験しておくことが必須です。 大抵どんなAPIでも、外部のものを使用するときはそれを基準に実装が決まるため、後から別の方法に置き換えることは大きな手戻りになります。ドキュメントを読むだけでなく、実際に呼び出して期待通りの挙動をするか実験しておきましょう。

以上が、今回紹介する実践手法の解説となります。

まとめ

今回紹介した手法をまとめると、以下になります。

  • 「単独で検証したい」「単独でオフにしたい」といった要望を想像することで、ゲームを再現可能なモジュールに分解する
  • モジュールを実装するときは、「契約」「概念」「戦略」「外部ツール」の4つの要素を明確にする

これは、将来的な問題の洗い出しから分解、単純化を行い、初期設計段階で解決を試みる方法の一例です。特定のフレームワークに従うのではなく、起こり得る課題とそれに対するアプローチを中心に、方針決定のコミュニケーションを取れる点が優れていると考えています。 皆さんもぜひ、ゲームを作り始めるときは一度立ち止まり、どんな要望のリスクがあるか想像してみてください。今の時代なら、対策に時間もあまりかかりません。この知見が助けになれば幸いです。

著者

(Inoue Masaki)

2023年にサイバーエージェントに新卒入社。株式会社QualiArtsにてUnityエンジニアとしてゲーム機能全般を担当。

この記事をシェア

目次