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.
Nah, I strained my eyes for this.
- bevyengine/bevy#5522 - For root widgets and read-only leaf widgets.
- bevy_eventlistener - For how it stores callbacks in components.
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 useegui::Context
rather thanegui::Ui
.- Added
World::egui_context_scope
for easy access toegui::Context
stored on the primary window entity.
- Added
- Using
World
andegui::Ui
extension methods to call functions; feels cleaner.- Take
impl Hash
rather thanWidgetId
directly when calling these functions, makes it easy to pass in whatever identification you want (likeEntity
!).
- Take
- Don't pass
WidgetId
to widget systems, they don't need it. - Relax the bound on
StateInstances
to allow bothRootWidgetSystem
andWidgetSystem
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 ofFxHasher32
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!
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 sameegui::Ui
back. - This is a workaround for not being able to pass non-
'static
references as systemIn
put.
- It takes an
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 anenum 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 inNodeShow
- Same as
NodeOnEnter
- Same as
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.
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);
}
}
});
});
}
}
Feel free to ask them below, on the Bevy Discord (username doot
), or here on the Bevy UI discussion board.
Your idea to pass the egui stuff by value is a breakthrough. Here is a solution that deprecates the
SystemParam
structs:Then you call with: