Skip to content

shanecelis/SeawispHunter.RolePlay.Attributes

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SeawispHunter.RolePlay.Attributes README

There comes a time when many a gamedev sets out for adventure but first must create their own stat class. That is to say a class which captures a game "stat", or "statistic" for lack of a better word, like health, attack, defense, etc. This one is mine. Oh wait! there is a better word: "attribute." But I don't want to take "attribute"1 from your game, so I'll call mine IModifiableValue<T>. Consider it a sound building block for you to create your own attribute class.

These attributes and their derivatives may affect and be effected by a multitude of transient things, e.g, a sword that bestows an attack advantage; a shield that raises one's defense; a ring that regenerates health. Because of that attributes ought to respect the following requirements.

Requirements

  • An attribute's value shall be altered non-destructively.
  • Because so many things may alter an attribute, it shall notify us when changed.

Features

  • Interface Based

The heart of this library is defined by a few interfaces. So one can substitute their own implementations. Especially helpful if one wants to define modifiers in Unity for instance.

  • Supports Generics

Too many libraries decide all attributes must be a float or an int. With this library one can choose which type suits an attribute, and they may still interact with one another.

  • Generic Math

Many a library probably shied away from generics because .NET has not had generic math support, i.e., it was not possible to write T Plus<T>(T a, T b) => a + b. The release of .NET 7 will have that support, which this library makes use of. In addition a workaround makes it possible to present the same API with netstandard2.0, which is important if one wants to use this library with Unity3D.

  • Flexible Modifiers

Sure, one can add, minus, multiply, and divide their stats, but what about clamping the value? Is it easy to add that feature? With this library one can implement their own modifier or create ad hoc ones easily.

Barebones Example

var health = new ModifiableValue<float>(100f);
Console.WriteLine($"Health is {health.value}."); // Prints: Health is 100.
health.modifiers.Add(Modifier.Times(1.10f, "+10% health")); 
Console.WriteLine($"Health is {health.value}."); // Prints: Health is 110.
health.modifiers.Add(Modifier.Plus(5f, "+5 health"));
Console.WriteLine($"Health is {health.value}."); // Prints: Health is 115.

Attribute

At its root, an attribute has an initial.value. With no modifiers present, its value equals its initial.value; its value is altered by modifiers starting from its initial.value.

public interface IModifiableValue<T> {
  T initial.value { get; set; }
  T value { get; }
  /** The list implementation sets up property change events automatically. */
  ICollection<IModifier<T>> modifiers { get; }
  event PropertyChangedEventHandler PropertyChanged;
}

Just to be explicit, an attribute's value $v$ that had an initial value $i$ and three modifiers $m_1, m_2, m_3$ would be computed like this:

$$ v = m_3(m_2(m_1(i))) $$

Modifier

A modifier accepts a value and can change it arbitrarily.

public interface IModifier<T> {
  bool enabled { get; set; }
  T Modify(T given);
  event PropertyChangedEventHandler PropertyChanged;
}

However, often times the changes one wants to make are simple: add a value, multiple a value, or substitute a value so these are made convenient for int, float, and double types.

public static class Modifier {
  public static IModifier<T> Plus(T value, string name = null);
  public static IModifier<T> Minus(T value, string name = null);
  public static IModifier<T> Times(T value, string name = null);
  public static IModifier<T> Divide(T value, string name = null);
  public static IModifier<T> Substitute(T value, string name = null);
}

Change Propogation

These classes use the INotifyPropertyChanged interface to propogate change events, so any modifier that's changed or added will notify its attribute which will notify any of its listeners. No need to poll for changes to an attribute.

Abridged API

The API shown above is abridged to make its most salient points easy to understand. The actual code includes some abstractions like IValue<T> and IReadOnlyValue<T> which are used to make attributes reuseable as modifiers for instance.

Indeed this README has outright lies and is better for it.

Further Examples

Using Notifications

var health = new ModifiableValue<float>(100f);
health.PropertyChanged += (_, _) => Console.WriteLine($"Health is {health.value}.");
health.modifiers.Add(Modifier.Times(1.10f, "+10% health"));
// Prints: Health is 110.
health.modifiers.Add(Modifier.Plus(5f, "+5 health"));
// Prints: Health is 115.

Modeling a Consumable Attribute

Let's create a current health value to pair with a max health attribute.

