C#:
#nullable disable
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Threading;
using Facepunch;
using Network;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Oxide.Core.Libraries.Covalence;
using Oxide.Game.Rust.Cui;
using UnityEngine;
using static RpcTarget;
namespace Oxide.Plugins;
[Info("NCP", "crash", "1.4.2")]
[Description("Advanced notification system with individual anchoring, collision avoidance, and slide animations")]
public class NCP : RustPlugin
{
#region Fields & Constants
private const string Layer = "UI.NCP";
private const string EditLayer = "UI.NCP.Edit";
private static NCP _instance;
private static readonly Dictionary<string, string> _cachedElementNames = new();
private readonly Dictionary<ulong, NotificationUI> _activeNotifications = new();
private readonly Dictionary<ulong, int> _editingSessions = new();
private readonly Dictionary<ulong, string> _persistentPreviewIds = new();
private readonly Dictionary<ulong, int> _currentEditTab = new();
private readonly Dictionary<ulong, string> _pendingCustomSounds = new();
private static readonly Effect _cachedEffect = new Effect();
private const string PermSee = "ncp.see", PermNotify = "ncp.notify",
PermPlayer = "ncp.player", PermAll = "ncp.allplayers",
PermEdit = "ncp.edit";
private static readonly WaitForSeconds UpdateInterval = CoroutineEx.waitForSeconds(0.5f);
private static readonly string[] AnchorNames =
{ "TopLeft", "TopRight", "BottomLeft", "BottomRight", "MiddleLeft", "MiddleRight" };
private static readonly AnchorPosition[] AnchorValues =
{
AnchorPosition.TopLeft, AnchorPosition.TopRight, AnchorPosition.BottomLeft,
AnchorPosition.BottomRight, AnchorPosition.MiddleLeft, AnchorPosition.MiddleRight
};
private static readonly string[] SlideDirectionNames =
{ "None", "Left", "Right", "Top", "Bottom", "BottomLeft", "BottomRight", "TopLeft", "TopRight" };
private enum AnchorPosition
{
TopLeft,
TopRight,
BottomLeft,
BottomRight,
MiddleLeft,
MiddleRight
}
private enum SlideDirection
{
None,
Left,
Right,
Top,
Bottom,
BottomLeft,
BottomRight,
TopLeft,
TopRight
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void AddUI(BasePlayer player, CuiElementContainer container)
=> CommunityEntity.ServerInstance.ClientRPC(
Player("AddUI", player.net.connection), container.ToJson());
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void DestroyUI(BasePlayer player, string name)
=> CommunityEntity.ServerInstance.ClientRPC(
Player("DestroyUI", player.net.connection), name);
#endregion
#region Configuration
private Configuration _config;
private sealed class Configuration
{
[JsonProperty("Permission (empty = no permission required)")]
public string Permission = "";
[JsonProperty("Notification Types")]
public Dictionary<int, NotificationType> Types = new Dictionary<int, NotificationType>
{
[0] = new NotificationType
{
Enabled = true,
Title = "Information",
Anchor = AnchorPosition.TopRight,
MaxVisible = 3,
IconAsset = "assets/icons/info.png",
BackgroundColor = new IColor("#1a1a1a", 92),
EnableGradient = true,
GradientColor = new IColor("#4A90E2", 20),
Sprite = "assets/content/ui/ui.background.transparent.linearltr.tga",
Material = "Assets/Icons/IconMaterial.mat",
IconBgColor = new IColor("#4A90E2", 80),
BorderColor = new IColor("#4A90E2", 50),
TitleColor = new IColor("#FFFFFF", 80),
TextColor = new IColor("#CCCCCC", 85),
TitleFontSize = 12,
MessageFontSize = 8,
Duration = 5f,
FadeIn = 0.3f,
FadeOut = 1.2f,
Effect = "assets/bundled/prefabs/fx/notice/item.select.fx.prefab",
XOffset = -15f,
YOffset = -15f,
Spacing = 5f,
TypeSpacing = 10f,
MainOffsetMin = "0 0",
MainOffsetMax = "0 0",
IconOffsetMin = "8 -14",
IconOffsetMax = "36 14",
TitleOffsetMin = "42 0",
TitleOffsetMax = "-10 0",
MessageOffsetMin = "42 0",
MessageOffsetMax = "-10 0",
EnableSlide = true,
Slide = SlideDirection.Left,
SlideDistance = 300f
},
[1] = new NotificationType
{
Enabled = true,
Title = "Warning",
Anchor = AnchorPosition.TopRight,
MaxVisible = 2,
IconAsset = "assets/icons/warning.png",
BackgroundColor = new IColor("#1a1a1a", 92),
EnableGradient = true,
GradientColor = new IColor("#E2A04A", 20),
Sprite = "assets/content/ui/ui.background.transparent.linearltr.tga",
Material = "Assets/Icons/IconMaterial.mat",
IconBgColor = new IColor("#E2A04A", 80),
BorderColor = new IColor("#E2A04A", 50),
TitleColor = new IColor("#FFFFFF", 80),
TextColor = new IColor("#CCCCCC", 85),
TitleFontSize = 12,
MessageFontSize = 8,
Duration = 7f,
FadeIn = 0.3f,
FadeOut = 1.2f,
Effect = "assets/bundled/prefabs/fx/notice/item.select.fx.prefab",
XOffset = -15f,
YOffset = -15f,
Spacing = 5f,
TypeSpacing = 10f,
MainOffsetMin = "0 0",
MainOffsetMax = "0 0",
IconOffsetMin = "8 -14",
IconOffsetMax = "36 14",
TitleOffsetMin = "42 0",
TitleOffsetMax = "-10 0",
MessageOffsetMin = "42 0",
MessageOffsetMax = "-10 0",
EnableSlide = true,
Slide = SlideDirection.Right,
SlideDistance = 300f
},
[2] = new NotificationType
{
Enabled = true,
Title = "Error",
Anchor = AnchorPosition.BottomLeft,
MaxVisible = 4,
IconAsset = "assets/icons/close.png",
BackgroundColor = new IColor("#1a1a1a", 92),
EnableGradient = true,
GradientColor = new IColor("#CE422B", 25),
Sprite = "assets/content/ui/ui.background.transparent.linearltr.tga",
Material = "Assets/Icons/IconMaterial.mat",
IconBgColor = new IColor("#CE422B", 85),
BorderColor = new IColor("#CE422B", 60),
TitleColor = new IColor("#FFFFFF", 80),
TextColor = new IColor("#CCCCCC", 85),
TitleFontSize = 12,
MessageFontSize = 8,
Duration = 8f,
FadeIn = 0.3f,
FadeOut = 1.2f,
Effect = "assets/bundled/prefabs/fx/notice/item.select.fx.prefab",
XOffset = 15f,
YOffset = 15f,
Spacing = 5f,
TypeSpacing = 10f,
MainOffsetMin = "0 0",
MainOffsetMax = "0 0",
IconOffsetMin = "8 -14",
IconOffsetMax = "36 14",
TitleOffsetMin = "42 0",
TitleOffsetMax = "-10 0",
MessageOffsetMin = "42 0",
MessageOffsetMax = "-10 0",
EnableSlide = true,
Slide = SlideDirection.Bottom,
SlideDistance = 300f
},
[3] = new NotificationType
{
Enabled = true,
Title = "Success",
Anchor = AnchorPosition.BottomRight,
MaxVisible = 5,
IconAsset = "assets/icons/thumbsup.png",
BackgroundColor = new IColor("#1a1a1a", 92),
EnableGradient = true,
GradientColor = new IColor("#7ED321", 20),
Sprite = "assets/content/ui/ui.background.transparent.linearltr.tga",
Material = "Assets/Icons/IconMaterial.mat",
IconBgColor = new IColor("#7ED321", 80),
BorderColor = new IColor("#7ED321", 50),
TitleColor = new IColor("#FFFFFF", 80),
TextColor = new IColor("#CCCCCC", 85),
TitleFontSize = 12,
MessageFontSize = 8,
Duration = 5f,
FadeIn = 0.3f,
FadeOut = 1.2f,
Effect = "assets/bundled/prefabs/fx/notice/item.select.fx.prefab",
XOffset = -15f,
YOffset = 15f,
Spacing = 5f,
TypeSpacing = 10f,
MainOffsetMin = "0 0",
MainOffsetMax = "0 0",
IconOffsetMin = "8 -14",
IconOffsetMax = "36 14",
TitleOffsetMin = "42 0",
TitleOffsetMax = "-10 0",
MessageOffsetMin = "42 0",
MessageOffsetMax = "-10 0",
EnableSlide = true,
Slide = SlideDirection.BottomRight,
SlideDistance = 300f
}
};
}
private sealed class NotificationType
{
[JsonProperty("Enabled")] public bool Enabled = true;
[JsonProperty("Title")] public string Title = "Notification";
[JsonProperty("Anchor Position")]
[JsonConverter(typeof(StringEnumConverter))] public AnchorPosition Anchor = AnchorPosition.TopRight;
[JsonProperty("Max Visible")] public int MaxVisible = 3;
[JsonProperty("Icon Asset")] public string IconAsset = "assets/icons/info.png";
[JsonProperty("Background Color")] public IColor BackgroundColor = new IColor("#1a1a1a", 95);
[JsonProperty("Enable Gradient")] public bool EnableGradient = true;
[JsonProperty("Gradient Color")] public IColor GradientColor = new IColor("#CE422B", 25);
[JsonProperty("Sprite")] public string Sprite = "assets/content/ui/ui.background.transparent.linearltr.tga";
[JsonProperty("Material")] public string Material = "Assets/Icons/IconMaterial.mat";
[JsonProperty("Icon Background Color")] public IColor IconBgColor = new IColor("#CE422B", 85);
[JsonProperty("Border Color")] public IColor BorderColor = new IColor("#CE422B", 60);
[JsonProperty("Title Color")] public IColor TitleColor = new IColor("#FFFFFF", 85);
[JsonProperty("Text Color")] public IColor TextColor = new IColor("#CCCCCC", 90);
[JsonProperty("Title Font Size")] public int TitleFontSize = 12;
[JsonProperty("Message Font Size")] public int MessageFontSize = 8;
[JsonProperty("Duration")] public float Duration = 5f;
[JsonProperty("Fade In")] public float FadeIn = 0.3f;
[JsonProperty("Fade Out")] public float FadeOut = 1.2f;
[JsonProperty("Sound Effect")] public string Effect = "";
[JsonProperty("X Offset")] public float XOffset = -15f;
[JsonProperty("Y Offset")] public float YOffset = -15f;
[JsonProperty("Spacing")] public float Spacing = 5f;
[JsonProperty("Type Spacing (spacing between different types in same anchor)")] public float TypeSpacing = 10f;
[JsonProperty("Title Anchor Min")] public string TitleAnchorMin = "0 0.5";
[JsonProperty("Title Anchor Max")] public string TitleAnchorMax = "1 0.9";
[JsonProperty("Message Anchor Min")] public string MessageAnchorMin = "0 0.1";
[JsonProperty("Message Anchor Max")] public string MessageAnchorMax = "1 0.5";
[JsonProperty("Icon Anchor Min")] public string IconAnchorMin = "0 0.5";
[JsonProperty("Icon Anchor Max")] public string IconAnchorMax = "0 0.5";
[JsonProperty("Main Panel Offset Min")] public string MainOffsetMin = "0 0";
[JsonProperty("Main Panel Offset Max")] public string MainOffsetMax = "0 0";
[JsonProperty("Container Width")] public float ContainerWidth = 240f;
[JsonProperty("Container Height")] public float ContainerHeight = 50f;
[JsonProperty("Container X Offset")] public float ContainerXOffset;
[JsonProperty("Container Y Offset")] public float ContainerYOffset;
[JsonProperty("Icon Offset Min")] public string IconOffsetMin = "8 -14";
[JsonProperty("Icon Offset Max")] public string IconOffsetMax = "36 14";
[JsonProperty("Title Offset Min")] public string TitleOffsetMin = "42 0";
[JsonProperty("Title Offset Max")] public string TitleOffsetMax = "-10 0";
[JsonProperty("Message Offset Min")] public string MessageOffsetMin = "42 0";
[JsonProperty("Message Offset Max")] public string MessageOffsetMax = "-10 0";
[JsonProperty("Enable Slide Effect")] public bool EnableSlide = true;
[JsonProperty("Slide Direction")]
[JsonConverter(typeof(StringEnumConverter))] public SlideDirection Slide = SlideDirection.Left;
[JsonProperty("Slide Distance")] public float SlideDistance = 300f;
[JsonProperty("Slide In Duration")] public float SlideInDuration = 0.3f;
[JsonProperty("Slide Out Direction")]
[JsonConverter(typeof(StringEnumConverter))] public SlideDirection SlideOut = SlideDirection.None;
[JsonProperty("Slide Out Distance")] public float SlideOutDistance = 300f;
[JsonProperty("Slide Out Duration")] public float SlideOutDuration = 0.3f;
[JsonIgnore] private string _titleUpper;
[JsonIgnore] public string TitleUpper => _titleUpper ??= Title.ToUpper();
}
private sealed class IColor
{
[JsonProperty("HEX")] public string Hex;
[JsonProperty("Opacity (0 - 100)")] public float Alpha;
[JsonIgnore] private string _computed;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Invalidate() => _computed = null;
public string Get()
{
if (_computed != null) return _computed;
if (string.IsNullOrEmpty(Hex)) Hex = "#FFFFFF";
var str = Hex.Trim('#');
if (str.Length != 6) throw new Exception(Hex);
var r = byte.Parse(str[..2], NumberStyles.HexNumber);
var g = byte.Parse(str.Substring(2, 2), NumberStyles.HexNumber);
var b = byte.Parse(str.Substring(4, 2), NumberStyles.HexNumber);
_computed = $"{(double)r / 255} {(double)g / 255} {(double)b / 255} {Alpha / 100}";
return _computed;
}
public IColor(string hex, float alpha)
{
Hex = hex;
Alpha = alpha;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static NotificationType GetTypeConfig(int type)
=> _instance._config.Types.TryGetValue(type, out var cfg) ? cfg : _instance._config.Types[0];
protected override void LoadConfig()
{
base.LoadConfig();
try
{
_config = Config.ReadObject<Configuration>();
if (_config == null) throw new Exception();
SaveConfig();
}
catch
{
PrintError("Configuration file is corrupt! Using default values.");
LoadDefaultConfig();
}
}
protected override void LoadDefaultConfig() => _config = new Configuration();
protected override void SaveConfig() => Config.WriteObject(_config);
#endregion
#region Core Hooks
private void OnServerInitialized()
{
_instance = this;
RegisterPermissions();
AddCovalenceCommand("ncp.show", nameof(CmdShow));
AddCovalenceCommand("ncp.player", nameof(CmdPlayer));
AddCovalenceCommand("ncp.allplayers", nameof(CmdAll));
AddCovalenceCommand("ncp.edit", nameof(CmdEdit));
}
private void Unload()
{
var notifications = Pool.Get<List<NotificationUI>>();
notifications.AddRange(_activeNotifications.Values);
foreach (var notification in notifications)
notification?.Destroy();
Pool.FreeUnmanaged(ref notifications);
foreach (var player in BasePlayer.activePlayerList)
{
DestroyUI(player, Layer);
DestroyUI(player, EditLayer);
}
_instance = null;
}
#endregion
#region Commands
private void CmdShow(IPlayer player, string command, string[] args)
{
if (!CanUse(player, PermNotify)) return;
if (args.Length < 2)
{
player.Reply($"Usage: /{command} <type> <message>");
return;
}
if (!int.TryParse(args[0], out var type))
{
player.Reply("Invalid type! Must be a number.");
return;
}
var message = string.Join(" ", args, 1, args.Length - 1);
if (player.Object is BasePlayer basePlayer) SendNotify(basePlayer, type, message);
}
private void CmdPlayer(IPlayer player, string command, string[] args)
{
if (!CanUse(player, PermPlayer)) return;
if (args.Length < 3)
{
player.Reply($"Usage: /{command} <player> <type> <message>");
return;
}
if (covalence.Players.FindPlayer(args[0])?.Object is not BasePlayer target)
{
player.Reply($"Player '{args[0]}' not found!");
return;
}
if (!int.TryParse(args[1], out var type))
{
player.Reply("Invalid type! Must be a number.");
return;
}
var message = string.Join(" ", args, 2, args.Length - 2);
SendNotify(target, type, message);
player.Reply($"Notification sent to {target.displayName}");
}
private void CmdAll(IPlayer player, string command, string[] args)
{
if (!CanUse(player, PermAll)) return;
if (args.Length < 2)
{
player.Reply($"Usage: /{command} <type> <message>");
return;
}
if (!int.TryParse(args[0], out var type))
{
player.Reply("Invalid type! Must be a number.");
return;
}
var message = string.Join(" ", args, 1, args.Length - 1);
SendNotifyAllPlayers(type, message);
player.Reply($"Notification sent to all online players ({BasePlayer.activePlayerList.Count})");
}
private void CmdEdit(IPlayer player, string command, string[] args)
{
if (!CanUse(player, PermEdit)) return;
if (player.Object is not BasePlayer basePlayer) return;
if (args.Length < 1)
{
player.Reply("Usage: /ncp.edit <type> - Available types: 0, 1, 2, 3");
return;
}
if (!int.TryParse(args[0], out var type) || !_config.Types.ContainsKey(type))
{
player.Reply("Invalid type! Available types: 0, 1, 2, 3");
return;
}
_editingSessions[basePlayer.userID] = type;
ShowEditUI(basePlayer, type);
}
#endregion
#region Notification System Core
private static int _nextNotifId;
private sealed class NotificationData
{
public int Type;
public string Message;
public float StartTime;
public string Id = Interlocked.Increment(ref _nextNotifId).ToString();
public bool IsEditMode;
public bool IsSliding;
public float SlideEndTime;
public bool IsSlidingOut;
public float SlideOutEndTime;
public bool IsFadingOut;
public float FadeOutEndTime;
}
private class NotificationUI : FacepunchBehaviour
{
private BasePlayer _player;
private readonly Dictionary<int, Queue<NotificationData>> _typeQueues = new();
private readonly Dictionary<int, List<NotificationData>> _typeActive = new();
private readonly CuiElementContainer _container = new();
private readonly CuiElementContainer _slideContainer = new();
private readonly Dictionary<string, string> _renderedPositions = new();
private readonly HashSet<string> _activeIds = new();
private readonly List<string> _toRemove = new();
private readonly HashSet<string> _justFinishedSliding = new();
private readonly HashSet<int> _slideOutCompletedTypes = new();
private readonly List<(NotificationData notification, CuiRectTransformComponent position)> _positionedResult = new();
private readonly Dictionary<AnchorPosition, List<(int type, NotificationData notification)>> _anchorGroups = new();
private readonly List<List<(int, NotificationData)>> _anchorGroupPool = new();
private bool _layerCreated;
private Coroutine _updateCoroutine;
private Coroutine _slideCoroutine;
private void Awake()
{
_player = GetComponent<BasePlayer>();
_instance._activeNotifications[_player.userID] = this;
_updateCoroutine = StartCoroutine(UpdateNotifications());
foreach (var typeId in _instance._config.Types.Keys)
{
_typeQueues[typeId] = new Queue<NotificationData>();
_typeActive[typeId] = new List<NotificationData>();
}
}
private void OnDestroy()
{
if (_updateCoroutine is not null) StopCoroutine(_updateCoroutine);
if (_slideCoroutine is not null) StopCoroutine(_slideCoroutine);
DestroyUI(_player, Layer);
_renderedPositions.Clear();
_layerCreated = false;
_instance?._activeNotifications.Remove(_player.userID);
}
public void AddNotification(int type, string message)
{
var config = GetTypeConfig(type);
var data = new NotificationData
{
Type = type,
Message = message,
StartTime = Time.time
};
if (config.EnableSlide && config.Slide != SlideDirection.None)
{
data.IsSliding = true;
data.SlideEndTime = Time.time + config.SlideInDuration;
}
if (!_typeQueues.TryGetValue(type, out var queue))
{
queue = new Queue<NotificationData>();
_typeQueues[type] = queue;
_typeActive[type] = new List<NotificationData>();
}
queue.Enqueue(data);
ProcessQueue(type);
PlayEffect(type);
if (data.IsSliding && _slideCoroutine == null)
_slideCoroutine = StartCoroutine(SlideUpdateLoop());
}
private void ProcessQueue(int type)
{
var config = GetTypeConfig(type);
var maxVisible = config.MaxVisible;
if (!_typeQueues.TryGetValue(type, out var queue) ||
!_typeActive.TryGetValue(type, out var active))
return;
while (queue.Count > 0 && active.Count < maxVisible)
{
var notification = queue.Dequeue();
active.Add(notification);
}
UpdateUI();
}
private IEnumerator UpdateNotifications()
{
while (true)
{
yield return UpdateInterval;
var hasChanges = false;
foreach (var kvp in _typeActive)
{
var type = kvp.Key;
var active = kvp.Value;
for (var i = active.Count - 1; i >= 0; i--)
{
var notif = active[i];
if (notif.IsEditMode) continue;
if (notif.IsFadingOut)
{
if (Time.time >= notif.FadeOutEndTime)
{
active.RemoveAt(i);
hasChanges = true;
}
continue;
}
if (notif.IsSlidingOut) continue;
var typeConfig = GetTypeConfig(notif.Type);
if (!(Time.time - notif.StartTime >= typeConfig.Duration)) continue;
if (typeConfig.EnableSlide && typeConfig.SlideOut != SlideDirection.None)
{
notif.IsSlidingOut = true;
notif.SlideOutEndTime = Time.time + typeConfig.SlideOutDuration;
_slideCoroutine ??= StartCoroutine(SlideUpdateLoop());
continue;
}
if (typeConfig.FadeOut > 0f && !typeConfig.EnableSlide)
{
InvalidateRendered(notif.Id);
notif.IsFadingOut = true;
notif.FadeOutEndTime = Time.time + typeConfig.FadeOut;
continue;
}
active.RemoveAt(i);
hasChanges = true;
}
if (hasChanges) ProcessQueue(type);
}
if (!hasChanges) continue;
var anyActive = false;
foreach (var list in _typeActive.Values)
{
if (list.Count <= 0) continue;
anyActive = true;
break;
}
if (anyActive) continue;
var anyQueued = false;
foreach (var queue in _typeQueues.Values)
{
if (queue.Count <= 0) continue;
anyQueued = true;
break;
}
if (anyQueued) continue;
Destroy();
yield break;
}
}
#region Slide Animation
private bool HasSlidingNotifications()
{
foreach (var kvp in _typeActive)
{
var active = kvp.Value;
foreach (var t in active)
{
if (t.IsSliding || t.IsSlidingOut) return true;
}
}
return false;
}
private void UpdateSlidingState()
{
foreach (var kvp in _typeActive)
{
var active = kvp.Value;
for (var i = active.Count - 1; i >= 0; i--)
{
var notif = active[i];
if (notif.IsSliding && Time.time >= notif.SlideEndTime)
{
notif.IsSliding = false;
_justFinishedSliding.Add(notif.Id);
}
if (!notif.IsSlidingOut || !(Time.time >= notif.SlideOutEndTime)) continue;
DestroyNotificationElements(notif.Id);
_renderedPositions.Remove(notif.Id);
active.RemoveAt(i);
_slideOutCompletedTypes.Add(kvp.Key);
}
}
foreach (var type in _slideOutCompletedTypes)
ProcessQueue(type);
_slideOutCompletedTypes.Clear();
}
private IEnumerator SlideUpdateLoop()
{
while (HasSlidingNotifications())
{
UpdateSlidingState();
RebuildSlidingPanels();
yield return null;
}
UpdateUI();
_slideCoroutine = null;
}
private void RebuildSlidingPanels()
{
var positionedNotifications = CalculateNotificationPositions();
_slideContainer.Clear();
foreach (var positioned in positionedNotifications)
{
var notif = positioned.notification;
if (!notif.IsSliding && !notif.IsSlidingOut) continue;
var position = positioned.position;
var cfg = GetTypeConfig(notif.Type);
if (notif.IsSliding)
{
var slideDuration = cfg.SlideInDuration;
if (slideDuration <= 0f) slideDuration = 0.3f;
var slideProgress =
Mathf.Clamp01(1f - (notif.SlideEndTime - Time.time) / slideDuration);
var smoothProgress = Mathf.SmoothStep(0f, 1f, slideProgress);
var remainingOffset = cfg.SlideDistance * (1f - smoothProgress);
ApplySlideOffset(position, cfg.Slide, remainingOffset);
}
else
{
var slideDuration = cfg.SlideOutDuration;
if (slideDuration <= 0f) slideDuration = 0.3f;
var slideProgress =
Mathf.Clamp01(1f - (notif.SlideOutEndTime - Time.time) / slideDuration);
var smoothProgress = Mathf.SmoothStep(0f, 1f, slideProgress);
var currentOffset = cfg.SlideOutDistance * smoothProgress;
ApplySlideOffset(position, cfg.SlideOut, currentOffset);
}
var panelId = $"{Layer}.{notif.Id}";
DestroyUI(_player, $"{panelId}.Shadow");
DestroyUI(_player, panelId);
CreateNotificationPanel(_slideContainer, notif, position, 0f);
}
AddUI(_player, _slideContainer);
}
private static void ApplySlideOffset(CuiRectTransformComponent position, SlideDirection direction,
float remainingOffset)
{
if (!TryParseOffsetPair(position.OffsetMin, out var minX, out var minY)) return;
if (!TryParseOffsetPair(position.OffsetMax, out var maxX, out var maxY)) return;
switch (direction)
{
case SlideDirection.Left:
minX -= remainingOffset;
maxX -= remainingOffset;
break;
case SlideDirection.Right:
minX += remainingOffset;
maxX += remainingOffset;
break;
case SlideDirection.Top:
minY += remainingOffset;
maxY += remainingOffset;
break;
case SlideDirection.Bottom:
minY -= remainingOffset;
maxY -= remainingOffset;
break;
case SlideDirection.TopLeft:
minX -= remainingOffset;
maxX -= remainingOffset;
minY += remainingOffset;
maxY += remainingOffset;
break;
case SlideDirection.TopRight:
minX += remainingOffset;
maxX += remainingOffset;
minY += remainingOffset;
maxY += remainingOffset;
break;
case SlideDirection.BottomLeft:
minX -= remainingOffset;
maxX -= remainingOffset;
minY -= remainingOffset;
maxY -= remainingOffset;
break;
case SlideDirection.BottomRight:
minX += remainingOffset;
maxX += remainingOffset;
minY -= remainingOffset;
maxY -= remainingOffset;
break;
}
position.OffsetMin = FormatPair(minX, minY);
position.OffsetMax = FormatPair(maxX, maxY);
}
#endregion
private void EnsureLayer()
{
if (_layerCreated) return;
_container.Clear();
CreateMainPanel(_container);
AddUI(_player, _container);
_layerCreated = true;
}
private void DestroyLayer()
{
if (!_layerCreated) return;
DestroyUI(_player, Layer);
_layerCreated = false;
_renderedPositions.Clear();
_justFinishedSliding.Clear();
}
private void DestroyNotificationElements(string notifId)
{
var panelId = $"{Layer}.{notifId}";
DestroyUI(_player, $"{panelId}.Shadow");
DestroyUI(_player, panelId);
}
private void InvalidateRendered(string notifId)
{
if (_renderedPositions.Remove(notifId))
DestroyNotificationElements(notifId);
}
private void UpdateUI()
{
var positionedNotifications = CalculateNotificationPositions();
if (positionedNotifications.Count == 0)
{
DestroyLayer();
return;
}
EnsureLayer();
_container.Clear();
_activeIds.Clear();
foreach (var positioned in positionedNotifications)
{
var notif = positioned.notification;
var position = positioned.position;
_activeIds.Add(notif.Id);
if (notif.IsFadingOut)
continue;
var basePosKey = $"{position.OffsetMin}|{position.OffsetMax}";
var alreadyRendered = _renderedPositions.TryGetValue(notif.Id, out var oldPosKey);
if (alreadyRendered && (notif.IsSliding || notif.IsSlidingOut))
{
_renderedPositions[notif.Id] = basePosKey;
continue;
}
var positionChanged = alreadyRendered &&
!string.Equals(oldPosKey, basePosKey, StringComparison.Ordinal);
var justFinishedSlide = _justFinishedSliding.Contains(notif.Id);
switch (alreadyRendered)
{
case true when !positionChanged && !justFinishedSlide:
continue;
case true:
DestroyNotificationElements(notif.Id);
break;
}
float effectiveFadeIn;
if (notif.IsSliding)
{
var cfg = GetTypeConfig(notif.Type);
var slideDuration = cfg.SlideInDuration;
if (slideDuration <= 0f) slideDuration = 0.3f;
var slideProgress =
Mathf.Clamp01(1f - (notif.SlideEndTime - Time.time) / slideDuration);
var smoothProgress = Mathf.SmoothStep(0f, 1f, slideProgress);
var remainingOffset = cfg.SlideDistance * (1f - smoothProgress);
ApplySlideOffset(position, cfg.Slide, remainingOffset);
effectiveFadeIn = 0f;
}
else if (alreadyRendered)
{
effectiveFadeIn = 0f;
}
else
{
var cfg = GetTypeConfig(notif.Type);
effectiveFadeIn = (cfg.EnableSlide && cfg.Slide != SlideDirection.None) ? 0f : cfg.FadeIn;
}
CreateNotificationPanel(_container, notif, position, effectiveFadeIn);
_renderedPositions[notif.Id] = basePosKey;
}
_toRemove.Clear();
foreach (var kvp in _renderedPositions)
{
if (!_activeIds.Contains(kvp.Key))
_toRemove.Add(kvp.Key);
}
foreach (var id in _toRemove)
{
DestroyNotificationElements(id);
_renderedPositions.Remove(id);
}
_justFinishedSliding.Clear();
if (_container.Count > 0)
AddUI(_player, _container);
}
private List<(NotificationData notification, CuiRectTransformComponent position)>
CalculateNotificationPositions()
{
var result = new List<(NotificationData, CuiRectTransformComponent)>();
var anchorGroups =
new Dictionary<AnchorPosition, List<(int type, NotificationData notification)>>();
foreach (var kvp in _typeActive)
{
var type = kvp.Key;
var notifications = kvp.Value;
if (notifications.Count == 0) continue;
var config = GetTypeConfig(type);
if (!anchorGroups.TryGetValue(config.Anchor, out var group))
{
group = new List<(int, NotificationData)>();
anchorGroups[config.Anchor] = group;
}
foreach (var notification in notifications)
group.Add((type, notification));
}
foreach (var anchorGroup in anchorGroups)
{
var notifications = anchorGroup.Value;
notifications.Sort((a, b) => a.type.CompareTo(b.type));
var cumulativeTypeOffset = 0f;
var groupStart = 0;
while (groupStart < notifications.Count)
{
var type = notifications[groupStart].type;
var groupEnd = groupStart;
while (groupEnd < notifications.Count && notifications[groupEnd].type == type)
groupEnd++;
var config = GetTypeConfig(type);
var groupSize = groupEnd - groupStart;
for (var j = 0; j < groupSize; j++)
{
var notification = notifications[groupStart + j].notification;
var position = CalculateIndividualPosition(type, j, cumulativeTypeOffset);
result.Add((notification, position));
}
if (groupSize > 0)
cumulativeTypeOffset += groupSize * (config.ContainerHeight + config.Spacing) +
config.TypeSpacing;
groupStart = groupEnd;
}
}
return result;
}
private static CuiRectTransformComponent CalculateIndividualPosition(int type, int indexInType,
float cumulativeTypeOffset)
{
var config = GetTypeConfig(type);
var isBottom = config.Anchor is AnchorPosition.BottomLeft or AnchorPosition.BottomRight;
var isRight = config.Anchor is AnchorPosition.TopRight or AnchorPosition.BottomRight
or AnchorPosition.MiddleRight;
var isMiddle = config.Anchor is AnchorPosition.MiddleLeft or AnchorPosition.MiddleRight;
var width = config.ContainerWidth;
var height = config.ContainerHeight;
var containerXOffset = config.ContainerXOffset;
var containerYOffset = config.ContainerYOffset;
var xOffset = config.XOffset + containerXOffset;
float yOffset;
if (isMiddle || isBottom)
yOffset = config.YOffset + containerYOffset + cumulativeTypeOffset +
indexInType * (height + config.Spacing);
else
yOffset = config.YOffset + containerYOffset - cumulativeTypeOffset -
indexInType * (height + config.Spacing);
string anchorMinX, anchorMaxX, anchorMinY, anchorMaxY;
if (isMiddle)
{
anchorMinX = isRight ? "1" : "0";
anchorMaxX = isRight ? "1" : "0";
anchorMinY = "0.5";
anchorMaxY = "0.5";
}
else
{
anchorMinX = isRight ? "1" : "0";
anchorMaxX = isRight ? "1" : "0";
anchorMinY = isBottom ? "0" : "1";
anchorMaxY = isBottom ? "0" : "1";
}
string offsetMinX, offsetMaxX, offsetMinY, offsetMaxY;
if (isRight)
{
offsetMinX = $"{xOffset - width}";
offsetMaxX = $"{xOffset}";
}
else
{
offsetMinX = $"{-xOffset}";
offsetMaxX = $"{-xOffset + width}";
}
if (isMiddle)
{
offsetMinY = $"{yOffset - height / 2}";
offsetMaxY = $"{yOffset + height / 2}";
}
else if (isBottom)
{
offsetMinY = $"{yOffset}";
offsetMaxY = $"{yOffset + height}";
}
else
{
offsetMinY = $"{yOffset - height}";
offsetMaxY = $"{yOffset}";
}
return new CuiRectTransformComponent
{
AnchorMin = $"{anchorMinX} {anchorMinY}",
AnchorMax = $"{anchorMaxX} {anchorMaxY}",
OffsetMin = $"{offsetMinX} {offsetMinY}",
OffsetMax = $"{offsetMaxX} {offsetMaxY}"
};
}
public void AddPreviewNotification(NotificationData data)
{
if (!_typeActive.TryGetValue(data.Type, out var active))
{
active = new List<NotificationData>();
_typeActive[data.Type] = active;
}
active.Add(data);
UpdateUI();
}
public void UpdatePreviewNotification(string previewId, int type, string message)
{
foreach (var activeList in _typeActive.Values)
{
NotificationData preview = null;
foreach (var t in activeList)
{
if (!string.Equals(t.Id, previewId, StringComparison.Ordinal)) continue;
preview = t;
break;
}
if (preview is null) continue;
InvalidateRendered(previewId);
preview.Type = type;
preview.Message = message;
preview.StartTime = Time.time;
UpdateUI();
return;
}
}
public void RemovePreviewNotification(string previewId)
{
foreach (var activeList in _typeActive.Values)
{
for (var i = 0; i < activeList.Count; i++)
{
if (!string.Equals(activeList[i].Id, previewId, StringComparison.Ordinal)) continue;
activeList.RemoveAt(i);
UpdateUI();
return;
}
}
}
private static void CreateMainPanel(CuiElementContainer container)
{
container.Add(new CuiElement
{
Name = Layer,
Parent = "Hud",
Components =
{
new CuiImageComponent { Color = "0 0 0 0" },
new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" }
}
});
}
private static void CreateNotificationPanel(CuiElementContainer container, NotificationData data,
CuiRectTransformComponent position, float fadeIn)
{
var config = GetTypeConfig(data.Type);
var useSlide = config.EnableSlide && config.Slide != SlideDirection.None;
var fadeOut = useSlide ? 0f : config.FadeOut;
var panelId = $"{Layer}.{data.Id}";
CreateShadowElement(container, panelId, position, config, fadeIn, fadeOut);
container.Add(new CuiElement
{
Name = panelId,
Parent = Layer,
Components =
{ new CuiImageComponent { Color = config.BackgroundColor.Get(), FadeIn = fadeIn }, position },
FadeOut = fadeOut
});
CreateBorderElements(container, panelId, config, fadeIn, fadeOut);
if (config.EnableGradient) CreateGradientElement(container, panelId, config, fadeIn, fadeOut);
CreateIconElements(container, panelId, config, fadeIn, fadeOut);
CreateTextElements(container, panelId, config, data, fadeIn, fadeOut);
}
private static void CreateShadowElement(CuiElementContainer container, string panelId,
CuiRectTransformComponent position, NotificationType config, float fadeIn, float fadeOut)
{
if (!TryParseOffsetPair(position.OffsetMin, out var sMinX, out var sMinY)) return;
if (!TryParseOffsetPair(position.OffsetMax, out var sMaxX, out var sMaxY)) return;
container.Add(new CuiElement
{
Name = $"{panelId}.Shadow",
Parent = Layer,
Components =
{
new CuiImageComponent { Color = "0 0 0 0.4", FadeIn = fadeIn },
new CuiRectTransformComponent
{
AnchorMin = position.AnchorMin,
AnchorMax = position.AnchorMax,
OffsetMin = FormatPair(sMinX + 2f, sMinY - 2f),
OffsetMax = FormatPair(sMaxX + 2f, sMaxY - 2f)
}
},
FadeOut = fadeOut
});
}
private static void CreateBorderElements(CuiElementContainer container, string panelId,
NotificationType config, float fadeIn, float fadeOut)
{
container.Add(new CuiElement
{
Name = $"{panelId}.HighlightBorder",
Parent = panelId,
Components =
{
new CuiImageComponent { Color = config.BorderColor.Get(), FadeIn = fadeIn },
new CuiRectTransformComponent
{ AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 -2", OffsetMax = "0 0" }
},
FadeOut = fadeOut
});
container.Add(new CuiElement
{
Name = $"{panelId}.AccentBorder",
Parent = panelId,
Components =
{
new CuiImageComponent { Color = config.IconBgColor.Get(), FadeIn = fadeIn },
new CuiRectTransformComponent
{ AnchorMin = "0 0", AnchorMax = "0 1", OffsetMin = "0 0", OffsetMax = "3 0" }
},
FadeOut = fadeOut
});
}
private static void CreateGradientElement(CuiElementContainer container, string panelId,
NotificationType config, float fadeIn, float fadeOut)
{
container.Add(new CuiElement
{
Name = $"{panelId}.Gradient",
Parent = panelId,
Components =
{
new CuiImageComponent
{
Color = config.GradientColor.Get(),
Sprite = config.Sprite,
Material = config.Material,
// ... [Code preview truncated due to length. Full source in attached file] ...