first comit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
|
||||
8
Editor.meta
Normal file
8
Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c0acbee4371cc244b8e5b10e1bbab803
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
18
Editor/Jovian.TagSystem.Editor.asmdef
Normal file
18
Editor/Jovian.TagSystem.Editor.asmdef
Normal 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
|
||||
}
|
||||
7
Editor/Jovian.TagSystem.Editor.asmdef.meta
Normal file
7
Editor/Jovian.TagSystem.Editor.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 941a740b30b4595478b5e69393ffa045
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
46
Editor/JovianTagsEditorHistory.cs
Normal file
46
Editor/JovianTagsEditorHistory.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/JovianTagsEditorHistory.cs.meta
Normal file
2
Editor/JovianTagsEditorHistory.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 45efc4b96d295b84498b0fed0c4130d1
|
||||
24
Editor/JovianTagsEditorUtility.cs
Normal file
24
Editor/JovianTagsEditorUtility.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/JovianTagsEditorUtility.cs.meta
Normal file
2
Editor/JovianTagsEditorUtility.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2a32b341a059664bb365498df0d6bdf
|
||||
630
Editor/JovianTagsEditorWindow.cs
Normal file
630
Editor/JovianTagsEditorWindow.cs
Normal file
@@ -0,0 +1,630 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.TagSystem.Editor {
|
||||
|
||||
public class JovianTagsEditorWindow : EditorWindow {
|
||||
|
||||
// --- Colors ---
|
||||
private static readonly Color DividerColor = new(1f, 1f, 1f, 0.12f);
|
||||
private static readonly Color GreenButton = new(0.3f, 0.8f, 0.3f);
|
||||
private static readonly Color HeaderBg = new(0.18f, 0.18f, 0.18f, 1f);
|
||||
private static readonly Color RowEven = new(0f, 0f, 0f, 0f);
|
||||
private static readonly Color RowOdd = new(1f, 1f, 1f, 0.03f);
|
||||
private static readonly Color RowHover = new(0.3f, 0.5f, 0.8f, 0.12f);
|
||||
private static readonly Color ChildIndentLine = new(1f, 1f, 1f, 0.08f);
|
||||
|
||||
// --- Constants ---
|
||||
private const int RowHeight = 22;
|
||||
private const int HeaderHeight = 26;
|
||||
private const int DepthIndent = 20;
|
||||
|
||||
// --- Cached layout options ---
|
||||
private static readonly GUILayoutOption RowHeightOpt = GUILayout.Height(RowHeight);
|
||||
private static readonly GUILayoutOption HeaderHeightOpt = GUILayout.Height(HeaderHeight);
|
||||
private static readonly GUILayoutOption ExpandWidth = GUILayout.ExpandWidth(true);
|
||||
private static readonly GUILayoutOption SmallBtnWidth = GUILayout.Width(22);
|
||||
private static readonly GUILayoutOption SmallBtnHeight = GUILayout.Height(18);
|
||||
|
||||
// --- State ---
|
||||
private Vector2 scrollPosition;
|
||||
private string searchFilter = "";
|
||||
private string newTagName = "";
|
||||
private string outputPath;
|
||||
private readonly Dictionary<string, bool> foldoutState = new();
|
||||
private bool scrollToBottom;
|
||||
private JovianTagsSettings[] allSettings;
|
||||
private int rowIndex;
|
||||
|
||||
// --- Styles (lazy init) ---
|
||||
private GUIStyle _tagStyle;
|
||||
|
||||
private GUIStyle TagStyle => _tagStyle ??= new GUIStyle(EditorStyles.label) {
|
||||
fontSize = 12, fixedHeight = RowHeight, padding = new RectOffset(2, 2, 2, 2)
|
||||
};
|
||||
|
||||
private GUIStyle _headerStyle;
|
||||
|
||||
private GUIStyle HeaderStyle => _headerStyle ??= new GUIStyle(EditorStyles.boldLabel) {
|
||||
fontSize = 12, fixedHeight = HeaderHeight, padding = new RectOffset(2, 2, 4, 4)
|
||||
};
|
||||
|
||||
private GUIStyle _pathStyle;
|
||||
|
||||
private GUIStyle PathStyle => _pathStyle ??= new GUIStyle(EditorStyles.miniLabel) {
|
||||
fontSize = 10, fixedHeight = RowHeight,
|
||||
normal = { textColor = new Color(1f, 1f, 1f, 0.3f) },
|
||||
padding = new RectOffset(4, 4, 4, 2)
|
||||
};
|
||||
|
||||
// --- Tree structure ---
|
||||
private class TagTreeNode {
|
||||
public string Name; // segment name (e.g., "Fire")
|
||||
public string FullPath; // full dotted path (e.g., "Damage.Fire")
|
||||
public bool IsRegistered; // exists in GameTagSettings
|
||||
public List<TagTreeNode> Children = new();
|
||||
}
|
||||
|
||||
[MenuItem("Fidelit&y/Tag System/Tag Editor...")]
|
||||
public static void ShowWindow() {
|
||||
var window = GetWindow<JovianTagsEditorWindow>(true, "Tag Editor");
|
||||
window.minSize = new Vector2(480, 380);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private void OnEnable() { RefreshData(); }
|
||||
private void OnFocus() { RefreshData(); }
|
||||
|
||||
private void RefreshData() {
|
||||
allSettings = JovianTagsEditorUtility.GetSettings();
|
||||
outputPath = JovianTagsGenerator.FindOrDefaultOutputPath();
|
||||
}
|
||||
|
||||
private void OnGUI() {
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Space(8);
|
||||
EditorGUILayout.BeginVertical();
|
||||
GUILayout.Space(8);
|
||||
|
||||
DrawToolbar();
|
||||
|
||||
if(scrollToBottom) {
|
||||
scrollPosition.y = float.MaxValue;
|
||||
scrollToBottom = false;
|
||||
}
|
||||
|
||||
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition, GUIStyle.none, GUI.skin.verticalScrollbar);
|
||||
DrawContent();
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
DrawDivider();
|
||||
DrawAddTagRow();
|
||||
GUILayout.Space(4);
|
||||
DrawFooter();
|
||||
|
||||
GUILayout.Space(8);
|
||||
EditorGUILayout.EndVertical();
|
||||
GUILayout.Space(8);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
// ===== Toolbar =====
|
||||
|
||||
private void DrawToolbar() {
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Output:", GUILayout.Width(50));
|
||||
EditorGUI.BeginDisabledGroup(true);
|
||||
EditorGUILayout.TextField(outputPath);
|
||||
EditorGUI.EndDisabledGroup();
|
||||
if(GUILayout.Button("...", GUILayout.Width(26), GUILayout.Height(18)))
|
||||
ChangeOutputPath();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
GUILayout.Space(2);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Search:", GUILayout.Width(50));
|
||||
searchFilter = EditorGUILayout.TextField(searchFilter);
|
||||
if(!string.IsNullOrEmpty(searchFilter) && GUILayout.Button("✕", GUILayout.Width(20), GUILayout.Height(18))) {
|
||||
searchFilter = "";
|
||||
GUI.FocusControl(null);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
GUILayout.Space(4);
|
||||
DrawDivider();
|
||||
}
|
||||
|
||||
// ===== Content =====
|
||||
|
||||
private void DrawContent() {
|
||||
if(allSettings.Length == 0) {
|
||||
GUILayout.Space(40);
|
||||
DrawCenteredMessage("No Tag Settings asset found.");
|
||||
GUILayout.Space(8);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.FlexibleSpace();
|
||||
var prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = GreenButton;
|
||||
if(GUILayout.Button("Create Tag Settings", GUILayout.Height(28), GUILayout.Width(180))) {
|
||||
CreateSettingsAsset();
|
||||
}
|
||||
GUI.backgroundColor = prevBg;
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
return;
|
||||
}
|
||||
|
||||
var allTags = CollectAllTags();
|
||||
if(allTags.Count == 0) {
|
||||
GUILayout.Space(40);
|
||||
DrawCenteredMessage("No tags defined.\nAdd tags using the field below.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build tree
|
||||
var roots = BuildTree(allTags);
|
||||
|
||||
// Filter
|
||||
if(!string.IsNullOrEmpty(searchFilter)) {
|
||||
roots = FilterTree(roots, searchFilter);
|
||||
}
|
||||
|
||||
rowIndex = 0;
|
||||
foreach(var root in roots.OrderBy(r => r.Name, System.StringComparer.OrdinalIgnoreCase)) {
|
||||
DrawTreeNode(root, 0);
|
||||
}
|
||||
|
||||
GUILayout.Space(8);
|
||||
var totalTags = allSettings.Sum(s => s.gameTags.Length);
|
||||
GUILayout.Label($"{totalTags} tags total", EditorStyles.centeredGreyMiniLabel);
|
||||
}
|
||||
|
||||
private void DrawTreeNode(TagTreeNode node, int depth) {
|
||||
var hasChildren = node.Children.Count > 0;
|
||||
var isRoot = depth == 0;
|
||||
|
||||
// --- Row ---
|
||||
if(isRoot) {
|
||||
// Root header
|
||||
var headerRect = EditorGUILayout.BeginHorizontal(HeaderHeightOpt);
|
||||
EditorGUI.DrawRect(headerRect, HeaderBg);
|
||||
GUILayout.Space(6);
|
||||
|
||||
if(hasChildren) {
|
||||
foldoutState.TryAdd(node.FullPath, true);
|
||||
foldoutState[node.FullPath] = EditorGUILayout.Foldout(
|
||||
foldoutState[node.FullPath], "", true, EditorStyles.foldout);
|
||||
GUILayout.Space(-4);
|
||||
GUILayout.Label(node.Name, HeaderStyle, ExpandWidth);
|
||||
GUILayout.Label($"{CountDescendants(node)}", new GUIStyle(EditorStyles.miniLabel) {
|
||||
alignment = TextAnchor.MiddleRight,
|
||||
normal = { textColor = new Color(1, 1, 1, 0.4f) }
|
||||
}, GUILayout.Width(30));
|
||||
}
|
||||
else {
|
||||
GUILayout.Space(14);
|
||||
GUILayout.Label(node.Name, HeaderStyle, ExpandWidth);
|
||||
}
|
||||
|
||||
DrawAddChildButton(node);
|
||||
DrawDeleteButton(node.FullPath);
|
||||
GUILayout.Space(4);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
else {
|
||||
// Child row
|
||||
var bgColor = rowIndex % 2 == 0 ? RowEven : RowOdd;
|
||||
var rect = EditorGUILayout.BeginHorizontal(RowHeightOpt);
|
||||
if(rect.Contains(Event.current.mousePosition)) bgColor = RowHover;
|
||||
if(bgColor.a > 0) EditorGUI.DrawRect(rect, bgColor);
|
||||
|
||||
// Indent with vertical guide lines
|
||||
GUILayout.Space(6 + depth * DepthIndent);
|
||||
if(Event.current.type == EventType.Repaint) {
|
||||
for(int d = 1; d <= depth; d++) {
|
||||
var lineX = 6 + d * DepthIndent - 10;
|
||||
EditorGUI.DrawRect(new Rect(lineX, rect.y, 1, rect.height), ChildIndentLine);
|
||||
}
|
||||
}
|
||||
|
||||
if(hasChildren) {
|
||||
foldoutState.TryAdd(node.FullPath, true);
|
||||
foldoutState[node.FullPath] = EditorGUILayout.Foldout(
|
||||
foldoutState[node.FullPath], "", true, EditorStyles.foldout);
|
||||
GUILayout.Space(-4);
|
||||
}
|
||||
else {
|
||||
GUILayout.Space(14);
|
||||
}
|
||||
|
||||
GUILayout.Label(node.Name, TagStyle, ExpandWidth);
|
||||
|
||||
// Full path hint for deep tags
|
||||
if(depth > 1) {
|
||||
GUILayout.Label(node.FullPath, PathStyle,
|
||||
GUILayout.Width(Mathf.Min(250, position.width * 0.3f)));
|
||||
}
|
||||
|
||||
DrawAddChildButton(node);
|
||||
DrawDeleteButton(node.FullPath);
|
||||
GUILayout.Space(4);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
// --- Children ---
|
||||
if(!hasChildren) return;
|
||||
foldoutState.TryAdd(node.FullPath, true);
|
||||
if(!foldoutState[node.FullPath]) return;
|
||||
|
||||
foreach(var child in node.Children.OrderBy(c => c.Name, System.StringComparer.OrdinalIgnoreCase)) {
|
||||
DrawTreeNode(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawAddChildButton(TagTreeNode parent) {
|
||||
var prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = GreenButton;
|
||||
if(GUILayout.Button("+", SmallBtnWidth, SmallBtnHeight)) {
|
||||
// Open a small input for the child name
|
||||
AddChildTagPopup.Show(parent.FullPath, childName => {
|
||||
var fullTag = parent.FullPath + "." + childName;
|
||||
AddTag(fullTag);
|
||||
});
|
||||
}
|
||||
GUI.backgroundColor = prevBg;
|
||||
}
|
||||
|
||||
private void DrawDeleteButton(string tag) {
|
||||
var prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = new Color(0.8f, 0.2f, 0.2f);
|
||||
if(GUILayout.Button("✕", SmallBtnWidth, SmallBtnHeight)) {
|
||||
DeleteTag(tag);
|
||||
}
|
||||
GUI.backgroundColor = prevBg;
|
||||
}
|
||||
|
||||
// ===== Add Tag Row =====
|
||||
|
||||
private void DrawAddTagRow() {
|
||||
GUILayout.Space(4);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Label("New Tag:", GUILayout.Width(60));
|
||||
newTagName = EditorGUILayout.TextField(newTagName, GUILayout.Height(20));
|
||||
|
||||
string validationError = null;
|
||||
if(!string.IsNullOrWhiteSpace(newTagName)) {
|
||||
validationError = JovianTagsGenerator.ValidateTagName(newTagName.Trim());
|
||||
if(validationError == null && CollectAllTags().Contains(newTagName.Trim()))
|
||||
validationError = $"Tag '{newTagName.Trim()}' already exists.";
|
||||
}
|
||||
var canAdd = !string.IsNullOrWhiteSpace(newTagName) && validationError == null;
|
||||
|
||||
EditorGUI.BeginDisabledGroup(!canAdd);
|
||||
var prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = GreenButton;
|
||||
if(GUILayout.Button("+ Add", GUILayout.Width(60), GUILayout.Height(20))) {
|
||||
AddTag(newTagName.Trim());
|
||||
newTagName = "";
|
||||
GUI.FocusControl(null);
|
||||
scrollToBottom = true;
|
||||
}
|
||||
GUI.backgroundColor = prevBg;
|
||||
EditorGUI.EndDisabledGroup();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
if(!string.IsNullOrWhiteSpace(newTagName) && validationError != null)
|
||||
EditorGUILayout.HelpBox(validationError, MessageType.Error);
|
||||
if(canAdd && newTagName.Contains(JovianTagsHandler.tagDelimiter))
|
||||
EditorGUILayout.HelpBox("Parent tags are created automatically.", MessageType.Info);
|
||||
|
||||
GUILayout.Space(4);
|
||||
}
|
||||
|
||||
// ===== Footer =====
|
||||
|
||||
private void DrawFooter() {
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
var prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = GreenButton;
|
||||
if(GUILayout.Button("Save & Generate", GUILayout.Height(28), GUILayout.MinWidth(150)))
|
||||
SaveAndGenerate();
|
||||
GUI.backgroundColor = prevBg;
|
||||
if(GUILayout.Button("Clean Deleted In Assets", GUILayout.Height(28))) {
|
||||
CleanStaleTagsFromAllAssets();
|
||||
}
|
||||
if(GUILayout.Button("Refresh", GUILayout.Height(28), GUILayout.Width(70))) {
|
||||
RefreshData();
|
||||
Repaint();
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
// ===== Tree Building =====
|
||||
|
||||
private List<TagTreeNode> BuildTree(List<string> allTags) {
|
||||
var rootNodes = new Dictionary<string, TagTreeNode>();
|
||||
var allNodes = new Dictionary<string, TagTreeNode>();
|
||||
|
||||
foreach(var tag in allTags) {
|
||||
var parts = tag.Split(JovianTagsHandler.tagDelimiter);
|
||||
var accumulated = "";
|
||||
TagTreeNode parent = null;
|
||||
|
||||
for(int i = 0; i < parts.Length; i++) {
|
||||
accumulated = i == 0 ? parts[i] : accumulated + "." + parts[i];
|
||||
|
||||
if(!allNodes.TryGetValue(accumulated, out var node)) {
|
||||
node = new TagTreeNode {
|
||||
Name = parts[i],
|
||||
FullPath = accumulated,
|
||||
IsRegistered = allTags.Contains(accumulated)
|
||||
};
|
||||
allNodes[accumulated] = node;
|
||||
|
||||
if(parent != null)
|
||||
parent.Children.Add(node);
|
||||
else
|
||||
rootNodes[accumulated] = node;
|
||||
}
|
||||
parent = node;
|
||||
}
|
||||
}
|
||||
|
||||
return rootNodes.Values.ToList();
|
||||
}
|
||||
|
||||
private List<TagTreeNode> FilterTree(List<TagTreeNode> roots, string filter) {
|
||||
var result = new List<TagTreeNode>();
|
||||
foreach(var root in roots) {
|
||||
var filtered = FilterNode(root, filter);
|
||||
if(filtered != null) result.Add(filtered);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private TagTreeNode FilterNode(TagTreeNode node, string filter) {
|
||||
var matchesSelf = node.FullPath.IndexOf(filter, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
var filteredChildren = new List<TagTreeNode>();
|
||||
|
||||
foreach(var child in node.Children) {
|
||||
var fc = FilterNode(child, filter);
|
||||
if(fc != null) filteredChildren.Add(fc);
|
||||
}
|
||||
|
||||
if(!matchesSelf && filteredChildren.Count == 0) return null;
|
||||
|
||||
return new TagTreeNode {
|
||||
Name = node.Name,
|
||||
FullPath = node.FullPath,
|
||||
IsRegistered = node.IsRegistered,
|
||||
Children = matchesSelf ? node.Children : filteredChildren
|
||||
};
|
||||
}
|
||||
|
||||
private int CountDescendants(TagTreeNode node) {
|
||||
int count = 0;
|
||||
foreach(var child in node.Children) {
|
||||
count++;
|
||||
count += CountDescendants(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ===== Actions =====
|
||||
|
||||
private void AddTag(string tagName) {
|
||||
if(allSettings.Length == 0) {
|
||||
EditorUtility.DisplayDialog("No Settings", "Create a JovianTagsSettings asset first.", "OK");
|
||||
return;
|
||||
}
|
||||
Undo.RecordObject(allSettings[0], "Add Tag");
|
||||
allSettings[0].AddGameTag(tagName);
|
||||
}
|
||||
|
||||
private void DeleteTag(string tag) {
|
||||
foreach(var s in allSettings) {
|
||||
if(!s.gameTags.Any(t => t.tag == tag)) continue;
|
||||
var hasChildren = s.gameTags.Any(t => t.tag != tag && t.tag.StartsWith(tag + "."));
|
||||
var message = hasChildren ? $"Delete '{tag}' and all children?" : $"Delete '{tag}'?";
|
||||
if(EditorUtility.DisplayDialog("Delete Tag", message, "Delete", "Cancel")) {
|
||||
Undo.RecordObject(s, "Remove Tag");
|
||||
s.RemoveTag(tag);
|
||||
GUIUtility.ExitGUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateSettingsAsset() {
|
||||
var folder = EditorUtility.OpenFolderPanel("Choose folder for Tag Settings", "Assets", "");
|
||||
if(string.IsNullOrEmpty(folder)) return;
|
||||
|
||||
var dataPath = Application.dataPath.Replace('\\', '/');
|
||||
folder = folder.Replace('\\', '/');
|
||||
if(folder.StartsWith(dataPath))
|
||||
folder = "Assets" + folder.Substring(dataPath.Length);
|
||||
|
||||
var asset = ScriptableObject.CreateInstance<JovianTagsSettings>();
|
||||
var path = $"{folder}/JovianTagsSettings.asset";
|
||||
AssetDatabase.CreateAsset(asset, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorUtility.FocusProjectWindow();
|
||||
Selection.activeObject = asset;
|
||||
Debug.Log($"[Tag System] Created Tag Settings at {path}");
|
||||
RefreshData();
|
||||
}
|
||||
|
||||
private void CleanStaleTagsFromAllAssets() {
|
||||
var validTags = new HashSet<string>(System.StringComparer.Ordinal);
|
||||
foreach(var s in allSettings)
|
||||
foreach(var t in s.gameTags)
|
||||
if(!string.IsNullOrEmpty(t.tag))
|
||||
validTags.Add(t.tag);
|
||||
|
||||
int assetsScanned = 0;
|
||||
int assetsCleaned = 0;
|
||||
int tagsRemoved = 0;
|
||||
|
||||
// Scan all prefabs and ScriptableObjects
|
||||
var guids = AssetDatabase.FindAssets("t:GameObject t:ScriptableObject");
|
||||
// FindAssets with multiple types uses OR, but let's do them separately for clarity
|
||||
var prefabGuids = AssetDatabase.FindAssets("t:Prefab");
|
||||
var soGuids = AssetDatabase.FindAssets("t:ScriptableObject");
|
||||
var allGuids = new HashSet<string>(prefabGuids);
|
||||
foreach(var g in soGuids) allGuids.Add(g);
|
||||
|
||||
foreach(var guid in allGuids) {
|
||||
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var assets = AssetDatabase.LoadAllAssetsAtPath(path);
|
||||
assetsScanned++;
|
||||
|
||||
foreach(var asset in assets) {
|
||||
if(asset == null) continue;
|
||||
var so = new SerializedObject(asset);
|
||||
var cleaned = CleanSerializedObject(so, validTags);
|
||||
if(cleaned > 0) {
|
||||
so.ApplyModifiedPropertiesWithoutUndo();
|
||||
EditorUtility.SetDirty(asset);
|
||||
assetsCleaned++;
|
||||
tagsRemoved += cleaned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
EditorUtility.DisplayDialog("Clean Complete",
|
||||
$"Scanned {assetsScanned} assets.\n" +
|
||||
$"Cleaned {assetsCleaned} assets.\n" +
|
||||
$"Removed {tagsRemoved} stale tag references.",
|
||||
"OK");
|
||||
}
|
||||
|
||||
private static int CleanSerializedObject(SerializedObject so, HashSet<string> validTags) {
|
||||
int totalRemoved = 0;
|
||||
var prop = so.GetIterator();
|
||||
|
||||
while(prop.NextVisible(true)) {
|
||||
// Look for string arrays named "tags" inside JovianTagsGroup structs
|
||||
if(prop.isArray && prop.propertyType == SerializedPropertyType.String) continue;
|
||||
if(prop.propertyType != SerializedPropertyType.Generic) continue;
|
||||
if(!prop.type.Contains("JovianTagsGroup")) continue;
|
||||
|
||||
var tagsProp = prop.FindPropertyRelative("tags");
|
||||
if(tagsProp == null || !tagsProp.isArray) continue;
|
||||
|
||||
for(int i = tagsProp.arraySize - 1; i >= 0; i--) {
|
||||
var val = tagsProp.GetArrayElementAtIndex(i).stringValue;
|
||||
if(!string.IsNullOrEmpty(val) && !validTags.Contains(val)) {
|
||||
tagsProp.DeleteArrayElementAtIndex(i);
|
||||
totalRemoved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalRemoved;
|
||||
}
|
||||
|
||||
private void SaveAndGenerate() {
|
||||
foreach(var s in allSettings) EditorUtility.SetDirty(s);
|
||||
AssetDatabase.SaveAssets();
|
||||
JovianTagsGenerator.Regenerate(outputPath);
|
||||
}
|
||||
|
||||
private void ChangeOutputPath() {
|
||||
var newPath = EditorUtility.SaveFilePanel("Choose Generated File Location",
|
||||
System.IO.Path.GetDirectoryName(outputPath) ?? "Assets",
|
||||
System.IO.Path.GetFileName(outputPath) ?? "GameTags.cs", "cs");
|
||||
if(string.IsNullOrEmpty(newPath)) return;
|
||||
var dataPath = Application.dataPath.Replace('\\', '/');
|
||||
newPath = newPath.Replace('\\', '/');
|
||||
if(newPath.StartsWith(dataPath))
|
||||
newPath = "Assets" + newPath.Substring(dataPath.Length);
|
||||
outputPath = newPath;
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private List<string> CollectAllTags() {
|
||||
var tags = new SortedSet<string>(System.StringComparer.OrdinalIgnoreCase);
|
||||
foreach(var s in allSettings)
|
||||
foreach(var t in s.gameTags)
|
||||
if(!string.IsNullOrEmpty(t.tag))
|
||||
tags.Add(t.tag);
|
||||
return tags.ToList();
|
||||
}
|
||||
|
||||
private static void DrawDivider() {
|
||||
var rect = EditorGUILayout.GetControlRect(false, 1);
|
||||
EditorGUI.DrawRect(rect, DividerColor);
|
||||
}
|
||||
|
||||
private static void DrawCenteredMessage(string message) {
|
||||
var style = new GUIStyle(EditorStyles.label) {
|
||||
alignment = TextAnchor.MiddleCenter, wordWrap = true, fontSize = 12,
|
||||
normal = { textColor = new Color(1, 1, 1, 0.4f) }
|
||||
};
|
||||
EditorGUILayout.LabelField(message, style, GUILayout.Height(60));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Add Child Tag Popup =====
|
||||
|
||||
public class AddChildTagPopup : EditorWindow {
|
||||
private string parentPath;
|
||||
private string childName = "";
|
||||
private System.Action<string> onConfirm;
|
||||
|
||||
public static void Show(string parentPath, System.Action<string> onConfirm) {
|
||||
var popup = CreateInstance<AddChildTagPopup>();
|
||||
popup.parentPath = parentPath;
|
||||
popup.onConfirm = onConfirm;
|
||||
popup.titleContent = new GUIContent("Add Child Tag");
|
||||
var size = new Vector2(300, 90);
|
||||
var mousePos = GUIUtility.GUIToScreenPoint(Event.current.mousePosition);
|
||||
popup.position = new Rect(mousePos.x - size.x * 0.5f, mousePos.y, size.x, size.y);
|
||||
popup.ShowUtility();
|
||||
popup.Focus();
|
||||
}
|
||||
|
||||
private void OnGUI() {
|
||||
GUILayout.Space(8);
|
||||
GUILayout.Label($"Parent: {parentPath}", EditorStyles.boldLabel);
|
||||
GUILayout.Space(4);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Name:", GUILayout.Width(42));
|
||||
GUI.SetNextControlName("ChildNameField");
|
||||
childName = EditorGUILayout.TextField(childName);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUI.FocusTextInControl("ChildNameField");
|
||||
|
||||
var error = string.IsNullOrWhiteSpace(childName)
|
||||
? null
|
||||
: JovianTagsGenerator.ValidateTagSegment(childName.Trim());
|
||||
var canAdd = !string.IsNullOrWhiteSpace(childName) && error == null;
|
||||
|
||||
GUILayout.Space(4);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUI.BeginDisabledGroup(!canAdd);
|
||||
if(GUILayout.Button("Add", GUILayout.Height(22)) || (canAdd && Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Return)) {
|
||||
onConfirm?.Invoke(childName.Trim());
|
||||
Close();
|
||||
}
|
||||
EditorGUI.EndDisabledGroup();
|
||||
if(GUILayout.Button("Cancel", GUILayout.Height(22))) {
|
||||
Close();
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
if(Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape)
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/JovianTagsEditorWindow.cs.meta
Normal file
2
Editor/JovianTagsEditorWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9190068b05141d74aae0f630a8080f23
|
||||
300
Editor/JovianTagsGenerator.cs
Normal file
300
Editor/JovianTagsGenerator.cs
Normal file
@@ -0,0 +1,300 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.TagSystem.Editor {
|
||||
/// <summary>
|
||||
/// Generates a C# file with static GameTag fields organized in a nested class hierarchy.
|
||||
/// Fields are resolved at runtime via JovianTagsHandler. Manual additions are preserved.
|
||||
/// </summary>
|
||||
public static class JovianTagsGenerator {
|
||||
|
||||
private const string DefaultClassName = "GameTags";
|
||||
private const string DefaultOutputPath = "Assets/GameTags.cs";
|
||||
private const string TagFieldPostfix = "_Tag";
|
||||
|
||||
/// <summary>
|
||||
/// Collects all tag names from all GameTagSettings assets in the project.
|
||||
/// </summary>
|
||||
public static List<string> CollectAllTags() {
|
||||
var allTags = new HashSet<string>(StringComparer.Ordinal);
|
||||
var settings = JovianTagsEditorUtility.GetSettings();
|
||||
foreach(var s in settings) {
|
||||
foreach(var t in s.gameTags) {
|
||||
if(!string.IsNullOrEmpty(t.tag)) {
|
||||
allTags.Add(t.tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allTags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the C# source content for the strong-typed tag fields.
|
||||
/// </summary>
|
||||
public static string GenerateFileContent(List<string> tagNames, string className) {
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("//------------------------------------------------------------------------------");
|
||||
sb.AppendLine("// Generated by Jovian Game Tag System");
|
||||
sb.AppendLine("// Manual edits are preserved — fields added here will be kept on regeneration.");
|
||||
sb.AppendLine($"// Last generated on {DateTime.Now:yyyy-MM-dd HH:mm}");
|
||||
sb.AppendLine("//------------------------------------------------------------------------------");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("using Jovian.TagSystem;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"public static partial class {className} {{");
|
||||
|
||||
// Build tree structure from dot-delimited names
|
||||
var root = new TagNode("", "");
|
||||
foreach(var fullName in tagNames) {
|
||||
var parts = fullName.Split(JovianTagsHandler.tagDelimiter);
|
||||
var current = root;
|
||||
var accumulated = "";
|
||||
for(int i = 0; i < parts.Length; i++) {
|
||||
var part = parts[i];
|
||||
accumulated = i == 0 ? part : accumulated + "." + part;
|
||||
if(!current.Children.TryGetValue(part, out var child)) {
|
||||
child = new TagNode(part, accumulated);
|
||||
current.Children[part] = child;
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate nested classes from tree
|
||||
GenerateNode(sb, root, 1);
|
||||
|
||||
// Generate initializer methods
|
||||
var allNodes = FlattenNodes(root);
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" private static readonly string[] AllTags = {");
|
||||
for(int i = 0; i < allNodes.Count; i++) {
|
||||
var comma = i < allNodes.Count - 1 ? "," : "";
|
||||
sb.AppendLine($" \"{allNodes[i].FullName}\"{comma}");
|
||||
}
|
||||
sb.AppendLine(" };");
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.BeforeSceneLoad)]");
|
||||
sb.AppendLine(" private static void RuntimeInitialize() {");
|
||||
sb.AppendLine(" JovianTagsHandler.Initialize();");
|
||||
sb.AppendLine(" JovianTagsHandler.RegisterTags(AllTags);");
|
||||
foreach(var node in allNodes) {
|
||||
sb.AppendLine($" {node.FieldPath} = JovianTagsHandler.GetTag(\"{node.FullName}\");");
|
||||
}
|
||||
sb.AppendLine(" }");
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("#if UNITY_EDITOR");
|
||||
sb.AppendLine(" [UnityEditor.InitializeOnLoadMethod]");
|
||||
sb.AppendLine(" private static void EditorInitialize() {");
|
||||
sb.AppendLine(" RuntimeInitialize();");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("#endif");
|
||||
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void GenerateNode(StringBuilder sb, TagNode node, int depth) {
|
||||
var indent = new string(' ', depth * 4);
|
||||
|
||||
foreach(var child in node.Children.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) {
|
||||
var safeName = SanitizeIdentifier(child.Name);
|
||||
|
||||
if(child.Children.Count > 0) {
|
||||
// Node with children → nested class + field
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"{indent}public static class {safeName} {{");
|
||||
sb.AppendLine($"{indent} public static JovianTag {safeName}{TagFieldPostfix};");
|
||||
GenerateNode(sb, child, depth + 1);
|
||||
sb.AppendLine($"{indent}}}");
|
||||
}
|
||||
else {
|
||||
// Leaf → just a field
|
||||
sb.AppendLine($"{indent}public static JovianTag {safeName}{TagFieldPostfix};");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<TagNode> FlattenNodes(TagNode root) {
|
||||
var result = new List<TagNode>();
|
||||
foreach(var child in root.Children.Values.OrderBy(c => c.FullName, StringComparer.OrdinalIgnoreCase)) {
|
||||
FlattenRecursive(child, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void FlattenRecursive(TagNode node, List<TagNode> result) {
|
||||
result.Add(node);
|
||||
foreach(var child in node.Children.Values.OrderBy(c => c.FullName, StringComparer.OrdinalIgnoreCase)) {
|
||||
FlattenRecursive(child, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the generated file and triggers AssetDatabase refresh.
|
||||
/// </summary>
|
||||
public static void WriteAndRefresh(string content, string outputPath) {
|
||||
outputPath = outputPath.Replace('\\', '/');
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) {
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(outputPath, content);
|
||||
Debug.Log($"[Game Tag System] Generated tag file at: {outputPath}");
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full regeneration from current GameTagSettings.
|
||||
/// </summary>
|
||||
public static void Regenerate(string outputPath = null) {
|
||||
if(string.IsNullOrEmpty(outputPath)) {
|
||||
outputPath = FindOrDefaultOutputPath();
|
||||
}
|
||||
var className = Path.GetFileNameWithoutExtension(outputPath);
|
||||
var tags = CollectAllTags();
|
||||
if(tags.Count == 0) {
|
||||
Debug.LogWarning("[Game Tag System] No tags found in any GameTagSettings asset.");
|
||||
return;
|
||||
}
|
||||
var content = GenerateFileContent(tags, className);
|
||||
WriteAndRefresh(content, outputPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for an existing generated file or returns a default path.
|
||||
/// </summary>
|
||||
public static string FindOrDefaultOutputPath() {
|
||||
var guids = AssetDatabase.FindAssets($"{DefaultClassName} t:MonoScript");
|
||||
if(guids.Length > 0) {
|
||||
return AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||
}
|
||||
return DefaultOutputPath;
|
||||
}
|
||||
|
||||
[MenuItem("Fidelit&y/Tag System/Regenerate Tag Constants")]
|
||||
private static void MenuRegenerate() {
|
||||
Regenerate();
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> CSharpKeywords = new(StringComparer.Ordinal) {
|
||||
"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char",
|
||||
"checked", "class", "const", "continue", "decimal", "default", "delegate", "do",
|
||||
"double", "else", "enum", "event", "explicit", "extern", "false", "finally",
|
||||
"fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int",
|
||||
"interface", "internal", "is", "lock", "long", "namespace", "new", "null",
|
||||
"object", "operator", "out", "override", "params", "private", "protected",
|
||||
"public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof",
|
||||
"stackalloc", "static", "string", "struct", "switch", "this", "throw", "true",
|
||||
"try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using",
|
||||
"virtual", "void", "volatile", "while"
|
||||
};
|
||||
|
||||
private static readonly System.Text.RegularExpressions.Regex ValidIdentifierRegex =
|
||||
new(@"^[A-Za-z][A-Za-z0-9_]*$");
|
||||
|
||||
/// <summary>
|
||||
/// Validates a single tag segment (one part between dots).
|
||||
/// Returns null if valid, or an error message if invalid.
|
||||
/// </summary>
|
||||
public static string ValidateTagSegment(string segment) {
|
||||
if(string.IsNullOrWhiteSpace(segment))
|
||||
return "Tag name cannot be empty.";
|
||||
|
||||
if(!ValidIdentifierRegex.IsMatch(segment))
|
||||
return $"'{segment}' must start with a letter and contain only letters, digits, or underscores.";
|
||||
|
||||
if(CSharpKeywords.Contains(segment))
|
||||
return $"'{segment}' is a C# reserved keyword.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a full dot-delimited tag name. Checks each segment.
|
||||
/// Returns null if valid, or an error message for the first invalid segment.
|
||||
/// </summary>
|
||||
public static string ValidateTagName(string tagName) {
|
||||
if(string.IsNullOrWhiteSpace(tagName))
|
||||
return "Tag name cannot be empty.";
|
||||
|
||||
var segments = tagName.Split(JovianTagsHandler.tagDelimiter);
|
||||
foreach(var segment in segments) {
|
||||
var error = ValidateTagSegment(segment);
|
||||
if(error != null) return error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SanitizeIdentifier(string name) {
|
||||
if(string.IsNullOrWhiteSpace(name)) return "_Empty";
|
||||
var sanitized = System.Text.RegularExpressions.Regex.Replace(name.Trim(), @"[^A-Za-z0-9_]", "_");
|
||||
if(char.IsDigit(sanitized[0])) sanitized = "_" + sanitized;
|
||||
if(CSharpKeywords.Contains(sanitized)) sanitized = "_" + sanitized;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private class TagNode {
|
||||
public string Name;
|
||||
public string FullName;
|
||||
public Dictionary<string, TagNode> Children = new(StringComparer.Ordinal);
|
||||
|
||||
public TagNode(string name, string fullName) {
|
||||
Name = name;
|
||||
FullName = fullName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the fully qualified C# field path for this node's tag field.
|
||||
/// Nodes with children are nested classes, so the path includes the class hierarchy.
|
||||
/// Leaf nodes at root level are just field names.
|
||||
/// E.g., "Character.Player" → "Character.Player.Player_Tag"
|
||||
/// E.g., "big" (leaf, no children) → "big_Tag"
|
||||
/// </summary>
|
||||
public string FieldPath {
|
||||
get {
|
||||
var parts = FullName.Split(JovianTagsHandler.tagDelimiter);
|
||||
var safeParts = parts.Select(p =>
|
||||
System.Text.RegularExpressions.Regex.Replace(p.Trim(), @"[^A-Za-z0-9_]", "_")).ToArray();
|
||||
var lastSafe = safeParts[^1];
|
||||
|
||||
// Nodes with children become classes; their _Tag field lives inside the class.
|
||||
// Leaf nodes are just fields inside their parent class.
|
||||
//
|
||||
// Examples (→ = generates):
|
||||
// "big" (root leaf) → big_Tag
|
||||
// "Damage" (root with children) → Damage.Damage_Tag
|
||||
// "Damage.Fire" (child leaf) → Damage.Fire_Tag
|
||||
// "Damage.Fire.AOE" (deep leaf) → Damage.Fire.AOE_Tag
|
||||
// "Damage.Fire" (with children) → Damage.Fire.Fire_Tag
|
||||
|
||||
if(Children.Count > 0) {
|
||||
// This node is a class — its _Tag field is inside itself
|
||||
// Class path = all segments joined, field = lastSegment_Tag
|
||||
return string.Join(".", safeParts) + "." + lastSafe + TagFieldPostfix;
|
||||
}
|
||||
|
||||
if(safeParts.Length == 1) {
|
||||
// Root leaf — field directly in outer class
|
||||
return lastSafe + TagFieldPostfix;
|
||||
}
|
||||
|
||||
// Non-root leaf — field inside parent class
|
||||
// Parent class path = all segments except last
|
||||
var parentPath = string.Join(".", safeParts.Take(safeParts.Length - 1));
|
||||
return parentPath + "." + lastSafe + TagFieldPostfix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/JovianTagsGenerator.cs.meta
Normal file
2
Editor/JovianTagsGenerator.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 126771008dabad6429c6a5408b5f204d
|
||||
287
Editor/JovianTagsPickerPopup.cs
Normal file
287
Editor/JovianTagsPickerPopup.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/JovianTagsPickerPopup.cs.meta
Normal file
2
Editor/JovianTagsPickerPopup.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 819384bf19a494342ba5e5f4319e34fd
|
||||
179
Editor/JovianTagsSelectionDrawer.cs
Normal file
179
Editor/JovianTagsSelectionDrawer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/JovianTagsSelectionDrawer.cs.meta
Normal file
2
Editor/JovianTagsSelectionDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d6c5df6e64308804287ab931e990df7b
|
||||
102
Editor/JovianTagsSettingsInspector.cs
Normal file
102
Editor/JovianTagsSettingsInspector.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/JovianTagsSettingsInspector.cs.meta
Normal file
2
Editor/JovianTagsSettingsInspector.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6aa5b2c3fc4ef68498c7852291a4e8a0
|
||||
29
LICENSE
29
LICENSE
@@ -1,18 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 uzihead
|
||||
Copyright (c) 2026 Sebastian Bularca
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
7
LICENSE.meta
Normal file
7
LICENSE.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 51e674b2adc94084caaa472190fca829
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
424
README.md
424
README.md
@@ -1,3 +1,423 @@
|
||||
# unity-tag-system
|
||||
# Jovian Tag System
|
||||
|
||||
A strong-typed tag system for various unity systems
|
||||
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
|
||||
|
||||
7
README.md.meta
Normal file
7
README.md.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95d784d03badc334eb57943d0da95b0e
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Runtime.meta
Normal file
8
Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a504e32ce3c16c4ba10a51b9444669a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
3
Runtime/AssemblyInfo.cs
Normal file
3
Runtime/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Jovian.TagSystem.Editor"), InternalsVisibleTo("Jovian.TagSystem.Tests")]
|
||||
2
Runtime/AssemblyInfo.cs.meta
Normal file
2
Runtime/AssemblyInfo.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 925b98fc2eb4d8f49900afdee32991a1
|
||||
14
Runtime/Jovian.TagSystem.asmdef
Normal file
14
Runtime/Jovian.TagSystem.asmdef
Normal 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
|
||||
}
|
||||
7
Runtime/Jovian.TagSystem.asmdef.meta
Normal file
7
Runtime/Jovian.TagSystem.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f71bff532e6b8e24a8fc3e2761205890
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
85
Runtime/JovianTag.cs
Normal file
85
Runtime/JovianTag.cs
Normal 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})";
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/JovianTag.cs.meta
Normal file
2
Runtime/JovianTag.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f7edc6a4a42107499ed2b46654d63f3
|
||||
163
Runtime/JovianTagsContainer.cs
Normal file
163
Runtime/JovianTagsContainer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Runtime/JovianTagsContainer.cs.meta
Normal file
2
Runtime/JovianTagsContainer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb368346014a3644995688d0a325abcd
|
||||
102
Runtime/JovianTagsGroup.cs
Normal file
102
Runtime/JovianTagsGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/JovianTagsGroup.cs.meta
Normal file
2
Runtime/JovianTagsGroup.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 140c6dc4e1c289e48b7441ac6ee5e20f
|
||||
151
Runtime/JovianTagsHandler.cs
Normal file
151
Runtime/JovianTagsHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/JovianTagsHandler.cs.meta
Normal file
2
Runtime/JovianTagsHandler.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e700d92d5f8326b4aacff3563881ed6f
|
||||
90
Runtime/JovianTagsSettings.cs
Normal file
90
Runtime/JovianTagsSettings.cs
Normal 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/Game Tag System/Game Tag Settings")]
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/JovianTagsSettings.cs.meta
Normal file
2
Runtime/JovianTagsSettings.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 32e658688044a8f469e0c311f9c4facb
|
||||
8
Tests.meta
Normal file
8
Tests.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e15e55a8a150db444b90c0394baf332f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Tests/Editor.meta
Normal file
8
Tests/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 973227b88de0b1e4ba64c1955c03f09a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
53
Tests/Editor/TestGameTagUtility.cs
Normal file
53
Tests/Editor/TestGameTagUtility.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Tests/Editor/TestGameTagUtility.cs.meta
Normal file
2
Tests/Editor/TestGameTagUtility.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9c31310bf551eb49bc72359408c5dee
|
||||
109
Tests/Editor/TestJovianTag.cs
Normal file
109
Tests/Editor/TestJovianTag.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Tests/Editor/TestJovianTag.cs.meta
Normal file
2
Tests/Editor/TestJovianTag.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 743efdf77fd571944a6ae11fff741bb5
|
||||
93
Tests/Editor/TestJovianTagsContainer.cs
Normal file
93
Tests/Editor/TestJovianTagsContainer.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Tests/Editor/TestJovianTagsContainer.cs.meta
Normal file
2
Tests/Editor/TestJovianTagsContainer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 56948cc0d1c038549b8f0c3a42aec365
|
||||
48
Tests/Editor/TestJovianTagsGroup.cs
Normal file
48
Tests/Editor/TestJovianTagsGroup.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Tests/Editor/TestJovianTagsGroup.cs.meta
Normal file
2
Tests/Editor/TestJovianTagsGroup.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ecc875077acb6d7499197f892e33d948
|
||||
157
Tests/Editor/TestJovianTagsHandler.cs
Normal file
157
Tests/Editor/TestJovianTagsHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Tests/Editor/TestJovianTagsHandler.cs.meta
Normal file
2
Tests/Editor/TestJovianTagsHandler.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd13f72302bceda4b8025e613532c78c
|
||||
18
Tests/Jovian.TagSystem.Tests.asmdef
Normal file
18
Tests/Jovian.TagSystem.Tests.asmdef
Normal 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
|
||||
}
|
||||
7
Tests/Jovian.TagSystem.Tests.asmdef.meta
Normal file
7
Tests/Jovian.TagSystem.Tests.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: edb98aafe9b3b37478eb078aae34a5ce
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
6
package.json
Normal file
6
package.json
Normal 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"
|
||||
}
|
||||
7
package.json.meta
Normal file
7
package.json.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb03f6dd77cb80e4a870bbdf375fc85c
|
||||
PackageManifestImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user