Line endings: LF everywhere

.gitattributes forces LF (CRLF only for .bat/.cmd), core.autocrlf is off;
files that differed only by CRLF are normalized.
This commit is contained in:
cthangover committed 2026-10-21 13:29:41 +02:00
1 parent 2c3d12e798
commit 1574c30ab7
10 files changed
+517 -487

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
* text=auto eol=lf
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
# Binaries: never converted, never diffed as text.
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.svg text
*.ogg binary
*.wav binary
*.mp3 binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.dll binary
*.so binary
*.dylib binary
*.pdb binary
*.exe binary
*.zip binary
*.pck binary
*.moc3 binary
*.res binary
*.scn binary
+37 -37
View File
@@ -1,37 +1,37 @@
# Godot 4.x # Godot 4.x
.godot/ .godot/
.idea/ .idea/
sdk/ sdk/
bin/ bin/
obj/ obj/
#ai #ai
.omo/ .omo/
.opencode/ .opencode/
gen/ gen/
obj/ obj/
bin/ bin/
*.tres~ *.tres~
*.tscn~ *.tscn~
# GDScript LSP # GDScript LSP
.godot/ .godot/
# Export # Export
*.exe *.exe
*.dmg *.dmg
*.apk *.apk
*.aab *.aab
*.pck *.pck
*.zip *.zip
*.log *.log
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Editor # Editor
*.import *.import
*.godot.uid *.godot.uid
+1 -1
View File
@@ -10,4 +10,4 @@
], ],
"depends": [], "depends": [],
"ui": [] "ui": []
} }
+161 -161
View File
@@ -1,161 +1,161 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Cthangover.Core.UI.Event; using Cthangover.Core.UI.Event;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Godot; using Godot;
namespace Cthangover.Mods.Effects.Animation namespace Cthangover.Mods.Effects.Animation
{ {
/// <summary> /// <summary>
/// Frame-by-frame sprite animation via crossfade between two TextureRect nodes. /// Frame-by-frame sprite animation via crossfade between two TextureRect nodes.
/// Rather than using a spritesheet, it keeps a "current" and "next" texture and /// Rather than using a spritesheet, it keeps a "current" and "next" texture and
/// lerps their modulate between transparent and opaque, creating smooth transitions. /// lerps their modulate between transparent and opaque, creating smooth transitions.
/// A built-in wait/waitPercent mechanism holds the "next" frame fully visible for /// A built-in wait/waitPercent mechanism holds the "next" frame fully visible for
/// a configurable interval before advancing, producing a paced flip-book effect. /// a configurable interval before advancing, producing a paced flip-book effect.
/// Exposes NextFrame/NextCycle events so external systems can sequence or react /// Exposes NextFrame/NextCycle events so external systems can sequence or react
/// to animation milestones without polling. /// to animation milestones without polling.
/// </summary> /// </summary>
public partial class AnimationController : Control, IOnUpdateEvent public partial class AnimationController : Control, IOnUpdateEvent
{ {
[Export] private TextureRect current; [Export] private TextureRect current;
[Export] private TextureRect next; [Export] private TextureRect next;
[Export] private Texture2D empty; [Export] private Texture2D empty;
[Export] private bool isLoop = true; [Export] private bool isLoop = true;
[Export] private float speed = 1f; [Export] private float speed = 1f;
[Export] private float nextFrameSpeed = 0.2f; [Export] private float nextFrameSpeed = 0.2f;
/// <summary>Fires when the current frame transition completes and the next frame texture is assigned.</summary> /// <summary>Fires when the current frame transition completes and the next frame texture is assigned.</summary>
public event Action NextFrame; public event Action NextFrame;
/// <summary>Fires when the animation wraps around (looping) or reaches the final frame (non-looping).</summary> /// <summary>Fires when the animation wraps around (looping) or reaches the final frame (non-looping).</summary>
public event Action NextCycle; public event Action NextCycle;
[Export] private Color clearColor = new(1f, 1f, 1f, 0f); [Export] private Color clearColor = new(1f, 1f, 1f, 0f);
[Export] private Color completeColor = new(1f, 1f, 1f, 1f); [Export] private Color completeColor = new(1f, 1f, 1f, 1f);
private int currentFrame; private int currentFrame;
private double timestamp; private double timestamp;
private float percent; private float percent;
private bool wait; private bool wait;
private float waitPercent; private float waitPercent;
private bool isStarted; 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> /// <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; } public bool IsLoop { get => isLoop; set => isLoop = value; }
/// <summary>Crossfade speed multiplier between frames. Higher values produce faster transitions.</summary> /// <summary>Crossfade speed multiplier between frames. Higher values produce faster transitions.</summary>
public float Speed { get => speed; set => speed = value; } public float Speed { get => speed; set => speed = value; }
/// <summary>Hold duration multiplier for the "wait" phase after a frame transition completes.</summary> /// <summary>Hold duration multiplier for the "wait" phase after a frame transition completes.</summary>
public float NextFrameSpeed { get => nextFrameSpeed; set => nextFrameSpeed = value; } 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> /// <summary>Whether playback is currently active. False on startup and after <see cref="Pause"/> or <see cref="Clear"/>.</summary>
public bool IsStarted => isStarted; public bool IsStarted => isStarted;
/// <summary>Ordered list of textures to cycle through. Set externally before calling <see cref="Play"/>.</summary> /// <summary>Ordered list of textures to cycle through. Set externally before calling <see cref="Play"/>.</summary>
public List<Texture2D> FrameSet { get; set; } public List<Texture2D> FrameSet { get; set; }
/// <summary>Resets to idle state: stops playback, clears frame set, and blanks both texture nodes to transparent.</summary> /// <summary>Resets to idle state: stops playback, clears frame set, and blanks both texture nodes to transparent.</summary>
public void Clear() public void Clear()
{ {
isStarted = false; isStarted = false;
FrameSet = null; FrameSet = null;
current.Texture = empty; current.Texture = empty;
current.Modulate = Colors.Transparent; current.Modulate = Colors.Transparent;
next.Texture = empty; next.Texture = empty;
next.Modulate = Colors.Transparent; next.Modulate = Colors.Transparent;
} }
/// <summary>Stops playback without clearing textures, freezing on the current frame.</summary> /// <summary>Stops playback without clearing textures, freezing on the current frame.</summary>
public void Pause() public void Pause()
{ {
isStarted = false; isStarted = false;
} }
/// <summary>Starts or restarts playback from the first frame in <see cref="FrameSet"/>.</summary> /// <summary>Starts or restarts playback from the first frame in <see cref="FrameSet"/>.</summary>
public void Play() public void Play()
{ {
currentFrame = 0; currentFrame = 0;
isStarted = true; isStarted = true;
timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0; timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
current.Texture = empty; current.Texture = empty;
next.Modulate = clearColor; next.Modulate = clearColor;
next.Texture = FrameSet[currentFrame]; next.Texture = FrameSet[currentFrame];
} }
/// <summary>Update priority. Lower numbers run earlier in the frame. Default 1.</summary> /// <summary>Update priority. Lower numbers run earlier in the frame. Default 1.</summary>
public int Priority => 1; 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> /// <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() public void OnUpdate()
{ {
if(!isStarted) if(!isStarted)
return; return;
if (Lists.IsEmpty(FrameSet)) if (Lists.IsEmpty(FrameSet))
{ {
Clear(); Clear();
return; return;
} }
double now = Godot.Time.GetTicksUsec() / 1_000_000.0; double now = Godot.Time.GetTicksUsec() / 1_000_000.0;
if (!wait) if (!wait)
{ {
float delta = (float)(now - timestamp) * speed; float delta = (float)(now - timestamp) * speed;
percent = Mathf.Min(percent + delta, 1f); percent = Mathf.Min(percent + delta, 1f);
current.Modulate = completeColor.Lerp(Colors.Transparent, percent); current.Modulate = completeColor.Lerp(Colors.Transparent, percent);
next.Modulate = Colors.Transparent.Lerp(completeColor, percent); next.Modulate = Colors.Transparent.Lerp(completeColor, percent);
} }
else else
{ {
float delta = (float)(now - timestamp) * nextFrameSpeed; float delta = (float)(now - timestamp) * nextFrameSpeed;
waitPercent = Mathf.Min(waitPercent + delta, 1f); waitPercent = Mathf.Min(waitPercent + delta, 1f);
} }
timestamp = now; timestamp = now;
if (percent >= 1f && !wait) if (percent >= 1f && !wait)
{ {
wait = true; wait = true;
SwitchNextFrame(); SwitchNextFrame();
percent = 0f; percent = 0f;
} }
if (waitPercent >= 1f && wait) if (waitPercent >= 1f && wait)
{ {
wait = false; wait = false;
waitPercent = 0f; waitPercent = 0f;
} }
} }
private void SwitchNextFrame() private void SwitchNextFrame()
{ {
NextFrame?.Invoke(); NextFrame?.Invoke();
if (Lists.IsEmpty(FrameSet)) if (Lists.IsEmpty(FrameSet))
{ {
Clear(); Clear();
return; return;
} }
var needNextCycle = false; var needNextCycle = false;
currentFrame++; currentFrame++;
if (FrameSet.Count <= currentFrame) if (FrameSet.Count <= currentFrame)
{ {
if (isLoop) if (isLoop)
currentFrame = 0; currentFrame = 0;
else else
{ {
currentFrame--; currentFrame--;
Pause(); Pause();
} }
needNextCycle = true; needNextCycle = true;
} }
current.Modulate = completeColor; current.Modulate = completeColor;
current.Texture = next.Texture; current.Texture = next.Texture;
next.Modulate = clearColor; next.Modulate = clearColor;
next.Texture = FrameSet[currentFrame]; next.Texture = FrameSet[currentFrame];
if(needNextCycle) if(needNextCycle)
NextCycle?.Invoke(); NextCycle?.Invoke();
} }
} }
} }
+84 -84
View File
@@ -1,84 +1,84 @@
using Godot; using Godot;
namespace Cthangover.Mods.Effects.Animation namespace Cthangover.Mods.Effects.Animation
{ {
/// <summary> /// <summary>
/// Lightweight, code-only alternative to EffectAnimator that does NOT require /// Lightweight, code-only alternative to EffectAnimator that does NOT require
/// a shader. Constructs an AtlasTexture and shifts its Region rect each frame /// a shader. Constructs an AtlasTexture and shifts its Region rect each frame
/// to scroll through a horizontal spritesheet. Designed for runtime spawning /// to scroll through a horizontal spritesheet. Designed for runtime spawning
/// via the static SpawnOn factory — it anchors to fill the target Control and /// via the static SpawnOn factory — it anchors to fill the target Control and
/// self-destructs when playback finishes, requiring no scene file setup. /// self-destructs when playback finishes, requiring no scene file setup.
/// </summary> /// </summary>
public partial class EffectAnimNode : TextureRect public partial class EffectAnimNode : TextureRect
{ {
private AtlasTexture atlas; private AtlasTexture atlas;
private int frameCount; private int frameCount;
private float frameInterval; private float frameInterval;
private float elapsed; private float elapsed;
private int currentFrame = -1; private int currentFrame = -1;
private int frameWidth; private int frameWidth;
private float totalDuration; 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> /// <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="target">Parent Control to anchor the animation to.</param>
/// <param name="sheet">Horizontal spritesheet texture. Frame width = sheet width / frameCount.</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="frameCount">Number of frames in the spritesheet.</param>
/// <param name="totalDuration">Total playback time in seconds; frameInterval = totalDuration / frameCount.</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) public static EffectAnimNode SpawnOn(Control target, Texture2D sheet, int frameCount, float totalDuration)
{ {
if (target == null || sheet == null) if (target == null || sheet == null)
return null; return null;
var node = new EffectAnimNode(); var node = new EffectAnimNode();
target.AddChild(node); target.AddChild(node);
node.AnchorLeft = 0; node.AnchorLeft = 0;
node.AnchorTop = 0; node.AnchorTop = 0;
node.AnchorRight = 1; node.AnchorRight = 1;
node.AnchorBottom = 1; node.AnchorBottom = 1;
node.OffsetLeft = 0; node.OffsetLeft = 0;
node.OffsetTop = 0; node.OffsetTop = 0;
node.OffsetRight = 0; node.OffsetRight = 0;
node.OffsetBottom = 0; node.OffsetBottom = 0;
node.MouseFilter = MouseFilterEnum.Ignore; node.MouseFilter = MouseFilterEnum.Ignore;
node.ZIndex = 100; node.ZIndex = 100;
node.Init(sheet, frameCount, totalDuration); node.Init(sheet, frameCount, totalDuration);
return node; return node;
} }
private void Init(Texture2D sheet, int frameCount, float totalDuration) private void Init(Texture2D sheet, int frameCount, float totalDuration)
{ {
this.frameCount = frameCount; this.frameCount = frameCount;
this.totalDuration = totalDuration; this.totalDuration = totalDuration;
frameInterval = totalDuration / frameCount; frameInterval = totalDuration / frameCount;
frameWidth = sheet.GetWidth() / frameCount; frameWidth = sheet.GetWidth() / frameCount;
atlas = new AtlasTexture atlas = new AtlasTexture
{ {
Atlas = sheet, Atlas = sheet,
Region = new Rect2(0, 0, frameWidth, sheet.GetHeight()), Region = new Rect2(0, 0, frameWidth, sheet.GetHeight()),
}; };
Texture = atlas; Texture = atlas;
ExpandMode = ExpandModeEnum.IgnoreSize; ExpandMode = ExpandModeEnum.IgnoreSize;
StretchMode = StretchModeEnum.KeepAspectCentered; StretchMode = StretchModeEnum.KeepAspectCentered;
SetFrame(0); SetFrame(0);
} }
/// <summary>Steps the atlas region forward per frame interval and frees the node once the total duration has played.</summary> /// <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) public override void _Process(double delta)
{ {
elapsed += (float)delta; elapsed += (float)delta;
var frame = Mathf.Min((int)(elapsed / frameInterval), frameCount - 1); var frame = Mathf.Min((int)(elapsed / frameInterval), frameCount - 1);
if (frame != currentFrame) if (frame != currentFrame)
SetFrame(frame); SetFrame(frame);
if (elapsed >= totalDuration) if (elapsed >= totalDuration)
QueueFree(); QueueFree();
} }
private void SetFrame(int frame) private void SetFrame(int frame)
{ {
currentFrame = frame; currentFrame = frame;
atlas.Region = new Rect2(frame * frameWidth, 0, frameWidth, atlas.Region.Size.Y); atlas.Region = new Rect2(frame * frameWidth, 0, frameWidth, atlas.Region.Size.Y);
} }
} }
} }
+71 -71
View File
@@ -1,71 +1,71 @@
using Cthangover.Core.UI.Event; using Cthangover.Core.UI.Event;
using Godot; using Godot;
namespace Cthangover.Mods.Effects.Animation namespace Cthangover.Mods.Effects.Animation
{ {
/// <summary> /// <summary>
/// Shader-driven frame animation: drives _FrameIndex and _FrameCount uniforms /// Shader-driven frame animation: drives _FrameIndex and _FrameCount uniforms
/// on the TextureRect's ShaderMaterial rather than swapping textures. This means /// 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 /// all animation frames live in a single GPU texture atlas and the shader selects
/// the visible frame. Auto-destroys (QueueFree) after totalDuration elapses, /// the visible frame. Auto-destroys (QueueFree) after totalDuration elapses,
/// making it suitable for fire-and-forget VFX that don't need external cleanup. /// making it suitable for fire-and-forget VFX that don't need external cleanup.
/// </summary> /// </summary>
public partial class EffectAnimator : Control, IOnUpdateEvent public partial class EffectAnimator : Control, IOnUpdateEvent
{ {
[Export] private TextureRect image; [Export] private TextureRect image;
[Export] private float totalDuration = 5f; [Export] private float totalDuration = 5f;
[Export] private int frameCount = 5; [Export] private int frameCount = 5;
private ShaderMaterial materialInstance; private ShaderMaterial materialInstance;
private double startTime; private double startTime;
private float frameInterval; private float frameInterval;
private int currentFrame; private int currentFrame;
private static readonly StringName FrameIndexId = "_FrameIndex"; private static readonly StringName FrameIndexId = "_FrameIndex";
private static readonly StringName FrameCountId = "_FrameCount"; private static readonly StringName FrameCountId = "_FrameCount";
/// <summary>Precomputes the frame interval and pushes the frame count uniform into the shader material.</summary> /// <summary>Precomputes the frame interval and pushes the frame count uniform into the shader material.</summary>
public override void _Ready() public override void _Ready()
{ {
frameInterval = totalDuration / frameCount; frameInterval = totalDuration / frameCount;
materialInstance = image.Material as ShaderMaterial; materialInstance = image.Material as ShaderMaterial;
if (materialInstance != null) if (materialInstance != null)
materialInstance.SetShaderParameter(FrameCountId, frameCount); materialInstance.SetShaderParameter(FrameCountId, frameCount);
} }
/// <summary>Intentionally empty — the animator drives frames through the event controller instead of engine callbacks.</summary> /// <summary>Intentionally empty — the animator drives frames through the event controller instead of engine callbacks.</summary>
public override void _EnterTree() public override void _EnterTree()
{ {
} }
/// <summary>Starts the shader animation from frame 0, resetting the elapsed timer.</summary> /// <summary>Starts the shader animation from frame 0, resetting the elapsed timer.</summary>
public void Play() public void Play()
{ {
startTime = Godot.Time.GetTicksUsec() / 1_000_000.0; startTime = Godot.Time.GetTicksUsec() / 1_000_000.0;
currentFrame = 0; currentFrame = 0;
if (materialInstance != null) if (materialInstance != null)
materialInstance.SetShaderParameter(FrameIndexId, 0f); materialInstance.SetShaderParameter(FrameIndexId, 0f);
} }
/// <summary>Update priority. Default 1.</summary> /// <summary>Update priority. Default 1.</summary>
public int Priority => 1; 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> /// <summary>Advances the shader's _FrameIndex uniform each frame based on elapsed time. Self-destructs when <c>totalDuration</c> elapses.</summary>
public void OnUpdate() public void OnUpdate()
{ {
double now = Godot.Time.GetTicksUsec() / 1_000_000.0; double now = Godot.Time.GetTicksUsec() / 1_000_000.0;
var elapsed = now - startTime; var elapsed = now - startTime;
var newFrame = Mathf.Min((int)(elapsed / frameInterval), frameCount - 1); var newFrame = Mathf.Min((int)(elapsed / frameInterval), frameCount - 1);
if (newFrame != currentFrame && materialInstance != null) if (newFrame != currentFrame && materialInstance != null)
{ {
currentFrame = newFrame; currentFrame = newFrame;
materialInstance.SetShaderParameter(FrameIndexId, currentFrame); materialInstance.SetShaderParameter(FrameIndexId, currentFrame);
} }
if (elapsed >= totalDuration) if (elapsed >= totalDuration)
QueueFree(); QueueFree();
} }
} }
} }
+17 -17
View File
@@ -1,17 +1,17 @@
namespace Cthangover.Mods.Effects.Animation namespace Cthangover.Mods.Effects.Animation
{ {
/// <summary> /// <summary>
/// Specialisation of AnimationController where Pause() also calls Clear(), /// Specialisation of AnimationController where Pause() also calls Clear(),
/// treating "pause" as a hard reset. This is intentional for one-shot effects /// 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. /// that should clean up instantly rather than freezing on their last frame.
/// The "new" keyword on Pause() shadows the base implementation on purpose. /// The "new" keyword on Pause() shadows the base implementation on purpose.
/// </summary> /// </summary>
public partial class EffectController : AnimationController public partial class EffectController : AnimationController
{ {
/// <summary>Shadows the base Pause() and calls <see cref="AnimationController.Clear"/> instead, instantly resetting one-shot effects.</summary> /// <summary>Shadows the base Pause() and calls <see cref="AnimationController.Clear"/> instead, instantly resetting one-shot effects.</summary>
public new void Pause() public new void Pause()
{ {
Clear(); Clear();
} }
} }
} }
+82 -82
View File
@@ -1,82 +1,82 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Cthangover.Core.Factories; using Cthangover.Core.Factories;
using Cthangover.Core.Mods; using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers; using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Godot; using Godot;
namespace Cthangover.Mods.Effects namespace Cthangover.Mods.Effects
{ {
/// <summary> /// <summary>
/// Factory for <c>PackedScene</c> visual effects. Overrides /// Factory for <c>PackedScene</c> visual effects. Overrides
/// <c>LoadFromMods</c> to use <c>ResourceLoader.Load</c> from the file /// <c>LoadFromMods</c> to use <c>ResourceLoader.Load</c> from the file
/// system instead of <c>ConvertFromBytes</c>, because Godot's scene /// system instead of <c>ConvertFromBytes</c>, because Godot's scene
/// format is a proprietary binary blob that cannot be reconstructed /// format is a proprietary binary blob that cannot be reconstructed
/// from raw bytes in managed code. This imposes a hard constraint: /// from raw bytes in managed code. This imposes a hard constraint:
/// effect scenes are only loadable from <b>folder mods</b> — zip mods /// effect scenes are only loadable from <b>folder mods</b> — zip mods
/// skip them with a warning — so mod authors distributing visual effects /// skip them with a warning — so mod authors distributing visual effects
/// must ship them unpacked. /// must ship them unpacked.
/// <para/> /// <para/>
/// The single instance is a self-contained lazy singleton owned by this /// The single instance is a self-contained lazy singleton owned by this
/// mod assembly; consumers resolve it through <see cref="Instance"/>. /// mod assembly; consumers resolve it through <see cref="Instance"/>.
/// </summary> /// </summary>
public class EffectFactory : PrefabFactory<PackedScene> public class EffectFactory : PrefabFactory<PackedScene>
{ {
private static readonly Lazy<EffectFactory> instance = new(() => new EffectFactory()); private static readonly Lazy<EffectFactory> instance = new(() => new EffectFactory());
/// <summary>Process-wide instance; laziness matches the former AppServices slot.</summary> /// <summary>Process-wide instance; laziness matches the former AppServices slot.</summary>
public static EffectFactory Instance => instance.Value; public static EffectFactory Instance => instance.Value;
private EffectFactory() : base("effects", 64) { } private EffectFactory() : base("effects", 64) { }
/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/effects</c>.</summary> /// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/effects</c>.</summary>
public override string GroupName => "effects"; public override string GroupName => "effects";
/// <summary>Scene file extensions accepted during the mod scan.</summary> /// <summary>Scene file extensions accepted during the mod scan.</summary>
protected override List<string> Extensions { get; } = new() { ".tscn", ".scn" }; 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> /// <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) 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); GameLogger.Log("FACTORY", $"PackedScene cannot be constructed from bytes; use folder mods for '{id}'", LogLevel.Error);
return null; return null;
} }
/// <summary> /// <summary>
/// Loads the scene via <c>ResourceLoader</c> from the mod's real file /// Loads the scene via <c>ResourceLoader</c> from the mod's real file
/// system path — which only folder mods can provide; zip mods are /// system path — which only folder mods can provide; zip mods are
/// skipped with a warning. /// skipped with a warning.
/// </summary> /// </summary>
protected override PackedScene LoadFromMods(string id, string extension) protected override PackedScene LoadFromMods(string id, string extension)
{ {
var files = Files.Collect(GroupName); var files = Files.Collect(GroupName);
string matchedKey = null; string matchedKey = null;
foreach (var key in files.Keys) foreach (var key in files.Keys)
{ {
if (key == id || key.Equals(id + extension, StringComparison.OrdinalIgnoreCase)) if (key == id || key.Equals(id + extension, StringComparison.OrdinalIgnoreCase))
{ {
matchedKey = key; matchedKey = key;
break; break;
} }
} }
if (matchedKey == null || !files.TryGetValue(matchedKey, out var entry)) if (matchedKey == null || !files.TryGetValue(matchedKey, out var entry))
{ {
GameLogger.Log("FACTORY", $"effect '{id}' - not found in group '{GroupName}'!", LogLevel.Error); GameLogger.Log("FACTORY", $"effect '{id}' - not found in group '{GroupName}'!", LogLevel.Error);
return null; return null;
} }
var fsPath = ModManager.Instance.GetFileSystemPath(entry.ModId, entry.FullPath); var fsPath = ModManager.Instance.GetFileSystemPath(entry.ModId, entry.FullPath);
if (fsPath == null) if (fsPath == null)
{ {
GameLogger.Log("FACTORY", $"effect '{id}' - only folder mods support PackedScene (zip mod '{entry.ModId}' skipped)", LogLevel.Warning); GameLogger.Log("FACTORY", $"effect '{id}' - only folder mods support PackedScene (zip mod '{entry.ModId}' skipped)", LogLevel.Warning);
return null; return null;
} }
return ResourceLoader.Load<PackedScene>(fsPath); return ResourceLoader.Load<PackedScene>(fsPath);
} }
} }
} }
+21 -21
View File
@@ -1,21 +1,21 @@
using Cthangover.Core.Mods; using Cthangover.Core.Mods;
namespace Cthangover.Mods.Effects namespace Cthangover.Mods.Effects
{ {
/// <summary> /// <summary>
/// Engine-mod initializer for the effects subsystem. The factory is a /// Engine-mod initializer for the effects subsystem. The factory is a
/// self-contained lazy singleton resolved by consumers through /// self-contained lazy singleton resolved by consumers through
/// <c>EffectFactory.Instance</c>, so there is no core binding to /// <c>EffectFactory.Instance</c>, so there is no core binding to
/// re-establish on hot reload — the initializer exists to register the /// re-establish on hot reload — the initializer exists to register the
/// assembly with the mod pipeline and keep the migration shape uniform /// assembly with the mod pipeline and keep the migration shape uniform
/// with the other engine mods. /// with the other engine mods.
/// </summary> /// </summary>
public class EffectsModInitializer : IModInitializer public class EffectsModInitializer : IModInitializer
{ {
/// <summary>No per-mod setup is required.</summary> /// <summary>No per-mod setup is required.</summary>
public void OnModLoaded(string modId) { } public void OnModLoaded(string modId) { }
/// <summary>Nothing to bind: the factory lazy self-initializes on first use.</summary> /// <summary>Nothing to bind: the factory lazy self-initializes on first use.</summary>
public void OnModResourcesReady() { } public void OnModResourcesReady() { }
} }
} }
+13 -13
View File
@@ -1,13 +1,13 @@
namespace Cthangover.Mods.Effects namespace Cthangover.Mods.Effects
{ {
/// <summary> /// <summary>
/// Migration marker for the coverage pipeline (<c>SubsystemsBridge</c> /// Migration marker for the coverage pipeline (<c>SubsystemsBridge</c>
/// reflection scan): declares the effects subsystem fully moved out of /// reflection scan): declares the effects subsystem fully moved out of
/// the core assembly into this engine mod. The id must match the /// the core assembly into this engine mod. The id must match the
/// <c>DeclaredTests</c> entry in <c>SubsystemsCoverageTestSetup</c>. /// <c>DeclaredTests</c> entry in <c>SubsystemsCoverageTestSetup</c>.
/// </summary> /// </summary>
public static class SubsystemRegistration public static class SubsystemRegistration
{ {
public static string SubsystemId => "effects"; public static string SubsystemId => "effects";
} }
} }