207 lines
7.4 KiB
C#
207 lines
7.4 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|