Claude Media
Claude CodeでScriptableObjectを設計する — Unityの敵とアイテムをデータ駆動に

Claude CodeでScriptableObjectを設計する — Unityの敵とアイテムをデータ駆動に

UnityのScriptableObjectで敵とアイテムの定義を作るとき、Claude Codeに任せる範囲を決めます。.assetを手で編集させない規約、C#の型設計、EditModeテストでの検証までを手順にしました。

Claude Codeに敵やアイテムのデータ定義を任せるなら、Claudeに書かせるのはC#の型と検証コードまでです。データそのものの.assetファイルは、Unity Editorのインスペクターで作って編集します。この線引きをCLAUDE.mdと権限設定に書いておけば、定義が増えても壊れにくいデータ駆動の構成になります。

ScriptableObjectは、GameObjectに付かず、アセットとしてプロジェクトに置かれるデータ入れ物です。ここでは「敵の定義」と「アイテムの定義」を例に、規約の置き方、型の設計、EditModeテストによる検証の順に進めます。

ScriptableObjectを敵とアイテムの定義に使う理由

ScriptableObjectはUnityEngine.Objectから派生するシリアライズ可能な型です。MonoBehaviourと違って、プロジェクト内にアセットとして存在します。インスペクターのフィールドへドラッグして参照できるのも、UnityEngine.Objectの派生型だからです。

敵やアイテムの定義に向く理由は、データの持ち方にあります。変わらない数値をMonoBehaviourに持たせたプレハブは、インスタンスを作るたびに値のコピーを抱えます。ScriptableObjectにデータを置いて各プレハブから参照すれば、メモリ上の実体は1つで済みます。

前と後

敵の基本ステータスの持たせ方

BEFORE

MonoBehaviourに直接持たせる

プレハブのインスタンスごとにHPや移動速度のコピーができます。数値を調整するたびに、プレハブ側を開いて直します。

AFTER

ScriptableObjectに置いて参照する

定義アセットが1つあり、プレハブは参照するだけです。バランス調整は定義アセットの1箇所で済みます。

Claude Codeとの相性もあります。数値の調整が.assetに閉じ、ロジックはC#に残るため、Claudeに書かせる対象(C#)と人が触る対象(インスペクターのデータ)が自然に分かれます。ゲーム全体のC#とCLIによる検証はClaude CodeでUnityゲーム開発を進める手順で扱っています。ここではScriptableObjectに絞ります。

Claudeに任せる範囲を先に決める

最初に決めるのは、どこまでをClaudeの仕事にするかです。次の表を出発点にします。

対象Claudeに任せるか理由
定義クラスのC#(EnemyDefinitionなど)Claudeに任せるか任せる理由テキストで完結し、コンパイルとテストで検証できる
検証メソッドとEditModeテストClaudeに任せるか任せる理由失敗を機械的に返せる
.assetの作成と数値編集Claudeに任せるか任せない理由EditorのメニューとインスペクターがUnityの正規の経路
.assetのYAMLを直接書き換えるClaudeに任せるか禁止理由参照の番号を壊しやすい(次節)

Unityのシーンやアセットのテキスト形式(YAML)では、各オブジェクトが--- !u!で始まる独立したYAML文書として書かれます。続く番号はファイル内で一意ですが、任意に割り振られたものです。この番号を人間もClaudeも意味づけられないまま手で書き換えると、参照が壊れます。さらに、アセットをテキストで保存する設定になっていないプロジェクトでは、そもそも差分が読めません。

アセットの値を変えたいときは、インスペクターで編集する方法を選びます。スクリプトからScriptableObjectの値を書き換える場合は、EditorUtility.SetDirtyの呼び出しが要ります。これがないと、Editorを閉じて開き直したときに値が元に戻ります。この一点は、Claudeに書かせるEditor拡張すべてに共通する規則としてCLAUDE.mdに置く価値があります。

CLAUDE.mdとパススコープのルールに規約を置く

規約は2か所に分けます。常に効かせたい短いものはCLAUDE.md、.assetや定義クラスに触れるときだけ効かせたいものは.claude/rules/に置きます。CLAUDE.mdは200行以内を目安に短く保ち、一部のファイルだけに関わる指示はパススコープのルールへ移します。

