generalize cooldown

This commit is contained in:
2025-12-19 14:47:05 -05:00
parent 5278bc9d1f
commit 14a307f29a
3 changed files with 157 additions and 17 deletions

View File

@@ -3,6 +3,7 @@ use crate::{
client::{audio::SoundSettings, control::CharacterInputEnabled},
loading_assets::UIAssets,
protocol::PlaySound,
utils::Cooldown,
};
use bevy::{color::palettes::css::BLACK, prelude::*};
@@ -26,9 +27,6 @@ struct VolumeValue(ProgressBar);
#[derive(Resource)]
struct PauseMenuSelection(ProgressBar);
#[derive(Resource)]
struct VolumeChangeCooldown(Timer);
#[derive(Message)]
struct VolumeChangeEvent {
direction: f32,
@@ -108,10 +106,6 @@ fn setup(mut commands: Commands, assets: Res<UIAssets>, settings: Res<SoundSetti
));
commands.insert_resource(PauseMenuSelection(ProgressBar::Music));
commands.insert_resource(VolumeChangeCooldown(Timer::from_seconds(
0.09,
TimerMode::Repeating,
)));
}
fn spawn_progress(bar: ProgressBar, value: u8, font: Handle<Font>) -> impl Bundle {
@@ -280,22 +274,15 @@ fn apply_volume_change(
mut commands: Commands,
selection: Res<PauseMenuSelection>,
mut settings: ResMut<SoundSettings>,
mut cooldown: ResMut<VolumeChangeCooldown>,
time: Res<Time>,
mut cooldown: Cooldown<90>,
mut events: MessageReader<VolumeChangeEvent>,
) {
let Some(event) = events.read().last() else {
return;
};
// On first press, apply immediately and reset timer
if event.just_pressed {
cooldown.0.reset();
} else {
cooldown.0.tick(time.delta());
if !cooldown.0.just_finished() {
return;
}
if !cooldown.ready(event.just_pressed) {
return;
}
commands.trigger(PlaySound::VolumeChange);

View File

@@ -0,0 +1,151 @@
use bevy::{ecs::system::SystemParam, prelude::*};
/// A cooldown timer that triggers immediately on first request, then throttles subsequent triggers.
///
/// Useful for input handling where you want immediate feedback on first action,
/// but want to rate-limit continuous actions (like volume control).
/// Automatically ticks with time, no manual `time` parameter needed.
///
/// # Type Parameters
/// * `MILLIS` - The cooldown delay in milliseconds between triggers
///
/// # Example
/// ```ignore
/// fn handle_input(
/// keyboard: Res<ButtonInput<KeyCode>>,
/// mut cooldown: Cooldown<50>, // 50ms between repeats
/// mut volume: ResMut<Volume>,
/// ) {
/// let is_new = keyboard.just_pressed(KeyCode::ArrowUp);
/// let is_active = keyboard.pressed(KeyCode::ArrowUp);
///
/// if is_active && cooldown.ready(is_new) {
/// volume.0 += 0.01;
/// }
/// }
/// ```
#[derive(SystemParam)]
pub struct Cooldown<'w, 's, const MILLIS: u64> {
timer: Local<'s, CooldownTimer<MILLIS>>,
time: Res<'w, Time>,
}
struct CooldownTimer<const MILLIS: u64> {
timer: Timer,
}
impl<const MILLIS: u64> Default for CooldownTimer<MILLIS> {
fn default() -> Self {
Self {
timer: Timer::from_seconds((MILLIS as f32) / 1000.0, TimerMode::Repeating),
}
}
}
impl<'w, 's, const MILLIS: u64> Cooldown<'w, 's, MILLIS> {
/// Check if the cooldown is ready to trigger.
///
/// Returns `true` if:
/// - `reset` is true (immediate trigger and reset timer), or
/// - The cooldown timer has finished (subsequent triggers after delay)
///
/// When `reset` is true, the timer is reset and returns immediately.
/// Otherwise, the timer is ticked automatically.
pub fn ready(&mut self, reset: bool) -> bool {
if reset {
self.timer.timer.reset();
true
} else {
self.timer.timer.tick(self.time.delta()).just_finished()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Resource, Default)]
struct Counter(u32);
#[test]
fn cooldown_triggers_immediately_on_reset() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<Counter>();
#[derive(Resource, Default)]
struct ShouldReset(bool);
fn increment_with_cooldown(
mut counter: ResMut<Counter>,
mut cooldown: Cooldown<50>,
should_reset: Res<ShouldReset>,
) {
if cooldown.ready(should_reset.0) {
counter.0 += 1;
}
}
app.init_resource::<ShouldReset>();
app.add_systems(Update, increment_with_cooldown);
// First call with reset should trigger immediately
app.world_mut().resource_mut::<ShouldReset>().0 = true;
app.update();
assert_eq!(app.world().resource::<Counter>().0, 1);
// Without reset, should not trigger
app.world_mut().resource_mut::<ShouldReset>().0 = false;
app.update();
assert_eq!(app.world().resource::<Counter>().0, 1);
}
#[test]
fn cooldown_throttles_continuous_triggers() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<Counter>();
#[derive(Resource, Default)]
struct ShouldReset(bool);
fn increment_with_cooldown(
mut counter: ResMut<Counter>,
mut cooldown: Cooldown<50>,
should_reset: Res<ShouldReset>,
) {
if cooldown.ready(should_reset.0) {
counter.0 += 1;
}
}
app.init_resource::<ShouldReset>();
app.add_systems(Update, increment_with_cooldown);
// First call with reset triggers immediately
app.world_mut().resource_mut::<ShouldReset>().0 = true;
app.update();
assert_eq!(app.world().resource::<Counter>().0, 1);
// Continuous calls without reset should not trigger yet
app.world_mut().resource_mut::<ShouldReset>().0 = false;
app.update();
assert_eq!(app.world().resource::<Counter>().0, 1);
// Sleep for less than cooldown
std::thread::sleep(std::time::Duration::from_millis(40));
app.update();
assert_eq!(app.world().resource::<Counter>().0, 1);
// Sleep past cooldown - should trigger again
std::thread::sleep(std::time::Duration::from_millis(15));
app.update();
assert_eq!(app.world().resource::<Counter>().0, 2);
// Sleep past cooldown again - should trigger a third time
std::thread::sleep(std::time::Duration::from_millis(55));
app.update();
assert_eq!(app.world().resource::<Counter>().0, 3);
}
}

View File

@@ -1,5 +1,6 @@
pub mod auto_rotate;
pub mod billboards;
pub mod cooldown;
pub mod debounce;
pub mod explosions;
pub mod observers;
@@ -10,6 +11,7 @@ pub mod squish_animation;
pub mod trail;
use bevy::prelude::*;
pub use cooldown::Cooldown;
pub use debounce::Debounce;
pub(crate) use observers::global_observer;