Skip to content

Add DespawnAfter mechanism #20244

@tbillington

Description

@tbillington

What problem does this solve or what need does it fill?

Provides a built-in way to despawn entities after a given period.

Currently there is no mechanism for this in Bevy, and while it's easy enough to implement manually it's likely something that many users will reach for in the engine.

What solution would you like?

I've split this up into two sections

  • DespawnAfter, which describes a straight forward component that can be added which will be ticked by a system within Bevy and despawn the entity it's on once the time has elapsed
  • GenericTimer, which is a slightly more general timer that can be observed. The "despawn after" mechanism would build on this

Additional context

Discord context: https://discord.com/channels/691052431525675048/692572690833473578/1397088235427205140

Loosely inspired by Godot's timer.

I'm not strongly attached to names of any of these constructs, I just picked the first thing off the top of my head.

DespawnAfter

A simple struct component that counts down to zero from some initial duration. Upon reaching zero it despawns the entity it's on.

I lean toward just using a f32 for such a simple component, as Timer is a relatively heavy 48 bytes compared to just 4 for the f32, for negligible if any real advantage in this context. If this component had any more functionality I could see it being more useful, but right now it seems like pure overhead.

I haven't included any logic to allow ignoring the timescale, useful if the user is doing some kind of slow-motion effect by modifying Time<Virtual>, or for ignoring Time<Virtual> being paused. Both of these options are on the Godot timer and I think they are there for good reason, and the actual implementation should include them. The functionality of a general timer should be able to "ignore" gameplay circumstances like time scaling/pausing, otherwise users will have to build their own workarounds.

Usage

Could be put into a method on Commands that would default to one schedule or the other, but this is the most basic use.

fn user_system(mut c: Commands) {
    let user_entity: Entity = c.spawn_empty().id();

    c.entity(user_entity)
        .insert(DespawnAfter::<Update>::new(5.));
}

Per Alice's suggestion of configuring which schedule it should run within it uses a generic parameter to differentiate. I didn't know if there is an existing way to indicate Fixed vs Update, and using something like a ScheduleLabel is too general.

If we make the despawn_after_tick function public then users can implement the trait for their own schedules if they want to re-use this DespawnAfter functionality.

pub fn despawn_after_plugin(app: &mut App) {
    app.add_systems(PostUpdate, despawn_after_tick::<Update>);
    app.add_systems(FixedPostUpdate, despawn_after_tick::<FixedUpdate>);
}

pub trait BevyScheduleStage: Send + Sync + 'static {}

impl BevyScheduleStage for Update {}
impl BevyScheduleStage for FixedUpdate {}

#[derive(Component)]
pub struct DespawnAfter<Stage: BevyScheduleStage> {
    time_remaining: f32,
    _stage: PhantomData<Stage>,
}

impl<Stage: BevyScheduleStage> DespawnAfter<Stage> {
    pub fn new(seconds: f32) -> Self {
        Self {
            time_remaining: seconds,
            _stage: PhantomData,
        }
    }
}

pub fn despawn_after_tick<Stage: BevyScheduleStage>(
    mut q: Query<(&mut DespawnAfter<Stage>, Entity)>,
    time: Res<Time>,
    mut c: Commands,
) {
    q.iter_mut().for_each(|(mut despawn_after, entity)| {
        despawn_after.time_remaining -= time.delta_secs();

        if despawn_after.time_remaining <= 0. {
            c.entity(entity).despawn();
        }
    });
}

GenericTimer

Implementing DespawnAfter got me thinking about a slightly more useful timer that could be used for repeated events as well as one shot events like despawning on a timer. It is not much more code for vastly more flexibility.

It would function similarly in practice to DespawnAfter, although it would trigger an event instead of just despawning the entity it's on. Then DespawnAfter can be implemented as a thin observer over the GenericTimer.

The use of a generic for indicating the schedule to run in is computationally optimal vs a bool to be checked in the ticking system, but it does make the observer slightly more awkward to use, and users might need to be aware of which schedule the timer is in in-order to register the observer properly. Though I assume it could be handled better, I haven't thought too much about that particular aspect.

Usage

fn user_system(mut c: Commands) {
    let my_entity = c.spawn_empty().id();

    c.despawn_after(my_entity, 5.);
}

// Defined in Bevy, Imagine this is a method on `Commands`
fn despawn_after(c: &mut Commands, target: Entity, seconds: f32) {
    c.spawn((
        GenericTimer::<FixedUpdate>::new(Timer::from_seconds(seconds, TimerMode::Once)),
        Observer::new(
            move |_: Trigger<GenericTimerFinished<FixedUpdate>>, mut c: Commands| {
                if let Ok(mut target) = c.get_entity(target) {
                    target.despawn();
                }
            },
        )
        .with_entity(target),
    ));
}

The use of an Event to propagate the timer finishing allows flexible use by observers. If the user wants to get some information from the underlying timer, such as how many ticks elapsed this frame, or to modify it, they can use the event.target on the observer trigger.

#[derive(Component)]
pub struct GenericTimer<Stage: BevyScheduleStage> {
    timer: Timer,
    despawn_on_finish: bool,
    _stage: PhantomData<Stage>,
}

impl<Stage: BevyScheduleStage> GenericTimer<Stage> {
    pub fn new(timer: Timer) -> Self {
        let despawn_on_finish = timer.mode() == TimerMode::Once;

        Self {
            timer,
            despawn_on_finish,
            _stage: PhantomData,
        }
    }

    pub fn with_despawn_on_finish(mut self, despawn: bool) -> Self {
        self.despawn_on_finish = despawn;
        self
    }
}

#[derive(Event)]
pub struct GenericTimerFinished<Stage: BevyScheduleStage>(PhantomData<Stage>);

fn generic_timer_tick<Stage: BevyScheduleStage>(
    mut q: Query<(&mut GenericTimer<Stage>, Entity)>,
    time: Res<Time>,
    mut c: Commands,
) {
    q.iter_mut().for_each(|(mut generic_timer, entity)| {
        generic_timer.timer.tick(time.delta());

        if generic_timer.timer.just_finished() {
            c.trigger_targets(GenericTimerFinished::<Stage>(PhantomData), entity);

            if generic_timer.despawn_on_finish {
                c.entity(entity).despawn();
            }
        }
    });
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-FeatureA new feature, making something new possibleS-Needs-TriageThis issue needs to be labelled

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions