-
Notifications
You must be signed in to change notification settings - Fork 83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal for "bundler systems" and macros v2 #177
Comments
I would love to see something like this to reduce the complexity & boilerplate as you build out ldtk maps. A couple of notes: From the examples, and some text, it looks like the I hope this won’t remove the existing For the limitations, yea, the generalization of For the generalization, maybe there’s a path to avoid this. You’re calling these spawning functions “systems”; have you looked at how bevy implements |
Oh, and from the discord, here’s a page that simplifies how systems are defined: https://promethia-27.github.io/dependency_injection_like_bevy_from_scratch/chapter1/system.html |
Thanks for the feedback! I've actually been rethinking this a bit after some discussion on discord. I think I want to try improving the "Blueprint pattern" as much as possible and see if it could be good enough to just replace most of the registration/macros and all the complexity and design headache that comes with them. Plus, it's not like improving that aspect of the plugin will hurt anybody in the mean time. And by "blueprint pattern" I mean fleshing out entities manually via systems that query for |
Hey! Just came across this, I don't have a ton of time right now to give my thoughts but here are my entity and tile blueprint traits that I'm using in my game right now. I'm not sure if there are any fundamental flaws with this but for now it's been working great: use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
/// Represents an entity that can be loaded from an LDtk scene.
pub trait EntityBlueprint
where
Self: Component + Default + Sized,
EntityBundle<Self>: LdtkEntity + Bundle,
{
/// The name of this entity. This MUST match the specified name in your LDtk scene.
const NAME: &'static str;
/// Registers this entity with Bevy.
fn register() -> impl Plugin {
|app: &mut App| {
trace!("Registering entity type: {0}", Self::NAME);
app.register_ldtk_entity::<EntityBundle<Self>>(Self::NAME)
.add_systems(Update, Self::hydrate)
.add_plugins(Self::plugin);
}
}
/// Hydrates newly added LDtk entities with any associated components.
fn hydrate(mut commands: Commands, new_entities: Query<bevy::prelude::Entity, Added<Self>>) {
for entity in new_entities.iter() {
trace!("Hydrating new {0}: Entity {1}", Self::NAME, entity.index());
commands
.entity(entity)
.insert((Name::new(Self::NAME), Self::components()));
}
}
/// Returns the components associated with this entity.
fn components() -> impl Bundle {}
/// Additional configuration for this entity's registration process.
fn plugin(app: &mut App) {
let _ = app;
}
}
#[derive(Bundle, Default, LdtkEntity)]
pub struct EntityBundle<T: Component + Default> {
component: T,
#[sprite_sheet_bundle]
sprite_sheet_bundle: LdtkSpriteSheetBundle,
}
/// Represents a tile that can be loaded from an LDtk scene.
pub trait TileBlueprint
where
Self: Component + Default + Sized,
TileBundle<Self>: LdtkIntCell + Bundle,
{
/// The name of this tile.
const NAME: &'static str;
/// The ID of this tile. This MUST match the specified ID in LDtk.
const ID: i32;
/// Registers this tile with Bevy.
fn register() -> impl Plugin {
|app: &mut App| {
app.register_ldtk_int_cell::<TileBundle<Self>>(Self::ID)
.add_systems(Update, Self::hydrate)
.add_plugins(Self::plugin);
}
}
/// Hydrates newly added LDtk tiles with any associated components.
fn hydrate(mut commands: Commands, new_entities: Query<Entity, Added<Self>>) {
for entity in new_entities.iter() {
trace!("Hydrating tile: {0} (ID: {1})", Self::NAME, Self::ID);
commands
.entity(entity)
.insert((Name::new(Self::NAME), Self::components()));
}
}
/// Additional configuration for this tile's registration process.
fn components() -> impl Bundle {}
/// Returns the components associated with this tile.
fn plugin(app: &mut App) {
let _ = app;
}
}
#[derive(Bundle, Default, LdtkIntCell)]
pub struct TileBundle<T: Component + Default> {
component: T,
} They're pretty ergonomic to use: #[derive(Default)]
pub struct PlayerPlugins;
impl PluginGroup for PlayerPlugins {
fn build(self) -> PluginGroupBuilder {
PluginGroupBuilder::start::<Self>()
.add(Player::register())
.add(PlayerCameraPlugin::default())
.add(PlayerMovementPlugin::default())
}
}
#[derive(Component, Default)]
pub struct Player;
impl EntityBlueprint for Player {
const NAME: &'static str = "Player";
fn components() -> impl Bundle {
(
KinematicCharacterController::default(),
RigidBody::KinematicPositionBased,
Collider::capsule_y(4.0, 4.0),
LockedAxes::ROTATION_LOCKED,
)
}
} |
Motivation
I've been dissatisfied with the
LdtkEntity
andLdtkIntCell
traits/derive macros for a while now. In this issue, I will mostly be focusing onLdtkEntity
since it is the more complex of the two, and pretty much all of these changes should be applied to each. I go into detail about some of my complaints in #47. Basically, my complaints are..LdtkEntity
, most method parameters go unused and add a lot of boilerplateLdtkEntity
implementationLdtkEntity
implementations that manually check the entity'sidentifier
internally to differentiateFrom<&EntityInstance>
insteadI think the final point is partially solved by the relatively-new
#[with]
attribute macro. This allows you to provide a custom constructor to a field in the bundle, without being bound to a trait. This is perfect for situations where you want to construct a component in two different ways for two different registrations. However, it is limited because your constructor can only accept an&EntityInstance
. Even if we gave it more arguments, it would still have a similar issue to theLdtkEntity
trait itself - we're still being opinionated about what information the user may need for construction.The solution to the issue of "opinionated dependencies" is dependency injection. This is what I was trying to express in #47. If we could somehow allow users to provide constructors that are more like bevy systems, where they define in the function arguments what information they need from the world, many of these issues go away. I liked the idea of re-using actual bevy systems, but there were many design questions around this, and I wasn't totally sure that it was possible. For example, I wasn't sure how to best provide metadata pertaining to the entity outside of system parameters (like
&EntityInstance
or&LayerMetadata
).Proposal Summary
Partially inspired by bevy run conditions, and partially inspired by the more functional design of the
#[with]
attribute macro (thanks again @marcoseiza), here is my proposal. We should introduce a new concept, let's call it "bundler systems" for now, which are systems that accept either anIn<LdtkEntityMetadata<'a>>
(a new type), or no input, and return some bundle/component.LdtkEntityMetadata<'a>
can contain references to all metadata relevant to an entity from the asset, like&EntityInstance
,&LayerMetadata
, and even a reserved bevyEntity
. In the future, it could also include new things like&LevelMetadata
, a map from tileset-uids to previously generated texture atlases (discussed in #87), and a map of entity iids to reservedEntity
s (to support #70).As for macros, the
LdtkEntity
derive macro wouldn't actually derive a trait, instead it would generate one of these bundler systems for your bundle. Furthermore, all existing attribute macros can be replaced with one:#[ldtk(my_bundler)]
wheremy_bundler
is also a bundler system, either custom-defined, provided by the library, or generated via macros. Similar to bevy run conditions, we'll have acommon_bundlers
module containing bundler systems likesprite_sheet_from_visual
orworldly
. Of course, defining your own will be as easy as defining a system, and doing so for the outer-most bundle of a registration won't be any different.User-facing API
Basic cases, without any custom bundlers, will look something like this:
Notice that the registrations accept a function instead of just being generic over the bundle. This allows users to define a separate custom system bundler for the same bundle, and submit a different registration for it.
Speaking of defining custom bundlers, let's see what that would look like. Let's say we want to give both the
PlayerBundle
andEnemyBundle
some newHealth
component:Over the course of the game, the player can pick up health upgrades which persist in a resource. When the player spawns, we want their max health to be based on how many upgrades they have. In this case, we could define a bundler for the health component like this:
As for the enemy, we want to set how much health each enemy has in the level design, so we store it in some field instance "Health". We also want to scale up the enemy's health based on some
Difficulty
resource. So, we can design a separate bundler for the same component:Finally, we can add the health component to the bundles using these two different bundlers in the
ldtk
attribute macro:No matching against
entity_instance.identifier
, no singleton bundles, much simpler macros with a more standard design, and we get dependency injection with access to the entire bevy world.Implementation
Much of the bundler system infrastructure will be very similar to bevy's conditions. We'll likely have a trait representing bundler systems like..
There will be something similar for bundler systems that don't accept an
In<LdtkEntityMetadata<'a>>
.We'll also have a trait like
IntoLdtkEntityBundler
that can be implemented on all functions with the appropriate parameters and return type. This will all result in us being able to convert such systems into some storable system type like..Generic output
The main difference between this and conditions is that the output is generic over some bundle
B
. We want to be able to store all of these bundlers in a resource that we can access during the spawn system. However, storingBoxedBundler<B>
s in a collection in some resource will necessarily make the collection/resource itself generic. This means that every bundle would have a different resource storing its registered bundlers. These resources wouldn't be easy to access from the level spawning system. We have to know each bundle type the user is registering at compile time to create such a system.My first reaction to this would be to have an extra layer of abstraction, where we could wrap the bundler system in another system that returns a
Box<dyn Bundle>
instead. However, as mentioned in a comment in an earlier example, bundles are not object safe, meaning we can't use them to create trait objects like this.So, this introduces a controversial implementation detail. The
LdtkPlugin
simply needs to know on construction what bundle types the user plans to create registrations for, so it needs to be generic over those types. So, we need to change the plugin toLdtkPlugin<B: Bundle = ()>
, and use macros to implementPlugin
for it on tuples up to a pre-determined length.However, this isn't all bad. Actually, this has some surprising benefits to it if we implement it correctly. Instead of having the level-spawning system try and use these bundlers, we could have it pipe the necessary metadata into some generic internal system that does..
And then
LdtkPlugin<(PlayerBundle, EnemyBundle)>
would have some internal schedule handling this piping..Note that each of these registration-handling systems now only concerns itself with one bundle type. This makes it easy for us to use batch-insertion to flesh out those entities, which could increase performance considerably. This will likely make it easier to use batch-insertion within the
process_ldtk_levels
system itself, since it wouldn't be responsible for any of the complicated entity customization logic anymore.Also note that
process_ldtk_levels
itself does not have to be exclusive - just the registration-handling systems. So, if users don't want to participate in this feature, they can simply use the defaultLdtkPlugin
(withB: ()
), and theprocess_ldtk_levels
pipe would remain non-exclusive.Lifetimes
I've been denoting the input type for these bundler systems as generic over a lifetime:
LdtkEntityMetadata<'a>
. This is because I want to be able to pass much of the metadata associated with an entity in by reference, to avoid too much data cloning. However, I haven't quite figured out whether or not this is possible with bevy systems. Logically it makes sense that it shouldn't break any borrowing rules, but I haven't been able to convince the compiler of this in my testing yet.At the very least, we'll be able to use smart pointers for this metadata like
Rc
, so that the borrow checker rules apply at runtime instead of compile time.Macros
Macro implementation with this design is actually simplified a lot. As I mentioned earlier, now there will be only 1 attribute macro for these bundles:
#[ldtk(...)]
. We may need a second one for having different input types (In<LdtkEntityMetadata<'a>
vs()
), but I'm not sure yet. All logic for the existing attributes can now be moved to public bundler systems, which will make them easier to write, test and maintain.Drawbacks
Admittedly, some of these aren't really drawbacks in my opinion, and some are just unknowns. The first item is definitely a drawback though.
LdtkPlugin
becomes generic over all bundle types the user plans to register. For bundles the user only plans to create one registration for, they will basically have to type the name of their bundle twice as many times in their app configuration. Worse, this means that theLdtkPlugin
type will have to accept all bundles in the same construction, defying modularity of the user's app. I will keep trying to find a way around this one - it's almost a dealbreaker for me on its own, but not quite.#[sprite_sheet_bundle]
, we'd have#[ldtk(sprite_sheet_from_visual)]
. I don't think verbosity is that big of a problem due to modern code-completion in every editor. However, It's worth mentioning since this plugin is billed as "super-ergonomic".&World
system param. When we design the macros we can ensure that nesting bundler systems isn't done in an unsafe way, but if users want to call one bundler system from another, this will be possible, but unsafe. I don't think this is so bad, doing this much bundle nesting customization is probably an anti-pattern we would like to discourage anyway.Commands
). However - some of our previousLdtkEntity
functionality did mutate the world, like#[sprite_sheet_bundle]
appending theTextureAtlas
asset store. For this particular case - we definitely need to implement Texture Atlas caching for sprite sheet bundles #87, but even after doing that we'd have to come up with some new solution for the#[sprite_sheet_bundle(no_grid)]
functionality, which allows spawning entities whose visual is a rectangle of tiles.I hope to get some feedback from the community on this design before going forward with this and coming up with a more detailed milestone/to-do list. I'm especially interested in hearing from @geieredgar since you implemented an alternative to this in #156.
The text was updated successfully, but these errors were encountered: