feat: BpmTimer and AttackArea

This commit is contained in:
Alexey 2026-04-13 14:24:58 +03:00
commit f6022d84b2
7 changed files with 198 additions and 2 deletions

75
src/combat/attack.rs Normal file
View file

@ -0,0 +1,75 @@
//! Attack module
use std::time::Duration;
use bevy::prelude::*;
use bevy_rapier2d::prelude::*;
/// Contains logic for attack-related calculations
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component, Clone, Debug)]
#[require(Transform, Collider, Sensor)]
pub struct AttackArea {
accuracy: f32,
damage: f32,
timer: Timer,
max_distance: f32,
}
/// Determines [AttackArea] damage based on distance of target from itself
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug, Reflect, Default)]
#[reflect(Component, Clone, PartialEq, Debug, Default)]
#[require(Transform)]
pub struct AttackAreaOrigin;
impl AttackArea {
/// Constructs new attack area
pub fn new(accuracy: f32, damage: f32, duration: Duration, half_area: Vec2) -> Self {
let max_distance = ((half_area.x * 2.).powi(2) + half_area.y.powi(2)).sqrt();
Self {
accuracy,
damage,
timer: Timer::new(duration, TimerMode::Once),
max_distance,
}
}
/// Returns attack area bundle with everything needed
pub fn bundle(
damage: f32,
accuracy: f32,
duration: Duration,
position: Vec2,
half_area: Vec2,
facing_left: bool,
) -> impl Bundle {
let origin_x = if facing_left { position.x - half_area.x } else { position.x + half_area.x };
(
// Basic
AttackArea::new(accuracy, damage, duration, half_area),
// Collision
Transform::from_xyz(position.x, position.y, 0.),
Collider::cuboid(half_area.x, half_area.y),
// AttackAreaOrigin
Children::spawn((
Spawn((
AttackAreaOrigin,
Transform::from_xyz(origin_x, 0., 0.),
)),
)),
)
}
/// Updates timer and returns true if attack has ended
pub fn tick(&mut self, dt: Duration) -> bool {
self.timer.tick(dt).is_finished()
}
/// Returns damage for given distance to origin
pub fn damage(&self, distance_to_origin: f32) -> f32 {
let distance_ratio = 1. - distance_to_origin.min(self.max_distance) / self.max_distance;
let total_multiplier = self.accuracy * distance_ratio;
self.damage * EaseFunction::QuinticOut.sample_unchecked(total_multiplier)
}
}

3
src/combat/mod.rs Normal file
View file

@ -0,0 +1,3 @@
//! This module contains core concepts of the combat system
pub mod attack;

View file

@ -5,14 +5,16 @@
#[cfg(test)]
mod tests;
pub mod combat;
pub mod graph;
pub mod input;
pub mod player;
pub mod plugin;
pub mod timer;
use bevy::prelude::*;
use crate::player::Player;
use crate::{player::Player, timer::BpmTimer};
const PIXELS_PER_METER: f32 = 16.;
@ -23,4 +25,5 @@ const PIXELS_PER_METER: f32 = 16.;
/// Temporary function to setup world
pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Player::bundle(&asset_server, Vec2::ZERO));
commands.spawn(BpmTimer::new(120.));
}

View file

@ -20,6 +20,9 @@ impl Plugin for GamePlugin {
InputManagerPlugin::<input::DebugInput>::default(),
))
.add_systems(Startup, setup)
.add_systems(Update, player::systems::handle_input);
.add_systems(Update, (
player::systems::handle_input,
timer::update_bpm_timers,
));
}
}

26
src/tests/attack.rs Normal file
View file

@ -0,0 +1,26 @@
use std::time::Duration;
use bevy::prelude::*;
use crate::combat::attack::AttackArea;
#[test]
fn attack_deals_max_damage() {
let a = AttackArea::new(1., 1., Duration::from_secs(1), vec2(1., 1.));
assert_eq!(a.damage(0.), 1.);
}
#[test]
fn attack_too_far() {
let a = AttackArea::new(1., 1., Duration::from_secs(1), vec2(1., 1.));
assert_eq!(a.damage(5f32.sqrt()), 0.);
}
#[test]
fn attack_too_inaccurate() {
let a = AttackArea::new(0., 1., Duration::from_secs(1), vec2(1., 1.));
assert_eq!(a.damage(0.), 0.);
}

View file

@ -1 +1,2 @@
mod attack;
mod graph;

85
src/timer.rs Normal file
View file

@ -0,0 +1,85 @@
//! Beats per minute based timer module
use std::time::Duration;
use bevy::prelude::*;
/// Calculates [Duration] from BPM
#[inline(always)]
pub fn duration_from_bpm(bpm: f32) -> Duration {
Duration::from_millis((60_000. / bpm) as u64)
}
/// Calculates BPM from [Duration]
#[inline(always)]
pub fn bpm_from_duration(dur: Duration) -> f32 {
60_000. / (dur.as_millis() as f32)
}
/// Event that is triggered when [BpmTimer] just ticked
#[derive(EntityEvent, Clone, Copy, PartialEq, Debug, Reflect)]
#[reflect(Event, Clone, PartialEq, Debug)]
pub struct TickEvent {
/// [BpmTimer] that triggered event
#[event_target] pub timer: Entity,
/// Total elapsed time of that timer in beats
pub elapsed: f32,
}
/// Timer that is based on beats per minute (BPM) value
/// Ticks every beat, counts total ticks and triggers [TickEvent]
#[derive(Component, Clone, PartialEq, Debug, Reflect)]
#[reflect(Component, Clone, PartialEq, Debug)]
pub struct BpmTimer {
timer: Timer,
elapsed: f32,
}
impl BpmTimer {
/// Constructs new BpmTimer from given BPM
pub fn new(bpm: f32) -> Self {
Self {
timer: Timer::new(duration_from_bpm(bpm), TimerMode::Repeating),
elapsed: 0.,
}
}
/// Updates internal timer and returns true if it just ticked
pub fn tick(&mut self, dt: Duration) -> bool {
let ticked = self.timer.tick(dt).just_finished();
if ticked { self.elapsed += 1.; }
ticked
}
/// Returns total elapsed time in beats
#[inline(always)]
pub fn beats_elapsed(&self) -> f32 {
self.elapsed + self.timer.fraction()
}
/// Get beats per minute value of timer
#[inline(always)]
pub fn get_bpm(&self) -> f32 {
bpm_from_duration(self.timer.duration())
}
/// Set new BPM value
pub fn set_bpm(&mut self, new_bpm: f32) {
self.timer.set_duration(duration_from_bpm(new_bpm));
}
/// Resets elapsed beats
pub fn reset(&mut self) {
self.timer.reset();
self.elapsed = 0.;
}
}
/// System that ticks each [BpmTimer] and triggers [TickEvent]s
pub fn update_bpm_timers(mut commands: Commands, time: Res<Time>, timers: Query<(Entity, &mut BpmTimer)>) {
for (timer_id, mut timer) in timers {
if timer.tick(time.delta()) {
commands.trigger(TickEvent { timer: timer_id, elapsed: timer.beats_elapsed() });
}
}
}