0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

開発で使った便利機能をOSSとして公開してみた

Last updated at Posted at 2025-12-14

はじめに

こんにちは、Streamerioでゲーム側を担当していた guu です。
アドベントカレンダーも14日目。僕の記事はひとまずこれで最後です。

今回は、Unity開発で使った「自動でScriptableObjectに登録する仕組み」をOSSとして公開したので、機能の概要と公開にあたって意識した点をまとめます。

開発理由

曲や3DオブジェクトなどをScriptableObjectに登録して、enumをキーに取り出すみたいなことをよくやっていました。
ただ、開発のたびに ScriptableObject を作って、手でアセットを登録して…
が面倒だったので、自動化するツールとして作りました。

ScriptableRegistry

今回僕が作ったOSSは、ScriptableRegistryです。
大きな機能としては、以下の2つです。

  • アセット名から生成したenumをキーにして、アセットを引けるScriptableObjectを生成
  • ScriptableObjectを生成するためのウィンドウ

使い方

導入

UnityのPackage Managerで、Add package from git URL...を選び、以下を入力すると導入できます。

https://github.com/guuolta/ScriptableRegistry.git?path=Packages/com.guuolta.ScriptableRegistry

また、導入時にエラーが出る場合は、System.Runtime.CompilerServices.UnsafeをDLLとして、Pluginsフォルダに置くことで解決できます。

手順は次の通りです。

  1. NuGet Gallery の System.Runtime.CompilerServices.Unsafe から .nupkg をダウンロード
  2. .nupkgは、zip なので展開し、lib/netstandard2.0/(または近いターゲット)配下の System.Runtime.CompilerServices.Unsafe.dll を取り出す
  3. Unity プロジェクトの Assets/Plugins/.dll を配置する

この方法だと、asmdef(アセンブリ分割)を用意して参照を設定しなくてもUnsafeを利用できるため、参照解決エラーが消えます。

スクリプトの生成

Unityのツールバーから、Window > ScriptableRegistry > CreateWindowを開きます。
すると以下の画面が表示されます。
Window.png
パラメータはそれぞれ以下のような意味です。

  • Script Settings: 生成するスクリプトの設定
    • Script Name: スクリプト名
    • Script Save Path: スクリプトの保存先
    • Script Namespace: スクリプトの名前空間
  • Editor Script Settings: 生成するエディタ拡張のスクリプトの設定
    • Editor Script Name: スクリプト名
    • Editor Save Path: スクリプトの保存先
    • Editor Namespace: スクリプトの名前空間
  • Create Asset Menu: ScriptableObject生成時のメニュー設定
    • Menu Name: ScriptableObject生成時のメニュー名
    • File Name: ScriptableObjectのファイル名
  • Registry Types: ScriptableObjectで管理するパラメータ設定
    • Key Enum Namespace: キーとなるenumの名前空間
    • Key Enum Name: キーとなるenum
    • Value Class Namespace: 値となるクラスの名前空間
    • Value Class Name: 値となるクラス名
    • Target File Class Name: フォルダから探す元になる型(例:GameObject, AudioClip)

Editor Script Settingsなどは、基本的に自動で設定されますが、右のボタンを押すことで自由にカスタマイズできます。

最後にSubmitボタンを押すことで、スクリプトが生成されます。

ScriptableObjectの設定

まず、先ほどCreate Asset Menuで指定したメニューから作成します。
ScriptableObjectのインスペクターは以下のような画面になります。
SO.png

そして、パラメータを入力します。

  • Paths
    • FolderPath: アセットを探索するフォルダまでのパス
    • EnumPath: enumが保存されているフォルダのパス
  • Enum Settings
    • EnumNamespace: enumの名前空間
    • EnumFileName: enumのスクリプト名
  • Filters
    • File Extensions: 探索するアセットの拡張子
    • Ignore FolderNames: 探索しないフォルダ名

パラメータを入力後、Generate Enum / Jsonボタンを押します。
すると、enumが生成されます。
Register Dictionary From Existing Enumボタンを押すとアセットがScriptableObjectに登録されます。
結果は、Dictionary欄に保存されます。

Resetボタンを押すことで、辞書を空にできます。

カスタマイズ

アセットそのものではなく別の型として登録したい場合は、自動生成されたエディタのスクリプトを変更することで実装できます。

以下の例では、GetDefaultParams()enumの先頭にNoneを追加しています。
また、CreateValue(GameObject file)で、GameObject型のファイルをフォルダから探し、プレファブにアタッチされているBlockBehaviourScriptableObjectに登録しています。

