forked from Shardstone/trail-into-darkness
added tag systempackage
This commit is contained in:
24
Packages/com.jovian.tag-system/.gitignore
vendored
Normal file
24
Packages/com.jovian.tag-system/.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
Packages/com.jovian.tag-system/Editor.meta
Normal file
8
Packages/com.jovian.tag-system/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c0acbee4371cc244b8e5b10e1bbab803
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 941a740b30b4595478b5e69393ffa045
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 45efc4b96d295b84498b0fed0c4130d1
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a2a32b341a059664bb365498df0d6bdf
|
||||||
630
Packages/com.jovian.tag-system/Editor/JovianTagsEditorWindow.cs
Normal file
630
Packages/com.jovian.tag-system/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("Jovian/Tag System/Tag Editor...")]
|
||||||
|
public static void ShowWindow() {
|
||||||
|
var window = GetWindow<JovianTagsEditorWindow>(true, "Tag Editor");
|
||||||
|
window.minSize = new Vector2(480, 380);
|
||||||
|
window.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnable() { RefreshData(); }
|
||||||
|
private void OnFocus() { RefreshData(); }
|
||||||
|
|
||||||
|
private void RefreshData() {
|
||||||
|
allSettings = JovianTagsEditorUtility.GetSettings();
|
||||||
|
outputPath = JovianTagsGenerator.FindOrDefaultOutputPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGUI() {
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
GUILayout.Space(8);
|
||||||
|
EditorGUILayout.BeginVertical();
|
||||||
|
GUILayout.Space(8);
|
||||||
|
|
||||||
|
DrawToolbar();
|
||||||
|
|
||||||
|
if(scrollToBottom) {
|
||||||
|
scrollPosition.y = float.MaxValue;
|
||||||
|
scrollToBottom = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition, GUIStyle.none, GUI.skin.verticalScrollbar);
|
||||||
|
DrawContent();
|
||||||
|
EditorGUILayout.EndScrollView();
|
||||||
|
|
||||||
|
DrawDivider();
|
||||||
|
DrawAddTagRow();
|
||||||
|
GUILayout.Space(4);
|
||||||
|
DrawFooter();
|
||||||
|
|
||||||
|
GUILayout.Space(8);
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
GUILayout.Space(8);
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Toolbar =====
|
||||||
|
|
||||||
|
private void DrawToolbar() {
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
GUILayout.Label("Output:", GUILayout.Width(50));
|
||||||
|
EditorGUI.BeginDisabledGroup(true);
|
||||||
|
EditorGUILayout.TextField(outputPath);
|
||||||
|
EditorGUI.EndDisabledGroup();
|
||||||
|
if(GUILayout.Button("...", GUILayout.Width(26), GUILayout.Height(18)))
|
||||||
|
ChangeOutputPath();
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
|
||||||
|
GUILayout.Space(2);
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
GUILayout.Label("Search:", GUILayout.Width(50));
|
||||||
|
searchFilter = EditorGUILayout.TextField(searchFilter);
|
||||||
|
if(!string.IsNullOrEmpty(searchFilter) && GUILayout.Button("✕", GUILayout.Width(20), GUILayout.Height(18))) {
|
||||||
|
searchFilter = "";
|
||||||
|
GUI.FocusControl(null);
|
||||||
|
}
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
|
||||||
|
GUILayout.Space(4);
|
||||||
|
DrawDivider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Content =====
|
||||||
|
|
||||||
|
private void DrawContent() {
|
||||||
|
if(allSettings.Length == 0) {
|
||||||
|
GUILayout.Space(40);
|
||||||
|
DrawCenteredMessage("No Tag Settings asset found.");
|
||||||
|
GUILayout.Space(8);
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
GUILayout.FlexibleSpace();
|
||||||
|
var prevBg = GUI.backgroundColor;
|
||||||
|
GUI.backgroundColor = GreenButton;
|
||||||
|
if(GUILayout.Button("Create Tag Settings", GUILayout.Height(28), GUILayout.Width(180))) {
|
||||||
|
CreateSettingsAsset();
|
||||||
|
}
|
||||||
|
GUI.backgroundColor = prevBg;
|
||||||
|
GUILayout.FlexibleSpace();
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var allTags = CollectAllTags();
|
||||||
|
if(allTags.Count == 0) {
|
||||||
|
GUILayout.Space(40);
|
||||||
|
DrawCenteredMessage("No tags defined.\nAdd tags using the field below.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tree
|
||||||
|
var roots = BuildTree(allTags);
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
if(!string.IsNullOrEmpty(searchFilter)) {
|
||||||
|
roots = FilterTree(roots, searchFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
rowIndex = 0;
|
||||||
|
foreach(var root in roots.OrderBy(r => r.Name, System.StringComparer.OrdinalIgnoreCase)) {
|
||||||
|
DrawTreeNode(root, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
GUILayout.Space(8);
|
||||||
|
var totalTags = allSettings.Sum(s => s.gameTags.Length);
|
||||||
|
GUILayout.Label($"{totalTags} tags total", EditorStyles.centeredGreyMiniLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawTreeNode(TagTreeNode node, int depth) {
|
||||||
|
var hasChildren = node.Children.Count > 0;
|
||||||
|
var isRoot = depth == 0;
|
||||||
|
|
||||||
|
// --- Row ---
|
||||||
|
if(isRoot) {
|
||||||
|
// Root header
|
||||||
|
var headerRect = EditorGUILayout.BeginHorizontal(HeaderHeightOpt);
|
||||||
|
EditorGUI.DrawRect(headerRect, HeaderBg);
|
||||||
|
GUILayout.Space(6);
|
||||||
|
|
||||||
|
if(hasChildren) {
|
||||||
|
foldoutState.TryAdd(node.FullPath, true);
|
||||||
|
foldoutState[node.FullPath] = EditorGUILayout.Foldout(
|
||||||
|
foldoutState[node.FullPath], "", true, EditorStyles.foldout);
|
||||||
|
GUILayout.Space(-4);
|
||||||
|
GUILayout.Label(node.Name, HeaderStyle, ExpandWidth);
|
||||||
|
GUILayout.Label($"{CountDescendants(node)}", new GUIStyle(EditorStyles.miniLabel) {
|
||||||
|
alignment = TextAnchor.MiddleRight,
|
||||||
|
normal = { textColor = new Color(1, 1, 1, 0.4f) }
|
||||||
|
}, GUILayout.Width(30));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
GUILayout.Space(14);
|
||||||
|
GUILayout.Label(node.Name, HeaderStyle, ExpandWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawAddChildButton(node);
|
||||||
|
DrawDeleteButton(node.FullPath);
|
||||||
|
GUILayout.Space(4);
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Child row
|
||||||
|
var bgColor = rowIndex % 2 == 0 ? RowEven : RowOdd;
|
||||||
|
var rect = EditorGUILayout.BeginHorizontal(RowHeightOpt);
|
||||||
|
if(rect.Contains(Event.current.mousePosition)) bgColor = RowHover;
|
||||||
|
if(bgColor.a > 0) EditorGUI.DrawRect(rect, bgColor);
|
||||||
|
|
||||||
|
// Indent with vertical guide lines
|
||||||
|
GUILayout.Space(6 + depth * DepthIndent);
|
||||||
|
if(Event.current.type == EventType.Repaint) {
|
||||||
|
for(int d = 1; d <= depth; d++) {
|
||||||
|
var lineX = 6 + d * DepthIndent - 10;
|
||||||
|
EditorGUI.DrawRect(new Rect(lineX, rect.y, 1, rect.height), ChildIndentLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(hasChildren) {
|
||||||
|
foldoutState.TryAdd(node.FullPath, true);
|
||||||
|
foldoutState[node.FullPath] = EditorGUILayout.Foldout(
|
||||||
|
foldoutState[node.FullPath], "", true, EditorStyles.foldout);
|
||||||
|
GUILayout.Space(-4);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
GUILayout.Space(14);
|
||||||
|
}
|
||||||
|
|
||||||
|
GUILayout.Label(node.Name, TagStyle, ExpandWidth);
|
||||||
|
|
||||||
|
// Full path hint for deep tags
|
||||||
|
if(depth > 1) {
|
||||||
|
GUILayout.Label(node.FullPath, PathStyle,
|
||||||
|
GUILayout.Width(Mathf.Min(250, position.width * 0.3f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawAddChildButton(node);
|
||||||
|
DrawDeleteButton(node.FullPath);
|
||||||
|
GUILayout.Space(4);
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
rowIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Children ---
|
||||||
|
if(!hasChildren) return;
|
||||||
|
foldoutState.TryAdd(node.FullPath, true);
|
||||||
|
if(!foldoutState[node.FullPath]) return;
|
||||||
|
|
||||||
|
foreach(var child in node.Children.OrderBy(c => c.Name, System.StringComparer.OrdinalIgnoreCase)) {
|
||||||
|
DrawTreeNode(child, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawAddChildButton(TagTreeNode parent) {
|
||||||
|
var prevBg = GUI.backgroundColor;
|
||||||
|
GUI.backgroundColor = GreenButton;
|
||||||
|
if(GUILayout.Button("+", SmallBtnWidth, SmallBtnHeight)) {
|
||||||
|
// Open a small input for the child name
|
||||||
|
AddChildTagPopup.Show(parent.FullPath, childName => {
|
||||||
|
var fullTag = parent.FullPath + "." + childName;
|
||||||
|
AddTag(fullTag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
GUI.backgroundColor = prevBg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawDeleteButton(string tag) {
|
||||||
|
var prevBg = GUI.backgroundColor;
|
||||||
|
GUI.backgroundColor = new Color(0.8f, 0.2f, 0.2f);
|
||||||
|
if(GUILayout.Button("✕", SmallBtnWidth, SmallBtnHeight)) {
|
||||||
|
DeleteTag(tag);
|
||||||
|
}
|
||||||
|
GUI.backgroundColor = prevBg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Add Tag Row =====
|
||||||
|
|
||||||
|
private void DrawAddTagRow() {
|
||||||
|
GUILayout.Space(4);
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
GUILayout.Label("New Tag:", GUILayout.Width(60));
|
||||||
|
newTagName = EditorGUILayout.TextField(newTagName, GUILayout.Height(20));
|
||||||
|
|
||||||
|
string validationError = null;
|
||||||
|
if(!string.IsNullOrWhiteSpace(newTagName)) {
|
||||||
|
validationError = JovianTagsGenerator.ValidateTagName(newTagName.Trim());
|
||||||
|
if(validationError == null && CollectAllTags().Contains(newTagName.Trim()))
|
||||||
|
validationError = $"Tag '{newTagName.Trim()}' already exists.";
|
||||||
|
}
|
||||||
|
var canAdd = !string.IsNullOrWhiteSpace(newTagName) && validationError == null;
|
||||||
|
|
||||||
|
EditorGUI.BeginDisabledGroup(!canAdd);
|
||||||
|
var prevBg = GUI.backgroundColor;
|
||||||
|
GUI.backgroundColor = GreenButton;
|
||||||
|
if(GUILayout.Button("+ Add", GUILayout.Width(60), GUILayout.Height(20))) {
|
||||||
|
AddTag(newTagName.Trim());
|
||||||
|
newTagName = "";
|
||||||
|
GUI.FocusControl(null);
|
||||||
|
scrollToBottom = true;
|
||||||
|
}
|
||||||
|
GUI.backgroundColor = prevBg;
|
||||||
|
EditorGUI.EndDisabledGroup();
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
|
||||||
|
if(!string.IsNullOrWhiteSpace(newTagName) && validationError != null)
|
||||||
|
EditorGUILayout.HelpBox(validationError, MessageType.Error);
|
||||||
|
if(canAdd && newTagName.Contains(JovianTagsHandler.tagDelimiter))
|
||||||
|
EditorGUILayout.HelpBox("Parent tags are created automatically.", MessageType.Info);
|
||||||
|
|
||||||
|
GUILayout.Space(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Footer =====
|
||||||
|
|
||||||
|
private void DrawFooter() {
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
var prevBg = GUI.backgroundColor;
|
||||||
|
GUI.backgroundColor = GreenButton;
|
||||||
|
if(GUILayout.Button("Save & Generate", GUILayout.Height(28), GUILayout.MinWidth(150)))
|
||||||
|
SaveAndGenerate();
|
||||||
|
GUI.backgroundColor = prevBg;
|
||||||
|
if(GUILayout.Button("Clean Deleted In Assets", GUILayout.Height(28))) {
|
||||||
|
CleanStaleTagsFromAllAssets();
|
||||||
|
}
|
||||||
|
if(GUILayout.Button("Refresh", GUILayout.Height(28), GUILayout.Width(70))) {
|
||||||
|
RefreshData();
|
||||||
|
Repaint();
|
||||||
|
}
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tree Building =====
|
||||||
|
|
||||||
|
private List<TagTreeNode> BuildTree(List<string> allTags) {
|
||||||
|
var rootNodes = new Dictionary<string, TagTreeNode>();
|
||||||
|
var allNodes = new Dictionary<string, TagTreeNode>();
|
||||||
|
|
||||||
|
foreach(var tag in allTags) {
|
||||||
|
var parts = tag.Split(JovianTagsHandler.tagDelimiter);
|
||||||
|
var accumulated = "";
|
||||||
|
TagTreeNode parent = null;
|
||||||
|
|
||||||
|
for(int i = 0; i < parts.Length; i++) {
|
||||||
|
accumulated = i == 0 ? parts[i] : accumulated + "." + parts[i];
|
||||||
|
|
||||||
|
if(!allNodes.TryGetValue(accumulated, out var node)) {
|
||||||
|
node = new TagTreeNode {
|
||||||
|
Name = parts[i],
|
||||||
|
FullPath = accumulated,
|
||||||
|
IsRegistered = allTags.Contains(accumulated)
|
||||||
|
};
|
||||||
|
allNodes[accumulated] = node;
|
||||||
|
|
||||||
|
if(parent != null)
|
||||||
|
parent.Children.Add(node);
|
||||||
|
else
|
||||||
|
rootNodes[accumulated] = node;
|
||||||
|
}
|
||||||
|
parent = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootNodes.Values.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TagTreeNode> FilterTree(List<TagTreeNode> roots, string filter) {
|
||||||
|
var result = new List<TagTreeNode>();
|
||||||
|
foreach(var root in roots) {
|
||||||
|
var filtered = FilterNode(root, filter);
|
||||||
|
if(filtered != null) result.Add(filtered);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TagTreeNode FilterNode(TagTreeNode node, string filter) {
|
||||||
|
var matchesSelf = node.FullPath.IndexOf(filter, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
var filteredChildren = new List<TagTreeNode>();
|
||||||
|
|
||||||
|
foreach(var child in node.Children) {
|
||||||
|
var fc = FilterNode(child, filter);
|
||||||
|
if(fc != null) filteredChildren.Add(fc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!matchesSelf && filteredChildren.Count == 0) return null;
|
||||||
|
|
||||||
|
return new TagTreeNode {
|
||||||
|
Name = node.Name,
|
||||||
|
FullPath = node.FullPath,
|
||||||
|
IsRegistered = node.IsRegistered,
|
||||||
|
Children = matchesSelf ? node.Children : filteredChildren
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CountDescendants(TagTreeNode node) {
|
||||||
|
int count = 0;
|
||||||
|
foreach(var child in node.Children) {
|
||||||
|
count++;
|
||||||
|
count += CountDescendants(child);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Actions =====
|
||||||
|
|
||||||
|
private void AddTag(string tagName) {
|
||||||
|
if(allSettings.Length == 0) {
|
||||||
|
EditorUtility.DisplayDialog("No Settings", "Create a JovianTagsSettings asset first.", "OK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Undo.RecordObject(allSettings[0], "Add Tag");
|
||||||
|
allSettings[0].AddGameTag(tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteTag(string tag) {
|
||||||
|
foreach(var s in allSettings) {
|
||||||
|
if(!s.gameTags.Any(t => t.tag == tag)) continue;
|
||||||
|
var hasChildren = s.gameTags.Any(t => t.tag != tag && t.tag.StartsWith(tag + "."));
|
||||||
|
var message = hasChildren ? $"Delete '{tag}' and all children?" : $"Delete '{tag}'?";
|
||||||
|
if(EditorUtility.DisplayDialog("Delete Tag", message, "Delete", "Cancel")) {
|
||||||
|
Undo.RecordObject(s, "Remove Tag");
|
||||||
|
s.RemoveTag(tag);
|
||||||
|
GUIUtility.ExitGUI();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateSettingsAsset() {
|
||||||
|
var folder = EditorUtility.OpenFolderPanel("Choose folder for Tag Settings", "Assets", "");
|
||||||
|
if(string.IsNullOrEmpty(folder)) return;
|
||||||
|
|
||||||
|
var dataPath = Application.dataPath.Replace('\\', '/');
|
||||||
|
folder = folder.Replace('\\', '/');
|
||||||
|
if(folder.StartsWith(dataPath))
|
||||||
|
folder = "Assets" + folder.Substring(dataPath.Length);
|
||||||
|
|
||||||
|
var asset = ScriptableObject.CreateInstance<JovianTagsSettings>();
|
||||||
|
var path = $"{folder}/JovianTagsSettings.asset";
|
||||||
|
AssetDatabase.CreateAsset(asset, path);
|
||||||
|
AssetDatabase.SaveAssets();
|
||||||
|
EditorUtility.FocusProjectWindow();
|
||||||
|
Selection.activeObject = asset;
|
||||||
|
Debug.Log($"[Tag System] Created Tag Settings at {path}");
|
||||||
|
RefreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanStaleTagsFromAllAssets() {
|
||||||
|
var validTags = new HashSet<string>(System.StringComparer.Ordinal);
|
||||||
|
foreach(var s in allSettings)
|
||||||
|
foreach(var t in s.gameTags)
|
||||||
|
if(!string.IsNullOrEmpty(t.tag))
|
||||||
|
validTags.Add(t.tag);
|
||||||
|
|
||||||
|
int assetsScanned = 0;
|
||||||
|
int assetsCleaned = 0;
|
||||||
|
int tagsRemoved = 0;
|
||||||
|
|
||||||
|
// Scan all prefabs and ScriptableObjects
|
||||||
|
var guids = AssetDatabase.FindAssets("t:GameObject t:ScriptableObject");
|
||||||
|
// FindAssets with multiple types uses OR, but let's do them separately for clarity
|
||||||
|
var prefabGuids = AssetDatabase.FindAssets("t:Prefab");
|
||||||
|
var soGuids = AssetDatabase.FindAssets("t:ScriptableObject");
|
||||||
|
var allGuids = new HashSet<string>(prefabGuids);
|
||||||
|
foreach(var g in soGuids) allGuids.Add(g);
|
||||||
|
|
||||||
|
foreach(var guid in allGuids) {
|
||||||
|
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||||
|
var assets = AssetDatabase.LoadAllAssetsAtPath(path);
|
||||||
|
assetsScanned++;
|
||||||
|
|
||||||
|
foreach(var asset in assets) {
|
||||||
|
if(asset == null) continue;
|
||||||
|
var so = new SerializedObject(asset);
|
||||||
|
var cleaned = CleanSerializedObject(so, validTags);
|
||||||
|
if(cleaned > 0) {
|
||||||
|
so.ApplyModifiedPropertiesWithoutUndo();
|
||||||
|
EditorUtility.SetDirty(asset);
|
||||||
|
assetsCleaned++;
|
||||||
|
tagsRemoved += cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetDatabase.SaveAssets();
|
||||||
|
|
||||||
|
EditorUtility.DisplayDialog("Clean Complete",
|
||||||
|
$"Scanned {assetsScanned} assets.\n" +
|
||||||
|
$"Cleaned {assetsCleaned} assets.\n" +
|
||||||
|
$"Removed {tagsRemoved} stale tag references.",
|
||||||
|
"OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CleanSerializedObject(SerializedObject so, HashSet<string> validTags) {
|
||||||
|
int totalRemoved = 0;
|
||||||
|
var prop = so.GetIterator();
|
||||||
|
|
||||||
|
while(prop.NextVisible(true)) {
|
||||||
|
// Look for string arrays named "tags" inside JovianTagsGroup structs
|
||||||
|
if(prop.isArray && prop.propertyType == SerializedPropertyType.String) continue;
|
||||||
|
if(prop.propertyType != SerializedPropertyType.Generic) continue;
|
||||||
|
if(!prop.type.Contains("JovianTagsGroup")) continue;
|
||||||
|
|
||||||
|
var tagsProp = prop.FindPropertyRelative("tags");
|
||||||
|
if(tagsProp == null || !tagsProp.isArray) continue;
|
||||||
|
|
||||||
|
for(int i = tagsProp.arraySize - 1; i >= 0; i--) {
|
||||||
|
var val = tagsProp.GetArrayElementAtIndex(i).stringValue;
|
||||||
|
if(!string.IsNullOrEmpty(val) && !validTags.Contains(val)) {
|
||||||
|
tagsProp.DeleteArrayElementAtIndex(i);
|
||||||
|
totalRemoved++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalRemoved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveAndGenerate() {
|
||||||
|
foreach(var s in allSettings) EditorUtility.SetDirty(s);
|
||||||
|
AssetDatabase.SaveAssets();
|
||||||
|
JovianTagsGenerator.Regenerate(outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ChangeOutputPath() {
|
||||||
|
var newPath = EditorUtility.SaveFilePanel("Choose Generated File Location",
|
||||||
|
System.IO.Path.GetDirectoryName(outputPath) ?? "Assets",
|
||||||
|
System.IO.Path.GetFileName(outputPath) ?? "GameTags.cs", "cs");
|
||||||
|
if(string.IsNullOrEmpty(newPath)) return;
|
||||||
|
var dataPath = Application.dataPath.Replace('\\', '/');
|
||||||
|
newPath = newPath.Replace('\\', '/');
|
||||||
|
if(newPath.StartsWith(dataPath))
|
||||||
|
newPath = "Assets" + newPath.Substring(dataPath.Length);
|
||||||
|
outputPath = newPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
|
||||||
|
private List<string> CollectAllTags() {
|
||||||
|
var tags = new SortedSet<string>(System.StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach(var s in allSettings)
|
||||||
|
foreach(var t in s.gameTags)
|
||||||
|
if(!string.IsNullOrEmpty(t.tag))
|
||||||
|
tags.Add(t.tag);
|
||||||
|
return tags.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawDivider() {
|
||||||
|
var rect = EditorGUILayout.GetControlRect(false, 1);
|
||||||
|
EditorGUI.DrawRect(rect, DividerColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawCenteredMessage(string message) {
|
||||||
|
var style = new GUIStyle(EditorStyles.label) {
|
||||||
|
alignment = TextAnchor.MiddleCenter, wordWrap = true, fontSize = 12,
|
||||||
|
normal = { textColor = new Color(1, 1, 1, 0.4f) }
|
||||||
|
};
|
||||||
|
EditorGUILayout.LabelField(message, style, GUILayout.Height(60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Add Child Tag Popup =====
|
||||||
|
|
||||||
|
public class AddChildTagPopup : EditorWindow {
|
||||||
|
private string parentPath;
|
||||||
|
private string childName = "";
|
||||||
|
private System.Action<string> onConfirm;
|
||||||
|
|
||||||
|
public static void Show(string parentPath, System.Action<string> onConfirm) {
|
||||||
|
var popup = CreateInstance<AddChildTagPopup>();
|
||||||
|
popup.parentPath = parentPath;
|
||||||
|
popup.onConfirm = onConfirm;
|
||||||
|
popup.titleContent = new GUIContent("Add Child Tag");
|
||||||
|
var size = new Vector2(300, 90);
|
||||||
|
var mousePos = GUIUtility.GUIToScreenPoint(Event.current.mousePosition);
|
||||||
|
popup.position = new Rect(mousePos.x - size.x * 0.5f, mousePos.y, size.x, size.y);
|
||||||
|
popup.ShowUtility();
|
||||||
|
popup.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGUI() {
|
||||||
|
GUILayout.Space(8);
|
||||||
|
GUILayout.Label($"Parent: {parentPath}", EditorStyles.boldLabel);
|
||||||
|
GUILayout.Space(4);
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
GUILayout.Label("Name:", GUILayout.Width(42));
|
||||||
|
GUI.SetNextControlName("ChildNameField");
|
||||||
|
childName = EditorGUILayout.TextField(childName);
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
|
||||||
|
EditorGUI.FocusTextInControl("ChildNameField");
|
||||||
|
|
||||||
|
var error = string.IsNullOrWhiteSpace(childName)
|
||||||
|
? null
|
||||||
|
: JovianTagsGenerator.ValidateTagSegment(childName.Trim());
|
||||||
|
var canAdd = !string.IsNullOrWhiteSpace(childName) && error == null;
|
||||||
|
|
||||||
|
GUILayout.Space(4);
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
EditorGUI.BeginDisabledGroup(!canAdd);
|
||||||
|
if(GUILayout.Button("Add", GUILayout.Height(22)) || (canAdd && Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Return)) {
|
||||||
|
onConfirm?.Invoke(childName.Trim());
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
EditorGUI.EndDisabledGroup();
|
||||||
|
if(GUILayout.Button("Cancel", GUILayout.Height(22))) {
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
|
||||||
|
if(Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape)
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9190068b05141d74aae0f630a8080f23
|
||||||
300
Packages/com.jovian.tag-system/Editor/JovianTagsGenerator.cs
Normal file
300
Packages/com.jovian.tag-system/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("Jovian/Tag System/Regenerate Tag Constants")]
|
||||||
|
private static void MenuRegenerate() {
|
||||||
|
Regenerate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly HashSet<string> CSharpKeywords = new(StringComparer.Ordinal) {
|
||||||
|
"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char",
|
||||||
|
"checked", "class", "const", "continue", "decimal", "default", "delegate", "do",
|
||||||
|
"double", "else", "enum", "event", "explicit", "extern", "false", "finally",
|
||||||
|
"fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int",
|
||||||
|
"interface", "internal", "is", "lock", "long", "namespace", "new", "null",
|
||||||
|
"object", "operator", "out", "override", "params", "private", "protected",
|
||||||
|
"public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof",
|
||||||
|
"stackalloc", "static", "string", "struct", "switch", "this", "throw", "true",
|
||||||
|
"try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using",
|
||||||
|
"virtual", "void", "volatile", "while"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly System.Text.RegularExpressions.Regex ValidIdentifierRegex =
|
||||||
|
new(@"^[A-Za-z][A-Za-z0-9_]*$");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a single tag segment (one part between dots).
|
||||||
|
/// Returns null if valid, or an error message if invalid.
|
||||||
|
/// </summary>
|
||||||
|
public static string ValidateTagSegment(string segment) {
|
||||||
|
if(string.IsNullOrWhiteSpace(segment))
|
||||||
|
return "Tag name cannot be empty.";
|
||||||
|
|
||||||
|
if(!ValidIdentifierRegex.IsMatch(segment))
|
||||||
|
return $"'{segment}' must start with a letter and contain only letters, digits, or underscores.";
|
||||||
|
|
||||||
|
if(CSharpKeywords.Contains(segment))
|
||||||
|
return $"'{segment}' is a C# reserved keyword.";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a full dot-delimited tag name. Checks each segment.
|
||||||
|
/// Returns null if valid, or an error message for the first invalid segment.
|
||||||
|
/// </summary>
|
||||||
|
public static string ValidateTagName(string tagName) {
|
||||||
|
if(string.IsNullOrWhiteSpace(tagName))
|
||||||
|
return "Tag name cannot be empty.";
|
||||||
|
|
||||||
|
var segments = tagName.Split(JovianTagsHandler.tagDelimiter);
|
||||||
|
foreach(var segment in segments) {
|
||||||
|
var error = ValidateTagSegment(segment);
|
||||||
|
if(error != null) return error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeIdentifier(string name) {
|
||||||
|
if(string.IsNullOrWhiteSpace(name)) return "_Empty";
|
||||||
|
var sanitized = System.Text.RegularExpressions.Regex.Replace(name.Trim(), @"[^A-Za-z0-9_]", "_");
|
||||||
|
if(char.IsDigit(sanitized[0])) sanitized = "_" + sanitized;
|
||||||
|
if(CSharpKeywords.Contains(sanitized)) sanitized = "_" + sanitized;
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TagNode {
|
||||||
|
public string Name;
|
||||||
|
public string FullName;
|
||||||
|
public Dictionary<string, TagNode> Children = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public TagNode(string name, string fullName) {
|
||||||
|
Name = name;
|
||||||
|
FullName = fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the fully qualified C# field path for this node's tag field.
|
||||||
|
/// Nodes with children are nested classes, so the path includes the class hierarchy.
|
||||||
|
/// Leaf nodes at root level are just field names.
|
||||||
|
/// E.g., "Character.Player" → "Character.Player.Player_Tag"
|
||||||
|
/// E.g., "big" (leaf, no children) → "big_Tag"
|
||||||
|
/// </summary>
|
||||||
|
public string FieldPath {
|
||||||
|
get {
|
||||||
|
var parts = FullName.Split(JovianTagsHandler.tagDelimiter);
|
||||||
|
var safeParts = parts.Select(p =>
|
||||||
|
System.Text.RegularExpressions.Regex.Replace(p.Trim(), @"[^A-Za-z0-9_]", "_")).ToArray();
|
||||||
|
var lastSafe = safeParts[^1];
|
||||||
|
|
||||||
|
// Nodes with children become classes; their _Tag field lives inside the class.
|
||||||
|
// Leaf nodes are just fields inside their parent class.
|
||||||
|
//
|
||||||
|
// Examples (→ = generates):
|
||||||
|
// "big" (root leaf) → big_Tag
|
||||||
|
// "Damage" (root with children) → Damage.Damage_Tag
|
||||||
|
// "Damage.Fire" (child leaf) → Damage.Fire_Tag
|
||||||
|
// "Damage.Fire.AOE" (deep leaf) → Damage.Fire.AOE_Tag
|
||||||
|
// "Damage.Fire" (with children) → Damage.Fire.Fire_Tag
|
||||||
|
|
||||||
|
if(Children.Count > 0) {
|
||||||
|
// This node is a class — its _Tag field is inside itself
|
||||||
|
// Class path = all segments joined, field = lastSegment_Tag
|
||||||
|
return string.Join(".", safeParts) + "." + lastSafe + TagFieldPostfix;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(safeParts.Length == 1) {
|
||||||
|
// Root leaf — field directly in outer class
|
||||||
|
return lastSafe + TagFieldPostfix;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-root leaf — field inside parent class
|
||||||
|
// Parent class path = all segments except last
|
||||||
|
var parentPath = string.Join(".", safeParts.Take(safeParts.Length - 1));
|
||||||
|
return parentPath + "." + lastSafe + TagFieldPostfix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 126771008dabad6429c6a5408b5f204d
|
||||||
287
Packages/com.jovian.tag-system/Editor/JovianTagsPickerPopup.cs
Normal file
287
Packages/com.jovian.tag-system/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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 819384bf19a494342ba5e5f4319e34fd
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d6c5df6e64308804287ab931e990df7b
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6aa5b2c3fc4ef68498c7852291a4e8a0
|
||||||
21
Packages/com.jovian.tag-system/LICENSE
Normal file
21
Packages/com.jovian.tag-system/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Sebastian Bularca
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
7
Packages/com.jovian.tag-system/LICENSE.meta
Normal file
7
Packages/com.jovian.tag-system/LICENSE.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 51e674b2adc94084caaa472190fca829
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
423
Packages/com.jovian.tag-system/README.md
Normal file
423
Packages/com.jovian.tag-system/README.md
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
# Jovian Tag System
|
||||||
|
|
||||||
|
A hierarchical, strongly-typed tag system for Unity. Define tags as dot-delimited hierarchies (`Damage.Fire.AOE`), auto-generate type-safe C# constants, and query relationships like ancestor/descendant/sibling at runtime with zero allocations.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [**Important: Tags Are Not Inherited**](#important-tags-are-not-inherited)
|
||||||
|
- [Hierarchical Tags](#hierarchical-tags)
|
||||||
|
- [Tag Editor Window](#tag-editor-window)
|
||||||
|
- [Using Tags in Code](#using-tags-in-code)
|
||||||
|
- [Inspector Integration](#inspector-integration)
|
||||||
|
- [Tag Picker Popup](#tag-picker-popup)
|
||||||
|
- [Code Generation](#code-generation)
|
||||||
|
- [Tag Containers](#tag-containers)
|
||||||
|
- [Tag Containers with Data](#tag-containers-with-data)
|
||||||
|
- [Runtime API Reference](#runtime-api-reference)
|
||||||
|
- [Performance](#performance)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Create a Tag Settings asset
|
||||||
|
Right-click in the Project window > `Create` > `Jovian` > `Tag System` > `Tag Settings`
|
||||||
|
|
||||||
|
### 2. Add tags
|
||||||
|
Open the Tag Editor via `Jovian` > `Tag System` > `Tag Editor...`
|
||||||
|
|
||||||
|
Add tags like:
|
||||||
|
```
|
||||||
|
Character
|
||||||
|
Character.Player
|
||||||
|
Character.Enemy
|
||||||
|
Character.Enemy.Boss
|
||||||
|
Damage
|
||||||
|
Damage.Fire
|
||||||
|
Damage.Ice
|
||||||
|
Status
|
||||||
|
Status.Burning
|
||||||
|
Status.Frozen
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Click Save & Generate
|
||||||
|
This creates a `GameTags.cs` file with strongly-typed constants:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Auto-generated
|
||||||
|
public static partial class GameTags {
|
||||||
|
public static class Character {
|
||||||
|
public static JovianTag Character_Tag;
|
||||||
|
public static class Enemy {
|
||||||
|
public static JovianTag Enemy_Tag;
|
||||||
|
public static JovianTag Boss_Tag;
|
||||||
|
}
|
||||||
|
public static JovianTag Player_Tag;
|
||||||
|
}
|
||||||
|
public static class Damage {
|
||||||
|
public static JovianTag Damage_Tag;
|
||||||
|
public static JovianTag Fire_Tag;
|
||||||
|
public static JovianTag Ice_Tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use in code
|
||||||
|
```csharp
|
||||||
|
if (projectile.IsDescendantOf(GameTags.Damage.Damage_Tag)) {
|
||||||
|
// This is any damage type (Fire, Ice, etc.)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important: Tags Are Not Inherited
|
||||||
|
|
||||||
|
> **TL;DR — Tagging an object with `Enemy.Name.Butcher.Size.Large` does NOT automatically assign `Enemy.Name.Butcher` or `Enemy` to that object.**
|
||||||
|
|
||||||
|
Each dot-delimited path is a **distinct, independent tag**. The hierarchy is **structural** — it enables tree queries — but it does **not** imply membership.
|
||||||
|
|
||||||
|
### Concrete Example
|
||||||
|
|
||||||
|
Register this tag in the Tag Editor:
|
||||||
|
```
|
||||||
|
Enemy.Name.Butcher.Size.Large
|
||||||
|
```
|
||||||
|
|
||||||
|
The system creates **5 separate tag definitions** (one per segment), parented in the tree:
|
||||||
|
- `Enemy`
|
||||||
|
- `Enemy.Name`
|
||||||
|
- `Enemy.Name.Butcher`
|
||||||
|
- `Enemy.Name.Butcher.Size`
|
||||||
|
- `Enemy.Name.Butcher.Size.Large`
|
||||||
|
|
||||||
|
Now tag a game object with `Enemy.Name.Butcher.Size.Large`. On that object, **only that one tag is set.** The ancestor tags exist as definitions, but they are not active on the object.
|
||||||
|
|
||||||
|
### What This Means in Practice
|
||||||
|
|
||||||
|
| You want to match... | Do this |
|
||||||
|
|----------------------|---------|
|
||||||
|
| Only Large Butcher | Tag object with `Enemy.Name.Butcher.Size.Large`, query with `Contains` |
|
||||||
|
| Any Butcher (any size) via separate tag | Add a second tag `Enemy.Name.Butcher` to the object |
|
||||||
|
| Any Butcher (any size) via hierarchy | Tag with `Enemy.Name.Butcher.Size.Large`, query with `ContainsDescendantOf(GameTags.Enemy.Name.Butcher_Tag)` |
|
||||||
|
|
||||||
|
### Exact vs Hierarchy Queries
|
||||||
|
|
||||||
|
| Query | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| `Contains(tag)` | **Exact match only** — matches the literal tag you assigned |
|
||||||
|
| `ContainsDescendantOf(tag)` | Matches any descendant of `tag`, including `tag` itself |
|
||||||
|
| `ContainsAncestorOf(tag)` | Matches any ancestor of `tag` |
|
||||||
|
|
||||||
|
**The tag itself does not decide matching behavior — the query does.** Choose the query type based on your intent: `Contains` for "this exact thing," `ContainsDescendantOf` for "anything in this category."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hierarchical Tags
|
||||||
|
|
||||||
|
Tags are organized in a tree using dot-delimited names. Each segment creates a level in the hierarchy.
|
||||||
|
|
||||||
|
```
|
||||||
|
Damage ← root tag (depth 0)
|
||||||
|
├── Damage.Fire ← child of Damage (depth 1)
|
||||||
|
│ └── Damage.Fire.AOE ← child of Fire (depth 2)
|
||||||
|
├── Damage.Ice ← child of Damage (depth 1)
|
||||||
|
└── Damage.Lightning ← child of Damage (depth 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hierarchy Queries
|
||||||
|
|
||||||
|
Every tag knows its parent. This enables three types of queries:
|
||||||
|
|
||||||
|
**IsDescendantOf** — "Is this tag a child/grandchild/etc. of another?"
|
||||||
|
```csharp
|
||||||
|
var fire = GameTags.Damage.Fire_Tag;
|
||||||
|
var damage = GameTags.Damage.Damage_Tag;
|
||||||
|
var aoe = GameTags.Damage.Fire.AOE_Tag;
|
||||||
|
|
||||||
|
fire.IsDescendantOf(damage); // true — Fire is under Damage
|
||||||
|
aoe.IsDescendantOf(damage); // true — AOE is under Damage (via Fire)
|
||||||
|
aoe.IsDescendantOf(fire); // true — AOE is directly under Fire
|
||||||
|
damage.IsDescendantOf(fire); // false — Damage is above Fire
|
||||||
|
fire.IsDescendantOf(fire); // true — a tag is a descendant of itself
|
||||||
|
```
|
||||||
|
|
||||||
|
**IsAncestorOf** — "Is this tag a parent/grandparent/etc. of another?"
|
||||||
|
```csharp
|
||||||
|
damage.IsAncestorOf(fire); // true
|
||||||
|
damage.IsAncestorOf(aoe); // true
|
||||||
|
fire.IsAncestorOf(damage); // false
|
||||||
|
```
|
||||||
|
|
||||||
|
**IsSiblingTo** — "Do these tags share the same parent?"
|
||||||
|
```csharp
|
||||||
|
var fire = GameTags.Damage.Fire_Tag;
|
||||||
|
var ice = GameTags.Damage.Ice_Tag;
|
||||||
|
var player = GameTags.Character.Player_Tag;
|
||||||
|
|
||||||
|
fire.IsSiblingTo(ice); // true — both under Damage
|
||||||
|
fire.IsSiblingTo(player); // false — different parents
|
||||||
|
```
|
||||||
|
|
||||||
|
### Practical Use Cases
|
||||||
|
|
||||||
|
**Category matching** — check if something belongs to a broad category:
|
||||||
|
```csharp
|
||||||
|
// Does this entity have ANY damage tag?
|
||||||
|
if (entity.tags.ContainsDescendantOf(GameTags.Damage.Damage_Tag)) {
|
||||||
|
ApplyDamageEffect();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Specific matching** — check for an exact tag:
|
||||||
|
```csharp
|
||||||
|
if (entity.tags.Contains(GameTags.Damage.Fire_Tag)) {
|
||||||
|
ApplyBurnEffect();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resistance system** — use hierarchy for type matching:
|
||||||
|
```csharp
|
||||||
|
// Entity is immune to all fire damage (Fire, Fire.AOE, etc.)
|
||||||
|
if (incomingDamage.IsDescendantOf(GameTags.Damage.Fire_Tag) && entity.HasResistance(GameTags.Damage.Fire_Tag)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tag Editor Window
|
||||||
|
|
||||||
|
Open via `Jovian` > `Tag System` > `Tag Editor...`
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Hierarchical tree view** — tags displayed as an expandable/collapsible tree
|
||||||
|
- **Add root tags** — type a name in the "New Tag" field at the bottom and click "+ Add"
|
||||||
|
- **Add child tags** — click the green **+** button on any tag to add a child under it
|
||||||
|
- **Delete tags** — click the red **✕** button. If the tag has children, you'll be asked to confirm deletion of the entire branch
|
||||||
|
- **Search** — filter tags by name
|
||||||
|
- **Save & Generate** — saves all changes and regenerates the C# constants file
|
||||||
|
- **Vertical indent guides** — visual lines showing parent-child relationships
|
||||||
|
|
||||||
|
### Adding hierarchical tags
|
||||||
|
You can type full paths in the "New Tag" field:
|
||||||
|
```
|
||||||
|
Damage.Fire.AOE
|
||||||
|
```
|
||||||
|
This automatically creates `Damage` and `Damage.Fire` as parents if they don't exist.
|
||||||
|
|
||||||
|
Or use the **+** button on an existing tag to add a child — a popup asks for just the child name.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
Tags must:
|
||||||
|
- Start with a letter
|
||||||
|
- Contain only letters, digits, or underscores
|
||||||
|
- Not be a C# reserved keyword (`class`, `int`, `static`, etc.)
|
||||||
|
|
||||||
|
Invalid names are rejected with an error message. Duplicates are detected and shown as warnings.
|
||||||
|
|
||||||
|
## Using Tags in Code
|
||||||
|
|
||||||
|
### Generated Constants
|
||||||
|
After clicking **Save & Generate**, use the generated constants:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Jovian.TagSystem;
|
||||||
|
|
||||||
|
public class Projectile : MonoBehaviour {
|
||||||
|
public void OnHit(JovianTag targetTag) {
|
||||||
|
if (targetTag.IsDescendantOf(GameTags.Character.Enemy.Enemy_Tag)) {
|
||||||
|
DealDamage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JovianTag Struct
|
||||||
|
The core type — 8 bytes, zero heap allocation:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
JovianTag tag = GameTags.Damage.Fire_Tag;
|
||||||
|
|
||||||
|
tag.IsValid(); // true (not the empty/None tag)
|
||||||
|
tag.IsNone(); // false
|
||||||
|
tag.Id; // unique int identifier
|
||||||
|
tag.ParentId; // parent's int identifier (0 = root)
|
||||||
|
tag.ToString(); // "Damage.Fire" (when manager is initialized)
|
||||||
|
tag.IsDescendantOf(otherTag); // hierarchy query
|
||||||
|
tag.IsAncestorOf(otherTag); // hierarchy query
|
||||||
|
tag.IsSiblingTo(otherTag); // same parent check
|
||||||
|
tag == otherTag; // equality by ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inspector Integration
|
||||||
|
|
||||||
|
### JovianTagsGroup
|
||||||
|
Use `JovianTagsGroup` on any MonoBehaviour or ScriptableObject to select tags in the inspector:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class Enemy : MonoBehaviour {
|
||||||
|
public JovianTagsGroup tags;
|
||||||
|
|
||||||
|
private void Start() {
|
||||||
|
// Check tags at runtime
|
||||||
|
if (tags.ContainsDescendantOf(GameTags.Character.Enemy.Enemy_Tag)) {
|
||||||
|
Debug.Log("This is an enemy!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to a runtime container for efficient repeated queries
|
||||||
|
var container = tags.ToContainer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inspector Display
|
||||||
|
- **Collapsible** — shows `Tags (3)` when collapsed, full list when expanded
|
||||||
|
- **Tag list** — each tag shown with a red ✕ remove button
|
||||||
|
- **+ Add Tags** button — opens the tag picker popup
|
||||||
|
- **Edit Tags** button — opens the Tag Editor window
|
||||||
|
|
||||||
|
## Tag Picker Popup
|
||||||
|
|
||||||
|
The picker popup opens when you click **+ Add Tags** in the inspector. Features:
|
||||||
|
|
||||||
|
- **Hierarchical tree with checkboxes** — check/uncheck any tag at any depth
|
||||||
|
- **Search** — filter by name
|
||||||
|
- **+all / -all buttons** — bulk select/deselect all children of a parent tag
|
||||||
|
- **Already-selected tags** are pre-checked when the popup opens
|
||||||
|
- **Confirm / Cancel** — apply or discard the selection
|
||||||
|
- **Edit Tags** button — jump to the Tag Editor window
|
||||||
|
|
||||||
|
## Code Generation
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
The Tag Editor generates a C# file with:
|
||||||
|
1. **Nested static classes** mirroring the tag hierarchy
|
||||||
|
2. **Static `JovianTag` fields** for each tag (postfixed with `_Tag`)
|
||||||
|
3. **`[RuntimeInitializeOnLoadMethod]`** that registers all tags and resolves the fields at startup
|
||||||
|
4. **`[InitializeOnLoadMethod]`** (editor-only) for editor play mode
|
||||||
|
|
||||||
|
### Manual Edits
|
||||||
|
You can add fields by hand to the generated file. They will be preserved on regeneration as long as the file compiles.
|
||||||
|
|
||||||
|
### Regenerate Without the Window
|
||||||
|
Use `Jovian` > `Tag System` > `Regenerate Tag Constants` to regenerate from the menu without opening the Tag Editor.
|
||||||
|
|
||||||
|
### Settings Inspector
|
||||||
|
The `JovianTagsSettings` ScriptableObject inspector has:
|
||||||
|
- Default array editor for direct tag editing
|
||||||
|
- **Save & Generate Tags** button
|
||||||
|
- **Open Tag Editor** button
|
||||||
|
- Validation errors shown inline (invalid names, duplicates)
|
||||||
|
|
||||||
|
## Tag Containers
|
||||||
|
|
||||||
|
### JovianTagsContainer (tags only)
|
||||||
|
A runtime collection for holding and querying multiple tags:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var container = new JovianTagsContainer(4);
|
||||||
|
container.Add(GameTags.Damage.Fire_Tag);
|
||||||
|
container.Add(GameTags.Status.Burning_Tag);
|
||||||
|
|
||||||
|
container.Contains(GameTags.Damage.Fire_Tag); // true
|
||||||
|
container.ContainsDescendantOf(GameTags.Damage.Damage_Tag); // true
|
||||||
|
container.ContainsAncestorOf(GameTags.Damage.Fire.AOE_Tag); // true
|
||||||
|
container.ContainsSibling(GameTags.Damage.Ice_Tag); // true (Fire and Ice are siblings)
|
||||||
|
container.Count; // 2
|
||||||
|
|
||||||
|
container.Remove(GameTags.Damage.Fire_Tag);
|
||||||
|
container.Clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
### From JovianTagsGroup
|
||||||
|
Convert a serialized selection to a runtime container:
|
||||||
|
```csharp
|
||||||
|
public JovianTagsGroup selectedTags;
|
||||||
|
|
||||||
|
void Start() {
|
||||||
|
var container = selectedTags.ToContainer();
|
||||||
|
// Use container for efficient queries
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tag Containers with Data
|
||||||
|
|
||||||
|
### JovianTagsContainer\<T\> (tags + values)
|
||||||
|
Pair tags with typed data — like a dictionary with hierarchy-aware queries:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Damage resistances
|
||||||
|
var resistances = new JovianTagsContainer<float>(4);
|
||||||
|
resistances.Add(GameTags.Damage.Fire_Tag, 0.5f); // 50% fire resistance
|
||||||
|
resistances.Add(GameTags.Damage.Ice_Tag, 0.75f); // 75% ice resistance
|
||||||
|
|
||||||
|
// Exact lookup
|
||||||
|
if (resistances.TryGetValue(GameTags.Damage.Fire_Tag, out float fireRes)) {
|
||||||
|
Debug.Log($"Fire resistance: {fireRes}"); // 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hierarchy query — do I resist ANY damage type?
|
||||||
|
resistances.ContainsDescendantOf(GameTags.Damage.Damage_Tag); // true
|
||||||
|
|
||||||
|
// Get all resistances under a parent
|
||||||
|
var results = new List<TagEntry<float>>();
|
||||||
|
resistances.GetByAncestor(GameTags.Damage.Damage_Tag, results);
|
||||||
|
// results contains Fire(0.5) and Ice(0.75)
|
||||||
|
```
|
||||||
|
|
||||||
|
### TagEntry\<T\>
|
||||||
|
Each entry in a generic container:
|
||||||
|
```csharp
|
||||||
|
TagEntry<float> entry = ...;
|
||||||
|
entry.Tag; // the JovianTag
|
||||||
|
entry.Value; // the float value
|
||||||
|
entry.IsDescendantOf(someAncestor); // hierarchy query on the tag
|
||||||
|
entry.Is(someTag); // exact match
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime API Reference
|
||||||
|
|
||||||
|
### JovianTagsHandler (static)
|
||||||
|
The central tag registry. Initialized automatically by the generated code.
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `Initialize()` | Reset and initialize the registry |
|
||||||
|
| `RegisterTag(string)` | Register a dot-delimited tag and all parents |
|
||||||
|
| `GetTag(string)` | Get tag by full name |
|
||||||
|
| `GetTag(int)` | Get tag by ID |
|
||||||
|
| `TryGetGameTag(string, out JovianTag)` | Safe lookup by name |
|
||||||
|
| `TagToString(JovianTag)` | Get the full name of a tag |
|
||||||
|
| `DisplayName(string)` | Get the last segment (`"Damage.Fire"` → `"Fire"`) |
|
||||||
|
| `IsInitialized` | Whether the registry is ready |
|
||||||
|
|
||||||
|
### JovianTag (struct, 8 bytes)
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `Id` | Unique integer identifier |
|
||||||
|
| `ParentId` | Parent's ID (0 = root) |
|
||||||
|
| `IsValid()` | Not the empty tag |
|
||||||
|
| `IsNone()` | Is the empty tag |
|
||||||
|
| `IsDescendantOf(tag)` | Hierarchy: child/grandchild check |
|
||||||
|
| `IsAncestorOf(tag)` | Hierarchy: parent/grandparent check |
|
||||||
|
| `IsSiblingTo(tag)` | Same parent check |
|
||||||
|
| `==`, `!=`, `Equals()` | Equality by ID |
|
||||||
|
|
||||||
|
### JovianTagsGroup (serializable struct)
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `tags` | `string[]` of tag names (serialized) |
|
||||||
|
| `ToContainer()` | Resolve to `JovianTagsContainer` |
|
||||||
|
| `Contains(tag)` | Check if any tag matches |
|
||||||
|
| `ContainsDescendantOf(tag)` | Hierarchy query |
|
||||||
|
| `ContainsAncestorOf(tag)` | Hierarchy query |
|
||||||
|
| `HasAny()` | True if any tags selected |
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **JovianTag** — 8 bytes (`int id` + `int parentId`), no heap allocation
|
||||||
|
- **Hierarchy queries** — O(depth) parent chain walk via `JovianTagsHandler.GetTag(int)` dictionary lookup. Typical depth is 1-4 hops.
|
||||||
|
- **Equality** — O(1) integer comparison
|
||||||
|
- **Container queries** — O(n) linear scan, optimal for typical small tag counts (<10 per entity)
|
||||||
|
- **ToString** — uses cached static `StringBuilder` to avoid per-call allocation
|
||||||
|
- **Registration** — uses `ReadOnlySpan<char>` for segment parsing to minimize string allocations
|
||||||
7
Packages/com.jovian.tag-system/README.md.meta
Normal file
7
Packages/com.jovian.tag-system/README.md.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 95d784d03badc334eb57943d0da95b0e
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Packages/com.jovian.tag-system/Runtime.meta
Normal file
8
Packages/com.jovian.tag-system/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9a504e32ce3c16c4ba10a51b9444669a
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
3
Packages/com.jovian.tag-system/Runtime/AssemblyInfo.cs
Normal file
3
Packages/com.jovian.tag-system/Runtime/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("Jovian.TagSystem.Editor"), InternalsVisibleTo("Jovian.TagSystem.Tests")]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 925b98fc2eb4d8f49900afdee32991a1
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Jovian.TagSystem",
|
||||||
|
"rootNamespace": "Jovian.TagSystem",
|
||||||
|
"references": [],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"precompiledReferences": [],
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f71bff532e6b8e24a8fc3e2761205890
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
85
Packages/com.jovian.tag-system/Runtime/JovianTag.cs
Normal file
85
Packages/com.jovian.tag-system/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
Packages/com.jovian.tag-system/Runtime/JovianTag.cs.meta
Normal file
2
Packages/com.jovian.tag-system/Runtime/JovianTag.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2f7edc6a4a42107499ed2b46654d63f3
|
||||||
163
Packages/com.jovian.tag-system/Runtime/JovianTagsContainer.cs
Normal file
163
Packages/com.jovian.tag-system/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cb368346014a3644995688d0a325abcd
|
||||||
102
Packages/com.jovian.tag-system/Runtime/JovianTagsGroup.cs
Normal file
102
Packages/com.jovian.tag-system/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 140c6dc4e1c289e48b7441ac6ee5e20f
|
||||||
151
Packages/com.jovian.tag-system/Runtime/JovianTagsHandler.cs
Normal file
151
Packages/com.jovian.tag-system/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e700d92d5f8326b4aacff3563881ed6f
|
||||||
90
Packages/com.jovian.tag-system/Runtime/JovianTagsSettings.cs
Normal file
90
Packages/com.jovian.tag-system/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/Tag System/JovianTagSettings")]
|
||||||
|
public class JovianTagsSettings : ScriptableObject {
|
||||||
|
public RegisteredTag[] gameTags = Array.Empty<RegisteredTag>();
|
||||||
|
|
||||||
|
private static readonly System.Text.RegularExpressions.Regex ValidSegment =
|
||||||
|
new(@"^[A-Za-z][A-Za-z0-9_]*$");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> CSharpKeywords = new(StringComparer.Ordinal) {
|
||||||
|
"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char",
|
||||||
|
"checked", "class", "const", "continue", "decimal", "default", "delegate", "do",
|
||||||
|
"double", "else", "enum", "event", "explicit", "extern", "false", "finally",
|
||||||
|
"fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int",
|
||||||
|
"interface", "internal", "is", "lock", "long", "namespace", "new", "null",
|
||||||
|
"object", "operator", "out", "override", "params", "private", "protected",
|
||||||
|
"public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof",
|
||||||
|
"stackalloc", "static", "string", "struct", "switch", "this", "throw", "true",
|
||||||
|
"try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using",
|
||||||
|
"virtual", "void", "volatile", "while"
|
||||||
|
};
|
||||||
|
|
||||||
|
public void AddGameTag(string newTag) {
|
||||||
|
List<string> tagHierarchy = newTag.Split(JovianTagsHandler.tagDelimiter).ToList();
|
||||||
|
tagHierarchy.Remove("");
|
||||||
|
newTag = string.Join(JovianTagsHandler.tagDelimiter, tagHierarchy);
|
||||||
|
|
||||||
|
// Validate each segment
|
||||||
|
foreach(var segment in tagHierarchy) {
|
||||||
|
if(!ValidSegment.IsMatch(segment)) {
|
||||||
|
Debug.LogError($"Invalid tag segment '{segment}': must start with a letter and contain only letters, digits, or underscores.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(CSharpKeywords.Contains(segment)) {
|
||||||
|
Debug.LogError($"Invalid tag segment '{segment}': is a C# reserved keyword.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(gameTags.Contains(new(newTag))) {
|
||||||
|
Debug.LogError($"{newTag} is already added to the game");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] tagsSplit = newTag.Split(JovianTagsHandler.tagDelimiter);
|
||||||
|
string tagToAdd = "";
|
||||||
|
for(int i = 0, n = tagsSplit.Length; i < n; i++) {
|
||||||
|
tagToAdd += tagsSplit[i];
|
||||||
|
if(gameTags.Any((a) => a.tag == tagToAdd)) {
|
||||||
|
tagToAdd += ".";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous = gameTags;
|
||||||
|
gameTags = new RegisteredTag[gameTags.Length + 1];
|
||||||
|
previous.CopyTo(gameTags, 0);
|
||||||
|
gameTags[^1] = new RegisteredTag(tagToAdd);
|
||||||
|
tagToAdd += ".";
|
||||||
|
}
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
UnityEditor.EditorUtility.SetDirty(this);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidSegment(string segment) {
|
||||||
|
return !string.IsNullOrWhiteSpace(segment)
|
||||||
|
&& ValidSegment.IsMatch(segment)
|
||||||
|
&& !CSharpKeywords.Contains(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidTag(string tag) {
|
||||||
|
if(string.IsNullOrWhiteSpace(tag)) return false;
|
||||||
|
return tag.Split(JovianTagsHandler.tagDelimiter).All(IsValidSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveTag(string gameTagToRemove) {
|
||||||
|
var tagSearch = gameTags.ToList();
|
||||||
|
tagSearch.RemoveAll((a) => a.tag.StartsWith(gameTagToRemove));
|
||||||
|
gameTags = tagSearch.ToArray();
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
UnityEditor.EditorUtility.SetDirty(this);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 32e658688044a8f469e0c311f9c4facb
|
||||||
8
Packages/com.jovian.tag-system/Tests.meta
Normal file
8
Packages/com.jovian.tag-system/Tests.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e15e55a8a150db444b90c0394baf332f
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Packages/com.jovian.tag-system/Tests/Editor.meta
Normal file
8
Packages/com.jovian.tag-system/Tests/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 973227b88de0b1e4ba64c1955c03f09a
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e9c31310bf551eb49bc72359408c5dee
|
||||||
109
Packages/com.jovian.tag-system/Tests/Editor/TestJovianTag.cs
Normal file
109
Packages/com.jovian.tag-system/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 743efdf77fd571944a6ae11fff741bb5
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 56948cc0d1c038549b8f0c3a42aec365
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ecc875077acb6d7499197f892e33d948
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cd13f72302bceda4b8025e613532c78c
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: edb98aafe9b3b37478eb078aae34a5ce
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
6
Packages/com.jovian.tag-system/package.json
Normal file
6
Packages/com.jovian.tag-system/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
Packages/com.jovian.tag-system/package.json.meta
Normal file
7
Packages/com.jovian.tag-system/package.json.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cb03f6dd77cb80e4a870bbdf375fc85c
|
||||||
|
PackageManifestImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"com.jovian.tag-system": {
|
"com.jovian.tag-system": {
|
||||||
"version": "file:com.jovian.tag-system",
|
"version": "file:unity-tag-system",
|
||||||
"depth": 0,
|
"depth": 0,
|
||||||
"source": "embedded",
|
"source": "embedded",
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
|
|||||||
Reference in New Issue
Block a user