個人開発ゲームでマスター管理ツールを作ってもらった話
この記事は Akatsuki Games Advent Calendar 2024 14日目の記事です。
開発中のゲーム
はじめに、イメージしやすいようにこのツールの開発に至ったゲームについて簡単に触れておきます。
- 名前:moorestech
- アニメRPG × 自動化工業ゲーム
- 工場を作ってアイテムを生産し、更に工場を大きくしていくゲーム
- 2021年から作り始めてはや3年半
マスタデータとは
マスタ管理ツールはマスターデータを編集するためのツールなので、マスターデータについて軽く触れておきます。
マスタデータはゲームやプロダクトにおける必要なデータ全般のことを指します。
たとえば、以下のようなデータがマスタデータとして登録されています。
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を開発してくれるフロントエンドエンジニアを大募集中です!もし興味のある方はお声がけいただければとおもいます!!
Discussion