Created
November 20, 2024 15:53
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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