/** * Author: dkanus * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore * License: GPL * Copyright 2023 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * * Acedia is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License, or * (at your option) any later version. * * Acedia is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ class PlayerNotificationQueue extends AcediaObject config(AcediaSystem); //! Manages queue of notifications that should be displayed for a certain player. //! //! Pushes messages one-by-one, once its their time to be displayed and making sure that only up to //! one message per channel is ever in queue. //! //! A channel is a way to group related notifications together. //! Its purpose is to control the display of notifications in a more organized and efficient manner. //! Each channel can only have one message in the queue at a time. //! This ensures that only the most relevant or up-to-date message is displayed to the player. /// Describes a single notification: title (optional, can be `none`) + message body, channel /// and timeout. struct Notification { var Text title; var Text body; var float duration; var Text channel; }; var private array notificationQueue; /// We need this variable to keep track of which channel the currently displayed message belongs to, /// so that we can ensure that only one message is displayed at a time for each channel. /// This variable allows us to easily check if a new message belongs to the same channel as /// the currently displayed message, and if so, replace the currently displayed message with /// the new one. var private Text currentChannel; /// Reference to the `PlayerController` for the player that owns this queue var private NativeActorRef playerControllerRef; /// Timer until next notification can be displayed /// /// `none` if queue currently doesn't display any notifications var private Timer nextNotificationTimer; /// Maximum time that a notification is allowed to be displayed on the player's screen var private const config float maximumNotifyTime; var private const int CODEPOINT_NEWLINE; protected function Finalizer() { local int i; for (i = 0; i < notificationQueue.length; i += 1) { _.memory.Free(notificationQueue[i].title); _.memory.Free(notificationQueue[i].body); } notificationQueue.length = 0; _.memory.Free(nextNotificationTimer); _.memory.Free(playerControllerRef); nextNotificationTimer = none; playerControllerRef = none; } /// Set owner `PlayerController` for this queue public final /*native*/ function SetupController(NativeActorRef newPlayerControllerRef) { local PlayerController oldController, newController; if (playerControllerRef != none) { oldController = PlayerController(playerControllerRef.Get()); } if (newPlayerControllerRef != none) { newController = PlayerController(newPlayerControllerRef.Get()); } if (oldController != newController) { InterruptScheduling(); _.memory.Free(playerControllerRef); if (newPlayerControllerRef != none) { newPlayerControllerRef.NewRef(); playerControllerRef = newPlayerControllerRef; SetupNextNotification(none); } else { playerControllerRef = none; } } } /// Sends a text message to notify the player about a particular event or situation, displayed /// as a notification. /// /// While the header parameter is optional, it is recommended to provide a meaningful header /// to give the player context about the notification. The duration parameter suggests the time /// that the message should be displayed for, but the actual duration may be limited by the /// implementation or server settings. /// /// The [`channel`] parameter (case-sensitive) allows you to group related notifications together by /// assigning them to a specific channel. /// Each channel can only have one message in the queue at any given time. /// For instance, when conducting a vote, the old message about the vote's start can be quickly /// replaced with a newer message about the vote's end. /// Notifications without a channel `none` can have any number of messages queued up. /// /// Acedia uses "voting" channel for voting. public final function AddNotification( BaseText title, BaseText body, float duration, BaseText channel ) { local int i, newNotificationIndex; local Notification newNotification; if (body == none) { return; } if (title != none) { newNotification.title = title.Copy(); } if (channel != none) { newNotification.channel = channel.Copy(); } newNotification.body = body.Copy(); newNotification.duration = duration; newNotificationIndex = notificationQueue.length; // Make sure there is only one message per channel by replacing the old one if (currentChannel != none && currentChannel.Compare(channel)) { SetupNotification(/*take*/ newNotification); return; } if (channel != none) { for (i = 0; i < notificationQueue.length; i += 1) { if (channel.Compare(notificationQueue[i].channel)) { _.memory.Free3( notificationQueue[i].title, notificationQueue[i].body, notificationQueue[i].channel); newNotificationIndex = i; break; } } } notificationQueue[newNotificationIndex] = newNotification; if (!IsUpdateScheduled()) { SetupNextNotification(none); } } // Sets up [`SetupNextNotification()`] to be called after [`timeUntilNextUpdate`] private function ScheduleUpdate(float timeUntilNextUpdate) { if (nextNotificationTimer == none) { nextNotificationTimer = __level().time.StartRealTimer(timeUntilNextUpdate, false); nextNotificationTimer.OnElapsed(nextNotificationTimer).connect = SetupNextNotification; } else { nextNotificationTimer.SetInterval(timeUntilNextUpdate); nextNotificationTimer.Start(); } } private function bool IsUpdateScheduled() { return (nextNotificationTimer != none); } // Prevents scheduled [`SetupNextNotification()`] call from going off private function InterruptScheduling() { _.memory.Free(nextNotificationTimer); nextNotificationTimer = none; } // Properly translates [`contents`] into a colored [`string`] before setting it up as // a [`PlayerController`]'s progress message at the given line index. private function SetupProgressLine( int lineIndex, BaseText contents, PlayerController playerController ) { local string contentsAsString; local BaseText.Formatting startingFormatting; if (contents == none) return; if (playerController == none) return; // Drop first colored tag, since we'll set first color through `playerController.progressColor` contentsAsString = Mid(contents.ToColoredString(,, _.color.white), 4); startingFormatting = contents.GetFormatting(0); if (startingFormatting.isColored) { playerController.SetProgressMessage(lineIndex, contentsAsString, startingFormatting.color); } else { playerController.SetProgressMessage(lineIndex, contentsAsString, _.color.white); } } // Prints [`notification`] on given lines, respecting line breaks inside it as much as possible // (creates up to [`maxLines`], replacing the rest of line breaks with whitespaces) private function PrintNotifcationAt( PlayerController playerController, BaseText notification, int startingLine, int maxLines ) { local int i, j; local MutableText lastLine; local array lines; if (notification == none) return; if (startingLine < 0) return; if (startingLine > 3) return; lines = notification.SplitByCharacter(_.text.CharacterFromCodePoint(CODEPOINT_NEWLINE),, true); for (i = 0; i < lines.length; i += 1) { if (i + 1 < maxLines) { SetupProgressLine(i + startingLine, lines[i], playerController); } else { lastLine = lines[i].MutableCopy(); for (j = i + 1; j < lines.length; j += 1) { lastLine.Append(P(" ")); lastLine.Append(lines[j]); } SetupProgressLine(startingLine + maxLines - 1, lastLine, playerController); _.memory.Free(lastLine); break; } } } private function SetupNextNotification(Timer callerInstance) { local Notification nextNotification; if (notificationQueue.length <= 0) { InterruptScheduling(); return; } nextNotification = notificationQueue[0]; notificationQueue.Remove(0, 1); SetupNotification(/*take*/ nextNotification); } private function SetupNotification(/*take*/ Notification notification) { local int titleShift; local MutableText upperCaseTitle; local PlayerController playerController; // Get appropriate [`PlayerController`] and next notification playerController = PlayerController(playerControllerRef.Get()); if (playerController == none) { _.memory.Free(playerControllerRef); playerControllerRef = none; InterruptScheduling(); return; } _.memory.Free(currentChannel); currentChannel = notification.channel; notification.duration = FMin(notification.duration, maximumNotifyTime); if (notification.duration <= 0) { notification.duration = 10.0; } // And print playerController.ClearProgressMessages(); playerController.SetProgressTime(notification.duration); if (notification.title != none) { upperCaseTitle = notification.title.UpperMutableCopy(); upperCaseTitle.ChangeDefaultColor(_.color.TextHeader); PrintNotifcationAt(playerController, upperCaseTitle, 0, 1); titleShift = 1; _.memory.Free(upperCaseTitle); } PrintNotifcationAt(playerController, notification.body, titleShift, 4 - titleShift); ScheduleUpdate(notification.duration); // We moved channel's reference into `currentChannel` _.memory.Free2(notification.title, notification.body); } defaultproperties { CODEPOINT_NEWLINE = 10 maximumNotifyTime = 20.0 }