|
|
|
/**
|
|
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
|
|
*/
|
|
|
|
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<Notification> 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<BaseText> 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
|
|
|
|
}
|