Gameplay tags
A gameplay tag is a label in a tree: damage.elemental.fire sits under
damage.elemental, which sits under damage. Everything in mettle is
labelled with tags. The mage’s fireball is tagged ability.spell.fire, the
burn it leaves is effect.affliction.burning, and while it burns the mage
owns state.burning.
What has a tag has every tag above it. A rule written for damage.elemental
holds for a fire hit, and a cure for effect.affliction takes off burning
and poison alike. This is how Unreal’s gameplay tags work too.
Tags in code
Code names a tag by its type. Derive Tag on a unit struct, under its
parent:
use bevy_mettle::prelude::*;
#[derive(Tag)]
struct State; // "state"
#[derive(Tag)]
#[tag(parent = State)]
struct Burning; // "state.burning"
#[derive(Tag)]
#[tag(parent = State, name = "wet")]
struct Soaked; // "state.wet"
let mut owned = OwnedTags::default();
owned.add(Burning);
assert!(owned.contains(Burning));
assert!(owned.contains(State)); // what's above it too
A tag’s name is its type’s in snake_case under its parent’s, unless
name says otherwise. A tag with no parent is at the top. Its parent is
Root, which every tag hangs from and nothing ever owns.
Naming a tag by type means a misspelled one doesn’t compile:
owned.contains(Burnign) is an error, where "state.burnign" would quietly
never match. The parent is a type too, so the tree in code can’t be
misspelled either.
Tags code names are added to the app, as attributes are:
use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Tag)] struct State;
#[derive(Tag)] #[tag(parent = State)] struct Burning;
App::new().add_tag::<Burning>(); // and `state`, above it
They join the registry content is checked against, whether the content defines them or not, so data naming them is checked against code.
Keys
Wherever mettle takes a tag, it takes anything that becomes a TagKey:
a tag type (Burning), or a name ("state.burning"). A key is the tag
interned: a number, cheap to copy and compare, the same whether code named
it by type or data by name. That number is this process’s alone. What’s
sent over the network is the tag’s number in the registry, a TagId, which
is the same on a server and its clients: see The tag registry.
A key knows where it sits in the tree:
use bevy_mettle::prelude::*;
#[derive(Tag)]
struct Damage;
#[derive(Tag)]
#[tag(parent = Damage)]
struct Elemental;
#[derive(Tag)]
#[tag(parent = Elemental)]
struct Fire;
let fire = TagKey::from(Fire);
assert_eq!(fire, TagKey::named("damage.elemental.fire"));
assert_eq!(fire.parent(), Some(TagKey::from(Elemental)));
assert!(fire.is_within(Damage));
let lineage: Vec<&str> = fire.lineage().map(TagKey::name).collect();
assert_eq!(lineage, ["damage.elemental.fire", "damage.elemental", "damage"]);
The tag registry
Tags that only data names needn’t be types. Content lists them by name,
and a mod can add its own. In data, a tag is written as its name:
"damage.elemental.fire".
In a game, they go in MettleContent’s tags, beside those code adds,
and mettle checks them as the content is loaded, keeping them as a
resource, Res<TagRegistry>. The tags code adds are kept in the registry
too, so a reload doesn’t lose them. TagRegistry::build is that step on
its own: it checks the definitions and gives you the registry everything
else is checked against:
use bevy_mettle::prelude::*;
let definitions = ["damage", "damage.elemental", "damage.elemental.fire"].map(TagKey::named);
let tags = TagRegistry::build(definitions).unwrap();
assert!(tags.contains("damage.elemental.fire"));
assert!(!tags.contains("damage.poison"));
The registry numbers its tags: each gets a TagId, in order of name. The
same tags are numbered the same wherever they’re checked, so a server and
its clients agree on every number. id and key go between the two:
use bevy_mettle::prelude::*;
let definitions = ["damage", "damage.elemental", "damage.elemental.fire"].map(TagKey::named);
let tags = TagRegistry::build(definitions).unwrap();
let fire: TagId = tags.id("damage.elemental.fire").unwrap();
assert_eq!(tags.key(fire), Some(TagKey::named("damage.elemental.fire")));
// A reload keeps the numbers tags had, and numbers new ones after them.
let reloaded = tags
.rebuild(["damage", "damage.elemental", "damage.elemental.fire", "damage.poison"].map(TagKey::named))
.unwrap();
assert_eq!(reloaded.id("damage.elemental.fire"), Some(fire));
Mettle rebuilds the registry this way each time content is loaded again, so what holds a tag’s number still names the same tag.
build takes definitions from however many files at once: chain them, or
collect them into one list. It reports every mistake together, not just the
first:
- A tag defined twice.
- A tag that isn’t a path. A path is words joined by
., each word made ofatoz,0to9and_.Fireanddamage..firearen’t tags. - A gap. Every tag’s parent has to be a tag of its own. Defining
damage.elemental.firewithoutdamage.elementalis an error.
use bevy_mettle::prelude::*;
let definitions = [TagKey::named("damage"), TagKey::named("damage.elemental.fire")];
let errors = TagRegistry::build(definitions).unwrap_err();
assert_eq!(
errors.to_string(),
"there's no tag called `damage.elemental` (in `damage.elemental.fire`)"
);
Mettle doesn’t read files. Your game loads them however it likes, and hands the definitions over: see Loading data.
Sets of tags
Tags is a set: what an effect, an ability or a hit is labelled with. It
holds every tag above the ones you give it, so asking it for effect finds
effect.affliction.burning:
use bevy_mettle::prelude::*;
let burning: Tags = ["effect.affliction.burning"].into_iter().collect();
assert!(burning.contains("effect.affliction"));
assert!(burning.contains("effect"));
// Written back as given, without what's above.
assert_eq!(burning.written().count(), 1);
In data, a set is written as a list of the tags as given, each by its name.
Owned tags
An actor’s states are tags it owns: OwnedTags, a component. The game
marks the mage state.casting while it casts. Effects tag their owner too: while
the mage burns, it owns state.burning. Every actor with an attribute has
OwnedTags.
Owned tags are counted. Each source adds one and later takes its one away.
If two effects both give it immune.fire, the mage stays immune until both
are gone:
use bevy_mettle::prelude::*;
#[derive(Tag)]
struct Immune;
#[derive(Tag)]
#[tag(parent = Immune, name = "fire")]
struct ImmuneToFire;
let mut owned = OwnedTags::default();
owned.add(ImmuneToFire); // A potion.
owned.add(ImmuneToFire); // A ring.
owned.remove(ImmuneToFire); // The potion wears off.
assert!(owned.contains(ImmuneToFire));
assert!(owned.contains(Immune));
owned.remove(ImmuneToFire); // The ring comes off.
assert!(!owned.contains(ImmuneToFire));
Removing a tag only takes away what was added as that tag. Owning
immune.fire means owning immune, but owned.remove(Immune) does
nothing: no source added immune itself. OwnedTags reads as a
TagCounts, which holds the counts: count(tag) says how many sources
hold a tag, and iter() lists them. Counts are kept by key, so asking is an
index, not a comparison of names.
Told as they come and go
When an actor comes to own a tag, TagAdded is triggered on it. When it
stops, TagRemoved is. Each tag above counts too: the first fire
immune state tells both immune.fire and immune. Observe them on the actor,
or globally:
use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Resource, Default)]
struct Told(Vec<String>);
let mut app = App::new();
// Mettle runs in `Update` here, so one update tells them.
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
.init_resource::<Told>()
.add_observer(|added: On<TagAdded>, mut told: ResMut<Told>| {
told.0.push(format!("+{}", added.tag));
})
.add_observer(|removed: On<TagRemoved>, mut told: ResMut<Told>| {
told.0.push(format!("-{}", removed.tag));
});
let mage = app.world_mut().spawn(OwnedTags::default()).id();
app.world_mut()
.get_mut::<OwnedTags>(mage)
.unwrap()
.add("state.burning");
app.update();
assert_eq!(app.world().resource::<Told>().0, ["+state.burning", "+state"]);
They’re told as the publish_tags system runs, as attributes are folded
(AttributeFold), before values are worked out. Changes are settled
first: a tag added and taken away again before then isn’t told at all.
Asking from a behavior tree
owns is a guard: a read-only system that takes an agent and a tag key,
and says whether the agent owns the tag or one under it. It has the shape
bevy_arbor takes for a decorator, so a tree can wait on state.stunned.
Checking a client numbers tags as its server does
A client and its server have to agree on the registry: tags travel as their
numbers, so a tag numbered differently would be read as another.
TagRegistry::fingerprint, as every registry’s, is a hash of the tags with
their numbers. Built from the same tags, in whatever order they were defined,
registries hash the same; a registry rebuilt by a reload keeps old numbers, so
two hash the same only if they were loaded the same way. Over lightyear,
mettle compares them for you as a client connects:
Lightyear.
Pitfalls
- Names aren’t checked where you write them.
"stat.burning"compiles wherever a tag is taken. Name tags code uses by type; a name is caught only when data naming it is checked against the registry. - Facts are tags too. Conditions name the numbers a read
supplies by tags, such as
fact.flown. Define them as tags, or conditions naming them fail their checks. - A tag can’t be under itself.
#[tag(parent = ..)]loops compile, but panic with both names the first time either tag is used. - No events without the attribute plugin.
publish_tagsruns inAttributeFold. WithoutAttributePlugin, owned tags still count, but nothing is told.