LoginSignup
140
151

街づくりゲームの住民管理システムを設計した話

Last updated at Posted at 2023-10-14

はじめに

ヘレの海底都市計画 ~箱庭に空気を植えるSLG~
というゲームの開発に携わらせていただきました。

イベントカバー.png

主にいくつかの主要な機能の全体的な設計と実装、UI の MVP モデル設計、また開発中に問題が生じた際の抜本的な再設計を担当しました。

せっかくなので、本プロジェクトで行った設計のエピソードについてお話していこうと思います。

第一弾は セーブデータ設計について 扱いました。

本記事は第二弾となります。
今回は本ゲームの中で、規模的にもゲーム的にも最も大きい要素の一つだった、住民の管理部分の設計をさせていただいた話です。

私が設計について学び始めた際、設計ができる人はどうしてその設計に至ったのかがわからずにもどかしい思いをしたので、当時の自分の様な設計を学び始めた人にとって助けになればと思います。

(本記事では説明のために具体的なコードを記載しておりますが、これらはこの記事のためのコードであり、実際のゲーム内のコードとは異なります。)

仕様について

本ゲームの仕様について

本作は街づくりシミュレーションゲーム です。
プレイヤーは何もない土地に家を建て、住民を住まわせます。
また、住民の仕事場となる生産施設を建てることで、住民に生産させ、資源を集めることができます。
集めた資源を使って新たな建築物を研究し、アンロックさせながら街を発展させるゲームです。

qiita_building.gif

住民の仕様について

  1. 住居を建てるとそこに住民が生えます
  2. 生えた住民は、可能な限り他の施設を目指して移動します
  3. 住民が施設に到達した場合、そこで生産を行います
  4. 生産が完了した場合、街の資源がその施設に応じて増減します
  5. 生産完了した住民は、自分の家に戻ります
  6. 住民は生きている限り、2 ~ 5 を繰り返します

qiita_inhabitant.gif

要件の整理

本ゲームの仕様と住民の仕様を照らし合わせるとわかることがあります。

それは、住民の仕様のほとんどが本ゲームの仕様そのものに関わっていることです。

被っていない点は研究ぐらいです。

さらにどの辺がネックになりそうかを見ていきます。

やはり生産周りが鬼門となるでしょうか。

なぜならば、住民の移動は View のレイヤーであるのに対して、
生産によって街の資源を増やすところはビジネスロジックのレイヤーであるからです。

とりあえず動くようにするならば、それほど困ることなく、やり方はたくさんあると思います。
たとえば、住民が街の資源管理に触れるようにするやり方です。

ResourceDataManager.cs
public class ResourceDataManager
{
    private int _money;

    public void Produce(Production production)
    {
        _money += production.Money;
    }
}
Person.cs
public class Person
{
    private ResourceDataManager _resourceDataManager;
    private Office _office;

    private void Work()
    {
        _resourceDataManager.Produce(_office.Production);
    }
}

しかしこのやり方には問題があります。
本作をプレイされた方、動画などを見た方には伝わるかと思いますが、本作は何十何百という住民が生活するほど発展することがあります。
人口を50人にする実績があるぐらいです。

つまりその分住民インスタンスを生成する必要があり、かつその数だけ ResourceDataManager を知っている存在が増えてしまうということです。

ResourceDataManager は本プロジェクト唯一の存在で、街の資源を管理する唯一の存在です。
いつどこから呼ばれるかわからない、しかも大量に呼ばれる可能性があるというのはなるべく避けたいです。

では住民管理クラスを作って、住民が施設の中に入ったかを監視し、施設に入ったら生産を行うとしてはどうでしょうか。

Person.cs
public class Person
{
    public Office Office => _office;
    private Office _office;
    public bool IsProducing => _isProducing;
    private bool _isProducing;
    public bool IsMoving => _isMoving;
    private bool _isMoving;

    public void EndProducing()
    {
        _isProducing = false;
        _isMoving = true;
    }

    public void Move()
    {
        // 移動処理
    }
}
PeopleManager.cs
public class PeopleManager
{
    private List<Person> _personList;
    private ResourceDataManager _resourceDataManager;

    private void Update()
    {
        foreach (var person in _personList)
        {
            if (person.IsProducing)
            {
                _resourceDataManager.Produce(person.Office);
                person.EndProducing();
            }
            else if (person.IsMoving)
            {
                person.Move();
            }
        }
    }
}

ResourceDataManager に View が依存しているという問題は解決することができました。
しかし、今度は新たに生やした PeopleManagerPerson にべったりと依存することになってしまいました。

また、家が建築されたときに住民を生やすことも必要です。
そして当然、家が移設されたときは住民も引っ越しさせなければならないし、
家が撤去されたときは住民も削除せねばなりません。

このように建築、生産と様々なシステムと関わることを考えると、住民管理はそれらの仕様変更に振り回されないようにするために、なるべく依存関係はすっきりさせておきたいところです。

Observable パターンを採用する

建築、生産という別のシステムと繋がりつつ、住民の動きに応じて適切に生産処理を行い、生産処理が行われたらプレイヤーデータを更新するという動きを、なるべく変更に強く、メンテナンスしやすく、かつ疎結合で実装したい。

こんな欲張りな要件がかなえられる設計なんてあるんでしょうか。

実はあります。

今回は Observable パターンを採用しました。

デザインパターン解説記事ではないので詳細は省きますが、要は UniRx です。

UniRx ならば、イベントの発行をする側はただイベントの発行をするだけでよく、
イベントの購読側はイベントが発行されたときの処理をまとめて行うことができます。

Person.cs
public class Person : MonoBehaviour
{
    private Subject<Office> _onProduced = new Subject<Office>();
    public IObservable<Office> OnProduced => _onProduced;
    private Office _office;

    // メソッドを公開する必要がない
    private void OnProduced()
    {
        _onProduced.OnNext(_office);
    }
}
PeopleManager.cs
public class PeopleManager
{
    private List<Person> _peopleList = new List<Person>();
    private ResourceDataManager _resourceDataManager;

    private void Start()
    {
        foreach (var person in _peopleList)
        {
            // イベントの購読と購読時の処理をまとめられる
            person.OnProduced
            .Subscribe(office =>
            {
                _resourceDataManager.Produce(person.Office);
            }).AddTo(person.gameObject)
        }
    }
}

PeopleManagerPerson を参照してはいるものの、イベントの購読だけにとどまり、Person の状態に左右されず、OnProduced 以外には関知しないという関係にすることができました。

さらに、家が建築、撤去されたときもイベントを用意して対応できます。

BuildingManager.cs
public class BuildingManager
{
    private Subject<Vector3> _onBuildedHouse = new Subject<Vector3>();
    public IObservable<Vector3> OnBuildedHouse => _onBuildedHouse;
    private Subject<Vector3> _onDeletedHouse = new Subject<Vector3>();
    public IObservable<Vector3> OnDeletedHouse => _ondeletedHouse;
}

住民は家の建築、撤去時に増減するので、住民の追加、削除も Observable イベントとしてしまいます。

PeopleManager.cs
public class PeopleManager
{
    private ReactiveCollection<Person> _currentPeopleList = new ReactiveCollection<Person>();
    private ResourceDataManager _resourceDataManager;
    private BuildingManager _buildingManager;
    private CompositeDisposables _disposables = new CompositeDisposables();

    private void Start()
    {
        _buildingManager.OnBuildedHouse
        .Subscribe(pos =>
        {
            // 自分の家は住民が生きている限り不変なのでコンストラクタで受け取る
            var newPerson = new Person(pos);
        }).AddTo(_disposables);

        _buildingManager.ObDeletedHouse
        .Subscribe(pos =>
        {
            var removingPerson = _currentPeopleList.FirstOrDefault(p => p.HousePos == pos);
            _currentPeopleList.Remove(removingPerson);
        }).AddTo(_disposables);

        _currentPeopleList.ObserveAdd()
        .Subscribe(addEvent =>
        {
            var person = addEvent.Value;
            
            person.OnProduced
            .Subscribe(office =>
            {
                _resourceDataManager.Produce(person.Office);
            }).AddTo(person.gameObject)
        }).AddTo(_disposables);

        _currentPeopleList.ObserveRemove()
        .Subscribe(removeEvent =>
        {
            var person = removeEvent.Value;
            person.Die();
        }).AddTo(_disposables);
    }
}

