LoginSignup
151
126

セーブデータの再設計をして開発速度を爆上げした話

Last updated at Posted at 2023-10-08

はじめに

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

イベントカバー.png

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

本記事では、開発中の事例を交えて、何を考えてどう設計して、なにが良くて何が良くなかったかを考察しようと思います。

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

この記事で言いたいこと

  • 設計の大切さを具体例を用いて伝えたい
  • どういう筋道で設計を行ったのかを共有することで、設計の雰囲気を掴む一助になりたい

×この記事で言っていないこと

× この事例がセーブデータ設計の最善手である
× この事例において行った設計が最適解である
× 設計をすると開発速度が爆上がりする

背景

ゲーム仕様について

本ゲームは、タイルベースの街づくりシミュレーションゲームです。

マスをクリックして建物や道を建築し、街を発展させるのがゲームの目的です。

スクリーンショット (198).png

セーブデータにおいて肝となるのは当然「街データ」です。

どのタイルにどんな建築物があるのか?
その建築物はどんな状態か?

といった情報をセーブデータに格納する必要があります。

また、タイルをまたぐ大きな建物も存在します。

スクリーンショット (199).png

大きな建物を表示する場合、各タイルは「自分はこの大きな建物のどの座標のタイルか」を知っていなければ、表示が崩れてしまいます。

そのため、本プロジェクトでは、建物の親となるタイルを、建物一つにつき一つだけ用意することにしました。

スクリーンショット (200).png

これによって、親以外のタイルは、以下のことがわかれば最低限表示ができるようになります。

  • 自分が親であるかどうか
  • 自分が親からどれくらい離れた場所にいるか

長くなりましたが、この部分は開発速度を爆速にした話の前提となるので、詳しく記述しました。

セーブデータ仕様について

本プロジェクトでは EasySave3 というアセットでセーブデータ管理を行っていました。

このアセットを使っていなくても、Unity 開発では多くの場合、 JSON を用いたセーブデータ管理を行っていると思います。

ご存じの方も多いと思いますが、 Json に値を格納するにあたって、いくつかの制約があります。例えばこんなところでしょうか。

  • プリミティブ型でない型を格納するには、 Serializable である必要がある
  • Serializable であっても、ネストが深くなると扱いが難しくなる
  • Dictionary は対応していない
  • private な変数は [SerializeField] 属性を付ける必要がある

これらの制約から、本プロジェクトではなるべくプリミティブ型と List 型で、ネストはしないという方針でセーブデータが設計されておりました。

開発中に発生した問題点

発生した問題点は大きく分けて3つです。

  • セーブデータを格納しているクラス内でビジネスロジックも実装されており、役割が肥大化し、かつ取り回しが難しくなっていた
  • セーブデータを格納しているクラスをどこからでも直接参照できるため、インスタンス自体を上書きできてしまっていた
  • private 変数を扱わないクラスがあり、どこからでも値を書き換えることができてしまっていた

どのようにして問題を解決したか

もともとの実装

元々のコードはこんな感じになっていました。

SaveData.cs
// セーブデータ全体のクラス
public class SaveData 
{
    public PlayerData PlayerData;
    public TownData TownData;
}
TownData.cs
// 街に関わるセーブデータ
public class TownData
{
    // 住居のリスト
    public List<string> HouseKeyList;
    // 仕事場のリスト
    public List<string> OfficeKeyList;

    public TownData(List<string> houseKeyList, List<string> officeKeyList)
    {
        HouseKeyList = houseKeyList;
        OfficeKeyList = officeKeyList;
    }

    // 建築物の数を返す
    public int AllBuildingCount()
    {
        return HouseKeyList.Count + OfficeKeyList;
    }

    public House GetHouse(string key)
    {
        // key から特定の House を返す
    }

    public Office GetOffice(string key)
    {
        // key から特定の Office を返す
    }
}

