Combat Stats

Published October 04, 2018
Advertisement

I am currently working on combat stats. I've worked on them before, but never really fleshed out a system I've been satisfied with. For the most part, I've gotten by with half-assed, or at best three-fourths-assed systems that always kinda change as my requirements change, but with no clear structure or rules. All of that half-assed code and system is gone now. In its place is basically a spreadsheet. In fact, it would make sense to define the data files underpinning it all as an actual spreadsheet. There are problems with that, though; namely that I'm terrible at spreadsheets, since I've never really used them. Sure, I have LibreOffice Calc on my laptop, and sure I've even fired it up once or twice. But I find the spreadsheet format to be clunky, and I really don't want to write a bunch of code to pull fields and expressions out of an XML document. So, I'm writing my data files by hand. It's actually not bad so far, though I can see the need to write a tool for it in the future.

So, stats.  As a jumping off point, I want to implement a system similar to Path of Exile. My goal is to support player-side stats with a similar level of depth/and complexity, and to also support monster-side stats with the ability to scale enemies based on player level. In order to support rapid iteration and testing of concepts, the stats need to be mostly defined in data. Some underlying concepts (damage types, damage scaling, etc...) are hard-coded, but the stat relations are defined in JSON files that are specified as attributes of the CombatStats component that all combat-enabled units possess.

If you are not familiar with Path of Exile, their system is a 3-stat based (Strength, Intellect, Dexterity) system, with 4 types of damage mitigation: armor (to mitigate physical), evasion (to dodge stuff), energy shield (to absorb damage) and resistances (for elemental damage types and chaos damage). Dexterity contributes to Evasion, Intellect contributes to Energy Shield, and Strength contributes to Life (which is stupid, imo; it should contribute to Armor instead, for the sake of balance). Body Armor, Boots and Gloves all have score requirements for Str, Dex and Int; there are high-strength armors that provide high Armor rating, high-int armors for Energy shield, etc... So in order to equip certain things, you need sufficient quantities of the relevant stat(s). Classes are also aligned with the three base stats, with one character class per stat, and one character class for each hybrid (ie, Templar is a Strength/Int hybrid class). Resistances stand outside of the 3-stat system, with each resistance being increased by mods on gear or from the passive talent tree. They are a percentage-based mitigation, with each having a maximum cap (starts at 75%) that can be raised through talents or equipment.

Aside from the 3 stats, there are other ways to build a character. Simple passives such as +%increased Spell Damage, or +%increased Melee damage, and other simple number increases are available. More importantly, there are Keystone talents available on the talent tree, and special mods available on certain unique items, that provide more complicated stat alterations than simple number increases/decreases. For example, the Avatar of Fire keystone dictates that 50% of all physical, lightning or damage the player deals is converted to Fire damage, and the character deals nothing but Fire damage. This has a huge effect on how the rest of the character can build. In conjunction with an item that provides 50% of physical damage is converted to Fire, it means you can build a straight melee brawler who can deal massive physical damage, but have all of that damage convert to Fire with a possibility for Fire damage to ignite targets, causing a DoT Burn effect.

So, in light of the fact that I would like to build something similar, I've come up with a fairly simple system. I have implemented a StatSet structure, which owns an unordered_map of stats. The stats are keyed by a string hash, so that they can be requested either by name or by the hash of the name. Requesting by name is useful for debugging/testing, and requesting by the hash is marginally more performant. A Stat is implemented simply as a linked list of mods.

In earlier versions, I implemented Stat as a value plus a list of mods, where each mod was either a flat addition to the stat, or a multiplier. The final value of the stat, then, was calculated as (Base + (SumOfFlatMods)) * (1.0 + SumOfMultMods) In this setup, you can have mods that add a flat amount (say, +10) to a given stat, or you can have mods that increase a given stat by some percentage. Specifying a Mult mod as 0.5, for example, is tantamount to increasing the stat value by 50%. In the current iteration, I have done away with the Base value, since it's basically just a Flat mod in disguise. Now, a Stat is simply a list of mods that either apply a flat additive value or a multiplier. These mods can either be simple numeric values, or they can be derived from other stats. I have also added a mod type of Scale, which can be set to scale the final result of a mod by a value. This Scale value is normally 1, so that it has no effect, but if a mod of type Scale with a value of 0 is applied, then it can set the stat to 0. This facilitates stats such as the previously mentioned Avatar of Fire, by applying a mod of 50% to the ConvertPhysicalToFire (and other conversion stats), and by supplying a mod of 0 to the Scale field of the other damage types, as an example. Thus, a Stat's value is finally calculated as ((SumOfFlatMods) * (1.0+SumOfMultMods))*ScaleMod. (Scale mods are not calculated as sums, but rather as a direct replacement. You can specify multiple Scale mods, but only the last one encountered will apply.)

Also allowed are mods that can fix the value of a stat to some specified maximum or minimum. By this means, I can cap a stat (for example, a resistance capped at 75%) and use another stat to specify the cap value (resistance max). I can also cap the bottom end of a stat, for example by clamping a resistance to a -1.0 value, meaning that you can't go any lower, and it's not possible to do more than double damage against a target due to negative resistance. This basement cap can, of course, be modified, opening up the potential for builds that are very powerful against negative-resistance enemies.

