Skip to content

Instantly share code, notes, and snippets.

@der-hugo
Created November 20, 2024 15:53
Show Gist options
  • Save der-hugo/09921de9eee815e3ccfbc8085cd502a3 to your computer and use it in GitHub Desktop.
Save der-hugo/09921de9eee815e3ccfbc8085cd502a3 to your computer and use it in GitHub Desktop.
Serializable Scene reference for Unity based on https://github.com/JohannesMP/unity-scene-reference
/*
* original source: https://github.com/JohannesMP/unity-scene-reference
*/
using System;
using System.Linq;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditor.VersionControl;
#endif
namespace derHugo
{
/// <summary>
/// A wrapper that provides the means to safely serialize Scene Asset References.
/// </summary>
[Serializable]
public class SceneReference
#if UNITY_EDITOR
: ISerializationCallbackReceiver
#endif
{
/// <summary>
/// This should only ever be set during serialization/deserialization!
/// </summary>
[SerializeField]
private string scenePath = string.Empty;
/// <summary>
/// Use this when you want to actually have the scene path
/// </summary>
public string ScenePath
{
get
{
#if UNITY_EDITOR
// In editor we always use the asset's path
return GetScenePathFromAsset();
#else
// At runtime we rely on the stored path value which we assume was serialized correctly at build time.
// See OnBeforeSerialize and OnAfterDeserialize
return scenePath;
#endif
}
set
{
scenePath = value;
#if UNITY_EDITOR
sceneAsset = GetSceneAssetFromPath();
#endif
}
}
/// <summary>
/// Implicitly converts to the <see cref="ScenePath"/>
/// </summary>
public static implicit operator string(SceneReference sceneReference)
{
return sceneReference.ScenePath;
}
#if UNITY_EDITOR
[SerializeField] private SceneAsset sceneAsset;
/// <summary>
/// Used to dirtify the data when needed upon displaying in the inspector. see <see cref="SceneReferencePropertyDrawer.DirtyStateHack"/>
/// </summary>
[SerializeField] private bool isDirty;
public void OnBeforeSerialize()
{
if (!sceneAsset && !string.IsNullOrEmpty(scenePath))
{
// Asset is invalid but have Path to try and recover from
var foundAsset = GetSceneAssetFromPath();
if (foundAsset != null)
{
sceneAsset = foundAsset;
isDirty = true;
EditorSceneManager.MarkAllScenesDirty();
}
}
else
{
// Asset takes precendence and overwrites Path
var foundPath = GetScenePathFromAsset();
if (!string.IsNullOrEmpty(foundPath) && foundPath != scenePath)
{
scenePath = foundPath;
isDirty = true;
}
}
}
public void OnAfterDeserialize()
{
// We cannot touch AssetDatabase during serialization, so defer by a bit.
EditorApplication.delayCall += HandleAfterDeserialize;
void HandleAfterDeserialize()
{
EditorApplication.delayCall -= HandleAfterDeserialize;
if (sceneAsset != null)
{
// Asset is valid, don't do anything - Path will always be set based on it when it matters
return;
}
if (string.IsNullOrEmpty(scenePath))
{
return;
}
// Asset is invalid but have path to try and recover from
var foundAsset = GetSceneAssetFromPath();
if (foundAsset != null)
{
sceneAsset = foundAsset;
isDirty = true;
if (!Application.isPlaying)
{
EditorSceneManager.MarkAllScenesDirty();
}
}
}
}
private SceneAsset GetSceneAssetFromPath()
{
return string.IsNullOrEmpty(scenePath) ? null : AssetDatabase.LoadAssetAtPath<SceneAsset>(scenePath);
}
private string GetScenePathFromAsset()
{
return sceneAsset == null ? string.Empty : AssetDatabase.GetAssetPath(sceneAsset);
}
/// <summary>
/// Display a Scene Reference object in the editor.
/// If scene is valid, provides basic buttons to interact with the scene's role in Build Settings.
/// </summary>
[CustomPropertyDrawer(typeof(SceneReference))]
public class SceneReferencePropertyDrawer : PropertyDrawer
{
private const float PAD_SIZE = 5f;
private static readonly RectOffset BOX_PADDING = EditorStyles.helpBox.padding;
private static readonly float FOLDOUT_PADDING = EditorStyles.foldout.padding.left;
private static readonly float SINGLE_LINE_HEIGHT = EditorGUIUtility.singleLineHeight;
private static readonly float PADDED_LINE_HEIGHT = SINGLE_LINE_HEIGHT + PAD_SIZE;
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
var sceneAssetProperty = property.FindPropertyRelative(nameof(sceneAsset));
var lines = property.isExpanded && sceneAssetProperty.objectReferenceValue != null ? 2 : 1;
return BOX_PADDING.vertical + SINGLE_LINE_HEIGHT * lines + PAD_SIZE * (lines - 1);
}
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
DirtyStateHack(property);
using (new EditorGUI.PropertyScope(position, label, property))
{
var sceneAssetProperty = property.FindPropertyRelative(nameof(sceneAsset));
var buildScene = BuildSettingsUtils.BuildScene.FromObject(sceneAssetProperty.objectReferenceValue);
var isInArray = property.propertyPath.Contains("Array");
GUI.Box(position, GUIContent.none, EditorStyles.helpBox);
position = BOX_PADDING.Remove(position);
Rect fieldRect;
if (buildScene == null)
{
property.isExpanded = false;
fieldRect = isInArray ? position : EditorGUI.PrefixLabel(position, label);
}
else
{
position.x += FOLDOUT_PADDING;
position.width -= FOLDOUT_PADDING;
var labelRect = position;
labelRect.height = SINGLE_LINE_HEIGHT;
var labelRectWidth = isInArray ? 0 : EditorGUIUtility.labelWidth - FOLDOUT_PADDING;
labelRect.width = labelRectWidth;
if (!property.isExpanded && buildScene != null)
{
labelRect.width += SINGLE_LINE_HEIGHT + PAD_SIZE;
}
property.isExpanded = EditorGUI.Foldout(labelRect, property.isExpanded, isInArray ? GUIContent.none : label, true);
fieldRect = position;
fieldRect.x += labelRectWidth;
fieldRect.width -= labelRectWidth;
}
fieldRect.height = SINGLE_LINE_HEIGHT;
if (!property.isExpanded && !property.hasMultipleDifferentValues && buildScene != null)
{
var iconWidth = SINGLE_LINE_HEIGHT;
fieldRect.x += iconWidth;
fieldRect.width -= iconWidth;
var iconRect = new Rect(fieldRect.x - iconWidth, fieldRect.y, SINGLE_LINE_HEIGHT, SINGLE_LINE_HEIGHT);
EditorGUI.LabelField(iconRect, buildScene.StateIcon);
}
using (var changeCheck = new EditorGUI.ChangeCheckScope())
{
var color = GUI.color;
if (sceneAssetProperty.objectReferenceValue == null)
{
GUI.color = Color.red;
}
EditorGUI.PropertyField(fieldRect, sceneAssetProperty, GUIContent.none);
GUI.color = color;
if (changeCheck.changed)
{
buildScene = BuildSettingsUtils.BuildScene.FromObject(sceneAssetProperty.objectReferenceValue);
if (buildScene?.Scene == null)
{
property.FindPropertyRelative(nameof(scenePath)).stringValue = string.Empty;
}
}
}
if (property.isExpanded && buildScene != null)
{
position.y += PADDED_LINE_HEIGHT;
if (property.hasMultipleDifferentValues)
{
var helpRect = position;
helpRect.height = SINGLE_LINE_HEIGHT;
EditorGUI.LabelField(helpRect, "Can not edit build settings for multiple different scenes");
}
else
{
// Draw the Build Settings Info of the selected Scene
DrawSceneInfoGUI(position, buildScene);
}
}
}
}
/// <summary>
/// This will force change in the property and make it dirty. This covers the case a scene is renamed (=> path changed)
/// </summary>
private static void DirtyStateHack(SerializedProperty property)
{
var isDirtyProperty = property.FindPropertyRelative(nameof(isDirty));
if (isDirtyProperty.boolValue)
{
// This will force change in the property and make it dirty.
isDirtyProperty.boolValue = false;
}
}
/// <summary>
/// Draws info box of the provided scene
/// </summary>
private void DrawSceneInfoGUI(Rect position, BuildSettingsUtils.BuildScene buildScene)
{
var readOnly = BuildSettingsUtils.IsReadOnly();
var readOnlyWarning = readOnly ? "\n\nWARNING: Build Settings is not checked out and so cannot be modified." : "";
// Left status label
var labelRect = DrawUtils.GetLabelRect(position);
EditorGUI.LabelField(labelRect, buildScene.StateLabel);
// Right context buttons
var buttonRect = DrawUtils.GetFieldRect(position);
buttonRect.width /= 3;
string tooltipMsg;
using (new EditorGUI.DisabledScope(readOnly))
{
if (buildScene.State == BuildSettingsUtils.BuildScene.SceneState.NotInBuild)
{
// NOT in build settings
buttonRect.width *= 2;
var addIndex = EditorBuildSettings.scenes.Length;
tooltipMsg = $"Add this scene to build settings. It will be appended to the end of the build scenes as buildIndex: {addIndex}.{readOnlyWarning}";
if (DrawUtils.ButtonHelper(buttonRect, "Add...", $"Add (buildIndex {addIndex})", EditorStyles.miniButtonLeft, tooltipMsg))
{
buildScene.AddBuildScene();
}
buttonRect.width /= 2;
buttonRect.x += buttonRect.width;
}
else
{
// In build settings
var isEnabled = buildScene.Scene.enabled;
var buttonLabel = isEnabled ? "Disable" : "Enable";
tooltipMsg = $"{buttonLabel} this scene in build settings.\n{(isEnabled ? "It will no longer be included in builds" : "It will be included in builds")}.{readOnlyWarning}";
if (DrawUtils.ButtonHelper(buttonRect, buttonLabel, buttonLabel + " In Build", EditorStyles.miniButtonLeft, tooltipMsg))
{
buildScene.SetBuildSceneState(!isEnabled);
}
buttonRect.x += buttonRect.width;
tooltipMsg = $"Completely remove this scene from build settings.\nYou will need to add it again for it to be included in builds!{readOnlyWarning}";
if (DrawUtils.ButtonHelper(buttonRect, "Remove", "Remove from Build", EditorStyles.miniButtonMid, tooltipMsg))
{
buildScene.RemoveBuildScene();
}
}
}
buttonRect.x += buttonRect.width;
tooltipMsg = $"Open the 'Build Settings' Window for managing scenes.{readOnlyWarning}";
if (DrawUtils.ButtonHelper(buttonRect, "Settings", "Build Settings", EditorStyles.miniButtonRight, tooltipMsg))
{
BuildSettingsUtils.OpenBuildSettings();
}
}
private static class DrawUtils
{
/// <summary>
/// Draw a GUI button, choosing between a short and a long button text based on if it fits
/// </summary>
public static bool ButtonHelper(Rect position, string msgShort, string msgLong, GUIStyle style, string tooltip)
{
var content = new GUIContent(msgLong) { tooltip = tooltip };
var longWidth = style.CalcSize(content).x;
if (longWidth > position.width)
content.text = msgShort;
return GUI.Button(position, content, style);
}
/// <summary>
/// Given a position rect, get its field portion
/// </summary>
public static Rect GetFieldRect(Rect position)
{
position.width -= EditorGUIUtility.labelWidth;
position.width += PAD_SIZE * 3;
position.x += EditorGUIUtility.labelWidth - PAD_SIZE * 3;
return position;
}
/// <summary>
/// Given a position rect, get its label portion
/// </summary>
public static Rect GetLabelRect(Rect position)
{
position.width = EditorGUIUtility.labelWidth - PAD_SIZE;
position.height = SINGLE_LINE_HEIGHT;
return position;
}
}
/// <summary>
/// Various BuildSettings interactions
/// </summary>
private static class BuildSettingsUtils
{
// time in seconds that we have to wait before we query again when IsReadOnly() is called.
private const float MIN_CHECK_WAIT = 3f;
private static float lastTimeChecked;
private static bool cachedReadonlyVal = true;
/// <summary>
/// A small container for tracking scene data BuildSettings
/// </summary>
public class BuildScene
{
public enum SceneState
{
None,
NotInBuild,
DisabledInBuild,
InBuild
}
private static readonly GUIContent green = EditorGUIUtility.IconContent("d_greenlight");
private static readonly GUIContent orange = EditorGUIUtility.IconContent("d_orangelight");
private static readonly GUIContent red = EditorGUIUtility.IconContent("d_redlight");
static BuildScene()
{
green.tooltip = "This scene is in build settings and ENABLED.\nIt will be included in builds.";
orange.tooltip = "This scene is in build settings and DISABLED.\nIt will be NOT included in builds.";
red.tooltip = "This scene is NOT in build settings.\nIt will be NOT included in builds.";
}
/// <summary>
/// Enable/Disable a given scene in the buildSettings
/// </summary>
public void SetBuildSceneState(bool enabled)
{
var scenesToModify = EditorBuildSettings.scenes;
foreach (var curScene in scenesToModify)
{
if (curScene.guid.Equals(GUID))
{
curScene.enabled = enabled;
EditorBuildSettings.scenes = scenesToModify;
break;
}
}
}
/// <summary>
/// Display Dialog to add a scene to build settings
/// </summary>
public void AddBuildScene(bool force = false, bool enabled = true)
{
if (force == false)
{
var selection = EditorUtility.DisplayDialogComplex(
"Add Scene To Build",
"You are about to add scene at " + Path + " To the Build Settings.",
"Add as Enabled", // option 0
"Add as Disabled", // option 1
"Cancel (do nothing)"); // option 2
switch (selection)
{
case 0: // enabled
enabled = true;
break;
case 1: // disabled
enabled = false;
break;
case 2: // cancel
default:
return;
}
}
var newScene = new EditorBuildSettingsScene(GUID, enabled);
var tempScenes = EditorBuildSettings.scenes.ToList();
tempScenes.Add(newScene);
EditorBuildSettings.scenes = tempScenes.ToArray();
}
/// <summary>
/// Display Dialog to remove a scene from build settings (or just disable it)
/// </summary>
public void RemoveBuildScene(bool force = false)
{
var onlyDisable = false;
if (force == false)
{
var selection = -1;
var title = "Remove Scene From Build";
var details = $"You are about to remove the following scene from build settings:\n {Path}\n buildIndex: {BuildIndex}\n\nThis will modify build settings, but the scene asset will remain untouched.";
var confirm = "Remove From Build";
var alt = "Just Disable";
var cancel = "Cancel (do nothing)";
if (Scene.enabled)
{
details += "\n\nIf you want, you can also just disable it instead.";
selection = EditorUtility.DisplayDialogComplex(title, details, confirm, alt, cancel);
}
else
{
selection = EditorUtility.DisplayDialog(title, details, confirm, cancel) ? 0 : 2;
}
switch (selection)
{
case 0: // remove
break;
case 1: // disable
onlyDisable = true;
break;
default:
//case 2: // cancel
return;
}
}
if (onlyDisable)
{
// User chose to not remove, only disable the scene
SetBuildSceneState(false);
}
else
{
// User chose to fully remove the scene from build settings
var tempScenes = EditorBuildSettings.scenes.ToList();
tempScenes.RemoveAll(scene => scene.guid.Equals(GUID));
EditorBuildSettings.scenes = tempScenes.ToArray();
}
}
public readonly int BuildIndex = -1;
public readonly GUID GUID;
public readonly string Path;
public readonly EditorBuildSettingsScene Scene;
public readonly GUIContent StateIcon;
public readonly SceneState State;
public readonly GUIContent StateLabel;
private BuildScene(string path, GUID guid, int buildIndex, EditorBuildSettingsScene scene)
{
GUID = guid;
Path = path;
Scene = scene;
BuildIndex = buildIndex;
State = buildIndex == -1 ? SceneState.NotInBuild : !scene.enabled ? SceneState.DisabledInBuild : SceneState.InBuild;
StateIcon = State switch
{
SceneState.InBuild => green,
SceneState.DisabledInBuild => orange,
SceneState.NotInBuild => red,
_ => null
};
StateLabel = State switch
{
SceneState.InBuild => new GUIContent($"BuildIndex: {BuildIndex}", StateIcon.image, StateIcon.tooltip),
SceneState.DisabledInBuild => new GUIContent($"BuildIndex: {BuildIndex}", StateIcon.image, StateIcon.tooltip),
SceneState.NotInBuild => new GUIContent("NOT In Build", StateIcon.image, StateIcon.tooltip),
_ => null
};
}
public static BuildScene FromObject(UnityEngine.Object sceneObject)
{
if (sceneObject as SceneAsset == null)
{
return null;
}
var path = AssetDatabase.GetAssetPath(sceneObject);
var guid = new GUID(AssetDatabase.AssetPathToGUID(path));
EditorBuildSettingsScene scene = null;
var buildIndex = -1;
var scenes = EditorBuildSettings.scenes;
for (var index = 0; index < scenes.Length; ++index)
{
scene = scenes[index];
if (guid.Equals(scene.guid))
{
buildIndex = index;
break;
}
}
return new BuildScene(path, guid, buildIndex, scene);
}
}
/// <summary>
/// Check if the build settings asset is readonly.
/// Caches value and only queries state a max of every 'minCheckWait' seconds.
/// </summary>
public static bool IsReadOnly()
{
var curTime = Time.realtimeSinceStartup;
var timeSinceLastCheck = curTime - lastTimeChecked;
if (timeSinceLastCheck < MIN_CHECK_WAIT)
{
return cachedReadonlyVal;
}
lastTimeChecked = curTime;
cachedReadonlyVal = QueryBuildSettingsStatus();
return cachedReadonlyVal;
}
/// <summary>
/// A blocking call to the Version Control system to see if the build settings asset is readonly.
/// Use BuildSettingsIsReadOnly for version that caches the value for better responsivenes.
/// </summary>
private static bool QueryBuildSettingsStatus()
{
// If no version control provider, assume not readonly
if (!Provider.enabled)
{
return false;
}
// If we cannot checkout, then assume we are not readonly
if (!Provider.hasCheckoutSupport)
{
return false;
}
// If offline (and are using a version control provider that requires checkout) we cannot edit.
if (Provider.onlineState == OnlineState.Offline)
{
return true;
}
// Try to get status for file
var status = Provider.Status("ProjectSettings/EditorBuildSettings.asset", false);
status.Wait();
// If no status listed we can edit
if (status.assetList == null || status.assetList.Count != 1)
{
return true;
}
// If is checked out, we can edit
return !status.assetList[0].IsState(Asset.States.CheckedOutLocal);
}
/// <summary>
/// Open the default Unity Build Settings window
/// </summary>
public static void OpenBuildSettings()
{
EditorWindow.GetWindow(typeof(BuildPlayerWindow));
}
}
}
#endif
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment