feat: Player attacking

- Added BpmTimer.accuracy method
- Added Weapon.attack_offset method
- Replaced beats_remaining with timer in Attacking and Awaiting
- AttackArea spawning on Attacking end
This commit is contained in:
Alexey 2026-04-17 12:53:20 +03:00
commit fb1923fe53
9 changed files with 107 additions and 58 deletions

View file

@ -37,6 +37,5 @@ pub const GROUP_ATTACK: Group = Group::GROUP_5;
/// Temporary function to setup world /// Temporary function to setup world
pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) { pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Player::bundle(&asset_server, Vec2::ZERO)); commands.spawn(Player::bundle(&asset_server, Vec2::ZERO));
commands.spawn(BpmTimer::new(120.)) commands.spawn(BpmTimer::new(120.));
.observe(player::systems::on_timer_tick);
} }

View file

@ -90,7 +90,10 @@ impl Player {
// Remove NextAttack before Attacking // Remove NextAttack before Attacking
.on_enter::<states::Attacking>(|c| { c.remove::<states::NextAttack>(); } ) .on_enter::<states::Attacking>(|c| { c.remove::<states::NextAttack>(); } )
// Attacking -> Awaiting // Attacking -> Awaiting
.trans::<states::Attacking, _>(done(None), states::Awaiting { beats_remaining: 1 }) .trans_builder(
done(None).ignore_and(triggers::get_timer_bpm),
triggers::from_attacking_to_awaiting,
)
// Awaiting -> Free // Awaiting -> Free
.trans::<states::Awaiting, _>(done(None), states::Free) .trans::<states::Awaiting, _>(done(None), states::Free)
// Awaiting -> Choosing // Awaiting -> Choosing

View file

@ -4,7 +4,7 @@ use std::fmt::Debug;
use bevy::prelude::*; use bevy::prelude::*;
use crate::{graph::action::ActionNode, input::PlayerInput, player::weapon::AttackType}; use crate::{graph::action::ActionNode, input::PlayerInput, player::weapon::AttackType, timer::duration_from_bpm};
/// Player is not doing anything special /// Player is not doing anything special
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug, Default, Reflect)] #[derive(Component, Clone, Copy, PartialEq, Eq, Debug, Default, Reflect)]
@ -22,26 +22,44 @@ pub struct Choosing {
} }
/// Player is acting and cannot do something else /// Player is acting and cannot do something else
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug, Reflect)] #[derive(Component, Clone, PartialEq, Debug, Reflect)]
#[reflect(Component, Clone, PartialEq, Debug)] #[reflect(Component, Clone, PartialEq, Debug)]
#[component(storage = "SparseSet")] #[component(storage = "SparseSet")]
pub struct Attacking { pub struct Attacking {
/// Which physical action was chosen /// Which physical action was chosen
pub phys: ActionNode<AttackType>, pub phys: ActionNode<AttackType>,
/// How much time left before action ends /// Timer that awaits for attack to end
pub beats_remaining: u32, pub timer: Timer,
/// How precisely user clicked in the beat
pub accuracy: f32,
}
impl Attacking {
/// Constructs new [Attacking] from given beats and bpm
pub fn new(phys: ActionNode<AttackType>, accuracy: f32, beats: f32, bpm: f32) -> Self {
let duration = duration_from_bpm(bpm).mul_f32(beats);
Self { phys, timer: Timer::new(duration, TimerMode::Once), accuracy }
}
} }
/// Next attack to be performed /// Next attack to be performed
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug, Reflect)] #[derive(Component, Clone, PartialEq, Debug, Reflect)]
#[reflect(Component, Clone, PartialEq, Debug)] #[reflect(Component, Clone, PartialEq, Debug)]
pub struct NextAttack(pub Attacking); pub struct NextAttack(pub Attacking);
/// Player is awaiting next action /// Player is awaiting next action
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug, Default, Reflect)] #[derive(Component, Clone, PartialEq, Debug, Default, Reflect)]
#[reflect(Component, Clone, PartialEq, Debug, Default)] #[reflect(Component, Clone, PartialEq, Debug, Default)]
#[component(storage = "SparseSet")] #[component(storage = "SparseSet")]
pub struct Awaiting { pub struct Awaiting {
/// How much time left before transitioning to Free /// Timer that awaits for next action
pub beats_remaining: u32, pub timer: Timer,
}
impl Awaiting {
/// Constructs new [Awaiting] from given beats and bpm
pub fn new(beats: f32, bpm: f32) -> Self {
let duration = duration_from_bpm(bpm).mul_f32(beats);
Self { timer: Timer::new(duration, TimerMode::Once) }
}
} }

View file

@ -1,6 +1,8 @@
//! Player systems //! Player systems
use crate::timer::TickEvent; use bevy_trait_query::One;
use crate::timer::BpmTimer;
use super::*; use super::*;
@ -17,19 +19,33 @@ pub fn handle_input(
&mut KinematicCharacterController, &mut KinematicCharacterController,
&mut Sprite, &mut Sprite,
Option<&mut AttackGraph>, Option<&mut AttackGraph>,
Option<One<&dyn Weapon>>,
Option<&states::Free>, Option<&states::Free>,
Option<&states::Choosing>, Option<&states::Choosing>,
Option<&states::Attacking>, Option<&mut states::Attacking>,
Option<&states::Awaiting>, Option<&mut states::Awaiting>,
)>, )>,
timer_query: Query<&BpmTimer>,
) { ) {
let Some(timer) = timer_query.iter().next() else {
error!("No BpmTimer provided");
return;
};
let bpm = timer.get_bpm();
for ( for (
// Basic things
player_id, player_id,
player, player,
action_state, action_state,
mut controller, mut controller,
mut sprite, mut sprite,
// Weapon
maybe_attack_graph, maybe_attack_graph,
maybe_weapon,
// States
maybe_free, maybe_free,
maybe_choosing, maybe_choosing,
maybe_attacking, maybe_attacking,
@ -46,18 +62,13 @@ pub fn handle_input(
} else if let Some(states::Choosing { log }) = maybe_choosing && } else if let Some(states::Choosing { log }) = maybe_choosing &&
let Some(mut attack_graph) = maybe_attack_graph { let Some(mut attack_graph) = maybe_attack_graph {
if let Some(next_state) = attack_graph.next(*log) { if let Some(next_state) = attack_graph.next(*log) {
let accuracy = timer.accuracy();
let next_attack = match *log { let next_attack = match *log {
PlayerInput::LightAttack => { PlayerInput::LightAttack => {
states::Attacking { states::Attacking::new(next_state, accuracy, 1., bpm)
phys: next_state,
beats_remaining: 1,
}
}, },
PlayerInput::HeavyAttack => { PlayerInput::HeavyAttack => {
states::Attacking { states::Attacking::new(next_state, accuracy, 2., bpm)
phys: next_state,
beats_remaining: 2,
}
}, },
_ => unreachable!(), _ => unreachable!(),
}; };
@ -65,35 +76,28 @@ pub fn handle_input(
} else { } else {
commands.entity(player_id).insert(Done::Failure); commands.entity(player_id).insert(Done::Failure);
} }
} else if let Some(states::Attacking { phys, beats_remaining }) = maybe_attacking { } else if let Some(mut attacking) = maybe_attacking {
println!("{phys:#?}"); attacking.timer.tick(time.delta());
if *beats_remaining == 0 {
commands.entity(player_id).insert(Done::Success);
}
} else if let Some(states::Awaiting { beats_remaining, .. }) = maybe_awaiting {
if *beats_remaining == 0 {
commands.entity(player_id).insert(Done::Success);
}
}
}
}
// TODO: change beats_remaining to more accurate counting if attacking.timer.just_finished() {
/// Observer that updates temporal states on timer tick info!("{}", attacking.accuracy);
pub fn on_timer_tick( commands.entity(player_id).insert(Done::Success);
_: On<TickEvent>, let Some(weapon) = maybe_weapon else {
player_query: Query<( error!("No weapon provided for player");
Option<&mut states::Attacking>, return;
Option<&mut states::Awaiting>, };
), With<Player>>
) { if let Some(attack_area) = weapon.attack_area(attacking.phys, attacking.accuracy) {
for (maybe_attacking, maybe_awaiting) in player_query { let offset = weapon.attack_offset();
if let Some(mut attacking) = maybe_attacking { commands.entity(player_id)
info!("attack tick"); .with_child(attack_area.into_bundle(offset, sprite.flip_x, GROUP_ENEMY));
attacking.beats_remaining -= 1; }
}
} else if let Some(mut awaiting) = maybe_awaiting { } else if let Some(mut awaiting) = maybe_awaiting {
info!("await tick"); awaiting.timer.tick(time.delta());
awaiting.beats_remaining -= 1; if awaiting.timer.just_finished() {
commands.entity(player_id).insert(Done::Success);
}
} }
} }
} }

View file

@ -1,5 +1,7 @@
//! Player state machine triggers //! Player state machine triggers
use crate::timer::BpmTimer;
use super::*; use super::*;
use super::states::*; use super::states::*;
@ -27,15 +29,25 @@ pub fn from_free_to_choosing(trans: Trans<Free, PlayerInput>) -> Choosing {
/// Returns true if player has [NextAttack] /// Returns true if player has [NextAttack]
pub fn attack_queued(In(player_id): In<Entity>, query: Query<&NextAttack>) -> Option<Attacking> { pub fn attack_queued(In(player_id): In<Entity>, query: Query<&NextAttack>) -> Option<Attacking> {
match query.get(player_id) { match query.get(player_id) {
Ok(next) => Some(next.0), Ok(next) => Some(next.0.clone()),
Err(_) => None, Err(_) => None,
} }
} }
/// Transition from [Choosing] to [Attacking] /// Transition from [Choosing] to [Attacking]
pub fn from_choosing_to_attacking(trans: Trans<Choosing, Attacking>) -> Attacking { trans.out } pub fn from_choosing_to_attacking(trans: Trans<Choosing, Attacking>) -> Attacking { trans.out.clone() }
/// Transition from [Awaiting] to [Choosing] /// Transition from [Awaiting] to [Choosing]
pub fn from_awaiting_to_choosing(trans: Trans<Awaiting, PlayerInput>) -> Choosing { pub fn from_awaiting_to_choosing(trans: Trans<Awaiting, PlayerInput>) -> Choosing {
Choosing { log: trans.out } Choosing { log: trans.out }
} }
/// Trigger that returns [BpmTimer]'s bpm
pub fn get_timer_bpm(query: Query<&BpmTimer>) -> Option<f32> {
query.iter().next().map(|t| t.get_bpm())
}
/// Transition from [Attacking] to [Awaiting]
pub fn from_attacking_to_awaiting(trans: Trans<Attacking, f32>) -> Awaiting {
Awaiting::new(2., trans.out)
}

View file

@ -44,7 +44,7 @@ impl Weapon for Knife {
ActionGraph::from_paths(paths) ActionGraph::from_paths(paths)
} }
fn attack_bundle(&self, attack: ActionNode<AttackType>, accuracy: f32) -> Option<AttackArea> { fn attack_area(&self, attack: ActionNode<AttackType>, accuracy: f32) -> Option<AttackArea> {
let Some(action_type) = attack.action else { let Some(action_type) = attack.action else {
return None; return None;
}; };
@ -57,27 +57,31 @@ impl Weapon for Knife {
let (duration, half_area) = match action_type { let (duration, half_area) = match action_type {
AttackType::HorizontalSwing => {( AttackType::HorizontalSwing => {(
Duration::from_secs(1), Duration::from_millis(500),
vec2(meters(0.5), meters(0.2)), vec2(meters(0.5), meters(0.2)),
)}, )},
AttackType::VerticalSwing => {( AttackType::VerticalSwing => {(
Duration::from_millis(1_500), Duration::from_millis(500),
vec2(meters(0.5), meters(1.)), vec2(meters(0.5), meters(0.5)),
)}, )},
AttackType::RoundSwing => {( AttackType::RoundSwing => {(
Duration::from_secs(1), Duration::from_millis(500),
vec2(meters(0.5), meters(0.2)), vec2(meters(0.5), meters(0.2)),
)}, )},
AttackType::Stab => {( AttackType::Stab => {(
Duration::from_secs(1), Duration::from_millis(500),
vec2(meters(0.5), meters(0.1)), vec2(meters(0.5), meters(0.1)),
)}, )},
AttackType::Lunge => {( AttackType::Lunge => {(
Duration::from_millis(1_500), Duration::from_millis(500),
vec2(meters(0.7), meters(0.1)), vec2(meters(0.7), meters(0.1)),
)}, )},
}; };
Some(AttackArea::new(accuracy, damage, duration, half_area)) Some(AttackArea::new(accuracy, damage, duration, half_area))
} }
fn attack_offset(self: &Self) -> Vec2 {
vec2(meters(0.5), 0.)
}
} }

View file

@ -30,5 +30,8 @@ pub trait Weapon {
fn graph(self: &Self) -> ActionGraph<AttackType, PlayerInput>; fn graph(self: &Self) -> ActionGraph<AttackType, PlayerInput>;
/// Returns [AttackArea] for given attack /// Returns [AttackArea] for given attack
fn attack_bundle(self: &Self, attack: ActionNode<AttackType>, accuracy: f32) -> Option<AttackArea>; fn attack_area(self: &Self, attack: ActionNode<AttackType>, accuracy: f32) -> Option<AttackArea>;
/// Returns [AttackArea] offset for this weapon
fn attack_offset(self: &Self) -> Vec2;
} }

View file

@ -25,6 +25,7 @@ impl Plugin for GamePlugin {
)) ))
.add_systems(Startup, setup) .add_systems(Startup, setup)
.add_systems(Update, ( .add_systems(Update, (
combat::attack::update_attack_areas,
player::systems::handle_input, player::systems::handle_input,
timer::update_bpm_timers, timer::update_bpm_timers,
)) ))

View file

@ -73,6 +73,11 @@ impl BpmTimer {
self.timer.reset(); self.timer.reset();
self.elapsed = 0.; self.elapsed = 0.;
} }
/// Returns how close current state is to rhythm
pub fn accuracy(&self) -> f32 {
(self.timer.fraction() - 0.5).abs() * 2.
}
} }
/// System that ticks each [BpmTimer] and triggers [TickEvent]s /// System that ticks each [BpmTimer] and triggers [TickEvent]s