From acb9dcaaaf65e877df8d9ba44ac033180f3c60da Mon Sep 17 00:00:00 2001 From: iisaev Date: Sun, 18 Oct 2026 12:41:38 +0300 Subject: [PATCH] init mod --- locale/en.properties | 2 + locale/ru.properties | 2 + manifest.json | 13 +++ src/Animation/AnimationController.cs | 161 +++++++++++++++++++++++++++ src/Animation/EffectAnimNode.cs | 84 ++++++++++++++ src/Animation/EffectAnimator.cs | 71 ++++++++++++ src/Animation/EffectController.cs | 17 +++ src/Animation/EffectDataSet.cs | 44 ++++++++ src/EffectFactory.cs | 82 ++++++++++++++ src/EffectsModInitializer.cs | 21 ++++ src/SubsystemRegistration.cs | 13 +++ 11 files changed, 510 insertions(+) create mode 100644 locale/en.properties create mode 100644 locale/ru.properties create mode 100644 manifest.json create mode 100644 src/Animation/AnimationController.cs create mode 100644 src/Animation/EffectAnimNode.cs create mode 100644 src/Animation/EffectAnimator.cs create mode 100644 src/Animation/EffectController.cs create mode 100644 src/Animation/EffectDataSet.cs create mode 100644 src/EffectFactory.cs create mode 100644 src/EffectsModInitializer.cs create mode 100644 src/SubsystemRegistration.cs diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..8477374 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,2 @@ +mod_effects_base_name = Effects Engine +mod_effects_base_desc = Engine effects subsystem: PackedScene visual effect factory and the frame/animated effect controllers. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..10513fe --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_effects_base_name = Движок эффектов +mod_effects_base_desc = Подсистема визуальных эффектов движка: фабрика PackedScene-эффектов и контроллеры кадровых и анимированных эффектов. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7d9d540 --- /dev/null +++ b/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "effects_base", + "name": "Effects Engine", + "version": "1.0.0", + "author": "ct", + "description": "Engine effects subsystem: PackedScene visual effect factory (folder mods only) and the frame/animated effect controllers. Replaces the former Core/UI/Animation implementations and Core/Factories/Impls/EffectFactory.", + "category": "engine, library", + "sources": [ + "src/**/*.cs" + ], + "depends": [], + "ui": [] +} diff --git a/src/Animation/AnimationController.cs b/src/Animation/AnimationController.cs new file mode 100644 index 0000000..200047e --- /dev/null +++ b/src/Animation/AnimationController.cs @@ -0,0 +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(); + } + } +} diff --git a/src/Animation/EffectAnimNode.cs b/src/Animation/EffectAnimNode.cs new file mode 100644 index 0000000..f2fc981 --- /dev/null +++ b/src/Animation/EffectAnimNode.cs @@ -0,0 +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); + } + } +} diff --git a/src/Animation/EffectAnimator.cs b/src/Animation/EffectAnimator.cs new file mode 100644 index 0000000..cfc6c11 --- /dev/null +++ b/src/Animation/EffectAnimator.cs @@ -0,0 +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(); + } + } +} diff --git a/src/Animation/EffectController.cs b/src/Animation/EffectController.cs new file mode 100644 index 0000000..e25a355 --- /dev/null +++ b/src/Animation/EffectController.cs @@ -0,0 +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(); + } + } +} diff --git a/src/Animation/EffectDataSet.cs b/src/Animation/EffectDataSet.cs new file mode 100644 index 0000000..8b24267 --- /dev/null +++ b/src/Animation/EffectDataSet.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Godot; + +namespace Cthangover.Mods.Effects.Animation +{ + /// + /// Editor-configured bridge between a frame Texture2D array and an AnimationController. + /// Converts Godot's typed Array into a List, wires the NextCycle event, and starts + /// playback. When the animation completes one cycle, it detaches the handler and + /// self-destructs — the "fire and forget" glue between artist-authored data and + /// the animation runtime. + /// + public partial class EffectDataSet : Control + { + + [Export] private AnimationController controller; + [Export] private Godot.Collections.Array frameSet; + + /// Converts the exported frame array, hands it to the controller and starts one playback cycle. + public override void _Ready() + { + AnchorLeft = 0f; + AnchorRight = 1f; + AnchorTop = 0f; + AnchorBottom = 1f; + + var frames = new List(); + foreach (var tex in frameSet) + frames.Add(tex); + + controller.Clear(); + controller.NextCycle += ControllerOnNextCycle; + controller.FrameSet = frames; + controller.Play(); + } + + private void ControllerOnNextCycle() + { + controller.NextCycle -= ControllerOnNextCycle; + controller.Clear(); + QueueFree(); + } + } +} diff --git a/src/EffectFactory.cs b/src/EffectFactory.cs new file mode 100644 index 0000000..fe9d4b5 --- /dev/null +++ b/src/EffectFactory.cs @@ -0,0 +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); + } + + } +} diff --git a/src/EffectsModInitializer.cs b/src/EffectsModInitializer.cs new file mode 100644 index 0000000..45bc8a3 --- /dev/null +++ b/src/EffectsModInitializer.cs @@ -0,0 +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() { } + } +} diff --git a/src/SubsystemRegistration.cs b/src/SubsystemRegistration.cs new file mode 100644 index 0000000..6dcee3c --- /dev/null +++ b/src/SubsystemRegistration.cs @@ -0,0 +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"; + } +}