This commit is contained in:
iisaev committed 2026-10-18 12:41:38 +03:00
commit acb9dcaaaf
11 files changed
+510

No files matched your search

+161
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Fires when the current frame transition completes and the next frame texture is assigned.</summary>
public event Action NextFrame;
/// <summary>Fires when the animation wraps around (looping) or reaches the final frame (non-looping).</summary>
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;
/// <summary>When true, the animation wraps from the last frame back to the first. When false, playback stops at the final frame.</summary>
public bool IsLoop { get => isLoop; set => isLoop = value; }
/// <summary>Crossfade speed multiplier between frames. Higher values produce faster transitions.</summary>
public float Speed { get => speed; set => speed = value; }
/// <summary>Hold duration multiplier for the "wait" phase after a frame transition completes.</summary>
public float NextFrameSpeed { get => nextFrameSpeed; set => nextFrameSpeed = value; }
/// <summary>Whether playback is currently active. False on startup and after <see cref="Pause"/> or <see cref="Clear"/>.</summary>
public bool IsStarted => isStarted;
/// <summary>Ordered list of textures to cycle through. Set externally before calling <see cref="Play"/>.</summary>
public List<Texture2D> FrameSet { get; set; }
/// <summary>Resets to idle state: stops playback, clears frame set, and blanks both texture nodes to transparent.</summary>
public void Clear()
{
isStarted = false;
FrameSet = null;
current.Texture = empty;
current.Modulate = Colors.Transparent;
next.Texture = empty;
next.Modulate = Colors.Transparent;
}
/// <summary>Stops playback without clearing textures, freezing on the current frame.</summary>
public void Pause()
{
isStarted = false;
}
/// <summary>Starts or restarts playback from the first frame in <see cref="FrameSet"/>.</summary>
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];
}
/// <summary>Update priority. Lower numbers run earlier in the frame. Default 1.</summary>
public int Priority => 1;
/// <summary>Advances the crossfade each frame. Handles both the transition phase (lerping modulate) and the wait phase (holding the next frame visible).</summary>
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();
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using Godot;
namespace Cthangover.Mods.Effects.Animation
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Factory method that creates the node, attaches it to <paramref name="target"/> filling the full rect, and initializes the spritesheet animation. Returns null if target or sheet is null.</summary>
/// <param name="target">Parent Control to anchor the animation to.</param>
/// <param name="sheet">Horizontal spritesheet texture. Frame width = sheet width / frameCount.</param>
/// <param name="frameCount">Number of frames in the spritesheet.</param>
/// <param name="totalDuration">Total playback time in seconds; frameInterval = totalDuration / frameCount.</param>
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);
}
/// <summary>Steps the atlas region forward per frame interval and frees the node once the total duration has played.</summary>
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);
}
}
}
+71
View File
@@ -0,0 +1,71 @@
using Cthangover.Core.UI.Event;
using Godot;
namespace Cthangover.Mods.Effects.Animation
{
/// <summary>
/// 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.
/// </summary>
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";
/// <summary>Precomputes the frame interval and pushes the frame count uniform into the shader material.</summary>
public override void _Ready()
{
frameInterval = totalDuration / frameCount;
materialInstance = image.Material as ShaderMaterial;
if (materialInstance != null)
materialInstance.SetShaderParameter(FrameCountId, frameCount);
}
/// <summary>Intentionally empty — the animator drives frames through the event controller instead of engine callbacks.</summary>
public override void _EnterTree()
{
}
/// <summary>Starts the shader animation from frame 0, resetting the elapsed timer.</summary>
public void Play()
{
startTime = Godot.Time.GetTicksUsec() / 1_000_000.0;
currentFrame = 0;
if (materialInstance != null)
materialInstance.SetShaderParameter(FrameIndexId, 0f);
}
/// <summary>Update priority. Default 1.</summary>
public int Priority => 1;
/// <summary>Advances the shader's _FrameIndex uniform each frame based on elapsed time. Self-destructs when <c>totalDuration</c> elapses.</summary>
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();
}
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Cthangover.Mods.Effects.Animation
{
/// <summary>
/// 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.
/// </summary>
public partial class EffectController : AnimationController
{
/// <summary>Shadows the base Pause() and calls <see cref="AnimationController.Clear"/> instead, instantly resetting one-shot effects.</summary>
public new void Pause()
{
Clear();
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using Godot;
namespace Cthangover.Mods.Effects.Animation
{
/// <summary>
/// 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.
/// </summary>
public partial class EffectDataSet : Control
{
[Export] private AnimationController controller;
[Export] private Godot.Collections.Array<Texture2D> frameSet;
/// <summary>Converts the exported frame array, hands it to the controller and starts one playback cycle.</summary>
public override void _Ready()
{
AnchorLeft = 0f;
AnchorRight = 1f;
AnchorTop = 0f;
AnchorBottom = 1f;
var frames = new List<Texture2D>();
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();
}
}
}
+82
View File
@@ -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
{
/// <summary>
/// Factory for <c>PackedScene</c> visual effects. Overrides
/// <c>LoadFromMods</c> to use <c>ResourceLoader.Load</c> from the file
/// system instead of <c>ConvertFromBytes</c>, 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 <b>folder mods</b> — zip mods
/// skip them with a warning — so mod authors distributing visual effects
/// must ship them unpacked.
/// <para/>
/// The single instance is a self-contained lazy singleton owned by this
/// mod assembly; consumers resolve it through <see cref="Instance"/>.
/// </summary>
public class EffectFactory : PrefabFactory<PackedScene>
{
private static readonly Lazy<EffectFactory> instance = new(() => new EffectFactory());
/// <summary>Process-wide instance; laziness matches the former AppServices slot.</summary>
public static EffectFactory Instance => instance.Value;
private EffectFactory() : base("effects", 64) { }
/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/effects</c>.</summary>
public override string GroupName => "effects";
/// <summary>Scene file extensions accepted during the mod scan.</summary>
protected override List<string> Extensions { get; } = new() { ".tscn", ".scn" };
/// <summary>Always fails with a log — PackedScene is a proprietary Godot blob; use <see cref="LoadFromMods"/> (folder mods only).</summary>
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;
}
/// <summary>
/// Loads the scene via <c>ResourceLoader</c> from the mod's real file
/// system path — which only folder mods can provide; zip mods are
/// skipped with a warning.
/// </summary>
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<PackedScene>(fsPath);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using Cthangover.Core.Mods;
namespace Cthangover.Mods.Effects
{
/// <summary>
/// Engine-mod initializer for the effects subsystem. The factory is a
/// self-contained lazy singleton resolved by consumers through
/// <c>EffectFactory.Instance</c>, 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.
/// </summary>
public class EffectsModInitializer : IModInitializer
{
/// <summary>No per-mod setup is required.</summary>
public void OnModLoaded(string modId) { }
/// <summary>Nothing to bind: the factory lazy self-initializes on first use.</summary>
public void OnModResourcesReady() { }
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Cthangover.Mods.Effects
{
/// <summary>
/// Migration marker for the coverage pipeline (<c>SubsystemsBridge</c>
/// reflection scan): declares the effects subsystem fully moved out of
/// the core assembly into this engine mod. The id must match the
/// <c>DeclaredTests</c> entry in <c>SubsystemsCoverageTestSetup</c>.
/// </summary>
public static class SubsystemRegistration
{
public static string SubsystemId => "effects";
}
}