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.