ZOA Stats
Evaluate base values against ordered stat modifiers.
Stats supplies StatId, StatModifier, and StatBlock for shared calculations such as weapon recoil. A block stores base values and evaluates a stat against the modifier collection supplied by its caller.
Each matching modifier adds its additive value and then applies its multiplier, in order. The producing system owns modifier lifetimes and notifications; this package does not provide a stat-definition editor or a central modifier registry.
Declared ZOA dependencies (0)
Referenced by ZOA manifests (6)
How it works
Concepts
Identity is a trimmed, case-insensitive string
StatId wraps a single string field. The constructor trims surrounding whitespace, and equality and hashing both run through OrdinalIgnoreCase, so "Recoil", "recoil", and " recoil " are the same stat. The tolerance matters because ids cross assembly boundaries as literals, and a stat that fails to match on a capital letter is a bug that surfaces during tuning.
Implicit conversions run in both directions, so a plain string literal can be passed anywhere a StatId is expected and vice versa. In practice you declare your ids as constants somewhere central, the way Weapon Modding declares WeaponStatIds, and let the implicit conversion do the rest.
The struct is marked Serializable and carries a public field rather than a property, so it survives Unity serialisation and shows up in the inspector without a custom drawer.
A modifier is add-then-multiply, applied in order
Each StatModifier carries an additive term, a multiplier, an integer order, and a source label. Evaluation sorts the supplied modifiers by order, skips any whose statId does not match the one being evaluated, and then folds each remaining modifier into a running value: add the additive term, then multiply by the multiplier.
Per-modifier interleaving is the part worth internalising, because it does not sum all additives and then apply all multipliers. A plus-ten at order zero followed by a times-two at order one gives a different number than the reverse. The order field is how you express which one you meant, and it is the only sequencing tool the package offers.
A multiplier of exactly zero is treated as one rather than as annihilation. This makes a default-constructed StatModifier harmless, which matters because Unity serialises new list entries with every numeric field zeroed. A modifier that only wants to add can leave the multiplier alone.
The block owns bases, the caller owns modifiers
StatBlock holds a dictionary of base values and nothing else. Evaluate takes the modifier set as an argument on every call, which means the block never has to be told when a source appears or disappears. You build the modifier list from whatever is currently true, hand it over, and read the answer.
IStatModifierSource is the interface a contributor implements to hand over its current list. Armory's weapon runtime implements it directly, so an equipped weapon can be asked what its attachments are doing without the asker knowing anything about attachments. A source is free to recompute or to hand back a cached list; the contract says nothing about lifetime.
Evaluating a stat with no base value returns zero rather than throwing, so a stat that only exists as a modifier target behaves sensibly.
Setup
Workflow
- 01
Declare your ids in one place
Keep shared stat names in a static class of constants. WeaponStatIds is an example used by Weapon Modding. Reuse those identifiers when setting base values and constructing modifiers.
- 02
Seed a StatBlock with base values
Call SetBase once per stat with the unmodified value, typically read from whatever definition asset owns the number. The block is a plain object with no Unity lifetime, so it can live on a runtime class rather than a component.
- 03
Collect modifiers and evaluate at the point of use
Gather the current modifier list from your sources, then call Evaluate for the stat you need. Evaluate sorts and allocates a working list per call, so on a hot path collect once per frame or per state change rather than once per read.
Reference
Key types
StatId
struct
Serializable, case-insensitive identifier for a single stat. Implicitly converts to and from string so call sites read as plain literals.
- StatId(string value)
- bool IsEmpty
- bool Equals(StatId other)
- static implicit operator StatId(string value)
- static implicit operator string(StatId statId)
StatModifier
struct
One source's effect on one stat: an additive term, a multiplier, a sort order, and a source label for debugging. Serializable, so it authors directly on a ScriptableObject.
- StatId statId
- float additive
- float multiplier
- int order
- string source
- StatModifier(StatId statId, float additive, float multiplier = 1f, int order = 0, string source = null)
StatBlock
class
Holds base values and evaluates one stat at a time against a supplied modifier set. Stateless with respect to the modifiers themselves.
- void SetBase(StatId statId, float value)
- float Evaluate(StatId statId, IEnumerable<StatModifier> modifiers)
IStatModifierSource
interface
Implemented by anything that contributes modifiers. Lets a consumer collect contributions without knowing what kind of thing is contributing.
- IReadOnlyList<StatModifier> GetStatModifiers()
Usage
Examples
using System.Collections.Generic;
using ZOA.Stats;
var block = new StatBlock();
block.SetBase("recoil", 4.0f);
var modifiers = new List<StatModifier>
{
// A compensator: multiply recoil by 0.85.
new StatModifier("recoil", additive: 0f, multiplier: 0.85f, order: 0, source: "attachment:Compensator"),
// A heavy stock: a flat reduction applied after the multiplier.
new StatModifier("recoil", additive: -0.5f, multiplier: 1f, order: 10, source: "attachment:HeavyStock"),
};
// (4.0 * 0.85) - 0.5 = 2.9
float recoil = block.Evaluate("recoil", modifiers);using System.Collections.Generic;
using ZOA.Stats;
public sealed class ArmorPlate : IStatModifierSource
{
private readonly List<StatModifier> _modifiers = new();
public void Recompute(float integrity01)
{
_modifiers.Clear();
// Worn plates slow the wearer less than fresh ones.
_modifiers.Add(new StatModifier(
statId: "handling",
additive: 0f,
multiplier: 0.8f + 0.2f * (1f - integrity01),
order: 0,
source: "armor:ChestPlate"));
}
public IReadOnlyList<StatModifier> GetStatModifiers() => _modifiers;
}using System.Collections.Generic;
using ZOA.Stats;
private readonly List<StatModifier> _scratch = new();
float EvaluateHandling(StatBlock block, IReadOnlyList<IStatModifierSource> sources)
{
_scratch.Clear();
for (int i = 0; i < sources.Count; i++)
{
var contributed = sources[i].GetStatModifiers();
if (contributed == null) continue;
for (int j = 0; j < contributed.Count; j++)
_scratch.Add(contributed[j]);
}
// Modifiers for other stats are ignored by Evaluate, so one
// flat list can serve every stat you go on to read.
return block.Evaluate("handling", _scratch);
}Integration
Behavior to account for
See also