And this:
Yes, then I realized I had already referred to the registry list as selecting entries. Now we are cross-selecting entries. Cross for across registries.
The name & description come from the SpellLangRegistry
whilst the internal name comes from the SpellInternalRegistry
. These are fetched & dirtied like the SpellRegistry
entry. Lang is a client registry, whilst internal is only loaded with the editor. Internal data is for note taking, this draws inspiration from wowedit’s spell editor (full image shown in the last post).
Essentially, our once fine Spell
is now a (Spell, SpellLang, SpellInternal)
. and getting everything from their respective registries is this mess:
let Some(((raw_spell, raw_lang), raw_internal)) = spell_registry.get(&spell_id).zip(lang_registry.get(&spell_id)).zip(internal_registry.get(&spell_id)) else {
return;
};
What about the entries that didn’t have that before? I dropped the contents of the table. Multiple times.
Remember all that egui code from the last post to display the spell editor? We’re moving that into a function. It will take all the dirty entry information and then Option<>
s for the existing/raw values. All checks for if a value is dirty become slightly more complex (for now, pending is_some_and stabilizing).
You can also use matches!(Some(raw_spell) if expression)
but I did not think of that.
For example below is how we’d check if the target is dirty. When creating a spell raw_spell
will be None
and thus it will appear dirty in the UI.
raw_spell.map_or(true, |raw_spell| dirty_spell.target != raw_spell.target)
The spell we are currently creating will be added to an Option<>
in SpellEditor
. The Create Spell window appears when it is_some
.
creating: Option<(Spell, SpellLang, SpellInternal)>,
All registry entries have a unique id. For a spell we have SpellId
from the spell
table, it is referenced by both lang_spell
& internal_spell
tables.
When saving we simply update the respective table entries. All of this is done in a single transaction which in sqlx can be started & committed with Pool::begin
& Transaction::commit
respectively. If a transaction isn’t committed it is rolled back when it goes out of scope.
Here is a cut-down version of spell saving. Calling this function only dirty entries are sent, meaning if nothing changed in lang
we don’t update it, etc. As mentioned previously, we completely ignore the async nature of SQLx currently, though here it would probably be good to as to not freeze the editor.
fn update_spell(
db_pool: &Pool<Any>,
spell_id: &SpellId,
spell: Option<&Spell>,
lang: Option<&SpellLang>,
internal: Option<&SpellInternal>,
) -> bool {
// Start transaction.
let Ok(mut connection) = async_std::task::block_on(db_pool.begin()) else {
error!("Couldn't get connection to DB.");
return false;
};
if let Some(spell) = spell {
// Spell update code...
if let Err(error) =
async_std::task::block_on(sqlx::query(&statement).execute(&mut connection))
{
error!("Failed to insert spell: {:?}", error);
return false; // If we failed to insert return false.
};
}
// Repeated for lang & internal.
// End transaction, returning whether or not it succeeded
async_std::task::block_on(connection.commit()).is_ok()
}
When it comes to creating new entries, the code mutates only slightly. In most flavors of SQL (as far as I can tell) the RETURNING
keyword exists which means when we insert a new row into the spell
table we can have it create and return our new spell id.
let spell_id = {
// Spell update code.
let statement = "{insert long statement here} RETURNING id";
let Ok(res) = async_std::task::block_on(sqlx::query(&statement).fetch_one(&mut connection)) else {
error!("Failed to insert spell");
return None;
};
let id: i64 = res.get(0);
id
};
The Cross-Selector is my solution for selecting across registries in a de-coupled and data-oriented way. It handles the flow from clicking the Select Aura button to the Aura you’ve selected being returned to the right editor.
In an Object-Oriented environment, this is relatively simple to represent. You create a selection window object and it has a reference to where you want to return the data and when you click the “Ok” button it pushes it back there.
But we can’t do it quite that way in Bevy, we need to decouple the dataflow a step further. Below is how the data flow for the selector works. We’ll go step by step explaining it.
When you click the Select Aura button in the Spell Editor, we set an Option<>
in the CrossSelector resource representing our current selection. This tells us:
selector
). In this case Spell
selecting
). In this case Aura
value
).creating
). This is necessary since these are stored in a different places.struct CurrentSelection {
selector: RegistryType,
selecting: RegistryType,
value: usize,
creating: bool,
}
#[derive(Default, Resource)]
struct CrossSelector {
selecting: Option<CurrentSelection>,
}
// In spell editor code.
if ui.button("Select Aura").clicked() {
cross_selector.selecting = Some(CurrentSelection {
selector: RegistryType::Spell,
selecting: RegistryType::Aura,
value: i,
creating: raw_spell.is_none(),
});
}
Whilst we are cross-selecting, we block all interaction with any editor using add_enabled_ui
, this takes a boolean indicating whether the UI is enabled & thus interactable.
let interactable = cross_selector.selecting.is_none();
ui.add_enabled_ui(interactable, |ui| {
// UI Code as usual.
});
For each type we want to select, we need a cross-selector window.
These look identical to the registry windows. See part 1 for that code.
Each of these is only displayed if the CurrentSelection selecting
property matches its registry type and as of Bevy 0.10, we do this via a custom run condition.
// Run condition.
fn is_cross_selecting_from_registry(
registry_type: RegistryType
) -> impl FnMut(Res<CrossSelector>) -> bool {
move |cross_selector: Res<CrossSelector>| {
cross_selector.selecting.as_ref().map_or(false, |current_selection| {
current_selection.selecting == registry_type
})
}
}
// When registering the system.
cross_select_aura.run_if(is_cross_selecting_from_registry(RegistryType::Aura))
Upon selecting an entry in the displayed window, we send a CrossSelectEvent
containing the id of the selected entry.
struct CrossSelectEvent {
selected_value: u32,
}
Here selected_value
is a u32
. This works since all registries have a u32
id but it tells us nothing of what we’ve selected. Whilst we are expecting an AuraId
this might be a SpellId
, we have no guarantee. This would be solved by using an enum as below. I have yet to make that change though.
enum SelectedValue {
Spell(SpellId),
Aura(AuraId),
// etc...
}
Enums with fields are my favorite Rust feature.
The final piece of the puzzle is reacting to the CrossSelectEvent
s we send. For us this means we write the selected AuraId
into the effect.
And this is where we need to handle the creating
property, which boils down to either getting the spell from the DirtySpells
set or the SpellEditor::creating
property.
let Some(spell) = (if current_selection.creating {
// Map creating tuple to &mut Spell.
spell_editor.creating.as_mut().map(|creating| &mut creating.0)
} else {
// Map selected spell first to dirty tuple & then to spell.
spell_editor
.selected
.and_then(|id| dirty_spells.0.get_mut(&id))
.map(|dirty_entry| &mut dirty_entry.0)
}) else { // Note this is wrapping the return value of "if current_selection.creating"
// There is no spell :(
return;
};
With the spell, we can now match what we are selecting and handle the selected id.
match current_selection.selecting {
RegistryType::Aura => {
if let Some(SpellEffect::ApplyAura { aura_id }) =
spell.effects.get_mut(current_selection.value)
{
*aura_id = AuraId(select_event.selected_value);
}
}
// etc...
}
And then, we finally end the selection.
cross_selector.selecting = None;
Even in writing this, I’ve realized multiple changes we could make to improve this. Some like the SelectedValue
I’ve mentioned, whilst others have been rolled into the system and emplaced into the writing. There are likely a hundred things that could’ve been done differently but this is how I’ve solved it. Hope you enjoyed the run-through.
Here’s my complete set of editors so far, building on what I’ve covered in these three posts.
I haven’t added deleting. Deleting entries becomes a bit of a mess when you have connections between tables (and when those connections are expressed in inline json) so let’s not.
There was initially a joke about dropping the table here but it has been omitted by production. Drop responsibly.
Probably creatures & AI? A bit of nav-mesh, maybe. Creatures are interesting.
Written 21 Mar 2023 by Grim