var maxHealth = new ModifiableValue<float>(100f);
var health = new BoundedValue<float>(maxHealth.value, 0f, maxHealth);

health.PropertyChanged += (_, _) => Console.WriteLine($"Health is {health.value}/{maxHealth.value}.");
// Prints: Health is 100/100.
health.value -= 10f;
// Prints: Health is 90/100.
maxHealth.modifiers.Add(Modifier.Plus(20f, "+20 level gain"));
// Prints: Health is 90/120.

Using an Attribute as a Modifier

In addition to creating modifiers with a static value like Modifier.Plus(20f), one can also create dynamic modifiers based on other values or attributes.

Suppose "max health" is affected by "constitution" like this.

HP Adjustment vs Constitution

var constitution = new ModifiableValue<int>(10);
int level = 10;
// We can project values with some limited LINQ-like extension methods.
var hpAdjustment = constitution.Select(con => (float) Math.Round((con - 10f) / 3f) * level);
var maxHealth = new ModifiableValue<float>(100f);

maxHealth.PropertyChanged += (_, _) => Console.WriteLine($"Max health is {maxHealth.value}.");
maxHealth.modifiers.Add(Modifier.Plus(hpAdjustment));
// Prints: Max health is 100.
constitution.initial.value = 15;
// Prints: Max health is 120.

Note: the attributes are different data types: constitution is an int, maxHealth is a float.

One might notice that hpAdjustment depends on the value of level. One would hope that a change to level would notify hpAdjustment and ultimately maxHealth; however, because level is an int that won't happen. See the Advanced Examples for how to include level changes elegantly.

Creating New Modifiers

New modifiers can be created by implementing the IModifier<T> interface or by using the convenience methods in Modifier like FromFunc() shown below. Perhaps one has armor that bestows different affects depending on the phase of the moon2.

var moonArmor = new ModifiableValue<float>(20f);
moonArmor.modifiers.Add(Modifier.Create((float x) => DateTime.Now.IsFullMoon() ? 2 * x : x));

Ordering Modifiers

The priority of a modifier defines its order. The default priority is 0. Lower numbers apply earlier; higher numbers apply later. Modifiers of the same priority apply in the order of they were inserted. This also demonstrates how we can clamp a value by creating an ad hoc modifier with a Func<float,float>.