TownData はセーブデータに使うクラスであると考えると、AllBuildingCountGetHouse GetOffice といったメソッドはビジネスロジック寄りですので、同じクラスにあるとあまり望ましいとは言えません。

具体的にどう望ましくないかというと、
「今建築物いくつあるのか?」だけを確認したいときでも、建築物に関わるデータまるまるアクセスできる状態になってしまっているという点です。

// 街に関わるセーブデータ
public class TownData
{
/*----- !!! ビジネスロジックのメソッドと同じ public で公開してしまっている !!! -----*/
    // 住居のリスト
    public List<string> HouseKeyList;
    // 仕事場のリスト
    public List<string> OfficeKeyList;

    public TownData(List<string> houseKeyList, List<string> officeKeyList)
    {
        HouseKeyList = houseKeyList;
        OfficeKeyList = officeKeyList;
    }

/*----- !!! データアクセスと同じクラスに混在してしまっている !!! -----*/
    // 建築物の数を返す
    public int AllBuildingCount()
    {
        return HouseKeyList.Count + OfficeKeyList;
    }

    public House GetHouse(string key)
    {
        // key から特定の House を返す
    }

    public Office GetOffice(string key)
    {
        // key から特定の Office を返す
    }
}

カプセル化を用いて問題を解決する

セーブデータに関わらず、値を外から直接更新できてしまったり、その値を元に何かする処理が同じところにあるというのは、保守、管理が難しくなります。誰がどの責務を担っているのかが曖昧になってしまうからです。

ではどうすればいいのか?

有効な手段として、カプセル化が挙げられます。

今回の事例ではこのようにしました。

TownData.cs
// 街に関わるセーブデータ
public class TownData
{
    /*----- 値自体を参照先で扱えないように private で保護する -----*/

    // 住居のリスト
    private List<string> _houseKeyList;
    // 仕事場のリスト
    private List<string> _officeKeyList;

    public TownData(List<string> houseKeyList, List<string> officeKeyList)
    {
        _houseKeyList = houseKeyList;
        _officeKeyList = officeKeyList;
    }

    /*----- 値を直接触らせず、問い合わせる形で値の更新・取得ができるようメソッドを公開する -----*/

    public void SetHouseKeyList(List<string> houseKeyList)
    {
        _houseKeyList = houseKeyList
    }

    public void AddHouseKeyList(string houseKey)
    {
        _houseKeyList.Add(houseKey);
    }

    public ReadOnlyCollection<string> GetHouseKeyList()
    {
        return _houseKeyList.AsReadOnly();
    }

    public void SetOfficeKeyList(List<string> officeKeyList)
    {
        _houseKeyList = officeKeyList
    }

    public void AddOfficeKeyList(string officeKey)
    {
        _houseKeyList.Add(officeKey);
    }

    public ReadOnlyCollection<string> GetOfficeKeyList()
    {
        return _officeKeyList.AsReadOnly();
    }
}

さらに、ビジネスロジックは別のクラスでまとめつつ、他のクラスからの窓口になるように、
Wrapper クラスとして一枚かませるようにしました。

TownDataWrapper.cs
public class TownDataWrapper
{
    private TownData _townData;

    public TownDataWrapper
    (
        List<string> houseKeyList,
        List<string> officeKeyList,
    )
    {
        _townData = new TownData(houseKeyList, officeKeyList);
    }

    public void SetHouseKeyList(List<string> houseKeyList)
    {
        _townData.SetHouseKeyList(houseKeyList);
    }

    public void AddHouseKeyList(string houseKey)
    {
        _townData.AddHouseKeyList(houseKey);
    }

    public void SetOfficeKeyList(List<string> officeKeyList)
    {
        _townData.SetOfficeKeyList(officeKeyList);
    }

    public void AddOfficeKeyList(string officeKey)
    {
        _townData.AddOfficeKeyList(officeKey);
    }

    public ReadOnlyCollection<string> GetOfficeKeyList()
    {
        return _townData.GetOfficeKeyList();
    }

