📑

個人開発ゲームでマスター管理ツールを作ってもらった話

2024/12/14に公開

この記事は Akatsuki Games Advent Calendar 2024 14日目の記事です。

開発中のゲーム

はじめに、イメージしやすいようにこのツールの開発に至ったゲームについて簡単に触れておきます。

  • 名前:moorestech
  • アニメRPG × 自動化工業ゲーム
  • 工場を作ってアイテムを生産し、更に工場を大きくしていくゲーム
  • 2021年から作り始めてはや3年半

https://www.4gamer.net/games/695/G069585/20230327015/

マスタデータとは

マスタ管理ツールはマスターデータを編集するためのツールなので、マスターデータについて軽く触れておきます。
マスタデータはゲームやプロダクトにおける必要なデータ全般のことを指します。

たとえば、以下のようなデータがマスタデータとして登録されています。

ECサイトの例

  • 顧客情報
    • メールアドレス, 名前, 住所
  • 商品カテゴリ
  • 商品情報
    • 名前, 値段, 商品カテゴリ

ゲームの例

  • 武器
    • 名前, ダメージ, 購入価格
  • キャラクター
    • 名前, HP, 所持スキル
  • スキル
    • 消費MP, スキルの効果

アイテムテーブルの一例

これらのデータは基本的にテーブル(表)で管理されています。

ID 名前 ダメージ 購入価格
1 木の棒 1 3
2 銅の剣 10 30
3 エクスカリバー 50 9999

マスタ管理ツールとは

マスタ管理ツールは上記のマスタを追加、管理していくためのツール群全般のことを指します。ツールとしては以下のような機能が求められます。

すべてのツールがこれらの機能を網羅しているわけではなく、各ゲームやプロダクトに応じて機能を開発、既存ツールで代替の判断をしています。

  • データ入力ツール
  • スキーマ定義機能
  • 入力データのバリデーション
  • データーローダーのコード自動生成
  • バージョン管理
  • 差分表示機能
  • デプロイ機能

データ入力と管理の一例

マスタ管理ツールの中で、データ入力や入力ツールの選定は大きな要素の一つです。多くの場合、非エンジニアがここを一番触ることになるため、マスタ入力の難易度もこのツールによって左右されます。

Excel, Googleスプレッドシート

個人的には一番よく聞くマスタ入力の手法だと思います。一般的なツールで使いやすく、非エンジニアでも使いやすいです。また、関数や装飾と言った機能でテーブル全体を見やすくすることができるのも特徴です。

反面、Excelは独自フォーマットのバイナリデータであるため差分管理の実装は難易度が高く、JSONやCSVに比べてコードからデータのロードするのに苦労します。

また、どんなデータでも入力できてしまうので、入力されたデータのバリデーションをしっかりと行う必要があります。

CSV

直接CSVを書くことは少ないと思いますが、VS Codeの拡張機能などの入力ツールと併用して使用することが多いと思います。Excelよりも機能や使いやすさは劣りますが、特に差分管理はExcelに比べて格段に良くなります。

ただし、Excelと同じくどんなデータでも入力できてしまうので、こちらもバリデーシンをしっかりと行う必要があります。

UnityのScriptableObject

Unity限定の機能にはなりますが、Unityでゲームを作っている場合はかなり有力な選択肢です。プレーンテキストで管理され、Unityが提供する入力UIがあり、Unity内にあるアセットとも簡単に紐づけができます。

また、データ構造をコードで定義するため、データのロードが非常にやりやすいのも特徴の一つです。

ただし、データをUnityの外に持っていくのは難しく、基本的にゲームのバイナリにすべてのデータが入ってしまいます。クラッキングされても問題ないようにサーバーにだけデータを持っておきたい、といった需要には答えづらくなります。

独自の入力ツールを開発する

一番コストはかかりますが、一番自由度が高い選択肢となります。データの持ち方も自由なので、JSONやCSVなど、管理が楽な方法を選択することができます。

また、入力ツールのフィールドでバリデーションを行うことで、不正なデータの入力を早い段階で避けることができます。

解決したい課題

