first comit

This commit is contained in:
Sebastian Bularca
2026-04-21 00:33:52 +02:00
parent 505b2ca161
commit 0331d0ede9
53 changed files with 3250 additions and 15 deletions

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 941a740b30b4595478b5e69393ffa045
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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<string> 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<string>();
}
}
private void Save() {
EditorPrefs.SetString(Application.productName + saveKey, string.Join(saveDelimiter, RecentTags));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 45efc4b96d295b84498b0fed0c4130d1

View File

@@ -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<JovianTagsSettings>();
}
var tagSettings = new List<JovianTagsSettings>();
for(int i = 0, n = settingsPaths.Length; i < n; i++) {
var guid = settingsPaths[i];
var path = AssetDatabase.GUIDToAssetPath(guid);
var setting = AssetDatabase.LoadAssetAtPath<JovianTagsSettings>(path);
tagSettings.Add(setting);
}
return tagSettings.ToArray();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a2a32b341a059664bb365498df0d6bdf

View File

@@ -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<string, bool> 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<TagTreeNode> Children = new();
}
[MenuItem("Fidelit&y/Tag System/Tag Editor...")]
public static void ShowWindow() {
var window = GetWindow<JovianTagsEditorWindow>(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<TagTreeNode> BuildTree(List<string> allTags) {
var rootNodes = new Dictionary<string, TagTreeNode>();
var allNodes = new Dictionary<string, TagTreeNode>();
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<TagTreeNode> FilterTree(List<TagTreeNode> roots, string filter) {
var result = new List<TagTreeNode>();
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<TagTreeNode>();
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<JovianTagsSettings>();
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<string>(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<string>(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<string> 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<string> CollectAllTags() {
var tags = new SortedSet<string>(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<string> onConfirm;
public static void Show(string parentPath, System.Action<string> onConfirm) {
var popup = CreateInstance<AddChildTagPopup>();
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();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9190068b05141d74aae0f630a8080f23

View File

@@ -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 {
/// <summary>
/// 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.
/// </summary>
public static class JovianTagsGenerator {
private const string DefaultClassName = "GameTags";
private const string DefaultOutputPath = "Assets/GameTags.cs";
private const string TagFieldPostfix = "_Tag";
/// <summary>
/// Collects all tag names from all GameTagSettings assets in the project.
/// </summary>
public static List<string> CollectAllTags() {
var allTags = new HashSet<string>(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();
}
/// <summary>
/// Generates the C# source content for the strong-typed tag fields.
/// </summary>
public static string GenerateFileContent(List<string> 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<TagNode> FlattenNodes(TagNode root) {
var result = new List<TagNode>();
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<TagNode> result) {
result.Add(node);
foreach(var child in node.Children.Values.OrderBy(c => c.FullName, StringComparer.OrdinalIgnoreCase)) {
FlattenRecursive(child, result);
}
}
/// <summary>
/// Writes the generated file and triggers AssetDatabase refresh.
/// </summary>
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();
}
/// <summary>
/// Full regeneration from current GameTagSettings.
/// </summary>
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);
}
/// <summary>
/// Searches for an existing generated file or returns a default path.
/// </summary>
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<string> 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_]*$");
/// <summary>
/// Validates a single tag segment (one part between dots).
/// Returns null if valid, or an error message if invalid.
/// </summary>
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;
}
/// <summary>
/// Validates a full dot-delimited tag name. Checks each segment.
/// Returns null if valid, or an error message for the first invalid segment.
/// </summary>
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<string, TagNode> Children = new(StringComparer.Ordinal);
public TagNode(string name, string fullName) {
Name = name;
FullName = fullName;
}
/// <summary>
/// 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"
/// </summary>
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;
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 126771008dabad6429c6a5408b5f204d

View File

@@ -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<string> selectedTags = new();
private Action<HashSet<string>> onConfirm;
private string searchFilter = "";
private Vector2 scrollPosition;
private List<string> allTags = new();
private readonly Dictionary<string, bool> 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<PickerNode> Children = new();
}
public static void Show(HashSet<string> currentTags, Action<HashSet<string>> onConfirm) {
var window = CreateInstance<JovianTagsPickerPopup>();
window.selectedTags = new HashSet<string>(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<string>(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<PickerNode> BuildTree() {
var rootNodes = new Dictionary<string, PickerNode>();
var allNodes = new Dictionary<string, PickerNode>();
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<PickerNode> FilterTree(List<PickerNode> roots, string filter) {
var result = new List<PickerNode>();
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<PickerNode>();
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<string> CollectAllPaths(PickerNode node) {
var result = new List<string>();
CollectPathsRecursive(node, result);
return result;
}
private void CollectPathsRecursive(PickerNode node, List<string> 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));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 819384bf19a494342ba5e5f4319e34fd

View File

@@ -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<string, bool> 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<string>();
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<string> 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<string>(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();
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d6c5df6e64308804287ab931e990df7b

View File

@@ -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<string> ValidateTags(JovianTagsSettings settings) {
var errors = new List<string>();
var seen = new HashSet<string>(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<string>(System.StringComparer.Ordinal);
var cleaned = new List<RegisteredTag>();
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();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6aa5b2c3fc4ef68498c7852291a4e8a0