Browse Source

Merge branch 'develop' into refactor_commands

pull/12/head
Anton Tarasenko 2 years ago
parent
commit
a9fd4abb64
  1. 4
      config/AcediaSystem.ini
  2. 53
      sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc
  3. 1
      sources/LevelAPI/API/Time/Timer.uc
  4. 627
      sources/LevelAPI/Features/Commands/Commands_Feature.uc
  5. 30
      sources/Players/EPlayer.uc
  6. 219
      sources/Players/PlayerNotificationQueue.uc
  7. 19
      sources/Users/User.uc

4
config/AcediaSystem.ini

@ -140,6 +140,10 @@ requiredGroup=""
maxVisibleLineWidth=80 maxVisibleLineWidth=80
maxTotalLineWidth=108 maxTotalLineWidth=108
[AcediaCore.PlayerNotificationQueue]
; Maximum time that a notification is allowed to be displayed on the player's screen
maximumNotifyTime=20
[AcediaCore.ColorAPI] [AcediaCore.ColorAPI]
; Changing these values will alter color's definitions in `ColorAPI`, ; Changing these values will alter color's definitions in `ColorAPI`,
; changing how Acedia behaves ; changing how Acedia behaves

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

1
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 * Setting this value while the caller `Timer` is running resets it (same as
* calling `StopMe().Start()`). * calling `StopMe().Start()`).
* But the already inactive timer won't start anew.
* *
* @param newInterval How many seconds should separate two `OnElapsed()` * @param newInterval How many seconds should separate two `OnElapsed()`
* signals (or starting a timer and next `OnElapsed()` event)? * signals (or starting a timer and next `OnElapsed()` event)?

627
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 <https://www.gnu.org/licenses/>.
*/
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<Text> commandDelimiters;
// Registered commands, recorded as (<command_name>, <command_instance>) pairs.
// Keys should be deallocated when their entry is removed.
var private HashTable registeredCommands;
// `HashTable` of "<command_group_name>" <-> `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<string> 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<Command> 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<Command> commandClass)
{
local int i;
local CollectionIterator iter;
local Command nextCommand;
local Text nextCommandName;
local array<Text> commandGroup;
local array<Text> 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<Command> 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<Text> GetCommandNames()
{
local array<Text> 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<Text> GetCommandNamesInGroup(BaseText groupName)
{
local int i;
local ArrayList groupArray;
local Command nextCommand;
local array<Text> 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<Text> GetGroupsNames()
{
local array<Text> 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.")
}

30
sources/Players/EPlayer.uc

@ -53,6 +53,8 @@ struct PlayerSignals
// `PlayersAPI` and is expected to be allocated during the whole Acedia run. // `PlayersAPI` and is expected to be allocated during the whole Acedia run.
var protected PlayerSignals signalsReferences; var protected PlayerSignals signalsReferences;
var private LoggerAPI.Definition errNoIdentity;
protected function Finalizer() protected function Finalizer()
{ {
_.memory.Free(controller); _.memory.Free(controller);
@ -502,7 +504,31 @@ public final function /* borrow */ ConsoleWriter BorrowConsole()
return consoleInstance.ForPlayer(self); 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 usesObjectPool = false
errNoIdentity = (l=LOG_Warning,m="`EPlayer` without `identity` is being used. It is likely not initialized and something is working incorrectly.")
} }

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

19
sources/Users/User.uc

@ -103,6 +103,25 @@ public final function int GetKey()
return key; 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 * 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. * name along with the name of the data group it is stored in.

Loading…
Cancel
Save