Tooltip Replacer System

Links

The link above will take you to a download page for Hymn of the Sands. Downloading the game will allow you to see an example of the replacer system in action.

The Problem

During development on Hymn of the Sands, we made extensive use of editor archetypes for our game data. This allowed the level designers to change important gameplay constants from the editor, without ever recompiling the source. This made balancing the abilities much easier and faster, allowing them to quickly iterate and test their changes in-game.

One problem we ran into with this system was that the tooltips needed to be updated whenever an ability constant changed. For example, whenever our designers wanted to change the damage radius for the Hand of Anubis ability, they had to remember to update the number in two places: changing the damage radius constant itself and changing the constant in the ability tooltip text as well.

To make matters worse, there were several numbers in the ability tooltip text that belonged to other entities in the game. For example, the Breath of Ra ability tooltip needed to display the amount of damage dealt by the ability's projectile, which was stored in a separate HSProjectile archetype. Hence, any designer who wanted to change this damage amount needed to remember to update the ability tooltip in the HSAbility archetype whenever the damage values changed in the associated HSProjectile archetype. As you can imagine, this made it even harder to keep the tooltip numbers in sync with the actual gameplay constants.

The Solution

As the UI programmer for our game, it was my job to design and implement a system that would allow the designers to make changes at will without having to worry about updating the tooltips. My solution was to create a common interface for any object with replacers that could be referenced from an ability tooltip. In order to support all replacersThe interface defines an EvaluateReplacer() function which takes a replacer name and outputs either a string result or another object that implements the same interface (HSReplacerNodeInterface). The HUD used this interface whenever a tooltip was displayed to evaluate replacers from the context of the current ability, searching for any name surrounded by curly braces (e.g. {Cooldown}) and replacing it with an appropriate value.

Replacer Node Interface

The advantage of using an interface to access each property was two-fold. Initially, it just allowed us to add replacer functionality to a new class easily by extending the function. However, the most powerful feature was that it allowed us to easily implement nested replacers. For example, querying {Projectile} from the Breath of Ra ability returns a HSReplacerNodeInterface object instead of a string, which means that it can be queried for additional properties (such as {Projectile.Damage} or {Projectile.Lifespan}). Supporting nested replacers allowed us to store the gameplay constants in archetypes other than the HSAbility itself (as they should be), and kept the tooltips up-to-date when these other archetypes were modified.

interface HSReplacerNodeInterface;
 
/**
 * Evaluates a replacer name with an index offset. Should return true if the expression was evaluated.
 * If the index is not necessary, the index will not be used and should be set to zero.
 * If the result was another object, the result should be true and the object returned as the third parameter.
 * If the result was a string, the result should be true and the string returned in the fourth parameter.
 * If the node cannot be evaluated, the function should return false.
 */
function bool EvaluateReplacer( HSGFx MoviePlayer, name Replacer, int Index,
                                out HSReplacerNodeInterface ObjectResult, out string StringResult );
 
DefaultProperties
{
}

Replacer Path Object

When a replacer in the tooltip was identified, the contents of the replacer were parsed and converted into an HSReplacerPath object. This object was simply an array of replacer strings that needed to be evaluated, in order. For example, {Projectile.Lifespan} would be stored as {"Projectile", "Lifespan"} in the array. After parsing the path object for a replacer, the tooltip system would call the EvaluateFromObject() function on the HSReplacerPath, specifying an object context from which to evaluate the replacer and receiving back a string containing the evaluated replacer value.

Internally, EvaluateFromObject() calls EvaluateReplacer() on the HSAbility represented by the tooltip. If there are more elements in the path, it continues to call EvaluateReplacer() on the result of the previous call (which is another HSReplacerNodeInterface object). The call terminates once there are no additional elements in the HSReplacerPath. The last EvaluateReplacer() call is expected to return a string, and whatever was passed to the calling function will be returned as the result. As a safeguard, if the replacer was not valid, the EvaluateReplacer() function returns false, allowing the system to fail gracefully.

/**
 * Represents a path through a set of nested replacers, for use in evaluating tooltip references.
 * The object can be evaluated in order to 
 */
class HSReplacerPath extends Object
	dependson( HSReplacerNodeInterface );
 
 
/**
 * Represents a token and optional offset in a replacer path.
 */
struct HSReplacerPathNode
{
	/** The token name, used to reference some piece of data inside an HSReplacerNodeInterface subclass. */
	var name Name;
 
	/** An optional offset, used when referencing a specific element of an array-like data structure. */
	var int Index;
};
 
 
/**
 * The list of replacer path nodes in this replacer path.
 */
var Array< HSReplacerPathNode > Nodes;
 
 
/**
 * Whether the replacer path is valid (i.e. the string data used to build it was well-formed).
 */
var bool bIsValid;
 
 
/**
 * Parses a replacer string and returns a replacer path object.
 */
