Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Curves

A curve gives a value by key. Usually the key is the level: the mage’s fireball hits harder as it levels up, 10 damage at level 1, 85 at level 10, 240 at level 20. Effects scale their numbers by curves, as Unreal’s curve tables do. A magnitude can read a curve at anything else it works out too, an attribute for diminishing returns: see Magnitudes.

Defining a curve

A curve is data, a CurveData: a name, how it goes between its keys, and its keys as (at, value) pairs, rising:

use bevy_mettle::prelude::*;
fn curves() -> Vec<CurveData> {
    vec![
        CurveData {
            name: "fireball.damage".into(),
            interpolation: Interpolation::Constant,
            keys: vec![(1.0, 10.0), (10.0, 85.0), (20.0, 240.0)],
        },
        CurveData {
            name: "burning.damage".into(),
            interpolation: Interpolation::Linear,
            keys: vec![(1.0, 4.0), (20.0, 40.0)],
        },
    ]
}
assert_eq!(curves().len(), 2);

Here the fireball’s damage steps up at levels 10 and 20, and burning’s grows steadily between its keys. CurveData is a serde type: in a file, it’s a struct with a name (a string), an interpolation, and keys as a list of pairs. interpolation can be left out: it’s Linear unless it says.

Between keys, a curve goes one of three ways:

interpolationBetween two keys
LinearA straight line from one to the next. The default.
ConstantEach key’s value, held until the next.
Ease(..)One of Bevy’s easing functions, such as Ease(EaseFunction::SineInOut) or Ease(EaseFunction::ExponentialIn), from one key to the next.

Before the first key and past the last, a curve stays at that key’s value. A curve with one key is that value at every level.

The curve registry

In a game, the curves go in MettleContent’s curves, and mettle builds the registry as the content is loaded, keeping it as a resource, Res<CurveRegistry>. CurveRegistry::build is that step on its own: it checks each curve, builds it as one of Bevy’s curves, and numbers it.

A curve’s number is a CurveId, given in order of name, as Bevy gives a component its ComponentId. Effects scale by the number once content is checked; the name is for content and debug output. CurveRegistry::id finds a curve’s number by its name:

use bevy_mettle::prelude::*;
fn curves() -> Vec<CurveData> {
    vec![
        CurveData {
            name: "fireball.damage".into(),
            interpolation: Interpolation::Constant,
            keys: vec![(1.0, 10.0), (10.0, 85.0), (20.0, 240.0)],
        },
        CurveData {
            name: "burning.damage".into(),
            interpolation: Interpolation::Linear,
            keys: vec![(1.0, 4.0), (20.0, 40.0)],
        },
    ]
}
let curves = CurveRegistry::build(&curves()).unwrap();

let burning = curves.id("burning.damage").unwrap();
assert_eq!(curves.at(burning, 1.0), Some(4.0));
assert_eq!(curves.at(burning, 50.0), Some(40.0)); // Past the last key.
assert_eq!(curves.id("frost.damage"), None);

As content is loaded again, the registry is rebuilt with CurveRegistry::rebuild: curves keep the numbers they had, and new ones are numbered after them.

build reports every mistake together:

  • A curve defined twice.
  • A curve with no keys.
  • A key that isn’t finite, such as NaN.
  • Levels that don’t rise. Each key’s level has to be above the one before it.

Effects name curves by name. As content is loaded, every curve an effect names is checked against the registry, and resolved to its number. Defining effects shows a magnitude scaled by a curve.

As one of Bevy’s curves

CurveRegistry::curve hands back the built curve itself, as Bevy’s Curve<f32>. Use it to draw a graph in your tooling, or with Bevy’s curve adaptors:

use bevy_mettle::prelude::*;
let curves = CurveRegistry::build(&[CurveData {
    name: Key::from_static("burning.damage"),
    interpolation: Interpolation::Linear,
    keys: vec![(1.0, 4.0), (20.0, 40.0)],
}])
.unwrap();
let burning = curves.curve(curves.id("burning.damage").unwrap()).unwrap();
let graph: Vec<f32> = (1..=20).map(|level| burning.sample_clamped(level as f32)).collect();
assert_eq!(graph.len(), 20);

Pitfalls

  • Levels are numbers, not steps. A level can be 2.5. Each curve goes between its keys as it says, so a Constant curve is the way to get steps.
  • Easing goes from one key to the next. Ease(QuadraticIn) starts slow after every key, not once across the whole curve.