These various relations are specifiable in a JSON file. In fact, I can load multiple JSON files into a StatSet. This allows me to break off common functionality into one file, and load class-specific files per-unit. The syntax of my data files is still a little bit clunky, but here is an example:


{
	"MajorStatPerLevel": [{"Type": "Flat", "Value":5}],
	"MinorStatPerLevel": [{"Type": "Flat", "Value":2}],

	"Bravado": [{"Type": "Flat", "Value": 5}, {"Type": "StatFlat", "ModType": "CalcLinear", "Stat": "Level", "Scale": "MajorStatPerLevel"}],
	"Arrogance": [{"Type": "Flat", "Value": 3}, {"Type": "StatFlat", "ModType": "CalcLinear", "Stat": "Level", "Scale": "MinorStatPerLevel"}],
	"Cunning": [{"Type": "Flat", "Value": 3}, {"Type": "StatFlat", "ModType": "CalcLinear", "Stat": "Level", "Scale": "MinorStatPerLevel"}],
}

In this example, I define two 'utility' stats, MajorStatPerLevel and MinorStatPerLevel, as flat values (5 and 2). Then I define 3 core stats (Bravado, Arrogance and Cunning, which are goblin-ish reskins of the more familiar Strength, Int and Dex). These core stats are defined using 2 mods apiece: a flat base mod, and a mod that applies a flat amount calculated as Level multiplied by a scaling factor. (The Level of a combatant is just another stat; in this way, by changing the Level stat, I 'automatically' get level-based scaling.) This snippet would scale Bravado higher than Arrogance and Cunning, and so would be appropriate for a Bravado-centric character class. A different file could be loaded for Arrogance or Cunning-based classes. (I could, of course, provide stats for hybrids, but I think 3 classes is quite enough for a solo developer to try to tackle.)

This setup means that with each 1 increase in experience Level, the unit gains 5 Bravado and 2 of Arrogance and Cunning, on top of a base of 5, 3 and 3. To get the current value of Bravado, I can simply call StatSet::GetStat(name)::Get(), and the scaling occurs depending on the value of Level.

To implement something along the lines of the Avatar of Fire keystone, I might do something like this:


{
	"AvatarOfFire": [{"Type": "Flat", "Value": 0}, {"Type": "Max", "Value": 1.0}],
	"AvatarOfFireConversionScale": [{"Type": "Flat", "Value": 0.5}],

	"ConvertCrushToBurn": [{"Type": "StatFlat", "ModType": "CalcLinear", "Stat": "AvatarOfFire", "Scale": "AvatarOfFireConversionScale"}],
	"CrushDamageFinal": [{"Type": "StatScale", "ModType": "CalcOneMinusStat", "Stat": "AvatarOfFire"}],
}

In this case, Avatar of Fire acts as a simple flag. If 0, no real effect is applied. The CrushToBurn conversion is ignored in damage calculations since it's equal to 0. A damage value of type Crush will be processed, amplified using the CrushDamage stat (not shown here, but all damage types have one which is used to boost damage based on things such as +%increased Crush damage), and then scaled by the value of CrushDamageFinal, which is calculated as (1-AvatarOfFire)). Since AoF is 0, this amounts to a simple multiply by 1 operation. However, simply by adding a Flat mod valued at 1 to the AvatorOfFire stat, the behavior of the other stats changes. Now, the ConvertCrushToBurn stat is non-zero, with a value of 50%, so in the damage calculation after CrushDamage is applied, then 50% of the intermediate value is converted to a damage record of type Burn. Any remaining damage of type Crush, then, is scaled by CrushDamageFinal which is now equal to 0 (ie, 1-AoF), meaning that any Crush damage remaining becomes effectively 0. By supplying a similar set of mods for the other damage types, I have effectively modeled the behavior of PoE's Avatar of Fire passive talent.

StatSets can be merged. For example, I can have the base StatSet for a character, then each piece of equipment can have its own mini StatSet that contains mods for just the stats it's interested in modifying. For example, a sword could have a mod for the JabDamage stat that increases Jab damage by 15%. When an attack is made using that sword, the combat system would copy the character's base stat set, then merge the swords stat set into it to get the final working set. This allows me to have equipment that can modify any of the stats, including flag-type stats such as Avatar of Fire. Thus, I could do things like "Equipping this armor grants Avatar of Fire". Removing the armor removes that particular set of stats from the merge.

The system still has some jankiness. It's pretty clunky writing JSON files (my poor " key), plus the syntax of the various mods leaves quite a bit to be desired. Also, for the moment, the ability to merge StatSets applies only to simple numeric mods (can't use equipment to apply mods that rely on other stats, for example.) I don't see this being a problem at the moment, but it could be in the future. Still, for the relative simplicity of the underlying structure, it's given me quite a lot of flexibility in structuring the stat systems. I've got level-scaling of monsters, monsters that scale differently based on their JSON spec files, monsters with randomized mods, etc... It's pretty cool how it's all coming together.

(Forgive the potato-quality of the stat sheet in the accompanying screen shot. This crap is way too much in flux right now for me to waste time building a custom UI widget yet.)

3 likes 1 comments

Comments

Bobek31235

rly nice game bro
https://www.bananki.pl/event/

April 19, 2021 11:11 PM
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Profile
Author
Advertisement
Advertisement