Skip to content

Instantly share code, notes, and snippets.

@ItsDoot
Last active January 7, 2024 00:17
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ItsDoot/c5e95258ec7b65fb6b2ace32fac79b7e to your computer and use it in GitHub Desktop.
Save ItsDoot/c5e95258ec7b65fb6b2ace32fac79b7e to your computer and use it in GitHub Desktop.
Bevy + egui in a text-based, turn-based adventure game.

Bevy + egui in a text-based, turn-based adventure game.

I won't go too far into the nitty details (lol), but this is generally how I've structured my WIP mostly-text-based, turn-based adventure game. I emphasize that because I suspect this approach doesn't work all that well for other styles of games. Specifically, because practically everything in my game happens on-click; there's very little running in the background.

TL;DR?

Nah, I strained my eyes for this.

Prior art

Modifications to bevyengine/bevy#5522

See the widget_systems.rs file for the complete implementation. Changes I've made from the original post in #5522:

  • Add RootWidgetSystem for root widgets which use egui::Context rather than egui::Ui.
    • Added World::egui_context_scope for easy access to egui::Context stored on the primary window entity.
  • Using World and egui::Ui extension methods to call functions; feels cleaner.
    • Take impl Hash rather than WidgetId directly when calling these functions, makes it easy to pass in whatever identification you want (like Entity!).
  • Don't pass WidgetId to widget systems, they don't need it.
  • Relax the bound on StateInstances to allow both RootWidgetSystem and WidgetSystem to use it.
    • You could add your own supertrait if you wanted, I didn't feel it to be necessary.
  • Add (Root)WidgetSystem::Args and (Root)WidgetSystem::Output to allow passing input data and receive output data.
  • Use AHasher instead of FxHasher32 because the former is readily provided through Bevy.

Big thanks to the guys at Foresight for this proof of concept!

Concepts from bevy_eventlistener

I don't actually have a need for normal events, because every action in my turn-based game is kind of an "event" itself. BUT, this crate was very helpful for understanding how to implement system callbacks as entity components. In particular, having to pull out the BoxedSystem, call it, and put it back in.

Big thanks to aevyrie for this crate!

Using those to implement entity-based story nodes (think actions or atomic quest sections)

Each story node is an "action", like fill up gas, or drive to the mall.

We have 4 needs, which results in 4 different Components, each holding a system callback. All system callbacks take a StoryCtx which holds a car entity so they know who to act on (see story_nodes.rs and example_story_node.rs).

Feel free to peek at story_nodes.rs and example_story_node.rs while you go through this.

  • NodeShow: We need to display the actual story text, which can also include interactive buttons, dropdowns, etc.
    • It takes an (StoryCtx, egui::Ui) and returns the same egui::Ui back.
    • This is a workaround for not being able to pass non-'static references as system Input.
  • NodeCanEnter: Can a passenger currently access this story node, due to whatever factors?
    • For example, make sure you can only fill up gas while at a gas station.
    • It takes a StoryCtx and returns an enum Available { Yes, No(String), Hidden }.
      • Similar to a standard run condition, but lets us hide choices, or show a reason you can't enter it right now.
  • NodeOnEnter: The meat of the game logic.
    • We do everything we need for a particular story node when we enter it, so that it's UI can be displayed instantly.
    • Takes a StoryCtx and returns nothing.
  • NodeOnExit: Finalizes the interactive elements in NodeShow
    • Same as NodeOnEnter

We combine those into a StoryNodeBundle and attach a bevy::Name to it as well. Each story section / "action" is stored as its own entity. X-As-Entity advocates rejoice!

P.S. I use an aery Relation to track which story node a car is currently on.

Putting it all together

My gameloop revolves around 1 primary entity and multiple secondary entities, like a car and its passengers. I'll use the car idea for this explanation for clarity.

Feel free to peek at the attached .rs files while you go through this.

/// Only a few functions get added directly to the `Update` schedule during play state.
/// The other ones handle on-demand asset loading, this one handles everything else.
///
/// Therefore, all UI functions stem from this one.
pub fn show_play(world: &mut World) {
    world.resource_scope(|world: &mut World, car: Mut<PrimaryCar>| {
        // Pass in the primary car's `Entity` to the root widgets.
        // I prefer this over using `query.single()` within,
        // in case I want to allow multiple cars later on.
        
        // `egui::SidePanel::left`
        world.root_widget_with::<InfoPanel>("info_panel", car.0);
        // `egui::CentralPanel` (call order is important)
        world.root_widget_with::<StoryPanel>("story_panel", car.0);
    });
}

