Multirious / bevy_tween

Flexible tweening plugin library for Bevy.
Apache License 2.0
87 stars 2 forks source link

Compose tweener sequences more easily #13

Closed musjj closed 4 months ago

musjj commented 4 months ago

You can already compose tweens, but playing a linear sequence of tweeners require you to manually read events and juggle marker components.

What if you can compose tweeners too? So for example, let's say you want to play these tweeners, one right after the other:

SpanTweenerBundle::new(Duration::from_secs_f32(2.0))
    .with_repeat(Repeat::times(2))
    .with_repeat_style(RepeatStyle::PingPong);

SpanTweenerBundle::new(Duration::from_secs_f32(5.0))
    .with_repeat(Repeat::times(5))
    .with_repeat_style(RepeatStyle::WrapAround);

SpanTweenerBundle::new(Duration::from_secs_f32(1.0))
    .with_repeat(Repeat::infinitely())
    .with_repeat_style(RepeatStyle::PingPong);

Right now, you need to do this manually, but it would be nice if there's an API that does it for you.

This is roughly how I'd imagine it to look like:

let prev = parent
    .spawn(
        SpanTweenerBundle::new(Duration::from_secs_f32(2.0))
            .with_repeat(Repeat::times(2))
            .with_repeat_style(RepeatStyle::PingPong),
    )
    .id();

parent.spawn(
    SpanTweenerBundle::new(Duration::from_secs_f32(2.0))
        .play_after(prev) // Only start playing after the specified tweener entity is finished
        .with_repeat(Repeat::times(2))
        .with_repeat_style(RepeatStyle::PingPong),
);

Another flexible option would be the ability to attach callbacks that is run after the tweener is completed:

parent.spawn(
    SpanTweenerBundle::new(Duration::from_secs_f32(2.0))
        .with_repeat(Repeat::times(2))
        .with_repeat_style(RepeatStyle::PingPong),
        .on_completed(|query: ...| { ... }) // One-shot system
);

This will also allow you to run clean up operations after the tween is completed, without having to use marker components. Though for the purpose of constructing sequences, this will quickly turn into nesting hell.

What do you think?

Multirious commented 4 months ago

Composing multiple tweeners is something I've thought of before but because this "operation" is very variadic by how many ways users may want to compose tweeners:

So I decided to leave this up to the users.

I wouldn't want to include any extra stuff to the current SpanTweener component where its only responsibility is to control their span tweens. So we're left with making additional components which I'm assuming is the solution you've had. (That's great! The crate I'm intended to be extendable is being extended. I wouldn't call this manually reading events too because that's what you need to use to extend the crate) I'd too like a way to make this possible with the builder API but since optional component in a bundle is not possible right now, its work around would be creating more bundle for each variant which I don't think anyone like it. So we're left with using the .spawn() where this code below is the one I'm assuming you're using to compose tweens.

let prev = parent
    .spawn(
        SpanTweenerBundle::new(Duration::from_secs_f32(2.0))
            .with_repeat(Repeat::times(2))
            .with_repeat_style(RepeatStyle::PingPong),
    )
    .id();

parent.spawn((
    PlayAfter(prev),
    SpanTweenerBundle::new(Duration::from_secs_f32(2.0))
        .with_repeat(Repeat::times(2))
        .with_repeat_style(RepeatStyle::PingPong)
        .with_paused(true),
));

From my perspective, this looks just fine. To run a one-shot system too is simply

parent.spawn((
    OnCompleted(/* a system closure */), // run a closure system once after completed
    OnCompletedId(/* a system id */), // run a system via id after completed
    PlayAfter(prev),
    SpanTweenerBundle::new(Duration::from_secs_f32(2.0))
        .with_repeat(Repeat::times(2))
        .with_repeat_style(RepeatStyle::PingPong)
        .with_paused(true),
));

Already using the .spawn() API to compose components seems to me like already a perfect fit for composing tweeners, we can add as many conditions and everything else through here without extending the builder API.

musjj commented 4 months ago

I've actually tried something like this before, but I found that if I add two tweeners that targets the same component as a child of an entity, they will interrupt each other. Even when the other one is paused. Do you have any solutions for this kind of use case?

Multirious commented 4 months ago

Interesting, do you mind giving those tweeners declaration? It shouldn't interrupt each other if I understand the systems correctly.

musjj commented 4 months ago

Here's an example:

commands
    .spawn(MaterialMesh2dBundle {
        mesh: meshes.add(Rectangle::from_size(Vec2::new(5.0, 5.0))).into(),
        transform: Transform::default(),
        material: materials.add(Color::PURPLE),
        ..default()
    })
    .with_children(|parent| {
        parent
            .spawn(SpanTweenerBundle::new(Duration::from_secs_f32(2.0)))
            .with_children(|parent| {
                parent.span_tweens().tween(
                    Duration::from_secs_f32(2.0),
                    EaseFunction::Linear,
                    ComponentTween::tweener_parent(interpolate::Scale {
                        start: Vec3::splat(1.0),
                        end: Vec3::new(8.0, 1.0, 1.0),
                    }),
                );
            });

        parent
            .spawn(SpanTweenerBundle::new(Duration::from_secs_f32(2.0)).with_paused(true))
            .with_children(|parent| {
                parent.span_tweens().tween(
                    Duration::from_secs_f32(2.0),
                    EaseFunction::Linear,
                    ComponentTween::tweener_parent(interpolate::Scale {
                        start: Vec3::splat(1.0),
                        end: Vec3::new(1.0, 8.0, 1.0),
                    }),
                );
            });
    });

The first tweener won't play unless the second one is removed.

Multirious commented 4 months ago