さて、これらのマスタデータの特性や、管理ツールの現状を踏まえて、私が開発中のゲーム「moorestech」で解決したい課題を整理していきます。

プレーンテキストでのデータ管理

まずデータの管理方法としてプレーンテキストで管理したいというものがあります。理由は以下の2点です。

Gitによるバージョン管理

プレーンテキストで用意にgitで管理し、差分を確認することができます。また、コンフリクトの解消も容易にできます。

mod開発者にとっての使いやすさ

このゲームでは、mod開発を積極的に推奨したいと考えています。そのため、誰でも簡単にコンテンツを追加できるような、扱いやすいデータ形式が必要です。
UnityのScriptableObjectは、ビルド後にデータを追加するのが難しく、Excelは扱いづらい上に有料アプリです。プレーンテキストであれば、これらの問題をクリアできます。

スキーマからのコード自動生成

次に、スキーマ定義からモデルとローダーのコードを自動生成したいという要望があります。これは、開発効率を向上させるために不可欠です。スキーマを変更するたびに、手動でコードを書き換えるのは非常に面倒でした。

ブロックタイプごとのパラメータの違い

このゲームの特徴にはブロックという概念があり、ブロックの種類によってパラメータが大きく異なるという点も考慮する必要があります。たとえば、機械ブロック、発電機ブロック、ベルトコンベアブロックは、タイプごとに必要なパラメータが全く違います。

これを無理やり表で表現しようとすると、以下のようなテーブルになってしまいます。

ブロックID 名前 タイプ 機械インプットスロット 発電量 ベルトコンベア速度
1 機械 Machine 3
2 発電機 Generator 100
3 ベルトコンベア BeltConveyor 0.7

これはあまりにも不格好で、タイプが増えるにつれて、管理がどんどん複雑になることが予想されます。また、データの入力ミスも頻発するでしょう。

この問題を解決するために、データ構造としてはJSON形式を採用し、以下のような構造にすることを考えました。

{
    "blocks":[
        {
            "name": "機械",
            "type": "machine",
            "parameter":{ 
                "inputSlot": 3,
                "outputSlot": 3
            }
        },
        {
            "name": "ベルトコンベア",
            "type": "beltConveyor",
            "parameter":{
                "conveyorSpeed": 0.7
            }
        }
    ]
}

この形式であれば、タイプごとに異なるパラメータを柔軟に表現できます。しかし、このJSONを直接編集するのは、人間にとっては非常に大変です。タイプが増えるほど複雑になるため、入力ミスも増えるでしょう。

誰でも直感的に使えるマスタ入力ツール

そこで、直感的に使えるマスタ入力ツールが必要になります。
具体的には、以下のような機能を持った入力ツールが必要となります。

  • UIから各種パラメータをフォームで編集できるようにしたい。
  • タイプごとにフォームが自動的に切り替わるようにしたい。
  • スキーマを定義するだけで、自動的にUIが生成されるようにしたい。
  • 依存するデータ(外部キー)は、ドロップダウンで選択できるようにしたい。
    • たとえば、ブロックがアイテム状態だったときのアイテムIDや、クラフトレシピで使用するアイテムIDの指定

要するに

  • いい感じにテキストでマスタ管理できて
  • いい感じの入力ツールがあって
  • いい感じにコード自動生成したい

しかし、そんな都合の良いツールは世の中に存在しない!
というわけで、作るしかない! という結論に至りました。

作ってもらったツール

そして、この理想を形にするために、素晴らしい協力者のお二人の力を借りて、以下の2つのツールを開発しました。

マスタ入力ツール:mooreseditor

Webブラウザで動作するマスタ編集ツールで、スキーマから編集UIを自動生成するツールとなっています。
boke0さんに作っていただきました。本当にありがとうございます。
このツールのおかげで複雑なスキーマを持つデータ入力のミスが格段に減りました。

複数のスキーマをサイドバーで切り替えて編集可能

無限ネスト構造を持つkey-value形式のデータ編集に対応

外部キーはドロップダウンで選択可能

ドロップダウンの選択に応じて、フォームが動的に変化

配列の入力に対応

