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();
}
}
}