こうして住民管理をつかさどる PeopleManager が住民と他システムの中継役をすることで、
住民は自身の移動に専念することができました。

しかしこれだけではまだ不十分です。

場合によっては住民がさぼることがあるからです。

本作は原則としていつでも建築可能な建築物を建築でき、また自分の建築したものを撤去することができます。
例えば住民が移動している道を撤去して行き場を失わせた場合、住民はいつまでたっても施設に到達できず、生産ができなくなります。

qiita_loop.gif

住民が街を歩き回るという仕様が、街がにぎわっている演出のためならば最悪これでもいいかもしれませんが、
本作は住民の活動が主要な稼ぎ手段であるので、こういった問題は事前に対応する必要があります。

次は、この問題に対応するためにどのように設計したのかについて紹介します。

住民の移動の仕様について整理する

  1. 自分の家と施設が道でつながっていた場合、生産活動を行います
  2. 移動中に向かっている施設が撤去された場合、帰宅します
  3. 移動中に家が撤去された場合、死にます
  4. なかなか目的地に到達できなかった場合、家からリスタートします

他にも大小さまざまな仕様はありますが、
本記事ではこれで十分ですので割愛します。

さて、仕様を整理してみるとわかってくることがあります。
住民の状態(移動中かどうかなど)とイベント(施設が撤去されたなど)によって処理が分岐しているということです。
次はこの部分をいかにして整理したかについて紹介します。

住民の移動の仕様を落とし込む

仕事場が撤去された場合の処理を入れ込む

PeopleManager.cs
public class PeopleManager
{
    private ReactiveCollection<Person> _currentPeopleList = new ReactiveCollection<Person>();
    private ResourceDataManager _resourceDataManager;
    private BuildingManager _buildingManager;
    private CompositeDisposables _disposables = new CompositeDisposables();

    private void Start()
    {
        _buildingManager.OnBuildedHouse
        .Subscribe(pos =>
        {
            // 自分の家は住民が生きている限り不変なのでコンストラクタで受け取る
            var newPerson = new Person(pos);
        }).AddTo(_disposables);

        _buildingManager.ObDeletedBuilding
        .Subscribe(building =>
        {
            if (building.Type == BuildingType.HOUSE)
            {
                var removingPerson = _currentPeopleList.FirstOrDefault(p => p.HousePos == pos);
                _currentPeopleList.Remove(removingPerson);
            }
            // 仕事場が撤去されたら、その仕事場に向かっている人を探して、いたら殺す
            else if (building.Type == BuildingType.OFFICE)
            {
                targetPerson = _currentPeopleList.FirstOrDefault(p => p.Office == building);
                if (targetPerson != null)
                {
                    _currentPeopleList.Remove(targetPerson);
                }
            }
        }).AddTo(_disposables);

        _currentPeopleList.ObserveAdd()
        .Subscribe(addEvent =>
        {
            var person = addEvent.Value;
            
            person.OnProduced
            .Subscribe(office =>
            {
                _resourceDataManager.Produce(person.Office);
            }).AddTo(person.gameObject)
        }).AddTo(_disposables);

        _currentPeopleList.ObserveRemove()
        .Subscribe(removeEvent =>
        {
            var person = removeEvent.Value;
            person.Die();
        }).AddTo(_disposables);
    }
}

これまでで住民の移動の仕様 2, 3 を満たせました。

ステートマシンを用いて住民のループ移動に対応する

  1. なかなか目的地に到達できなかった場合、家からリスタートします

最後にこちらの仕様を片づけます。
住民はとにかく移動だけをやってほしいので、ループの判定も住民側が持つようにします。
ループしたらイベントを発行し、 PeopleManager が購読するという形を目指します。

まず、移動用のステートを用意します。

