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"; + } +}