diff --git a/Movement tests.csproj b/Movement tests.csproj
index 618b1cab..6c0c2ec2 100644
--- a/Movement tests.csproj
+++ b/Movement tests.csproj
@@ -1,142 +1,143 @@
-
- net9.0
- true
- Movementtests
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- none
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+ net9.0
+ true
+ Movementtests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
\ No newline at end of file
diff --git a/addons/forge/Forge.props b/addons/forge/Forge.props
new file mode 100644
index 00000000..6ed69133
--- /dev/null
+++ b/addons/forge/Forge.props
@@ -0,0 +1,8 @@
+
+
+ enable
+
+
+
+
+
diff --git a/addons/forge/ForgePluginLoader.cs b/addons/forge/ForgePluginLoader.cs
new file mode 100644
index 00000000..a25e602a
--- /dev/null
+++ b/addons/forge/ForgePluginLoader.cs
@@ -0,0 +1,109 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System.Diagnostics;
+using Gamesmiths.Forge.Godot.Editor;
+using Gamesmiths.Forge.Godot.Editor.Attributes;
+using Gamesmiths.Forge.Godot.Editor.Cues;
+using Gamesmiths.Forge.Godot.Editor.Tags;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot;
+
+[Tool]
+public partial class ForgePluginLoader : EditorPlugin
+{
+ private const string AutoloadPath = "uid://ba8fquhtwu5mu";
+ private const string PluginScenePath = "uid://pjscvogl6jak";
+
+ private EditorDock? _editorDock;
+ private PanelContainer? _dockedScene;
+ private TagContainerInspectorPlugin? _tagContainerInspectorPlugin;
+ private TagInspectorPlugin? _tagInspectorPlugin;
+ private AttributeSetInspectorPlugin? _attributeSetInspectorPlugin;
+ private CueHandlerInspectorPlugin? _cueHandlerInspectorPlugin;
+ private AttributeEditorPlugin? _attributeEditorPlugin;
+
+ public override void _EnterTree()
+ {
+ PackedScene pluginScene = ResourceLoader.Load(PluginScenePath);
+
+ _editorDock = new EditorDock
+ {
+ Title = "Forge",
+ DockIcon = GD.Load("uid://cu6ncpuumjo20"),
+ DefaultSlot = EditorDock.DockSlot.RightUl,
+ };
+
+ _dockedScene = (PanelContainer)pluginScene.Instantiate();
+ _dockedScene.GetNode("%Tags").IsPluginInstance = true;
+
+ _editorDock.AddChild(_dockedScene);
+ AddDock(_editorDock);
+
+ _tagContainerInspectorPlugin = new TagContainerInspectorPlugin();
+ AddInspectorPlugin(_tagContainerInspectorPlugin);
+ _tagInspectorPlugin = new TagInspectorPlugin();
+ AddInspectorPlugin(_tagInspectorPlugin);
+ _attributeSetInspectorPlugin = new AttributeSetInspectorPlugin();
+ AddInspectorPlugin(_attributeSetInspectorPlugin);
+ _cueHandlerInspectorPlugin = new CueHandlerInspectorPlugin();
+ AddInspectorPlugin(_cueHandlerInspectorPlugin);
+ _attributeEditorPlugin = new AttributeEditorPlugin();
+ AddInspectorPlugin(_attributeEditorPlugin);
+
+ AddToolMenuItem("Repair assets tags", new Callable(this, MethodName.CallAssetRepairTool));
+ }
+
+ public override void _ExitTree()
+ {
+ Debug.Assert(_editorDock is not null, $"{nameof(_editorDock)} should have been initialized on _Ready().");
+ Debug.Assert(_dockedScene is not null, $"{nameof(_dockedScene)} should have been initialized on _Ready().");
+
+ RemoveDock(_editorDock);
+ _editorDock.QueueFree();
+ _dockedScene.Free();
+
+ RemoveInspectorPlugin(_tagContainerInspectorPlugin);
+ RemoveInspectorPlugin(_tagInspectorPlugin);
+ RemoveInspectorPlugin(_attributeSetInspectorPlugin);
+ RemoveInspectorPlugin(_cueHandlerInspectorPlugin);
+ RemoveInspectorPlugin(_attributeEditorPlugin);
+
+ RemoveToolMenuItem("Repair assets tags");
+ }
+
+ public override void _EnablePlugin()
+ {
+ base._EnablePlugin();
+
+ var config = ProjectSettings.LoadResourcePack(AutoloadPath);
+
+ if (config)
+ {
+ GD.PrintErr("Failed to load script at res://addons/forge/core/ForgeBootstrap.cs");
+ return;
+ }
+
+ if (!ProjectSettings.HasSetting("autoload/Forge Bootstrap"))
+ {
+ ProjectSettings.SetSetting("autoload/Forge Bootstrap", AutoloadPath);
+ ProjectSettings.Save();
+ }
+ }
+
+ public override void _DisablePlugin()
+ {
+ if (ProjectSettings.HasSetting("autoload/Forge Bootstrap"))
+ {
+ ProjectSettings.Clear("autoload/Forge Bootstrap");
+ ProjectSettings.Save();
+ }
+ }
+
+ private static void CallAssetRepairTool()
+ {
+ AssetRepairTool.RepairAllAssetsTags();
+ }
+}
+#endif
diff --git a/addons/forge/ForgePluginLoader.cs.uid b/addons/forge/ForgePluginLoader.cs.uid
new file mode 100644
index 00000000..eed76d43
--- /dev/null
+++ b/addons/forge/ForgePluginLoader.cs.uid
@@ -0,0 +1 @@
+uid://686m2ah4as6w
diff --git a/addons/forge/LICENSE b/addons/forge/LICENSE
new file mode 100644
index 00000000..7e1635df
--- /dev/null
+++ b/addons/forge/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Gamesmiths Guild
+
+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.
diff --git a/addons/forge/README.md b/addons/forge/README.md
new file mode 100644
index 00000000..2dacbfc3
--- /dev/null
+++ b/addons/forge/README.md
@@ -0,0 +1,58 @@
+# Forge for Godot
+
+Forge for Godot is an Unreal GAS-like gameplay framework for the Godot Engine.
+
+It integrates the [Forge Gameplay System](https://github.com/gamesmiths-guild/forge) into Godot, providing a robust, data-driven foundation for gameplay features such as attributes, effects, gameplay tags, abilities, events, and cues, fully aligned with Godot’s node, resource, and editor workflows.
+
+This plugin enables you to:
+
+- Use **ForgeEntity** nodes or implement `IForgeEntity` to integrate core Forge systems like attributes, effects, abilities, events and tags.
+- Define attributes, effects, abilities, cues, and tags directly in the Godot editor.
+- Apply and manage gameplay effects with area or raycasting nodes.
+- Create hierarchical gameplay tags using the built-in Tags Editor.
+- Trigger visual and audio feedback with the Cues system.
+- Create player skills, attacks, or behaviors, with support for custom logic, costs, cooldowns, and triggers.
+
+## Features
+
+- **Effects System**: Comprehensive effect application and management, including stacking, periodic, instant, and infinite effects.
+- **Attributes System**: Attribute management, supporting sets, modifiers, and configuration.
+- **Tags System**: Full hierarchical tag system with Godot editor integration.
+- **Abilities System**: Feature-complete ability system, supporting grant/removal, custom behaviors, triggers, cooldowns, and costs.
+- **Events System**: Gameplay event bus supporting event-driven logic, subscriptions, and triggers.
+- **Cues System**: Visual/audio feedback layer; decouples presentation from game logic.
+- **Editor Extensions**: Custom inspector elements and tag editor with Godot integration.
+- **Custom Nodes**: Includes nodes like `ForgeEntity`, `ForgeAttributeSet`, `EffectArea2D`, and more.
+
+## Installation
+
+### Requirements
+
+- Godot 4.6 or later with .NET support.
+- .NET SDK 8.0 or later.
+
+### Steps
+
+1. Install the plugin via the Godot Asset Library or manually by copying the `addons` folder.
+ - [Godot Asset Library](https://godotengine.org/asset-library/asset/4239)
+ - [Manual installation guide](https://docs.godotengine.org/en/stable/tutorials/plugins/editor/installing_plugins.html)
+2. Add the following line in your `.csproj` file (before the closing `` tag). The `.csproj` file can be created through Godot by navigating to `Project > Tools > C# > Create C# solution`:
+ ```xml
+
+ ```
+3. Back in the Godot editor, build your project by clicking `Build` in the top-right corner of the script editor.
+4. Enable **Forge Gameplay System** in `Project > Project Settings > Plugins`.
+
+## Getting Started
+
+- See the [Quick Start Guide](https://github.com/gamesmiths-guild/forge-godot/blob/main/docs/quick-start.md) for a basic setup.
+- Explore [sample scenes](https://github.com/gamesmiths-guild/forge-godot/tree/main/examples) by cloning the full repo.
+
+## Documentation
+
+Full documentation, examples, and advanced usage are available in the [Forge for Godot GitHub repository](https://github.com/gamesmiths-guild/forge-godot).
+For technical details about core systems, see the [Forge Gameplay System documentation](https://github.com/gamesmiths-guild/forge/blob/main/docs/README.md).
+
+## License
+
+This plugin is licensed under the same terms as the core [Forge Gameplay System](https://github.com/gamesmiths-guild/forge).
diff --git a/addons/forge/core/EffectApplier.cs b/addons/forge/core/EffectApplier.cs
new file mode 100644
index 00000000..c24bb7db
--- /dev/null
+++ b/addons/forge/core/EffectApplier.cs
@@ -0,0 +1,312 @@
+// Copyright © Gamesmiths Guild.
+
+using System.Collections.Generic;
+using Gamesmiths.Forge.Core;
+using Gamesmiths.Forge.Effects;
+using Gamesmiths.Forge.Godot.Nodes;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Core;
+
+internal sealed class EffectApplier
+{
+ private record struct EffectKey(EffectData EffectData, EffectOwnership EffectOwnership, int Level);
+
+ private readonly List _effects = [];
+
+ private readonly Dictionary> _effectInstances = [];
+ private readonly Dictionary _effectsCache = [];
+
+ public EffectApplier(Node node)
+ {
+ foreach (Node child in node.GetChildren())
+ {
+ if (child is ForgeEffect effectNode && effectNode.EffectData is not null)
+ {
+ _effects.Add(effectNode.EffectData.GetEffectData());
+ }
+ }
+ }
+
+ public void ApplyEffects(
+ Node node,
+ IForgeEntity? effectOwner,
+ IForgeEntity? effectSource,
+ int level = 1)
+ {
+ if (node is IForgeEntity forgeEntity)
+ {
+ ApplyEffects(forgeEntity, effectOwner, effectSource, level);
+ return;
+ }
+
+ foreach (Node? child in node.GetChildren())
+ {
+ if (child is IForgeEntity forgeEntityChild)
+ {
+ ApplyEffects(forgeEntityChild, effectOwner, effectSource, level);
+ return;
+ }
+ }
+ }
+
+ public void ApplyEffects(
+ Node node,
+ TData contextData,
+ IForgeEntity? effectOwner,
+ IForgeEntity? effectSource,
+ int level = 1)
+ {
+ if (node is IForgeEntity forgeEntity)
+ {
+ ApplyEffects(forgeEntity, contextData, effectOwner, effectSource, level);
+ return;
+ }
+
+ foreach (Node? child in node.GetChildren())
+ {
+ if (child is IForgeEntity forgeEntityChild)
+ {
+ ApplyEffects(forgeEntityChild, contextData, effectOwner, effectSource, level);
+ return;
+ }
+ }
+ }
+
+ public void AddEffects(
+ Node node,
+ IForgeEntity? effectOwner,
+ IForgeEntity? effectSource,
+ int level)
+ {
+ if (node is IForgeEntity forgeEntity)
+ {
+ AddEffects(forgeEntity, effectOwner, effectSource, level);
+ return;
+ }
+
+ foreach (Node? child in node.GetChildren())
+ {
+ if (child is IForgeEntity forgeEntityChild)
+ {
+ AddEffects(forgeEntityChild, effectOwner, effectSource, level);
+ return;
+ }
+ }
+ }
+
+ public void AddEffects(
+ Node node,
+ TData contextData,
+ IForgeEntity? effectOwner,
+ IForgeEntity? effectSource,
+ int level)
+ {
+ if (node is IForgeEntity forgeEntity)
+ {
+ AddEffects(forgeEntity, contextData, effectOwner, effectSource, level);
+ return;
+ }
+
+ foreach (Node? child in node.GetChildren())
+ {
+ if (child is IForgeEntity forgeEntityChild)
+ {
+ AddEffects(forgeEntityChild, contextData, effectOwner, effectSource, level);
+ return;
+ }
+ }
+ }
+
+ public void RemoveEffects(Node node)
+ {
+ if (node is IForgeEntity forgeEntity)
+ {
+ RemoveEffects(forgeEntity);
+ return;
+ }
+
+ foreach (Node? child in node.GetChildren())
+ {
+ if (child is IForgeEntity forgeEntityChild)
+ {
+ RemoveEffects(forgeEntityChild);
+ return;
+ }
+ }
+ }
+
+ private void ApplyEffects(
+ IForgeEntity forgeEntity,
+ IForgeEntity? effectOwner,
+ IForgeEntity? effectSource,
+ int level)
+ {
+ var effectOwnership = new EffectOwnership(effectOwner, effectSource);
+
+ foreach (EffectData effectData in _effects)
+ {
+ var key = new EffectKey(effectData, effectOwnership, level);
+
+ if (_effectsCache.TryGetValue(key, out Effect? cachedEffect))
+ {
+ forgeEntity.EffectsManager.ApplyEffect(cachedEffect);
+ continue;
+ }
+
+ var effect = new Effect(
+ effectData,
+ new EffectOwnership(effectOwner, effectSource),
+ level);
+
+ _effectsCache[key] = effect;
+
+ forgeEntity.EffectsManager.ApplyEffect(effect);
+ }
+ }
+
+ private void ApplyEffects(
+ IForgeEntity forgeEntity,
+ TData contextData,
+ IForgeEntity? effectOwner,
+ IForgeEntity? effectSource,
+ int level)
+ {
+ var effectOwnership = new EffectOwnership(effectOwner, effectSource);
+
+ foreach (EffectData effectData in _effects)
+ {
+ var key = new EffectKey(effectData, effectOwnership, level);
+
+ if (_effectsCache.TryGetValue(key, out Effect? cachedEffect))
+ {
+ forgeEntity.EffectsManager.ApplyEffect(cachedEffect);
+ continue;
+ }
+
+ var effect = new Effect(
+ effectData,
+ new EffectOwnership(effectOwner, effectSource),
+ level);
+
+ _effectsCache[key] = effect;
+
+ forgeEntity.EffectsManager.ApplyEffect(effect, contextData);
+ }
+ }
+
+ private void AddEffects(
+ IForgeEntity forgeEntity,
+ IForgeEntity?
+ effectOwner,
+ IForgeEntity? effectSource,
+ int level)
+ {
+ var instanceEffects = new List();
+ if (!_effectInstances.TryAdd(forgeEntity, instanceEffects))
+ {
+ instanceEffects = _effectInstances[forgeEntity];
+ }
+
+ var effectOwnership = new EffectOwnership(effectOwner, effectSource);
+
+ foreach (EffectData effectData in _effects)
+ {
+ var key = new EffectKey(effectData, effectOwnership, level);
+
+ ActiveEffectHandle? handle;
+ if (_effectsCache.TryGetValue(key, out Effect? cachedEffect))
+ {
+ handle = forgeEntity.EffectsManager.ApplyEffect(cachedEffect);
+
+ if (handle is null)
+ {
+ continue;
+ }
+
+ instanceEffects.Add(handle);
+
+ continue;
+ }
+
+ var effect = new Effect(
+ effectData,
+ new EffectOwnership(effectOwner, effectSource),
+ level);
+
+ handle = forgeEntity.EffectsManager.ApplyEffect(effect);
+
+ if (handle is null)
+ {
+ continue;
+ }
+
+ instanceEffects.Add(handle);
+ }
+ }
+
+ private void AddEffects(
+ IForgeEntity forgeEntity,
+ TData contextData,
+ IForgeEntity? effectOwner,
+ IForgeEntity? effectSource,
+ int level)
+ {
+ var instanceEffects = new List();
+ if (!_effectInstances.TryAdd(forgeEntity, instanceEffects))
+ {
+ instanceEffects = _effectInstances[forgeEntity];
+ }
+
+ var effectOwnership = new EffectOwnership(effectOwner, effectSource);
+
+ foreach (EffectData effectData in _effects)
+ {
+ var key = new EffectKey(effectData, effectOwnership, level);
+
+ ActiveEffectHandle? handle;
+ if (_effectsCache.TryGetValue(key, out Effect? cachedEffect))
+ {
+ handle = forgeEntity.EffectsManager.ApplyEffect(cachedEffect);
+
+ if (handle is null)
+ {
+ continue;
+ }
+
+ instanceEffects.Add(handle);
+
+ continue;
+ }
+
+ var effect = new Effect(
+ effectData,
+ new EffectOwnership(effectOwner, effectSource),
+ level);
+
+ handle = forgeEntity.EffectsManager.ApplyEffect(effect, contextData);
+
+ if (handle is null)
+ {
+ continue;
+ }
+
+ instanceEffects.Add(handle);
+ }
+ }
+
+ private void RemoveEffects(IForgeEntity forgeEntity)
+ {
+ if (!_effectInstances.TryGetValue(forgeEntity, out List? value))
+ {
+ return;
+ }
+
+ foreach (ActiveEffectHandle handle in value)
+ {
+ forgeEntity.EffectsManager.RemoveEffect(handle);
+ }
+
+ _effectInstances[forgeEntity] = [];
+ }
+}
diff --git a/addons/forge/core/EffectApplier.cs.uid b/addons/forge/core/EffectApplier.cs.uid
new file mode 100644
index 00000000..53a44f26
--- /dev/null
+++ b/addons/forge/core/EffectApplier.cs.uid
@@ -0,0 +1 @@
+uid://bs52uo5esaiu2
diff --git a/addons/forge/core/ForgeBootstrap.cs b/addons/forge/core/ForgeBootstrap.cs
new file mode 100644
index 00000000..eb2a52c7
--- /dev/null
+++ b/addons/forge/core/ForgeBootstrap.cs
@@ -0,0 +1,14 @@
+// Copyright © Gamesmiths Guild.
+
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Core;
+
+public partial class ForgeBootstrap : Node
+{
+ public override void _Ready()
+ {
+ ForgeData pluginData = ResourceLoader.Load("uid://8j4xg16o3qnl");
+ _ = new ForgeManagers(pluginData);
+ }
+}
diff --git a/addons/forge/core/ForgeBootstrap.cs.uid b/addons/forge/core/ForgeBootstrap.cs.uid
new file mode 100644
index 00000000..7b9668ce
--- /dev/null
+++ b/addons/forge/core/ForgeBootstrap.cs.uid
@@ -0,0 +1 @@
+uid://ba8fquhtwu5mu
diff --git a/addons/forge/core/ForgeCurve.cs b/addons/forge/core/ForgeCurve.cs
new file mode 100644
index 00000000..cf18d287
--- /dev/null
+++ b/addons/forge/core/ForgeCurve.cs
@@ -0,0 +1,21 @@
+// Copyright © Gamesmiths Guild.
+
+using Gamesmiths.Forge.Core;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Core;
+
+public readonly struct ForgeCurve(Curve? curve) : ICurve
+{
+ private readonly Curve? _curve = curve;
+
+ public float Evaluate(float value)
+ {
+ if (_curve is null)
+ {
+ return 1;
+ }
+
+ return _curve.Sample(value);
+ }
+}
diff --git a/addons/forge/core/ForgeCurve.cs.uid b/addons/forge/core/ForgeCurve.cs.uid
new file mode 100644
index 00000000..85910f89
--- /dev/null
+++ b/addons/forge/core/ForgeCurve.cs.uid
@@ -0,0 +1 @@
+uid://cei2cgvy84iy6
diff --git a/addons/forge/core/ForgeData.cs b/addons/forge/core/ForgeData.cs
new file mode 100644
index 00000000..7b8261db
--- /dev/null
+++ b/addons/forge/core/ForgeData.cs
@@ -0,0 +1,13 @@
+// Copyright © Gamesmiths Guild.
+
+using Godot;
+using Godot.Collections;
+
+namespace Gamesmiths.Forge.Godot.Core;
+
+[Tool]
+public partial class ForgeData : Resource
+{
+ [Export]
+ public Array RegisteredTags { get; set; } = [];
+}
diff --git a/addons/forge/core/ForgeData.cs.uid b/addons/forge/core/ForgeData.cs.uid
new file mode 100644
index 00000000..1c5e412c
--- /dev/null
+++ b/addons/forge/core/ForgeData.cs.uid
@@ -0,0 +1 @@
+uid://bq4vlbfx00hea
diff --git a/addons/forge/core/ForgeManagers.cs b/addons/forge/core/ForgeManagers.cs
new file mode 100644
index 00000000..c13cf167
--- /dev/null
+++ b/addons/forge/core/ForgeManagers.cs
@@ -0,0 +1,30 @@
+// Copyright © Gamesmiths Guild.
+
+using Gamesmiths.Forge.Core;
+using Gamesmiths.Forge.Cues;
+using Gamesmiths.Forge.Tags;
+
+namespace Gamesmiths.Forge.Godot.Core;
+
+public class ForgeManagers
+{
+ public static ForgeManagers Instance { get; private set; } = null!;
+
+ public TagsManager TagsManager { get; private set; }
+
+ public CuesManager CuesManager { get; private set; }
+
+ public ForgeManagers(ForgeData pluginData)
+ {
+ Instance = this;
+
+#if DEBUG
+ Validation.Enabled = true;
+#else
+ Validation.Enabled = false;
+#endif
+
+ TagsManager = new TagsManager([.. pluginData.RegisteredTags]);
+ CuesManager = new CuesManager();
+ }
+}
diff --git a/addons/forge/core/ForgeManagers.cs.uid b/addons/forge/core/ForgeManagers.cs.uid
new file mode 100644
index 00000000..305cabac
--- /dev/null
+++ b/addons/forge/core/ForgeManagers.cs.uid
@@ -0,0 +1 @@
+uid://djeiinm1gclh4
diff --git a/addons/forge/core/ForgeRandom.cs b/addons/forge/core/ForgeRandom.cs
new file mode 100644
index 00000000..08ee3d6d
--- /dev/null
+++ b/addons/forge/core/ForgeRandom.cs
@@ -0,0 +1,98 @@
+// Copyright © Gamesmiths Guild.
+
+using System;
+using Gamesmiths.Forge.Core;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Core;
+
+public class ForgeRandom : IRandom, IDisposable
+{
+ private readonly RandomNumberGenerator _randomNumberGenerator;
+
+ public ForgeRandom()
+ {
+ _randomNumberGenerator = new RandomNumberGenerator();
+ _randomNumberGenerator.Randomize();
+ }
+
+ public void NextBytes(byte[] buffer)
+ {
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ buffer[i] = (byte)_randomNumberGenerator.RandiRange(0, 255);
+ }
+ }
+
+ public void NextBytes(Span buffer)
+ {
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ buffer[i] = (byte)_randomNumberGenerator.RandiRange(0, 255);
+ }
+ }
+
+ public double NextDouble()
+ {
+ return _randomNumberGenerator.Randf();
+ }
+
+ public int NextInt()
+ {
+ return (int)_randomNumberGenerator.Randi();
+ }
+
+ public int NextInt(int maxValue)
+ {
+ return _randomNumberGenerator.RandiRange(0, maxValue - 1);
+ }
+
+ public int NextInt(int minValue, int maxValue)
+ {
+ return _randomNumberGenerator.RandiRange(minValue, maxValue - 1);
+ }
+
+ public long NextInt64()
+ {
+ unchecked
+ {
+ var high = _randomNumberGenerator.Randi();
+ var low = _randomNumberGenerator.Randi();
+ return ((long)high << 32) | low;
+ }
+ }
+
+ public long NextInt64(long maxValue)
+ {
+ return NextInt64(0, maxValue);
+ }
+
+ public long NextInt64(long minValue, long maxValue)
+ {
+ if (minValue >= maxValue)
+ {
+ throw new ArgumentOutOfRangeException(nameof(minValue), "minValue must be less than maxValue.");
+ }
+
+ var range = (ulong)(maxValue - minValue);
+ var rand = (ulong)NextInt64();
+
+ return (long)(rand % range) + minValue;
+ }
+
+ public float NextSingle()
+ {
+ return _randomNumberGenerator.Randf();
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ _randomNumberGenerator.Dispose();
+ }
+}
diff --git a/addons/forge/core/ForgeRandom.cs.uid b/addons/forge/core/ForgeRandom.cs.uid
new file mode 100644
index 00000000..59d70c12
--- /dev/null
+++ b/addons/forge/core/ForgeRandom.cs.uid
@@ -0,0 +1 @@
+uid://dap6x2ddf6baj
diff --git a/addons/forge/core/forge_data.tres b/addons/forge/core/forge_data.tres
new file mode 100644
index 00000000..7671d561
--- /dev/null
+++ b/addons/forge/core/forge_data.tres
@@ -0,0 +1,7 @@
+[gd_resource type="Resource" load_steps=2 format=3 uid="uid://8j4xg16o3qnl"]
+
+[ext_resource type="Script" uid="uid://bq4vlbfx00hea" path="res://addons/forge/core/ForgeData.cs" id="1_x0pne"]
+
+[resource]
+script = ExtResource("1_x0pne")
+RegisteredTags = Array[String](["effect.fire", "effect.wet", "cue.floating.text", "cue.vfx.fire", "cue.vfx.wet", "cue.vfx.regen", "cooldown.enemy.attack", "set_by_caller.damage", "event.damage", "cooldown", "cooldown.skill.projectile", "cooldown.skill.shield", "cooldown.skill.dash", "movement.block", "immunity.damage", "effect.mana_shield", "cue.vfx.shield", "event.damage.taken", "event.damage.dealt", "event", "set_by_caller", "trait.flammable", "trait.healable", "trait.damageable", "trait.wettable", "cue.vfx.reflect", "cue.vfx", "cooldown.skill", "cooldown.skill.reflect"])
diff --git a/addons/forge/editor/AssetRepairTool.cs b/addons/forge/editor/AssetRepairTool.cs
new file mode 100644
index 00000000..46682f3d
--- /dev/null
+++ b/addons/forge/editor/AssetRepairTool.cs
@@ -0,0 +1,210 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Gamesmiths.Forge.Godot.Core;
+using Gamesmiths.Forge.Godot.Resources;
+using Gamesmiths.Forge.Tags;
+using Godot;
+using Godot.Collections;
+
+namespace Gamesmiths.Forge.Godot.Editor;
+
+[Tool]
+public partial class AssetRepairTool : EditorPlugin
+{
+ public static void RepairAllAssetsTags()
+ {
+ ForgeData pluginData = ResourceLoader.Load("uid://8j4xg16o3qnl");
+ var tagsManager = new TagsManager([.. pluginData.RegisteredTags]);
+
+ List scenes = GetScenePaths("res://");
+ GD.Print($"Found {scenes.Count} scene(s) to process.");
+
+ var openedScenes = EditorInterface.Singleton.GetOpenScenes();
+
+ foreach (var originalScenePath in scenes)
+ {
+ // For some weird reason scenes from the GetScenePath are coming with 3 slashes instead of just two.
+ var scenePath = originalScenePath.Replace("res:///", "res://");
+
+ GD.Print($"Processing scene: {scenePath}.");
+ PackedScene? packedScene = ResourceLoader.Load(scenePath);
+
+ if (packedScene is null)
+ {
+ GD.PrintErr($"Failed to load scene: {scenePath}.");
+ continue;
+ }
+
+ Node sceneInstance = packedScene.Instantiate();
+ var modified = ProcessNode(sceneInstance, tagsManager);
+
+ if (!modified)
+ {
+ GD.Print($"No changes needed for {scenePath}.");
+ continue;
+ }
+
+ // 'sceneInstance' is the modified scene instance in memory, need to save to disk and reload if needed.
+ var newScene = new PackedScene();
+ Error error = newScene.Pack(sceneInstance);
+ if (error != Error.Ok)
+ {
+ GD.PrintErr($"Failed to pack scene: {error}.");
+ continue;
+ }
+
+ error = ResourceSaver.Save(newScene, scenePath);
+ if (error != Error.Ok)
+ {
+ GD.PrintErr($"Failed to save scene: {error}.");
+ continue;
+ }
+
+ if (openedScenes.Contains(scenePath))
+ {
+ GD.Print($"Scene was opened, reloading background scene: {scenePath}.");
+ EditorInterface.Singleton.ReloadSceneFromPath(scenePath);
+ }
+ }
+ }
+
+ ///
+ /// Recursively get scene files from a folder.
+ ///
+ /// Current path iteration.
+ /// List of scenes found.
+ private static List GetScenePaths(string basePath)
+ {
+ var scenePaths = new List();
+ var dir = DirAccess.Open(basePath);
+
+ if (dir is null)
+ {
+ GD.PrintErr($"Failed to open directory: {basePath}");
+ return scenePaths;
+ }
+
+ // Start listing directory entries; skip navigational and hidden files.
+ dir.ListDirBegin();
+ while (true)
+ {
+ var fileName = dir.GetNext();
+ if (string.IsNullOrEmpty(fileName))
+ {
+ break;
+ }
+
+ var filePath = $"{basePath}/{fileName}";
+ if (dir.CurrentIsDir())
+ {
+ // Recursively scan subdirectories.
+ scenePaths.AddRange(GetScenePaths(filePath));
+ }
+ else if (fileName.EndsWith(".tscn", StringComparison.InvariantCultureIgnoreCase)
+ || fileName.EndsWith(".scn", StringComparison.InvariantCultureIgnoreCase))
+ {
+ scenePaths.Add(filePath);
+ }
+ }
+
+ dir.ListDirEnd();
+ return scenePaths;
+ }
+
+ ///
+ /// Recursively process nodes; returns true if any ForgeEntity was modified.
+ ///
+ /// Current node iteration.
+ /// The tags manager used to validate tags.
+ /// if any ForgeEntity was modified.
+ private static bool ProcessNode(Node node, TagsManager tagsManager)
+ {
+ var modified = ValidateNode(node, tagsManager);
+
+ foreach (Node child in node.GetChildren())
+ {
+ modified |= ProcessNode(child, tagsManager);
+ }
+
+ return modified;
+ }
+
+ private static bool ValidateNode(Node node, TagsManager tagsManager)
+ {
+ var modified = false;
+ foreach (Dictionary propertyInfo in node.GetPropertyList())
+ {
+ if (!propertyInfo.TryGetValue("class_name", out Variant className))
+ {
+ continue;
+ }
+
+ if (className.AsString() != "TagContainer")
+ {
+ continue;
+ }
+
+ if (!propertyInfo.TryGetValue("name", out Variant nameObj))
+ {
+ continue;
+ }
+
+ var propertyName = nameObj.AsString();
+ Variant value = node.Get(propertyName);
+
+ if (value.VariantType != Variant.Type.Object)
+ {
+ continue;
+ }
+
+ if (value.As() is ForgeTagContainer tagContainer)
+ {
+ modified |= ValidateTagContainerProperty(tagContainer, node.Name, tagsManager);
+ }
+ }
+
+ return modified;
+ }
+
+ private static bool ValidateTagContainerProperty(
+ ForgeTagContainer container,
+ string nodeName,
+ TagsManager tagsManager)
+ {
+ if (container.ContainerTags is null)
+ {
+ return false;
+ }
+
+ Array originalTags = container.ContainerTags;
+ var newTags = new Array();
+ var modified = false;
+
+ foreach (var tag in originalTags)
+ {
+ try
+ {
+ Tag.RequestTag(tagsManager, tag);
+ newTags.Add(tag);
+ }
+ catch (TagNotRegisteredException)
+ {
+ GD.PrintRich(
+ $"[color=LIGHT_STEEL_BLUE][RepairTool] Removing invalid tag [{tag}] from node {nodeName}.");
+ modified = true;
+ }
+ }
+
+ if (modified)
+ {
+ container.ContainerTags = newTags;
+ }
+
+ return modified;
+ }
+}
+#endif
diff --git a/addons/forge/editor/AssetRepairTool.cs.uid b/addons/forge/editor/AssetRepairTool.cs.uid
new file mode 100644
index 00000000..90b58b38
--- /dev/null
+++ b/addons/forge/editor/AssetRepairTool.cs.uid
@@ -0,0 +1 @@
+uid://1runivyr5don
diff --git a/addons/forge/editor/EditorUtils.cs b/addons/forge/editor/EditorUtils.cs
new file mode 100644
index 00000000..94cef20d
--- /dev/null
+++ b/addons/forge/editor/EditorUtils.cs
@@ -0,0 +1,62 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Gamesmiths.Forge.Attributes;
+
+namespace Gamesmiths.Forge.Godot.Editor;
+
+internal static class EditorUtils
+{
+ ///
+ /// Uses reflection to gather all classes inheriting from AttributeSet and their fields of type Attribute.
+ ///
+ /// An array with the available attributes.
+ public static string[] GetAttributeSetOptions()
+ {
+ var options = new List();
+
+ // Get all types in the current assembly
+ Type[] allTypes = Assembly.GetExecutingAssembly().GetTypes();
+
+ // Find all types that subclass AttributeSet
+ foreach (Type attributeSetType in allTypes.Where(x => x.IsSubclassOf(typeof(AttributeSet))))
+ {
+ options.Add(attributeSetType.Name);
+ }
+
+ return [.. options];
+ }
+
+ ///
+ /// Uses reflection to gather all classes inheriting from AttributeSet and their fields of type Attribute.
+ ///
+ /// The attribute set used to search for the attributes.
+ /// An array with the available attributes.
+ public static string[] GetAttributeOptions(string? attributeSet)
+ {
+ if (string.IsNullOrEmpty(attributeSet))
+ {
+ return [];
+ }
+
+ var asm = Assembly.GetExecutingAssembly();
+ Type? type = Array.Find(
+ asm.GetTypes(),
+ x => x.IsSubclassOf(typeof(AttributeSet)) && x.Name == attributeSet);
+
+ if (type is null)
+ {
+ return [];
+ }
+
+ IEnumerable properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(x => x.PropertyType == typeof(EntityAttribute));
+
+ return [.. properties.Select(x => $"{x.Name}")];
+ }
+}
+#endif
diff --git a/addons/forge/editor/EditorUtils.cs.uid b/addons/forge/editor/EditorUtils.cs.uid
new file mode 100644
index 00000000..da206c48
--- /dev/null
+++ b/addons/forge/editor/EditorUtils.cs.uid
@@ -0,0 +1 @@
+uid://dcvmf0r1f43m6
diff --git a/addons/forge/editor/ForgeEditor.tscn b/addons/forge/editor/ForgeEditor.tscn
new file mode 100644
index 00000000..d0cf8f15
--- /dev/null
+++ b/addons/forge/editor/ForgeEditor.tscn
@@ -0,0 +1,14 @@
+[gd_scene format=3 uid="uid://pjscvogl6jak"]
+
+[ext_resource type="PackedScene" uid="uid://c17f812by5x23" path="res://addons/forge/editor/tags/TagsEditor.tscn" id="1_bxwfw"]
+
+[node name="Forge" type="PanelContainer" unique_id=249446352]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="Tags" parent="." unique_id=654228508 instance=ExtResource("1_bxwfw")]
+unique_name_in_owner = true
+layout_mode = 2
diff --git a/addons/forge/editor/attributes/AttributeEditorPlugin.cs b/addons/forge/editor/attributes/AttributeEditorPlugin.cs
new file mode 100644
index 00000000..92c32e19
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeEditorPlugin.cs
@@ -0,0 +1,34 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Attributes;
+
+[Tool]
+public partial class AttributeEditorPlugin : EditorInspectorPlugin
+{
+ public override bool _CanHandle(GodotObject @object)
+ {
+ return @object is Resources.ForgeModifier || @object is Resources.ForgeCue;
+ }
+
+ public override bool _ParseProperty(
+ GodotObject @object,
+ Variant.Type type,
+ string name,
+ PropertyHint hintType,
+ string hintString,
+ PropertyUsageFlags usageFlags,
+ bool wide)
+ {
+ if (name == "Attribute" || name == "CapturedAttribute" || name == "MagnitudeAttribute")
+ {
+ AddPropertyEditor(name, new AttributeEditorProperty());
+ return true;
+ }
+
+ return false;
+ }
+}
+#endif
diff --git a/addons/forge/editor/attributes/AttributeEditorPlugin.cs.uid b/addons/forge/editor/attributes/AttributeEditorPlugin.cs.uid
new file mode 100644
index 00000000..20330ba5
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeEditorPlugin.cs.uid
@@ -0,0 +1 @@
+uid://bl2w0vp6b5p8k
diff --git a/addons/forge/editor/attributes/AttributeEditorProperty.cs b/addons/forge/editor/attributes/AttributeEditorProperty.cs
new file mode 100644
index 00000000..dbdf791f
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeEditorProperty.cs
@@ -0,0 +1,101 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Attributes;
+
+[Tool]
+public partial class AttributeEditorProperty : EditorProperty
+{
+ private const int ButtonSize = 26;
+ private const int PopupSize = 300;
+
+ private Label _label = null!;
+
+ public override void _Ready()
+ {
+ Texture2D dropdownIcon = EditorInterface.Singleton
+ .GetEditorTheme()
+ .GetIcon("GuiDropdown", "EditorIcons");
+
+ var hbox = new HBoxContainer();
+ _label = new Label { Text = "None", SizeFlagsHorizontal = SizeFlags.ExpandFill };
+ var button = new Button { Icon = dropdownIcon, CustomMinimumSize = new Vector2(ButtonSize, 0) };
+
+ hbox.AddChild(_label);
+ hbox.AddChild(button);
+ AddChild(hbox);
+
+ var popup = new Popup { Size = new Vector2I(PopupSize, PopupSize) };
+ var tree = new Tree
+ {
+ HideRoot = true,
+ AnchorRight = 1,
+ AnchorBottom = 1,
+ };
+ popup.AddChild(tree);
+
+ var bg = new StyleBoxFlat
+ {
+ BgColor = EditorInterface.Singleton
+ .GetEditorTheme()
+ .GetColor("dark_color_2", "Editor"),
+ };
+ tree.AddThemeStyleboxOverride("panel", bg);
+
+ AddChild(popup);
+
+ BuildAttributeTree(tree);
+
+ button.Pressed += () =>
+ {
+ Window win = GetWindow();
+ popup.Position = (Vector2I)button.GlobalPosition
+ + win.Position
+ - new Vector2I(PopupSize - ButtonSize, -30);
+ popup.Popup();
+ };
+
+ tree.ItemActivated += () =>
+ {
+ TreeItem item = tree.GetSelected();
+ if (item?.HasMeta("attribute_path") != true)
+ {
+ return;
+ }
+
+ var fullPath = item.GetMeta("attribute_path").AsString();
+ _label.Text = fullPath;
+ EmitChanged(GetEditedProperty(), fullPath);
+ popup.Hide();
+ };
+ }
+
+ public override void _UpdateProperty()
+ {
+ var value = GetEditedObject().Get(GetEditedProperty()).AsString();
+ _label.Text = string.IsNullOrEmpty(value) ? "None" : value;
+ }
+
+ private static void BuildAttributeTree(Tree tree)
+ {
+ TreeItem root = tree.CreateItem();
+
+ foreach (var attributeSet in EditorUtils.GetAttributeSetOptions())
+ {
+ TreeItem setItem = tree.CreateItem(root);
+ setItem.SetText(0, attributeSet);
+ setItem.Collapsed = true;
+
+ foreach (var attribute in EditorUtils.GetAttributeOptions(attributeSet))
+ {
+ TreeItem attributeItem = tree.CreateItem(setItem);
+ var attributePath = $"{attributeSet}.{attribute}";
+ attributeItem.SetText(0, attribute);
+ attributeItem.SetMeta("attribute_path", attributePath);
+ }
+ }
+ }
+}
+#endif
diff --git a/addons/forge/editor/attributes/AttributeEditorProperty.cs.uid b/addons/forge/editor/attributes/AttributeEditorProperty.cs.uid
new file mode 100644
index 00000000..b4186ac3
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeEditorProperty.cs.uid
@@ -0,0 +1 @@
+uid://dvjqj637kfav
diff --git a/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs b/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs
new file mode 100644
index 00000000..fbda0d75
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs
@@ -0,0 +1,74 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System;
+using System.Linq;
+using System.Reflection;
+using Gamesmiths.Forge.Attributes;
+using Gamesmiths.Forge.Godot.Nodes;
+using Godot;
+using Godot.Collections;
+
+namespace Gamesmiths.Forge.Godot.Editor.Attributes;
+
+[Tool]
+public partial class AttributeSetClassEditorProperty : EditorProperty
+{
+ private OptionButton _optionButton = null!;
+
+ public override void _Ready()
+ {
+ _optionButton = new OptionButton();
+ AddChild(_optionButton);
+
+ _optionButton.AddItem("Select AttributeSet Class");
+ foreach (var option in EditorUtils.GetAttributeSetOptions())
+ {
+ _optionButton.AddItem(option);
+ }
+
+ _optionButton.ItemSelected += x =>
+ {
+ var className = _optionButton.GetItemText((int)x);
+ EmitChanged(GetEditedProperty(), className);
+
+ GodotObject obj = GetEditedObject();
+ if (obj is not null)
+ {
+ var dict = new Dictionary();
+
+ var assembly = Assembly.GetAssembly(typeof(ForgeAttributeSet));
+ Type? targetType = System.Array.Find(assembly?.GetTypes() ?? [], x => x.Name == className);
+ if (targetType is not null)
+ {
+ System.Collections.Generic.IEnumerable attrProps = targetType
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(x => x.PropertyType == typeof(EntityAttribute));
+
+ foreach (PropertyInfo? pi in attrProps)
+ {
+ dict[pi.Name] = new AttributeValues(0, 0, int.MaxValue);
+ }
+ }
+
+ EmitChanged("InitialAttributeValues", dict);
+ }
+ };
+ }
+
+ public override void _UpdateProperty()
+ {
+ GodotObject obj = GetEditedObject();
+ StringName property = GetEditedProperty();
+ var val = obj.Get(property).AsString();
+ for (var i = 0; i < _optionButton.GetItemCount(); i++)
+ {
+ if (_optionButton.GetItemText(i) == val)
+ {
+ _optionButton.Selected = i;
+ break;
+ }
+ }
+ }
+}
+#endif
diff --git a/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs.uid b/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs.uid
new file mode 100644
index 00000000..7774a449
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs.uid
@@ -0,0 +1 @@
+uid://bumuxlivyt66b
diff --git a/addons/forge/editor/attributes/AttributeSetInspectorPlugin.cs b/addons/forge/editor/attributes/AttributeSetInspectorPlugin.cs
new file mode 100644
index 00000000..5143465c
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeSetInspectorPlugin.cs
@@ -0,0 +1,43 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using Gamesmiths.Forge.Godot.Nodes;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Attributes;
+
+[Tool]
+public partial class AttributeSetInspectorPlugin : EditorInspectorPlugin
+{
+ private PackedScene? _inspectorScene;
+
+ public override bool _CanHandle(GodotObject @object)
+ {
+ return @object is ForgeAttributeSet;
+ }
+
+ public override bool _ParseProperty(
+ GodotObject @object,
+ Variant.Type type,
+ string name,
+ PropertyHint hintType,
+ string hintString,
+ PropertyUsageFlags usageFlags,
+ bool wide)
+ {
+ if (name == "AttributeSetClass")
+ {
+ AddPropertyEditor(name, new AttributeSetClassEditorProperty());
+ return true;
+ }
+
+ if (name == "InitialAttributeValues")
+ {
+ AddPropertyEditor(name, new AttributeSetValuesEditorProperty());
+ return true;
+ }
+
+ return false;
+ }
+}
+#endif
diff --git a/addons/forge/editor/attributes/AttributeSetInspectorPlugin.cs.uid b/addons/forge/editor/attributes/AttributeSetInspectorPlugin.cs.uid
new file mode 100644
index 00000000..49007f10
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeSetInspectorPlugin.cs.uid
@@ -0,0 +1 @@
+uid://t3gpjlcyqor
diff --git a/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs b/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs
new file mode 100644
index 00000000..f8a61ec9
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs
@@ -0,0 +1,171 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Reflection;
+using Gamesmiths.Forge.Attributes;
+using Gamesmiths.Forge.Godot.Nodes;
+using Godot;
+using Godot.Collections;
+
+namespace Gamesmiths.Forge.Godot.Editor.Attributes;
+
+[Tool]
+public partial class AttributeSetValuesEditorProperty : EditorProperty
+{
+ public override void _Ready()
+ {
+ var attributesRoot = new VBoxContainer { Name = "AttributesRoot" };
+ AddChild(attributesRoot);
+ SetBottomEditor(attributesRoot);
+ }
+
+ public override void _UpdateProperty()
+ {
+ VBoxContainer attributesRoot = GetNodeOrNull("AttributesRoot");
+
+ if (attributesRoot is null)
+ {
+ return;
+ }
+
+ FreeAllChildren(attributesRoot);
+
+ if (GetEditedObject() is not ForgeAttributeSet obj
+ || string.IsNullOrEmpty(obj.AttributeSetClass)
+ || obj.InitialAttributeValues is null)
+ {
+ return;
+ }
+
+ var className = obj.AttributeSetClass;
+ var assembly = Assembly.GetAssembly(typeof(ForgeAttributeSet));
+ Type? targetType = System.Array.Find(assembly?.GetTypes() ?? [], x => x.Name == className);
+
+ if (targetType is null)
+ {
+ return;
+ }
+
+ System.Collections.Generic.IEnumerable attributeProperties = targetType
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(x => x.PropertyType == typeof(EntityAttribute));
+
+ foreach (var attributeName in attributeProperties.Select(x => x.Name))
+ {
+ var groupVBox = new VBoxContainer();
+
+ groupVBox.AddChild(AttributeHeader(attributeName));
+
+ AttributeValues value = obj.InitialAttributeValues.TryGetValue(attributeName, out AttributeValues? v)
+ ? v
+ : new AttributeValues(0, 0, int.MaxValue);
+
+ SpinBox spinDefault = CreateSpinBox(value.Min, value.Max, value.Default);
+ SpinBox spinMin = CreateSpinBox(int.MinValue, value.Max, value.Min);
+ SpinBox spinMax = CreateSpinBox(value.Min, int.MaxValue, value.Max);
+
+ groupVBox.AddChild(AttributeFieldRow("Default", spinDefault));
+ groupVBox.AddChild(AttributeFieldRow("Min", spinMin));
+ groupVBox.AddChild(AttributeFieldRow("Max", spinMax));
+
+ spinDefault.ValueChanged += x =>
+ {
+ UpdateAndEmit(obj, attributeName, (int)x, (int)spinMin.Value, (int)spinMax.Value);
+ };
+
+ spinMin.ValueChanged += x =>
+ {
+ spinDefault.MinValue = x;
+ spinMax.MinValue = x;
+ UpdateAndEmit(obj, attributeName, (int)spinDefault.Value, (int)x, (int)spinMax.Value);
+ };
+
+ spinMax.ValueChanged += x =>
+ {
+ spinDefault.MaxValue = x;
+ spinMin.MaxValue = x;
+ UpdateAndEmit(obj, attributeName, (int)spinDefault.Value, (int)spinMin.Value, (int)x);
+ };
+
+ attributesRoot.AddChild(groupVBox);
+ }
+ }
+
+ private static PanelContainer AttributeHeader(string text)
+ {
+ var headerPanel = new PanelContainer
+ {
+ CustomMinimumSize = new Vector2(0, 28),
+ };
+
+ var style = new StyleBoxFlat
+ {
+ BgColor = new Color(0.16f, 0.17f, 0.20f),
+ };
+
+ headerPanel.AddThemeStyleboxOverride("panel", style);
+
+ var label = new Label
+ {
+ Text = text,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ SizeFlagsHorizontal = (SizeFlags)(int)SizeFlags.ExpandFill,
+ CustomMinimumSize = new Vector2(0, 22),
+ AutowrapMode = TextServer.AutowrapMode.Off,
+ };
+
+ headerPanel.AddChild(label);
+ return headerPanel;
+ }
+
+ private static HBoxContainer AttributeFieldRow(string label, SpinBox spinBox)
+ {
+ var hbox = new HBoxContainer();
+
+ hbox.AddChild(new Label
+ {
+ Text = label,
+ CustomMinimumSize = new Vector2(80, 0),
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ });
+
+ hbox.AddChild(spinBox);
+ return hbox;
+ }
+
+ private static SpinBox CreateSpinBox(int min, int max, int value)
+ {
+ return new SpinBox
+ {
+ MinValue = min,
+ MaxValue = max,
+ Value = value,
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ };
+ }
+
+ private static void FreeAllChildren(Node node)
+ {
+ for (var i = node.GetChildCount() - 1; i >= 0; i--)
+ {
+ node.GetChild(i).QueueFree();
+ }
+ }
+
+ private void UpdateAndEmit(ForgeAttributeSet obj, string name, int def, int min, int max)
+ {
+ Debug.Assert(obj.InitialAttributeValues is not null, "InitialAttributeValues should not be null here.");
+
+ var dict = new Dictionary(obj.InitialAttributeValues)
+ {
+ [name] = new AttributeValues(def, min, max),
+ };
+
+ EmitChanged(nameof(ForgeAttributeSet.InitialAttributeValues), dict);
+ }
+}
+#endif
diff --git a/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs.uid b/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs.uid
new file mode 100644
index 00000000..b38d71d8
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs.uid
@@ -0,0 +1 @@
+uid://cdj20gbpxkda1
diff --git a/addons/forge/editor/attributes/AttributeValues.cs b/addons/forge/editor/attributes/AttributeValues.cs
new file mode 100644
index 00000000..6944827a
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeValues.cs
@@ -0,0 +1,29 @@
+// Copyright © Gamesmiths Guild.
+
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Attributes;
+
+[Tool]
+public partial class AttributeValues : Resource
+{
+ [Export]
+ public int Default { get; set; }
+
+ [Export]
+ public int Min { get; set; }
+
+ [Export]
+ public int Max { get; set; }
+
+ public AttributeValues()
+ {
+ }
+
+ public AttributeValues(int @default, int min, int max)
+ {
+ Default = @default;
+ Min = min;
+ Max = max;
+ }
+}
diff --git a/addons/forge/editor/attributes/AttributeValues.cs.uid b/addons/forge/editor/attributes/AttributeValues.cs.uid
new file mode 100644
index 00000000..7a4fc1c5
--- /dev/null
+++ b/addons/forge/editor/attributes/AttributeValues.cs.uid
@@ -0,0 +1 @@
+uid://ccovd5i0wr3kk
diff --git a/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs b/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs
new file mode 100644
index 00000000..246e2f65
--- /dev/null
+++ b/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs
@@ -0,0 +1,53 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System;
+using Gamesmiths.Forge.Godot.Nodes;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Cues;
+
+[Tool]
+public partial class CueHandlerInspectorPlugin : EditorInspectorPlugin
+{
+ public override bool _CanHandle(GodotObject @object)
+ {
+ // Find out if its an implementation of CueHandler without having to add [Tool] attribute to them.
+ if (@object?.GetScript().As() is CSharpScript script)
+ {
+ StringName className = script.GetGlobalName();
+
+ Type baseType = typeof(ForgeCueHandler);
+ System.Reflection.Assembly assembly = baseType.Assembly;
+
+ Type? implementationType =
+ Array.Find(assembly.GetTypes(), x =>
+ x.Name == className &&
+ baseType.IsAssignableFrom(x));
+
+ return implementationType is not null;
+ }
+
+ return false;
+ }
+
+ public override bool _ParseProperty(
+ GodotObject @object,
+ Variant.Type type,
+ string name,
+ PropertyHint hintType,
+ string hintString,
+ PropertyUsageFlags usageFlags,
+ bool wide)
+ {
+ if (name == "CueTag")
+ {
+ var cueKeyEditorProperty = new CueKeyEditorProperty();
+ AddPropertyEditor(name, cueKeyEditorProperty);
+ return true;
+ }
+
+ return base._ParseProperty(@object, type, name, hintType, hintString, usageFlags, wide);
+ }
+}
+#endif
diff --git a/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs.uid b/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs.uid
new file mode 100644
index 00000000..4e32dad0
--- /dev/null
+++ b/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs.uid
@@ -0,0 +1 @@
+uid://dattkelp87mhv
diff --git a/addons/forge/editor/cues/CueKeyEditorProperty.cs b/addons/forge/editor/cues/CueKeyEditorProperty.cs
new file mode 100644
index 00000000..8681f14c
--- /dev/null
+++ b/addons/forge/editor/cues/CueKeyEditorProperty.cs
@@ -0,0 +1,106 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System.Collections.Generic;
+using Gamesmiths.Forge.Godot.Core;
+using Gamesmiths.Forge.Tags;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Cues;
+
+[Tool]
+public partial class CueKeyEditorProperty : EditorProperty
+{
+ private const int ButtonSize = 26;
+ private const int PopupSize = 300;
+
+ private Label _label = null!;
+
+ public override void _Ready()
+ {
+ Texture2D dropdownIcon = EditorInterface.Singleton
+ .GetEditorTheme()
+ .GetIcon("GuiDropdown", "EditorIcons");
+
+ var hbox = new HBoxContainer();
+ _label = new Label { Text = "None", SizeFlagsHorizontal = SizeFlags.ExpandFill };
+ var button = new Button { Icon = dropdownIcon, CustomMinimumSize = new Vector2(ButtonSize, 0) };
+
+ hbox.AddChild(_label);
+ hbox.AddChild(button);
+ AddChild(hbox);
+
+ var popup = new Popup { Size = new Vector2I(PopupSize, PopupSize) };
+ var tree = new Tree
+ {
+ HideRoot = true,
+ AnchorRight = 1,
+ AnchorBottom = 1,
+ };
+ popup.AddChild(tree);
+
+ var backgroundStyle = new StyleBoxFlat
+ {
+ BgColor = EditorInterface.Singleton.GetEditorTheme().GetColor("base_color", "Editor"),
+ };
+ tree.AddThemeStyleboxOverride("panel", backgroundStyle);
+
+ AddChild(popup);
+
+ ForgeData pluginData = ResourceLoader.Load("uid://8j4xg16o3qnl");
+ var tagsManager = new TagsManager([.. pluginData.RegisteredTags]);
+ TreeItem root = tree.CreateItem();
+ BuildTreeRecursively(tree, root, tagsManager.RootNode);
+
+ button.Pressed += () =>
+ {
+ Window win = GetWindow();
+ popup.Position = (Vector2I)button.GlobalPosition
+ + win.Position
+ - new Vector2I(PopupSize - ButtonSize, -30);
+ popup.Popup();
+ };
+
+ tree.ItemActivated += () =>
+ {
+ TreeItem item = tree.GetSelected();
+ if (item is null)
+ {
+ return;
+ }
+
+ // Build full path from root.
+ var segments = new List();
+ TreeItem current = item;
+ while (current.GetParent() is not null)
+ {
+ segments.Insert(0, current.GetText(0));
+ current = current.GetParent();
+ }
+
+ var fullPath = string.Join(".", segments);
+
+ _label.Text = fullPath;
+ EmitChanged(GetEditedProperty(), fullPath);
+ popup.Hide();
+ };
+ }
+
+ public override void _UpdateProperty()
+ {
+ var property = GetEditedObject().Get(GetEditedProperty()).AsString();
+ _label.Text = string.IsNullOrEmpty(property) ? "None" : property;
+ }
+
+ private static void BuildTreeRecursively(Tree tree, TreeItem currentTreeItem, TagNode currentNode)
+ {
+ foreach (TagNode childTagNode in currentNode.ChildTags)
+ {
+ TreeItem childTreeNode = tree.CreateItem(currentTreeItem);
+ childTreeNode.SetText(0, childTagNode.TagKey);
+ childTreeNode.Collapsed = true;
+ BuildTreeRecursively(tree, childTreeNode, childTagNode);
+ }
+ }
+}
+#endif
diff --git a/addons/forge/editor/cues/CueKeyEditorProperty.cs.uid b/addons/forge/editor/cues/CueKeyEditorProperty.cs.uid
new file mode 100644
index 00000000..cfe15a10
--- /dev/null
+++ b/addons/forge/editor/cues/CueKeyEditorProperty.cs.uid
@@ -0,0 +1 @@
+uid://csmr2puffid4k
diff --git a/addons/forge/editor/cues/CueKeysEditor.cs.uid b/addons/forge/editor/cues/CueKeysEditor.cs.uid
new file mode 100644
index 00000000..10774bc0
--- /dev/null
+++ b/addons/forge/editor/cues/CueKeysEditor.cs.uid
@@ -0,0 +1 @@
+uid://dnsy7p8h1ujjv
diff --git a/addons/forge/editor/tags/TagContainerEditorProperty.cs b/addons/forge/editor/tags/TagContainerEditorProperty.cs
new file mode 100644
index 00000000..e2ab253f
--- /dev/null
+++ b/addons/forge/editor/tags/TagContainerEditorProperty.cs
@@ -0,0 +1,160 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System.Collections.Generic;
+using Gamesmiths.Forge.Godot.Core;
+using Gamesmiths.Forge.Tags;
+using Godot;
+
+using GodotStringArray = Godot.Collections.Array;
+
+namespace Gamesmiths.Forge.Godot.Editor.Tags;
+
+[Tool]
+public partial class TagContainerEditorProperty : EditorProperty
+{
+ private readonly Dictionary _treeItemToNode = [];
+
+ private VBoxContainer _root = null!;
+ private Button _containerButton = null!;
+ private ScrollContainer _scroll = null!;
+ private Tree _tree = null!;
+
+ private Texture2D _checkedIcon = null!;
+ private Texture2D _uncheckedIcon = null!;
+
+ private GodotStringArray _currentValue = [];
+
+ public override void _Ready()
+ {
+ _root = new VBoxContainer
+ {
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ };
+
+ _containerButton = new Button
+ {
+ ToggleMode = true,
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ };
+ _containerButton.Toggled += OnToggled;
+
+ _scroll = new ScrollContainer
+ {
+ Visible = false,
+ CustomMinimumSize = new Vector2(0, 220),
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ SizeFlagsVertical = SizeFlags.ExpandFill,
+ };
+
+ _tree = new Tree
+ {
+ HideRoot = true,
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ SizeFlagsVertical = SizeFlags.ExpandFill,
+ };
+
+ _scroll.AddChild(_tree);
+
+ _root.AddChild(_containerButton);
+ _root.AddChild(_scroll);
+
+ AddChild(_root);
+ SetBottomEditor(_root);
+
+ _checkedIcon = EditorInterface.Singleton
+ .GetEditorTheme()
+ .GetIcon("GuiChecked", "EditorIcons");
+
+ _uncheckedIcon = EditorInterface.Singleton
+ .GetEditorTheme()
+ .GetIcon("GuiUnchecked", "EditorIcons");
+
+ _tree.ButtonClicked += OnTreeButtonClicked;
+ }
+
+ public override void _UpdateProperty()
+ {
+ GodotObject obj = GetEditedObject();
+ string propertyName = GetEditedProperty();
+
+ _currentValue =
+ obj.Get(propertyName).AsGodotArray() ?? [];
+
+ RebuildTree();
+ }
+
+ private void RebuildTree()
+ {
+ _tree.Clear();
+ _treeItemToNode.Clear();
+
+ _containerButton.Text =
+ $"Container (size: {_currentValue.Count})";
+
+ TreeItem root = _tree.CreateItem();
+
+ ForgeData forgePluginData =
+ ResourceLoader.Load("uid://8j4xg16o3qnl");
+
+ var tagsManager =
+ new TagsManager([.. forgePluginData.RegisteredTags]);
+
+ BuildTreeRecursive(root, tagsManager.RootNode);
+
+ UpdateMinimumSize();
+ NotifyPropertyListChanged();
+ }
+
+ private void BuildTreeRecursive(TreeItem parent, TagNode node)
+ {
+ foreach (TagNode child in node.ChildTags)
+ {
+ TreeItem item = _tree.CreateItem(parent);
+ item.SetText(0, child.TagKey);
+
+ var checkedState =
+ _currentValue.Contains(child.CompleteTagKey);
+
+ item.AddButton(
+ 0,
+ checkedState ? _checkedIcon : _uncheckedIcon);
+
+ _treeItemToNode[item] = child;
+ BuildTreeRecursive(item, child);
+ }
+ }
+
+ private void OnTreeButtonClicked(
+ TreeItem item,
+ long column,
+ long id,
+ long mouseButtonIndex)
+ {
+ if (mouseButtonIndex != 1 || id != 0)
+ {
+ return;
+ }
+
+ string tag = _treeItemToNode[item].CompleteTagKey;
+
+ var newValue = new GodotStringArray();
+ newValue.AddRange(_currentValue);
+
+ if (!newValue.Remove(tag))
+ {
+ newValue.Add(tag);
+ }
+
+ EmitChanged(GetEditedProperty(), newValue);
+ }
+
+ private void OnToggled(bool toggled)
+ {
+ _scroll.Visible = toggled;
+
+ UpdateMinimumSize();
+ NotifyPropertyListChanged();
+ }
+}
+#endif
diff --git a/addons/forge/editor/tags/TagContainerEditorProperty.cs.uid b/addons/forge/editor/tags/TagContainerEditorProperty.cs.uid
new file mode 100644
index 00000000..425a9c28
--- /dev/null
+++ b/addons/forge/editor/tags/TagContainerEditorProperty.cs.uid
@@ -0,0 +1 @@
+uid://dppi5lmv8q5ti
diff --git a/addons/forge/editor/tags/TagContainerInspectorPlugin.cs b/addons/forge/editor/tags/TagContainerInspectorPlugin.cs
new file mode 100644
index 00000000..42a2c831
--- /dev/null
+++ b/addons/forge/editor/tags/TagContainerInspectorPlugin.cs
@@ -0,0 +1,35 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using Gamesmiths.Forge.Godot.Resources;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Tags;
+
+public partial class TagContainerInspectorPlugin : EditorInspectorPlugin
+{
+ public override bool _CanHandle(GodotObject @object)
+ {
+ return @object is ForgeTagContainer;
+ }
+
+ public override bool _ParseProperty(
+ GodotObject @object,
+ Variant.Type type,
+ string name,
+ PropertyHint hintType,
+ string hintString,
+ PropertyUsageFlags usageFlags,
+ bool wide)
+ {
+ if (name != "ContainerTags")
+ {
+ return false;
+ }
+
+ var prop = new TagContainerEditorProperty();
+ AddPropertyEditor(name, prop);
+ return true;
+ }
+}
+#endif
diff --git a/addons/forge/editor/tags/TagContainerInspectorPlugin.cs.uid b/addons/forge/editor/tags/TagContainerInspectorPlugin.cs.uid
new file mode 100644
index 00000000..f69a0aff
--- /dev/null
+++ b/addons/forge/editor/tags/TagContainerInspectorPlugin.cs.uid
@@ -0,0 +1 @@
+uid://8g56j8vs35mn
diff --git a/addons/forge/editor/tags/TagEditorProperty.cs b/addons/forge/editor/tags/TagEditorProperty.cs
new file mode 100644
index 00000000..05edd2de
--- /dev/null
+++ b/addons/forge/editor/tags/TagEditorProperty.cs
@@ -0,0 +1,149 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System.Collections.Generic;
+using Gamesmiths.Forge.Godot.Core;
+using Gamesmiths.Forge.Tags;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Tags;
+
+[Tool]
+public partial class TagEditorProperty : EditorProperty
+{
+ private readonly Dictionary _treeItemToNode = [];
+
+ private VBoxContainer _root = null!;
+ private Button _containerButton = null!;
+ private ScrollContainer _scroll = null!;
+ private Tree _tree = null!;
+
+ private Texture2D _checkedIcon = null!;
+ private Texture2D _uncheckedIcon = null!;
+
+ private string _currentValue = string.Empty;
+
+ public override void _Ready()
+ {
+ _root = new VBoxContainer
+ {
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ };
+
+ _containerButton = new Button
+ {
+ ToggleMode = true,
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ };
+ _containerButton.Toggled += OnToggled;
+
+ _scroll = new ScrollContainer
+ {
+ Visible = false,
+ CustomMinimumSize = new Vector2(0, 220),
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ SizeFlagsVertical = SizeFlags.ExpandFill,
+ };
+
+ _tree = new Tree
+ {
+ HideRoot = true,
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ SizeFlagsVertical = SizeFlags.ExpandFill,
+ };
+
+ _scroll.AddChild(_tree);
+
+ _root.AddChild(_containerButton);
+ _root.AddChild(_scroll);
+
+ AddChild(_root);
+ SetBottomEditor(_root);
+
+ _checkedIcon = EditorInterface.Singleton
+ .GetEditorTheme()
+ .GetIcon("GuiRadioChecked", "EditorIcons");
+
+ _uncheckedIcon = EditorInterface.Singleton
+ .GetEditorTheme()
+ .GetIcon("GuiRadioUnchecked", "EditorIcons");
+
+ _tree.ButtonClicked += OnTreeButtonClicked;
+ }
+
+ public override void _UpdateProperty()
+ {
+ GodotObject obj = GetEditedObject();
+ string propertyName = GetEditedProperty();
+
+ _currentValue = obj.Get(propertyName).AsString();
+ RebuildTree();
+ }
+
+ private void RebuildTree()
+ {
+ _tree.Clear();
+ _treeItemToNode.Clear();
+
+ _containerButton.Text =
+ string.IsNullOrEmpty(_currentValue) ? "(none)" : _currentValue;
+
+ TreeItem root = _tree.CreateItem();
+
+ ForgeData forgePluginData =
+ ResourceLoader.Load("uid://8j4xg16o3qnl");
+
+ var tagsManager =
+ new TagsManager([.. forgePluginData.RegisteredTags]);
+
+ BuildTreeRecursive(root, tagsManager.RootNode);
+
+ UpdateMinimumSize();
+ NotifyPropertyListChanged();
+ }
+
+ private void BuildTreeRecursive(TreeItem parent, TagNode node)
+ {
+ foreach (TagNode child in node.ChildTags)
+ {
+ TreeItem item = _tree.CreateItem(parent);
+ item.SetText(0, child.TagKey);
+
+ var selected = _currentValue == child.CompleteTagKey;
+ item.AddButton(0, selected ? _checkedIcon : _uncheckedIcon);
+
+ _treeItemToNode[item] = child;
+ BuildTreeRecursive(item, child);
+ }
+ }
+
+ private void OnTreeButtonClicked(
+ TreeItem item,
+ long column,
+ long id,
+ long mouseButtonIndex)
+ {
+ if (mouseButtonIndex != 1 || id != 0)
+ {
+ return;
+ }
+
+ string newValue = _treeItemToNode[item].CompleteTagKey;
+
+ if (newValue == _currentValue)
+ {
+ newValue = string.Empty;
+ }
+
+ EmitChanged(GetEditedProperty(), newValue);
+ }
+
+ private void OnToggled(bool toggled)
+ {
+ _scroll.Visible = toggled;
+
+ UpdateMinimumSize();
+ NotifyPropertyListChanged();
+ }
+}
+#endif
diff --git a/addons/forge/editor/tags/TagEditorProperty.cs.uid b/addons/forge/editor/tags/TagEditorProperty.cs.uid
new file mode 100644
index 00000000..62837f96
--- /dev/null
+++ b/addons/forge/editor/tags/TagEditorProperty.cs.uid
@@ -0,0 +1 @@
+uid://bc4vhfbuyp7xd
diff --git a/addons/forge/editor/tags/TagInspectorPlugin.cs b/addons/forge/editor/tags/TagInspectorPlugin.cs
new file mode 100644
index 00000000..a4007849
--- /dev/null
+++ b/addons/forge/editor/tags/TagInspectorPlugin.cs
@@ -0,0 +1,35 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using Gamesmiths.Forge.Godot.Resources;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Tags;
+
+public partial class TagInspectorPlugin : EditorInspectorPlugin
+{
+ public override bool _CanHandle(GodotObject @object)
+ {
+ return @object is ForgeTag;
+ }
+
+ public override bool _ParseProperty(
+ GodotObject @object,
+ Variant.Type type,
+ string name,
+ PropertyHint hintType,
+ string hintString,
+ PropertyUsageFlags usageFlags,
+ bool wide)
+ {
+ if (name != "Tag")
+ {
+ return false;
+ }
+
+ var prop = new TagEditorProperty();
+ AddPropertyEditor(name, prop);
+ return true;
+ }
+}
+#endif
diff --git a/addons/forge/editor/tags/TagInspectorPlugin.cs.uid b/addons/forge/editor/tags/TagInspectorPlugin.cs.uid
new file mode 100644
index 00000000..3daba19c
--- /dev/null
+++ b/addons/forge/editor/tags/TagInspectorPlugin.cs.uid
@@ -0,0 +1 @@
+uid://cx3reriadfsnh
diff --git a/addons/forge/editor/tags/TagsEditor.cs b/addons/forge/editor/tags/TagsEditor.cs
new file mode 100644
index 00000000..e14a9f57
--- /dev/null
+++ b/addons/forge/editor/tags/TagsEditor.cs
@@ -0,0 +1,192 @@
+// Copyright © Gamesmiths Guild.
+
+#if TOOLS
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using Gamesmiths.Forge.Godot.Core;
+using Gamesmiths.Forge.Tags;
+using Godot;
+
+namespace Gamesmiths.Forge.Godot.Editor.Tags;
+
+[Tool]
+public partial class TagsEditor : VBoxContainer, ISerializationListener
+{
+ private readonly Dictionary _treeItemToNode = [];
+
+ private TagsManager _tagsManager = null!;
+
+ private ForgeData? _forgePluginData;
+
+ private Tree? _tree;
+ private LineEdit? _tagNameTextField;
+ private Button? _addTagButton;
+
+ private Texture2D? _addIcon;
+ private Texture2D? _removeIcon;
+
+ public bool IsPluginInstance { get; set; }
+
+ public override void _Ready()
+ {
+ base._Ready();
+
+ if (!IsPluginInstance)
+ {
+ return;
+ }
+
+ _forgePluginData = ResourceLoader.Load("uid://8j4xg16o3qnl");
+ _tagsManager = new TagsManager([.. _forgePluginData.RegisteredTags]);
+
+ _addIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Add", "EditorIcons");
+ _removeIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Remove", "EditorIcons");
+
+ _tree = GetNode("%Tree");
+ _tagNameTextField = GetNode("%TagNameField");
+ _addTagButton = GetNode