Ah I think I remember now. with_paused will only pause the timer from ticking but not a tweener from running. Can you try SkipTweener component?

musjj commented 4 months ago

Thanks, that works for me! I guess I'll close this issue.

musjj commented 4 months ago

Additional info: you need to set both with_paused AND SkipTweener or your tween might have finished "playing" before you even removed the SkipTweener component. This was driving me nuts :sweat_smile:.

Multirious commented 4 months ago

Oops, I'll make sure this is properly documented in the next release.😂 These are intentionally separated to support for an editor in the future.

musjj commented 4 months ago

Another question, can you tell my why isn't this working? I was trying to reproduce a bug, but I ended up hitting a different one :sob:

use bevy::{prelude::*, sprite::MaterialMesh2dBundle};
use bevy_tween::{prelude::*, span_tween::SpanTweener, tween::SkipTweener};

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, DefaultTweenPlugins))
        .add_systems(Startup, setup)
        .add_systems(Update, transition)
        .run();
}

#[derive(Component)]
pub struct First;

#[derive(Component)]
pub struct Second;

fn transition(
    mut commands: Commands,
    mut ended_reader: EventReader<SpanTweenerEnded>,
    mut first: Query<&mut SpanTweener, With<First>>,
    mut second: Query<
        (Entity, &mut SpanTweener),
        (Without<First>, With<Second>, With<SkipTweener>),
    >,
) {
    for ended in ended_reader.read() {
        if ended.is_completed() {
            if let Ok(mut tweener) = first.get_mut(ended.tweener) {
                tweener.timer.set_paused(true);
                commands.entity(ended.tweener).insert(SkipTweener);

                let (entity, mut tweener) = second.single_mut();
                commands.entity(entity).remove::<SkipTweener>();
                tweener.timer.set_paused(false);
            }
        }
    }
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2dBundle::default());

    commands
        .spawn(MaterialMesh2dBundle {
            mesh: meshes
                .add(Rectangle::from_size(Vec2::new(100.0, 100.0)))
                .into(),
            material: materials.add(Color::PURPLE),
            ..default()
        })
        .with_children(|parent| {
            parent
                .spawn((First, SpanTweenerBundle::new(Duration::from_secs_f32(2.0))))
                .with_children(|parent| {
                    parent.span_tweens().tween_exact(
                        ..Duration::from_secs_f32(2.0),
                        EaseFunction::Linear,
                        ComponentTween::tweener_parent(interpolate::Scale {
                            start: Vec3::splat(1.0),
                            end: Vec3::new(3.0, 1.0, 1.0),
                        }),
                    );
                });

            parent
                .spawn((
                    Second,
                    SpanTweenerBundle::new(Duration::from_secs_f32(2.0)).with_paused(true),
                    SkipTweener,
                ))
                .with_children(|parent| {
                    parent.span_tweens().tween_exact(
                        ..Duration::from_secs_f32(1.2),
                        EaseFunction::Linear,
                        ComponentTween::tweener_parent(interpolate::Scale {
                            start: Vec3::new(1.0, 8.0, 1.0),
                            end: Vec3::new(1.0, 1.0, 1.0),
                        }),
                    );
                });
        });
}

First gets instantly skipped because a SpanTweenerEnded event was instantly sent for the First entity...

The bug goes away if you remove the transition system.

Multirious commented 4 months ago

First gets instantly skipped because a SpanTweenerEnded event was instantly sent for the First entity...

Bug found. tick_span_tweener_system doesn't account for timer direction so if elasped time is 0 then the event will be fired. Thanks!

Multirious commented 4 months ago

Should be fixed by bd6841c

Multirious commented 4 months ago

Additionally, I have to add apply_deferred after the transition system to add the SkipTweener right away.

musjj commented 4 months ago

Thank you :sob:! But now I can finally get to the main bug I was dealing with.

In the snippet above, I'm expecting a square stretching horizontally in the First animation. Then a tall rectangle squeezing vertically back to a square in the Second animation.

But after the Second animation is completed, the square becomes a horizontally stretched rectangle, identical with the final frame of the First animation despite it being paused.

Can you reproduce this? (no changes to snippet above is required).

Multirious commented 4 months ago

@musjj I've fixed that by adding

        .add_systems(Update, (transition, apply_deferred).chain())
musjj commented 4 months ago

I tried it, but the final frame is still a wide rectangle instead of a square, weird. The order of the transition system shouldn't matter because it only reacts to the First animation completing.

Multirious commented 4 months ago

Oh wait I missed that last part. Yeah, something fishy going on.

Multirious commented 4 months ago

Not exactly sure why but having your second tween have shorter duration than the tweener causes the problem.

musjj commented 4 months ago

Yeah, the tween's duration being shorter than the tweener's duration was one of the conditions for the bug.

My real use case was that I was doing several parallel tweens. When one of the tweens is shorter than the tweener, this bug happens.

Multirious commented 4 months ago

How does one even debug this 😭. I'm going to give system stepper a try. Never used it before. Edit: No need for it. Just added a name component then print em out when they ran.

Multirious commented 4 months ago

@musjj There's a bug in tweener's system that didn't remove TweenProgress (It's used as a marker to make tweens running) from their tweens when SkipTweener component is added or timer is completed. I'll get started on fixing this.

Usually I remove all my tweeners whenever they're done and only add my tweeners whenever they're needed if possible you might want to consider those.

musjj commented 4 months ago

Yeah, thanks despawning the tweener when it's done works as a workaround for now!

Multirious commented 4 months ago

Fixed in 3b858d9 Thanks!

musjj commented 4 months ago

Thank you, that fixed it!