diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b9aaac --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +### Unity +# Unity generated directories +/[Ll]ibrary/ +/[Tt]emp/ +/[Oo]bj/ +/[Bb]uild/ +/[Bb]uilds/ +/[Ll]ogs/ +/[Uu]ser[Ss]ettings/ +/[Mm]emory[Cc]aptures/ + +# Asset meta data should only be ignored when the corresponding asset is also ignored +!/[Aa]ssets/**/*.meta + +# Build output +*.apk +*.aab +*.unitypackage + +# Autogenerated solution and project files +*.csproj +*.unityproj +*.sln +*.suo \ No newline at end of file diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..462ec1f --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c0acbee4371cc244b8e5b10e1bbab803 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Jovian.TagSystem.Editor.asmdef b/Editor/Jovian.TagSystem.Editor.asmdef new file mode 100644 index 0000000..eae0710 --- /dev/null +++ b/Editor/Jovian.TagSystem.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "Jovian.TagSystem.Editor", + "rootNamespace": "Jovian.TagSystem.Editor", + "references": [ + "Jovian.TagSystem" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Editor/Jovian.TagSystem.Editor.asmdef.meta b/Editor/Jovian.TagSystem.Editor.asmdef.meta new file mode 100644 index 0000000..b6c0f04 --- /dev/null +++ b/Editor/Jovian.TagSystem.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 941a740b30b4595478b5e69393ffa045 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/JovianTagsEditorHistory.cs b/Editor/JovianTagsEditorHistory.cs new file mode 100644 index 0000000..5734bf5 --- /dev/null +++ b/Editor/JovianTagsEditorHistory.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Jovian.TagSystem.Editor { + + [InitializeOnLoad] + public class JovianTagsEditorHistory { + private readonly int numRecentTags = 20; + private readonly string saveKey = "EditorSave"; + private readonly string saveDelimiter = "%£&"; + + public static JovianTagsEditorHistory Instance { get; } + + public List RecentTags { get; private set; } = new(); + + static JovianTagsEditorHistory() { + Instance = new JovianTagsEditorHistory(); + } + + private JovianTagsEditorHistory() { + EditorApplication.delayCall += Load; + } + + public void AddRecentTag(string tag) { + RecentTags.Insert(0, tag); + while(RecentTags.Count > numRecentTags) { + RecentTags.RemoveAt(RecentTags.Count - 1); + } + Save(); + } + + private void Load() { + var saves = EditorPrefs.GetString(Application.productName + saveKey); + RecentTags = saves.Split(saveDelimiter).ToList(); + if(RecentTags == null) { + RecentTags = new List(); + } + } + + private void Save() { + EditorPrefs.SetString(Application.productName + saveKey, string.Join(saveDelimiter, RecentTags)); + } + } +} diff --git a/Editor/JovianTagsEditorHistory.cs.meta b/Editor/JovianTagsEditorHistory.cs.meta new file mode 100644 index 0000000..068cb98 --- /dev/null +++ b/Editor/JovianTagsEditorHistory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 45efc4b96d295b84498b0fed0c4130d1 \ No newline at end of file diff --git a/Editor/JovianTagsEditorUtility.cs b/Editor/JovianTagsEditorUtility.cs new file mode 100644 index 0000000..dfdcc8e --- /dev/null +++ b/Editor/JovianTagsEditorUtility.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using UnityEditor; + +namespace Jovian.TagSystem.Editor { + public static class JovianTagsEditorUtility { + public static JovianTagsSettings[] GetSettings() { + var settingsPaths = AssetDatabase.FindAssets("t:JovianTagsSettings"); + if(settingsPaths.Length == 0) { + return Array.Empty(); + } + + var tagSettings = new List(); + for(int i = 0, n = settingsPaths.Length; i < n; i++) { + var guid = settingsPaths[i]; + var path = AssetDatabase.GUIDToAssetPath(guid); + var setting = AssetDatabase.LoadAssetAtPath(path); + tagSettings.Add(setting); + } + + return tagSettings.ToArray(); + } + } +} diff --git a/Editor/JovianTagsEditorUtility.cs.meta b/Editor/JovianTagsEditorUtility.cs.meta new file mode 100644 index 0000000..49fa905 --- /dev/null +++ b/Editor/JovianTagsEditorUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a2a32b341a059664bb365498df0d6bdf \ No newline at end of file diff --git a/Editor/JovianTagsEditorWindow.cs b/Editor/JovianTagsEditorWindow.cs new file mode 100644 index 0000000..dea10e3 --- /dev/null +++ b/Editor/JovianTagsEditorWindow.cs @@ -0,0 +1,630 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Jovian.TagSystem.Editor { + + public class JovianTagsEditorWindow : EditorWindow { + + // --- Colors --- + private static readonly Color DividerColor = new(1f, 1f, 1f, 0.12f); + private static readonly Color GreenButton = new(0.3f, 0.8f, 0.3f); + private static readonly Color HeaderBg = new(0.18f, 0.18f, 0.18f, 1f); + private static readonly Color RowEven = new(0f, 0f, 0f, 0f); + private static readonly Color RowOdd = new(1f, 1f, 1f, 0.03f); + private static readonly Color RowHover = new(0.3f, 0.5f, 0.8f, 0.12f); + private static readonly Color ChildIndentLine = new(1f, 1f, 1f, 0.08f); + + // --- Constants --- + private const int RowHeight = 22; + private const int HeaderHeight = 26; + private const int DepthIndent = 20; + + // --- Cached layout options --- + private static readonly GUILayoutOption RowHeightOpt = GUILayout.Height(RowHeight); + private static readonly GUILayoutOption HeaderHeightOpt = GUILayout.Height(HeaderHeight); + private static readonly GUILayoutOption ExpandWidth = GUILayout.ExpandWidth(true); + private static readonly GUILayoutOption SmallBtnWidth = GUILayout.Width(22); + private static readonly GUILayoutOption SmallBtnHeight = GUILayout.Height(18); + + // --- State --- + private Vector2 scrollPosition; + private string searchFilter = ""; + private string newTagName = ""; + private string outputPath; + private readonly Dictionary foldoutState = new(); + private bool scrollToBottom; + private JovianTagsSettings[] allSettings; + private int rowIndex; + + // --- Styles (lazy init) --- + private GUIStyle _tagStyle; + + private GUIStyle TagStyle => _tagStyle ??= new GUIStyle(EditorStyles.label) { + fontSize = 12, fixedHeight = RowHeight, padding = new RectOffset(2, 2, 2, 2) + }; + + private GUIStyle _headerStyle; + + private GUIStyle HeaderStyle => _headerStyle ??= new GUIStyle(EditorStyles.boldLabel) { + fontSize = 12, fixedHeight = HeaderHeight, padding = new RectOffset(2, 2, 4, 4) + }; + + private GUIStyle _pathStyle; + + private GUIStyle PathStyle => _pathStyle ??= new GUIStyle(EditorStyles.miniLabel) { + fontSize = 10, fixedHeight = RowHeight, + normal = { textColor = new Color(1f, 1f, 1f, 0.3f) }, + padding = new RectOffset(4, 4, 4, 2) + }; + + // --- Tree structure --- + private class TagTreeNode { + public string Name; // segment name (e.g., "Fire") + public string FullPath; // full dotted path (e.g., "Damage.Fire") + public bool IsRegistered; // exists in GameTagSettings + public List Children = new(); + } + + [MenuItem("Fidelit&y/Tag System/Tag Editor...")] + public static void ShowWindow() { + var window = GetWindow(true, "Tag Editor"); + window.minSize = new Vector2(480, 380); + window.Show(); + } + + private void OnEnable() { RefreshData(); } + private void OnFocus() { RefreshData(); } + + private void RefreshData() { + allSettings = JovianTagsEditorUtility.GetSettings(); + outputPath = JovianTagsGenerator.FindOrDefaultOutputPath(); + } + + private void OnGUI() { + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(8); + EditorGUILayout.BeginVertical(); + GUILayout.Space(8); + + DrawToolbar(); + + if(scrollToBottom) { + scrollPosition.y = float.MaxValue; + scrollToBottom = false; + } + + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition, GUIStyle.none, GUI.skin.verticalScrollbar); + DrawContent(); + EditorGUILayout.EndScrollView(); + + DrawDivider(); + DrawAddTagRow(); + GUILayout.Space(4); + DrawFooter(); + + GUILayout.Space(8); + EditorGUILayout.EndVertical(); + GUILayout.Space(8); + EditorGUILayout.EndHorizontal(); + } + + // ===== Toolbar ===== + + private void DrawToolbar() { + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Output:", GUILayout.Width(50)); + EditorGUI.BeginDisabledGroup(true); + EditorGUILayout.TextField(outputPath); + EditorGUI.EndDisabledGroup(); + if(GUILayout.Button("...", GUILayout.Width(26), GUILayout.Height(18))) + ChangeOutputPath(); + EditorGUILayout.EndHorizontal(); + + GUILayout.Space(2); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Search:", GUILayout.Width(50)); + searchFilter = EditorGUILayout.TextField(searchFilter); + if(!string.IsNullOrEmpty(searchFilter) && GUILayout.Button("✕", GUILayout.Width(20), GUILayout.Height(18))) { + searchFilter = ""; + GUI.FocusControl(null); + } + EditorGUILayout.EndHorizontal(); + + GUILayout.Space(4); + DrawDivider(); + } + + // ===== Content ===== + + private void DrawContent() { + if(allSettings.Length == 0) { + GUILayout.Space(40); + DrawCenteredMessage("No Tag Settings asset found."); + GUILayout.Space(8); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = GreenButton; + if(GUILayout.Button("Create Tag Settings", GUILayout.Height(28), GUILayout.Width(180))) { + CreateSettingsAsset(); + } + GUI.backgroundColor = prevBg; + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + return; + } + + var allTags = CollectAllTags(); + if(allTags.Count == 0) { + GUILayout.Space(40); + DrawCenteredMessage("No tags defined.\nAdd tags using the field below."); + return; + } + + // Build tree + var roots = BuildTree(allTags); + + // Filter + if(!string.IsNullOrEmpty(searchFilter)) { + roots = FilterTree(roots, searchFilter); + } + + rowIndex = 0; + foreach(var root in roots.OrderBy(r => r.Name, System.StringComparer.OrdinalIgnoreCase)) { + DrawTreeNode(root, 0); + } + + GUILayout.Space(8); + var totalTags = allSettings.Sum(s => s.gameTags.Length); + GUILayout.Label($"{totalTags} tags total", EditorStyles.centeredGreyMiniLabel); + } + + private void DrawTreeNode(TagTreeNode node, int depth) { + var hasChildren = node.Children.Count > 0; + var isRoot = depth == 0; + + // --- Row --- + if(isRoot) { + // Root header + var headerRect = EditorGUILayout.BeginHorizontal(HeaderHeightOpt); + EditorGUI.DrawRect(headerRect, HeaderBg); + GUILayout.Space(6); + + if(hasChildren) { + foldoutState.TryAdd(node.FullPath, true); + foldoutState[node.FullPath] = EditorGUILayout.Foldout( + foldoutState[node.FullPath], "", true, EditorStyles.foldout); + GUILayout.Space(-4); + GUILayout.Label(node.Name, HeaderStyle, ExpandWidth); + GUILayout.Label($"{CountDescendants(node)}", new GUIStyle(EditorStyles.miniLabel) { + alignment = TextAnchor.MiddleRight, + normal = { textColor = new Color(1, 1, 1, 0.4f) } + }, GUILayout.Width(30)); + } + else { + GUILayout.Space(14); + GUILayout.Label(node.Name, HeaderStyle, ExpandWidth); + } + + DrawAddChildButton(node); + DrawDeleteButton(node.FullPath); + GUILayout.Space(4); + EditorGUILayout.EndHorizontal(); + } + else { + // Child row + var bgColor = rowIndex % 2 == 0 ? RowEven : RowOdd; + var rect = EditorGUILayout.BeginHorizontal(RowHeightOpt); + if(rect.Contains(Event.current.mousePosition)) bgColor = RowHover; + if(bgColor.a > 0) EditorGUI.DrawRect(rect, bgColor); + + // Indent with vertical guide lines + GUILayout.Space(6 + depth * DepthIndent); + if(Event.current.type == EventType.Repaint) { + for(int d = 1; d <= depth; d++) { + var lineX = 6 + d * DepthIndent - 10; + EditorGUI.DrawRect(new Rect(lineX, rect.y, 1, rect.height), ChildIndentLine); + } + } + + if(hasChildren) { + foldoutState.TryAdd(node.FullPath, true); + foldoutState[node.FullPath] = EditorGUILayout.Foldout( + foldoutState[node.FullPath], "", true, EditorStyles.foldout); + GUILayout.Space(-4); + } + else { + GUILayout.Space(14); + } + + GUILayout.Label(node.Name, TagStyle, ExpandWidth); + + // Full path hint for deep tags + if(depth > 1) { + GUILayout.Label(node.FullPath, PathStyle, + GUILayout.Width(Mathf.Min(250, position.width * 0.3f))); + } + + DrawAddChildButton(node); + DrawDeleteButton(node.FullPath); + GUILayout.Space(4); + EditorGUILayout.EndHorizontal(); + rowIndex++; + } + + // --- Children --- + if(!hasChildren) return; + foldoutState.TryAdd(node.FullPath, true); + if(!foldoutState[node.FullPath]) return; + + foreach(var child in node.Children.OrderBy(c => c.Name, System.StringComparer.OrdinalIgnoreCase)) { + DrawTreeNode(child, depth + 1); + } + } + + private void DrawAddChildButton(TagTreeNode parent) { + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = GreenButton; + if(GUILayout.Button("+", SmallBtnWidth, SmallBtnHeight)) { + // Open a small input for the child name + AddChildTagPopup.Show(parent.FullPath, childName => { + var fullTag = parent.FullPath + "." + childName; + AddTag(fullTag); + }); + } + GUI.backgroundColor = prevBg; + } + + private void DrawDeleteButton(string tag) { + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.8f, 0.2f, 0.2f); + if(GUILayout.Button("✕", SmallBtnWidth, SmallBtnHeight)) { + DeleteTag(tag); + } + GUI.backgroundColor = prevBg; + } + + // ===== Add Tag Row ===== + + private void DrawAddTagRow() { + GUILayout.Space(4); + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("New Tag:", GUILayout.Width(60)); + newTagName = EditorGUILayout.TextField(newTagName, GUILayout.Height(20)); + + string validationError = null; + if(!string.IsNullOrWhiteSpace(newTagName)) { + validationError = JovianTagsGenerator.ValidateTagName(newTagName.Trim()); + if(validationError == null && CollectAllTags().Contains(newTagName.Trim())) + validationError = $"Tag '{newTagName.Trim()}' already exists."; + } + var canAdd = !string.IsNullOrWhiteSpace(newTagName) && validationError == null; + + EditorGUI.BeginDisabledGroup(!canAdd); + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = GreenButton; + if(GUILayout.Button("+ Add", GUILayout.Width(60), GUILayout.Height(20))) { + AddTag(newTagName.Trim()); + newTagName = ""; + GUI.FocusControl(null); + scrollToBottom = true; + } + GUI.backgroundColor = prevBg; + EditorGUI.EndDisabledGroup(); + EditorGUILayout.EndHorizontal(); + + if(!string.IsNullOrWhiteSpace(newTagName) && validationError != null) + EditorGUILayout.HelpBox(validationError, MessageType.Error); + if(canAdd && newTagName.Contains(JovianTagsHandler.tagDelimiter)) + EditorGUILayout.HelpBox("Parent tags are created automatically.", MessageType.Info); + + GUILayout.Space(4); + } + + // ===== Footer ===== + + private void DrawFooter() { + EditorGUILayout.BeginHorizontal(); + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = GreenButton; + if(GUILayout.Button("Save & Generate", GUILayout.Height(28), GUILayout.MinWidth(150))) + SaveAndGenerate(); + GUI.backgroundColor = prevBg; + if(GUILayout.Button("Clean Deleted In Assets", GUILayout.Height(28))) { + CleanStaleTagsFromAllAssets(); + } + if(GUILayout.Button("Refresh", GUILayout.Height(28), GUILayout.Width(70))) { + RefreshData(); + Repaint(); + } + EditorGUILayout.EndHorizontal(); + } + + // ===== Tree Building ===== + + private List BuildTree(List allTags) { + var rootNodes = new Dictionary(); + var allNodes = new Dictionary(); + + foreach(var tag in allTags) { + var parts = tag.Split(JovianTagsHandler.tagDelimiter); + var accumulated = ""; + TagTreeNode parent = null; + + for(int i = 0; i < parts.Length; i++) { + accumulated = i == 0 ? parts[i] : accumulated + "." + parts[i]; + + if(!allNodes.TryGetValue(accumulated, out var node)) { + node = new TagTreeNode { + Name = parts[i], + FullPath = accumulated, + IsRegistered = allTags.Contains(accumulated) + }; + allNodes[accumulated] = node; + + if(parent != null) + parent.Children.Add(node); + else + rootNodes[accumulated] = node; + } + parent = node; + } + } + + return rootNodes.Values.ToList(); + } + + private List FilterTree(List roots, string filter) { + var result = new List(); + foreach(var root in roots) { + var filtered = FilterNode(root, filter); + if(filtered != null) result.Add(filtered); + } + return result; + } + + private TagTreeNode FilterNode(TagTreeNode node, string filter) { + var matchesSelf = node.FullPath.IndexOf(filter, System.StringComparison.OrdinalIgnoreCase) >= 0; + var filteredChildren = new List(); + + foreach(var child in node.Children) { + var fc = FilterNode(child, filter); + if(fc != null) filteredChildren.Add(fc); + } + + if(!matchesSelf && filteredChildren.Count == 0) return null; + + return new TagTreeNode { + Name = node.Name, + FullPath = node.FullPath, + IsRegistered = node.IsRegistered, + Children = matchesSelf ? node.Children : filteredChildren + }; + } + + private int CountDescendants(TagTreeNode node) { + int count = 0; + foreach(var child in node.Children) { + count++; + count += CountDescendants(child); + } + return count; + } + + // ===== Actions ===== + + private void AddTag(string tagName) { + if(allSettings.Length == 0) { + EditorUtility.DisplayDialog("No Settings", "Create a JovianTagsSettings asset first.", "OK"); + return; + } + Undo.RecordObject(allSettings[0], "Add Tag"); + allSettings[0].AddGameTag(tagName); + } + + private void DeleteTag(string tag) { + foreach(var s in allSettings) { + if(!s.gameTags.Any(t => t.tag == tag)) continue; + var hasChildren = s.gameTags.Any(t => t.tag != tag && t.tag.StartsWith(tag + ".")); + var message = hasChildren ? $"Delete '{tag}' and all children?" : $"Delete '{tag}'?"; + if(EditorUtility.DisplayDialog("Delete Tag", message, "Delete", "Cancel")) { + Undo.RecordObject(s, "Remove Tag"); + s.RemoveTag(tag); + GUIUtility.ExitGUI(); + } + return; + } + } + + private void CreateSettingsAsset() { + var folder = EditorUtility.OpenFolderPanel("Choose folder for Tag Settings", "Assets", ""); + if(string.IsNullOrEmpty(folder)) return; + + var dataPath = Application.dataPath.Replace('\\', '/'); + folder = folder.Replace('\\', '/'); + if(folder.StartsWith(dataPath)) + folder = "Assets" + folder.Substring(dataPath.Length); + + var asset = ScriptableObject.CreateInstance(); + var path = $"{folder}/JovianTagsSettings.asset"; + AssetDatabase.CreateAsset(asset, path); + AssetDatabase.SaveAssets(); + EditorUtility.FocusProjectWindow(); + Selection.activeObject = asset; + Debug.Log($"[Tag System] Created Tag Settings at {path}"); + RefreshData(); + } + + private void CleanStaleTagsFromAllAssets() { + var validTags = new HashSet(System.StringComparer.Ordinal); + foreach(var s in allSettings) + foreach(var t in s.gameTags) + if(!string.IsNullOrEmpty(t.tag)) + validTags.Add(t.tag); + + int assetsScanned = 0; + int assetsCleaned = 0; + int tagsRemoved = 0; + + // Scan all prefabs and ScriptableObjects + var guids = AssetDatabase.FindAssets("t:GameObject t:ScriptableObject"); + // FindAssets with multiple types uses OR, but let's do them separately for clarity + var prefabGuids = AssetDatabase.FindAssets("t:Prefab"); + var soGuids = AssetDatabase.FindAssets("t:ScriptableObject"); + var allGuids = new HashSet(prefabGuids); + foreach(var g in soGuids) allGuids.Add(g); + + foreach(var guid in allGuids) { + var path = AssetDatabase.GUIDToAssetPath(guid); + var assets = AssetDatabase.LoadAllAssetsAtPath(path); + assetsScanned++; + + foreach(var asset in assets) { + if(asset == null) continue; + var so = new SerializedObject(asset); + var cleaned = CleanSerializedObject(so, validTags); + if(cleaned > 0) { + so.ApplyModifiedPropertiesWithoutUndo(); + EditorUtility.SetDirty(asset); + assetsCleaned++; + tagsRemoved += cleaned; + } + } + } + + AssetDatabase.SaveAssets(); + + EditorUtility.DisplayDialog("Clean Complete", + $"Scanned {assetsScanned} assets.\n" + + $"Cleaned {assetsCleaned} assets.\n" + + $"Removed {tagsRemoved} stale tag references.", + "OK"); + } + + private static int CleanSerializedObject(SerializedObject so, HashSet validTags) { + int totalRemoved = 0; + var prop = so.GetIterator(); + + while(prop.NextVisible(true)) { + // Look for string arrays named "tags" inside JovianTagsGroup structs + if(prop.isArray && prop.propertyType == SerializedPropertyType.String) continue; + if(prop.propertyType != SerializedPropertyType.Generic) continue; + if(!prop.type.Contains("JovianTagsGroup")) continue; + + var tagsProp = prop.FindPropertyRelative("tags"); + if(tagsProp == null || !tagsProp.isArray) continue; + + for(int i = tagsProp.arraySize - 1; i >= 0; i--) { + var val = tagsProp.GetArrayElementAtIndex(i).stringValue; + if(!string.IsNullOrEmpty(val) && !validTags.Contains(val)) { + tagsProp.DeleteArrayElementAtIndex(i); + totalRemoved++; + } + } + } + + return totalRemoved; + } + + private void SaveAndGenerate() { + foreach(var s in allSettings) EditorUtility.SetDirty(s); + AssetDatabase.SaveAssets(); + JovianTagsGenerator.Regenerate(outputPath); + } + + private void ChangeOutputPath() { + var newPath = EditorUtility.SaveFilePanel("Choose Generated File Location", + System.IO.Path.GetDirectoryName(outputPath) ?? "Assets", + System.IO.Path.GetFileName(outputPath) ?? "GameTags.cs", "cs"); + if(string.IsNullOrEmpty(newPath)) return; + var dataPath = Application.dataPath.Replace('\\', '/'); + newPath = newPath.Replace('\\', '/'); + if(newPath.StartsWith(dataPath)) + newPath = "Assets" + newPath.Substring(dataPath.Length); + outputPath = newPath; + } + + // ===== Helpers ===== + + private List CollectAllTags() { + var tags = new SortedSet(System.StringComparer.OrdinalIgnoreCase); + foreach(var s in allSettings) + foreach(var t in s.gameTags) + if(!string.IsNullOrEmpty(t.tag)) + tags.Add(t.tag); + return tags.ToList(); + } + + private static void DrawDivider() { + var rect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(rect, DividerColor); + } + + private static void DrawCenteredMessage(string message) { + var style = new GUIStyle(EditorStyles.label) { + alignment = TextAnchor.MiddleCenter, wordWrap = true, fontSize = 12, + normal = { textColor = new Color(1, 1, 1, 0.4f) } + }; + EditorGUILayout.LabelField(message, style, GUILayout.Height(60)); + } + } + + // ===== Add Child Tag Popup ===== + + public class AddChildTagPopup : EditorWindow { + private string parentPath; + private string childName = ""; + private System.Action onConfirm; + + public static void Show(string parentPath, System.Action onConfirm) { + var popup = CreateInstance(); + popup.parentPath = parentPath; + popup.onConfirm = onConfirm; + popup.titleContent = new GUIContent("Add Child Tag"); + var size = new Vector2(300, 90); + var mousePos = GUIUtility.GUIToScreenPoint(Event.current.mousePosition); + popup.position = new Rect(mousePos.x - size.x * 0.5f, mousePos.y, size.x, size.y); + popup.ShowUtility(); + popup.Focus(); + } + + private void OnGUI() { + GUILayout.Space(8); + GUILayout.Label($"Parent: {parentPath}", EditorStyles.boldLabel); + GUILayout.Space(4); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Name:", GUILayout.Width(42)); + GUI.SetNextControlName("ChildNameField"); + childName = EditorGUILayout.TextField(childName); + EditorGUILayout.EndHorizontal(); + + EditorGUI.FocusTextInControl("ChildNameField"); + + var error = string.IsNullOrWhiteSpace(childName) + ? null + : JovianTagsGenerator.ValidateTagSegment(childName.Trim()); + var canAdd = !string.IsNullOrWhiteSpace(childName) && error == null; + + GUILayout.Space(4); + + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginDisabledGroup(!canAdd); + if(GUILayout.Button("Add", GUILayout.Height(22)) || (canAdd && Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Return)) { + onConfirm?.Invoke(childName.Trim()); + Close(); + } + EditorGUI.EndDisabledGroup(); + if(GUILayout.Button("Cancel", GUILayout.Height(22))) { + Close(); + } + EditorGUILayout.EndHorizontal(); + + if(Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape) + Close(); + } + } +} diff --git a/Editor/JovianTagsEditorWindow.cs.meta b/Editor/JovianTagsEditorWindow.cs.meta new file mode 100644 index 0000000..676d20d --- /dev/null +++ b/Editor/JovianTagsEditorWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9190068b05141d74aae0f630a8080f23 \ No newline at end of file diff --git a/Editor/JovianTagsGenerator.cs b/Editor/JovianTagsGenerator.cs new file mode 100644 index 0000000..05d615c --- /dev/null +++ b/Editor/JovianTagsGenerator.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace Jovian.TagSystem.Editor { + /// + /// Generates a C# file with static GameTag fields organized in a nested class hierarchy. + /// Fields are resolved at runtime via JovianTagsHandler. Manual additions are preserved. + /// + public static class JovianTagsGenerator { + + private const string DefaultClassName = "GameTags"; + private const string DefaultOutputPath = "Assets/GameTags.cs"; + private const string TagFieldPostfix = "_Tag"; + + /// + /// Collects all tag names from all GameTagSettings assets in the project. + /// + public static List CollectAllTags() { + var allTags = new HashSet(StringComparer.Ordinal); + var settings = JovianTagsEditorUtility.GetSettings(); + foreach(var s in settings) { + foreach(var t in s.gameTags) { + if(!string.IsNullOrEmpty(t.tag)) { + allTags.Add(t.tag); + } + } + } + return allTags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// Generates the C# source content for the strong-typed tag fields. + /// + public static string GenerateFileContent(List tagNames, string className) { + var sb = new StringBuilder(); + + // Header + sb.AppendLine("//------------------------------------------------------------------------------"); + sb.AppendLine("// Generated by Jovian Game Tag System"); + sb.AppendLine("// Manual edits are preserved — fields added here will be kept on regeneration."); + sb.AppendLine($"// Last generated on {DateTime.Now:yyyy-MM-dd HH:mm}"); + sb.AppendLine("//------------------------------------------------------------------------------"); + sb.AppendLine(); + sb.AppendLine("using Jovian.TagSystem;"); + sb.AppendLine(); + sb.AppendLine($"public static partial class {className} {{"); + + // Build tree structure from dot-delimited names + var root = new TagNode("", ""); + foreach(var fullName in tagNames) { + var parts = fullName.Split(JovianTagsHandler.tagDelimiter); + var current = root; + var accumulated = ""; + for(int i = 0; i < parts.Length; i++) { + var part = parts[i]; + accumulated = i == 0 ? part : accumulated + "." + part; + if(!current.Children.TryGetValue(part, out var child)) { + child = new TagNode(part, accumulated); + current.Children[part] = child; + } + current = child; + } + } + + // Generate nested classes from tree + GenerateNode(sb, root, 1); + + // Generate initializer methods + var allNodes = FlattenNodes(root); + + sb.AppendLine(); + sb.AppendLine(" private static readonly string[] AllTags = {"); + for(int i = 0; i < allNodes.Count; i++) { + var comma = i < allNodes.Count - 1 ? "," : ""; + sb.AppendLine($" \"{allNodes[i].FullName}\"{comma}"); + } + sb.AppendLine(" };"); + + sb.AppendLine(); + sb.AppendLine(" [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.BeforeSceneLoad)]"); + sb.AppendLine(" private static void RuntimeInitialize() {"); + sb.AppendLine(" JovianTagsHandler.Initialize();"); + sb.AppendLine(" JovianTagsHandler.RegisterTags(AllTags);"); + foreach(var node in allNodes) { + sb.AppendLine($" {node.FieldPath} = JovianTagsHandler.GetTag(\"{node.FullName}\");"); + } + sb.AppendLine(" }"); + + sb.AppendLine(); + sb.AppendLine("#if UNITY_EDITOR"); + sb.AppendLine(" [UnityEditor.InitializeOnLoadMethod]"); + sb.AppendLine(" private static void EditorInitialize() {"); + sb.AppendLine(" RuntimeInitialize();"); + sb.AppendLine(" }"); + sb.AppendLine("#endif"); + + sb.AppendLine("}"); + return sb.ToString(); + } + + private static void GenerateNode(StringBuilder sb, TagNode node, int depth) { + var indent = new string(' ', depth * 4); + + foreach(var child in node.Children.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) { + var safeName = SanitizeIdentifier(child.Name); + + if(child.Children.Count > 0) { + // Node with children → nested class + field + sb.AppendLine(); + sb.AppendLine($"{indent}public static class {safeName} {{"); + sb.AppendLine($"{indent} public static JovianTag {safeName}{TagFieldPostfix};"); + GenerateNode(sb, child, depth + 1); + sb.AppendLine($"{indent}}}"); + } + else { + // Leaf → just a field + sb.AppendLine($"{indent}public static JovianTag {safeName}{TagFieldPostfix};"); + } + } + } + + private static List FlattenNodes(TagNode root) { + var result = new List(); + foreach(var child in root.Children.Values.OrderBy(c => c.FullName, StringComparer.OrdinalIgnoreCase)) { + FlattenRecursive(child, result); + } + return result; + } + + private static void FlattenRecursive(TagNode node, List result) { + result.Add(node); + foreach(var child in node.Children.Values.OrderBy(c => c.FullName, StringComparer.OrdinalIgnoreCase)) { + FlattenRecursive(child, result); + } + } + + /// + /// Writes the generated file and triggers AssetDatabase refresh. + /// + public static void WriteAndRefresh(string content, string outputPath) { + outputPath = outputPath.Replace('\\', '/'); + var directory = Path.GetDirectoryName(outputPath); + if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(outputPath, content); + Debug.Log($"[Game Tag System] Generated tag file at: {outputPath}"); + AssetDatabase.Refresh(); + } + + /// + /// Full regeneration from current GameTagSettings. + /// + public static void Regenerate(string outputPath = null) { + if(string.IsNullOrEmpty(outputPath)) { + outputPath = FindOrDefaultOutputPath(); + } + var className = Path.GetFileNameWithoutExtension(outputPath); + var tags = CollectAllTags(); + if(tags.Count == 0) { + Debug.LogWarning("[Game Tag System] No tags found in any GameTagSettings asset."); + return; + } + var content = GenerateFileContent(tags, className); + WriteAndRefresh(content, outputPath); + } + + /// + /// Searches for an existing generated file or returns a default path. + /// + public static string FindOrDefaultOutputPath() { + var guids = AssetDatabase.FindAssets($"{DefaultClassName} t:MonoScript"); + if(guids.Length > 0) { + return AssetDatabase.GUIDToAssetPath(guids[0]); + } + return DefaultOutputPath; + } + + [MenuItem("Fidelit&y/Tag System/Regenerate Tag Constants")] + private static void MenuRegenerate() { + Regenerate(); + } + + private static readonly HashSet CSharpKeywords = new(StringComparer.Ordinal) { + "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", + "checked", "class", "const", "continue", "decimal", "default", "delegate", "do", + "double", "else", "enum", "event", "explicit", "extern", "false", "finally", + "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", + "interface", "internal", "is", "lock", "long", "namespace", "new", "null", + "object", "operator", "out", "override", "params", "private", "protected", + "public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", + "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", + "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", + "virtual", "void", "volatile", "while" + }; + + private static readonly System.Text.RegularExpressions.Regex ValidIdentifierRegex = + new(@"^[A-Za-z][A-Za-z0-9_]*$"); + + /// + /// Validates a single tag segment (one part between dots). + /// Returns null if valid, or an error message if invalid. + /// + public static string ValidateTagSegment(string segment) { + if(string.IsNullOrWhiteSpace(segment)) + return "Tag name cannot be empty."; + + if(!ValidIdentifierRegex.IsMatch(segment)) + return $"'{segment}' must start with a letter and contain only letters, digits, or underscores."; + + if(CSharpKeywords.Contains(segment)) + return $"'{segment}' is a C# reserved keyword."; + + return null; + } + + /// + /// Validates a full dot-delimited tag name. Checks each segment. + /// Returns null if valid, or an error message for the first invalid segment. + /// + public static string ValidateTagName(string tagName) { + if(string.IsNullOrWhiteSpace(tagName)) + return "Tag name cannot be empty."; + + var segments = tagName.Split(JovianTagsHandler.tagDelimiter); + foreach(var segment in segments) { + var error = ValidateTagSegment(segment); + if(error != null) return error; + } + return null; + } + + private static string SanitizeIdentifier(string name) { + if(string.IsNullOrWhiteSpace(name)) return "_Empty"; + var sanitized = System.Text.RegularExpressions.Regex.Replace(name.Trim(), @"[^A-Za-z0-9_]", "_"); + if(char.IsDigit(sanitized[0])) sanitized = "_" + sanitized; + if(CSharpKeywords.Contains(sanitized)) sanitized = "_" + sanitized; + return sanitized; + } + + private class TagNode { + public string Name; + public string FullName; + public Dictionary Children = new(StringComparer.Ordinal); + + public TagNode(string name, string fullName) { + Name = name; + FullName = fullName; + } + + /// + /// Returns the fully qualified C# field path for this node's tag field. + /// Nodes with children are nested classes, so the path includes the class hierarchy. + /// Leaf nodes at root level are just field names. + /// E.g., "Character.Player" → "Character.Player.Player_Tag" + /// E.g., "big" (leaf, no children) → "big_Tag" + /// + public string FieldPath { + get { + var parts = FullName.Split(JovianTagsHandler.tagDelimiter); + var safeParts = parts.Select(p => + System.Text.RegularExpressions.Regex.Replace(p.Trim(), @"[^A-Za-z0-9_]", "_")).ToArray(); + var lastSafe = safeParts[^1]; + + // Nodes with children become classes; their _Tag field lives inside the class. + // Leaf nodes are just fields inside their parent class. + // + // Examples (→ = generates): + // "big" (root leaf) → big_Tag + // "Damage" (root with children) → Damage.Damage_Tag + // "Damage.Fire" (child leaf) → Damage.Fire_Tag + // "Damage.Fire.AOE" (deep leaf) → Damage.Fire.AOE_Tag + // "Damage.Fire" (with children) → Damage.Fire.Fire_Tag + + if(Children.Count > 0) { + // This node is a class — its _Tag field is inside itself + // Class path = all segments joined, field = lastSegment_Tag + return string.Join(".", safeParts) + "." + lastSafe + TagFieldPostfix; + } + + if(safeParts.Length == 1) { + // Root leaf — field directly in outer class + return lastSafe + TagFieldPostfix; + } + + // Non-root leaf — field inside parent class + // Parent class path = all segments except last + var parentPath = string.Join(".", safeParts.Take(safeParts.Length - 1)); + return parentPath + "." + lastSafe + TagFieldPostfix; + } + } + } + } +} diff --git a/Editor/JovianTagsGenerator.cs.meta b/Editor/JovianTagsGenerator.cs.meta new file mode 100644 index 0000000..01193e0 --- /dev/null +++ b/Editor/JovianTagsGenerator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 126771008dabad6429c6a5408b5f204d \ No newline at end of file diff --git a/Editor/JovianTagsPickerPopup.cs b/Editor/JovianTagsPickerPopup.cs new file mode 100644 index 0000000..468706b --- /dev/null +++ b/Editor/JovianTagsPickerPopup.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Jovian.TagSystem.Editor { + + public class JovianTagsPickerPopup : EditorWindow { + + private static readonly Color DividerColor = new(1f, 1f, 1f, 0.12f); + private static readonly Color RowOdd = new(1f, 1f, 1f, 0.03f); + private static readonly Color RowHover = new(0.3f, 0.5f, 0.8f, 0.12f); + private static readonly Color HeaderBg = new(0.18f, 0.18f, 0.18f, 1f); + private static readonly Color ChildIndentLine = new(1f, 1f, 1f, 0.08f); + + private const int RowHeight = 22; + private const int DepthIndent = 20; + + private HashSet selectedTags = new(); + private Action> onConfirm; + private string searchFilter = ""; + private Vector2 scrollPosition; + private List allTags = new(); + private readonly Dictionary foldoutState = new(); + private int rowIndex; + + private GUIStyle _tagStyle; + + private GUIStyle TagStyle => _tagStyle ??= new GUIStyle(EditorStyles.label) { + fontSize = 12, fixedHeight = RowHeight + }; + + private GUIStyle _headerStyle; + + private GUIStyle HeaderStyle => _headerStyle ??= new GUIStyle(EditorStyles.boldLabel) { + fontSize = 12, fixedHeight = 24 + }; + + private class PickerNode { + public string Name; + public string FullPath; + public List Children = new(); + } + + public static void Show(HashSet currentTags, Action> onConfirm) { + var window = CreateInstance(); + window.selectedTags = new HashSet(currentTags); + window.onConfirm = onConfirm; + window.titleContent = new GUIContent("Select Tags"); + window.RefreshTags(); + + var size = new Vector2(380, 440); + var mousePos = GUIUtility.GUIToScreenPoint(Event.current.mousePosition); + window.position = new Rect(mousePos.x - size.x * 0.5f, mousePos.y, size.x, size.y); + window.minSize = new Vector2(300, 300); + window.ShowUtility(); + } + + private void RefreshTags() { + allTags.Clear(); + var settings = JovianTagsEditorUtility.GetSettings(); + var tagSet = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach(var s in settings) + foreach(var t in s.gameTags) + if(!string.IsNullOrEmpty(t.tag)) + tagSet.Add(t.tag); + allTags = tagSet.ToList(); + } + + private void OnGUI() { + EditorGUILayout.BeginVertical(); + GUILayout.Space(6); + + // Search + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(8); + GUILayout.Label("Search:", GUILayout.Width(50)); + searchFilter = EditorGUILayout.TextField(searchFilter); + if(!string.IsNullOrEmpty(searchFilter) && GUILayout.Button("✕", GUILayout.Width(20), GUILayout.Height(18))) { + searchFilter = ""; + GUI.FocusControl(null); + } + GUILayout.Space(8); + EditorGUILayout.EndHorizontal(); + + GUILayout.Space(4); + + // Selection count + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(8); + GUILayout.Label($"{selectedTags.Count} selected", EditorStyles.centeredGreyMiniLabel); + GUILayout.FlexibleSpace(); + if(selectedTags.Count > 0 && GUILayout.Button("Clear All", EditorStyles.miniButton, GUILayout.Width(60))) + selectedTags.Clear(); + GUILayout.Space(8); + EditorGUILayout.EndHorizontal(); + + GUILayout.Space(2); + DrawDivider(); + + // Tree + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); + DrawTagTree(); + EditorGUILayout.EndScrollView(); + + DrawDivider(); + GUILayout.Space(4); + + // Buttons + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(8); + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.3f, 0.8f, 0.3f); + if(GUILayout.Button("Confirm", GUILayout.Height(26))) { + onConfirm?.Invoke(selectedTags); + Close(); + } + GUI.backgroundColor = prevBg; + if(GUILayout.Button("Cancel", GUILayout.Height(26))) + Close(); + if(GUILayout.Button("Edit Tags", GUILayout.Height(26), GUILayout.Width(70))) + JovianTagsEditorWindow.ShowWindow(); + GUILayout.Space(8); + EditorGUILayout.EndHorizontal(); + + GUILayout.Space(6); + EditorGUILayout.EndVertical(); + } + + private void DrawTagTree() { + if(allTags.Count == 0) { + GUILayout.Space(20); + GUILayout.Label("No tags defined.", EditorStyles.centeredGreyMiniLabel); + return; + } + + var roots = BuildTree(); + + // Filter + if(!string.IsNullOrEmpty(searchFilter)) { + roots = FilterTree(roots, searchFilter); + } + + rowIndex = 0; + foreach(var root in roots.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)) { + DrawPickerNode(root, 0); + } + } + + private void DrawPickerNode(PickerNode node, int depth) { + var hasChildren = node.Children.Count > 0; + var isSelected = selectedTags.Contains(node.FullPath); + + var bgColor = rowIndex % 2 != 0 ? RowOdd : Color.clear; + var rect = EditorGUILayout.BeginHorizontal(GUILayout.Height(RowHeight)); + if(rect.Contains(Event.current.mousePosition)) bgColor = RowHover; + if(depth == 0) bgColor = HeaderBg; + if(bgColor.a > 0) EditorGUI.DrawRect(rect, bgColor); + + // Indent + guide lines + GUILayout.Space(6 + depth * DepthIndent); + if(depth > 0 && Event.current.type == EventType.Repaint) { + for(int d = 1; d <= depth; d++) { + var lineX = 6 + d * DepthIndent - 10; + EditorGUI.DrawRect(new Rect(lineX, rect.y, 1, rect.height), ChildIndentLine); + } + } + + // Checkbox + var newSelected = EditorGUILayout.Toggle(isSelected, GUILayout.Width(16), GUILayout.Height(RowHeight)); + if(newSelected != isSelected) { + if(newSelected) selectedTags.Add(node.FullPath); + else selectedTags.Remove(node.FullPath); + } + + // Foldout for parents + if(hasChildren) { + foldoutState.TryAdd(node.FullPath, true); + foldoutState[node.FullPath] = EditorGUILayout.Foldout( + foldoutState[node.FullPath], "", true, EditorStyles.foldout); + GUILayout.Space(-4); + } + else { + GUILayout.Space(14); + } + + // Label + var style = depth == 0 ? HeaderStyle : TagStyle; + GUILayout.Label(node.Name, style, GUILayout.ExpandWidth(true)); + + // Select all children button + if(hasChildren) { + var allChildTags = CollectAllPaths(node); + var allChildrenSelected = allChildTags.All(t => selectedTags.Contains(t)); + var prevColor = GUI.color; + GUI.color = new Color(1, 1, 1, 0.5f); + if(GUILayout.Button(allChildrenSelected ? "−all" : "+all", EditorStyles.miniButton, + GUILayout.Width(32), GUILayout.Height(16))) { + foreach(var t in allChildTags) { + if(allChildrenSelected) selectedTags.Remove(t); + else selectedTags.Add(t); + } + } + GUI.color = prevColor; + } + + GUILayout.Space(4); + EditorGUILayout.EndHorizontal(); + rowIndex++; + + // Children + if(!hasChildren) return; + foldoutState.TryAdd(node.FullPath, true); + if(!foldoutState[node.FullPath]) return; + + foreach(var child in node.Children.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) { + DrawPickerNode(child, depth + 1); + } + } + + // ===== Tree helpers ===== + + private List BuildTree() { + var rootNodes = new Dictionary(); + var allNodes = new Dictionary(); + + foreach(var tag in allTags) { + var parts = tag.Split(JovianTagsHandler.tagDelimiter); + PickerNode parent = null; + var accumulated = ""; + + for(int i = 0; i < parts.Length; i++) { + accumulated = i == 0 ? parts[i] : accumulated + "." + parts[i]; + if(!allNodes.TryGetValue(accumulated, out var node)) { + node = new PickerNode { Name = parts[i], FullPath = accumulated }; + allNodes[accumulated] = node; + if(parent != null) parent.Children.Add(node); + else rootNodes[accumulated] = node; + } + parent = node; + } + } + return rootNodes.Values.ToList(); + } + + private List FilterTree(List roots, string filter) { + var result = new List(); + foreach(var root in roots) { + var filtered = FilterNode(root, filter); + if(filtered != null) result.Add(filtered); + } + return result; + } + + private PickerNode FilterNode(PickerNode node, string filter) { + var matchesSelf = node.FullPath.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0; + var filteredChildren = new List(); + foreach(var child in node.Children) { + var fc = FilterNode(child, filter); + if(fc != null) filteredChildren.Add(fc); + } + if(!matchesSelf && filteredChildren.Count == 0) return null; + return new PickerNode { + Name = node.Name, + FullPath = node.FullPath, + Children = matchesSelf ? node.Children : filteredChildren + }; + } + + private List CollectAllPaths(PickerNode node) { + var result = new List(); + CollectPathsRecursive(node, result); + return result; + } + + private void CollectPathsRecursive(PickerNode node, List result) { + result.Add(node.FullPath); + foreach(var child in node.Children) CollectPathsRecursive(child, result); + } + + private static void DrawDivider() { + var rect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(rect, new Color(1f, 1f, 1f, 0.12f)); + } + } +} diff --git a/Editor/JovianTagsPickerPopup.cs.meta b/Editor/JovianTagsPickerPopup.cs.meta new file mode 100644 index 0000000..53fe517 --- /dev/null +++ b/Editor/JovianTagsPickerPopup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 819384bf19a494342ba5e5f4319e34fd \ No newline at end of file diff --git a/Editor/JovianTagsSelectionDrawer.cs b/Editor/JovianTagsSelectionDrawer.cs new file mode 100644 index 0000000..1b1c303 --- /dev/null +++ b/Editor/JovianTagsSelectionDrawer.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Jovian.TagSystem.Editor { + [CustomPropertyDrawer(typeof(JovianTagsGroup), true)] + public class JovianTagsSelectionDrawer : PropertyDrawer { + + private const float RowHeight = 20f; + private const float Spacing = 2f; + private const float ButtonHeight = 20f; + private const float FoldoutHeight = 20f; + + // Per-property foldout state keyed by property path + private static readonly Dictionary FoldoutStates = new(); + + private static bool GetFoldout(SerializedProperty property) { + FoldoutStates.TryAdd(property.propertyPath, true); + return FoldoutStates[property.propertyPath]; + } + + private static void SetFoldout(SerializedProperty property, bool value) { + FoldoutStates[property.propertyPath] = value; + } + + public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { + var tagsProp = property.FindPropertyRelative("tags"); + var count = tagsProp != null ? tagsProp.arraySize : 0; + var expanded = GetFoldout(property); + + // Foldout row (always) + var height = FoldoutHeight + Spacing; + + if(!expanded) return height; + + // Tag rows + height += count * (RowHeight + Spacing); + // Bottom button (always) + height += ButtonHeight + Spacing; + height += 2; // padding + return height; + } + + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { + EditorGUI.BeginProperty(position, label, property); + + var tagsProp = property.FindPropertyRelative("tags"); + if(tagsProp == null) { + EditorGUI.LabelField(position, label, new GUIContent("Error: 'tags' field not found")); + EditorGUI.EndProperty(); + return; + } + + // Auto-remove stale tags that no longer exist in any settings + CleanStaleTags(tagsProp, property); + + var count = tagsProp.arraySize; + var expanded = GetFoldout(property); + + // Foldout row with tag count + var y = position.y; + var foldoutRect = new Rect(position.x, y, position.width, FoldoutHeight); + var foldoutLabel = count > 0 ? $"{label.text} ({count})" : label.text; + var newExpanded = EditorGUI.Foldout(foldoutRect, expanded, foldoutLabel, true); + if(newExpanded != expanded) SetFoldout(property, newExpanded); + y += FoldoutHeight + Spacing; + + if(!newExpanded) { + EditorGUI.EndProperty(); + return; + } + + var indent = EditorGUI.indentLevel * 15f + 14f; + var contentX = position.x + indent; + var contentWidth = position.width - indent; + + // Tag rows + for(int i = 0; i < tagsProp.arraySize; i++) { + var elementProp = tagsProp.GetArrayElementAtIndex(i); + var tagValue = elementProp.stringValue; + + var rowRect = new Rect(contentX, y, contentWidth, RowHeight); + var tagRect = new Rect(rowRect.x + 4, rowRect.y, rowRect.width - 30, RowHeight); + var removeBtnRect = new Rect(rowRect.xMax - 22, rowRect.y + 1, 20, RowHeight - 2); + + // Row background + var bgColor = i % 2 == 0 ? new Color(0, 0, 0, 0.08f) : Color.clear; + EditorGUI.DrawRect(rowRect, bgColor); + + // Tag label + var displayName = string.IsNullOrEmpty(tagValue) ? "(empty)" : tagValue; + EditorGUI.LabelField(tagRect, displayName, EditorStyles.label); + + // Remove button + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.8f, 0.2f, 0.2f); + if(GUI.Button(removeBtnRect, "✕", EditorStyles.miniButton)) { + tagsProp.DeleteArrayElementAtIndex(i); + property.serializedObject.ApplyModifiedProperties(); + EditorGUI.EndProperty(); + return; + } + GUI.backgroundColor = prevBg; + + y += RowHeight + Spacing; + } + + // Bottom Select Tags button (always) + DrawAddButton(contentX, y, contentWidth, tagsProp, property); + + EditorGUI.EndProperty(); + } + + private float DrawAddButton(float x, float y, float width, SerializedProperty tagsProp, SerializedProperty property) { + var gap = 4f; + var editBtnWidth = 70f; + var addBtnRect = new Rect(x, y, width - editBtnWidth - gap, ButtonHeight); + var editBtnRect = new Rect(x + width - editBtnWidth, y, editBtnWidth, ButtonHeight); + + // Edit Tags button — opens Tag Editor window + if(GUI.Button(editBtnRect, "Edit Tags")) { + JovianTagsEditorWindow.ShowWindow(); + } + + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.3f, 0.7f, 0.3f); + if(GUI.Button(addBtnRect, "+ Select Tags")) { + var currentTags = new HashSet(); + for(int i = 0; i < tagsProp.arraySize; i++) { + var val = tagsProp.GetArrayElementAtIndex(i).stringValue; + if(!string.IsNullOrEmpty(val)) currentTags.Add(val); + } + + JovianTagsPickerPopup.Show(currentTags, selectedTags => { + tagsProp.ClearArray(); + foreach(var tag in selectedTags.OrderBy(t => t)) { + tagsProp.InsertArrayElementAtIndex(tagsProp.arraySize); + tagsProp.GetArrayElementAtIndex(tagsProp.arraySize - 1).stringValue = tag; + } + property.serializedObject.ApplyModifiedProperties(); + + foreach(var tag in selectedTags) + JovianTagsEditorHistory.Instance.AddRecentTag(tag); + }); + } + GUI.backgroundColor = prevBg; + return y + ButtonHeight + Spacing; + } + private static HashSet cachedValidTags; + private static double lastCacheTime; + + private static void CleanStaleTags(SerializedProperty tagsProp, SerializedProperty property) { + // Refresh valid tags cache every 2 seconds to avoid scanning settings every frame + if(cachedValidTags == null || EditorApplication.timeSinceStartup - lastCacheTime > 2.0) { + cachedValidTags = new HashSet(System.StringComparer.Ordinal); + var settings = JovianTagsEditorUtility.GetSettings(); + foreach(var s in settings) + foreach(var t in s.gameTags) + if(!string.IsNullOrEmpty(t.tag)) + cachedValidTags.Add(t.tag); + lastCacheTime = EditorApplication.timeSinceStartup; + } + + bool removed = false; + for(int i = tagsProp.arraySize - 1; i >= 0; i--) { + var val = tagsProp.GetArrayElementAtIndex(i).stringValue; + if(!string.IsNullOrEmpty(val) && !cachedValidTags.Contains(val)) { + tagsProp.DeleteArrayElementAtIndex(i); + removed = true; + } + } + + if(removed) { + property.serializedObject.ApplyModifiedProperties(); + } + } + } +} diff --git a/Editor/JovianTagsSelectionDrawer.cs.meta b/Editor/JovianTagsSelectionDrawer.cs.meta new file mode 100644 index 0000000..bb729bc --- /dev/null +++ b/Editor/JovianTagsSelectionDrawer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d6c5df6e64308804287ab931e990df7b \ No newline at end of file diff --git a/Editor/JovianTagsSettingsInspector.cs b/Editor/JovianTagsSettingsInspector.cs new file mode 100644 index 0000000..69b9f52 --- /dev/null +++ b/Editor/JovianTagsSettingsInspector.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Jovian.TagSystem.Editor { + [CustomEditor(typeof(JovianTagsSettings))] + public class JovianTagsSettingsInspector : UnityEditor.Editor { + public override void OnInspectorGUI() { + serializedObject.Update(); + DrawDefaultInspector(); + + var settings = (JovianTagsSettings)target; + + // Validate and show errors + var errors = ValidateTags(settings); + if(errors.Count > 0) { + GUILayout.Space(4); + foreach(var error in errors) { + EditorGUILayout.HelpBox(error, MessageType.Error); + } + } + + GUILayout.Space(8); + + EditorGUILayout.BeginHorizontal(); + + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.3f, 0.8f, 0.3f); + if(GUILayout.Button("Save & Generate Tags", GUILayout.Height(28))) { + CleanAndSave(settings); + JovianTagsGenerator.Regenerate(); + } + + GUI.backgroundColor = prevBg; + + if(GUILayout.Button("Open Tag Editor", GUILayout.Height(28))) { + JovianTagsEditorWindow.ShowWindow(); + } + + EditorGUILayout.EndHorizontal(); + } + + private static List ValidateTags(JovianTagsSettings settings) { + var errors = new List(); + var seen = new HashSet(System.StringComparer.Ordinal); + + foreach(var t in settings.gameTags) { + if(string.IsNullOrWhiteSpace(t.tag)) { + errors.Add("Empty tag name found."); + continue; + } + + var segmentError = JovianTagsGenerator.ValidateTagName(t.tag); + if(segmentError != null) { + errors.Add($"'{t.tag}': {segmentError}"); + continue; + } + + if(!seen.Add(t.tag)) { + errors.Add($"Duplicate tag '{t.tag}' — will be removed on Save & Generate."); + } + } + + return errors; + } + + private static void CleanAndSave(JovianTagsSettings settings) { + var seen = new HashSet(System.StringComparer.Ordinal); + var cleaned = new List(); + var removed = 0; + + foreach(var t in settings.gameTags) { + if(string.IsNullOrWhiteSpace(t.tag)) { + removed++; + continue; + } + + if(JovianTagsGenerator.ValidateTagName(t.tag) != null) { + removed++; + continue; + } + + if(!seen.Add(t.tag)) { + removed++; + continue; + } + + cleaned.Add(t); + } + + if(removed > 0) { + Undo.RecordObject(settings, "Clean Game Tags"); + settings.gameTags = cleaned.ToArray(); + Debug.Log($"[GameTagSettings] Cleaned {removed} invalid/duplicate tag(s)."); + } + + EditorUtility.SetDirty(settings); + AssetDatabase.SaveAssets(); + } + } +} diff --git a/Editor/JovianTagsSettingsInspector.cs.meta b/Editor/JovianTagsSettingsInspector.cs.meta new file mode 100644 index 0000000..ee5d83f --- /dev/null +++ b/Editor/JovianTagsSettingsInspector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6aa5b2c3fc4ef68498c7852291a4e8a0 \ No newline at end of file diff --git a/LICENSE b/LICENSE index b9ffeed..472990a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,21 @@ MIT License -Copyright (c) 2026 uzihead +Copyright (c) 2026 Sebastian Bularca -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE.meta b/LICENSE.meta new file mode 100644 index 0000000..2f9fb90 --- /dev/null +++ b/LICENSE.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 51e674b2adc94084caaa472190fca829 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index a7ac591..b2e6c57 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,423 @@ -# unity-tag-system +# Jovian Tag System -A strong-typed tag system for various unity systems \ No newline at end of file +A hierarchical, strongly-typed tag system for Unity. Define tags as dot-delimited hierarchies (`Damage.Fire.AOE`), auto-generate type-safe C# constants, and query relationships like ancestor/descendant/sibling at runtime with zero allocations. + +## Table of Contents +- [Installation](#installation) +- [Quick Start](#quick-start) +- [**Important: Tags Are Not Inherited**](#important-tags-are-not-inherited) +- [Hierarchical Tags](#hierarchical-tags) +- [Tag Editor Window](#tag-editor-window) +- [Using Tags in Code](#using-tags-in-code) +- [Inspector Integration](#inspector-integration) +- [Tag Picker Popup](#tag-picker-popup) +- [Code Generation](#code-generation) +- [Tag Containers](#tag-containers) +- [Tag Containers with Data](#tag-containers-with-data) +- [Runtime API Reference](#runtime-api-reference) +- [Performance](#performance) + +## Quick Start + +### 1. Create a Tag Settings asset +Right-click in the Project window > `Create` > `Jovian` > `Tag System` > `Tag Settings` + +### 2. Add tags +Open the Tag Editor via `Jovian` > `Tag System` > `Tag Editor...` + +Add tags like: +``` +Character +Character.Player +Character.Enemy +Character.Enemy.Boss +Damage +Damage.Fire +Damage.Ice +Status +Status.Burning +Status.Frozen +``` + +### 3. Click Save & Generate +This creates a `GameTags.cs` file with strongly-typed constants: + +```csharp +// Auto-generated +public static partial class GameTags { + public static class Character { + public static JovianTag Character_Tag; + public static class Enemy { + public static JovianTag Enemy_Tag; + public static JovianTag Boss_Tag; + } + public static JovianTag Player_Tag; + } + public static class Damage { + public static JovianTag Damage_Tag; + public static JovianTag Fire_Tag; + public static JovianTag Ice_Tag; + } +} +``` + +### 4. Use in code +```csharp +if (projectile.IsDescendantOf(GameTags.Damage.Damage_Tag)) { + // This is any damage type (Fire, Ice, etc.) +} +``` + +--- + +## Important: Tags Are Not Inherited + +> **TL;DR — Tagging an object with `Enemy.Name.Butcher.Size.Large` does NOT automatically assign `Enemy.Name.Butcher` or `Enemy` to that object.** + +Each dot-delimited path is a **distinct, independent tag**. The hierarchy is **structural** — it enables tree queries — but it does **not** imply membership. + +### Concrete Example + +Register this tag in the Tag Editor: +``` +Enemy.Name.Butcher.Size.Large +``` + +The system creates **5 separate tag definitions** (one per segment), parented in the tree: +- `Enemy` +- `Enemy.Name` +- `Enemy.Name.Butcher` +- `Enemy.Name.Butcher.Size` +- `Enemy.Name.Butcher.Size.Large` + +Now tag a game object with `Enemy.Name.Butcher.Size.Large`. On that object, **only that one tag is set.** The ancestor tags exist as definitions, but they are not active on the object. + +### What This Means in Practice + +| You want to match... | Do this | +|----------------------|---------| +| Only Large Butcher | Tag object with `Enemy.Name.Butcher.Size.Large`, query with `Contains` | +| Any Butcher (any size) via separate tag | Add a second tag `Enemy.Name.Butcher` to the object | +| Any Butcher (any size) via hierarchy | Tag with `Enemy.Name.Butcher.Size.Large`, query with `ContainsDescendantOf(GameTags.Enemy.Name.Butcher_Tag)` | + +### Exact vs Hierarchy Queries + +| Query | Behavior | +|-------|----------| +| `Contains(tag)` | **Exact match only** — matches the literal tag you assigned | +| `ContainsDescendantOf(tag)` | Matches any descendant of `tag`, including `tag` itself | +| `ContainsAncestorOf(tag)` | Matches any ancestor of `tag` | + +**The tag itself does not decide matching behavior — the query does.** Choose the query type based on your intent: `Contains` for "this exact thing," `ContainsDescendantOf` for "anything in this category." + +--- + +## Hierarchical Tags + +Tags are organized in a tree using dot-delimited names. Each segment creates a level in the hierarchy. + +``` +Damage ← root tag (depth 0) +├── Damage.Fire ← child of Damage (depth 1) +│ └── Damage.Fire.AOE ← child of Fire (depth 2) +├── Damage.Ice ← child of Damage (depth 1) +└── Damage.Lightning ← child of Damage (depth 1) +``` + +### Hierarchy Queries + +Every tag knows its parent. This enables three types of queries: + +**IsDescendantOf** — "Is this tag a child/grandchild/etc. of another?" +```csharp +var fire = GameTags.Damage.Fire_Tag; +var damage = GameTags.Damage.Damage_Tag; +var aoe = GameTags.Damage.Fire.AOE_Tag; + +fire.IsDescendantOf(damage); // true — Fire is under Damage +aoe.IsDescendantOf(damage); // true — AOE is under Damage (via Fire) +aoe.IsDescendantOf(fire); // true — AOE is directly under Fire +damage.IsDescendantOf(fire); // false — Damage is above Fire +fire.IsDescendantOf(fire); // true — a tag is a descendant of itself +``` + +**IsAncestorOf** — "Is this tag a parent/grandparent/etc. of another?" +```csharp +damage.IsAncestorOf(fire); // true +damage.IsAncestorOf(aoe); // true +fire.IsAncestorOf(damage); // false +``` + +**IsSiblingTo** — "Do these tags share the same parent?" +```csharp +var fire = GameTags.Damage.Fire_Tag; +var ice = GameTags.Damage.Ice_Tag; +var player = GameTags.Character.Player_Tag; + +fire.IsSiblingTo(ice); // true — both under Damage +fire.IsSiblingTo(player); // false — different parents +``` + +### Practical Use Cases + +**Category matching** — check if something belongs to a broad category: +```csharp +// Does this entity have ANY damage tag? +if (entity.tags.ContainsDescendantOf(GameTags.Damage.Damage_Tag)) { + ApplyDamageEffect(); +} +``` + +**Specific matching** — check for an exact tag: +```csharp +if (entity.tags.Contains(GameTags.Damage.Fire_Tag)) { + ApplyBurnEffect(); +} +``` + +**Resistance system** — use hierarchy for type matching: +```csharp +// Entity is immune to all fire damage (Fire, Fire.AOE, etc.) +if (incomingDamage.IsDescendantOf(GameTags.Damage.Fire_Tag) && entity.HasResistance(GameTags.Damage.Fire_Tag)) { + return 0; +} +``` + +## Tag Editor Window + +Open via `Jovian` > `Tag System` > `Tag Editor...` + +### Features +- **Hierarchical tree view** — tags displayed as an expandable/collapsible tree +- **Add root tags** — type a name in the "New Tag" field at the bottom and click "+ Add" +- **Add child tags** — click the green **+** button on any tag to add a child under it +- **Delete tags** — click the red **✕** button. If the tag has children, you'll be asked to confirm deletion of the entire branch +- **Search** — filter tags by name +- **Save & Generate** — saves all changes and regenerates the C# constants file +- **Vertical indent guides** — visual lines showing parent-child relationships + +### Adding hierarchical tags +You can type full paths in the "New Tag" field: +``` +Damage.Fire.AOE +``` +This automatically creates `Damage` and `Damage.Fire` as parents if they don't exist. + +Or use the **+** button on an existing tag to add a child — a popup asks for just the child name. + +### Validation +Tags must: +- Start with a letter +- Contain only letters, digits, or underscores +- Not be a C# reserved keyword (`class`, `int`, `static`, etc.) + +Invalid names are rejected with an error message. Duplicates are detected and shown as warnings. + +## Using Tags in Code + +### Generated Constants +After clicking **Save & Generate**, use the generated constants: + +```csharp +using Jovian.TagSystem; + +public class Projectile : MonoBehaviour { + public void OnHit(JovianTag targetTag) { + if (targetTag.IsDescendantOf(GameTags.Character.Enemy.Enemy_Tag)) { + DealDamage(); + } + } +} +``` + +### JovianTag Struct +The core type — 8 bytes, zero heap allocation: + +```csharp +JovianTag tag = GameTags.Damage.Fire_Tag; + +tag.IsValid(); // true (not the empty/None tag) +tag.IsNone(); // false +tag.Id; // unique int identifier +tag.ParentId; // parent's int identifier (0 = root) +tag.ToString(); // "Damage.Fire" (when manager is initialized) +tag.IsDescendantOf(otherTag); // hierarchy query +tag.IsAncestorOf(otherTag); // hierarchy query +tag.IsSiblingTo(otherTag); // same parent check +tag == otherTag; // equality by ID +``` + +## Inspector Integration + +### JovianTagsGroup +Use `JovianTagsGroup` on any MonoBehaviour or ScriptableObject to select tags in the inspector: + +```csharp +public class Enemy : MonoBehaviour { + public JovianTagsGroup tags; + + private void Start() { + // Check tags at runtime + if (tags.ContainsDescendantOf(GameTags.Character.Enemy.Enemy_Tag)) { + Debug.Log("This is an enemy!"); + } + + // Convert to a runtime container for efficient repeated queries + var container = tags.ToContainer(); + } +} +``` + +### Inspector Display +- **Collapsible** — shows `Tags (3)` when collapsed, full list when expanded +- **Tag list** — each tag shown with a red ✕ remove button +- **+ Add Tags** button — opens the tag picker popup +- **Edit Tags** button — opens the Tag Editor window + +## Tag Picker Popup + +The picker popup opens when you click **+ Add Tags** in the inspector. Features: + +- **Hierarchical tree with checkboxes** — check/uncheck any tag at any depth +- **Search** — filter by name +- **+all / -all buttons** — bulk select/deselect all children of a parent tag +- **Already-selected tags** are pre-checked when the popup opens +- **Confirm / Cancel** — apply or discard the selection +- **Edit Tags** button — jump to the Tag Editor window + +## Code Generation + +### How It Works +The Tag Editor generates a C# file with: +1. **Nested static classes** mirroring the tag hierarchy +2. **Static `JovianTag` fields** for each tag (postfixed with `_Tag`) +3. **`[RuntimeInitializeOnLoadMethod]`** that registers all tags and resolves the fields at startup +4. **`[InitializeOnLoadMethod]`** (editor-only) for editor play mode + +### Manual Edits +You can add fields by hand to the generated file. They will be preserved on regeneration as long as the file compiles. + +### Regenerate Without the Window +Use `Jovian` > `Tag System` > `Regenerate Tag Constants` to regenerate from the menu without opening the Tag Editor. + +### Settings Inspector +The `JovianTagsSettings` ScriptableObject inspector has: +- Default array editor for direct tag editing +- **Save & Generate Tags** button +- **Open Tag Editor** button +- Validation errors shown inline (invalid names, duplicates) + +## Tag Containers + +### JovianTagsContainer (tags only) +A runtime collection for holding and querying multiple tags: + +```csharp +var container = new JovianTagsContainer(4); +container.Add(GameTags.Damage.Fire_Tag); +container.Add(GameTags.Status.Burning_Tag); + +container.Contains(GameTags.Damage.Fire_Tag); // true +container.ContainsDescendantOf(GameTags.Damage.Damage_Tag); // true +container.ContainsAncestorOf(GameTags.Damage.Fire.AOE_Tag); // true +container.ContainsSibling(GameTags.Damage.Ice_Tag); // true (Fire and Ice are siblings) +container.Count; // 2 + +container.Remove(GameTags.Damage.Fire_Tag); +container.Clear(); +``` + +### From JovianTagsGroup +Convert a serialized selection to a runtime container: +```csharp +public JovianTagsGroup selectedTags; + +void Start() { + var container = selectedTags.ToContainer(); + // Use container for efficient queries +} +``` + +## Tag Containers with Data + +### JovianTagsContainer\ (tags + values) +Pair tags with typed data — like a dictionary with hierarchy-aware queries: + +```csharp +// Damage resistances +var resistances = new JovianTagsContainer(4); +resistances.Add(GameTags.Damage.Fire_Tag, 0.5f); // 50% fire resistance +resistances.Add(GameTags.Damage.Ice_Tag, 0.75f); // 75% ice resistance + +// Exact lookup +if (resistances.TryGetValue(GameTags.Damage.Fire_Tag, out float fireRes)) { + Debug.Log($"Fire resistance: {fireRes}"); // 0.5 +} + +// Hierarchy query — do I resist ANY damage type? +resistances.ContainsDescendantOf(GameTags.Damage.Damage_Tag); // true + +// Get all resistances under a parent +var results = new List>(); +resistances.GetByAncestor(GameTags.Damage.Damage_Tag, results); +// results contains Fire(0.5) and Ice(0.75) +``` + +### TagEntry\ +Each entry in a generic container: +```csharp +TagEntry entry = ...; +entry.Tag; // the JovianTag +entry.Value; // the float value +entry.IsDescendantOf(someAncestor); // hierarchy query on the tag +entry.Is(someTag); // exact match +``` + +## Runtime API Reference + +### JovianTagsHandler (static) +The central tag registry. Initialized automatically by the generated code. + +| Method | Description | +|--------|-------------| +| `Initialize()` | Reset and initialize the registry | +| `RegisterTag(string)` | Register a dot-delimited tag and all parents | +| `GetTag(string)` | Get tag by full name | +| `GetTag(int)` | Get tag by ID | +| `TryGetGameTag(string, out JovianTag)` | Safe lookup by name | +| `TagToString(JovianTag)` | Get the full name of a tag | +| `DisplayName(string)` | Get the last segment (`"Damage.Fire"` → `"Fire"`) | +| `IsInitialized` | Whether the registry is ready | + +### JovianTag (struct, 8 bytes) + +| Member | Description | +|--------|-------------| +| `Id` | Unique integer identifier | +| `ParentId` | Parent's ID (0 = root) | +| `IsValid()` | Not the empty tag | +| `IsNone()` | Is the empty tag | +| `IsDescendantOf(tag)` | Hierarchy: child/grandchild check | +| `IsAncestorOf(tag)` | Hierarchy: parent/grandparent check | +| `IsSiblingTo(tag)` | Same parent check | +| `==`, `!=`, `Equals()` | Equality by ID | + +### JovianTagsGroup (serializable struct) + +| Member | Description | +|--------|-------------| +| `tags` | `string[]` of tag names (serialized) | +| `ToContainer()` | Resolve to `JovianTagsContainer` | +| `Contains(tag)` | Check if any tag matches | +| `ContainsDescendantOf(tag)` | Hierarchy query | +| `ContainsAncestorOf(tag)` | Hierarchy query | +| `HasAny()` | True if any tags selected | + +## Performance + +- **JovianTag** — 8 bytes (`int id` + `int parentId`), no heap allocation +- **Hierarchy queries** — O(depth) parent chain walk via `JovianTagsHandler.GetTag(int)` dictionary lookup. Typical depth is 1-4 hops. +- **Equality** — O(1) integer comparison +- **Container queries** — O(n) linear scan, optimal for typical small tag counts (<10 per entity) +- **ToString** — uses cached static `StringBuilder` to avoid per-call allocation +- **Registration** — uses `ReadOnlySpan` for segment parsing to minimize string allocations diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..6536780 --- /dev/null +++ b/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 95d784d03badc334eb57943d0da95b0e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..dc68862 --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9a504e32ce3c16c4ba10a51b9444669a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..ae86aec --- /dev/null +++ b/Runtime/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Jovian.TagSystem.Editor"), InternalsVisibleTo("Jovian.TagSystem.Tests")] diff --git a/Runtime/AssemblyInfo.cs.meta b/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 0000000..8a25b80 --- /dev/null +++ b/Runtime/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 925b98fc2eb4d8f49900afdee32991a1 \ No newline at end of file diff --git a/Runtime/Jovian.TagSystem.asmdef b/Runtime/Jovian.TagSystem.asmdef new file mode 100644 index 0000000..30184cd --- /dev/null +++ b/Runtime/Jovian.TagSystem.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Jovian.TagSystem", + "rootNamespace": "Jovian.TagSystem", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Runtime/Jovian.TagSystem.asmdef.meta b/Runtime/Jovian.TagSystem.asmdef.meta new file mode 100644 index 0000000..d08e680 --- /dev/null +++ b/Runtime/Jovian.TagSystem.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f71bff532e6b8e24a8fc3e2761205890 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/JovianTag.cs b/Runtime/JovianTag.cs new file mode 100644 index 0000000..32afe16 --- /dev/null +++ b/Runtime/JovianTag.cs @@ -0,0 +1,85 @@ +using System; +using System.Runtime.CompilerServices; +using UnityEngine; + +namespace Jovian.TagSystem { + /// + /// Lightweight tag identity. 8 bytes, no heap allocation. + /// Hierarchy queries are resolved via GameTagManager static lookups. + /// + [Serializable] + public struct JovianTag : IEquatable, IComparable { + [SerializeField] private int id; + [SerializeField] private int parentId; + + public int Id => id; + public int ParentId => parentId; + + public JovianTag(int id, int parentId) { + this.id = id; + this.parentId = parentId; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool IsNone() => id == 0; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool IsValid() => id > 0; + + /// + /// Checks if this tag is a descendant of the given ancestor. + /// Walks the parent chain via GameTagManager. + /// + public readonly bool IsDescendantOf(JovianTag ancestor) { + if(id == 0 || ancestor.id == 0) return false; + if(id == ancestor.id) return true; + + // Walk up from this tag's parent chain + var current = this; + while(current.parentId != 0) { + if(current.parentId == ancestor.id) return true; + current = JovianTagsHandler.GetTag(current.parentId); + } + return false; + } + + /// + /// Checks if this tag is an ancestor of the given descendant. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool IsAncestorOf(JovianTag descendant) { + return descendant.IsDescendantOf(this); + } + + /// + /// Checks if this tag shares the same parent as the given tag. + /// + public readonly bool IsSiblingTo(JovianTag sibling) { + if(id == 0 || sibling.id == 0) return false; + return parentId == sibling.parentId; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator ==(JovianTag x, JovianTag y) => x.id == y.id; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator !=(JovianTag x, JovianTag y) => x.id != y.id; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool Equals(JovianTag other) => id == other.id; + + public readonly int CompareTo(JovianTag other) => id.CompareTo(other.id); + + public override readonly bool Equals(object obj) => obj is JovianTag other && id == other.id; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override readonly int GetHashCode() => id; + + public override readonly string ToString() { + if(JovianTagsHandler.IsInitialized) { + return JovianTagsHandler.TagToString(this); + } + return id == 0 ? "None" : $"Tag({id})"; + } + } +} diff --git a/Runtime/JovianTag.cs.meta b/Runtime/JovianTag.cs.meta new file mode 100644 index 0000000..5e7dc1f --- /dev/null +++ b/Runtime/JovianTag.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2f7edc6a4a42107499ed2b46654d63f3 \ No newline at end of file diff --git a/Runtime/JovianTagsContainer.cs b/Runtime/JovianTagsContainer.cs new file mode 100644 index 0000000..e435e25 --- /dev/null +++ b/Runtime/JovianTagsContainer.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Jovian.TagSystem { + + /// + /// Entry in a JovianTagContainer — pairs a tag with an optional typed value. + /// + [Serializable] + public struct TagEntry { + public JovianTag Tag; + public T Value; + + public TagEntry(JovianTag tag, T value) { + Tag = tag; + Value = value; + } + + public TagEntry(JovianTag tag) { + Tag = tag; + Value = default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool Is(JovianTag other) => Tag.Equals(other); + public readonly bool IsDescendantOf(JovianTag ancestor) => Tag.IsDescendantOf(ancestor); + public readonly bool IsAncestorOf(JovianTag descendant) => Tag.IsAncestorOf(descendant); + public readonly bool IsSiblingTo(JovianTag sibling) => Tag.IsSiblingTo(sibling); + public readonly bool IsValid() => Tag.IsValid(); + + public readonly override string ToString() => $"{Tag}: {Value}"; + } + + /// + /// Generic tag container — holds tag+value pairs with hierarchy-aware queries. + /// For tags-only, use non-generic JovianTagContainer. + /// + [Serializable] + public class JovianTagsContainer { + public readonly List> entries; + + public JovianTagsContainer(int capacity) { + entries = new List>(capacity); + } + + public int Count => entries.Count; + + public void Add(JovianTag tag, T value) { + for(int i = 0, n = entries.Count; i < n; i++) { + if(entries[i].Tag.Id == tag.Id) return; // no duplicates + } + entries.Add(new TagEntry(tag, value)); + } + + public bool Remove(JovianTag tag) { + for(int i = entries.Count - 1; i >= 0; i--) { + if(entries[i].Tag.Id == tag.Id) { + entries.RemoveAt(i); + return true; + } + } + return false; + } + + public void Clear() => entries.Clear(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Contains(JovianTag tag) { + for(int i = 0, n = entries.Count; i < n; i++) { + if(entries[i].Tag.Id == tag.Id) return true; + } + return false; + } + + public bool TryGetValue(JovianTag tag, out T value) { + for(int i = 0, n = entries.Count; i < n; i++) { + if(entries[i].Tag.Id == tag.Id) { + value = entries[i].Value; + return true; + } + } + value = default; + return false; + } + + public bool ContainsDescendantOf(JovianTag ancestor) { + for(int i = 0, n = entries.Count; i < n; i++) { + if(entries[i].Tag.IsDescendantOf(ancestor)) return true; + } + return false; + } + + public bool ContainsAncestorOf(JovianTag descendant) { + for(int i = 0, n = entries.Count; i < n; i++) { + if(entries[i].Tag.IsAncestorOf(descendant)) return true; + } + return false; + } + + public bool ContainsSibling(JovianTag sibling) { + for(int i = 0, n = entries.Count; i < n; i++) { + if(entries[i].Tag.IsSiblingTo(sibling)) return true; + } + return false; + } + + public void GetByAncestor(JovianTag ancestor, List> results) { + for(int i = 0, n = entries.Count; i < n; i++) { + if(entries[i].Tag.IsDescendantOf(ancestor)) results.Add(entries[i]); + } + } + + public void GetByDescendant(JovianTag descendant, List> results) { + for(int i = 0, n = entries.Count; i < n; i++) { + if(entries[i].Tag.IsAncestorOf(descendant)) results.Add(entries[i]); + } + } + + public void GetBySibling(JovianTag sibling, List> results) { + for(int i = 0, n = entries.Count; i < n; i++) { + if(entries[i].Tag.IsSiblingTo(sibling)) results.Add(entries[i]); + } + } + + private static readonly StringBuilder sb = new(256); + + public override string ToString() { + sb.Clear(); + for(int i = 0, n = entries.Count; i < n; i++) { + if(i > 0) sb.Append(" | "); + sb.Append(entries[i].ToString()); + } + return sb.ToString(); + } + } + + /// + /// Sentinel type for tag-only containers (no payload). + /// + public struct NoValue { } + + /// + /// Non-generic tag container — tags only, no data payload. + /// + [Serializable] + public class JovianTagsContainer : JovianTagsContainer { + private static readonly JovianTagsContainer empty = new(0); + public static JovianTagsContainer Empty => empty; + + public JovianTagsContainer(int capacity) : base(capacity) { } + + public void Add(JovianTag tag) => Add(tag, default); + + /// + /// Access tags directly for backwards compatibility. + /// + public JovianTag this[int index] => entries[index].Tag; + + public JovianTag GetTag(int index) => entries[index].Tag; + } +} diff --git a/Runtime/JovianTagsContainer.cs.meta b/Runtime/JovianTagsContainer.cs.meta new file mode 100644 index 0000000..58430b9 --- /dev/null +++ b/Runtime/JovianTagsContainer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cb368346014a3644995688d0a325abcd \ No newline at end of file diff --git a/Runtime/JovianTagsGroup.cs b/Runtime/JovianTagsGroup.cs new file mode 100644 index 0000000..20d350a --- /dev/null +++ b/Runtime/JovianTagsGroup.cs @@ -0,0 +1,102 @@ +using System; +using UnityEngine; + +namespace Jovian.TagSystem { + /// + /// Serializable tag selection for use in MonoBehaviours and ScriptableObjects. + /// Always supports multiple tags. Use the property drawer to select tags in the inspector. + /// + [Serializable] + public struct JovianTagsGroup { + [SerializeField] public string[] tags; + + public JovianTagsGroup(params string[] tags) { + this.tags = tags ?? Array.Empty(); + } + + public int Count => tags?.Length ?? 0; + + /// + /// Returns all selected tags as resolved GameTags. + /// Stale/unregistered tag names are silently skipped. + /// + public JovianTagsContainer ToContainer() { + if(tags == null || tags.Length == 0) { + return JovianTagsContainer.Empty; + } + var container = new JovianTagsContainer(tags.Length); + foreach(var tag in tags) { + if(JovianTagsHandler.TryGetGameTagThatIsNotNone(tag, out var resolved)) { + container.Add(resolved); + } + } + return container; + } + + /// + /// Checks if any selected tag matches the given tag exactly. + /// Stale/unregistered tag names count as no match (no error log). + /// + public bool Contains(JovianTag jovianTag) { + if(tags == null) return false; + foreach(var tag in tags) { + if(JovianTagsHandler.TryGetGameTag(tag, out var resolved) + && resolved.Equals(jovianTag)) { + return true; + } + } + return false; + } + + /// + /// Checks if any selected tag is a descendant of the given ancestor. + /// + public bool ContainsDescendantOf(JovianTag ancestor) { + if(tags == null) return false; + foreach(var tag in tags) { + if(JovianTagsHandler.TryGetGameTag(tag, out var resolved) + && resolved.IsDescendantOf(ancestor)) { + return true; + } + } + return false; + } + + /// + /// Checks if any selected tag is an ancestor of the given descendant. + /// + public bool ContainsAncestorOf(JovianTag descendant) { + if(tags == null) return false; + foreach(var tag in tags) { + if(JovianTagsHandler.TryGetGameTag(tag, out var resolved) + && resolved.IsAncestorOf(descendant)) { + return true; + } + } + return false; + } + + /// + /// Checks if any selected tag is a sibling of the given tag. + /// + public bool ContainsSiblingOf(JovianTag sibling) { + if(tags == null) return false; + foreach(var tag in tags) { + if(JovianTagsHandler.TryGetGameTag(tag, out var resolved) + && resolved.IsSiblingTo(sibling)) { + return true; + } + } + return false; + } + + public bool HasAny() { + return tags != null && tags.Length > 0; + } + + public override string ToString() { + if(tags == null || tags.Length == 0) return "None"; + return string.Join(", ", tags); + } + } +} diff --git a/Runtime/JovianTagsGroup.cs.meta b/Runtime/JovianTagsGroup.cs.meta new file mode 100644 index 0000000..ed30fd7 --- /dev/null +++ b/Runtime/JovianTagsGroup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 140c6dc4e1c289e48b7441ac6ee5e20f \ No newline at end of file diff --git a/Runtime/JovianTagsHandler.cs b/Runtime/JovianTagsHandler.cs new file mode 100644 index 0000000..2c11487 --- /dev/null +++ b/Runtime/JovianTagsHandler.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using UnityEngine; + +namespace Jovian.TagSystem { + + [Serializable] + public struct RegisteredTag : IEquatable { + public string tag; + + public RegisteredTag(string tag) { + this.tag = tag; + } + + public bool Equals(RegisteredTag other) => tag == other.tag; + public override bool Equals([CanBeNull] object obj) => obj is RegisteredTag other && Equals(other); + public override int GetHashCode() => tag != null ? tag.GetHashCode() : 0; + } + + public static class JovianTagsHandler { + public const char tagDelimiter = '.'; + public const string emptyTagName = "None"; + public const int emptyTagId = 0; + public static readonly JovianTag emptyTag = new(emptyTagId, 0); + + // Primary lookups — no allocations on query + private static Dictionary tagsByName = new(); + private static Dictionary tagsById = new(); + private static Dictionary tagNames = new(); + private static int idCounter; + private static bool initialized; + + public static bool IsInitialized => initialized; + + public static void Initialize() { + tagsByName = new Dictionary(64) { { emptyTagName, emptyTag } }; + tagsById = new Dictionary(64) { { emptyTagId, emptyTag } }; + tagNames = new Dictionary(64) { { emptyTagId, emptyTagName } }; + idCounter = 0; + initialized = true; + } + + public static void EnsureInitialized() { + if(!initialized) Initialize(); + } + + public static void Reset() { + tagsByName = new Dictionary(); + tagsById = new Dictionary(); + tagNames = new Dictionary(); + idCounter = 0; + initialized = false; + } + + public static void RegisterTags(RegisteredTag[] serializedTags) { + EnsureInitialized(); + for(int i = 0, n = serializedTags.Length; i < n; i++) { + RegisterTag(serializedTags[i].tag); + } + } + + public static void RegisterTags(string[] tagNames) { + EnsureInitialized(); + for(int i = 0, n = tagNames.Length; i < n; i++) { + RegisterTag(tagNames[i]); + } + } + + public static void RegisterTag(string tagToRegister) { + EnsureInitialized(); + + // Walk segments without allocating a string[] — use ReadOnlySpan + var span = tagToRegister.AsSpan(); + int parentId = 0; + + for(int i = 0; i <= span.Length; i++) { + if(i < span.Length && span[i] != tagDelimiter) { + continue; + } + + // span[segStart..i] is the current segment + // Full tag name is tagToRegister[0..i] + var fullName = tagToRegister.Substring(0, i); + + if(!tagsByName.TryGetValue(fullName, out var tag)) { + idCounter++; + tag = new JovianTag(idCounter, parentId); + tagsByName[fullName] = tag; + tagsById[idCounter] = tag; + tagNames[idCounter] = fullName; + } + + parentId = tag.Id; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static JovianTag GetTag(string tagName) { + if(string.IsNullOrEmpty(tagName)) { + return emptyTag; + } + if(tagsByName.TryGetValue(tagName, out var tag)) { + return tag; + } + Debug.LogError($"[TagManager] Trying to get unregistered Tag: {tagName}"); + return emptyTag; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static JovianTag GetTag(int id) { + return tagsById.GetValueOrDefault(id, emptyTag); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetGameTag(string tagName, out JovianTag tag) { + tag = emptyTag; + return !string.IsNullOrEmpty(tagName) && tagsByName.TryGetValue(tagName, out tag); + } + + public static bool TryGetGameTagThatIsNotNone(string tagName, out JovianTag tag) { + if(!TryGetGameTag(tagName, out tag)) { + return false; + } + return tag.Id != emptyTagId; + } + + public static void GetAllTags(List results) { + foreach(var kvp in tagsByName) { + if(kvp.Value.Id != emptyTagId) + results.Add(kvp.Value); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string TagToString(JovianTag jovianTag) { + return tagNames.TryGetValue(jovianTag.Id, out var text) ? text : string.Empty; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string TagToString(int tagId) { + return tagNames.TryGetValue(tagId, out var text) ? text : string.Empty; + } + + public static string DisplayName(string name) { + var lastDot = name.LastIndexOf(tagDelimiter); + return lastDot < 0 ? name : name.Substring(lastDot + 1); + } + } +} diff --git a/Runtime/JovianTagsHandler.cs.meta b/Runtime/JovianTagsHandler.cs.meta new file mode 100644 index 0000000..7fab607 --- /dev/null +++ b/Runtime/JovianTagsHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e700d92d5f8326b4aacff3563881ed6f \ No newline at end of file diff --git a/Runtime/JovianTagsSettings.cs b/Runtime/JovianTagsSettings.cs new file mode 100644 index 0000000..a28fcf9 --- /dev/null +++ b/Runtime/JovianTagsSettings.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEngine.Serialization; + +namespace Jovian.TagSystem { + [CreateAssetMenu(menuName = "Jovian/Game Tag System/Game Tag Settings")] + public class JovianTagsSettings : ScriptableObject { + public RegisteredTag[] gameTags = Array.Empty(); + + private static readonly System.Text.RegularExpressions.Regex ValidSegment = + new(@"^[A-Za-z][A-Za-z0-9_]*$"); + + private static readonly HashSet CSharpKeywords = new(StringComparer.Ordinal) { + "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", + "checked", "class", "const", "continue", "decimal", "default", "delegate", "do", + "double", "else", "enum", "event", "explicit", "extern", "false", "finally", + "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", + "interface", "internal", "is", "lock", "long", "namespace", "new", "null", + "object", "operator", "out", "override", "params", "private", "protected", + "public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", + "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", + "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", + "virtual", "void", "volatile", "while" + }; + + public void AddGameTag(string newTag) { + List tagHierarchy = newTag.Split(JovianTagsHandler.tagDelimiter).ToList(); + tagHierarchy.Remove(""); + newTag = string.Join(JovianTagsHandler.tagDelimiter, tagHierarchy); + + // Validate each segment + foreach(var segment in tagHierarchy) { + if(!ValidSegment.IsMatch(segment)) { + Debug.LogError($"Invalid tag segment '{segment}': must start with a letter and contain only letters, digits, or underscores."); + return; + } + if(CSharpKeywords.Contains(segment)) { + Debug.LogError($"Invalid tag segment '{segment}': is a C# reserved keyword."); + return; + } + } + + if(gameTags.Contains(new(newTag))) { + Debug.LogError($"{newTag} is already added to the game"); + return; + } + + string[] tagsSplit = newTag.Split(JovianTagsHandler.tagDelimiter); + string tagToAdd = ""; + for(int i = 0, n = tagsSplit.Length; i < n; i++) { + tagToAdd += tagsSplit[i]; + if(gameTags.Any((a) => a.tag == tagToAdd)) { + tagToAdd += "."; + continue; + } + + var previous = gameTags; + gameTags = new RegisteredTag[gameTags.Length + 1]; + previous.CopyTo(gameTags, 0); + gameTags[^1] = new RegisteredTag(tagToAdd); + tagToAdd += "."; + } +#if UNITY_EDITOR + UnityEditor.EditorUtility.SetDirty(this); +#endif + } + + private static bool IsValidSegment(string segment) { + return !string.IsNullOrWhiteSpace(segment) + && ValidSegment.IsMatch(segment) + && !CSharpKeywords.Contains(segment); + } + + private static bool IsValidTag(string tag) { + if(string.IsNullOrWhiteSpace(tag)) return false; + return tag.Split(JovianTagsHandler.tagDelimiter).All(IsValidSegment); + } + + public void RemoveTag(string gameTagToRemove) { + var tagSearch = gameTags.ToList(); + tagSearch.RemoveAll((a) => a.tag.StartsWith(gameTagToRemove)); + gameTags = tagSearch.ToArray(); +#if UNITY_EDITOR + UnityEditor.EditorUtility.SetDirty(this); +#endif + } + } +} diff --git a/Runtime/JovianTagsSettings.cs.meta b/Runtime/JovianTagsSettings.cs.meta new file mode 100644 index 0000000..ad783ba --- /dev/null +++ b/Runtime/JovianTagsSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 32e658688044a8f469e0c311f9c4facb \ No newline at end of file diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..c466259 --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e15e55a8a150db444b90c0394baf332f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor.meta b/Tests/Editor.meta new file mode 100644 index 0000000..a104800 --- /dev/null +++ b/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 973227b88de0b1e4ba64c1955c03f09a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/TestGameTagUtility.cs b/Tests/Editor/TestGameTagUtility.cs new file mode 100644 index 0000000..88468a3 --- /dev/null +++ b/Tests/Editor/TestGameTagUtility.cs @@ -0,0 +1,53 @@ +using Jovian.TagSystem; +using NUnit.Framework; + +namespace Jovian.TagSystem.Tests { + public class TestGameTagUtility { + private const string TagBase = "Test"; + private const string TagOne = "Test.One"; + private const string TagTwo = "Test.Two"; + private const string TagThree = "Test.Three"; + private const string TagFour = "Test.One.Four"; + + [SetUp] + public void Setup() { + var tags = new RegisteredTag[] { new(TagOne), new(TagTwo), new(TagThree), new(TagFour), new(TagBase) }; + JovianTagsHandler.Initialize(); + JovianTagsHandler.RegisterTags(tags); + } + + [Test] + public void TestCreateGameTagContainer() { + var selection = new JovianTagsGroup(TagOne, TagTwo, TagFour); + var container = CreateGameTagContainer(selection); + Assert.AreEqual(3, container.Count); + } + + [Test] + public void TestCreateGameTagContainerWithCapacity() { + var selection = new JovianTagsGroup(TagOne, TagTwo, TagFour); + var container = CreateGameTagContainer(selection, 10); + Assert.AreEqual(3, container.Count); + Assert.AreEqual(10, container.entries.Capacity); + } + + [TearDown] + public void PostTest() { + JovianTagsHandler.Reset(); + } + + private JovianTagsContainer CreateGameTagContainer(JovianTagsGroup group) { + return group.ToContainer(); + } + + private JovianTagsContainer CreateGameTagContainer(JovianTagsGroup group, int capacity) { + if(!group.HasAny()) return JovianTagsContainer.Empty; + var container = new JovianTagsContainer(capacity); + foreach(var tag in group.tags) { + var resolved = JovianTagsHandler.GetTag(tag); + if(resolved.IsValid()) container.Add(resolved); + } + return container; + } + } +} diff --git a/Tests/Editor/TestGameTagUtility.cs.meta b/Tests/Editor/TestGameTagUtility.cs.meta new file mode 100644 index 0000000..6925bdb --- /dev/null +++ b/Tests/Editor/TestGameTagUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e9c31310bf551eb49bc72359408c5dee \ No newline at end of file diff --git a/Tests/Editor/TestJovianTag.cs b/Tests/Editor/TestJovianTag.cs new file mode 100644 index 0000000..6c5cd94 --- /dev/null +++ b/Tests/Editor/TestJovianTag.cs @@ -0,0 +1,109 @@ +using NUnit.Framework; + +namespace Jovian.TagSystem.Tests { + public class TestJovianTag { + + [SetUp] + public void Setup() { + JovianTagsHandler.Initialize(); + JovianTagsHandler.RegisterTag("A.B.C.D"); + JovianTagsHandler.RegisterTag("A.B.E"); + } + + [TearDown] + public void TearDown() { + JovianTagsHandler.Reset(); + } + + [Test] + public void GivenTwoEqualTags_WhenCompared_ThenEqual() { + var tag1 = JovianTagsHandler.GetTag("A.B"); + var tag2 = JovianTagsHandler.GetTag("A.B"); + Assert.AreEqual(tag1, tag2); + } + + [Test] + public void GivenNoneTag_WhenChecked_ThenIsNone() { + Assert.IsTrue(JovianTagsHandler.emptyTag.IsNone()); + } + + [Test] + public void GivenValidTag_WhenChecked_ThenIsValid() { + var tag = JovianTagsHandler.GetTag("A.B"); + Assert.IsTrue(tag.IsValid()); + } + + [Test] + public void GivenSameTag_WhenIsDescendantOf_ThenTrue() { + var tag = JovianTagsHandler.GetTag("A.B"); + Assert.IsTrue(tag.IsDescendantOf(tag)); + } + + [Test] + public void GivenChild_WhenIsDescendantOfParent_ThenTrue() { + var parent = JovianTagsHandler.GetTag("A.B"); + var child = JovianTagsHandler.GetTag("A.B.C"); + Assert.IsTrue(child.IsDescendantOf(parent)); + } + + [Test] + public void GivenGrandchild_WhenIsDescendantOfRoot_ThenTrue() { + var root = JovianTagsHandler.GetTag("A"); + var grandchild = JovianTagsHandler.GetTag("A.B.C.D"); + Assert.IsTrue(grandchild.IsDescendantOf(root)); + } + + [Test] + public void GivenParent_WhenIsDescendantOfChild_ThenFalse() { + var parent = JovianTagsHandler.GetTag("A.B"); + var child = JovianTagsHandler.GetTag("A.B.C"); + Assert.IsFalse(parent.IsDescendantOf(child)); + } + + [Test] + public void GivenParent_WhenIsAncestorOfChild_ThenTrue() { + var parent = JovianTagsHandler.GetTag("A"); + var child = JovianTagsHandler.GetTag("A.B.C.D"); + Assert.IsTrue(parent.IsAncestorOf(child)); + } + + [Test] + public void GivenChild_WhenIsAncestorOfParent_ThenFalse() { + var parent = JovianTagsHandler.GetTag("A"); + var child = JovianTagsHandler.GetTag("A.B.C.D"); + Assert.IsFalse(child.IsAncestorOf(parent)); + } + + [Test] + public void GivenSiblings_WhenIsSiblingTo_ThenTrue() { + var c = JovianTagsHandler.GetTag("A.B.C"); + var e = JovianTagsHandler.GetTag("A.B.E"); + // Both have parent A.B + Assert.IsTrue(c.IsSiblingTo(e)); + Assert.IsTrue(e.IsSiblingTo(c)); + } + + [Test] + public void GivenNonSiblings_WhenIsSiblingTo_ThenFalse() { + var d = JovianTagsHandler.GetTag("A.B.C.D"); + var e = JovianTagsHandler.GetTag("A.B.E"); + // D's parent is C, E's parent is B — not siblings + Assert.IsFalse(d.IsSiblingTo(e)); + } + + [Test] + public void GivenUnrelatedBranches_WhenCrossChecked_ThenFalse() { + var c = JovianTagsHandler.GetTag("A.B.C"); + var e = JovianTagsHandler.GetTag("A.B.E"); + Assert.IsFalse(c.IsDescendantOf(e)); + Assert.IsFalse(e.IsDescendantOf(c)); + } + + [Test] + public void GivenTwoDifferentTags_WhenCompared_ThenNotEqual() { + var tag1 = JovianTagsHandler.GetTag("A.B.C"); + var tag2 = JovianTagsHandler.GetTag("A.B.E"); + Assert.AreNotEqual(tag1, tag2); + } + } +} diff --git a/Tests/Editor/TestJovianTag.cs.meta b/Tests/Editor/TestJovianTag.cs.meta new file mode 100644 index 0000000..0b09485 --- /dev/null +++ b/Tests/Editor/TestJovianTag.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 743efdf77fd571944a6ae11fff741bb5 \ No newline at end of file diff --git a/Tests/Editor/TestJovianTagsContainer.cs b/Tests/Editor/TestJovianTagsContainer.cs new file mode 100644 index 0000000..a2174f3 --- /dev/null +++ b/Tests/Editor/TestJovianTagsContainer.cs @@ -0,0 +1,93 @@ +using NUnit.Framework; + +namespace Jovian.TagSystem.Tests { + public class TestJovianTagsContainer { + private JovianTagsContainer container; + + [SetUp] + public void Setup() { + JovianTagsHandler.Initialize(); + JovianTagsHandler.RegisterTag("A.B.C.D"); + JovianTagsHandler.RegisterTag("A.B.E"); + JovianTagsHandler.RegisterTag("X.Y"); + container = new JovianTagsContainer(8); + } + + [TearDown] + public void TearDown() { + JovianTagsHandler.Reset(); + } + + [Test] + public void GivenEmptyContainer_WhenAdd_ThenContainsTag() { + var tag = JovianTagsHandler.GetTag("A.B"); + container.Add(tag); + Assert.IsTrue(container.Contains(tag)); + Assert.AreEqual(1, container.Count); + } + + [Test] + public void GivenContainer_WhenAddDuplicate_ThenNotAdded() { + var tag = JovianTagsHandler.GetTag("A.B"); + container.Add(tag); + container.Add(tag); + Assert.AreEqual(1, container.Count); + } + + [Test] + public void GivenContainer_WhenRemove_ThenNotContained() { + var tag = JovianTagsHandler.GetTag("A.B"); + container.Add(tag); + container.Remove(tag); + Assert.IsFalse(container.Contains(tag)); + } + + [Test] + public void GivenContainer_WhenClear_ThenEmpty() { + container.Add(JovianTagsHandler.GetTag("A")); + container.Add(JovianTagsHandler.GetTag("A.B")); + container.Clear(); + Assert.AreEqual(0, container.Count); + } + + [Test] + public void GivenContainer_WhenIndexer_ThenCorrectTag() { + var tagA = JovianTagsHandler.GetTag("A"); + var tagB = JovianTagsHandler.GetTag("A.B"); + container.Add(tagA); + container.Add(tagB); + Assert.AreEqual(tagA, container[0]); + Assert.AreEqual(tagB, container[1]); + } + + [Test] + public void GivenContainerWithChild_WhenContainsDescendantOf_ThenTrue() { + var parent = JovianTagsHandler.GetTag("A.B"); + container.Add(JovianTagsHandler.GetTag("A.B.C.D")); + container.Add(JovianTagsHandler.GetTag("X.Y")); + Assert.IsTrue(container.ContainsDescendantOf(parent)); + } + + [Test] + public void GivenContainerWithParent_WhenContainsAncestorOf_ThenTrue() { + var child = JovianTagsHandler.GetTag("A.B.C"); + container.Add(JovianTagsHandler.GetTag("A.B")); + container.Add(JovianTagsHandler.GetTag("X.Y")); + Assert.IsTrue(container.ContainsAncestorOf(child)); + } + + [Test] + public void GivenContainerWithSibling_WhenContainsSibling_ThenTrue() { + var tag = JovianTagsHandler.GetTag("A.B.C"); + container.Add(JovianTagsHandler.GetTag("A.B.E")); // sibling of C (both under A.B) + Assert.IsTrue(container.ContainsSibling(tag)); + } + + [Test] + public void GivenContainerWithoutSibling_WhenContainsSibling_ThenFalse() { + var tag = JovianTagsHandler.GetTag("A.B.C"); + container.Add(JovianTagsHandler.GetTag("X.Y")); // unrelated + Assert.IsFalse(container.ContainsSibling(tag)); + } + } +} diff --git a/Tests/Editor/TestJovianTagsContainer.cs.meta b/Tests/Editor/TestJovianTagsContainer.cs.meta new file mode 100644 index 0000000..077328b --- /dev/null +++ b/Tests/Editor/TestJovianTagsContainer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 56948cc0d1c038549b8f0c3a42aec365 \ No newline at end of file diff --git a/Tests/Editor/TestJovianTagsGroup.cs b/Tests/Editor/TestJovianTagsGroup.cs new file mode 100644 index 0000000..5c64662 --- /dev/null +++ b/Tests/Editor/TestJovianTagsGroup.cs @@ -0,0 +1,48 @@ +using NUnit.Framework; + +namespace Jovian.TagSystem.Tests { + public class TestJovianTagsGroup { + private const string TAG_ONE = "Test"; + private const int EXPECTED_TAG_ID = 1; + + [SetUp] + public void Setup() { + var tags = new RegisteredTag[] { new(TAG_ONE) }; + JovianTagsHandler.Initialize(); + JovianTagsHandler.RegisterTags(tags); + } + + [Test] + public void GivenSelectionWithTag_WhenContains_ThenTrue() { + var selection = new JovianTagsGroup(TAG_ONE); + var resolved = JovianTagsHandler.GetTag(TAG_ONE); + Assert.IsTrue(selection.Contains(resolved)); + } + + [Test] + public void GivenSelectionWithoutTag_WhenContains_ThenFalse() { + var selection = new JovianTagsGroup("NonExistent"); + var resolved = JovianTagsHandler.GetTag(TAG_ONE); + Assert.IsFalse(selection.Contains(resolved)); + } + + [Test] + public void GivenMultipleTags_WhenToContainer_ThenAllResolved() { + JovianTagsHandler.RegisterTag("Other"); + var selection = new JovianTagsGroup(TAG_ONE, "Other"); + var container = selection.ToContainer(); + Assert.AreEqual(2, container.Count); + } + + [Test] + public void GivenSelection_WhenHasAny_ThenCorrect() { + Assert.IsTrue(new JovianTagsGroup(TAG_ONE).HasAny()); + Assert.IsFalse(new JovianTagsGroup().HasAny()); + } + + [TearDown] + public void Dispose() { + JovianTagsHandler.Reset(); + } + } +} diff --git a/Tests/Editor/TestJovianTagsGroup.cs.meta b/Tests/Editor/TestJovianTagsGroup.cs.meta new file mode 100644 index 0000000..b8b82cd --- /dev/null +++ b/Tests/Editor/TestJovianTagsGroup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ecc875077acb6d7499197f892e33d948 \ No newline at end of file diff --git a/Tests/Editor/TestJovianTagsHandler.cs b/Tests/Editor/TestJovianTagsHandler.cs new file mode 100644 index 0000000..dbca1b4 --- /dev/null +++ b/Tests/Editor/TestJovianTagsHandler.cs @@ -0,0 +1,157 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace Jovian.TagSystem.Tests { + public class TestJovianTagsHandler { + private const string TagBase = "Test"; + private const string TagOne = "Test.One"; + private const string TagTwo = "Test.Two"; + private const string TagThree = "Test.Three"; + private const string TagFour = "Test.One.Four"; + + private RegisteredTag[] tags; + + [SetUp] + public void Setup() { + tags = new RegisteredTag[] { new(TagOne), new(TagTwo), new(TagThree), new(TagFour), new(TagBase) }; + JovianTagsHandler.Initialize(); + } + + [TearDown] + public void TearDown() { + JovianTagsHandler.Reset(); + } + + [Test] + public void GivenUnregisteredTag_WhenGetTag_ThenReturnsNone() { + LogAssert.ignoreFailingMessages = true; + var tag = JovianTagsHandler.GetTag(TagOne); + Assert.IsTrue(tag.IsNone()); + LogAssert.ignoreFailingMessages = false; + } + + [Test] + public void GivenRegisteredTags_WhenGetTag_ThenReturnsValidTag() { + JovianTagsHandler.RegisterTags(tags); + var tagOne = JovianTagsHandler.GetTag(TagOne); + var tagThree = JovianTagsHandler.GetTag(TagThree); + Assert.IsTrue(tagOne.IsValid()); + Assert.IsTrue(tagThree.IsValid()); + Assert.AreNotEqual(tagOne.Id, tagThree.Id); + } + + [Test] + [TestCase(TagOne, ExpectedResult = true)] + [TestCase(null, ExpectedResult = false)] + [TestCase("", ExpectedResult = false)] + [TestCase("None", ExpectedResult = true)] + public bool GivenTryGetGameTag_ThenReturnsExpected(string tagName) { + JovianTagsHandler.RegisterTags(tags); + return JovianTagsHandler.TryGetGameTag(tagName, out _); + } + + [Test] + [TestCase(TagOne, ExpectedResult = true)] + [TestCase(null, ExpectedResult = false)] + [TestCase("", ExpectedResult = false)] + [TestCase("None", ExpectedResult = false)] + public bool GivenTryGetGameTagNotNone_ThenReturnsExpected(string tagName) { + JovianTagsHandler.RegisterTags(tags); + return JovianTagsHandler.TryGetGameTagThatIsNotNone(tagName, out _); + } + + [Test] + public void GivenRegisteredTag_WhenTryGet_ThenTagIsCorrect() { + JovianTagsHandler.RegisterTags(tags); + JovianTagsHandler.TryGetGameTag(TagOne, out var tag); + Assert.AreEqual(JovianTagsHandler.GetTag(TagOne), tag); + } + + [Test] + public void GivenChildTag_WhenRegistered_ThenParentIdIsSet() { + JovianTagsHandler.RegisterTags(tags); + var tagFour = JovianTagsHandler.GetTag(TagFour); + var tagOne = JovianTagsHandler.GetTag(TagOne); + // TagFour is "Test.One.Four" — its parent should be "Test.One" + Assert.AreEqual(tagOne.Id, tagFour.ParentId); + } + + [Test] + public void GivenChildTag_WhenChecked_ThenIsDescendantOfParent() { + JovianTagsHandler.RegisterTags(tags); + var tagBase = JovianTagsHandler.GetTag(TagBase); + var tagOne = JovianTagsHandler.GetTag(TagOne); + var tagFour = JovianTagsHandler.GetTag(TagFour); + + Assert.IsTrue(tagOne.IsDescendantOf(tagBase)); + Assert.IsTrue(tagFour.IsDescendantOf(tagBase)); + Assert.IsTrue(tagFour.IsDescendantOf(tagOne)); + Assert.IsFalse(tagBase.IsDescendantOf(tagOne)); + } + + [Test] + public void GivenParentTag_WhenChecked_ThenIsAncestorOfChild() { + JovianTagsHandler.RegisterTags(tags); + var tagBase = JovianTagsHandler.GetTag(TagBase); + var tagOne = JovianTagsHandler.GetTag(TagOne); + var tagFour = JovianTagsHandler.GetTag(TagFour); + + Assert.IsTrue(tagBase.IsAncestorOf(tagOne)); + Assert.IsTrue(tagBase.IsAncestorOf(tagFour)); + Assert.IsTrue(tagOne.IsAncestorOf(tagFour)); + Assert.IsFalse(tagFour.IsAncestorOf(tagBase)); + } + + [Test] + public void GivenSiblingTags_WhenChecked_ThenAreSiblings() { + JovianTagsHandler.RegisterTags(tags); + var tagOne = JovianTagsHandler.GetTag(TagOne); + var tagTwo = JovianTagsHandler.GetTag(TagTwo); + var tagThree = JovianTagsHandler.GetTag(TagThree); + var tagFour = JovianTagsHandler.GetTag(TagFour); + + // One, Two, Three are all children of Test — siblings + Assert.IsTrue(tagOne.IsSiblingTo(tagTwo)); + Assert.IsTrue(tagTwo.IsSiblingTo(tagThree)); + // Four is child of Test.One — not a sibling of Two + Assert.IsFalse(tagFour.IsSiblingTo(tagTwo)); + } + + [Test] + public void GivenRegisteredTag_WhenTagToString_ThenCorrectName() { + JovianTagsHandler.RegisterTags(tags); + var tagThree = JovianTagsHandler.GetTag(TagThree); + Assert.AreEqual(TagThree, JovianTagsHandler.TagToString(tagThree)); + } + + [Test] + public void GivenTagName_WhenDisplayName_ThenReturnsLastSegment() { + Assert.AreEqual("Four", JovianTagsHandler.DisplayName(TagFour)); + Assert.AreEqual("Test", JovianTagsHandler.DisplayName(TagBase)); + } + + [Test] + public void GivenRegisteredTags_WhenGetById_ThenReturnsCorrectTag() { + JovianTagsHandler.RegisterTags(tags); + var tagOne = JovianTagsHandler.GetTag(TagOne); + var tagById = JovianTagsHandler.GetTag(tagOne.Id); + Assert.AreEqual(tagOne, tagById); + } + + [Test] + public void GivenEmptyTag_WhenChecked_ThenIsNone() { + Assert.IsTrue(JovianTagsHandler.emptyTag.IsNone()); + Assert.IsFalse(JovianTagsHandler.emptyTag.IsValid()); + } + + [Test] + public void GivenSameTag_WhenRegisteredTwice_ThenSameId() { + JovianTagsHandler.RegisterTag("Foo.Bar"); + var first = JovianTagsHandler.GetTag("Foo.Bar"); + JovianTagsHandler.RegisterTag("Foo.Bar"); + var second = JovianTagsHandler.GetTag("Foo.Bar"); + Assert.AreEqual(first.Id, second.Id); + } + } +} diff --git a/Tests/Editor/TestJovianTagsHandler.cs.meta b/Tests/Editor/TestJovianTagsHandler.cs.meta new file mode 100644 index 0000000..6a22257 --- /dev/null +++ b/Tests/Editor/TestJovianTagsHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd13f72302bceda4b8025e613532c78c \ No newline at end of file diff --git a/Tests/Jovian.TagSystem.Tests.asmdef b/Tests/Jovian.TagSystem.Tests.asmdef new file mode 100644 index 0000000..044735f --- /dev/null +++ b/Tests/Jovian.TagSystem.Tests.asmdef @@ -0,0 +1,18 @@ +{ + "name": "Jovian.TagSystem.Tests", + "rootNamespace": "Jovian.TagSystem.Tests", + "references": [ + "Jovian.TagSystem" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/Jovian.TagSystem.Tests.asmdef.meta b/Tests/Jovian.TagSystem.Tests.asmdef.meta new file mode 100644 index 0000000..c6fbe84 --- /dev/null +++ b/Tests/Jovian.TagSystem.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: edb98aafe9b3b37478eb078aae34a5ce +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json new file mode 100644 index 0000000..c4b0a43 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "com.jovian.tag-system", + "displayName": "Jovian Tag System", + "version": "1.0.0", + "description": "Strongly typed and hierarchical game tag system" +} \ No newline at end of file diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..46de686 --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cb03f6dd77cb80e4a870bbdf375fc85c +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: