first comit

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

3
Runtime/AssemblyInfo.cs Normal file
View File

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

View File

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

View File

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

View File

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

85
Runtime/JovianTag.cs Normal file
View File

@@ -0,0 +1,85 @@
using System;
using System.Runtime.CompilerServices;
using UnityEngine;
namespace Jovian.TagSystem {
/// <summary>
/// Lightweight tag identity. 8 bytes, no heap allocation.
/// Hierarchy queries are resolved via GameTagManager static lookups.
/// </summary>
[Serializable]
public struct JovianTag : IEquatable<JovianTag>, IComparable<JovianTag> {
[SerializeField] private int id;
[SerializeField] private int parentId;
public int Id => id;
public int ParentId => parentId;
public JovianTag(int id, int parentId) {
this.id = id;
this.parentId = parentId;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly bool IsNone() => id == 0;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly bool IsValid() => id > 0;
/// <summary>
/// Checks if this tag is a descendant of the given ancestor.
/// Walks the parent chain via GameTagManager.
/// </summary>
public readonly bool IsDescendantOf(JovianTag ancestor) {
if(id == 0 || ancestor.id == 0) return false;
if(id == ancestor.id) return true;
// Walk up from this tag's parent chain
var current = this;
while(current.parentId != 0) {
if(current.parentId == ancestor.id) return true;
current = JovianTagsHandler.GetTag(current.parentId);
}
return false;
}
/// <summary>
/// Checks if this tag is an ancestor of the given descendant.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly bool IsAncestorOf(JovianTag descendant) {
return descendant.IsDescendantOf(this);
}
/// <summary>
/// Checks if this tag shares the same parent as the given tag.
/// </summary>
public readonly bool IsSiblingTo(JovianTag sibling) {
if(id == 0 || sibling.id == 0) return false;
return parentId == sibling.parentId;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool operator ==(JovianTag x, JovianTag y) => x.id == y.id;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool operator !=(JovianTag x, JovianTag y) => x.id != y.id;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly bool Equals(JovianTag other) => id == other.id;
public readonly int CompareTo(JovianTag other) => id.CompareTo(other.id);
public override readonly bool Equals(object obj) => obj is JovianTag other && id == other.id;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override readonly int GetHashCode() => id;
public override readonly string ToString() {
if(JovianTagsHandler.IsInitialized) {
return JovianTagsHandler.TagToString(this);
}
return id == 0 ? "None" : $"Tag({id})";
}
}
}

View File

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

View File

@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
namespace Jovian.TagSystem {
/// <summary>
/// Entry in a JovianTagContainer — pairs a tag with an optional typed value.
/// </summary>
[Serializable]
public struct TagEntry<T> {
public JovianTag Tag;
public T Value;
public TagEntry(JovianTag tag, T value) {
Tag = tag;
Value = value;
}
public TagEntry(JovianTag tag) {
Tag = tag;
Value = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly bool Is(JovianTag other) => Tag.Equals(other);
public readonly bool IsDescendantOf(JovianTag ancestor) => Tag.IsDescendantOf(ancestor);
public readonly bool IsAncestorOf(JovianTag descendant) => Tag.IsAncestorOf(descendant);
public readonly bool IsSiblingTo(JovianTag sibling) => Tag.IsSiblingTo(sibling);
public readonly bool IsValid() => Tag.IsValid();
public readonly override string ToString() => $"{Tag}: {Value}";
}
/// <summary>
/// Generic tag container — holds tag+value pairs with hierarchy-aware queries.
/// For tags-only, use non-generic JovianTagContainer.
/// </summary>
[Serializable]
public class JovianTagsContainer<T> {
public readonly List<TagEntry<T>> entries;
public JovianTagsContainer(int capacity) {
entries = new List<TagEntry<T>>(capacity);
}
public int Count => entries.Count;
public void Add(JovianTag tag, T value) {
for(int i = 0, n = entries.Count; i < n; i++) {
if(entries[i].Tag.Id == tag.Id) return; // no duplicates
}
entries.Add(new TagEntry<T>(tag, value));
}
public bool Remove(JovianTag tag) {
for(int i = entries.Count - 1; i >= 0; i--) {
if(entries[i].Tag.Id == tag.Id) {
entries.RemoveAt(i);
return true;
}
}
return false;
}
public void Clear() => entries.Clear();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Contains(JovianTag tag) {
for(int i = 0, n = entries.Count; i < n; i++) {
if(entries[i].Tag.Id == tag.Id) return true;
}
return false;
}
public bool TryGetValue(JovianTag tag, out T value) {
for(int i = 0, n = entries.Count; i < n; i++) {
if(entries[i].Tag.Id == tag.Id) {
value = entries[i].Value;
return true;
}
}
value = default;
return false;
}
public bool ContainsDescendantOf(JovianTag ancestor) {
for(int i = 0, n = entries.Count; i < n; i++) {
if(entries[i].Tag.IsDescendantOf(ancestor)) return true;
}
return false;
}
public bool ContainsAncestorOf(JovianTag descendant) {
for(int i = 0, n = entries.Count; i < n; i++) {
if(entries[i].Tag.IsAncestorOf(descendant)) return true;
}
return false;
}
public bool ContainsSibling(JovianTag sibling) {
for(int i = 0, n = entries.Count; i < n; i++) {
if(entries[i].Tag.IsSiblingTo(sibling)) return true;
}
return false;
}
public void GetByAncestor(JovianTag ancestor, List<TagEntry<T>> results) {
for(int i = 0, n = entries.Count; i < n; i++) {
if(entries[i].Tag.IsDescendantOf(ancestor)) results.Add(entries[i]);
}
}
public void GetByDescendant(JovianTag descendant, List<TagEntry<T>> results) {
for(int i = 0, n = entries.Count; i < n; i++) {
if(entries[i].Tag.IsAncestorOf(descendant)) results.Add(entries[i]);
}
}
public void GetBySibling(JovianTag sibling, List<TagEntry<T>> results) {
for(int i = 0, n = entries.Count; i < n; i++) {
if(entries[i].Tag.IsSiblingTo(sibling)) results.Add(entries[i]);
}
}
private static readonly StringBuilder sb = new(256);
public override string ToString() {
sb.Clear();
for(int i = 0, n = entries.Count; i < n; i++) {
if(i > 0) sb.Append(" | ");
sb.Append(entries[i].ToString());
}
return sb.ToString();
}
}
/// <summary>
/// Sentinel type for tag-only containers (no payload).
/// </summary>
public struct NoValue { }
/// <summary>
/// Non-generic tag container — tags only, no data payload.
/// </summary>
[Serializable]
public class JovianTagsContainer : JovianTagsContainer<NoValue> {
private static readonly JovianTagsContainer empty = new(0);
public static JovianTagsContainer Empty => empty;
public JovianTagsContainer(int capacity) : base(capacity) { }
public void Add(JovianTag tag) => Add(tag, default);
/// <summary>
/// Access tags directly for backwards compatibility.
/// </summary>
public JovianTag this[int index] => entries[index].Tag;
public JovianTag GetTag(int index) => entries[index].Tag;
}
}

View File

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

102
Runtime/JovianTagsGroup.cs Normal file
View File

@@ -0,0 +1,102 @@
using System;
using UnityEngine;
namespace Jovian.TagSystem {
/// <summary>
/// Serializable tag selection for use in MonoBehaviours and ScriptableObjects.
/// Always supports multiple tags. Use the property drawer to select tags in the inspector.
/// </summary>
[Serializable]
public struct JovianTagsGroup {
[SerializeField] public string[] tags;
public JovianTagsGroup(params string[] tags) {
this.tags = tags ?? Array.Empty<string>();
}
public int Count => tags?.Length ?? 0;
/// <summary>
/// Returns all selected tags as resolved GameTags.
/// Stale/unregistered tag names are silently skipped.
/// </summary>
public JovianTagsContainer ToContainer() {
if(tags == null || tags.Length == 0) {
return JovianTagsContainer.Empty;
}
var container = new JovianTagsContainer(tags.Length);
foreach(var tag in tags) {
if(JovianTagsHandler.TryGetGameTagThatIsNotNone(tag, out var resolved)) {
container.Add(resolved);
}
}
return container;
}
/// <summary>
/// Checks if any selected tag matches the given tag exactly.
/// Stale/unregistered tag names count as no match (no error log).
/// </summary>
public bool Contains(JovianTag jovianTag) {
if(tags == null) return false;
foreach(var tag in tags) {
if(JovianTagsHandler.TryGetGameTag(tag, out var resolved)
&& resolved.Equals(jovianTag)) {
return true;
}
}
return false;
}
/// <summary>
/// Checks if any selected tag is a descendant of the given ancestor.
/// </summary>
public bool ContainsDescendantOf(JovianTag ancestor) {
if(tags == null) return false;
foreach(var tag in tags) {
if(JovianTagsHandler.TryGetGameTag(tag, out var resolved)
&& resolved.IsDescendantOf(ancestor)) {
return true;
}
}
return false;
}
/// <summary>
/// Checks if any selected tag is an ancestor of the given descendant.
/// </summary>
public bool ContainsAncestorOf(JovianTag descendant) {
if(tags == null) return false;
foreach(var tag in tags) {
if(JovianTagsHandler.TryGetGameTag(tag, out var resolved)
&& resolved.IsAncestorOf(descendant)) {
return true;
}
}
return false;
}
/// <summary>
/// Checks if any selected tag is a sibling of the given tag.
/// </summary>
public bool ContainsSiblingOf(JovianTag sibling) {
if(tags == null) return false;
foreach(var tag in tags) {
if(JovianTagsHandler.TryGetGameTag(tag, out var resolved)
&& resolved.IsSiblingTo(sibling)) {
return true;
}
}
return false;
}
public bool HasAny() {
return tags != null && tags.Length > 0;
}
public override string ToString() {
if(tags == null || tags.Length == 0) return "None";
return string.Join(", ", tags);
}
}
}

View File

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

View File

@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
using UnityEngine;
namespace Jovian.TagSystem {
[Serializable]
public struct RegisteredTag : IEquatable<RegisteredTag> {
public string tag;
public RegisteredTag(string tag) {
this.tag = tag;
}
public bool Equals(RegisteredTag other) => tag == other.tag;
public override bool Equals([CanBeNull] object obj) => obj is RegisteredTag other && Equals(other);
public override int GetHashCode() => tag != null ? tag.GetHashCode() : 0;
}
public static class JovianTagsHandler {
public const char tagDelimiter = '.';
public const string emptyTagName = "None";
public const int emptyTagId = 0;
public static readonly JovianTag emptyTag = new(emptyTagId, 0);
// Primary lookups — no allocations on query
private static Dictionary<string, JovianTag> tagsByName = new();
private static Dictionary<int, JovianTag> tagsById = new();
private static Dictionary<int, string> tagNames = new();
private static int idCounter;
private static bool initialized;
public static bool IsInitialized => initialized;
public static void Initialize() {
tagsByName = new Dictionary<string, JovianTag>(64) { { emptyTagName, emptyTag } };
tagsById = new Dictionary<int, JovianTag>(64) { { emptyTagId, emptyTag } };
tagNames = new Dictionary<int, string>(64) { { emptyTagId, emptyTagName } };
idCounter = 0;
initialized = true;
}
public static void EnsureInitialized() {
if(!initialized) Initialize();
}
public static void Reset() {
tagsByName = new Dictionary<string, JovianTag>();
tagsById = new Dictionary<int, JovianTag>();
tagNames = new Dictionary<int, string>();
idCounter = 0;
initialized = false;
}
public static void RegisterTags(RegisteredTag[] serializedTags) {
EnsureInitialized();
for(int i = 0, n = serializedTags.Length; i < n; i++) {
RegisterTag(serializedTags[i].tag);
}
}
public static void RegisterTags(string[] tagNames) {
EnsureInitialized();
for(int i = 0, n = tagNames.Length; i < n; i++) {
RegisterTag(tagNames[i]);
}
}
public static void RegisterTag(string tagToRegister) {
EnsureInitialized();
// Walk segments without allocating a string[] — use ReadOnlySpan
var span = tagToRegister.AsSpan();
int parentId = 0;
for(int i = 0; i <= span.Length; i++) {
if(i < span.Length && span[i] != tagDelimiter) {
continue;
}
// span[segStart..i] is the current segment
// Full tag name is tagToRegister[0..i]
var fullName = tagToRegister.Substring(0, i);
if(!tagsByName.TryGetValue(fullName, out var tag)) {
idCounter++;
tag = new JovianTag(idCounter, parentId);
tagsByName[fullName] = tag;
tagsById[idCounter] = tag;
tagNames[idCounter] = fullName;
}
parentId = tag.Id;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static JovianTag GetTag(string tagName) {
if(string.IsNullOrEmpty(tagName)) {
return emptyTag;
}
if(tagsByName.TryGetValue(tagName, out var tag)) {
return tag;
}
Debug.LogError($"[TagManager] Trying to get unregistered Tag: {tagName}");
return emptyTag;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static JovianTag GetTag(int id) {
return tagsById.GetValueOrDefault(id, emptyTag);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool TryGetGameTag(string tagName, out JovianTag tag) {
tag = emptyTag;
return !string.IsNullOrEmpty(tagName) && tagsByName.TryGetValue(tagName, out tag);
}
public static bool TryGetGameTagThatIsNotNone(string tagName, out JovianTag tag) {
if(!TryGetGameTag(tagName, out tag)) {
return false;
}
return tag.Id != emptyTagId;
}
public static void GetAllTags(List<JovianTag> results) {
foreach(var kvp in tagsByName) {
if(kvp.Value.Id != emptyTagId)
results.Add(kvp.Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string TagToString(JovianTag jovianTag) {
return tagNames.TryGetValue(jovianTag.Id, out var text) ? text : string.Empty;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string TagToString(int tagId) {
return tagNames.TryGetValue(tagId, out var text) ? text : string.Empty;
}
public static string DisplayName(string name) {
var lastDot = name.LastIndexOf(tagDelimiter);
return lastDot < 0 ? name : name.Substring(lastDot + 1);
}
}
}

View File

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

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Serialization;
namespace Jovian.TagSystem {
[CreateAssetMenu(menuName = "Jovian/Game Tag System/Game Tag Settings")]
public class JovianTagsSettings : ScriptableObject {
public RegisteredTag[] gameTags = Array.Empty<RegisteredTag>();
private static readonly System.Text.RegularExpressions.Regex ValidSegment =
new(@"^[A-Za-z][A-Za-z0-9_]*$");
private static readonly HashSet<string> CSharpKeywords = new(StringComparer.Ordinal) {
"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char",
"checked", "class", "const", "continue", "decimal", "default", "delegate", "do",
"double", "else", "enum", "event", "explicit", "extern", "false", "finally",
"fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int",
"interface", "internal", "is", "lock", "long", "namespace", "new", "null",
"object", "operator", "out", "override", "params", "private", "protected",
"public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof",
"stackalloc", "static", "string", "struct", "switch", "this", "throw", "true",
"try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using",
"virtual", "void", "volatile", "while"
};
public void AddGameTag(string newTag) {
List<string> tagHierarchy = newTag.Split(JovianTagsHandler.tagDelimiter).ToList();
tagHierarchy.Remove("");
newTag = string.Join(JovianTagsHandler.tagDelimiter, tagHierarchy);
// Validate each segment
foreach(var segment in tagHierarchy) {
if(!ValidSegment.IsMatch(segment)) {
Debug.LogError($"Invalid tag segment '{segment}': must start with a letter and contain only letters, digits, or underscores.");
return;
}
if(CSharpKeywords.Contains(segment)) {
Debug.LogError($"Invalid tag segment '{segment}': is a C# reserved keyword.");
return;
}
}
if(gameTags.Contains(new(newTag))) {
Debug.LogError($"{newTag} is already added to the game");
return;
}
string[] tagsSplit = newTag.Split(JovianTagsHandler.tagDelimiter);
string tagToAdd = "";
for(int i = 0, n = tagsSplit.Length; i < n; i++) {
tagToAdd += tagsSplit[i];
if(gameTags.Any((a) => a.tag == tagToAdd)) {
tagToAdd += ".";
continue;
}
var previous = gameTags;
gameTags = new RegisteredTag[gameTags.Length + 1];
previous.CopyTo(gameTags, 0);
gameTags[^1] = new RegisteredTag(tagToAdd);
tagToAdd += ".";
}
#if UNITY_EDITOR
UnityEditor.EditorUtility.SetDirty(this);
#endif
}
private static bool IsValidSegment(string segment) {
return !string.IsNullOrWhiteSpace(segment)
&& ValidSegment.IsMatch(segment)
&& !CSharpKeywords.Contains(segment);
}
private static bool IsValidTag(string tag) {
if(string.IsNullOrWhiteSpace(tag)) return false;
return tag.Split(JovianTagsHandler.tagDelimiter).All(IsValidSegment);
}
public void RemoveTag(string gameTagToRemove) {
var tagSearch = gameTags.ToList();
tagSearch.RemoveAll((a) => a.tag.StartsWith(gameTagToRemove));
gameTags = tagSearch.ToArray();
#if UNITY_EDITOR
UnityEditor.EditorUtility.SetDirty(this);
#endif
}
}
}

View File

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