2016年4月19日火曜日

[Unity]ScriptableObjectをモデルクラスとして使う

ScriptableObjectでデータを定義しつつそのままモデルクラスとして扱ってしまおうというお話。
ゲームロジックと表示部分の分離ができるようになります。

何はともあれ作ってみる

突然ですが、敵を表現するクラスをScriptableObjectで作ります。ScriptableObjectで作るとEditor上でレベルデザインができるようになります。*1
敵はそれぞれがHPとPowerを持っているとします。...できました。なんとなくダメージを食らうメソッドを持ってます。
using UnityEngine;
[CreateAssetMenu(menuName = "ScriptableObject/Enemy", fileName = "NewEnemy")]
public class Enemy : ScriptableObject{
public string Name;
public int Hp;
public int Power;
public virtual void Damage(int damageValue){
Hp -= damageValue;
}
}
view raw Enemy.cs hosted with ❤ by GitHub


早速3種類のデータを作ってみました。

さて、ここで作ったのは敵の種類であるため、HPが減ったからといってアセットファイルを書き換えたくはありません。*2
また、それぞれの種類の敵は複数居るかもしれません。スライム2匹とか。
なので、これらのScriptableObjectは複製して使用することにします。ScriptableObject.Instantiate()で複製が可能です。複製した後はHPが減ろうがどうなろうが好き放題です。
とてもいいですね。

GameObjectにくっつける

これをGameObjectにくっつけて使いたい場合はこうします。汎用ジェネリクスクラス。
using UnityEngine;
public abstract class ScriptableObjectInstancePresenter<T> : MonoBehaviour where T : ScriptableObject{
public T m_model;
protected T model { get; set; }
protected void Awake() {
model = ScriptableObject.Instantiate<T>(m_model);
}
}

こっちは実際に利用する用のクラス。
public class EnemyPresenter : ScriptableObjectInstancePresenter<Enemy> {
//...
}

GameObjectのインスペクタにアセットファイルを指定すると、OnAwakeで複製して、以降modelからインスタンスにアクセスできるようになります。
m_modelにアクセスするとアセットが書き換えられちゃうので注意です。
すでに複製されたオブジェクトを取り扱う場合にはmodelをpublicにして露出させちゃえばOKです。

MonoBehaviourとScriptableObjectが別々のスクリプトになるので、ゲームのロジックと表示処理を分離することが可能です。これがやりたかった。

複製されたオブジェクトに固有の動的データを取り扱う

静的なデータだけでなく動的なオブジェクトの状態を持たせることもできます。
例として、攻撃を受ける度に強くなるダルマという敵を考えて見ます。ダルマの仕様はこんな感じ。
  • ダメージを受けるとPowerが1上昇する
  • 3回ダメージを受けると残り体力が0になる
ここでは簡単のためにEnemyクラスを継承します。*3*4...こんな感じ?
using UnityEngine;
[CreateAssetMenu(menuName = "ScriptableObject/SubEnemy", fileName = "NewSubEnemy")]
public class SubEnemy : Enemy{
private int damageCount = 0;
public override void Damage(int damageValue){
base.Damage(damageValue);
++ damageCount;
++ Power;
if(damageCount == 3) Hp = 0;
}
}
view raw SubEnemy.cs hosted with ❤ by GitHub

privateメンバはインスペクタ上に表示されないので、敵のパラメータの設定の際に邪魔になりません。もし、damageCountを外のクラスに公開したい場合は、HideInInspector属性や自動実装プロパティを使うといい感じです。
ダルマが2匹居てもそれぞれが別の状態を持つことができます。

初期化

いろいろ実装してHPが回復できるようになったとします。前のままではHPは際限なく回復させることができるのでEnemyクラスに最大HPを保持させることにしましょう。現在のHPは最大HPで初期化したいですよね?
OnEnableを書いておけばInstantiateのタイミングで初期化されます。但し、UnityObjectの例に漏れずパラメータ付初期化はできません。
using UnityEngine;
[CreateAssetMenu(menuName = "ScriptableObject/Enemy", fileName = "NewEnemy")]
public class Enemy : ScriptableObject{
public string Name;
public int HpMax;
public int Power;
private int hp;
protected virtual void OnEnable() {
hp = HpMax;
}
public virtual void Damage(int damageValue){
hp -= damageValue;
}
}
view raw Enemy2.cs hosted with ❤ by GitHub


まとめ

  • ScriptableObjectをモデルとして使うときはInstantiateで複製しましょうね
  • ゲームロジックと表示処理を分離できるよ
  • パラメータとして設定したいメンバはInspecterに表示して動的なメンバは隠しましょう
  • OnEnableで初期化できるよ

*1: 他の方法としてはCSVとかExcelに敵を定義していく方法やPrefabを使う方法がある。他にもあるかも? それぞれ利点があるので一概にどれが良いとはいえない。

*2: Rsources.Load()で読み込んだScriptableObjectはスクリプト上からファイルを書き換えることが可能(但しゲーム起動時にリセットされる)。この性質を利用してシーン間をまたがるデータをScriptableObjectに持たせて管理する方法があったりする。

*3: ScriptableObjectは継承してポリモーフィズムを利かせることができるのが強みのひとつ。今回のケースではSubEnemyはEnemyと同等に扱うことができる。

*4: 後々の拡張を考えた場合、継承は安易に使うべきではないというのが俺ルール。継承だと複数の特性を組み合わせたりするのが難しいし。今回のケースだと「敵特性」から派生したダルマ特性ってScriptableObjectを作成して、Enemyは複数の敵特性を持っているっていう感じの実装にすると思う。最良の選択など無い。

1 件のコメント:

  1. はじめまして、大変参考になりました、ありがとうございます!

    こちらでも色々確認したところ、ScriptableObjectはそのままだとprivateメンバもシリアライズされますが、NonSerialized属性をつけるとシリアライズ対象外になります。

    フレームをカウントするprivateメンバなど、初期化が必要なメンバに対して付加すると、初期化用関数の実行やコールを省略できますよ!

    返信削除