3
\$\begingroup\$

Prologue

I'm quite new to data oriented programming and my goal is to implement a Runescape-style stats & damage mechanic in a data oriented styme.

This is quite a complex topic (Runescape-Mechanics) and I haven't found any ECS-related sources on that topic yet.

In the following example, we see a bunch of items which modify the wearer's stats based on a few conditions.

This happens in two different variants, either for the damage calculation only, or as a buff.

  • Brine sabre and Brackish blade increase damage against crabs.

    or

  • Silverlight and Darklight increase ability damage by a scaling of 25-124% against most demons. The exact damage bonus is based on your base Attack and Strength, and the monster's base Defence.

The problem

Such RPG systems are very complex. There different damage types, different resistances, and other stats.

Having a strong OOP background and no real experience in DOP, I can't find a suitable architecture to fill those needs.

In my current approach, every stat is a component. Items and Buffs are structs. A item "buffs" its owner and the buff modifies his stats. This works so far, but I have no idea how I could realise the damage calculation, while still keeping it as flexible as it is in Runescape.

This little example would just be able to buff the stats... not deal, receive or modify the damage itself.

// The stats

public struct Health{
  float max;
  float value;
}

public struct MeleeDamage{
  float base;
  float value;
}

public struct MeleeResistence{
  float base;
  float value;
}

// Item & Buffs
public struct Item{
  string name;
  int amount;
  bool equippable;
  List<Buff> buffs;
}

public struct Buff{
  string name;
  float duration;
  Condition applicable;
  ToBuff stat;
  float value;
}

It's also important that an entity can also deal damage to multiple other entities in one frame.

How would you implement such a complex mechanic? Any examples are appreciated!

\$\endgroup\$
1
  • 1
    \$\begingroup\$ We have some past Q&A about similar topics under the effect-modifier tag, which might be useful to you as you puzzle this out. Keep in mind that ECS/DOP is a tool to use when it helps, not a straightjacket to confine you when it doesn't. You can have an ECS/DOP foundation that still includes more OOP style elements where that style might help you work more intuitively/efficiently. \$\endgroup\$
    – DMGregory
    Apr 11, 2021 at 22:52

2 Answers 2

2
\$\begingroup\$

Here's one way to approach it.

First throw all your base stats into one component as follows:

public struct BaseStats {
    float maxHp = 100;
    float meleeDmg = 40;
    float meleeResistance = 20;
    ...
}

This BaseStat component is complimented by a StatModifier component with multiplier and offset modifiers for each stat:

public struct StatModifier {
    float maxHPMult = 1.0;
    float maxHPOffset = 0.0;

    float meleeDmgMult = 1.0;
    float meleeDmgOffset = 0.0;
    
    float meleeResistanceMult = 1.0;
    float meleeResistanceOffset = 0.0;

    ...
}

Your player entity will have both of these components attached and each stat is evaluated in a System using the following equation:

stat = (base * multiplier) + offset;

All equip-able items will also have a StatModifer, and when that item is equipped the on the player, its stat multipliers and offsets are added to the player's StatModifier.


So say you have a ring item that:

  • adds +50 health
  • increases melee damage by 10%
  • reduces melee resistance by 5%

Its StatModifier component would be implemented as follows:

public struct StatModifier {
    float maxHPMult = 0.0;
    float maxHPOffset = 50.0;

    float meleeDmgMult = 0.1;
    float meleeDmgOffset = 0.0;
    
    float meleeResistanceMult = -0.05;
    float meleeResistanceOffset = 0.0;

    ...
}

With the ring equipped the player entity's StatModifier component is updated with the following values:

  playerStatModifier.maxHPMult == 1.0 // unchanged
  playerStatModifier.maxHpOffset == 50 // 0.0 + 50.0
  
  playerStatModifier.meleeDmgMult = 1.1 // (1.0 + 0.1) up 10%
  playerStatModifier.meleeDmgOffset = 0.0 // unchanged
  
  playerStatModifier.meleeResistanceMult = 0.95 // (1.0 - 0.05) down 5%
  playerStatModifier.meleeResistanceOffset = 0.0 // unchanged 
  

The cool thing about this setup is that the player's StatModifier component acts as an accumulator, allowing you to easily stack status effects as items are removed and added.

\$\endgroup\$
0
\$\begingroup\$

My main systems aren't really ECS, but the stat system I came up with is fairly self-contained and consists of just a few components.

The idea behind my system is to have stats stack in all kinds of ways, additive, multiplicative, and a mix of both. Specifically, here's one scenario that I wanted to enable.

A character has its "own" 100 HP (for example), wears an item that gives it an additional 10 HP, and then uses an ability giving it a +20% HP boost. This will then result in the character having (100 + 10) + 20% = 132 HP.