## ゲームデータの規約
 
- 敵・アイテムなどの定義データはScriptableObjectで持つ。MonoBehaviourに数値を直書きしない
- ScriptableObjectの`.asset`ファイルはYAMLを直接編集しない。作成・編集はEditorのインスペクターで行う
- 定義クラスのフィールドは`[SerializeField] private`にし、読み取り用のプロパティを別に公開する
- 実行中に変わる値(現在HPなど)は定義クラスに持たせず、MonoBehaviour側に持つ
- スクリプトからScriptableObjectを書き換えるEditorコードでは`EditorUtility.SetDirty`を呼ぶ
- 定義クラスを追加・変更したら`Validate()`とEditModeテストを更新し、テストを通してから完了報告する

パススコープのルールは、YAMLフロントマターのpathsで対象を絞ります。次の例は.assetと定義クラスのフォルダを対象にしています。

---
paths:
  - "Assets/**/*.asset"
  - "Assets/Scripts/Data/**/*.cs"
---
 
# データ定義ファイルの扱い
 
- `.asset`を読むのは構わないが、書き込まない。値を変えたいときは、インスペクターで変更する手順を示す
- 新しいフィールドを足したら、既存の`.asset`に値の入れ直しが必要かどうかを報告する

このルールは、Claudeが一致するファイルに対してRead・Write・Editツールを使ったときに読み込まれます。単一ファイルへのcatやheadのようなBashコマンドも、読み取りとみなされるものは対象です。

ルールもCLAUDE.mdも、Claudeに渡される文脈であって強制ではありません。「.assetを書き換えない」を確実にしたいなら、権限の拒否ルールを足します。

{
  "permissions": {
    "deny": [
      "Edit(**/*.asset)"
    ]
  }
}

ファイル編集の権限はEdit(path)ルールで判定されます。Write(path)のルールは受理されても照合されないので、書き方はEditにそろえます。>によるリダイレクトやteeの書き込み先もEditルールで照合されます。ただし、それ以外のコマンドによる書き込みまで止める保証は権限のドキュメントにありません。より厳密に守るなら、PreToolUseフックでEdit|Writeの呼び出しを検査する方法を組み合わせます。権限ルールの書式全般はBash権限ルールの複合コマンド判定も参照してください。

敵とアイテムの定義クラスを設計する

規約を置いたら、型の設計を依頼します。指示は「Assets/Scripts/DataにEnemyDefinitionとItemDefinitionをScriptableObjectで作る。CLAUDE.mdの規約に従い、Validate()も付ける」程度で足ります。次のコードは、返ってくる型の一例です。実際の出力は環境やプロジェクトによって変わります。

using System;
using System.Collections.Generic;
using UnityEngine;
 
[CreateAssetMenu(fileName = "Item", menuName = "Game/Item Definition")]
public class ItemDefinition : ScriptableObject
{
    [SerializeField] private string displayName;
    [SerializeField] private int price = 1;
    [SerializeField] private int stackLimit = 99;
 
    public string DisplayName => displayName;
    public int Price => price;
    public int StackLimit => stackLimit;
 
    public IEnumerable<string> Validate()
    {
        if (string.IsNullOrEmpty(displayName)) yield return "displayNameが空です";
        if (price < 0) yield return "priceが負の値です";
        if (stackLimit < 1) yield return "stackLimitは1以上にしてください";
    }
}

敵の定義は、アイテムへの参照と確率の重みをまとめたDropEntryを持ちます。

[Serializable]
public struct DropEntry
{
    public ItemDefinition item;
    public float weight;
}
 
[CreateAssetMenu(fileName = "Enemy", menuName = "Game/Enemy Definition")]
public class EnemyDefinition : ScriptableObject
{
    [SerializeField] private string displayName;
    [SerializeField] private int maxHp = 10;
    [SerializeField] private float moveSpeed = 2f;
    [SerializeField] private List<DropEntry> drops = new List<DropEntry>();
 
