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/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc new file mode 100644 index 0000000..d992449 --- /dev/null +++ b/sources/BaseAPI/API/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/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/Commands_Feature.uc b/sources/LevelAPI/Features/Commands/Commands_Feature.uc new file mode 100644 index 0000000..2f9008c --- /dev/null +++ b/sources/LevelAPI/Features/Commands/Commands_Feature.uc @@ -0,0 +1,627 @@ +/** + * This feature provides a mechanism to define commands that automatically + * parse their arguments into standard Acedia collection. It also allows to + * manage them (and specify limitation on how they can be called) in a + * centralized manner. + * Copyright 2021-2022 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 Commands_Feature extends Feature; + +/** + * # `Commands_Feature` + * + * This feature provides a mechanism to define commands that automatically + * parse their arguments into standard Acedia collection. It also allows to + * manage them (and specify limitation on how they can be called) in a + * centralized manner. + * Support command input from chat and "mutate" command. + * + * ## Usage + * + * Should be enabled like any other feature. Additionally support + * `EmergencyEnable()` enabling method that bypasses regular settings to allow + * admins to start this feature while forcefully enabling "mutate" command + * input method. + * Available configuration: + * + * 1. Whether to use command input from chat and what prefix is used to + * denote a command (by default "!"); + * 2. Whether to use command input from "mutate" command. + * + * To add new commands into the system - get enabled instance of this + * feature and call its `RegisterCommand()` method to add your custom + * `Command` class. `RemoveCommand()` can also be used to de-register + * a command, if you need this for some reason. + * + * ## Implementation + * + * Implementation is simple: calling a method `RegisterCommand()` adds + * command into two caches `registeredCommands` for obtaining registered + * commands by name and `groupedCommands` for obtaining arrays of commands by + * their group name. These arrays are used for providing methods for fetching + * arrays of commands and obtaining pre-allocated `Command` instances by their + * name. + * Depending on settings, this feature also connects to corresponding + * signals for catching "mutate"/chat input, then it checks user-specified name + * for being an alias and picks correct command from `registeredCommands`. + * Emergency enabling this feature sets `emergencyEnabledMutate` flag that + * enforces connecting to the "mutate" input. + */ + +// Delimiters that always separate command name from it's parameters +var private array commandDelimiters; +// Registered commands, recorded as (, ) pairs. +// Keys should be deallocated when their entry is removed. +var private HashTable registeredCommands; +// `HashTable` of "" <-> `ArrayList` of commands pairs +// to allow quick fetch of commands belonging to a single group +var private HashTable groupedCommands; + +// When this flag is set to true, mutate input becomes available +// despite `useMutateInput` flag to allow to unlock server in case of an error +var private bool emergencyEnabledMutate; + +// Setting this to `true` enables players to input commands right in the chat +// by prepending them with `chatCommandPrefix`. +// Default is `true`. +var private /*config*/ bool useChatInput; +// Setting this to `true` enables players to input commands with "mutate" +// console command. +// Default is `true`. +var private /*config*/ bool useMutateInput; +// Chat messages, prepended by this prefix will be treated as commands. +// Default is "!". Empty values are also treated as "!". +var private /*config*/ Text chatCommandPrefix; +// List of steam IDs of players allowed to use commands. +// Temporary measure until a better solution is finished. +var private /*config*/ array allowedPlayers; + +// Contains name of the command to call plus, optionally, +// additional sub-command name. +// Normally sub-command name is parsed by the command itself, however +// command aliases can try to enforce one. +struct CommandCallPair +{ + var MutableText commandName; + // In case it is enforced by an alias + var MutableText subCommandName; +}; + +var LoggerAPI.Definition errCommandDuplicate; + +protected function OnEnabled() +{ + registeredCommands = _.collections.EmptyHashTable(); + groupedCommands = _.collections.EmptyHashTable(); + RegisterCommand(class'ACommandHelp'); + RegisterCommand(class'ACommandNotify'); + // Macro selector + commandDelimiters[0] = _.text.FromString("@"); + // Key selector + commandDelimiters[1] = _.text.FromString("#"); + // Player array (possibly JSON array) + commandDelimiters[2] = _.text.FromString("["); + // Negation of the selector + commandDelimiters[3] = _.text.FromString("!"); +} + +protected function OnDisabled() +{ + if (useChatInput) { + _.chat.OnMessage(self).Disconnect(); + } + if (useMutateInput) { + _server.unreal.mutator.OnMutate(self).Disconnect(); + } + useChatInput = false; + useMutateInput = false; + _.memory.Free(registeredCommands); + _.memory.Free(groupedCommands); + _.memory.Free(chatCommandPrefix); + _.memory.FreeMany(commandDelimiters); + registeredCommands = none; + groupedCommands = none; + chatCommandPrefix = none; + commandDelimiters.length = 0; +} + +protected function SwapConfig(FeatureConfig config) +{ + local Commands newConfig; + + newConfig = Commands(config); + if (newConfig == none) { + return; + } + _.memory.Free(chatCommandPrefix); + chatCommandPrefix = _.text.FromString(newConfig.chatCommandPrefix); + allowedPlayers = newConfig.allowedPlayers; + if (useChatInput != newConfig.useChatInput) + { + useChatInput = newConfig.useChatInput; + if (newConfig.useChatInput) { + _.chat.OnMessage(self).connect = HandleCommands; + } + else { + _.chat.OnMessage(self).Disconnect(); + } + } + // Do not make any modifications here in case "mutate" was + // emergency-enabled + if (useMutateInput != newConfig.useMutateInput && !emergencyEnabledMutate) + { + useMutateInput = newConfig.useMutateInput; + if (newConfig.useMutateInput) { + _server.unreal.mutator.OnMutate(self).connect = HandleMutate; + } + else { + _server.unreal.mutator.OnMutate(self).Disconnect(); + } + } +} + +/** + * `Command_Feature` is a critical command to have running on your server and, + * if disabled by accident, there will be no way of starting it again without + * restarting the level or even editing configs. + * + * This method allows to enable it along with "mutate" input in case something + * goes wrong. + */ +public final static function EmergencyEnable() +{ + local Text autoConfig; + local Commands_Feature feature; + + if (!IsEnabled()) + { + autoConfig = GetAutoEnabledConfig(); + EnableMe(autoConfig); + __().memory.Free(autoConfig); + } + feature = Commands_Feature(GetEnabledInstance()); + if ( !feature.emergencyEnabledMutate + && !feature.IsUsingMutateInput() && !feature.IsUsingChatInput()) + { + default.emergencyEnabledMutate = true; + feature.emergencyEnabledMutate = true; + __server().unreal.mutator.OnMutate(feature).connect = HandleMutate; + } +} + +/** + * Checks if `Commands_Feature` currently uses chat as input. + * If `Commands_Feature` is not enabled, then it does not use anything + * as input. + * + * @return `true` if `Commands_Feature` is currently enabled and is using chat + * as input and `false` otherwise. + */ +public final static function bool IsUsingChatInput() +{ + local Commands_Feature instance; + + instance = Commands_Feature(GetEnabledInstance()); + if (instance != none) { + return instance.useChatInput; + } + return false; +} + +/** + * Checks if `Commands_Feature` currently uses mutate command as input. + * If `Commands_Feature` is not enabled, then it does not use anything + * as input. + * + * @return `true` if `Commands_Feature` is currently enabled and is using + * mutate command as input and `false` otherwise. + */ +public final static function bool IsUsingMutateInput() +{ + local Commands_Feature instance; + + instance = Commands_Feature(GetEnabledInstance()); + if (instance != none) { + return instance.useMutateInput; + } + return false; +} + +/** + * Returns prefix that will indicate that chat message is intended to be + * a command. By default "!". + * + * @return Prefix that indicates that chat message is intended to be a command. + * If `Commands_Feature` is disabled, always returns `false`. + */ +public final static function Text GetChatPrefix() +{ + local Commands_Feature instance; + + instance = Commands_Feature(GetEnabledInstance()); + if (instance != none && instance.chatCommandPrefix != none) { + return instance.chatCommandPrefix.Copy(); + } + return none; +} + +/** + * Registers given command class, making it available for usage. + * + * If `commandClass` provides command with a name that is already taken + * (comparison is case-insensitive) by a different command - a warning will be + * logged and newly passed `commandClass` discarded. + * + * @param commandClass New command class to register. + */ +public final function RegisterCommand(class commandClass) +{ + local Text commandName, groupName; + local ArrayList groupArray; + local Command newCommandInstance, existingCommandInstance; + + if (commandClass == none) return; + if (registeredCommands == none) return; + + newCommandInstance = Command(_.memory.Allocate(commandClass, true)); + commandName = newCommandInstance.GetName(); + groupName = newCommandInstance.GetGroupName(); + // Check for duplicates and report them + existingCommandInstance = Command(registeredCommands.GetItem(commandName)); + if (existingCommandInstance != none) + { + _.logger.Auto(errCommandDuplicate) + .ArgClass(existingCommandInstance.class) + .Arg(commandName) + .ArgClass(commandClass); + _.memory.Free(groupName); + _.memory.Free(newCommandInstance); + _.memory.Free(existingCommandInstance); + return; + } + // Otherwise record new command + // `commandName` used as a key, do not deallocate it + registeredCommands.SetItem(commandName, newCommandInstance); + // Add to grouped collection + groupArray = groupedCommands.GetArrayList(groupName); + if (groupArray == none) { + groupArray = _.collections.EmptyArrayList(); + } + groupArray.AddItem(newCommandInstance); + groupedCommands.SetItem(groupName, groupArray); + _.memory.Free(groupArray); + _.memory.Free(groupName); + _.memory.Free(commandName); + _.memory.Free(newCommandInstance); +} + +/** + * Removes command of class `commandClass` from the list of + * registered commands. + * + * WARNING: removing once registered commands is not an action that is expected + * to be performed under normal circumstances and it is not efficient. + * It is linear on the current amount of commands. + * + * @param commandClass Class of command to remove from being registered. + */ +public final function RemoveCommand(class commandClass) +{ + local int i; + local CollectionIterator iter; + local Command nextCommand; + local Text nextCommandName; + local array commandGroup; + local array keysToRemove; + + if (commandClass == none) return; + if (registeredCommands == none) return; + + for (iter = registeredCommands.Iterate(); !iter.HasFinished(); iter.Next()) + { + nextCommand = Command(iter.Get()); + nextCommandName = Text(iter.GetKey()); + if ( nextCommand == none || nextCommandName == none + || nextCommand.class != commandClass) + { + _.memory.Free(nextCommand); + _.memory.Free(nextCommandName); + continue; + } + keysToRemove[keysToRemove.length] = nextCommandName; + commandGroup[commandGroup.length] = nextCommand.GetGroupName(); + _.memory.Free(nextCommand); + } + iter.FreeSelf(); + for (i = 0; i < keysToRemove.length; i += 1) + { + registeredCommands.RemoveItem(keysToRemove[i]); + _.memory.Free(keysToRemove[i]); + } + + for (i = 0; i < commandGroup.length; i += 1) { + RemoveClassFromGroup(commandClass, commandGroup[i]); + } + _.memory.FreeMany(commandGroup); +} + +private final function RemoveClassFromGroup( + class commandClass, + BaseText commandGroup) +{ + local int i; + local ArrayList groupArray; + local Command nextCommand; + + groupArray = groupedCommands.GetArrayList(commandGroup); + if (groupArray == none) { + return; + } + while (i < groupArray.GetLength()) + { + nextCommand = Command(groupArray.GetItem(i)); + if (nextCommand != none && nextCommand.class == commandClass) { + groupArray.RemoveIndex(i); + } + else { + i += 1; + } + _.memory.Free(nextCommand); + } + if (groupArray.GetLength() == 0) { + groupedCommands.RemoveItem(commandGroup); + } + _.memory.Free(groupArray); +} + +/** + * Returns command based on a given name. + * + * @param commandName Name of the registered `Command` to return. + * Case-insensitive. + * @return Command, registered with a given name `commandName`. + * If no command with such name was registered - returns `none`. + */ +public final function Command GetCommand(BaseText commandName) +{ + local Text commandNameLowerCase; + local Command commandInstance; + + if (commandName == none) return none; + if (registeredCommands == none) return none; + + commandNameLowerCase = commandName.LowerCopy(); + commandInstance = Command(registeredCommands.GetItem(commandNameLowerCase)); + commandNameLowerCase.FreeSelf(); + return commandInstance; +} + +/** + * Returns array of names of all available commands. + * + * @return Array of names of all available (registered) commands. + */ +public final function array GetCommandNames() +{ + local array emptyResult; + + if (registeredCommands != none) { + return registeredCommands.GetTextKeys(); + } + return emptyResult; +} + +/** + * Returns array of names of all available commands belonging to the group + * `groupName`. + * + * @return Array of names of all available (registered) commands, belonging to + * the group `groupName`. + */ +public final function array GetCommandNamesInGroup(BaseText groupName) +{ + local int i; + local ArrayList groupArray; + local Command nextCommand; + local array result; + + if (groupedCommands == none) return result; + groupArray = groupedCommands.GetArrayList(groupName); + if (groupArray == none) return result; + + for (i = 0; i < groupArray.GetLength(); i += 1) + { + nextCommand = Command(groupArray.GetItem(i)); + if (nextCommand != none) { + result[result.length] = nextCommand.GetName(); + } + _.memory.Free(nextCommand); + } + return result; +} + +/** + * Returns all available command groups' names. + * + * @return Array of all available command groups' names. + */ +public final function array GetGroupsNames() +{ + local array emptyResult; + + if (groupedCommands != none) { + return groupedCommands.GetTextKeys(); + } + return emptyResult; +} + +/** + * Handles user input: finds appropriate command and passes the rest of + * the arguments to it for further processing. + * + * @param input Test that contains user's command input. + * @param callerPlayer Player that caused this command call. + */ +public final function HandleInput(BaseText input, EPlayer callerPlayer) +{ + local Parser wrapper; + + if (input == none) { + return; + } + wrapper = input.Parse(); + HandleInputWith(wrapper, callerPlayer); + wrapper.FreeSelf(); +} + +/** + * Handles user input: finds appropriate command and passes the rest of + * the arguments to it for further processing. + * + * @param parser Parser filled with user input that is expected to + * contain command's name and it's parameters. + * @param callerPlayer Player that caused this command call. + */ +public final function HandleInputWith(Parser parser, EPlayer callerPlayer) +{ + local int i; + local bool foundID; + local string steamID; + local PlayerController controller; + local Command commandInstance; + local Command.CallData callData; + local CommandCallPair callPair; + + if (parser == none) return; + if (callerPlayer == none) return; + if (!parser.Ok()) return; + controller = callerPlayer.GetController(); + if (controller == none) return; + + steamID = controller.GetPlayerIDHash(); + for (i = 0; i < allowedPlayers.length; i += 1) + { + if (allowedPlayers[i] == steamID) + { + foundID = true; + break; + } + } + if (!foundID) { + return; + } + callPair = ParseCommandCallPairWith(parser); + commandInstance = GetCommand(callPair.commandName); + if ( commandInstance == none + && callerPlayer != none && callerPlayer.IsExistent()) + { + callerPlayer + .BorrowConsole() + .Flush() + .Say(F("{$TextFailure Command not found!}")); + } + if (parser.Ok() && commandInstance != none) + { + callData = commandInstance + .ParseInputWith(parser, callerPlayer, callPair.subCommandName); + commandInstance.Execute(callData, callerPlayer); + commandInstance.DeallocateCallData(callData); + } + _.memory.Free(callPair.commandName); + _.memory.Free(callPair.subCommandName); +} + +// Parses command's name into `CommandCallPair` - sub-command is filled in case +// specified name is an alias with specified sub-command name. +private final function CommandCallPair ParseCommandCallPairWith(Parser parser) +{ + local Text resolvedValue; + local MutableText userSpecifiedName; + local CommandCallPair result; + local Text.Character dotCharacter; + + if (parser == none) return result; + if (!parser.Ok()) return result; + + parser.MUntilMany(userSpecifiedName, commandDelimiters, true, true); + resolvedValue = _.alias.ResolveCommand(userSpecifiedName); + // This isn't an alias + if (resolvedValue == none) + { + result.commandName = userSpecifiedName; + return result; + } + // It is an alias - parse it + dotCharacter = _.text.GetCharacter("."); + resolvedValue.Parse() + .MUntil(result.commandName, dotCharacter) + .MatchS(".") + .MUntil(result.subCommandName, dotCharacter) + .FreeSelf(); + if (result.subCommandName.IsEmpty()) + { + result.subCommandName.FreeSelf(); + result.subCommandName = none; + } + resolvedValue.FreeSelf(); + return result; +} + +private function bool HandleCommands( + EPlayer sender, + MutableText message, + bool teamMessage) +{ + local Parser parser; + + // We are only interested in messages that start with `chatCommandPrefix` + parser = _.text.Parse(message); + if (!parser.Match(chatCommandPrefix).Ok()) + { + parser.FreeSelf(); + return true; + } + // Pass input to command feature + HandleInputWith(parser, sender); + parser.FreeSelf(); + return false; +} + +private function HandleMutate(string command, PlayerController sendingPlayer) +{ + local Parser parser; + local EPlayer sender; + + // A lot of other mutators use these commands + if (command ~= "help") return; + if (command ~= "version") return; + if (command ~= "status") return; + if (command ~= "credits") return; + + parser = _.text.ParseString(command); + sender = _.players.FromController(sendingPlayer); + HandleInputWith(parser, sender); + sender.FreeSelf(); + parser.FreeSelf(); +} + +defaultproperties +{ + configClass = class'Commands' + errCommandDuplicate = (l=LOG_Error,m="Command `%1` is already registered with name '%2'. Command `%3` with the same name will be ignored.") +} \ No newline at end of file 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.