upd: sync local changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
iisaevandClaude Opus 5.5 committed 2026-10-16 16:18:16 +00:00
1 parent 7a30f4b8c5
commit 6884cd5374
36 files changed
+3363 -6

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI.Messages;
namespace Cthangover.Mods.Interface.Messages
{
/// <summary>
/// Static factory for floating UI messages (like "Quest updated" or item
/// pickup notifications). Manages a cycling position index of 0-5 so
/// consecutive messages stack vertically without overlapping. Each message
/// is instantiated from message.tscn and parented to the Background node
/// (found via scene tree search). Messages auto-fade and self-destruct
/// via UiMessage's OnUpdate.
/// </summary>
public static class MessagesHelper
{
private const int maxMessageIndex = 5;
private static int lastMessageIndex = 0;
private static ModScenePrefab prefab;
private static int GetNextIndex(int index)
{
if (index++ >= maxMessageIndex)
index = 0;
return index;
}
private static float GetPositionByLastIndex(int index)
{
return (index - 1) * 60;
}
/// <summary>
/// Resets the cycling position index to 0. Call when UI state resets
/// to prevent messages from accumulating at high Y positions.
/// </summary>
public static void ClearLastIndex()
{
lastMessageIndex = 0;
}
/// <summary>
/// Shows a message in the default warning colour at the next vertical slot.
/// </summary>
/// <param name="text">The message text to display.</param>
/// <param name="speed">Fade speed multiplier (higher = faster fade, default 1).</param>
public static void AddMessage(string text, float speed = 1f)
{
AddMessage(text, UiTokens.Warning, speed);
}
/// <summary>
/// Shows a message with a custom color at the next vertical slot.
/// Messages auto-fade and self-destruct after ~3 seconds of display plus
/// a fade duration determined by <paramref name="speed"/>.
/// </summary>
/// <param name="text">The message text to display.</param>
/// <param name="color">The text color.</param>
/// <param name="speed">Fade speed multiplier (higher = faster fade, default 1).</param>
public static void AddMessage(string text, Color color, float speed = 1f)
{
var nextIndex = GetNextIndex(lastMessageIndex);
AddMessage(GetPositionByLastIndex(nextIndex), text, color, speed);
lastMessageIndex = nextIndex;
}
/// <summary>
/// Shows a message in the default warning colour at a specific vertical
/// position, bypassing the cycling slot system.
/// </summary>
/// <param name="position">Y-offset in pixels for this message.</param>
/// <param name="text">The message text to display.</param>
/// <param name="speed">Fade speed multiplier (default 1).</param>
public static void AddMessage(float position, string text, float speed = 1f)
{
AddMessage(position, text, UiTokens.Warning, speed);
}
/// <summary>
/// Instantiates a <see cref="UiMessage"/> from <c>message.tscn</c>, parents
/// it to the Background node, and configures it for display and auto-fade.
/// </summary>
/// <param name="position">Y-offset in pixels for this message.</param>
/// <param name="text">The message text to display.</param>
/// <param name="color">The text color.</param>
/// <param name="speed">Fade speed multiplier (default 1).</param>
public static void AddMessage(float position, string text, Color color, float speed = 1f)
{
if (prefab == null)
prefab = new ModScenePrefab("scenes/ui/message.tscn");
var instance = prefab.Instantiate<UiMessage>();
var background = SceneContextNode.FindNode<Control>("Background");
if (background == null)
{
background = SceneContextNode.Instance?.GetTree()?.GetFirstNodeInGroup("Background") as Control;
if (background == null)
{
GameLogger.Log("UI", "Background node not found for messages", LogLevel.Error);
return;
}
}
background.AddChild(instance);
instance.Setup(position, text, speed, color);
ModEventBus.Publish(new NotificationRequestedEvent
{
Text = text,
Category = "message",
});
}
}
}
+206
View File
@@ -0,0 +1,206 @@
using System.Collections.Generic;
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Event;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Core.UI.Messages;
namespace Cthangover.Mods.Interface.Messages
{
/// <summary>
/// Manages the visual notification list in the top-left corner of the scene.
/// Displays up to <c>MaxVisibleSlots</c> notification banners simultaneously.
/// Pending items wait in <see cref="NotificationBuffer"/> and are pulled
/// into visible slots as they become free. Each visible notification has a
/// 10-second lifetime; when it expires, it fades out and the remaining
/// notifications shift upward with a smooth animation.
/// </summary>
public partial class NotificationPanel : Control, IOnUpdateEvent
{
private const int MaxVisibleSlots = 4;
private const float SlotHeight = 40f;
private const float SlotSpacing = 6f;
private const float DisplayDuration = 10f;
private const float FadeInDuration = 0.3f;
private const float FadeOutDuration = 0.5f;
private readonly List<NotificationSlot> _slots = new();
private readonly List<int> _dyingIndices = new();
/// <summary>Update priority <c>1</c> — runs after the highest-priority listeners.</summary>
public int Priority => 1;
/// <summary>Subscribes to notification requests; text is queued in <see cref="NotificationBuffer"/>, not shown immediately.</summary>
public override void _EnterTree()
{
ModEventBus.Subscribe<NotificationRequestedEvent>(OnNotificationRequested);
}
/// <summary>Registers with the event controller for per-frame slot updates.</summary>
public override void _Ready()
{
MouseFilter = MouseFilterEnum.Ignore;
if (SceneContextNode.Instance != null)
{
var eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.AddUpdateEventListener(this);
}
}
/// <summary>Unsubscribes from notification requests and detaches from the update controller.</summary>
public override void _ExitTree()
{
ModEventBus.Unsubscribe<NotificationRequestedEvent>(OnNotificationRequested);
if (SceneContextNode.Instance != null)
{
var eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.RemoveUpdateEventListener(this);
}
}
/// <summary>
/// Per-frame update: expires old slots, collects dead ones,
/// pulls new notifications from the buffer, and repositions
/// all visible slots with a smooth slide animation.
/// </summary>
public void OnUpdate()
{
var currentTime = (float)Godot.Time.GetTicksMsec() / 1000f;
for (int i = _slots.Count - 1; i >= 0; i--)
{
var slot = _slots[i];
var elapsed = currentTime - slot.DisplayedAt;
if (elapsed >= DisplayDuration && !slot.IsFadingOut)
{
FadeOutSlot(i);
}
}
_dyingIndices.Clear();
for (int i = 0; i < _slots.Count; i++)
{
if (_slots[i].IsDead)
_dyingIndices.Add(i);
}
for (int j = _dyingIndices.Count - 1; j >= 0; j--)
{
var idx = _dyingIndices[j];
_slots[idx].Container.QueueFree();
_slots.RemoveAt(idx);
}
while (_slots.Count < MaxVisibleSlots && NotificationBuffer.TryDequeue(out var text))
{
CreateSlot(text, currentTime);
}
RepositionSlots();
}
private void CreateSlot(string text, float time)
{
var container = new Panel
{
SizeFlagsHorizontal = SizeFlags.ShrinkBegin,
MouseFilter = MouseFilterEnum.Ignore,
};
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.ToastBg, UiTokens.Line);
style.ContentMarginLeft = UiTokens.Space4;
style.ContentMarginRight = UiTokens.Space4;
style.ContentMarginTop = UiTokens.Space2;
style.ContentMarginBottom = UiTokens.Space2;
container.AddThemeStyleboxOverride("panel", style);
var label = new Label
{
Text = text,
Modulate = UiTokens.Alpha(Colors.White, 0f),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
};
label.AddThemeFontSizeOverride("font_size", UiTokens.FontLead);
label.AddThemeColorOverride("font_color", UiTokens.Ink);
container.AddChild(label);
AddChild(container);
var slot = new NotificationSlot
{
Container = container,
Text = text,
DisplayedAt = time,
};
_slots.Add(slot);
var tween = CreateTween();
tween.TweenProperty(label, "modulate", Colors.White, FadeInDuration);
}
private void FadeOutSlot(int index)
{
var slot = _slots[index];
slot.IsFadingOut = true;
var label = slot.Container.GetChildOrNull<Label>(0);
if (label != null)
{
var tween = CreateTween();
tween.TweenProperty(label, "modulate", UiTokens.Alpha(Colors.White, 0f), FadeOutDuration);
tween.TweenCallback(Callable.From(() =>
{
slot.IsDead = true;
}));
}
else
{
slot.IsDead = true;
}
}
private void RepositionSlots()
{
for (int i = 0; i < _slots.Count; i++)
{
var targetY = i * (SlotHeight + SlotSpacing);
var container = _slots[i].Container;
if (Mathf.Abs(container.Position.Y - targetY) > 0.5f)
{
var tween = CreateTween();
tween.TweenProperty(container, "position",
new Vector2(container.Position.X, targetY), 0.2f)
.SetTrans(Tween.TransitionType.Quad)
.SetEase(Tween.EaseType.Out);
}
}
}
private void OnNotificationRequested(NotificationRequestedEvent evt)
{
NotificationBuffer.Add(evt.Text);
}
private sealed class NotificationSlot
{
/// <summary>Visual container of the banner.</summary>
public Panel Container;
/// <summary>Message text kept for the banner label and re-use.</summary>
public string Text;
/// <summary>Timestamp of the moment the banner became visible; drives expiry.</summary>
public float DisplayedAt;
/// <summary>True while the exit tween is playing.</summary>
public bool IsFadingOut;
/// <summary>Set when the fade-out finished and the slot can be recycled.</summary>
public bool IsDead;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Event;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Core.UI.Messages;
namespace Cthangover.Mods.Interface.Messages
{
/// <summary>
/// Floating text message with auto-fade. Appears at a vertical position,
/// stays visible for 3 seconds, then fades over 2/Speed seconds. Self-
/// destructs (QueueFree) when fully transparent. Registers with
/// SceneEventController for per-frame updates; unregisters on tree exit
/// to prevent leaks. Speed affects only the fade duration, not the display
/// duration, so faster speed = shorter fade = message disappears more quickly.
/// </summary>
public partial class UiMessage : Control, IOnUpdateEvent
{
[Export] private Label textField;
/// <summary>Tint of the message text; defaults to the warning colour.</summary>
[Export] public Color Color { get; set; } = UiTokens.Warning;
/// <summary>Fade speed multiplier — 1 is normal; higher values shorten the fade-out.</summary>
[Export] public float Speed { get; set; } = 1f;
private float timestamp;
/// <summary>
/// The text content displayed by this message. Reads/writes the child
/// <c>textField</c> label directly.
/// </summary>
public string Text
{
get => textField.Text;
set => textField.Text = value;
}
/// <summary>
/// Initializes the message: positions it, sets text/speed/color, records
/// the start timestamp, and registers with the <see cref="SceneEventController"/>
/// for per-frame updates. Called by <see cref="MessagesHelper"/>.
/// </summary>
/// <param name="pos">Vertical Y-offset in pixels.</param>
/// <param name="text">The message text.</param>
/// <param name="speed">Fade speed multiplier.</param>
/// <param name="color">The text color.</param>
public void Setup(float pos, string text, float speed, Color color)
{
OffsetLeft = 50;
OffsetRight = -10;
Size = new Vector2(Size.X, 50);
Position = new Vector2(0, pos + 200);
Text = text;
Speed = speed;
Color = color;
timestamp = (float)Godot.Time.GetTicksMsec() / 1000f;
if (SceneContextNode.Instance != null)
{
var eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.AddUpdateEventListener(this);
}
}
/// <summary>Detaches from the event controller so a freed message stops receiving updates.</summary>
public override void _ExitTree()
{
if (SceneContextNode.Instance != null)
{
var eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.RemoveUpdateEventListener(this);
}
}
/// <summary>
/// Priority <c>1</c> — lightweight enough for many concurrent messages.
/// </summary>
public int Priority => 1;
/// <summary>
/// Per-frame update: displays for 3 seconds, then fades out over
/// <c>2 / <see cref="Speed"/></c> seconds. Self-destructs via
/// <c>QueueFree</c> when fully transparent.
/// </summary>
public void OnUpdate()
{
var currentTime = (float)Godot.Time.GetTicksMsec() / 1000f;
var elapsed = currentTime - timestamp;
if (elapsed < 3f)
return;
var fadeDuration = 2f / Speed;
var fadeElapsed = elapsed - 3f;
var progress = Mathf.Clamp(fadeElapsed / fadeDuration, 0f, 1f);
textField.SelfModulate = UiTokens.Alpha(Colors.White, 1f - progress);
if (progress >= 1f)
QueueFree();
}
}
}