diff --git a/config/AcediaSystem.ini b/config/AcediaSystem.ini
index ac5dbc6..5bfd2d6 100644
--- a/config/AcediaSystem.ini
+++ b/config/AcediaSystem.ini
@@ -140,6 +140,10 @@ requiredGroup=""
maxVisibleLineWidth=80
maxTotalLineWidth=108
+[AcediaCore.PlayerNotificationQueue]
+; Maximum time that a notification is allowed to be displayed on the player's screen
+maximumNotifyTime=20
+
[AcediaCore.ColorAPI]
; Changing these values will alter color's definitions in `ColorAPI`,
; changing how Acedia behaves
diff --git a/sources/LevelAPI/API/Time/Timer.uc b/sources/LevelAPI/API/Time/Timer.uc
index f9dd79e..136f249 100644
--- a/sources/LevelAPI/API/Time/Timer.uc
+++ b/sources/LevelAPI/API/Time/Timer.uc
@@ -78,6 +78,7 @@ public function float GetInterval();
*
* Setting this value while the caller `Timer` is running resets it (same as
* calling `StopMe().Start()`).
+ * But the already inactive timer won't start anew.
*
* @param newInterval How many seconds should separate two `OnElapsed()`
* signals (or starting a timer and next `OnElapsed()` event)?
diff --git a/sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandNotify.uc b/sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandNotify.uc
new file mode 100644
index 0000000..d992449
--- /dev/null
+++ b/sources/LevelAPI/Features/Commands/BuiltInCommands/ACommandNotify.uc
@@ -0,0 +1,53 @@
+/**
+ * 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 ACommandNotify extends Command;
+
+protected function BuildData(CommandDataBuilder builder) {
+ builder.Name(P("notify"));
+ builder.Group(P("core"));
+ builder.Summary(P("Notifies players with provided message."));
+ builder.ParamText(P("message"));
+ builder.OptionalParams();
+ builder.ParamNumber(P("duration"));
+ builder.Describe(P("Notify to players message with distinct header and body."));
+ builder.RequireTarget();
+
+ builder.Option(P("title"));
+ builder.Describe(P("Specify the optional title of the notification."));
+ builder.ParamText(P("title"));
+}
+
+protected function ExecutedFor(EPlayer target, CallData arguments, EPlayer instigator) {
+ local Text title, message, plainTitle, plainMessage;
+
+ plainMessage = arguments.parameters.GetText(P("message"));
+ if (arguments.options.HasKey(P("title"))) {
+ plainTitle = arguments.options.GetTextBy(P("/title/title"));
+ }
+ title = _.text.FromFormatted(plainTitle);
+ message = _.text.FromFormatted(plainMessage);
+ target.Notify(title, message, arguments.parameters.GetFloat(P("duration")));
+ _.memory.Free4(title, message, plainTitle, plainMessage);
+}
+
+defaultproperties {
+}
\ No newline at end of file
diff --git a/sources/LevelAPI/Features/Commands/Commands_Feature.uc b/sources/LevelAPI/Features/Commands/Commands_Feature.uc
index 5e9166e..2f9008c 100644
--- a/sources/LevelAPI/Features/Commands/Commands_Feature.uc
+++ b/sources/LevelAPI/Features/Commands/Commands_Feature.uc
@@ -109,6 +109,7 @@ protected function OnEnabled()
registeredCommands = _.collections.EmptyHashTable();
groupedCommands = _.collections.EmptyHashTable();
RegisterCommand(class'ACommandHelp');
+ RegisterCommand(class'ACommandNotify');
// Macro selector
commandDelimiters[0] = _.text.FromString("@");
// Key selector
diff --git a/sources/Players/EPlayer.uc b/sources/Players/EPlayer.uc
index 40dabce..1dc7fef 100644
--- a/sources/Players/EPlayer.uc
+++ b/sources/Players/EPlayer.uc
@@ -53,6 +53,8 @@ struct PlayerSignals
// `PlayersAPI` and is expected to be allocated during the whole Acedia run.
var protected PlayerSignals signalsReferences;
+var private LoggerAPI.Definition errNoIdentity;
+
protected function Finalizer()
{
_.memory.Free(controller);
@@ -502,7 +504,31 @@ public final function /* borrow */ ConsoleWriter BorrowConsole()
return consoleInstance.ForPlayer(self);
}
-defaultproperties
-{
+/// Notifies player about something with a text message.
+///
+/// Header is allowed to be `none`, but it is recommended to set it to an actual value.
+/// Duration is more of a suggestion and might be clamped to some reasonable value that depends on
+/// implementation/server settings.
+public final function Notify(BaseText header, BaseText body, optional float duration) {
+ local HashTable sessionData;
+ local PlayerNotificationQueue messageQueue;
+
+ if (identity == none) {
+ _.logger.Auto(errNoIdentity);
+ return;
+ }
+ sessionData = identity.GetSessionData(P("Acedia"));
+ messageQueue = PlayerNotificationQueue(sessionData.GetItem(P("MessageQueue")));
+ if (messageQueue == none) {
+ messageQueue = PlayerNotificationQueue(_.memory.Allocate(class'PlayerNotificationQueue'));
+ sessionData.SetItem(P("MessageQueue"), messageQueue);
+ }
+ messageQueue.SetupController(controller);
+ messageQueue.AddNotification(header, body, duration);
+ _.memory.Free2(sessionData, messageQueue);
+}
+
+defaultproperties {
usesObjectPool = false
+ errNoIdentity = (l=LOG_Warning,m="`EPlayer` without `identity` is being used. It is likely not initialized and something is working incorrectly.")
}
\ No newline at end of file
diff --git a/sources/Players/PlayerNotificationQueue.uc b/sources/Players/PlayerNotificationQueue.uc
new file mode 100644
index 0000000..968d619
--- /dev/null
+++ b/sources/Players/PlayerNotificationQueue.uc
@@ -0,0 +1,219 @@
+/**
+ * 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.
+
+/// Describes a single notification: title (optional, can be `none`) + message body and timeout
+struct Notification {
+ var Text title;
+ var Text body;
+ var float time;
+};
+var private array notificationQueue;
+/// 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;
+ }
+ }
+}
+
+/// Add new notification to the queue
+public final function AddNotification(BaseText title, BaseText body, float time) {
+ local Notification newNotification;
+
+ if (body == none) {
+ return;
+ }
+ if (title != none) {
+ newNotification.title = title.Copy();
+ }
+ newNotification.body = body.Copy();
+ notificationQueue[notificationQueue.length] = 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 int titleShift;
+ local MutableText upperCaseTitle;
+ local Notification nextNotification;
+ local PlayerController playerController;
+
+ // Get appropriate [`PlayerController`] and next notification
+ playerController = PlayerController(playerControllerRef.Get());
+ if (playerController == none) {
+ _.memory.Free(playerControllerRef);
+ playerControllerRef = none;
+ }
+ if (notificationQueue.length <= 0 || playerController == none) {
+ InterruptScheduling();
+ return;
+ }
+ nextNotification = notificationQueue[0];
+ notificationQueue.Remove(0, 1);
+ nextNotification.time = FMin(nextNotification.time, maximumNotifyTime);
+ if (nextNotification.time <= 0) {
+ nextNotification.time = 10.0;
+ }
+
+ // And print
+ playerController.ClearProgressMessages();
+ playerController.SetProgressTime(nextNotification.time);
+ if (nextNotification.title != none) {
+ upperCaseTitle = nextNotification.title.UpperMutableCopy();
+ upperCaseTitle.ChangeDefaultColor(_.color.TextHeader);
+ PrintNotifcationAt(playerController, upperCaseTitle, 0, 1);
+ titleShift = 1;
+ _.memory.Free(upperCaseTitle);
+ }
+ PrintNotifcationAt(playerController, nextNotification.body, titleShift, 4 - titleShift);
+ ScheduleUpdate(nextNotification.time);
+ _.memory.Free2(nextNotification.title, nextNotification.body);
+}
+
+defaultproperties {
+ CODEPOINT_NEWLINE = 10
+ maximumNotifyTime = 20.0
+}
\ No newline at end of file
diff --git a/sources/Users/User.uc b/sources/Users/User.uc
index 246a825..4eae0ad 100644
--- a/sources/Users/User.uc
+++ b/sources/Users/User.uc
@@ -103,6 +103,25 @@ public final function int GetKey()
return key;
}
+/**
+ * Returns a reference to user's session data for a given group.
+ *
+ * Guaranteed to not be `none`.
+ */
+public final function HashTable GetSessionData(Text groupName)
+{
+ local HashTable groupData;
+ if (sessionData == none) {
+ sessionData = _.collections.EmptyHashTable();
+ }
+ groupData = sessionData.GetHashTable(groupName);
+ if (groupData == none) {
+ groupData = _.collections.EmptyHashTable();
+ sessionData.SetItem(groupName, groupData);
+ }
+ return groupData;
+}
+
/**
* Returns persistent data for the caller user. Data is specified by the its
* name along with the name of the data group it is stored in.