diff --git a/.gitignore b/.gitignore
index c301ca8..bb88804 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,37 +1,37 @@
-# Godot 4.x
-.godot/
-.idea/
-sdk/
-bin/
-obj/
-
-#ai
-.omo/
-.opencode/
-
-gen/
-obj/
-bin/
-
-*.tres~
-*.tscn~
-
-# GDScript LSP
-.godot/
-
-# Export
-*.exe
-*.dmg
-*.apk
-*.aab
-*.pck
-*.zip
-*.log
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Editor
-*.import
-*.godot.uid
+# Godot 4.x
+.godot/
+.idea/
+sdk/
+bin/
+obj/
+
+#ai
+.omo/
+.opencode/
+
+gen/
+obj/
+bin/
+
+*.tres~
+*.tscn~
+
+# GDScript LSP
+.godot/
+
+# Export
+*.exe
+*.dmg
+*.apk
+*.aab
+*.pck
+*.zip
+*.log
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Editor
+*.import
+*.godot.uid
diff --git a/manifest.json b/manifest.json
index 7d9d540..b3a3270 100644
--- a/manifest.json
+++ b/manifest.json
@@ -10,4 +10,4 @@
],
"depends": [],
"ui": []
-}
+}
diff --git a/src/Animation/AnimationController.cs b/src/Animation/AnimationController.cs
index 200047e..68947a1 100644
--- a/src/Animation/AnimationController.cs
+++ b/src/Animation/AnimationController.cs
@@ -1,161 +1,161 @@
-using System;
-using System.Collections.Generic;
-using Cthangover.Core.UI.Event;
-using Cthangover.Core.Utils;
-using Godot;
-
-namespace Cthangover.Mods.Effects.Animation
-{
- ///
- /// Frame-by-frame sprite animation via crossfade between two TextureRect nodes.
- /// Rather than using a spritesheet, it keeps a "current" and "next" texture and
- /// lerps their modulate between transparent and opaque, creating smooth transitions.
- /// A built-in wait/waitPercent mechanism holds the "next" frame fully visible for
- /// a configurable interval before advancing, producing a paced flip-book effect.
- /// Exposes NextFrame/NextCycle events so external systems can sequence or react
- /// to animation milestones without polling.
- ///
- public partial class AnimationController : Control, IOnUpdateEvent
- {
-
- [Export] private TextureRect current;
- [Export] private TextureRect next;
- [Export] private Texture2D empty;
-
- [Export] private bool isLoop = true;
- [Export] private float speed = 1f;
- [Export] private float nextFrameSpeed = 0.2f;
-
- /// Fires when the current frame transition completes and the next frame texture is assigned.
- public event Action NextFrame;
- /// Fires when the animation wraps around (looping) or reaches the final frame (non-looping).
- public event Action NextCycle;
-
- [Export] private Color clearColor = new(1f, 1f, 1f, 0f);
- [Export] private Color completeColor = new(1f, 1f, 1f, 1f);
-
- private int currentFrame;
- private double timestamp;
- private float percent;
- private bool wait;
- private float waitPercent;
- private bool isStarted;
-
- /// When true, the animation wraps from the last frame back to the first. When false, playback stops at the final frame.
- public bool IsLoop { get => isLoop; set => isLoop = value; }
- /// Crossfade speed multiplier between frames. Higher values produce faster transitions.
- public float Speed { get => speed; set => speed = value; }
- /// Hold duration multiplier for the "wait" phase after a frame transition completes.
- public float NextFrameSpeed { get => nextFrameSpeed; set => nextFrameSpeed = value; }
- /// Whether playback is currently active. False on startup and after or .
- public bool IsStarted => isStarted;
-
- /// Ordered list of textures to cycle through. Set externally before calling .
- public List FrameSet { get; set; }
-
- /// Resets to idle state: stops playback, clears frame set, and blanks both texture nodes to transparent.
- public void Clear()
- {
- isStarted = false;
- FrameSet = null;
- current.Texture = empty;
- current.Modulate = Colors.Transparent;
- next.Texture = empty;
- next.Modulate = Colors.Transparent;
- }
-
- /// Stops playback without clearing textures, freezing on the current frame.
- public void Pause()
- {
- isStarted = false;
- }
-
- /// Starts or restarts playback from the first frame in .
- public void Play()
- {
- currentFrame = 0;
- isStarted = true;
- timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
-
- current.Texture = empty;
- next.Modulate = clearColor;
- next.Texture = FrameSet[currentFrame];
- }
-
- /// Update priority. Lower numbers run earlier in the frame. Default 1.
- public int Priority => 1;
- /// Advances the crossfade each frame. Handles both the transition phase (lerping modulate) and the wait phase (holding the next frame visible).
- public void OnUpdate()
- {
- if(!isStarted)
- return;
-
- if (Lists.IsEmpty(FrameSet))
- {
- Clear();
- return;
- }
-
- double now = Godot.Time.GetTicksUsec() / 1_000_000.0;
- if (!wait)
- {
- float delta = (float)(now - timestamp) * speed;
- percent = Mathf.Min(percent + delta, 1f);
- current.Modulate = completeColor.Lerp(Colors.Transparent, percent);
- next.Modulate = Colors.Transparent.Lerp(completeColor, percent);
- }
- else
- {
- float delta = (float)(now - timestamp) * nextFrameSpeed;
- waitPercent = Mathf.Min(waitPercent + delta, 1f);
- }
-
- timestamp = now;
-
- if (percent >= 1f && !wait)
- {
- wait = true;
- SwitchNextFrame();
- percent = 0f;
- }
- if (waitPercent >= 1f && wait)
- {
- wait = false;
- waitPercent = 0f;
- }
- }
-
- private void SwitchNextFrame()
- {
- NextFrame?.Invoke();
- if (Lists.IsEmpty(FrameSet))
- {
- Clear();
- return;
- }
-
- var needNextCycle = false;
- currentFrame++;
- if (FrameSet.Count <= currentFrame)
- {
- if (isLoop)
- currentFrame = 0;
- else
- {
- currentFrame--;
- Pause();
- }
- needNextCycle = true;
- }
-
- current.Modulate = completeColor;
- current.Texture = next.Texture;
-
- next.Modulate = clearColor;
- next.Texture = FrameSet[currentFrame];
-
- if(needNextCycle)
- NextCycle?.Invoke();
- }
- }
-}
+using System;
+using System.Collections.Generic;
+using Cthangover.Core.UI.Event;
+using Cthangover.Core.Utils;
+using Godot;
+
+namespace Cthangover.Mods.Effects.Animation
+{
+ ///
+ /// Frame-by-frame sprite animation via crossfade between two TextureRect nodes.
+ /// Rather than using a spritesheet, it keeps a "current" and "next" texture and
+ /// lerps their modulate between transparent and opaque, creating smooth transitions.
+ /// A built-in wait/waitPercent mechanism holds the "next" frame fully visible for
+ /// a configurable interval before advancing, producing a paced flip-book effect.
+ /// Exposes NextFrame/NextCycle events so external systems can sequence or react
+ /// to animation milestones without polling.
+ ///
+ public partial class AnimationController : Control, IOnUpdateEvent
+ {
+
+ [Export] private TextureRect current;
+ [Export] private TextureRect next;
+ [Export] private Texture2D empty;
+
+ [Export] private bool isLoop = true;
+ [Export] private float speed = 1f;
+ [Export] private float nextFrameSpeed = 0.2f;
+
+ /// Fires when the current frame transition completes and the next frame texture is assigned.
+ public event Action NextFrame;
+ /// Fires when the animation wraps around (looping) or reaches the final frame (non-looping).
+ public event Action NextCycle;
+
+ [Export] private Color clearColor = new(1f, 1f, 1f, 0f);
+ [Export] private Color completeColor = new(1f, 1f, 1f, 1f);
+
+ private int currentFrame;
+ private double timestamp;
+ private float percent;
+ private bool wait;
+ private float waitPercent;
+ private bool isStarted;
+
+ /// When true, the animation wraps from the last frame back to the first. When false, playback stops at the final frame.
+ public bool IsLoop { get => isLoop; set => isLoop = value; }
+ /// Crossfade speed multiplier between frames. Higher values produce faster transitions.
+ public float Speed { get => speed; set => speed = value; }
+ /// Hold duration multiplier for the "wait" phase after a frame transition completes.
+ public float NextFrameSpeed { get => nextFrameSpeed; set => nextFrameSpeed = value; }
+ /// Whether playback is currently active. False on startup and after or .
+ public bool IsStarted => isStarted;
+
+ /// Ordered list of textures to cycle through. Set externally before calling .
+ public List FrameSet { get; set; }
+
+ /// Resets to idle state: stops playback, clears frame set, and blanks both texture nodes to transparent.
+ public void Clear()
+ {
+ isStarted = false;
+ FrameSet = null;
+ current.Texture = empty;
+ current.Modulate = Colors.Transparent;
+ next.Texture = empty;
+ next.Modulate = Colors.Transparent;
+ }
+
+ /// Stops playback without clearing textures, freezing on the current frame.
+ public void Pause()
+ {
+ isStarted = false;
+ }
+
+ /// Starts or restarts playback from the first frame in .
+ public void Play()
+ {
+ currentFrame = 0;
+ isStarted = true;
+ timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
+
+ current.Texture = empty;
+ next.Modulate = clearColor;
+ next.Texture = FrameSet[currentFrame];
+ }
+
+ /// Update priority. Lower numbers run earlier in the frame. Default 1.
+ public int Priority => 1;
+ /// Advances the crossfade each frame. Handles both the transition phase (lerping modulate) and the wait phase (holding the next frame visible).
+ public void OnUpdate()
+ {
+ if(!isStarted)
+ return;
+
+ if (Lists.IsEmpty(FrameSet))
+ {
+ Clear();
+ return;
+ }
+
+ double now = Godot.Time.GetTicksUsec() / 1_000_000.0;
+ if (!wait)
+ {
+ float delta = (float)(now - timestamp) * speed;
+ percent = Mathf.Min(percent + delta, 1f);
+ current.Modulate = completeColor.Lerp(Colors.Transparent, percent);
+ next.Modulate = Colors.Transparent.Lerp(completeColor, percent);
+ }
+ else
+ {
+ float delta = (float)(now - timestamp) * nextFrameSpeed;
+ waitPercent = Mathf.Min(waitPercent + delta, 1f);
+ }
+
+ timestamp = now;
+
+ if (percent >= 1f && !wait)
+ {
+ wait = true;
+ SwitchNextFrame();
+ percent = 0f;
+ }
+ if (waitPercent >= 1f && wait)
+ {
+ wait = false;
+ waitPercent = 0f;
+ }
+ }
+
+ private void SwitchNextFrame()
+ {
+ NextFrame?.Invoke();
+ if (Lists.IsEmpty(FrameSet))
+ {
+ Clear();
+ return;
+ }
+
+ var needNextCycle = false;
+ currentFrame++;
+ if (FrameSet.Count <= currentFrame)
+ {
+ if (isLoop)
+ currentFrame = 0;
+ else
+ {
+ currentFrame--;
+ Pause();
+ }
+ needNextCycle = true;
+ }
+
+ current.Modulate = completeColor;
+ current.Texture = next.Texture;
+
+ next.Modulate = clearColor;
+ next.Texture = FrameSet[currentFrame];
+
+ if(needNextCycle)
+ NextCycle?.Invoke();
+ }
+ }
+}
diff --git a/src/Animation/EffectAnimNode.cs b/src/Animation/EffectAnimNode.cs
index f2fc981..50546ab 100644
--- a/src/Animation/EffectAnimNode.cs
+++ b/src/Animation/EffectAnimNode.cs
@@ -1,84 +1,84 @@
-using Godot;
-
-namespace Cthangover.Mods.Effects.Animation
-{
- ///
- /// Lightweight, code-only alternative to EffectAnimator that does NOT require
- /// a shader. Constructs an AtlasTexture and shifts its Region rect each frame
- /// to scroll through a horizontal spritesheet. Designed for runtime spawning
- /// via the static SpawnOn factory — it anchors to fill the target Control and
- /// self-destructs when playback finishes, requiring no scene file setup.
- ///
- public partial class EffectAnimNode : TextureRect
- {
- private AtlasTexture atlas;
- private int frameCount;
- private float frameInterval;
- private float elapsed;
- private int currentFrame = -1;
- private int frameWidth;
- private float totalDuration;
-
- /// Factory method that creates the node, attaches it to filling the full rect, and initializes the spritesheet animation. Returns null if target or sheet is null.
- /// Parent Control to anchor the animation to.
- /// Horizontal spritesheet texture. Frame width = sheet width / frameCount.
- /// Number of frames in the spritesheet.
- /// Total playback time in seconds; frameInterval = totalDuration / frameCount.
- public static EffectAnimNode SpawnOn(Control target, Texture2D sheet, int frameCount, float totalDuration)
- {
- if (target == null || sheet == null)
- return null;
-
- var node = new EffectAnimNode();
- target.AddChild(node);
- node.AnchorLeft = 0;
- node.AnchorTop = 0;
- node.AnchorRight = 1;
- node.AnchorBottom = 1;
- node.OffsetLeft = 0;
- node.OffsetTop = 0;
- node.OffsetRight = 0;
- node.OffsetBottom = 0;
- node.MouseFilter = MouseFilterEnum.Ignore;
- node.ZIndex = 100;
- node.Init(sheet, frameCount, totalDuration);
- return node;
- }
-
- private void Init(Texture2D sheet, int frameCount, float totalDuration)
- {
- this.frameCount = frameCount;
- this.totalDuration = totalDuration;
- frameInterval = totalDuration / frameCount;
- frameWidth = sheet.GetWidth() / frameCount;
-
- atlas = new AtlasTexture
- {
- Atlas = sheet,
- Region = new Rect2(0, 0, frameWidth, sheet.GetHeight()),
- };
- Texture = atlas;
- ExpandMode = ExpandModeEnum.IgnoreSize;
- StretchMode = StretchModeEnum.KeepAspectCentered;
- SetFrame(0);
- }
-
- /// Steps the atlas region forward per frame interval and frees the node once the total duration has played.
- public override void _Process(double delta)
- {
- elapsed += (float)delta;
- var frame = Mathf.Min((int)(elapsed / frameInterval), frameCount - 1);
- if (frame != currentFrame)
- SetFrame(frame);
-
- if (elapsed >= totalDuration)
- QueueFree();
- }
-
- private void SetFrame(int frame)
- {
- currentFrame = frame;
- atlas.Region = new Rect2(frame * frameWidth, 0, frameWidth, atlas.Region.Size.Y);
- }
- }
-}
+using Godot;
+
+namespace Cthangover.Mods.Effects.Animation
+{
+ ///
+ /// Lightweight, code-only alternative to EffectAnimator that does NOT require
+ /// a shader. Constructs an AtlasTexture and shifts its Region rect each frame
+ /// to scroll through a horizontal spritesheet. Designed for runtime spawning
+ /// via the static SpawnOn factory — it anchors to fill the target Control and
+ /// self-destructs when playback finishes, requiring no scene file setup.
+ ///
+ public partial class EffectAnimNode : TextureRect
+ {
+ private AtlasTexture atlas;
+ private int frameCount;
+ private float frameInterval;
+ private float elapsed;
+ private int currentFrame = -1;
+ private int frameWidth;
+ private float totalDuration;
+
+ /// Factory method that creates the node, attaches it to filling the full rect, and initializes the spritesheet animation. Returns null if target or sheet is null.
+ /// Parent Control to anchor the animation to.
+ /// Horizontal spritesheet texture. Frame width = sheet width / frameCount.
+ /// Number of frames in the spritesheet.
+ /// Total playback time in seconds; frameInterval = totalDuration / frameCount.
+ public static EffectAnimNode SpawnOn(Control target, Texture2D sheet, int frameCount, float totalDuration)
+ {
+ if (target == null || sheet == null)
+ return null;
+
+ var node = new EffectAnimNode();
+ target.AddChild(node);
+ node.AnchorLeft = 0;
+ node.AnchorTop = 0;
+ node.AnchorRight = 1;
+ node.AnchorBottom = 1;
+ node.OffsetLeft = 0;
+ node.OffsetTop = 0;
+ node.OffsetRight = 0;
+ node.OffsetBottom = 0;
+ node.MouseFilter = MouseFilterEnum.Ignore;
+ node.ZIndex = 100;
+ node.Init(sheet, frameCount, totalDuration);
+ return node;
+ }
+
+ private void Init(Texture2D sheet, int frameCount, float totalDuration)
+ {
+ this.frameCount = frameCount;
+ this.totalDuration = totalDuration;
+ frameInterval = totalDuration / frameCount;
+ frameWidth = sheet.GetWidth() / frameCount;
+
+ atlas = new AtlasTexture
+ {
+ Atlas = sheet,
+ Region = new Rect2(0, 0, frameWidth, sheet.GetHeight()),
+ };
+ Texture = atlas;
+ ExpandMode = ExpandModeEnum.IgnoreSize;
+ StretchMode = StretchModeEnum.KeepAspectCentered;
+ SetFrame(0);
+ }
+
+ /// Steps the atlas region forward per frame interval and frees the node once the total duration has played.
+ public override void _Process(double delta)
+ {
+ elapsed += (float)delta;
+ var frame = Mathf.Min((int)(elapsed / frameInterval), frameCount - 1);
+ if (frame != currentFrame)
+ SetFrame(frame);
+
+ if (elapsed >= totalDuration)
+ QueueFree();
+ }
+
+ private void SetFrame(int frame)
+ {
+ currentFrame = frame;
+ atlas.Region = new Rect2(frame * frameWidth, 0, frameWidth, atlas.Region.Size.Y);
+ }
+ }
+}
diff --git a/src/Animation/EffectAnimator.cs b/src/Animation/EffectAnimator.cs
index cfc6c11..1440abd 100644
--- a/src/Animation/EffectAnimator.cs
+++ b/src/Animation/EffectAnimator.cs
@@ -1,71 +1,71 @@
-using Cthangover.Core.UI.Event;
-using Godot;
-
-namespace Cthangover.Mods.Effects.Animation
-{
- ///
- /// Shader-driven frame animation: drives _FrameIndex and _FrameCount uniforms
- /// on the TextureRect's ShaderMaterial rather than swapping textures. This means
- /// all animation frames live in a single GPU texture atlas and the shader selects
- /// the visible frame. Auto-destroys (QueueFree) after totalDuration elapses,
- /// making it suitable for fire-and-forget VFX that don't need external cleanup.
- ///
- public partial class EffectAnimator : Control, IOnUpdateEvent
- {
-
- [Export] private TextureRect image;
- [Export] private float totalDuration = 5f;
- [Export] private int frameCount = 5;
-
- private ShaderMaterial materialInstance;
- private double startTime;
- private float frameInterval;
- private int currentFrame;
-
- private static readonly StringName FrameIndexId = "_FrameIndex";
- private static readonly StringName FrameCountId = "_FrameCount";
-
- /// Precomputes the frame interval and pushes the frame count uniform into the shader material.
- public override void _Ready()
- {
- frameInterval = totalDuration / frameCount;
- materialInstance = image.Material as ShaderMaterial;
- if (materialInstance != null)
- materialInstance.SetShaderParameter(FrameCountId, frameCount);
- }
-
- /// Intentionally empty — the animator drives frames through the event controller instead of engine callbacks.
- public override void _EnterTree()
- {
- }
-
- /// Starts the shader animation from frame 0, resetting the elapsed timer.
- public void Play()
- {
- startTime = Godot.Time.GetTicksUsec() / 1_000_000.0;
- currentFrame = 0;
- if (materialInstance != null)
- materialInstance.SetShaderParameter(FrameIndexId, 0f);
- }
-
- /// Update priority. Default 1.
- public int Priority => 1;
-
- /// Advances the shader's _FrameIndex uniform each frame based on elapsed time. Self-destructs when totalDuration elapses.
- public void OnUpdate()
- {
- double now = Godot.Time.GetTicksUsec() / 1_000_000.0;
- var elapsed = now - startTime;
- var newFrame = Mathf.Min((int)(elapsed / frameInterval), frameCount - 1);
-
- if (newFrame != currentFrame && materialInstance != null)
- {
- currentFrame = newFrame;
- materialInstance.SetShaderParameter(FrameIndexId, currentFrame);
- }
-
- if (elapsed >= totalDuration)
- QueueFree();
- }
- }
-}
+using Cthangover.Core.UI.Event;
+using Godot;
+
+namespace Cthangover.Mods.Effects.Animation
+{
+ ///
+ /// Shader-driven frame animation: drives _FrameIndex and _FrameCount uniforms
+ /// on the TextureRect's ShaderMaterial rather than swapping textures. This means
+ /// all animation frames live in a single GPU texture atlas and the shader selects
+ /// the visible frame. Auto-destroys (QueueFree) after totalDuration elapses,
+ /// making it suitable for fire-and-forget VFX that don't need external cleanup.
+ ///
+ public partial class EffectAnimator : Control, IOnUpdateEvent
+ {
+
+ [Export] private TextureRect image;
+ [Export] private float totalDuration = 5f;
+ [Export] private int frameCount = 5;
+
+ private ShaderMaterial materialInstance;
+ private double startTime;
+ private float frameInterval;
+ private int currentFrame;
+
+ private static readonly StringName FrameIndexId = "_FrameIndex";
+ private static readonly StringName FrameCountId = "_FrameCount";
+
+ /// Precomputes the frame interval and pushes the frame count uniform into the shader material.
+ public override void _Ready()
+ {
+ frameInterval = totalDuration / frameCount;
+ materialInstance = image.Material as ShaderMaterial;
+ if (materialInstance != null)
+ materialInstance.SetShaderParameter(FrameCountId, frameCount);
+ }
+
+ /// Intentionally empty — the animator drives frames through the event controller instead of engine callbacks.
+ public override void _EnterTree()
+ {
+ }
+
+ /// Starts the shader animation from frame 0, resetting the elapsed timer.
+ public void Play()
+ {
+ startTime = Godot.Time.GetTicksUsec() / 1_000_000.0;
+ currentFrame = 0;
+ if (materialInstance != null)
+ materialInstance.SetShaderParameter(FrameIndexId, 0f);
+ }
+
+ /// Update priority. Default 1.
+ public int Priority => 1;
+
+ /// Advances the shader's _FrameIndex uniform each frame based on elapsed time. Self-destructs when totalDuration elapses.
+ public void OnUpdate()
+ {
+ double now = Godot.Time.GetTicksUsec() / 1_000_000.0;
+ var elapsed = now - startTime;
+ var newFrame = Mathf.Min((int)(elapsed / frameInterval), frameCount - 1);
+
+ if (newFrame != currentFrame && materialInstance != null)
+ {
+ currentFrame = newFrame;
+ materialInstance.SetShaderParameter(FrameIndexId, currentFrame);
+ }
+
+ if (elapsed >= totalDuration)
+ QueueFree();
+ }
+ }
+}
diff --git a/src/Animation/EffectController.cs b/src/Animation/EffectController.cs
index e25a355..c066c04 100644
--- a/src/Animation/EffectController.cs
+++ b/src/Animation/EffectController.cs
@@ -1,17 +1,17 @@
-namespace Cthangover.Mods.Effects.Animation
-{
- ///
- /// Specialisation of AnimationController where Pause() also calls Clear(),
- /// treating "pause" as a hard reset. This is intentional for one-shot effects
- /// that should clean up instantly rather than freezing on their last frame.
- /// The "new" keyword on Pause() shadows the base implementation on purpose.
- ///
- public partial class EffectController : AnimationController
- {
- /// Shadows the base Pause() and calls instead, instantly resetting one-shot effects.
- public new void Pause()
- {
- Clear();
- }
- }
-}
+namespace Cthangover.Mods.Effects.Animation
+{
+ ///
+ /// Specialisation of AnimationController where Pause() also calls Clear(),
+ /// treating "pause" as a hard reset. This is intentional for one-shot effects
+ /// that should clean up instantly rather than freezing on their last frame.
+ /// The "new" keyword on Pause() shadows the base implementation on purpose.
+ ///
+ public partial class EffectController : AnimationController
+ {
+ /// Shadows the base Pause() and calls instead, instantly resetting one-shot effects.
+ public new void Pause()
+ {
+ Clear();
+ }
+ }
+}
diff --git a/src/EffectFactory.cs b/src/EffectFactory.cs
index fe9d4b5..f71cc5d 100644
--- a/src/EffectFactory.cs
+++ b/src/EffectFactory.cs
@@ -1,82 +1,82 @@
-using System;
-using System.Collections.Generic;
-using Cthangover.Core.Factories;
-using Cthangover.Core.Mods;
-using Cthangover.Core.Mods.Resolvers;
-using Cthangover.Core.Utils;
-using Godot;
-
-namespace Cthangover.Mods.Effects
-{
- ///
- /// Factory for PackedScene visual effects. Overrides
- /// LoadFromMods to use ResourceLoader.Load from the file
- /// system instead of ConvertFromBytes, because Godot's scene
- /// format is a proprietary binary blob that cannot be reconstructed
- /// from raw bytes in managed code. This imposes a hard constraint:
- /// effect scenes are only loadable from folder mods — zip mods
- /// skip them with a warning — so mod authors distributing visual effects
- /// must ship them unpacked.
- ///
- /// The single instance is a self-contained lazy singleton owned by this
- /// mod assembly; consumers resolve it through .
- ///
- public class EffectFactory : PrefabFactory
- {
- private static readonly Lazy instance = new(() => new EffectFactory());
-
- /// Process-wide instance; laziness matches the former AppServices slot.
- public static EffectFactory Instance => instance.Value;
-
- private EffectFactory() : base("effects", 64) { }
-
- /// Mod asset group scanned by this factory — files live under mods/*/effects.
- public override string GroupName => "effects";
-
- /// Scene file extensions accepted during the mod scan.
- protected override List Extensions { get; } = new() { ".tscn", ".scn" };
-
- /// Always fails with a log — PackedScene is a proprietary Godot blob; use (folder mods only).
- protected override PackedScene ConvertFromBytes(string id, byte[] data, string extension)
- {
- GameLogger.Log("FACTORY", $"PackedScene cannot be constructed from bytes; use folder mods for '{id}'", LogLevel.Error);
- return null;
- }
-
- ///
- /// Loads the scene via ResourceLoader from the mod's real file
- /// system path — which only folder mods can provide; zip mods are
- /// skipped with a warning.
- ///
- protected override PackedScene LoadFromMods(string id, string extension)
- {
- var files = Files.Collect(GroupName);
-
- string matchedKey = null;
- foreach (var key in files.Keys)
- {
- if (key == id || key.Equals(id + extension, StringComparison.OrdinalIgnoreCase))
- {
- matchedKey = key;
- break;
- }
- }
-
- if (matchedKey == null || !files.TryGetValue(matchedKey, out var entry))
- {
- GameLogger.Log("FACTORY", $"effect '{id}' - not found in group '{GroupName}'!", LogLevel.Error);
- return null;
- }
-
- var fsPath = ModManager.Instance.GetFileSystemPath(entry.ModId, entry.FullPath);
- if (fsPath == null)
- {
- GameLogger.Log("FACTORY", $"effect '{id}' - only folder mods support PackedScene (zip mod '{entry.ModId}' skipped)", LogLevel.Warning);
- return null;
- }
-
- return ResourceLoader.Load(fsPath);
- }
-
- }
-}
+using System;
+using System.Collections.Generic;
+using Cthangover.Core.Factories;
+using Cthangover.Core.Mods;
+using Cthangover.Core.Mods.Resolvers;
+using Cthangover.Core.Utils;
+using Godot;
+
+namespace Cthangover.Mods.Effects
+{
+ ///
+ /// Factory for PackedScene visual effects. Overrides
+ /// LoadFromMods to use ResourceLoader.Load from the file
+ /// system instead of ConvertFromBytes, because Godot's scene
+ /// format is a proprietary binary blob that cannot be reconstructed
+ /// from raw bytes in managed code. This imposes a hard constraint:
+ /// effect scenes are only loadable from folder mods — zip mods
+ /// skip them with a warning — so mod authors distributing visual effects
+ /// must ship them unpacked.
+ ///
+ /// The single instance is a self-contained lazy singleton owned by this
+ /// mod assembly; consumers resolve it through .
+ ///
+ public class EffectFactory : PrefabFactory
+ {
+ private static readonly Lazy instance = new(() => new EffectFactory());
+
+ /// Process-wide instance; laziness matches the former AppServices slot.
+ public static EffectFactory Instance => instance.Value;
+
+ private EffectFactory() : base("effects", 64) { }
+
+ /// Mod asset group scanned by this factory — files live under mods/*/effects.
+ public override string GroupName => "effects";
+
+ /// Scene file extensions accepted during the mod scan.
+ protected override List Extensions { get; } = new() { ".tscn", ".scn" };
+
+ /// Always fails with a log — PackedScene is a proprietary Godot blob; use (folder mods only).
+ protected override PackedScene ConvertFromBytes(string id, byte[] data, string extension)
+ {
+ GameLogger.Log("FACTORY", $"PackedScene cannot be constructed from bytes; use folder mods for '{id}'", LogLevel.Error);
+ return null;
+ }
+
+ ///
+ /// Loads the scene via ResourceLoader from the mod's real file
+ /// system path — which only folder mods can provide; zip mods are
+ /// skipped with a warning.
+ ///
+ protected override PackedScene LoadFromMods(string id, string extension)
+ {
+ var files = Files.Collect(GroupName);
+
+ string matchedKey = null;
+ foreach (var key in files.Keys)
+ {
+ if (key == id || key.Equals(id + extension, StringComparison.OrdinalIgnoreCase))
+ {
+ matchedKey = key;
+ break;
+ }
+ }
+
+ if (matchedKey == null || !files.TryGetValue(matchedKey, out var entry))
+ {
+ GameLogger.Log("FACTORY", $"effect '{id}' - not found in group '{GroupName}'!", LogLevel.Error);
+ return null;
+ }
+
+ var fsPath = ModManager.Instance.GetFileSystemPath(entry.ModId, entry.FullPath);
+ if (fsPath == null)
+ {
+ GameLogger.Log("FACTORY", $"effect '{id}' - only folder mods support PackedScene (zip mod '{entry.ModId}' skipped)", LogLevel.Warning);
+ return null;
+ }
+
+ return ResourceLoader.Load(fsPath);
+ }
+
+ }
+}
diff --git a/src/EffectsModInitializer.cs b/src/EffectsModInitializer.cs
index 45bc8a3..ab240d0 100644
--- a/src/EffectsModInitializer.cs
+++ b/src/EffectsModInitializer.cs
@@ -1,21 +1,21 @@
-using Cthangover.Core.Mods;
-
-namespace Cthangover.Mods.Effects
-{
- ///
- /// Engine-mod initializer for the effects subsystem. The factory is a
- /// self-contained lazy singleton resolved by consumers through
- /// EffectFactory.Instance, so there is no core binding to
- /// re-establish on hot reload — the initializer exists to register the
- /// assembly with the mod pipeline and keep the migration shape uniform
- /// with the other engine mods.
- ///
- public class EffectsModInitializer : IModInitializer
- {
- /// No per-mod setup is required.
- public void OnModLoaded(string modId) { }
-
- /// Nothing to bind: the factory lazy self-initializes on first use.
- public void OnModResourcesReady() { }
- }
-}
+using Cthangover.Core.Mods;
+
+namespace Cthangover.Mods.Effects
+{
+ ///
+ /// Engine-mod initializer for the effects subsystem. The factory is a
+ /// self-contained lazy singleton resolved by consumers through
+ /// EffectFactory.Instance, so there is no core binding to
+ /// re-establish on hot reload — the initializer exists to register the
+ /// assembly with the mod pipeline and keep the migration shape uniform
+ /// with the other engine mods.
+ ///
+ public class EffectsModInitializer : IModInitializer
+ {
+ /// No per-mod setup is required.
+ public void OnModLoaded(string modId) { }
+
+ /// Nothing to bind: the factory lazy self-initializes on first use.
+ public void OnModResourcesReady() { }
+ }
+}
diff --git a/src/SubsystemRegistration.cs b/src/SubsystemRegistration.cs
index 6dcee3c..12f7001 100644
--- a/src/SubsystemRegistration.cs
+++ b/src/SubsystemRegistration.cs
@@ -1,13 +1,13 @@
-namespace Cthangover.Mods.Effects
-{
- ///
- /// Migration marker for the coverage pipeline (SubsystemsBridge
- /// reflection scan): declares the effects subsystem fully moved out of
- /// the core assembly into this engine mod. The id must match the
- /// DeclaredTests entry in SubsystemsCoverageTestSetup.
- ///
- public static class SubsystemRegistration
- {
- public static string SubsystemId => "effects";
- }
-}
+namespace Cthangover.Mods.Effects
+{
+ ///
+ /// Migration marker for the coverage pipeline (SubsystemsBridge
+ /// reflection scan): declares the effects subsystem fully moved out of
+ /// the core assembly into this engine mod. The id must match the
+ /// DeclaredTests entry in SubsystemsCoverageTestSetup.
+ ///
+ public static class SubsystemRegistration
+ {
+ public static string SubsystemId => "effects";
+ }
+}