[CustomEditor(typeof(BlockRegisterObject))]
public class BlockRegisterObjectEditor : ScriptableRegistryObjectBaseEditor<BlockRegisterObject, BlockType, BlockBehaviour, GameObject>
{
    protected override string[] GetDefaultParams()
    {
        return new string[] { "None" };
    }

    protected override BlockBehaviour CreateValue(GameObject file)
    {
        return file.GetComponent<BlockBehaviour>();
    }
}

ScriptableObject

ScriptableObjectを作る上でこだわった機能は以下の3つです。

  • シリアライズ可能な辞書
  • スクリプトの自動生成
  • enumの生成

シリアライズ可能な辞書

Unity では、標準のDictionaryをインスペクター上で編集できないため、独自に「シリアライズ可能な辞書」を実装しました。

この辞書は、できるだけC#のDictionaryと近い感覚で使えることを意識しています。
内部では、

  • インスペクターに表示するための「キーと値のペアのリスト」
  • 実行時に使う通常の Dictionary

の2つを持ち、両者が常に同期するようにしています。

プログラム
/// <summary>
/// Serializable dictionary-like container for Unity
/// </summary>
[Serializable]
public class SerializableDictionary<TKey, TValue>
    : ISerializationCallbackReceiver, IReadOnlyDictionary<TKey, TValue>
{
    /// <summary>
    /// Unity-serializable key/value pair (KeyValuePair is not serialized by Unity)
    /// </summary>
    [Serializable]
    private struct Pair
    {
        public TKey Key;
        public TValue Value;
    }

    // Data for Inspector/serialization
    [SerializeField]
    private List<Pair> _pairs = new();

    // Runtime dictionary (source of truth)
    private readonly Dictionary<TKey, TValue> _dict = new();

    /// <summary>
    /// Get/Set value by key
    /// </summary>
    public TValue this[TKey key]
    {
        get => _dict[key];
        set => Set(key, value);
    }

    public int Count => _dict.Count;
    public IEnumerable<TKey> Keys => _dict.Keys;
    public IEnumerable<TValue> Values => _dict.Values;

    public bool ContainsKey(TKey key) => _dict.ContainsKey(key);
    public bool TryGetValue(TKey key, out TValue value) => _dict.TryGetValue(key, out value);

    /// <summary>
    /// Enable foreach over key/value pairs
    /// </summary>
    public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() => _dict.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    /// <summary>
    /// Add or update a value and keep the serialized list in sync
    /// </summary>
    public void Set(TKey key, TValue value)
    {
        _dict[key] = value;

        int index = _pairs.FindIndex(p =>
            EqualityComparer<TKey>.Default.Equals(p.Key, key));

        if (index >= 0)
            _pairs[index] = new Pair { Key = key, Value = value };
        else
            _pairs.Add(new Pair { Key = key, Value = value });
    }

    /// <summary>
    /// Add a new key/value (throws if key exists)
    /// </summary>
    public void Add(TKey key, TValue value)
    {
        _dict.Add(key, value);
        _pairs.Add(new Pair { Key = key, Value = value });
    }

    /// <summary>
    /// Remove a key/value and sync the list
    /// </summary>
    public bool Remove(TKey key)
    {
        if (!_dict.Remove(key)) return false;

        int index = _pairs.FindIndex(p =>
            EqualityComparer<TKey>.Default.Equals(p.Key, key));
        if (index >= 0) _pairs.RemoveAt(index);

        return true;
    }

    /// <summary>
    /// Clear all entries
    /// </summary>
    public void Clear()
    {
        _dict.Clear();
        _pairs.Clear();
    }

    /// <summary>
    /// Return a normal Dictionary copy
    /// </summary>
    public Dictionary<TKey, TValue> ToDictionary() => new Dictionary<TKey, TValue>(_dict);

    public void OnBeforeSerialize() { }

    /// <summary>
    /// After loading: rebuild runtime dict from list
    /// </summary>
    public void OnAfterDeserialize()
    {
        _dict.Clear();
        foreach (var p in _pairs)
        {
            _dict[p.Key] = p.Value;
        }
    }
}

スクリプトの自動生成

usingattribute など、よく出てくる部分はクラス化して扱いやすくしました。
それ以外(継承や名前空間など)は文字列で持っています。

結果として、設定を足していくだけでコードが整形・生成されるようになりました。

プログラム
public sealed class ClassCode : ScriptCode
{
    private const string BaseClassFormat = " : {0}";

    /// <summary>
    /// Build class script code.
    /// </summary>
    /// <param name="name">Class/file name.</param>
    /// <param name="usingModule">Using directives.</param>
    /// <param name="nameSpace">Namespace (empty for none).</param>
    /// <param name="attribute">Class attributes.</param>
    /// <param name="contents">Class body.</param>
    /// <param name="baseClassNames">Base class / interface names.</param>
    public ClassCode(
        string name,
        UsingModule usingModule = null,
        string nameSpace = Empty,
        ScriptAttribute attribute = null,
        string contents = Empty,
        params string[] baseClassNames)
    {
        var builder = StringBuilderFactory.Create();

        // using section
        if (usingModule != null)
        {
            builder.Append(usingModule.Contents);
        }

        // attributes
        var attributeContents = attribute?.Contents ?? string.Empty;

        // declaration line
        var scriptName = BuildScriptName(name, baseClassNames);

        // body
        if (string.IsNullOrEmpty(nameSpace) || nameSpace == Empty)
        {
            builder.AppendFormat(ScriptFormat, attributeContents, scriptName, contents);
        }
        else
        {
            builder.AppendFormat(NamespaceScriptFormat, nameSpace, attributeContents, scriptName, contents);
        }

        Contents = builder.ToString();
        StringBuilderFactory.Release(builder);
    }

    /// <summary>
    /// Copy constructor.
    /// </summary>
    public ClassCode(ClassCode code) : base(code) { }

    /// <summary>
    /// Build the declaration line (e.g., "public class Foo : Bar, IBaz").
    /// </summary>
    private static string BuildScriptName(string name, params string[] baseClassNames)
    {
        var builder = StringBuilderFactory.Create();
        builder.AppendFormat(ScriptNameFormat, "class", name);

        if (baseClassNames != null && baseClassNames.Length > 0)
        {
            var baseClassNamesString = string.Join(", ", baseClassNames);
            builder.AppendFormat(BaseClassFormat, baseClassNamesString);
        }

        var result = builder.ToString();
        StringBuilderFactory.Release(builder);
        
        return result;
    }
}

enumの生成

enumの要素を追加・削除すると、インスペクターに保存されている値と対応がズレてしまうことがあります。
しかもこのズレはエラーにならないため、気づかないまま不具合につながりがちです。

そこで、enumを生成するタイミングでJSONも同時に出力し、「パラメータ名」と「数値」の対応を保存するようにしました。
以降は 一度割り当てた数値は固定し、既存要素の数値は変更しない運用を前提にしています。

この仕組みにより、途中に新しい要素を追加しても、数値の重複やenumのズレが起きにくくなります。

JSON
[
  {
    "RawName": "None",
    "Name": "None",
    "Num": 5
  },
  {
    "RawName": "Block1",
    "Name": "Block1",
    "Num": 0
  },
  {
    "RawName": "Block2",
    "Name": "Block2",
    "Num": 1
  },
  {
    "RawName": "Block3",
    "Name": "Block3",
    "Num": 2
  },
  {
    "RawName": "Block4",
    "Name": "Block4",
    "Num": 3
  },
  {
    "RawName": "Block5",
    "Name": "Block5",
    "Num": 4
  }
]
enum
namespace ScriptableRegistry.Sample.Block
{
    public enum BlockType
    {
        None = 5,
		Block1 = 0,
		Block2 = 1,
		Block3 = 2,
		Block4 = 3,
		Block5 = 4,

    }
}

OSS作成手順

OSSを作成した手順を紹介します。

フォルダの準備

まず、Packagesフォルダにcom.guuolta.ScriptableRegistryというフォルダを作成しました。
調べたところ、com.<ユーザー名>.<OSS名>という形式がよく使われているようだったので、これに合わせました。

次に以下のフォルダを作りました。

  • Runtime
    • ランタイムで動かすスクリプト
  • Editor
    • エディタ拡張など

package.jsonの準備