///////////////////////////////////////////////////////////
/// This just a simple info panel for current game state
/// But it demonstrates a simple usecase from the Prior Art
///////////////////////////////////////////////////////////

#[derive(SystemParam)]
pub struct InfoPanel {
    // details not important for this post
}

impl RootWidgetSystem for InfoPanel {
    type Args = Entity;
    type Output = ();

    fn system(
        world: &mut World,
        state: &mut SystemState<Self>,
        ctx: &mut egui::Context,
        car: Self::Args
    ) {
        egui::SidePanel::left("info_panel").show(ctx, |ui| {
            // get our SystemParam state
            let state = state.get(world);
            
            let mut passengers: Vec<Entity> = use_aery_to_fetch_those_attached_to_the(car);
            let player: Option<Entity> = passengers.extract_if(|passenger| is_player(passenger)).next();
           
            // pass the car as the `WidgetId` for easy IDing, and also as input
            // if you want an example of what this looks like, just look at
            // https://github.com/bevyengine/bevy/discussions/5522
            ui.add_system_with::<CarInfo>(world, /* widget id */ car, /* system input */ car);

            if let Some(player) = player {
                // display the player above other passengers
                ui.add_system_with::<PassengerInfo>(world, player, player);
            }

            // display all other passengers
            for passenger in passengers {
                ui.add_system_with::<PassengerInfo>(world, passenger, passenger);
            }
        });
    }
}

//////////////////////////////////////////////////////
/// Now the actual meat of the game management logic
//////////////////////////////////////////////////////

#[derive(SystemParam)]
pub struct StoryPanel<'w, 's> {
    pub cars: Query<'w, 's, EdgeWQ, With<Car>>, // EdgeWQ is how to query for aery relation stuff
    pub player: Query<'w, 's, (), (With<Player>, With<Character>)>,
    // These queries contain all of our system callbacks
    pub shows: Query<'w, 's, &'static mut NodeShow>,
    pub can_enters: Query<'w, 's, (Entity, &'static Name, &'static mut NodeCanEnter)>,
    pub on_enters: Query<'w, 's, &'static mut NodeOnEnter>,
    pub on_exits: Query<'w, 's, &'static mut NodeOnExit>,
}

impl RootWidgetSystem for StoryPanel<'w, 's> {
    type Args = Entity;
    type Output = ();
    
    fn system(
        world: &mut World,
        state: &mut SystemState<Self>,
        ctx: &mut egui::Context,
        car: Self::Args,
    ) {
        egui::CentralPanel::default().show(ctx, |ui| {
            let state_mut = state.get_mut(world);

            let player_is_driver = state_mut
                .player
                .contains(state_mut.cars.get(car).unwrap().host::<Driver>());
            
            // get the car's current active story node
            let current_node = state_mut.cars.get(car).unwrap().host::<CurrentStory>();

            // we need to display the current story node's interactive UI
            // so, pull out the node UI system callback (NodeShow) to be able to run it
            if let Some(mut show) = show.get_mut(world).shows.get_mut(current_node).unwrap().take() {
                // create an owned child `egui::Ui` to pass to the function
                let child_ui = ui.child_ui(ui.available_rect_before_wrap(), *ui.layout());
                // then run it and grab the rendered child Ui back
                let child_ui = show.run(world, (StoryCtx { car }, child_ui));
                // allocate space in our parent Ui based on this
                // wouldn't need all of this if we could pass non-static references to systems
                ui.allocate_space(child_ui.min_size());
                
                // put it back in
                state.get_mut(world).shows.get_mut(current_node).unwrap().insert(show);
            }

            // below that, we can display the choices available to the player for next story nodes
            ui.separator();
            ui.horizontal(|ui| {
                // pull out all can_enter systems (NodeCanEnter) to be able to run them
                // this Vec contains tuples of:
                // - entity pointing to the story node choice
                // - the Name of the story node, to display as a button
                // - Option<NodeCanEnter's inner system callback type>
                //     - Can be None, in case the story node is always allowed
                //     - Which you might not need/want
                let can_enters: Vec<_> = state
                    .get_mut(world)
                    .can_enters
                    .iter_mut()
                    .filter(|(next_node, _, _)| *next_node != current_node)
                    .map(|(next_node, name, mut can_enter)| {
                        (next_node, name.as_str().to_owned(), can_enter.take())
                    })
                    .collect();
                
                for (next_node, name, mut can_enter) in can_enters {
                    let result = can_enter
                        .as_mut()
                        .map(|can_enter| can_enter.run(world, StoryCtx { car }))
                        .unwrap_or(Available::Yes);

                    if let Available::No(reason) = result {
                        // If the player can't enter that story node, tell them why
                        // by showing a disabled button with a hover text
                        ui.add_enabled(false, egui::Button::new(name))
                            .on_disabled_hover_text(
                                RichText::new(reason).color(Color32::LIGHT_RED),
                            );
                    } else if result == Available::Yes && ui.button(name).clicked() {
                        // The button was clicked, so lets
                        // - call NodeOnExit for the current node
                        // - change the story node that the car is pointing to
                        // - call NodeOnEnter for the clicked node

                        // call current_node's on_exit
                        if let Some(mut on_exit) = state.get_mut(world).on_exits
                            .get_mut(current_node)
                            .unwrap()
                            .take()
                        {
                            on_exit.run(world, StoryCtx { car });

                            state
                                .get_mut(world)
                                .on_exits
                                .get_mut(current_node)
                                .unwrap()
                                .insert(on_exit);
                        }

                        // change the car's current story node
                        world.entity_mut(current_node).unset::<CurrentNode>(car);
                        world.entity_mut(next_node).set::<CurrentNode>(car);

                        // I also track story node history for gameplay reasons
                        world
                            .entity_mut(car)
                            .get_mut::<StoryHistory>()
                            .expect("no story history on car")
                            .push(current_node);

                        // call next_node's on_enter
                        if let Some(mut on_enter) = state
                            .get_mut(world)
                            .on_enters
                            .get_mut(next_node)
                            .unwrap()
                            .take()
                        {
                            on_enter.run(world, StoryCtx { car });

                            state
                                .get_mut(world)
                                .on_enters
                                .get_mut(next_node)
                                .unwrap()
                                .insert(on_enter);
                        }
                    }
                    
                    // place the can_enter callback back into its entity
                    if let Some(can_enter) = can_enter {
                        let mut state_mut = state.get_mut(world);
                        let (_, _, mut node_can_enter) =
                            state_mut.can_enters.get_mut(next_node).unwrap();
                        node_can_enter.insert(can_enter);
                    }
                }
            });
        });
    }
}