Vector3等の特殊なデータ形式にも対応

コード生成ツール:mooresmaster

スキーマを記述することで、モデルとローダーをコード自動生成することができるツールです。
juhaさんに作っていただきました。本当にありがとうございます。

モデルとローダーのコードを自動生成

以下のようなスキーマファイルを記述すると、モデルとローダーのコードを自動生成してくれます。
SourceGeneratorなのでコード生成忘れがなく、差分の発生もないのが非常に便利です。

スキーマファイル
{
  "$id": "items",
  "type": "object",
  "isDefaultOpen": true,

  "properties": {
    "data": {
      "type": "array",
      "overrideCodeGeneratePropertyName": "ItemMasterElement",
      
      "items": {
        "type": "object",
        "thumbnail": "imagePath",

        "properties": {
          "itemGuid": {
            "type": "string",
            "format": "uuid",
            "autoGenerated": true
          },

          "imagePath": {
            "type": "string",
            "pattern": "@imagePath",
            "thumbnail": true,
            "optional": true
          },

          "name": {
            "type": "string"
          },

          "maxStack": {
            "type": "integer",
            "default" : 100
          }
        }
      }
    }
  }
}
モデルコード
namespace Mooresmaster.Model.ItemsModule
{
    public class ItemMasterElement 
    {
        public global::System.Guid ItemGuid { get; }
        public string? ImagePath { get; }
        public string Name { get; }
        public int MaxStack { get; }
        
        public ItemMasterElement(global::System.Guid ItemGuid, string? ImagePath, string Name, int MaxStack)
        {
            this.ItemGuid = ItemGuid;
            this.ImagePath = ImagePath;
            this.Name = Name;
            this.MaxStack = MaxStack;
        }
        
        
    }
}
ローダーコード
namespace Mooresmaster.Loader.ItemsModule
{
    public static class ItemMasterElementLoader
    {
        public static Mooresmaster.Model.ItemsModule.ItemMasterElement Load(global::Newtonsoft.Json.Linq.JToken json)
        {
            if (json["itemGuid"] == null)
            {
                var errorMessage = $"SchemaLoadError\nErrorPath: {json.Path}\nTargetProperty: itemGuid\n\n";
                
                var parent = json.Parent;
                while (parent != null)
                {
                //    errorMessage += parent.ToString() + "\n";
                    parent = parent.Parent;
                }
            
                throw new global::System.Exception(errorMessage);
            }
            
            if (json["name"] == null)
            {
                var errorMessage = $"SchemaLoadError\nErrorPath: {json.Path}\nTargetProperty: name\n\n";
                
                var parent = json.Parent;
                while (parent != null)
                {
                //    errorMessage += parent.ToString() + "\n";
                    parent = parent.Parent;
                }
            
                throw new global::System.Exception(errorMessage);
            }
            
            if (json["maxStack"] == null)
            {
                var errorMessage = $"SchemaLoadError\nErrorPath: {json.Path}\nTargetProperty: maxStack\n\n";
                
                var parent = json.Parent;
                while (parent != null)
                {
                //    errorMessage += parent.ToString() + "\n";
                    parent = parent.Parent;
                }
            
                throw new global::System.Exception(errorMessage);
            }
            
            
        
            global::System.Guid ItemGuid = Mooresmaster.Loader.BuiltinLoader.LoadUUID(json["itemGuid"]);
            string? ImagePath = ((json["imagePath"] == null) ? null : Mooresmaster.Loader.BuiltinLoader.LoadString(json["imagePath"]));
            string Name = Mooresmaster.Loader.BuiltinLoader.LoadString(json["name"]);
            int MaxStack = Mooresmaster.Loader.BuiltinLoader.LoadInt(json["maxStack"]);
        
            return new Mooresmaster.Model.ItemsModule.ItemMasterElement(ItemGuid, ImagePath, Name, MaxStack);
        }
    }
}

タイプごとのInterface実装

ブロックタイプ事にスキーマが違うという問題をコード側ではInterfaceを用いて解消しています。
以下の例ではベルトコンベアとチェストのそれぞれの生成コードを記述しています。

