init mod
This commit is contained in:
commit
acb9dcaaaf
11 files changed
+510
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user