added tag systempackage

This commit is contained in:
Sebastian Bularca
2026-05-17 18:49:35 +02:00
parent a239e6286b
commit 3d13dac256
54 changed files with 3257 additions and 1 deletions

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c0acbee4371cc244b8e5b10e1bbab803
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

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("Jovian/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("Jovian/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

View File

@@ -0,0 +1,21 @@
MIT License
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:
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.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 51e674b2adc94084caaa472190fca829
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,423 @@
# Jovian Tag System
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\<T\> (tags + values)
Pair tags with typed data — like a dictionary with hierarchy-aware queries:
```csharp
// Damage resistances
var resistances = new JovianTagsContainer<float>(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<TagEntry<float>>();
resistances.GetByAncestor(GameTags.Damage.Damage_Tag, results);
// results contains Fire(0.5) and Ice(0.75)
```
### TagEntry\<T\>
Each entry in a generic container:
```csharp
TagEntry<float> 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<char>` for segment parsing to minimize string allocations

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 95d784d03badc334eb57943d0da95b0e
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9a504e32ce3c16c4ba10a51b9444669a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Jovian.TagSystem.Editor"), InternalsVisibleTo("Jovian.TagSystem.Tests")]

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 925b98fc2eb4d8f49900afdee32991a1

View File

@@ -0,0 +1,14 @@
{
"name": "Jovian.TagSystem",
"rootNamespace": "Jovian.TagSystem",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

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

View File

@@ -0,0 +1,85 @@
using System;
using System.Runtime.CompilerServices;
using UnityEngine;
namespace Jovian.TagSystem {
/// <summary>
/// Lightweight tag identity. 8 bytes, no heap allocation.
/// Hierarchy queries are resolved via GameTagManager static lookups.
/// </summary>
[Serializable]
public struct JovianTag : IEquatable<JovianTag>, IComparable<JovianTag> {
[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;
/// <summary>
/// Checks if this tag is a descendant of the given ancestor.
/// Walks the parent chain via GameTagManager.
/// </summary>
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;
}
/// <summary>
/// Checks if this tag is an ancestor of the given descendant.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly bool IsAncestorOf(JovianTag descendant) {
return descendant.IsDescendantOf(this);
}
/// <summary>
/// Checks if this tag shares the same parent as the given tag.
/// </summary>
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})";
}
}
}

View File

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

View File

@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
namespace Jovian.TagSystem {
/// <summary>
/// Entry in a JovianTagContainer — pairs a tag with an optional typed value.
/// </summary>
[Serializable]
public struct TagEntry<T> {
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}";
}
/// <summary>
/// Generic tag container — holds tag+value pairs with hierarchy-aware queries.
/// For tags-only, use non-generic JovianTagContainer.
/// </summary>
[Serializable]
public class JovianTagsContainer<T> {
public readonly List<TagEntry<T>> entries;
public JovianTagsContainer(int capacity) {
entries = new List<TagEntry<T>>(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<T>(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<TagEntry<T>> 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<TagEntry<T>> 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<TagEntry<T>> 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();
}
}
/// <summary>
/// Sentinel type for tag-only containers (no payload).
/// </summary>
public struct NoValue { }
/// <summary>
/// Non-generic tag container — tags only, no data payload.
/// </summary>
[Serializable]
public class JovianTagsContainer : JovianTagsContainer<NoValue> {
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);
/// <summary>
/// Access tags directly for backwards compatibility.
/// </summary>
public JovianTag this[int index] => entries[index].Tag;
public JovianTag GetTag(int index) => entries[index].Tag;
}
}

View File

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

View File

@@ -0,0 +1,102 @@
using System;
using UnityEngine;
namespace Jovian.TagSystem {
/// <summary>
/// Serializable tag selection for use in MonoBehaviours and ScriptableObjects.
/// Always supports multiple tags. Use the property drawer to select tags in the inspector.
/// </summary>
[Serializable]
public struct JovianTagsGroup {
[SerializeField] public string[] tags;
public JovianTagsGroup(params string[] tags) {
this.tags = tags ?? Array.Empty<string>();
}
public int Count => tags?.Length ?? 0;
/// <summary>
/// Returns all selected tags as resolved GameTags.
/// Stale/unregistered tag names are silently skipped.
/// </summary>
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;
}
/// <summary>
/// Checks if any selected tag matches the given tag exactly.
/// Stale/unregistered tag names count as no match (no error log).
/// </summary>
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;
}
/// <summary>
/// Checks if any selected tag is a descendant of the given ancestor.
/// </summary>
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;
}
/// <summary>
/// Checks if any selected tag is an ancestor of the given descendant.
/// </summary>
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;
}
/// <summary>
/// Checks if any selected tag is a sibling of the given tag.
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 140c6dc4e1c289e48b7441ac6ee5e20f

View File

@@ -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<RegisteredTag> {
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<string, JovianTag> tagsByName = new();
private static Dictionary<int, JovianTag> tagsById = new();
private static Dictionary<int, string> tagNames = new();
private static int idCounter;
private static bool initialized;
public static bool IsInitialized => initialized;
public static void Initialize() {
tagsByName = new Dictionary<string, JovianTag>(64) { { emptyTagName, emptyTag } };
tagsById = new Dictionary<int, JovianTag>(64) { { emptyTagId, emptyTag } };
tagNames = new Dictionary<int, string>(64) { { emptyTagId, emptyTagName } };
idCounter = 0;
initialized = true;
}
public static void EnsureInitialized() {
if(!initialized) Initialize();
}
public static void Reset() {
tagsByName = new Dictionary<string, JovianTag>();
tagsById = new Dictionary<int, JovianTag>();
tagNames = new Dictionary<int, string>();
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<JovianTag> 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);
}
}
}

View File

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

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Serialization;
namespace Jovian.TagSystem {
[CreateAssetMenu(menuName = "Jovian/Tag System/JovianTagSettings")]
public class JovianTagsSettings : ScriptableObject {
public RegisteredTag[] gameTags = Array.Empty<RegisteredTag>();
private static readonly System.Text.RegularExpressions.Regex ValidSegment =
new(@"^[A-Za-z][A-Za-z0-9_]*$");
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"
};
public void AddGameTag(string newTag) {
List<string> 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
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 32e658688044a8f469e0c311f9c4facb

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e15e55a8a150db444b90c0394baf332f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 973227b88de0b1e4ba64c1955c03f09a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 743efdf77fd571944a6ae11fff741bb5

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 56948cc0d1c038549b8f0c3a42aec365

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cb03f6dd77cb80e4a870bbdf375fc85c
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -75,7 +75,7 @@
} }
}, },
"com.jovian.tag-system": { "com.jovian.tag-system": {
"version": "file:com.jovian.tag-system", "version": "file:unity-tag-system",
"depth": 0, "depth": 0,
"source": "embedded", "source": "embedded",
"dependencies": {} "dependencies": {}