static function HSReplacerPath CreateFromString( string Replacer )
{
	local HSReplacerPath Path;
	local Array< string > Tokens;
	local string Token;
	local int TokenSplitLocation;
	local HSReplacerPathNode Node;
 
	// Create a new path object.
	Path = new class'HSReplacerPath';
	Path.bIsValid = true;
 
	// Split the string into separate tokens based on the dots in the replacer string.
	ParseStringIntoArray( Replacer, Tokens, ".", false );
 
	foreach Tokens( Token )
	{
		// Search for an underscore at the end of the token to use as an index delimeter.
		TokenSplitLocation = InStr( Token, "_", true );
 
		if( TokenSplitLocation != -1 )
		{
			// If there is an underscore in the token, grab the text after the last underscore
			// and try to parse it as an integer for the index.
			// NOTE: Indices are assumed to be one-based in the replacer string, but are converted
			// to a zero-based index when interpreted.
			Node.Index = ( int( GetRightMost( Token ) ) - 1 );
 
			if( Node.Index >= 0 )
			{
				// If the text after the underscore could be cast to an integer, split the token
				// at the last underscore.
				Token = Left( Token, TokenSplitLocation );
			}
		}
 
		// Convert each token to a name.
		Node.Name = name( Token );
 
		// Otherwise, append the new token and index to the path.
		Path.Nodes.AddItem( Node );
	}
 
	return Path;
}
 
 
/**
 * Evaluates the replacer path from a certain object and returns a string result.
 * If the replacer path could not be evaluated, returns an empty string.
 */
function string EvaluateFromObject( HSGFx MoviePlayer, HSReplacerNodeInterface ParentObject )
{
	local HSReplacerPathNode Node;
	local HSReplacerNodeInterface NextObject;
	local string Result;
 
	// Start at the current object.
	NextObject = ParentObject;
 
	foreach Nodes( Node )
	{
		// Evaluate the node from the current object.
		if( NextObject.EvaluateReplacer( MoviePlayer, Node.Name, Node.Index, NextObject, Result ) == false )
		{
			// If the node could not be evaluated. Return an empty string.
			Result = "";
			break;
		}
	}
 
	// (At this point, there should be a string result in the Result var, or an empty string.)
 
	return Result;
}
 
 
DefaultProperties
{
}

Property Access vs. Public Interface

Originally, I set out to find some way to allow the tooltips to access properties of each class directly. My thinking was that anything defined in the class should be available as a tooltip replacer, to prevent having to update the EvaluateReplacer() function every time I wanted to expose a new property. However, I eventually realized that there are big advantages to using an accessor method over grabbing the properties directly.

The first advantage of using an accessor method is that the datatype can be controlled. Like Java's toString() method, the calling code never has to worry about what type of data will be returned. The result will always be a string, and will be formatted using whatever formatting conventions the class normally uses to describe itself. In this case, the callee can determine how best to format the property as a string. Moreover, the programmer has far more power over how the internal property is converted and formatted. Explicitly writing this code on a case-by-case basis is much more robust than trying to write a generic method that can convert a property of any data type to a string.

The second main advantage of using an accessor is that the programmer can define replacers for properties that don't really exist. For example, for our HSMovementModifier status effect, we had two ways of accessing the same property for the modifier value: {MovementModifier} and {MovementModifierPercentage}. The only difference between the two was that the first formatted the result as a scalar value (e.g. in the 0.0 to 1.0 range) while the second returned the value formatted as a percentage (e.g. in the 0 to 100 range). The data always stayed in sync, since both replacers were accessing the same property internally, but having both allowed our level designers to be more expressive when writing the ability descriptions. This turned out to be vital in allowing our designers to access elements of an array within a class. The designer would simply append an underscore and a number to an array replacer (such as {OwnerStatusEffect_2}, for the second owner status effect of an ability), and the function would return the array element at that index.

One final advantage is that it supports inheritance nicely. If a replacer is not evaluated by a child class, the child can call the parent implementation and return the result. (See the example below.) This allowed us to have certain replacers available from any subclass of HSAbility, such as {Cooldown} and {MaxChannelDuration}.

The typical implementation of EvaluateReplacer() was quite simple. Here is an example from our HSAbility_Beam class, which we used to implement the Spirit Drain ability:

function bool EvaluateReplacer( HSGFx MoviePlayer, name Replacer, int Index, out HSReplacerNodeInterface ObjectResult, out string StringResult )
{
	local bool bWasEvaluated;
 
	// Assume that the replacer will be matched.
	bWasEvaluated = true;
 
	switch( Replacer )
	{
	case 'MaxTargetCount':
		StringResult = string( BeamLengths.Length ); break;
 
	case 'TargetStatusEffect':
		ObjectResult = TargetStatusEffectArchetype; break;
 
	case 'DPSPercentage':
		StringResult = string( int( DPSPercent * 100 ) ); break;
 
	case 'DPSHealPercentage':
		StringResult = string( int( DPSHealPercent * 100 ) ); break;
 
	case 'MaxDamage':
		StringResult = string( MaxDamage ); break;
 
	case 'MaxHeal':
		StringResult = string( MaxHeal ); break;
 
	case 'MinDamage':
		StringResult = string( MinDamage ); break;
 
	case 'MinHeal':
		StringResult = string( MinHeal ); break;
 
	default:
		// Let the parent class handle the replacer.
		bWasEvaluated = super.EvaluateReplacer( MoviePlayer, Replacer, Index, ObjectResult, StringResult );
	}
 
	return bWasEvaluated;
}

As you can see, with simplicity often comes flexibility.