    public string DisplayName => displayName;
    public int MaxHp => maxHp;
    public float MoveSpeed => moveSpeed;
    public IReadOnlyList<DropEntry> Drops => drops;
 
    public IEnumerable<string> Validate()
    {
        if (string.IsNullOrEmpty(displayName)) yield return "displayNameが空です";
        if (maxHp < 1) yield return "maxHpは1以上にしてください";
        if (moveSpeed < 0f) yield return "moveSpeedが負の値です";
        foreach (var drop in drops)
        {
            if (drop.item == null) yield return "ドロップ表にitemが未設定の行があります";
            if (drop.weight <= 0f) yield return "ドロップ表のweightは0より大きくしてください";
        }
    }
}

[CreateAssetMenu]を付けた型は、Assets/Createのサブメニューに並びます。fileNameは新規インスタンスの既定のファイル名、menuNameはメニューの表示名です。ここで作ったGame/Enemy Definitionから、敵ごとの.assetを1つずつ作ります。

型の設計でClaudeが外しやすい点

Unityのシリアライズには、一般的なC#と違う規則があります。Claudeが標準的なC#の流儀で書くと、保存されないフィールドを作りがちです。

落とし穴

シリアライズされない書き方

  • プロパティだけで値を持つ

    Unityが保存するのはフィールドで、プロパティではありません。値の実体は[SerializeField]付きのフィールドに置き、プロパティは読み取り用に添えます。

  • readonlyやconstにする

    readonly、const、staticのフィールドはシリアライズされません。インスペクターから変えられる値には付けないでください。

  • コレクションを入れ子にする

    多次元配列、ジャグ配列、コレクションを別のコレクションに直接入れた構造はシリアライズされません。ドロップ表のDropEntryのように、専用の型で1段包みます。

  • 独自クラスにSerializableを付けない

    フィールドとして使うカスタムの型には[Serializable]が必要です。この属性は継承されないので、継承階層の各クラスに付けます。

Dictionary<TKey, TValue>は[SerializeField]を付ければシリアライズされますが、敵やアイテムの定義にはリストとIDで十分なことが多く、まずは使わない設計をClaudeに指示すると扱いが単純になります。CLAUDE.mdに一行で書いておくと効きます。

定義の検証をEditModeテストに落とす

Validate()だけでは、誰かが呼ばない限り動きません。EditModeテストで、プロジェクトにあるすべての敵定義の.assetを読み込み、検証を通す仕組みにします。Claudeに「Assets/Data/Enemiesの全アセットを検証するEditModeテストを書いて」と依頼します。

using System.IO;
using System.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
 
public class EnemyDefinitionTests
{
    private static string[] EnemyAssetPaths()
    {
        var dir = Path.Combine(Application.dataPath, "Data/Enemies");
        return Directory.GetFiles(dir, "*.asset")
            .Select(p => "Assets" + p.Replace('\\', '/').Substring(
                Application.dataPath.Length))
            .ToArray();
    }
 
    [Test]
    public void 敵定義のアセットが1つ以上ある()
    {
        Assert.Greater(EnemyAssetPaths().Length, 0);
    }
 
    [Test]
    public void すべての敵定義が検証を通る()
    {
        foreach (var path in EnemyAssetPaths())
        {
            var enemy = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(path);
            Assert.IsNotNull(enemy, path);
            CollectionAssert.IsEmpty(enemy.Validate().ToList(), path);
        }
    }
}

1本目のテストは、フォルダが空のまま検証が「通ってしまう」状況を防ぐためのものです。テストが何も検証しないまま緑になるのは、Claudeに書かせたテストで起きやすい失敗です。

テストはバッチモードで実行できます。-testPlatformを省略した場合の動作は、Unityゲーム開発の手順で扱ったとおりです。ここではEditModeを明示します。

/Applications/Unity/Hub/Editor/6000.6.0f1/Unity.app/Contents/MacOS/Unity \
  -batchmode -runTests \
  -projectPath . \
  -testPlatform EditMode \
  -testResults /tmp/unity-test-results.xml

結果はXMLファイルに出力されるので、Claudeにこのファイルを読ませます。失敗したテスト名には、Assertのメッセージに入れた.assetのパスが含まれます。「どの敵のどの項目が不正か」がそのまま修正指示になります。

CLAUDE.mdには、完了条件として次の1行を足しておきます。

- データ定義のC#を変更したら、EditModeテストを上のコマンドで実行し、失敗が0件になるまで直す

実行ファイルのパスは、権限のallowルールで-batchmode始まりの形に合わせておくと、確認プロンプトを減らせます。手順は前掲の記事にまとめています。

定義が増えたときの運用上の注意

敵が数十体になると、設計よりも運用の問題が出てきます。

ビルドしたゲームでは、ScriptableObjectの値を保存できません。Editorでは編集モードでもプレイモードでもScriptableObjectの値を保存できますが、スタンドアロンのPlayerでは保存済みのデータを読み取れるだけです。「現在HP」「所持数」のような実行中に変わる値を定義クラスに持たせると、Editorでは動いてもビルドで保存されません。CLAUDE.mdの規約で、変わる値はMonoBehaviour側に置くよう書いたのはこのためです。

.assetのマージ衝突には、UnityYAMLMergeが既定では効きません。UnityYAMLMergeが標準で意味的にマージするのは、シーン、プレハブ、.metaファイルです。.assetのようなYAML形式のファイルは、既定ではフォールバックのマージツールに渡されます。--force引数を付ければUnityYAMLMergeで.assetもマージできますが、チームで敵のデータを同時に編集する運用なら、衝突しにくいようにアセットを1体1ファイルに分けておくのが現実的です。Claudeには「1体につき1アセット、1つの巨大な定義に敵をまとめない」と指示しておきます。

値の範囲チェックは、OnValidateにも置けます。OnValidateは、スクリプトの読み込み時やインスペクターの値の変更時にEditorで呼ばれる関数です。maxHpが1未満なら1に直す処理を入れれば、入力時点で不正値を弾けます。ただしValidate()のように「どの項目が不正か」を報告するほうがテストと相性が良く、両方を持たせる構成が使いやすくなります。

作業の流れ

手順

ScriptableObject定義を追加する流れ

  1. 1

    規約を先に置く

    CLAUDE.mdのゲームデータ規約、.claude/rules/のパススコープルール、Edit(**/*.asset)の拒否ルールを入れます。

  2. 2

    型と検証をClaudeに書かせる

    EnemyDefinitionのような定義クラス、Validate()、EditModeテストを一度に依頼します。

  3. 3

    アセットはEditorで作る

    Assets/Create/Game/Enemy Definitionから作り、インスペクターで数値を入れます。

  4. 4

    テストを回して直す

    バッチモードでEditModeテストを実行し、失敗した.assetのパスを手がかりに値を直します。

Editor画面そのものの操作(シーン上のオブジェクト配置やプレハブの配線)まで自動化したい場合は、Unity公式MCPサーバーをClaude Codeで使う方法が選択肢になります。データのテキストを多言語化する場合はUnityのString Table移行の手順に続きます。

まとめ

定義が増えるほど手戻りを左右するのは、フィールドを足したときの扱いです。コンパイルが通っても、既存の.assetでは新しい項目が未設定のまま残ることがあります。パススコープのルールに書いた「既存アセットに値の入れ直しが要るかを報告する」という一文は、敵が数十体になった段階で最も効きます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/memory
  2. code.claude.com/docs/en/permissions
  3. docs.unity3d.com/6000.6/Documentation/Manual/class-ScriptableObject.html
  4. docs.unity3d.com/6000.6/Documentation/ScriptReference/ScriptableObject.html
  5. docs.unity3d.com/6000.6/Documentation/ScriptReference/CreateAssetMenuAttrib…
  6. docs.unity3d.com/6000.6/Documentation/Manual/script-serialization-rules.html
  7. docs.unity3d.com/6000.6/Documentation/Manual/SmartMerge.html
  8. docs.unity3d.com/6000.6/Documentation/Manual/test-framework/run-tests-from-…
この記事を共有:XはてブLinkedIn