MoveState.cs
public enum MoveState
{
    IDLE,
    MOVING,
    WANDERING,
}
Person.cs
public class Person : MonoBehaviour
{
    private Subject<Office> _onProduced = new Subject<Office>();
    public IObservable<Office> OnProduced => _onProduced;
    private Office _office;
    private Subject<Unit> _onWandered = new Subject<Unit>();
    public IObservable<Unit> OnWandered => _onWandered;
    private ReactiveProperty<MoveState> _currentMoveState = new ReactiveProperty<MoveState>();

    private void Start()
    {
        // ステートに応じて移動を行う
        _currentMoveState
        .Subscribe(state =>
        {
            switch (state)
            {
                case IDLE:
                    Move();
                    break;
                case WANDERING:
                    _onWandered.OnNext(Unit.Default);
                    break;
            }
        }).AddTo(gameObject);
    }

    private void OnProduced()
    {
        _onProduced.OnNext(_office);
    }

    private void Move()
    {
        if (IsLooping())
        {
            // ループしてたら状態を変える
            _currentMoveState.Value = MoveState.WANDERING;
            return;
        }
        // 移動処理
    }

    private bool IsLooping()
    {
        retun // ループしてるか判断する
    }

    public void Die()
    {
        // 移動状態を待機に戻す
        _currentMoveState.Value = MoveState.IDLE;
    }
}
PeopleManager.cs
public class PeopleManager
{
    private ReactiveCollection<Person> _currentPeopleList = new ReactiveCollection<Person>();
    private ResourceDataManager _resourceDataManager;
    private BuildingManager _buildingManager;
    private CompositeDisposables _disposables = new CompositeDisposables();

    private void Start()
    {
        _buildingManager.OnBuildedHouse
        .Subscribe(pos =>
        {
            // 自分の家は住民が生きている限り不変なのでコンストラクタで受け取る
            var newPerson = new Person(pos);
        }).AddTo(_disposables);

        _buildingManager.ObDeletedBuilding
        .Subscribe(building =>
        {
            if (building.Type == BuildingType.HOUSE)
            {
                var removingPerson = _currentPeopleList.FirstOrDefault(p => p.HousePos == pos);
                _currentPeopleList.Remove(removingPerson);
            }
            // 仕事場が撤去されたら、その仕事場に向かっている人を探して、いたら殺す
            else if (building.Type == BuildingType.OFFICE)
            {
                targetPerson = _currentPeopleList.FirstOrDefault(p => p.Office == building);
                if (targetPerson != null)
                {
                    _currentPeopleList.Remove(targetPerson);
                }
            }
        }).AddTo(_disposables);

        _currentPeopleList.ObserveAdd()
        .Subscribe(addEvent =>
        {
            var person = addEvent.Value;
            
            person.OnProduced
            .Subscribe(office =>
            {
                _resourceDataManager.Produce(person.Office);
            }).AddTo(person.gameObject)

            // ループイベントを購読して Die を実行する
            person.OnWandered
            .Subscribe(_ =>
            {
                person.Die();
            }).AddTo(person.gameObject);
        }).AddTo(_disposables);

        _currentPeopleList.ObserveRemove()
        .Subscribe(removeEvent =>
        {
            var person = removeEvent.Value;
            person.Die();
        }).AddTo(_disposables);
    }
}

これで、当初より守り続けていた、「移動は住民だけが司る」という設計を守りつつ、住民の状態とイベントによる処理の分岐を実装することができました。

まとめ

ゲーム仕様の大半に関わるシステムをいかに設計したかの話でした。

うっかりすれば大量の View から大事なセーブデータがいつの間にか更新されるところを、管理クラスをかますことで View は自身以外を知らないという状態を保ったままで設計することができました。

その分住民管理クラスは肥大気味になっておりますが…。実際のコードではいくつかの責務に分けて複数のクラスで運用しております。

今回は、仕様から要件整理までを詳しく紹介することで、どのように試行錯誤して設計を考えたかの筋道が見えるように意識してみました。

この記事が設計する際の役に立てれば嬉しいです。

140
151
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
140
151