To keep the markup in your strings concise, I'm going to assume that we can infer the style to use for a value from the effect we're reading it from. For example, if I'm reading DamageEffect._value
, I want it to be rendered in "damage red", but if I'm reading HealingEffect._value
, I want it to be rendered in "healing green".
This relationship doesn't need to be one-to-one. I could have multiple different damage effects that all use the same "damage red" style, or a VampiricEffect
type that does damage and renders its value in "healing green", etc.
With this approach, we can write your example fairly concisely and intuitively as:
Choose a target within {DamageRange} squares. Deal {DamageValue} damage to that target and restore your HP by {HealingValue}.
If you want to specify the styling, source, and member explicitly in the string, you can of course support that - but I think the simplicity here is worth the restrictions it imposes, since those restrictions also make it easy to stay consistent. You won't have to track down bugs where one stray text string out of the thousands is using the wrong format for damage values, if it's not the string's job to specify the formatting. 😉
First, let's set aside an enumeration to standardize our vocabulary of text styles. That way we're not passing stringly-typed tokens around everywhere. I chose to put this inside a ScriptableObject we can use to edit our mapping of style preset IDs to their corresponding styling information.
public class TextStyle : ScriptableObject
{
public enum Preset {
Default,
Damage,
Healing,
Duration,
Target,
Range,
Special
}
[System.Serializable]
struct Style {
public Preset preset;
public Color32 colour;
public string numericFormat;
}
[SerializeField]
List<Style> _styles;
int GetStyleIndex(Preset preset) {
return _styles.FindIndex(s => s.preset == preset);
}
// ...More to come...
}
We can obviously get more sophisticated than this, but already this gives us a functional way to specify colours and numeric formatting rules for each style preset.
Next, we'll add some helper methods that standardize applying these styles to a text or numeric parameter value, and packing the result into a structure useful for further localization.
public struct StyledText {
readonly public string text;
readonly public Preset style;
// It's useful to tag replaced text with this info, so you can refer to it
// and correctly substitute "You have 1 item" vs "You have 2 items".
readonly public int pluralClass;
public StyledText(string text, Preset style, int pluralClass = 0) {
this.text = text;
this.pluralClass = pluralClass;
this.style = style;
}
}
public StyledText Stylize(string text, Preset preset) {
int index = GetStyleIndex(preset);
if (index < 0)
return Stylize(text);
return Stylize(text, index);
}
public StyledText Stylize(float value, Preset preset) {
int index = GetStyleIndex(preset);
if (index < 0)
return Stylize(value.ToString());
string text = value.ToString(_styles[index].numericFormat);
return Stylize(text, index, GetPluralClass(value));
}
// Punt when we don't have a matching style, and just pass the text through as-is.
StyledText Stylize(string text) {
return new StyledText(text, Preset.Default);
}
// Apply colour formatting and package up the result for consumption.
StyledText Stylize(string text, int presetIndex, int pluralClass = 0) {
var colour = ColorUtility.ToHtmlStringRGB(_styles[presetIndex].colour);
text = string.Format("<#{0}>{1}</color>", colour, text);
return new StyledText(text, _styles[presetIndex].preset, pluralClass);
}
// This currently implements "Plural Rule #1" which works for Germanic languages like English.
// TODO: Extend to select an appropriate plural rule based on your localization settings.
// https://developer.mozilla.org/en-US/docs/Mozilla/Localization/Localization_and_Plurals
int GetPluralClass(float value) {
if (Mathf.Approximately(value, 1f))
return 0;
return 1;
}
Why the plural stuff? I wanted to show an example of how you can pipe other useful metadata through your text substitution pipeline. In some games I've worked on, we've used this to implement sophisticated substitutions like this:
"You have {MessageCount} new {MessageCount?message,messages}"
- if
MessageCount == 0
, prints "You have 0 new messages"
- if
MessageCount == 1
, prints "You have 1 new message"
- if
MessageCount == 2
, prints "You have 2 new messages"
What's great about this is if you build it with knowledge of different plural rules, you can easily handle translating your text data to languages that have more than one plural form, like Russian, which needs a different variation for numbers ending in 1 other than 11 (first), numbers ending in 2-4 except 12-14 (second), or everything else (third):
У вас {MessageCount} {MessageCount?новое сообщение,новых сообщений,новых сообщения}
(This is just a Google translation - definitely don't use this in your project without paying a real translator!)
I haven't implemented that here, just shown a stub that you could extend to add support for this in future, if you have need of it.
Okay, back to the task at hand. Now we need to map from our replacement tokens to the right value to fetch and the right style to apply to it. As we did with styling presets, let's standardize the vocabulary for the parameters we might want to replace, using an enum.
public enum ParameterID {
DamageValue,
DamageRange,
DamageDuration,
HealingValue,
HealingRange,
HealingDuration
// Add more as needed...
}
You could put the mapping from this vocabulary to the effects to data source and style in a lot of different places, but for simplicity I decided to put it into the Effect
class itself.
// Sets a contract for data structures we can ask to provide parameter values
// and associated styling appropriate to their use.
public interface IParameterSource {
bool TryGetStyledParameter(
ParameterID param,
TextStyle style,
ref TextStyle.StyledText styled);
}
public abstract class Effect : IParameterSource
{
// ... rest of your implementation...
// Implement the interface.
public virtual bool TryGetStyledParameter(
ParameterID param,
TextStyle style,
ref TextStyle.StyledText styled) {
return false;
}
}
Now each subtype can claim the parameters it can supply, and map them to the right style to use for its context.
public class DamageEffect : Effect
{
public override bool TryGetStyledParameter(
ParameterID param,
TextStyle style,
ref TextStyle.StyledText styled) {
switch(param) {
case Parameter.DamageValue:
styled = style.Stylize(_value, TextStyle.Preset.Damage);
return true;
case Parameter.DamageRange:
styled = style.Stylize(_value, TextStyle.Preset.Range);
return true;
case Parameter.DamageDuration:
styled = style.Stylize(_value, TextStyle.Preset.Duration);
return true;
default:
return false;
}
}
}
The advantage of doing it this way is that multiple subtypes can share the same parameter, so we don't need to explode our parameter vocabulary if we have a dozen different versions of the DamageEffect
. When we need to substitute the {DamageValue}
keyword, we'll just grab the first effect in the list that knows how to provide a matching value.
You can also include logic here where needed to vary the style based on the value - like rendering a currency value in red if it's negative but green if it's positive - or other parameters on the effect - like an ElementalDamageEffect
that renders its DamageValue
in different colours depending on the value of its DamageElement
property.
Finally it all comes together. We'll have a function that...
Finds all the {ParameterName}
tokens in our text template, and parses them to their corresponding enum value.
Walks through the list of Effects to find one that has a value for this parameter.
Replaces the token with the styled text from the matching effect.
Here's a simple version of that. I put it in my TextStyle
class, but you could implement it elsewhere instead:
static readonly Regex PARAMETER_TOKEN_FINDER = new Regex(@"{(\w+)}");
public string StylizeParameters(string template, List<IParameterSource> sources) {
string replaced = template;
var matches = PARAMETER_TOKEN_FINDER.Matches(template);
foreach(Match match in matches) {
string token = match.Groups[1].Value;
if(!System.Enum.TryParse(token, out ParameterID param)) {
Debug.LogWarningFormat("Unknown tag in text string: {0}", token);
continue;
}
StyledText replacement = default;
bool matched = false;
foreach(var source in sources) {
if (source.TryGetStyledParameter(param, this, ref replacement)) {
matched = true;
break;
}
}
if(!matched) {
Debug.LogWarningFormat("No parameter source matches token: {0}", token);
continue;
}
replaced = replaced.Replace(match.Value, replacement.text);
}
return replaced;
}
Because this will work with anything that implements the IParameterSource
interface, you could slap an implementation of that interface on all sorts of data structures in your game that you might want to dig values out of for text replacement, like the player character, their inventory, or any number of UI systems.