OSSのフォルダ直下にpackage.jsonを作成します。
package.jsonには、以下のような情報を書きます。

  • name: パッケージ名(例:com.guuolta.ScriptableRegistry
  • displayName: Package Managerに表示される名前
  • version: OSSのバージョン(最初は 0.1.0 が多い)
  • unity: 対応する Unity のメジャー/マイナーバージョン(例:6000.3
  • unityRelease: リリース識別子(例:0f1
  • description: OSSの説明
  • author: 著者情報
    • name: 著者名
    • url: 著者のGitHubのURL

実装例

{
  "name": "com.guuolta.scriptableregistry",
  "displayName": "Scriptable Registry",
  "version": "0.1.0",
  "unity": "6000.3",
  "unityRelease": "0f1",
  "description": "Auto-generate enums and registries from assets.",
  "author": {
    "name": "guuolta",
    "url": "https://github.com/guuolta"
  }
}

アセンブリ分割

UnityのOSSでは、RuntimeEditorを分けて、asmdef(Assembly Definition)を切るのが定番です。
こうしておくと、ビルド時にEditor用コードが混ざらない & 依存関係が分かりやすい状態にできます。

asmdefは、分割したいフォルダにAssembly Definitionファイルを置くだけで作れます。
今回は RuntimeEditorにそれぞれasmdefを作りました。

Editor側は、Runtime を参照したいので、EditorのasmdefのAssembly Definition References に、Runtimeのasmdefを追加します。
さらに、PlatformsEditorのみにすることで、Editorフォルダのコードがビルドに入らないようにしています。

スクリーンショット 2025-12-13 23.29.17.png

OSSだからこそ気をつけたこと

外部ライブラリの利用

今回、最適化目的で ZString を導入しています。
ただ、外部ライブラリを入れたくない人もいると思ったので、ZStringがなくても動くようにしました。

そこで、asmdefのVersion Definesを使い、ZStringが導入されている場合だけ、#defineを有効にして処理を切り替えています。

設定項目の意味はざっくり以下です。

  • If Resource:導入したいアセンブリ(/パッケージ)
  • Version Is:指定したパッケージが「このバージョン以上」のときに有効化(今回は、0.0.0を指定して「入っていればOK」にしています)
  • Set Define:条件を満たしたときに追加されるdefine#if で使う)

スクリーンショット 2025-12-13 23.49.37.png

コード側では、Set Defineで指定したUSE_ZSTRINGを使い、次のように分岐しています。

#if USE_ZSTRING
using Cysharp.Text;
#endif

namespace ScriptableRegistry.Util
{
    /// <summary>
    /// Simple factory that swaps between ZString and System.Text.StringBuilder depending on defines.
    /// </summary>
    public static class StringBuilderFactory
    {
#if USE_ZSTRING
        public static Utf16ValueStringBuilder Create() => ZString.CreateStringBuilder();
#else
        public static System.Text.StringBuilder Create() => new();
#endif

#if USE_ZSTRING
        public static void Release(Utf16ValueStringBuilder sb) => sb.Dispose();
#else
        public static void Release(System.Text.StringBuilder sb) {}
#endif
    }
}

ウィンドウを作る

開発中は「用意してある基底クラスを継承するだけ」で使えていたのですが、OSSとして公開するとなると、その前提を利用者に求めるのはハードルが高いと感じました。
そこで、初期導入の手間を減らすために、ScriptableObject一式を生成できる専用ウィンドウを用意しました。

このウィンドウは、必要な項目だけを入力すれば動くようにしつつ、必要なら自由に調整できるようにしています。
具体的には、名前空間や保存先などは候補を自動で埋める(またはボタンで自動設定できる)ようにして、入力ミスや手作業を減らしました。

Window.png

バリデーションをしっかりする

開発中は「変な文字を入れたら自分で直す」で回せていたのですが、OSSとして公開すると利用者側で同じ手作業が発生してしまいます。
そこで、生成処理の段階でできるだけコケないように、バリデーションを確認しっかり入れました。

たとえば、enum生成時に

  • C# の予約語が使われていたら先頭に _ を付ける
  • 先頭が数字など “識別子として無効な文字” だったら _ を付ける
  • 記号や空白など “使えない文字” は削除する

といった補正を自動で行います。

このあたりをツール側で吸収しておくことで、「生成に失敗したから手で直す」を減らし、安心して回せるようにしました。

コメントやREADMEの英語化

多くの人に使ってもらえるよう、コード内のコメントは英語で書き、README も英語版を用意しました。
(英語が苦手なので、ほぼ AI に頼りました)

まとめ

初めてのOSS作成で、知らない概念に触れることも多かったですが、なんとか形にできてひと安心です。
もし「ここ使いにくい」「こういう機能がほしい」などあれば、ぜひコメントやIssueで教えてください。改善していきます。

というわけで、僕のアドカレ担当は今回でいったん終了。最後はしっかり宣伝回でした。
次回からは、チームをまとめてくれたゲームエンジニアの三上さんにバトンタッチです。お楽しみに!

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?