    public int AllBuildingCount()
    {
        return GetHouseKeyList().Count + GetOfficeKeyList().Count;
    }

    public House GetHouse(string key)
    {
        // key から特定の House を返す
    }

    public Office GetOffice(string key)
    {
        // key から特定の Office を返す
    }
}

外からの参照は Wrapper に任せればよく、
また値の書き換えは Wrapper からしか呼ばれないことになるので、
いつの間にか値が書き換わった!となった際にも
Wrapper がどこから呼ばれているのかを探れば問題に行きつけるようになります。

このようにして責務を分けることにより、問題点の一つであった役割の肥大化を抑えることができました。

また、同時に Wrapper クラスを一枚かませることにより、セーブデータを格納しているクラスを直接参照出来てしまうという問題も同時に解決できました。

さらに、Wrapper クラスを通して適切に値を返すようにすることにより、もとのクラスが private か public かを考慮する必要がなく、もとのコードを大きく変えることなくより堅実な設計にすることができました。

どのようにして開発速度が爆速になったのか?

まず、セーブデータの値を使う側でうっかり書き換えちゃったりといったヒューマンエラーによる手戻りがなくなりました。

さらに、データアクセスとロジックを分けたことにより、思い切ったロジックの実装をしやすくなったり、雑に参照を渡したりしやすくなりました。

具体的には、大きな建物の親子関係の管理が非常に簡潔になりました。
今まではセーブデータの JSON による取り扱いの関係から、他の独自クラスの参照を控えざるを得なかったのですが、 Wrapper 自体はセーブデータに直接入らないので、子建築マスが親建築マスを参照するということができるようになりました。

今までは親建築マスを子が取得するにはこのように全建築物をぶん回して、座標から取得しなければなりませんでした。

今作は 42 * 42 = 1764 マスもあるので、「すべての建築物の親建築物を取得する」という処理をする場合、最大で 1764 * 1764 回の繰り返し処理をぶん回す必要がありました。

private Building GetParentBuilding(Building child)
{
    foreach (var building in allBuildingList)
    {
        if (building.Position == child)
        {
            return building;
        }
    }
    return null;
}

今回の対応で、このように一発で取得できるようになりました。

private BuildingWrapper GetParentBuildingWrapper(BuildingWrapper child)
{
    return child.ParentBuilding;
}

これにより、この処理の実行速度が 200 倍にまで向上しました。

まとめ

まず今回の事例で解決できた問題点についておさらいします。

  • セーブデータを格納しているクラス内でビジネスロジックも実装されており、役割が肥大化し、かつ取り回しが難しくなっていた
  • セーブデータを格納しているクラスをどこからでも直接参照できるため、インスタンス自体を上書きできてしまっていた
  • private 変数を扱わないクラスがあり、どこからでも値を書き換えることができてしまっていた

これらの様々なレイヤーの問題点をカプセル化という観点から、一つの再設計によって一気に解決することができました。

しかしながら問題点もなかったわけではなく、

  • Wrapper クラスのインスタンスはどこからでも作れてしまい、そこからコードが汚れる可能性がある
  • Wrapper クラスを介して特定の値だけを返すメソッドの整備が大変で、時には元のクラス自体を渡した方が嬉しい場合もある

など挙げられるかと思います。

それでも、すでに動いているプロジェクトで、大幅な設計変更もなく、今までのやり方を維持しながら実現できた点を考えると、有意義な再設計ができたかと思っています。


// 旧コード
Vector3 pos = Building.Pos;

// 新コード
Vector3 pos = BuildingWrapper.GetPos();

設計は、できてあるものを見たときはそうであるのが当たり前に思えるのに対して、いざやってみようと思うと何も思いつかないということはあるあるだと思います。
本記事では、実際の事例を通して、どのような課題を設定し、どのように試行錯誤したかを紹介してみました。

これが何かの設計の助けになれば幸いに思います。

151
126
5

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
151
126