cooldown/
cooldown.rs

1//! Demonstrates implementing a cooldown in UI.
2//!
3//! You might want a system like this for abilities, buffs or consumables.
4//! We create four food buttons to eat with 2, 1, 10, and 4 seconds cooldown.
5
6use bevy::{color::palettes::tailwind, ecs::spawn::SpawnIter, prelude::*};
7
8fn main() {
9    App::new()
10        .add_plugins(DefaultPlugins)
11        .add_systems(Startup, setup)
12        .add_systems(
13            Update,
14            (
15                activate_ability,
16                animate_cooldowns.run_if(any_with_component::<ActiveCooldown>),
17            ),
18        )
19        .run();
20}
21
22fn setup(
23    mut commands: Commands,
24    mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
25    asset_server: Res<AssetServer>,
26) {
27    commands.spawn(Camera2d);
28    let texture = asset_server.load("textures/food_kenney.png");
29    let layout = TextureAtlasLayout::from_grid(UVec2::splat(64), 7, 7, None, None);
30    let texture_atlas_layout = texture_atlas_layouts.add(layout);
31    commands.spawn((
32        Node {
33            width: percent(100),
34            height: percent(100),
35            align_items: AlignItems::Center,
36            justify_content: JustifyContent::Center,
37            column_gap: px(15),
38            ..default()
39        },
40        Children::spawn(SpawnIter(
41            [
42                FoodItem {
43                    name: "an apple",
44                    cooldown: 2.,
45                    index: 2,
46                },
47                FoodItem {
48                    name: "a burger",
49                    cooldown: 1.,
50                    index: 23,
51                },
52                FoodItem {
53                    name: "chocolate",
54                    cooldown: 10.,
55                    index: 32,
56                },
57                FoodItem {
58                    name: "cherries",
59                    cooldown: 4.,
60                    index: 41,
61                },
62            ]
63            .into_iter()
64            .map(move |food| build_ability(food, texture.clone(), texture_atlas_layout.clone())),
65        )),
66    ));
67    commands.spawn((
68        Text::new("*Click some food to eat it*"),
69        Node {
70            position_type: PositionType::Absolute,
71            top: px(12),
72            left: px(12),
73            ..default()
74        },
75    ));
76}
77
78struct FoodItem {
79    name: &'static str,
80    cooldown: f32,
81    index: usize,
82}
83
84fn build_ability(
85    food: FoodItem,
86    texture: Handle<Image>,
87    layout: Handle<TextureAtlasLayout>,
88) -> impl Bundle {
89    let FoodItem {
90        name,
91        cooldown,
92        index,
93    } = food;
94    let name = Name::new(name);
95
96    // Every food item is a button with a child node.
97    // The child node's height will be animated to be at 100% at the beginning
98    // of a cooldown, effectively graying out the whole button, and then getting smaller over time.
99    (
100        Node {
101            width: px(80),
102            height: px(80),
103            flex_direction: FlexDirection::ColumnReverse,
104            ..default()
105        },
106        BackgroundColor(tailwind::SLATE_400.into()),
107        Button,
108        ImageNode::from_atlas_image(texture, TextureAtlas { layout, index }),
109        Cooldown(Timer::from_seconds(cooldown, TimerMode::Once)),
110        name,
111        children![(
112            Node {
113                width: percent(100),
114                height: percent(0),
115                ..default()
116            },
117            BackgroundColor(tailwind::SLATE_50.with_alpha(0.5).into()),
118        )],
119    )
120}
121
122#[derive(Component)]
123struct Cooldown(Timer);
124
125#[derive(Component)]
126#[component(storage = "SparseSet")]
127struct ActiveCooldown;
128
129fn activate_ability(
130    mut commands: Commands,
131    mut interaction_query: Query<
132        (
133            Entity,
134            &Interaction,
135            &mut Cooldown,
136            &Name,
137            Option<&ActiveCooldown>,
138        ),
139        (Changed<Interaction>, With<Button>),
140    >,
141    mut text: Query<&mut Text>,
142) -> Result {
143    for (entity, interaction, mut cooldown, name, on_cooldown) in &mut interaction_query {
144        if *interaction == Interaction::Pressed {
145            if on_cooldown.is_none() {
146                cooldown.0.reset();
147                commands.entity(entity).insert(ActiveCooldown);
148                **text.single_mut()? = format!("You ate {name}");
149            } else {
150                **text.single_mut()? = format!(
151                    "You can eat {name} again in {} seconds.",
152                    cooldown.0.remaining_secs().ceil()
153                );
154            }
155        }
156    }
157
158    Ok(())
159}
160
161fn animate_cooldowns(
162    time: Res<Time>,
163    mut commands: Commands,
164    buttons: Query<(Entity, &mut Cooldown, &Children), With<ActiveCooldown>>,
165    mut nodes: Query<&mut Node>,
166) -> Result {
167    for (entity, mut timer, children) in buttons {
168        timer.0.tick(time.delta());
169        let cooldown = children.first().ok_or("No child")?;
170        if timer.0.just_finished() {
171            commands.entity(entity).remove::<ActiveCooldown>();
172            nodes.get_mut(*cooldown)?.height = percent(0);
173        } else {
174            nodes.get_mut(*cooldown)?.height = percent((1. - timer.0.fraction()) * 100.);
175        }
176    }
177
178    Ok(())
179}