var maxMana = new ModifiableValue<float>(50f);
var mana = new Modifiable<IReadOnlyValue<float>,float>(maxMana); // maxMana is an IReadOnlyValue.
var manaCost = Modifier.Minus(0f);
mana.modifiers.Add(manaCost);
mana.PropertyChanged += (_, _) => Console.WriteLine($"Mana is {mana.value}/{maxMana.value}.");
mana.modifiers.Add(priority: 100, Modifier.Create((float x) => Math.Clamp(x, 0, maxMana.value));
// Prints: Mana is 50/50.
manaCost.value = 1000f;
// Prints: Mana is 0/50.

Time Out a Modifier

There are EnableAfter() and DisableAfter() extension methods for IModifier<T>.

var armor = new ModifiableValue<int>(10);
var powerUp = Modifier.Plus(5);
armor.modifiers.Add(powerUp);
health.PropertyChanged += (_, _) => Console.WriteLine($"Armor is {armor.value}.");
// Prints: Armor is 15.
powerUp.DisableAfter(TimeSpan.FromSeconds(20f));
// ... 
// [Wait 20 seconds.]
// Prints: Armor is 10.

Advanced Examples

Synthesizing Multiple Values

In the above constitution example, level is an int so hpAdjustment does not update when it's changed. Instead, we can take another page out of LINQ and use a Zip() extension method to synthesize two values. This way a change to either level or constitution will notify hpAdjustment and therefore maxHealth.

var constitution = new ModifiableValue<int>(10);
var level = new Value<int>(10);
// We can project values, with some limited LINQ-like extension methods.
var hpAdjustment = constitution.Zip(level, (con, lev) => (float) Math.Round((con - 10f) / 3f) * lev);
var maxHealth = new ModifiableValue<float>(100f);

maxHealth.PropertyChanged += (_, _) => Console.WriteLine($"Max health is {maxHealth.value}.");
maxHealth.modifiers.Add(Modifier.Plus(hpAdjustment));
// Prints: Max health is 100. (unchanged)
constitution.initial.value = 15;
// Prints: Max health is 120.
level.value = 15;
// Prints: Max health is 130.

Writing Your Own Attribute Class

One can go far with IModifiableValue<T> but one may want to organize modifiers beyond just their order at some point. There are plenty of ways to do this. Seeing as this library's design was informed by a number of sources:

What would their stats classes look like rewritten in this library?

Jacob Penner's Stat Class

Here is an example of what an attribute class might look like if organized like Jacob Penner:

public class PennerStat<T> : ModifiableValue<T> {
  public readonly IModifiableValue<T> baseValuePlus = new ModifiableValue<T>();
  public readonly IModifiableValue<T> baseValueTimes = new ModifiableValue<T>(one);
  public readonly IModifiableValue<T> totalValuePlus = new ModifiableValue<T>();
  public readonly IModifiableValue<T> totalValueTimes = new ModifiableValue<T>(one);

  public PennerStat() {
    // value = (baseValue * baseValueTimes + baseValuePlus) * totalValueTimes + totalValuePlus
    modifiers.Add(100, Modifier.Times(baseValueTimes));
    modifiers.Add(200, Modifier.Plus(baseValuePlus));
    modifiers.Add(300, Modifier.Times(totalValueTimes));
    modifiers.Add(400, Modifier.Plus(totalValuePlus));
  }

  private static T one => Modifier.GetOp<T>().one;
}

Kryzarel's Stat Class

Kryzarel's Stat class might look like this:

public class KryzarelStat<T> : ModifiableValue<T> {
  public enum Priority {
    Flat = 100,
    PercentAdd = 200,
    PercentTimes = 300
  };

  public readonly IModifiableValue<T> flat = new ModifiableValue<T>();
  public readonly IModifiableValue<T> percentAdd = new ModifiableValue<T>(one);
  public readonly IModifiableValue<T> percentTimes = new ModifiableValue<T>(one);

  public KryzarelStat() {
    // value = (baseValue + flat) * percentAdd * percentTimes
    modifiers.Add((int) Priority.Flat, Modifier.Plus(flat));
    modifiers.Add((int) Priority.PercentAdd, Modifier.Times(percentAdd));
    modifiers.Add((int) Priority.PercentTimes, Modifier.Times(percentTimes));
  }

  private static T one => Modifier.GetOp<T>().one;
}

Some care might need to be taken when adding modifiers to preserve the original's behavior.

var stat = new KryzarelStat(30f);
stat.flat.modifiers.Add(Modifier.Plus(10f)); // flat expects plus modifiers (or subtract).
stat.percentAdd.modifiers.Add(Modifier.Plus(10f)); // percentAdd expects plus modifiers (or subtract).
stat.percentTimes.modifiers.Add(Modifier.Times(1.2f, "+20%")); // percentTimes expects times modifiers (or divide but who does that?).

But that's either a discipline you can adopt or a convenience method you can write. A small price to pay for the flexibility these modifiers provide.

Other Stat Classes

See the Style.cs file for more examples.

And please feel free to share any that you develop with me @shanecelis.

Notes

Dealing with Math in Generics

.NET 7 has generic math operators, which will be a godsend. It will allow us to write methods like this:

T Plus<T>(T a, T b) where T : INumber<T> => a + b;

which is invalid for prior versions.

This attributes library makes use of this generic math, however, we also want to support netstandard2.0 because that's what Unity supports. So here's a trick given by this article to allow you to do generic math without .NET 7's INumber<T> support.

interface IOperator<T> {
  T Plus(T a, T a)
}

struct OpFloat : IOperator<float> {
  public float Plus(float a, float b) => a + b;
}

void SomeProcessing<T, TOperator>(...) where TOperation : struct, IOperator<T> {
  T var1 = ...;
  T var2 = ...;
  T sum = default(TOperator).Plus(var1, var2);  // This is zero cost!
}

void Caller() {
  SomeProcessing<float, OpFloat>(...);
}

License

This project is released under the MIT license.

Acknowledgments

This project was inspired and informed by the following sources:

I am indebted to each of them for the generosity they showed in writing about the role playing attributes problem, both for their prose and code.

Footnotes

  1. Or "Attribute", "IAttribute", etc.

  2. Unfortunately there is no such extension method IsFullMoon() for DateTime by default but one can add it.

About

Defines role playing attributes like "health" using generics that are modified non-destructively and notify on changes

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Sponsor this project

 

Packages

No packages published

Languages