Any questions or comments?

Feel free to ask them below, on the Bevy Discord (username doot), or here on the Bevy UI discussion board.

/// we spawn this right before play state
pub fn bundle() -> StoryNodeBundle {
// I don't show the builder method, but it basically just makes type conversion/inference easier
StoryNodeBundle::builder("Fill Gas")
.show(show)
.can_enter(can_enter)
.on_enter(on_enter)
.on_exit(on_exit)
}
/// The following are all full-featured systems, so you can use whatever normal bevy systems support!
fn show(
In((ctx, mut ui)): In<(StoryCtx, Ui)>,
// ...
) -> Ui {
ui.label("The car stops by the gas pump.");
// We could display a slider here for how much fuel the player wants to fill up to
// based on how much money they currently have / want to spend.
// we need to pass the Ui back afterwards so that the `StoryPanel`
// can calculate how much space it needs to give to the story node
ui
}
fn can_enter(
In(ctx): In<StoryCtx>,
// ...
) -> Available {
if location != GAS_STATION {
// They shouldn't see this story node as an option if they're not at a gas station
Available::Hidden
} else if money == 0 {
Available::No("You don't any money to spend on fuel.")
} else {
Available::Yes
}
}
fn on_enter(
In(ctx): In<StoryCtx>,
// ...
) {
// we could set the location to GAS_STATION(PUMP) here, for example
}
fn on_exit(
In(ctx): In<StoryCtx>,
// ...
) {
// we need to take the final value from the fuel slider from the `show` function,
// and apply it and deduct the money from the player
}
/// We pass this into system callbacks so they know who to act on.
/// My previous iteration of this game (bevy 0.8) did not have this kind of "context",
/// which made the game logic related to story very brittle.
pub struct StoryCtx {
// The target car
pub car: Entity
}
#[derive(Bundle)]
pub struct StoryNodeBundle {
pub name: Name,
pub show: NodeShow,
pub can_enter: NodeCanEnter,
pub on_enter: NodeOnEnter,
pub on_exit: NodeOnExit,
}
/// [`Component`] for story node UI callbacks.
#[derive(Component, Default)]
pub struct NodeShow(Option<Callback<(StoryCtx, Ui), Ui>>);
impl NodeShow {
pub fn take(&mut self) -> Option<Callback<(StoryCtx, Ui), Ui>> {
self.0.take()
}
pub fn insert(&mut self, callback: Callback<(StoryCtx, Ui), Ui>) {
if let Some(_) = self.0 {
panic!("{} was already filled", std::any::type_name::<Self>())
}
self.0 = Some(callback);
}
}
/// [`Component`] for story node run conditions.
#[derive(Component, Default)]
pub struct NodeCanEnter(Option<Callback<StoryCtx, Available>>);
impl NodeCanEnter {
pub fn take(&mut self) -> Option<Callback<StoryCtx, Available>> {
self.0.take()
}
pub fn insert(&mut self, callback: Callback<StoryCtx, Available>) {
if let Some(_) = self.0 {
panic!("{} was already filled", std::any::type_name::<Self>())
}
self.0 = Some(callback);
}
}
pub enum Available {
Yes,
No(String),
Hidden,
}
/// [`Component`] for story node on-enter callbacks.
#[derive(Component, Default)]
pub struct NodeOnEnter(Option<Callback<StoryCtx>>);
impl NodeOnEnter {
pub fn take(&mut self) -> Option<Callback<StoryCtx>> {
self.0.take()
}
pub fn insert(&mut self, callback: Callback<StoryCtx>) {
if let Some(_) = self.0 {
panic!("{} was already filled", std::any::type_name::<Self>())
}
self.0 = Some(callback);
}
}
/// [`Component`] for story node on-exit callbacks.
#[derive(Component, Default)]
pub struct NodeOnExit(Option<Callback<StoryCtx>>);
impl NodeOnExit {
pub fn take(&mut self) -> Option<Callback<StoryCtx>> {
self.0.take()
}
pub fn insert(&mut self, callback: Callback<StoryCtx>) {
if let Some(_) = self.0 {
panic!("{} was already filled", std::any::type_name::<Self>())
}
self.0 = Some(callback);
}
}
/// A lazily initialized one-shot system callback,
/// with support for system `In`put and `Out`put.
///
/// Stored in `Component`s.
pub struct Callback<In = (), Out = ()> {
initialized: bool,
system: BoxedSystem<In, Out>,
}
impl<In: 'static, Out: 'static> Callback<In, Out> {
pub fn new<M>(system: impl IntoSystem<In, Out, M>) -> Self {
Self {
initialized: false,
system: Box::new(IntoSystem::into_system(system)),
}
}
pub fn run(&mut self, world: &mut World, input: In) -> Out {
if !self.initialized {
self.system.initialize(world);
self.initialized = true;
}
let output = self.system.run(input, world);
self.system.apply_deferred(world);
output
}
}
///////////////////////////////////
/// HOW TO CALL ROOT WIDGETS
///////////////////////////////////
// impl'd on `World`
pub trait WorldWidgetSystemExt {
fn root_widget<S: RootWidgetSystem<Args = ()> + 'static>(
&mut self,
id: impl Hash,
) -> S::Output {
self.root_widget_with::<S>(id, ())
}
fn root_widget_with<S: RootWidgetSystem + 'static>(
&mut self,
id: impl Hash,
args: S::Args,
) -> S::Output;
fn egui_context_scope<R>(&mut self, f: impl FnOnce(&mut Self, Context) -> R) -> R;
}
impl WorldWidgetSystemExt for World {
fn root_widget_with<S: RootWidgetSystem + 'static>(
&mut self,
id: impl Hash,
args: S::Args,
) -> S::Output {
self.egui_context_scope(|world, mut ctx| {
let id = WidgetId::new(id);
if !world.contains_resource::<StateInstances<S>>() {
debug!("Init system state {}", std::any::type_name::<S>());
world.insert_resource(StateInstances::<S> {
instances: HashMap::new(),
});
}
world.resource_scope(|world, mut states: Mut<StateInstances<S>>| {
let cached_state = states.instances.entry(id).or_insert_with(|| {
debug!(
"Registering system state for root widget {id:?} of type {}",
std::any::type_name::<S>()
);
SystemState::new(world)
});
let output = S::system(world, cached_state, &mut ctx, args);
cached_state.apply(world);
output
})
})
}
fn egui_context_scope<R>(&mut self, f: impl FnOnce(&mut Self, Context) -> R) -> R {
let mut state =
self.query_filtered::<&mut EguiContext, (With<EguiContext>, With<PrimaryWindow>)>();
let mut egui_ctx = state.single_mut(self);
let ctx = egui_ctx.get_mut().clone();
f(self, ctx)
}
}
///////////////////////////////////
/// HOW TO CALL NON-ROOT WIDGETS
///////////////////////////////////
// impl'd on `egui::Ui`
pub trait UiWidgetSystemExt {
fn add_system<S: WidgetSystem<Args = ()> + 'static>(
&mut self,
world: &mut World,
id: impl Hash,
) -> S::Output {
self.add_system_with::<S>(world, id, ())
}
fn add_system_with<S: WidgetSystem + 'static>(
&mut self,
world: &mut World,
id: impl Hash,
args: S::Args,
) -> S::Output;
}
impl UiWidgetSystemExt for egui::Ui {
fn add_system_with<S: WidgetSystem + 'static>(
&mut self,
world: &mut World,
id: impl Hash,
args: S::Args,
) -> S::Output {
let id = WidgetId::new(id);
if !world.contains_resource::<StateInstances<S>>() {
debug!("Init system state {}", std::any::type_name::<S>());
world.insert_resource(StateInstances::<S> {
instances: HashMap::new(),
});
}
world.resource_scope(|world, mut states: Mut<StateInstances<S>>| {
let cached_state = states.instances.entry(id).or_insert_with(|| {
debug!(
"Registering system state for widget {id:?} of type {}",
std::any::type_name::<S>()
);
SystemState::new(world)
});
let output = S::system(world, cached_state, self, args);
cached_state.apply(world);
output
})
}
}
///////////////////////////////////
/// WIDGETS IMPLEMENT THESE
///////////////////////////////////
pub trait RootWidgetSystem: SystemParam {
type Args;
type Output;
fn system(
world: &mut World,
state: &mut SystemState<Self>,
ctx: &mut egui::Context,
args: Self::Args,
) -> Self::Output;
}
pub trait WidgetSystem: SystemParam {
type Args;
type Output;
fn system(
world: &mut World,
state: &mut SystemState<Self>,
ui: &mut egui::Ui,
args: Self::Args,
) -> Self::Output;
}
///////////////////////////////////
/// RUN-TIME MACHINERY
///////////////////////////////////
#[derive(Resource, Default)]
struct StateInstances<T: SystemParam + 'static> {
instances: HashMap<WidgetId, SystemState<T>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WidgetId(pub u64);
impl WidgetId {
pub fn new(id: impl Hash) -> Self {
let mut hasher = AHasher::default();
id.hash(&mut hasher);
WidgetId(hasher.finish())
}
}
@UkoeHB
Copy link