スキーマ
{
  "blockParam": {
    "oneOf": [
      {
        "if": {
          "properties": {
            "blockType": {
              "const": "BeltConveyor"
            }
          }
        },
        "then": {
          "type": "object",

          "properties": {
            "beltConveyorItemCount": {"type": "integer", "default": 1},
            "timeOfItemEnterToExit": {"type": "number", "default": 1},
            "slopeType": {"type": "string", "enum": ["Straight", "Up", "Down"] },
            "inventoryConnectors": {
              "$ref": "inventoryConnects"
            }
          }
        }
      },
      {
        "if": {
          "properties": {
            "blockType": {
              "const": "Chest"
            }
          }
        },
        "then": {
          "type": "object",
          "properties": {
            "chestItemSlotCount": {"type": "integer", "default": 5},
            "inventoryConnectors": {
              "$ref": "inventoryConnects"
            }
          }
        }
      }
    ]
  }
}
ベルトコンベアの生成コード

public class GearBeltConveyorBlockParam  : Mooresmaster.Model.BlocksModule.IBlockParam
{
    public int BeltConveyorItemCount { get; }
    public float BeltConveyorSpeed { get; }
    public float RequireTorque { get; }
    public string SlopeType { get; }
    public Mooresmaster.Model.InventoryConnectsModule.InventoryConnects InventoryConnectors { get; }
    public Mooresmaster.Model.GearModule.Gear Gear { get; }
    
    public GearBeltConveyorBlockParam(int BeltConveyorItemCount, float BeltConveyorSpeed, float RequireTorque, string SlopeType, Mooresmaster.Model.InventoryConnectsModule.InventoryConnects InventoryConnectors, Mooresmaster.Model.GearModule.Gear Gear)
    {
        this.BeltConveyorItemCount = BeltConveyorItemCount;
        this.BeltConveyorSpeed = BeltConveyorSpeed;
        this.RequireTorque = RequireTorque;
        this.SlopeType = SlopeType;
        this.InventoryConnectors = InventoryConnectors;
        this.Gear = Gear;
    }
}
チェストの生成コード
public class ChestBlockParam  : Mooresmaster.Model.BlocksModule.IBlockParam
{
    public int ChestItemSlotCount { get; }
    public Mooresmaster.Model.InventoryConnectsModule.InventoryConnects InventoryConnectors { get; }
    
    public ChestBlockParam(int ChestItemSlotCount, Mooresmaster.Model.InventoryConnectsModule.InventoryConnects InventoryConnectors)
    {
        this.ChestItemSlotCount = ChestItemSlotCount;
        this.InventoryConnectors = InventoryConnectors;
    }  
}

パラメータの共通化Interface実装

たとえば、「電気で動く機械」と「歯車で動く機械」はいくつかのプロパティは共通していますが、クラスとしては別となってしまいます。そのため、この「機械としてのプロパティ」を使えるようにするため、プロパティのInterface機能を実装しました。

具体的には、Interfaceのスキーマを記述し、各objectに実装するInterfaceを記述します。
以下のようなInterfaceが生成されて実装されるため、プロパティを一部分だけ共通化することができます。

Interfaceの定義スキーマ
{
	"defineInterface": [
		{
			"interfaceName": "IMachineParam",
			"properties": {
				"inputSlotCount": {
					"type": "integer"
				},
				"outputSlotCount": {
					"type": "integer"
				}
			}
		}
	]
}
生成されるコード
namespace Mooresmaster.Model.BlocksModule
{
    public interface IMachineParam
    {
        public int InputSlotCount { get; }
        public int OutputSlotCount { get; }
    }
}

まとめ

これらのツールにより、頻繁に発生するスキーマの変更や複雑なデータ入力に対応できるマスタ管理ツールができたと思います。
これらのツールはOSSで公開しているため、興味のある方は除いてみてください。

また、現在mooreseditorを開発してくれるフロントエンドエンジニアを大募集中です!もし興味のある方はお声がけいただければとおもいます!!

https://github.com/moorestech/moorestech

https://github.com/moorestech/mooreseditor

Discussion