GRIM

Editors (3/3): Selection dialog & new entries (With<Bevy>)

For the final part of this series, we will talk about this, selection dialog, which we will here forth call the Cross-Selector:

Cross-Selector

And this:

Spell Creation

Cross-Selector? You called it the Selector at the end of the last post.

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.

Name, description, and internal name weren’t in the last post. Will you elaborate?

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).

Internal name

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.

Creating

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)>,

Saving

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.

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
};

Cross-Selector

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.

Select Aura button & flow

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.

Cross Selection Flowchart

1. Starting selecting.

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:

  1. Where the selection is coming from (selector). In this case Spell
  2. What we are selecting (selecting). In this case Aura
  3. A value representing the index of the effect we are selecting for (value).
  4. Whether we are selecting a spell we are in the process of creating (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.
});

Start Selecting

2. Display Selection Window

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))

Display Window

3. Select & Send Cross-Select Event

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.

Send Event

4. Handling the CrossSelectEvent

The final piece of the puzzle is reacting to the CrossSelectEvents we send. For us this means we write the selected AuraId into the effect.

Handle Events

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;

Wrapping Up.

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. Complete Editor Set

You never covered deleting entries.

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.

What’s next?

Probably creatures & AI? A bit of nav-mesh, maybe. Creatures are interesting.

Written 21 Mar 2023 by Grim