UkoeHB commented Aug 30, 2023

Your idea to pass the egui stuff by value is a breakthrough. Here is a solution that deprecates the SystemParam structs:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SysId(pub u64);

impl SysId
{
    pub fn new(id: impl Hash) -> Self
    {
        let mut hasher = AHasher::default();
        id.hash(&mut hasher);
        SysId(hasher.finish())
    }
}

#[derive(Resource)]
struct IdMappedSystems<I, O, S>
where
    I: Send + 'static,
    O: Send + 'static,
    S: Send + 'static + Sync
{
    systems: HashMap<SysId, Option<BoxedSystem<I, O>>>,
    _phantom: PhantomData<S>
}

impl<I, O, S> Default for IdMappedSystems<I, O, S>
where
    I: Send + 'static,
    O: Send + 'static,
    S: Send + 'static + Sync
{
    fn default() -> Self { Self{ systems: HashMap::default(), _phantom: PhantomData::default() } }
}

pub fn named_syscall<H, I, O, S, Marker>(
    world  : &mut World,
    id     : H,
    input  : I,
    system : S
) -> O
where
    H: Hash,
    I: Send + 'static,
    O: Send + 'static,
    S: IntoSystem<I, O, Marker> + Send + 'static + Sync,
{
    // the system id
    let sys_id = SysId::new(id);

    // get resource storing the id-mapped systems
    let mut id_mapped_systems = world.get_resource_or_insert_with::<IdMappedSystems<I, O, S>>(
            || IdMappedSystems::default()
        );

    // take the initialized system
    let mut system =
        match id_mapped_systems.systems.get_mut(&sys_id).map_or(None, |node| node.take())
        {
            Some(system) => system,
            None =>
            {
                let mut sys = IntoSystem::into_system(system);
                sys.initialize(world);
                Box::new(sys)
            }
        };

    // run the system
    let result = system.run(input, world);

    // apply any pending changes
    system.apply_buffers(world);

    // re-acquire mutable access to id-mapped systems
    let mut id_mapped_systems = world.get_resource_or_insert_with::<IdMappedSystems<I, O, S>>(
            || IdMappedSystems::default()
        );

    // put the system back
    // - we ignore overwrites
    match id_mapped_systems.systems.get_mut(&sys_id)
    {
        Some(node) => { let _ = node.replace(system); },
        None       => { let _ = id_mapped_systems.systems.insert(sys_id, Some(system)); },
    }

    result
}

Then you call with:

fn display_number(In((egui_ctx, num)): In<(egui::Context, u32), ... your system parameters ...) -> egui::Context
{
    ...
    egui_ctxt
}

let egui_ctx = named_syscall(&mut world, "fps", (egui_ctx, fps), display_number);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment