using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.Serialization; using System.Threading.Tasks; using Needle.Engine.Samples.Helpers; using Needle.Engine.Utils; using Newtonsoft.Json; using UnityEditor; using UnityEditor.PackageManager; using UnityEditor.PackageManager.Requests; using UnityEditor.SceneManagement; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UIElements; using Object = UnityEngine.Object; namespace Needle.Engine.Samples { public class SamplesWindow : EditorWindow, IHasCustomMenu { internal const string SamplesUrl = "https://engine.needle.tools/samples"; // private const string Unity2020Requirements = "Unity 2020.3.33+ and URP"; private const string Unity2021Requirements = "Unity 2021.3.9+"; private const string Unity2022Requirements = "Unity 2022.3+"; private const string NonLtsRequirements = "Newer non-LTS versions (use at your own risk)"; // [MenuItem("Window/Needle/Needle Engine Samples", priority = -1000)] [MenuItem("Needle Engine/Explore Samples 👀", priority = Engine.Constants.MenuItemOrder - 998)] public static void Open() { var existing = Resources.FindObjectsOfTypeAll().FirstOrDefault(); if (existing) { existing.Show(true); existing.Focus(); } else { CreateWindow().Show(); } } private static bool DidOpen { get => SessionState.GetBool("OpenedNeedleSamplesWindow", false); set => SessionState.SetBool("OpenedNeedleSamplesWindow", value); } /// /// Enable to view samples "as remote" /// private static bool ForceRemoteNeedleSamples { get => SessionState.GetBool(nameof(ForceRemoteNeedleSamples), false); set => SessionState.SetBool(nameof(ForceRemoteNeedleSamples), value); } [InitializeOnLoadMethod] private static async void Init() { if (DidOpen) return; DidOpen = true; await Task.Yield(); // Open samples window automatically on start only when in samples project if(Application.dataPath.Contains("Needle Engine Samples") && Application.isBatchMode == false) Open(); } public void AddItemsToMenu(GenericMenu menu) { menu.AddItem(new GUIContent("Refresh"), false, Refresh); menu.AddItem(new GUIContent("Reopen Window"), false, () => { Close(); Open(); }); menu.AddSeparator(""); menu.AddItem(new GUIContent("Force Remote Samples"), ForceRemoteNeedleSamples, () => { ForceRemoteNeedleSamples = !ForceRemoteNeedleSamples; HaveFetchedNeedleSamples = false; Close(); Open(); }); if (HaveSamplesPackage) { menu.AddItem(new GUIContent("Remove Sample Package"), false, () => { Client.Remove(Constants.SamplesPackageName); }); } var isDevMode = PackageUtils.IsMutable(Engine.Constants.TestPackagePath) || SessionState.GetBool("Needle_SamplesWindow_DevMode", false); if (isDevMode) { menu.AddSeparator(""); menu.AddItem( new GUIContent( "Update Samples Artifacts", "Creates samples.json and Samples.md in the repo root with the current sample data.\nAlso bumps the Needle Exporter dependency in the samples package to the current."), false, () => { SampleCollectionEditor.ProduceSampleArtifacts(); }); menu.AddItem( new GUIContent( "Export Local Package .tgz", "Outputs the Samples package as immutable needle-engine-samples.tgz.\nThis is referenced by Tests projects to get the same experience as installing the package from a registry."), false, () => { SampleCollectionEditor.ExportLocalPackage(); }); menu.AddItem( new GUIContent("Copy Samples Checklist to Clipboard"), false, () => { var samples = GetLocalSampleInfos(); var joined = string.Join("\n", samples.OrderBy(x => x.Name).Select(x => x.Name)); EditorGUIUtility.systemCopyBuffer = joined; ShowNotification(new GUIContent("Copied sample list to clipboard")); }); } } private static bool HaveSamplesPackage => #if HAS_NEEDLE_SAMPLES !ForceRemoteNeedleSamples; #else false; #endif private void Refresh() { if (!this) return; rootVisualElement.Clear(); RefreshAndCreateSampleView(rootVisualElement, this); } private static bool CanInstallSamples { #if (HAS_URP && HAS_2020_LTS) || HAS_2021_LTS || HAS_2022_LTS || HAS_2023_LTS || HAS_2024_LTS get => true; #else get => false; #endif } private static bool HasUnityLTSVersion { #if HAS_2021_LTS || HAS_2022_LTS || HAS_2023_LTS || HAS_2024_LTS get => true; #else get => false; #endif } private const string LTSWarning = "⚠️ Warning\nYou don't seem to be on a supported Unity LTS version.\nWe recommend using the latest LTS versions of Unity."; internal static int CountPathIndents(string path) => path.Count(y => y == '\\' || y == '/'); internal static List GetLocalSampleInfos(bool includeNonSamples = false) { var sampleInfos = AssetDatabase.FindAssets("t:" + nameof(SampleInfo)) .Select(AssetDatabase.GUIDToAssetPath) .Select(AssetDatabase.LoadAssetAtPath) .ToList(); AssetDatabase.Refresh(); if (includeNonSamples) { var scenes = AssetDatabase.FindAssets("t:SceneAsset", new[] { Constants.SamplesDirectory }); // Filtering out scenes that are not in the root folders of individual samples // + 2 since: // root has 2 idents (Packages/samples/Runtime) // scene path has 4 idents (Packages/samples/Runtime/Sample/Scene.unity int sampleSceneIndentLimit = CountPathIndents(Constants.SamplesDirectory) + 2; var filteredScenes = scenes .Select(AssetDatabase.GUIDToAssetPath) .Where(x => CountPathIndents(x) <= sampleSceneIndentLimit) .Select(AssetDatabase.LoadAssetAtPath) .Where(x => x) // seems after package updates we can get missing/null entries from the AssetDB... .OrderBy(x => x.name); foreach (var sceneAsset in filteredScenes) { if (sampleInfos.Any(s => s.Scene == sceneAsset)) continue; var info = CreateInstance(); info.Scene = sceneAsset; info.name = sceneAsset.name; if (TryGetScreenshot(sceneAsset.name, out var screenshotPath)) { info.Thumbnail = AssetDatabase.LoadAssetAtPath(screenshotPath); } sampleInfos.Add(info); } } sampleInfos = sampleInfos .OrderByDescending(x => x.Priority) .ThenBy(x => x.DisplayNameOrName) .ThenBy(x => !(bool) x.Thumbnail) .ToList(); return sampleInfos; } internal static async void RefreshAndCreateSampleView(VisualElement parent, object context) { var loadingLabel = new Label("Loading...") { name = "LoadingLabel", style = { fontSize = 24, unityTextAlign = TextAnchor.MiddleCenter, height = new StyleLength(new Length(90, LengthUnit.Percent)), opacity = 0.5f, } }; parent.Add(loadingLabel); await Task.Delay(10); // give the UI a chance to update List sampleInfos = default; // sampleInfos can either come from the project we're in, or come from a JSON file. // when the samples package is present, we use that, otherwise we fetch the JSON from elsewhere if (HaveSamplesPackage) { sampleInfos = GetLocalSampleInfos(); } else { var serializerSettings = SerializerSettings.Get(); serializerSettings.Context = new StreamingContext(StreamingContextStates.Persistence, context); var cachePath = default(string); #if false // for local testing var rootPath = "../../"; var jsonPath = rootPath + "samples.json"; var json = File.ReadAllText(jsonPath); await Task.CompletedTask; #else var jsonPath = Constants.RemoteSampleJsonPath; cachePath = Constants.CacheRoot + SanitizePath(Path.GetFileName(jsonPath)); if (!cachePath.EndsWith(".json")) cachePath += ".json"; if (HaveFetchedNeedleSamples && File.Exists(cachePath)) jsonPath = "file://" + Path.GetFullPath(cachePath); var request = new UnityWebRequest(jsonPath); request.downloadHandler = new DownloadHandlerBuffer(); var op = request.SendWebRequest(); while (!op.isDone) await Task.Yield(); if (request.result != UnityWebRequest.Result.Success) { var errorMessage = "Error: " + request.result + ", " + request.error; parent.Q