Loading data
Tags, curves, effects, abilities and grants are data. Reading files isn’t
mettle’s business. Its data types are serde types, so your game loads them
however it likes: from any serde format, as Bevy assets, from a mod folder or
a server’s download, or built in code. Then it hands them over as one
resource, MettleContent.
Mettle has no file format of its own yet. A designed one is issue #9.
The content
MettleContent has a list for each kind of thing: tags, curves,
effects, abilities, and grants (see Grants and
triggers). Here is the mage’s, built in code with the
data builders:
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
fn content() -> MettleContent {
MettleContent {
tags: ["cooldown", "cooldown.fireball", "state", "state.stunned"]
.map(TagKey::named)
.to_vec(),
effects: vec![
EffectData::instant("fireball.cost")
.modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
],
abilities: vec![
AbilityData::new("fireball", "cast_fireball")
.cost("fireball.cost")
.cooldown("fireball.cooldown")
.require(!Condition::owner_has("state.stunned")),
],
..Default::default()
}
}
assert_eq!(content().effects.len(), 2);
Code with a type for something can name it by type instead,
AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>()), and a
typo is a compile error (see Data and code).
Its serde shape
MettleContent is Serialize and Deserialize, and so is everything in
it. Whatever format your game reads, the shape is the same:
- The content is a struct of those five lists. Each can be left out, and is empty if it is. Unknown fields are errors, so a typo in a field’s name doesn’t pass quietly.
- Each definition is a struct with the fields its chapter lists:
CurveData,EffectData,AbilityDataandGrant. Fields that have a default can be left out. Unknown fields are errors here too. - Names are strings. A tag is written as its name,
"cooldown.fireball", and a set of tags as a list of names. So is what a definition names: an effect’sname, the attributes, curves, effects, executions, calculations and routes it names, an ability’s behaviour. Each of these is a key, aKeyof its kind, such asKey<AnyEffect>for an effect. Attributes are named as their types are, insnake_caseunless they say otherwise, soManais"mana". - Choices are enums, written as the format writes an enum’s variant:
an effect’s
durationisInstant,InfiniteorForwith aMagnitude, and a modifier’sopis one of theOperations.
Numbers never appear in data. Content names things; mettle numbers them as it’s checked (see Names and numbers).
Handing it over
Insert it as a resource. Mettle checks all of it at once, against the
attributes, behaviours and effect code the app added and the tags, effects
and abilities code names, before any part runs. If it all holds, the
TagRegistry, CurveRegistry, EffectRegistry and
AbilityRegistry are replaced, and ContentLoaded is triggered.
use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
#[derive(Component, Default, Behaviour)] struct CastFireball;
fn content() -> MettleContent {
MettleContent {
tags: ["cooldown", "cooldown.fireball", "state", "state.stunned"].map(TagKey::named).to_vec(),
effects: vec![
EffectData::instant("fireball.cost").modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
],
abilities: vec![AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>()).cost("fireball.cost").cooldown("fireball.cooldown").require(!Condition::owner_has("state.stunned"))],
..Default::default()
}
}
#[derive(Ability)]
struct Fireball;
/// The mage, granted the fireball: its content is already loaded.
fn spawn_mage(mut commands: Commands) {
commands.spawn(Base::<Mana>::default()).grant_ability(Fireball);
}
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins));
app.add_attribute::<Mana>()
.add_behaviour::<CastFireball>()
.add_ability::<Fireball>()
.insert_resource(content())
.add_systems(Startup, spawn_mage);
app.update();
assert_eq!(app.world_mut().query::<&Abilities>().iter(app.world()).count(), 1);
Inserted as the app is built, content is loaded in PreStartup, so
Startup systems can grant and apply what it defines. Inserted later, by a
system once your files have loaded, it’s loaded in the next PreUpdate.
Content split over many files, one per item or one per mod, is merged by your game: extend each list, then hand over the whole.
use bevy_mettle::prelude::*;
/// Every file's content, as one.
fn merged(files: impl IntoIterator<Item = MettleContent>) -> MettleContent {
let mut all = MettleContent::default();
for file in files {
all.tags.extend(file.tags);
all.curves.extend(file.curves);
all.effects.extend(file.effects);
all.abilities.extend(file.abilities);
all.grants.extend(file.grants);
}
all
}
let all = merged([
MettleContent { effects: vec![EffectData::infinite("staff")], ..Default::default() },
MettleContent { effects: vec![EffectData::infinite("ring")], ..Default::default() },
]);
assert_eq!(all.effects.len(), 2);
As a Bevy asset, the same goes: a loader of yours deserializes each file, and a system inserts the merged content as the assets load or change.
What code names stays
An effect or ability code names (add_effect, add_ability) is one code
counts on: something applies or grants it. Content has to define it, or it’s
rejected: a mod can’t take it away. A mod that wants it gone redefines it
doing nothing instead, and code goes on applying it, to no effect:
use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)]
struct Haste;
let mut app = App::new();
app.add_plugins(MettlePlugins).add_effect::<Haste>();
// The game's haste, and a mod's take on it: haste, doing nothing.
let game = MettleContent {
effects: vec![EffectData::lasting("haste", 5.0)],
..Default::default()
};
let modded = MettleContent {
effects: vec![EffectData::instant("haste")],
..Default::default()
};
assert!(game.check(app.world()).is_ok());
assert!(modded.check(app.world()).is_ok());
// Without haste at all, it's rejected.
let errors = MettleContent::default().check(app.world()).unwrap_err();
assert_eq!(
errors.to_string(),
"code names effect `haste`, which content doesn't define"
);
Merging files, let a later file’s definition take an earlier one’s place by
name, so a mod’s haste replaces the game’s rather than clashing with it.
Everything wrong, at once
Content that doesn’t hold is rejected as a whole: each error is logged,
ContentRejected is triggered with all of them, and the content before
stays. Observe it to show whoever wrote the data:
use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn report(rejected: On<ContentRejected>) {
for error in &rejected.errors.0 {
eprintln!("{error}");
}
}
App::new().add_observer(report);
Tags and curves are checked first, then effects against them, then abilities against the effects. An effect’s errors show once the tags it names hold, and an ability’s once its effects do.
To ask without loading anything, such as a mod before it’s enabled, call
MettleContent::check with the app’s world:
use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
let mut app = App::new();
app.add_plugins(MettlePlugins).add_attribute::<Mana>();
let content = MettleContent {
tags: vec![TagKey::named("cooldown")],
effects: vec![
// A tag that isn't defined.
EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
// A curve that isn't there.
EffectData::instant("fireball.cost").modifier(EffectModifier::new::<Mana>(
Operation::Flat,
Magnitude::scaled(-20.0, "fireball.cost"),
)),
],
..default()
};
let errors = content.check(app.world()).unwrap_err();
assert_eq!(errors.0.len(), 2);
Reloading
Insert a changed MettleContent, or change the one there, and mettle loads
it again, checked as a whole. What was there keeps its number: every tag,
curve, effect and ability already numbered keeps it, and new ones are
numbered after them, so whatever holds a number still names the same thing.
Active effects take their new definitions, and granted abilities theirs.
use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)]
struct Staff;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
.add_effect::<Staff>()
.insert_resource(MettleContent {
effects: vec![EffectData::infinite("staff")],
..default()
});
app.update();
let staff = app.world().resource::<EffectRegistry>().id_of::<Staff>();
// A mod adds an amulet, whose name comes first.
app.insert_resource(MettleContent {
effects: vec![EffectData::infinite("amulet"), EffectData::infinite("staff")],
..default()
});
app.update();
let effects = app.world().resource::<EffectRegistry>();
assert_eq!(effects.id_of::<Staff>(), staff, "the staff keeps its number");
assert!(effects.id("amulet").unwrap() > staff.unwrap(), "the amulet's is new");
A fresh app numbers the same content in order of name. So a server and its clients agree as long as each loads the same content the same way: start from the same content, and reload the same changes.
Replace it as your files change:
use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn reread() -> Option<MettleContent> { None }
fn reload(mut commands: Commands) {
if let Some(content) = reread() {
commands.insert_resource(content);
}
}
A reload with a mistake in it is rejected, and the game keeps running on the content before.
Pitfalls
- Code before content. Content naming an attribute, a behaviour or
effect code the app hasn’t added yet is rejected. Add them all as the app
is built, before content is loaded. The same goes for the effects and
abilities code names with
add_effectandadd_ability: content that doesn’t define one is rejected. - What code names, content keeps. A mod can’t delete an effect or ability code adds; it redefines it doing nothing. See What code names stays.
- Nothing until it’s loaded. Applying an effect or granting an ability before its content is loaded finds nothing, and warns.
- One reload, everywhere. A server that reloads alone numbers new things its clients don’t know. Reload both sides alike. Over lightyear, a client that numbers content otherwise is told: see Checking content.