If now this character receives another effect that gives it an additional 50 HP (not 50%), it should have 182 HP.

If this character instead wears an additional item that gives it a 20% HP boost (not an ability effect), then its HP should be

100 + 10 + 20 (20% of 100) = 130, and then 20% from the ability on top of that (156).

I accomplish this using a sort of a hierarchy, an order of stats to be applied.

I also refer to stats by string values - which has all kinds of useful effects, like being able to add any arbitrary stats without messing with the code, or being able to refer to these stats easily in data, without coding complex object factories or switch statements.

The first piece is the StatBonus object. It contains the name of the stat it affects, the flat amount (+10 HP) and the multiplier (+10% HP), as well as which stat order it belongs to.

In my project, I have defined a few such obvious orders:

Template, these are the absolute base stats for a character of a specific kind (class, species, whatever). Those aren't meant to be altered but there's nothing in the code that says that they cannot be, of course.

Character, these are stats that this specific character has in addition to the base stats, but are still an innate part of the character. This includes stat upgrades from levelling up, permanent effects from completing quests, player-assigned stats (some games, instead or in addition to increasing your stats on level-up, give you a small amount of "points" to assign to stats as you please).

Equip, these are bonuses obtained from equipment worn by the character. Those are directly referring to the corresponding StatBonus objects attached to the equipment Items, which makes stat recalculation on equipping/unequipping items trivial.

Socket, those could be a part of the previous order, but I find it easier to calculate item modifications separately - since under the hood these modifications carry their own StatBonus objects and are intended to be swappable, so a modification that gets removed from an equipment piece turns back into an InventoryItem and takes its associated StatBonuses along.

Effect, any stat modifications done by an ability effect. This means that an effect that increases a stat will then synergise with equipment and any other boosts above it.

The last item, Count, is added to simplify the iteration process. I can freely add other stat orders before it and they will be automatically included in the process.

public class StatBonus : ICloneable
{
    public float FlatValue;
    public float Multiplier;
    public string Type;
    public string Description;
    public StatOrder Order;
    public enum StatOrder
    {
        Template,Character,Equip,Socket,Effect,Count
    }

    public object Clone()
    {
        return this.MemberwiseClone();
    }
}

Note how this has no references to anything else in my codebase, this is 100% drop-in ready for any project. The actual class is very barebones, I have added a Clone method that is handy sometimes, for example, when creating things (items, characters etc), as well as a Description - that's mostly there for convenience and is not necessary. The rest should be self explanatory, there is the flat value, the percentage value and a string to refer to the stat affected.

If tomorrow I decide to add a new stat, I don't really need to do anything - I can add it to anything and it will just work, and checking for a non-existing stat will just default to zero.

To receive the final value of a specific stat, I then only need to find all StatBonuses that apply to that stat, and calculate each order and apply in that order.

Here's a pair of functions I use. Again, there are no references specific to anything in my code (except for the StatBonus class from above, of course). All this needs is a List<StatBonus> to iterate over.

public float CalculateStage(float input,List<StatBonus> Bonuses,StatBonus.StatOrder Stage)
    {
        float result = 0;

        List<StatBonus> stage = Bonuses.FindAll(s => s.Order == Stage).ToList();
        float flats = stage.Sum(s => s.FlatValue);
        float multis = stage.Sum(s => s.Multiplier);
        result = (input + flats) * (1 + multis);
        return result;
    }

public float CalculateStat(string statname)
    {
        float result = 0;
        List<StatBonus> stats = StatBonuses.FindAll(s => s.Type == statname).ToList();
        if (stats.Count < 1)
            return 0;
        for(int i=0;i<(int)StatBonus.StatOrder.Count;i++)
        {
            result=CalculateStage(result,stats,(StatBonus.StatOrder)i);
        }
        

Here, the CalculateStat function iterates over all stat orders (here called "stages" and calls the CalculateStage function on each.

The CalculateStage function simply finds all StatBonuses matching the correct stat and the order and spits out the result. This function takes the input param, this is used as the result of the previous stages calculated. I also made this function take the List<StatBonus> as a parameter in case I need it elsewhere, and should probably do that to the CalculateStat function as well, as right now it is referring to a StatBonuses member (which is a List<StatBonus>, of course) of the class it is residing in. Moving that to a parameter as well will make this fully portable and just called from the main class as needed.

Returning to the very beginning, the example of a character with

100 HP, an item adding 10 HP and another adding 20% HP, and an effect granting further 20%

will be calculated in this order:

Template receives 0 as a starting value and returns 100.

Character returns 100 as there's nothing to add (in this example).

Equip receives 100 and returns 130 (10 HP flat and 20% of 100 of input)

Effect receives 130 and returns the final value of 156, having applied a 20% increase to the previous value of 130.

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .