3
\$\begingroup\$

Consider this spell:

Drain Life

As you can see in my design a Spell is a composition of one or more Effects. An Effect has properties on it like value, range, etc. So, I can say that the Spell Description is an aggregate description of the composition of effects.

Presentation Layer

In my presentation, I want to decorate part of the description string in HTML tags and display as rich text. I also want to ensure that my string's numeric values come from data.

Solution 1

Simply hard code the string. In this case the spell description would look like: Choose a target within <#f4b350>4</color> squares. Deal <#f33613>4</color> damage to that target and restore your HP by <#3cf73c>50%</color> of the damage done.

This approach has some obvious issues, namely:

  • Changing the color value of a particular tag would be a nightmare when there are hundreds of abilities

  • Changing the damage value of a spell means updating it in two places, the string and the data

Solution 2

Create a tag series separated by _ that indicates various properties. I could for example, do something like Deal {subtractive_value} damage to that target with the idea that when I go to parse the string, subtractive could be a tag indicating a red color, and value can be a tag indicating that its the effect's value that should go there.

This is all fine except that a spell can have multiple effects. I need a way to associate the part of the description with the specific effect that drives it.

Modification to Solution 2

So then I suppose I could just add IDs to the spell effects such that the string defined in data becomes:

Choose a target within {neutral_damageEffect_range} squares. Deal {subtractive_damageEffect_value} damage to that target and restore your HP by {additive_healEffect_value}.

But now you see that its getting a bit cumbersome and I'm dubious of how scalable that solution is.

Non-Goal

Lastly I figured I'd mention a non-goal. If you take my approach thus far to its ultimate conclusion, you would probably end up with trying to find a way to generate your description text from the effect themselves. While this is an interesting idea, I think it's a bit too ambitious, so I'm specifically not trying to do that. That said, if you feel like that's easier than I think it is, feel free to make that suggestion!

Question

So, given what I've tried and the problem I'm trying to solve, there's no way this is a unique problem to me. Is there a battle-tested solution to this problem? What's a good way to go about it or some patterns to use I might not know about?

EDIT

Some have asked for some code, it is lengthy though so I can just link to a gist here: https://gist.github.com/jsandersr/ef6d560ad95f1d63b1f8e3b2099038a5

\$\endgroup\$
4
  • 1
    \$\begingroup\$ Can I confirm whether you also need to localize these strings to different languages, or is it safe to assume you will support only the English dialect you've shown here so far? \$\endgroup\$ – DMGregory Sep 11 '20 at 22:29
  • \$\begingroup\$ Only English so far, I don't plan on localizing but I suppose it'd nice a nice bonus to have a solution that could extend to support that \$\endgroup\$ – Josh Sanders Sep 11 '20 at 22:59
  • 1
    \$\begingroup\$ I have some thoughts on how to tackle this. To plug into what you have now though, it would help to see how your effects are implemented. Are these polymorphic, with different subclasses for the different effect types? Or are they all the same type, like a discriminated union with an enum describing the effect, or delegate variables wiring-up the distinct behaviours of each? \$\endgroup\$ – DMGregory Sep 12 '20 at 0:32
  • \$\begingroup\$ Sure! Updated with a link to a gist that details the class drivers of the data I'm working with. Effect is just a base class with a virtual Apply function that takes in a source and a target. The idea is that an effect should know how to manipulate the "hero" that they target or their "hero" source. It might get more complex later because I have some pretty complex spells, but to get something working I was going to start there. \$\endgroup\$ – Josh Sanders Sep 12 '20 at 0:58
1
+50
\$\begingroup\$

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.

Text Style Inspector

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...

  1. Finds all the {ParameterName} tokens in our text template, and parses them to their corresponding enum value.

  2. Walks through the list of Effects to find one that has a value for this parameter.

  3. 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.

\$\endgroup\$
1
\$\begingroup\$

Based on your question, I think you have a good grasp of what your problem is, and what potential solutions you have, but you are having trouble with deciding which solution to use. Therefore, my answer will be focused on that.

I suppose you are making this project because you actually want to ship a game, so you want to minimize the amount of effort that will take to build an abstraction, write the data for such abstraction, and minimize the effort that will take to modify that data. Based on this assumption, the main consideration when making design choices, should be:

If I spend X hours to make this abstraction, so it will later save me Y hours, is X < Y?

Let's dissect X and Y a bit more. For X, the amount of time that it takes to write an abstraction layer is not the only thing that counts; the amount of time you spend thinking about the problem, and even the amount of time it took you to write this question here at StackExchange, is definitely part of X. There are two pieces of advice I can give you when calculating X:

  • It is very easy to underestimate X. Just like any other system, the amount of time it takes to build and maintain a system, grows exponentially to the complexity of such system. Corollary: simple systems are much easier to build and maintain than complex ones.
  • It takes a lot of determination to drop a system to which you have already invested time on. In other words, if you start building a system, and you then learn you didn't need it, it is very easy to fall in the trap of thinking that you might as well finish it, since you already started it. Especially for complex systems.

As for Y, a good way of estimating it is by defining it as Y = k * (S - R), where k is the amount of data that the system uses, S is the amount of time it would take to build and maintain that piece of data without the abstraction, and R is the amount of time it will take to build and maintain that piece of data with the abstraction. There are some observations to be made here:

  • The smaller k is, the less worth it is to make an abstraction. For your specific system, consider how many spells your game will have. If it has something in the order of 10~50, it may not be worth writing a whole abstraction for so few spells. If you're having hundreds to thousands of different spells, then abstracting it may sound better and better.
  • As engineers, we like overestimating S. You said that you are worried about the possibility of changing a color, which will require you to go through every string changing it. But how about writing the data in a text file and using find-and-replace? or how about writing a simple regex that changes the colors each time you want to change them? In other words: is the actual problem as big as you think it is?
  • Also, as engineers, we like underestimating R. Going back to the color issue, it is possible that the majority of the time spent adjusting the color will not be spent actually changing the color definition, but compiling the game, launching it, going to the actual screen where you can view the color, and deciding whether it needs changing or not. This time is consumed regardless of whether you have an abstraction or not.

Furthermore, as you build an abstraction, and especially, the earliest you start building it, you might fall into either one of the following paths:

  • Your abstraction is too specific, and any additional flexibility you need will further increase X exponentially.
  • Your abstraction is too general, and becomes nothing more than a convoluted Enterprise Rules Engine. The result is that configuring your system can even take longer than if there was no system to begin with!

It may seem as if I'm suggesting you don't make an abstraction, and I probably am a bit. If you're making a game, I think there will be dozens to hundreds of other problems that you will face, and you can end up spending much more time building abstractions than actually building the game. That's a great way to get demotivated and give up on making a game.

As controversial as it seems, hard coding is not a bad thing. My suggestion is to start building without any abstractions, and only build abstractions for specific problems that you're facing, when the time you've already spent without the abstraction is the same as the time it would take to build such an abstraction (view the ski rental problem).

What I said above is my interpretation of Donald Knuth's quote:

"Premature optimization is the root of all evil"

\$\endgroup\$
1
  • \$\begingroup\$ This is an awesome response, thank you so much! This is really great insight and great advice. I think I will likely take your approach for now and DMGregory's approach later. \$\endgroup\$ – Josh Sanders Sep 21 '20 at 4:15

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.