// Author: JohannesMP (2018-08-12)
//
// A wrapper that provides the means to safely serialize Scene Asset References.
//
// Internally we serialize an Object to the SceneAsset which only exists at editor time.
// Any time the object is serialized, we store the path provided by this Asset (assuming it was valid).
//
// This means that, come build time, the string path of the scene asset is always already stored, which if
// the scene was added to the build settings means it can be loaded.
//
// It is up to the user to ensure the scene exists in the build settings so it is loadable at runtime.
// To help with this, a custom PropertyDrawer displays the scene build settings state.
//
// Known issues:
// - When reverting back to a prefab which has the asset stored as null, Unity will show the property
// as modified despite having just reverted. This only happens the fist time, and reverting again
//
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using System.IO;
#if UNITY_EDITOR
using UnityEditor;
#endif
[System.Serializable]
public class SceneReference : SceneReferenceLite, ISerializationCallbackReceiver
{
}
#if UNITY_EDITOR
///
/// 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.
///
[CustomPropertyDrawer(typeof(SceneReference))]
public class SceneReferencePropertyDrawer : PropertyDrawer
{
// The exact name of the asset Object variable in the SceneReference object
const string sceneAssetPropertyString = "sceneAsset";
// The exact name of the scene Path variable in the SceneReference object
const string scenePathPropertyString = "scenePath";
static readonly RectOffset boxPadding = EditorStyles.helpBox.padding;
static readonly float padSize = 2f;
static readonly float lineHeight = EditorGUIUtility.singleLineHeight;
static readonly float paddedLine = lineHeight + padSize;
static readonly float footerHeight = 10f;
///
/// Drawing the 'SceneReference' property
///
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
var sceneAssetProperty = GetSceneAssetProperty(property);
// Draw the Box Background
position.height -= footerHeight;
GUI.Box(EditorGUI.IndentedRect(position), GUIContent.none, EditorStyles.helpBox);
position = boxPadding.Remove(position);
position.height = lineHeight;
// Draw the main Object field
//label.tooltip = "The actual Scene Asset reference.\nOn serialize this is also stored as the asset's path.";
EditorGUI.BeginProperty(position, GUIContent.none, property);
EditorGUI.BeginChangeCheck();
int sceneControlID = GUIUtility.GetControlID(FocusType.Passive);
var selectedObject = EditorGUI.ObjectField(position, label, sceneAssetProperty.objectReferenceValue, typeof(SceneAsset), false);
BuildUtils.BuildScene buildScene = BuildUtils.GetBuildScene(selectedObject);
if (EditorGUI.EndChangeCheck())
{
sceneAssetProperty.objectReferenceValue = selectedObject;
// If no valid scene asset was selected, reset the stored path accordingly
if (buildScene.scene == null)
GetScenePathProperty(property).stringValue = string.Empty;
}
position.y += paddedLine;
if (buildScene.assetGUID.Empty() == false)
{
// Draw the Build Settings Info of the selected Scene
DrawSceneInfoGUI(position, buildScene, sceneControlID + 1);
}
EditorGUI.EndProperty();
}
///
/// Ensure that what we draw in OnGUI always has the room it needs
///
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
int lines = 2;
SerializedProperty sceneAssetProperty = GetSceneAssetProperty(property);
if (sceneAssetProperty.objectReferenceValue == null)
lines = 1;
return boxPadding.vertical + lineHeight * lines + padSize * (lines - 1) + footerHeight;
}
///
/// Draws info box of the provided scene
///
private void DrawSceneInfoGUI(Rect position, BuildUtils.BuildScene buildScene, int sceneControlID)
{
bool readOnly = BuildUtils.IsReadOnly();
string readOnlyWarning = readOnly ? "\n\nWARNING: Build Settings is not checked out and so cannot be modified." : "";
// Label Prefix
GUIContent iconContent = new GUIContent();
GUIContent labelContent = new GUIContent();
// Missing from build scenes
if (buildScene.buildIndex == -1)
{
iconContent = EditorGUIUtility.IconContent("d_winbtn_mac_close");
labelContent.text = "NOT In Build";
labelContent.tooltip = "This scene is NOT in build settings.\nIt will be NOT included in builds.";
}
// In build scenes and enabled
else if (buildScene.scene.enabled)
{
iconContent = EditorGUIUtility.IconContent("d_winbtn_mac_max");
labelContent.text = "BuildIndex: " + buildScene.buildIndex;
labelContent.tooltip = "This scene is in build settings and ENABLED.\nIt will be included in builds." + readOnlyWarning;
}
// In build scenes and disabled
else
{
iconContent = EditorGUIUtility.IconContent("d_winbtn_mac_min");
labelContent.text = "BuildIndex: " + buildScene.buildIndex;
labelContent.tooltip = "This scene is in build settings and DISABLED.\nIt will be NOT included in builds.";
}
// Left status label
using (new EditorGUI.DisabledScope(readOnly))
{
Rect labelRect = DrawUtils.GetLabelRect(position);
Rect iconRect = labelRect;
iconRect.width = iconContent.image.width + padSize;
labelRect.width -= iconRect.width;
labelRect.x += iconRect.width;
EditorGUI.PrefixLabel(iconRect, sceneControlID, iconContent);
EditorGUI.PrefixLabel(labelRect, sceneControlID, labelContent);
}
// Right context buttons
Rect buttonRect = DrawUtils.GetFieldRect(position);
buttonRect.width = (buttonRect.width) / 3;
string tooltipMsg = "";
using (new EditorGUI.DisabledScope(readOnly))
{
// NOT in build settings
if (buildScene.buildIndex == -1)
{
buttonRect.width *= 2;
int 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))
BuildUtils.AddBuildScene(buildScene);
buttonRect.width /= 2;
buttonRect.x += buttonRect.width;
}
// In build settings
else
{
bool isEnabled = buildScene.scene.enabled;
string stateString = isEnabled ? "Disable" : "Enable";
tooltipMsg = stateString + " 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, stateString, stateString + " In Build", EditorStyles.miniButtonLeft, tooltipMsg))
BuildUtils.SetBuildSceneState(buildScene, !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))
BuildUtils.RemoveBuildScene(buildScene);
}
}
buttonRect.x += buttonRect.width;
tooltipMsg = "Open the 'Build Settings' Window for managing scenes." + readOnlyWarning;
if (DrawUtils.ButtonHelper(buttonRect, "Settings", "Build Settings", EditorStyles.miniButtonRight, tooltipMsg))
{
BuildUtils.OpenBuildSettings();
}
}
static SerializedProperty GetSceneAssetProperty(SerializedProperty property)
{
return property.FindPropertyRelative(sceneAssetPropertyString);
}
static SerializedProperty GetScenePathProperty(SerializedProperty property)
{
return property.FindPropertyRelative(scenePathPropertyString);
}
private static class DrawUtils
{
///
/// Draw a GUI button, choosing between a short and a long button text based on if it fits
///
static public bool ButtonHelper(Rect position, string msgShort, string msgLong, GUIStyle style, string tooltip = null)
{
GUIContent content = new GUIContent(msgLong);
content.tooltip = tooltip;
float longWidth = style.CalcSize(content).x;
if (longWidth > position.width)
content.text = msgShort;
return GUI.Button(position, content, style);
}
///
/// Given a position rect, get its field portion
///
static public Rect GetFieldRect(Rect position)
{
position.width -= EditorGUIUtility.labelWidth;
position.x += EditorGUIUtility.labelWidth;
return position;
}
///
/// Given a position rect, get its label portion
///
static public Rect GetLabelRect(Rect position)
{
position.width = EditorGUIUtility.labelWidth - padSize;
return position;
}
}
///
/// Various BuildSettings interactions
///
static private class BuildUtils
{
// time in seconds that we have to wait before we query again when IsReadOnly() is called.
public static float minCheckWait = 3;
static float lastTimeChecked = 0;
static bool cachedReadonlyVal = true;
///
/// A small container for tracking scene data BuildSettings
///
public struct BuildScene
{
public int buildIndex;
public GUID assetGUID;
public string assetPath;
public EditorBuildSettingsScene scene;
}
///
/// Check if the build settings asset is readonly.
/// Caches value and only queries state a max of every 'minCheckWait' seconds.
///
static public bool IsReadOnly()
{
float curTime = Time.realtimeSinceStartup;
float timeSinceLastCheck = curTime - lastTimeChecked;
if (timeSinceLastCheck > minCheckWait)
{
lastTimeChecked = curTime;
cachedReadonlyVal = QueryBuildSettingsStatus();
}
return cachedReadonlyVal;
}
///
/// 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.
///
static private bool QueryBuildSettingsStatus()
{
// If no version control provider, assume not readonly
if (UnityEditor.VersionControl.Provider.enabled == false)
return false;
// If we cannot checkout, then assume we are not readonly
if (UnityEditor.VersionControl.Provider.hasCheckoutSupport == false)
return false;
//// If offline (and are using a version control provider that requires checkout) we cannot edit.
//if (UnityEditor.VersionControl.Provider.onlineState == UnityEditor.VersionControl.OnlineState.Offline)
// return true;
// Try to get status for file
var status = UnityEditor.VersionControl.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
if (status.assetList[0].IsState(UnityEditor.VersionControl.Asset.States.CheckedOutLocal))
return false;
return true;
}
///
/// For a given Scene Asset object reference, extract its build settings data, including buildIndex.
///
static public BuildScene GetBuildScene(Object sceneObject)
{
BuildScene entry = new BuildScene()
{
buildIndex = -1,
assetGUID = new GUID(string.Empty)
};
if (sceneObject as SceneAsset == null)
return entry;
entry.assetPath = AssetDatabase.GetAssetPath(sceneObject);
entry.assetGUID = new GUID(AssetDatabase.AssetPathToGUID(entry.assetPath));
for (int index = 0; index < EditorBuildSettings.scenes.Length; ++index)
{
if (entry.assetGUID.Equals(EditorBuildSettings.scenes[index].guid))
{
entry.scene = EditorBuildSettings.scenes[index];
entry.buildIndex = index;
return entry;
}
}
return entry;
}
///
/// Enable/Disable a given scene in the buildSettings
///
static public void SetBuildSceneState(BuildScene buildScene, bool enabled)
{
bool modified = false;
EditorBuildSettingsScene[] scenesToModify = EditorBuildSettings.scenes;
foreach (var curScene in scenesToModify)
{
if (curScene.guid.Equals(buildScene.assetGUID))
{
curScene.enabled = enabled;
modified = true;
break;
}
}
if (modified)
EditorBuildSettings.scenes = scenesToModify;
}
///
/// Display Dialog to add a scene to build settings
///
static public void AddBuildScene(BuildScene buildScene, bool force = false, bool enabled = true)
{
if (force == false)
{
int selection = EditorUtility.DisplayDialogComplex(
"Add Scene To Build",
"You are about to add scene at " + buildScene.assetPath + " 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;
default:
case 2: // cancel
return;
}
}
EditorBuildSettingsScene newScene = new EditorBuildSettingsScene(buildScene.assetGUID, enabled);
List tempScenes = EditorBuildSettings.scenes.ToList();
tempScenes.Add(newScene);
EditorBuildSettings.scenes = tempScenes.ToArray();
}
///
/// Display Dialog to remove a scene from build settings (or just disable it)
///
static public void RemoveBuildScene(BuildScene buildScene, bool force = false)
{
bool onlyDisable = false;
if (force == false)
{
int selection = -1;
string title = "Remove Scene From Build";
string details = string.Format("You are about to remove the following scene from build settings:\n {0}\n buildIndex: {1}\n\n{2}",
buildScene.assetPath, buildScene.buildIndex,
"This will modify build settings, but the scene asset will remain untouched.");
string confirm = "Remove From Build";
string alt = "Just Disable";
string cancel = "Cancel (do nothing)";
if (buildScene.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;
}
}
// User chose to not remove, only disable the scene
if (onlyDisable)
{
SetBuildSceneState(buildScene, false);
}
// User chose to fully remove the scene from build settings
else
{
List tempScenes = EditorBuildSettings.scenes.ToList();
tempScenes.RemoveAll(scene => scene.guid.Equals(buildScene.assetGUID));
EditorBuildSettings.scenes = tempScenes.ToArray();
}
}
///
/// Open the default Unity Build Settings window
///
static public void OpenBuildSettings()
{
EditorWindow.GetWindow(typeof(BuildPlayerWindow));
}
}
}
#endif