Skip to main content

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::{
7    color::palettes::tailwind::{SLATE_400, SLATE_50},
8    image::TextureAtlasTemplate,
9    prelude::*,
10    ui_widgets::{Activate, Button},
11};
12
13fn main() {
14    App::new()
15        .add_plugins(DefaultPlugins)
16        .add_systems(Startup, setup)
17        .add_systems(
18            Update,
19            animate_cooldowns.run_if(any_with_component::<ActiveCooldown>),
20        )
21        .add_observer(on_activate_start_cooldown)
22        .run();
23}
24
25fn setup(mut commands: Commands, mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>) {
26    let layout = TextureAtlasLayout::from_grid(UVec2::splat(64), 7, 7, None, None);
27    let texture_atlas_layout = texture_atlas_layouts.add(layout);
28
29    commands.spawn(Camera2d);
30
31    // The food items
32    commands.spawn_scene(bsn! {
33        Node {
34            width: percent(100),
35            height: percent(100),
36            align_items: AlignItems::Center,
37            justify_content: JustifyContent::Center,
38            column_gap: px(15),
39        }
40        Children [
41            { food_items_scene_list(texture_atlas_layout) }
42        ]
43    });
44
45    // Status text
46    commands.spawn_scene(bsn! {
47        Node {
48            position_type: PositionType::Absolute,
49            top: px(12),
50            left: px(12),
51        }
52        Children [
53            Text("*Click some food to eat it*")
54        ]
55    });
56}
57
58// Returns the scene list of food items.
59fn food_items_scene_list(texture_atlas_layout: Handle<TextureAtlasLayout>) -> impl SceneList {
60    let foods = [
61        FoodItem {
62            name: "an apple",
63            cooldown: 2.,
64            index: 2,
65        },
66        FoodItem {
67            name: "a burger",
68            cooldown: 1.,
69            index: 23,
70        },
71        FoodItem {
72            name: "chocolate",
73            cooldown: 10.,
74            index: 32,
75        },
76        FoodItem {
77            name: "cherries",
78            cooldown: 4.,
79            index: 41,
80        },
81    ];
82
83    foods
84        .into_iter()
85        .map(|food| {
86            build_ability(
87                food,
88                "textures/food_kenney.png".into(),
89                texture_atlas_layout.clone(),
90            )
91        })
92        .collect::<Vec<_>>()
93}
94
95struct FoodItem {
96    name: &'static str,
97    cooldown: f32,
98    index: usize,
99}
100
101// Returns a scene of this food item as a button.
102fn build_ability(
103    food: FoodItem,
104    texture: String,
105    layout: Handle<TextureAtlasLayout>,
106) -> impl Scene {
107    let FoodItem {
108        name,
109        cooldown,
110        index,
111    } = food;
112
113    // Every food item is a button with a child node.
114    // The child node's height will be animated to be at 100% at the beginning
115    // of a cooldown, effectively graying out the whole button, and then getting smaller over time.
116    bsn! {
117        Node {
118            width: px(80),
119            height: px(80),
120            flex_direction: FlexDirection::ColumnReverse,
121        }
122        BackgroundColor(SLATE_400)
123        Button
124        ImageNode {
125            image: texture,
126            texture_atlas: texture_atlas_template(layout, index),
127        }
128        Cooldown(Timer::from_seconds(cooldown, TimerMode::Once))
129        Name(name)
130        Children [
131            Node {
132                width: percent(100),
133                height: percent(0),
134            }
135            BackgroundColor({SLATE_50.with_alpha(0.5)})
136        ]
137    }
138}
139
140// Ensure the texture_atlas in the ImageNode can be set correctly.
141fn texture_atlas_template(
142    layout: Handle<TextureAtlasLayout>,
143    index: usize,
144) -> TextureAtlasTemplate {
145    TextureAtlasTemplate {
146        layout: layout.into(),
147        index,
148    }
149}
150
151#[derive(Component, Clone, Default)]
152struct Cooldown(Timer);
153
154#[derive(Component)]
155#[component(storage = "SparseSet")]
156struct ActiveCooldown;
157
158/// Observer that starts the cooldown whenever a food item button is activated.
159fn on_activate_start_cooldown(
160    event: On<Activate>,
161    mut commands: Commands,
162    mut interaction_query: Query<
163        (Entity, &mut Cooldown, &Name, Option<&ActiveCooldown>),
164        With<Button>,
165    >,
166    mut text: Query<&mut Text>,
167) -> Result {
168    if let Ok((entity, mut cooldown, name, on_cooldown)) = interaction_query.get_mut(event.entity) {
169        if on_cooldown.is_none() {
170            cooldown.0.reset();
171            commands.entity(entity).insert(ActiveCooldown);
172            **text.single_mut()? = format!("You ate {name}");
173        } else {
174            **text.single_mut()? = format!(
175                "You can eat {name} again in {} seconds.",
176                cooldown.0.remaining_secs().ceil()
177            );
178        }
179    }
180
181    Ok(())
182}
183
184fn animate_cooldowns(
185    time: Res<Time>,
186    mut commands: Commands,
187    buttons: Query<(Entity, &mut Cooldown, &Children), With<ActiveCooldown>>,
188    mut nodes: Query<&mut Node>,
189) -> Result {
190    for (entity, mut timer, children) in buttons {
191        timer.0.tick(time.delta());
192        let cooldown = children.first().ok_or("No child")?;
193        if timer.0.just_finished() {
194            commands.entity(entity).remove::<ActiveCooldown>();
195            nodes.get_mut(*cooldown)?.height = percent(0);
196        } else {
197            nodes.get_mut(*cooldown)?.height = percent((1. - timer.0.fraction()) * 100.);
198        }
199    }
200
201    Ok(())
202}