using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace Unity.Tutorials.Core.Editor
{
///
/// Manages the startup and transitions of tutorials.
///
public class TutorialManager : ScriptableObject
{
[Serializable]
struct SceneViewState
{
public bool In2DMode;
public bool Orthographic;
public float Size;
public Vector3 Point;
public Quaternion Direction;
}
[Serializable]
struct SceneInfo
{
public bool Active;
public string AssetPath;
public bool WasLoaded;
}
[SerializeField]
SceneViewState m_OriginalSceneView;
[SerializeField]
List m_OriginalScenes = new List();
// The original layout files are copied into this folder for modifications.
const string k_UserLayoutDirectory = "Temp";
// The original/previous layout is stored into this when loading new layouts.
internal static readonly string k_OriginalLayoutPath = $"{k_UserLayoutDirectory}/OriginalLayout.dwlt";
const string k_DefaultsFolder = "Tutorial Defaults";
///
/// The singleton instance.
///
public static TutorialManager Instance
{
get
{
if (s_TutorialManager == null)
{
s_TutorialManager = Resources.FindObjectsOfTypeAll().FirstOrDefault();
if (s_TutorialManager == null)
{
s_TutorialManager = CreateInstance();
s_TutorialManager.hideFlags = HideFlags.HideAndDontSave;
}
}
return s_TutorialManager;
}
}
static TutorialManager s_TutorialManager;
///
/// The currently active tutorial, if any.
///
public Tutorial ActiveTutorial { get => m_Tutorial; }
Tutorial m_Tutorial;
///
/// Are we currently (during this frame) transitioning from one tutorial to another.
///
///
/// This transition typically happens when using a Switch Tutorial button on a tutorial page.
///
public bool IsTransitioningBetweenTutorials { get; internal set; }
///
/// Are we currently loading a window layout.
///
///
/// A window layout load typically happens when the project is started for the first time
/// and the project's startup settings specify a window layout for the project, or when entering
/// or exiting a tutorial with a window layout specified.
///
public static bool IsLoadingLayout { get; private set; }
internal static event Action AboutToLoadLayout;
internal static event Action LayoutLoaded; // bool == successful
bool m_SkipSceneSaveDialog;
internal static TutorialWindow GetTutorialWindow()
{
return EditorWindowUtils.FindOpenInstance();
}
///
/// Starts a tutorial.
///
/// The tutorial to be started.
///
/// The caller of the funtion is responsible for positioning the TutorialWindow for the tutorials.
/// If no TutorialWindow is visible, it is created and shown as a free-floating window.
/// If the currently active scene has unsaved changes, the user is asked to save them.
/// If we are in Play Mode, it is exited.
/// Note that currently there is no explicit way to quit a tutorial. Instead, a tutorial should be quit either
/// by user interaction or by closing the TutorialWindow programmatically.
///
public void StartTutorial(Tutorial tutorial)
{
if (tutorial == null)
{
Debug.LogError("Null Tutorial.");
return;
}
// Early-out if user decides to cancel. Otherwise the user can get reset to the
// main tutorial selection screen in cases where the user was about to switch to
// another tutorial while finishing up another (typical use case would be having a
// "start next tutorial" button at the last page of a tutorial).
m_SkipSceneSaveDialog = true;
if (!EditorApplication.isPlaying && !EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
return;
if (m_Tutorial)
{
// Is the previous tutorial finished? Make sure to record the progress
// by trying to progress to the next page which will take care of it.
if (m_Tutorial.IsCompleted)
m_Tutorial.TryGoToNextPage(); // TODO this might be unnecessary now, double-check
m_Tutorial.RaiseQuit();
}
m_Tutorial = tutorial;
// Ensure we are in edit mode
if (EditorApplication.isPlaying)
{
EditorApplication.isPlaying = false;
EditorApplication.playModeStateChanged += PostponeStartTutorialToEditMode;
}
else
StartTutorialInEditMode();
}
void PostponeStartTutorialToEditMode(PlayModeStateChange playModeStateChange)
{
if (playModeStateChange == PlayModeStateChange.EnteredEditMode)
{
EditorApplication.playModeStateChanged -= PostponeStartTutorialToEditMode;
StartTutorialInEditMode();
}
}
void StartTutorialInEditMode()
{
Debug.Assert(!EditorApplication.isPlaying);
if (!m_SkipSceneSaveDialog)
{
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
return;
}
// NOTE maximizeOnPlay=true was causing problems at some point
// (tutorial was closed for some reason) but that problem seems to be gone.
// Keeping this here in case the problem returns.
//GameViewProxy.maximizeOnPlay = false;
// Prevent Game view flashing briefly when starting tutorial.
EditorWindow.GetWindow().Focus();
if (!IsTransitioningBetweenTutorials)
{
SaveOriginalScenes();
SaveOriginalWindowLayout();
SaveSceneViewState();
}
// Make sure the active container persist through potential window layout load.
var activeContainer = GetTutorialWindow()?.ActiveContainer;
UserStartupCode.PrepareWindowLayouts();
m_Tutorial.LoadWindowLayout();
// Ensure TutorialWindow is open and set the current tutorial
var tutorialWindow = TutorialWindow.GetOrCreateWindow();
tutorialWindow.ActiveContainer = activeContainer;
tutorialWindow.SetTutorial(m_Tutorial);
// Do not overwrite workspace in authoring mode, use version control instead.
if (!ProjectMode.IsAuthoringMode())
LoadTutorialDefaultsIntoAssetsFolder();
}
internal void RestoreOriginalState()
{
EditorCoroutines.Editor.EditorCoroutineUtility.StartCoroutineOwnerless(RestoreOriginalScenes());
// Restore layout only if the tutorial used window layout, meaning, the new auto-docking mechanism was not used.
if (m_Tutorial?.WindowLayout)
RestoreOriginalWindowLayout();
RestoreSceneViewState();
}
internal void ResetTutorial()
{
m_Tutorial = GetTutorialWindow()?.currentTutorial;
if (m_Tutorial == null)
return; // tutorial has been quit
// Ensure we are in edit mode
if (EditorApplication.isPlaying)
{
EditorApplication.isPlaying = false;
EditorApplication.playModeStateChanged += PostponeResetTutorialToEditMode;
}
else
ResetTutorialInEditMode();
}
void PostponeResetTutorialToEditMode(PlayModeStateChange playModeStateChange)
{
if (playModeStateChange == PlayModeStateChange.EnteredEditMode)
{
EditorApplication.playModeStateChanged -= PostponeStartTutorialToEditMode;
ResetTutorialInEditMode();
}
}
void ResetTutorialInEditMode()
{
Debug.Assert(!EditorApplication.isPlaying);
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
return;
m_Tutorial.LoadWindowLayout();
m_Tutorial.ResetProgress();
// Do not overwrite workspace in authoring mode, use version control instead.
if (!ProjectMode.IsAuthoringMode())
LoadTutorialDefaultsIntoAssetsFolder();
}
internal static void SaveOriginalWindowLayout()
{
TutorialModalWindow.Hide();
WindowLayoutProxy.SaveWindowLayout(k_OriginalLayoutPath);
}
internal static void RestoreOriginalWindowLayout()
{
if (File.Exists(k_OriginalLayoutPath))
{
LoadWindowLayout(k_OriginalLayoutPath);
File.Delete(k_OriginalLayoutPath);
}
}
void SaveSceneViewState()
{
var sv = EditorWindow.GetWindow();
m_OriginalSceneView.In2DMode = sv.in2DMode;
m_OriginalSceneView.Point = sv.pivot;
m_OriginalSceneView.Direction = sv.rotation;
m_OriginalSceneView.Size = sv.size;
m_OriginalSceneView.Orthographic = sv.orthographic;
}
void RestoreSceneViewState()
{
var sv = EditorWindow.GetWindow();
sv.in2DMode = m_OriginalSceneView.In2DMode;
sv.LookAt(
m_OriginalSceneView.Point,
m_OriginalSceneView.Direction,
m_OriginalSceneView.Size,
m_OriginalSceneView.Orthographic,
instant: true
);
}
internal static bool LoadWindowLayout(string path)
{
IsLoadingLayout = true;
AboutToLoadLayout?.Invoke();
bool successful = EditorUtility.LoadWindowLayout(path); // will log an error if fails
LayoutLoaded?.Invoke(successful);
IsLoadingLayout = false;
return successful;
}
internal static bool LoadWindowLayoutWorkingCopy(string path) =>
LoadWindowLayout(GetWorkingCopyWindowLayoutPath(path));
internal static string GetWorkingCopyWindowLayoutPath(string layoutPath) =>
$"{k_UserLayoutDirectory}/{new FileInfo(layoutPath).Name}";
// Makes a copy of the window layout file and replaces LastProjectPaths in the window layout
// so that pre-saved Project window states work correctly. Also resets TutorialWindow's readme in the layout.
// Returns path to the new layout file.
internal static string PrepareWindowLayout(string layoutPath)
{
try
{
if (!Directory.Exists(k_UserLayoutDirectory))
Directory.CreateDirectory(k_UserLayoutDirectory);
var destinationPath = GetWorkingCopyWindowLayoutPath(layoutPath);
File.Copy(layoutPath, destinationPath, overwrite: true);
const string lastProjectPathProp = "m_LastProjectPath: ";
const string readmeProp = "m_Readme: ";
const string nullObject = "{fileID: 0}";
string userProjectPath = Directory.GetCurrentDirectory();
var fileContents = new List();
using (var reader = new StreamReader(destinationPath))
{
string line;
while ((line = reader.ReadLine()) != null)
{
line = ReplaceAfter(lastProjectPathProp, userProjectPath, line);
line = ReplaceAfter(readmeProp, nullObject, line);
fileContents.Add(line);
}
}
using (var writer = new StreamWriter(destinationPath, append: false))
{
fileContents.ForEach(writer.WriteLine);
}
return destinationPath;
}
catch (Exception e)
{
Debug.LogException(e);
return string.Empty;
}
}
///
/// Saves current state of open/loaded scenes so we can restore later
///
void SaveOriginalScenes()
{
m_OriginalScenes = GetCurrentScenes()
.Select(scene =>
new SceneInfo
{
Active = scene == SceneManager.GetActiveScene(),
AssetPath = scene.path,
WasLoaded = scene.isLoaded,
})
.ToList();
}
static List GetCurrentScenes()
{
var scenes = new List();
for (int i = 0; i < SceneManager.sceneCount; ++i)
{
scenes.Add(SceneManager.GetSceneAt(i));
}
return scenes;
}
internal IEnumerator RestoreOriginalScenes()
{
if (!m_OriginalScenes.Any())
yield break;
if (EditorApplication.isPlaying)
{
// Exit play mode so we can open scenes (without necessarily loading them)
EditorApplication.isPlaying = false;
int currentFrameCount = Time.frameCount;
while (currentFrameCount == Time.frameCount)
{
yield return null; //going out of play mode requires a frame
}
}
else
{
yield return null;
}
if (IsTransitioningBetweenTutorials)
{
IsTransitioningBetweenTutorials = false;
yield break;
}
// Close all existing scenes
// Closing all scenes allows us to retain the original order of scenes if the original scenes,
// would they contain same scenes as the tutorial. As we cannot remove all scenes, and must have
// at least one scene open at all times, create a dummy scene for the time being.
var dummySceneMode = SceneManager.GetActiveScene().path.IsNullOrEmpty()
? NewSceneMode.Single
: NewSceneMode.Additive; // prevents potential "Cannot create a new scene additively with an untitled scene unsaved" error
var dummyScene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, dummySceneMode);
GetCurrentScenes()
.Where(scene => scene != dummyScene)
.ToList()
.ForEach(scene => EditorSceneManager.CloseScene(scene, true));
// Load original scenes
foreach (var sceneInfo in m_OriginalScenes)
{
if (sceneInfo.AssetPath.IsNullOrEmpty())
continue; // Skip new unsaved scenes
var openSceneMode = sceneInfo.WasLoaded ? OpenSceneMode.Additive : OpenSceneMode.AdditiveWithoutLoading;
EditorSceneManager.OpenScene(sceneInfo.AssetPath, openSceneMode);
}
// Set original active scene
var originalActiveScenePath = m_OriginalScenes
.Where(sceneInfo => sceneInfo.Active)
.Select(sceneInfo => sceneInfo.AssetPath)
.FirstOrDefault();
foreach (var scene in GetCurrentScenes())
{
if (scene.path == originalActiveScenePath)
{
SceneManager.SetActiveScene(scene);
break;
}
}
// Clean up the dummy scene if we have real scenes.
if (SceneManager.sceneCount > 1)
EditorSceneManager.CloseScene(dummyScene, true);
m_OriginalScenes.Clear();
}
static void LoadTutorialDefaultsIntoAssetsFolder()
{
if (!TutorialProjectSettings.Instance.RestoreDefaultAssetsOnTutorialReload)
return;
AssetDatabase.SaveAssets();
string defaultsPath = Path.Combine(Directory.GetParent(Application.dataPath).FullName, k_DefaultsFolder);
var dirtyMetaFiles = new HashSet();
DirectoryCopy(defaultsPath, Application.dataPath, dirtyMetaFiles);
AssetDatabase.Refresh();
int startIndex = Application.dataPath.Length - "Assets".Length;
foreach (var dirtyMetaFile in dirtyMetaFiles)
AssetDatabase.ImportAsset(Path.ChangeExtension(dirtyMetaFile.Substring(startIndex), null));
}
internal static void WriteAssetsToTutorialDefaultsFolder()
{
if (!TutorialProjectSettings.Instance.RestoreDefaultAssetsOnTutorialReload)
return;
if (EditorApplication.isPlaying)
{
Debug.LogError("Defaults cannot be written during play mode");
return;
}
string defaultsPath = Path.Combine(Directory.GetParent(Application.dataPath).FullName, k_DefaultsFolder);
DirectoryInfo defaultsDirectory = new DirectoryInfo(defaultsPath);
if (defaultsDirectory.Exists)
{
foreach (var file in defaultsDirectory.GetFiles())
file.Delete();
foreach (var directory in defaultsDirectory.GetDirectories())
directory.Delete(true);
}
DirectoryCopy(Application.dataPath, defaultsPath);
}
internal static void DirectoryCopy(string sourceDirectory, string destinationDirectory, HashSet dirtyMetaFiles = default)
{
var sourceDir = new DirectoryInfo(sourceDirectory);
if (!sourceDir.Exists)
return;
if (!Directory.Exists(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
foreach (var file in sourceDir.GetFiles())
{
string tempPath = Path.Combine(destinationDirectory, file.Name);
if (dirtyMetaFiles != null && string.Equals(Path.GetExtension(tempPath), ".meta", StringComparison.OrdinalIgnoreCase))
{
if (!File.Exists(tempPath) || !File.ReadAllBytes(tempPath).SequenceEqual(File.ReadAllBytes(file.FullName)))
dirtyMetaFiles.Add(tempPath);
}
file.CopyTo(tempPath, true);
}
foreach (var subdir in sourceDir.GetDirectories())
{
string tempPath = Path.Combine(destinationDirectory, subdir.Name);
DirectoryCopy(subdir.FullName, tempPath, dirtyMetaFiles);
}
}
static string ReplaceAfter(string before, string replaceWithThis, string lineToRead)
{
int index = -1;
index = lineToRead.IndexOf(before, StringComparison.Ordinal);
if (index > -1)
{
lineToRead = lineToRead.Substring(0, index + before.Length) + replaceWithThis;
}
return lineToRead;
}
}
}