【Unity】SerializeReferenceを試してみた
私は北海道からリモートで仕事をしていますが、ようやく冬の寒さも和らぎ、春の陽気を感じられる季節になってきました。
はじめに
Unity 2019.3 以降からリリースされた機能、Serialize Referenceをご存知でしょうか?これまでUnityでサポートされていたSerializeFieldとは違い、抽象型やインターフェースもシリアライズできるという機能です。
今回はこの機能を使ってエディタ上から振る舞いを編集できる機能を作ってみたいと思います。
開発環境
- Unity 2021.3.16f1
- UniTask 2.3.3
- UniRx 7.1.0
- Extenject 9.2.0
- OS: Mac OS Ventura 13.2.1
下準備
まずは下準備として、動かすものを3種類ほど作っていきたいと思います。- シーン上に置いてあるキューブ
- テキストを表示するUI
- カメラ
配置はいい感じにしておきます。
CubeView.cs
using UnityEngine;
public sealed class CubeView : MonoBehaviour
{
[SerializeField] private GameObject cube;
public void UpdateCubePosition(Vector3 pos)
{
cube.transform.position = pos;
}
}
TextView.cs
using UnityEngine;
using UnityEngine.UI;
public sealed class TextView : MonoBehaviour
{
[SerializeField] private GameObject canvasObj;
[SerializeField] private Text text;
public void SetCanvasActive(bool isActive)
{
canvasObj.SetActive(isActive);
}
public void UpdateText(string str)
{
text.text = str;
}
}
CameraView.cs
using UnityEngine;
public sealed class CameraView : MonoBehaviour
{
[SerializeField] private Camera mainCamera;
public void UpdateCameraPosition(Vector3 pos)
{
mainCamera.transform.position = pos;
}
}
それぞれに対応したModel, Presenterも用意しますCubeModel.cs
using System;
using Cysharp.Threading.Tasks;
using UniRx;
using UnityEngine;
public sealed class CubeModel
{
public IObservable<Vector3> CubePositionUpdated => cubePosition;
private readonly ReactiveProperty<Vector3> cubePosition = new(new(-5, 3, -10));
private float speed = 1f;
public async UniTask UpdateCubePositionAsync(Vector3 position)
{
var t = 0f;
var firstPos = cubePosition.Value;
while (t <= 1f)
{
t += speed * Time.deltaTime;
cubePosition.Value = Vector3.Lerp(firstPos, position, t);
await UniTask.Yield();
}
}
}
TextModel.cs
using System;
using Cysharp.Threading.Tasks;
using UniRx;
using UnityEngine;
public sealed class TextModel
{
public IObservable<string> TextUpdated => text;
public IObservable<bool> SetActiveChanged => setActiveChanged;
private readonly ReactiveProperty<string> text = new();
private Subject<bool> setActiveChanged = new();
public async UniTask UpdateTextAsync(string text)
{
this.text.Value = text;
await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.Return));
}
public void SetCanvasActive(bool isActive)
{
setActiveChanged.OnNext(isActive);
}
}
CameraModel.cs
using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UniRx;
public sealed class CameraModel
{
public IObservable<Vector3> CameraPositionUpdated => cameraPosition;
private readonly ReactiveProperty<Vector3> cameraPosition = new(new(0, 0, -20));
private float speed = 1f;
public async UniTask UpdateCameraPositionAsync(Vector3 position)
{
var t = 0f;
var firstPos = cameraPosition.Value;
while (t <= 1f)
{
t += speed * Time.deltaTime;
cameraPosition.Value = Vector3.Lerp(firstPos, position, t);
await UniTask.Yield();
}
}
}
CubePresenter.cs
using System;
using UniRx;
using Zenject;
public sealed class CubePresenter : IInitializable, IDisposable
{
private readonly CubeModel model;
private readonly CubeView view;
private readonly CompositeDisposable compositDisposable = new();
[Inject]
private CubePresenter(CubeModel model, CubeView view)
{
this.model = model;
this.view = view;
}
void IInitializable.Initialize()
{
model.CubePositionUpdated
.Subscribe(view.UpdateCubePosition)
.AddTo(compositDisposable);
}
void IDisposable.Dispose()
{
compositDisposable.Dispose();
}
}
TextPresenter.cs
using System;
using UniRx;
using Zenject;
public sealed class TextPresenter : IInitializable, IDisposable
{
private readonly TextModel model;
private readonly TextView view;
private readonly CompositeDisposable compositDisposable = new();
[Inject]
private TextPresenter(TextModel model, TextView view)
{
this.model = model;
this.view = view;
}
void IInitializable.Initialize()
{
model.TextUpdated
.Subscribe(view.UpdateText)
.AddTo(compositDisposable);
model.SetActiveChanged
.Subscribe(view.SetCanvasActive)
.AddTo(compositDisposable);
}
void IDisposable.Dispose()
{
compositDisposable.Dispose();
}
}
CameraPresenter.cs
using System;
using UniRx;
using Zenject;
public sealed class CameraPresenter : IInitializable, IDisposable
{
private readonly CameraModel model;
private readonly CameraView view;
private readonly CompositeDisposable compositDisposable = new();
[Inject]
private CameraPresenter(CameraModel model, CameraView view)
{
this.model = model;
this.view = view;
}
void IInitializable.Initialize()
{
model.CameraPositionUpdated
.Subscribe(view.UpdateCameraPosition)
.AddTo(compositDisposable);
}
void IDisposable.Dispose()
{
compositDisposable.Dispose();
}
}
下準備はこれにて完了です。
イベントデータの作成
下準備で作成したオブジェクトに対して何か命令をする処理を書いていきます。以下の処理を「ゲームイベント」と名付けます。まずはイベントインターフェースを作成します。
IGameEvent.cs
public interface IGameEvent
{
}
イベントインターフェースはマーカーインターフェースとして定義しておきます。実際の挙動は イベントハンドラーに書いていきます。
IGameEventHandler.cs
using Cysharp.Threading.Tasks;
public interface IGameEventHandler
{
bool Suppurts(IGameEvent gameEvent);
UniTask Handle(IGameEvent gameEvent);
}
それでは、それぞれのゲームイベントを作成していこうと思います。CubeMoveEvent.cs
SerializeFieldに必要なデータを登録できるようにしておきます。
using System;
using UnityEngine;
[Serializable]
public class CubeMoveEvent : IGameEvent
{
[SerializeField] private Vector3 position;
public Vector3 Position => position;
}
テキストUIを更新する TextEvent.cs
using System;
[Serializable]
public sealed class TextEvent : IGameEvent
{
[SerializeField] string text;
public string Text => text;
}
カメラを指定した座標に移動させる CameraMoveEvent.cs
using System;
using UnityEngine;
[Serializable]
public class CameraMoveEvent : IGameEvent
{
[SerializeField] Vector3 position;
public Vector3 Position => position;
}
そしてこれらを登録するためのScriptableObject、EventDataを追加します。
EventData.cs
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Data/イベントデータ")]
public class EventData : ScriptableObject
{
[SerializeReference] private List<IEvent> events;
public List<IEvent> Events => events;
}
しかしこのままではゲームイベントを登録することができません。なのでEditor拡張を行います。
EventDataExpansion.cs
using UnityEngine;
using UnityEditor;
[CustomEditor(typeof(EventData))]
public class EventDataExpansion : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
var eventData = target as EventData;
if(GUILayout.Button("カメラ移動イベントを追加"))
{
eventData.SetCameraMoveEvent();
}
if(GUILayout.Button("キューブ移動イベントを追加"))
{
eventData.SetCubeMoveEvent();
}
if(GUILayout.Button("テキスト表示イベントを追加"))
{
eventData.SetTextEvent();
}
}
}
ゲームイベントデータにも拡張用の関数を追加します。
EventData.cs
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
[CreateAssetMenu(menuName = "Data/イベントデータ")]
public class EventData : ScriptableObject
{
[SerializeReference] private List<IGameEvent> events;
public List<IGameEvent> Events => events;
#if UNITY_EDITOR
public void SetCameraMoveEvent()
{
events.Add(new CameraMoveEvent());
EditorUtility.SetDirty(this);
}
public void SetCubeMoveEvent()
{
events.Add(new CubeMoveEvent());
EditorUtility.SetDirty(this);
}
public void SetTextEvent()
{
events.Add(new TextEvent());
EditorUtility.SetDirty(this);
}
#endif
}
すると、イベントデータをエディタ上から追加することができます。
(分かりやすいようにTitleを追加しています)
しかし、中に処理は何も書かれていないので、データだけの追加となります。実際に処理を書いていきます。
イベントハンドラーの作成
ゲームイベントの処理をゲームイベントハンドラーに書いていきます。CubeMoveEventHandler.cs
using System;
using Cysharp.Threading.Tasks;
using Zenject;
public sealed class CubeMoveEventHandler : IGameEventHandler
{
private readonly CubeModel cube;
[Inject]
private CubeMoveEventHandler(CubeModel cube)
{
this.cube = cube;
}
bool IGameEventHandler.Suppurts(IGameEvent gameEvent)
{
return gameEvent is CubeMoveEvent;
}
async UniTask IGameEventHandler.Handle(IGameEvent gameEvent)
{
if (!(gameEvent is CubeMoveEvent))
{
throw new Exception("IGameEventHandler.Suppurts() で型を確認してから使ってください");
}
var cubeMoveEvent = gameEvent as CubeMoveEvent;
await cube.UpdateCubePositionAsync(cubeMoveEvent.Position);
}
}
TextEventHandler.cs
using System;
using Cysharp.Threading.Tasks;
using Zenject;
public class TextEventHandler : IGameEventHandler
{
private readonly TextModel text;
[Inject]
private TextEventHandler(TextModel text)
{
this.text = text;
}
bool IGameEventHandler.Suppurts(IGameEvent gameEvent)
{
return gameEvent is TextEvent;
}
async UniTask IGameEventHandler.Handle(IGameEvent gameEvent)
{
if (!(gameEvent is TextEvent))
{
throw new Exception("IGameEventHandler.Suppurts() で型を確認してから使ってください");
}
var textEvent = gameEvent as TextEvent;
text.SetCanvasActive(true);
await text.UpdateTextAsync(textEvent.Text);
text.SetCanvasActive(false);
}
}
CameraMoveEventHandler.cs
using System;
using Cysharp.Threading.Tasks;
using Zenject;
public sealed class CameraMoveEventHandler : IGameEventHandler
{
private readonly CameraModel camera;
[Inject]
private CameraMoveEventHandler(CameraModel camera)
{
this.camera = camera;
}
bool IGameEventHandler.Suppurts(IGameEvent gameEvent)
{
return gameEvent is CameraMoveEvent;
}
async UniTask IGameEventHandler.Handle(IGameEvent gameEvent)
{
if (!(gameEvent is CameraMoveEvent))
{
throw new Exception("IGameEventHandler.Suppurts() で型を確認してから使ってください");
}
var cameraMoveEvent = gameEvent as CameraMoveEvent;
await camera.UpdateCameraPositionAsync(cameraMoveEvent.Position);
}
}
MonoInstallerとイベント実行用クラスの作成
最後に、MonoInstallerと、イベント実行用クラスを追加して完了になります。
SceneInstaller.cs
using UnityEngine;
using Zenject;
public class SceneInstaller : MonoInstaller
{
[SerializeField] private CameraView cameraView;
[SerializeField] private CubeView cubeView;
[SerializeField] private TextView textView;
public override void InstallBindings()
{
Container.BindInstance(cameraView).AsSingle();
Container.BindInstance(cubeView).AsSingle();
Container.BindInstance(textView).AsSingle();
Container.Bind<CameraModel>().AsSingle();
Container.Bind<CubeModel>().AsSingle();
Container.Bind<TextModel>().AsSingle();
Container.BindInterfacesTo<CameraPresenter>().AsSingle();
Container.BindInterfacesTo<CubePresenter>().AsSingle();
Container.BindInterfacesTo<TextPresenter>().AsSingle();
Container.Bind<CubeMoveEventHandler>().AsSingle();
Container.Bind<CameraMoveEventHandler>().AsSingle();
Container.Bind<TextEventHandler>().AsSingle();
}
}
EventExecutor.cs
using Cysharp.Threading.Tasks;
using UnityEngine;
using Zenject;
public class EventExecutor : MonoBehaviour
{
[SerializeField] EventData eventData;
private IList<IGameEventHandler> gameEventHandlers;
[Inject]
private void Construct(CubeMoveEventHandler cubeMoveEventHandler,
CameraMoveEventHandler cameraMoveEventHandler,
TextEventHandler textEventHandler)
{
gameEventHandlers = new List<IGameEventHandler>()
{
cubeMoveEventHandler,
cameraMoveEventHandler,
textEventHandler
};
}
private void Start()
{
ExecuteEvent().Forget();
}
private async UniTaskVoid ExecuteEvent()
{
foreach (var item in eventData.Events)
{
foreach (var handler in gameEventHandlers)
{
if (handler.Suppurts(item))
{
await handler.Handle(item);
}
}
}
}
}
このようにしてイベントを作成すれば、イベントそのものはエディタで編集し、実際にイベントの実行はそのデータをループで回していくだけでゲームが進行していくような流れを作ることができます。
最後に
イベント処理さえ作ってしまえば、あとはどんなデータにするかをエディタで編集できるようになる SerializeReference、いかがでしたでしょうか?
上手く使えばゲーム開発の効率化が図れるかもしれません。
参考資料
- Unity公式ドキュメント- 【Unity】SerializeReferenceをちゃんと理解する