feat: Dummy enemy and attack hit detection

This commit is contained in:
Alexey 2026-04-21 17:08:33 +03:00
commit a41cf4879e
7 changed files with 140 additions and 14 deletions

32
src/ai/dummy.rs Normal file
View file

@ -0,0 +1,32 @@
//! Dummy enemy module that does nothing
use super::*;
use crate::{GROUP_ATTACK, GROUP_ENEMY, GROUP_FRIENDLY, GROUP_INTERACTIVE, GROUP_STATIC, combat::Health, meters};
/// Dummy enemy. Has [Health] component and no AI logic
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug, Default, Reflect)]
#[reflect(Component, Clone, PartialEq, Debug, Default)]
#[require(Health)]
pub struct Dummy;
impl Dummy {
/// Constructs [Dummy] bundle without sprite
pub fn bundle(position: Vec2, health: f32) -> impl Bundle {
(
// Basic
Dummy,
Health::new(health),
// World
Transform::from_xyz(position.x, position.y, 0.),
// Collision
Collider::cuboid(meters(0.5), meters(0.5)),
CollisionGroups::new(
GROUP_ENEMY,
GROUP_STATIC | GROUP_INTERACTIVE | GROUP_FRIENDLY | GROUP_ATTACK,
),
)
}
}

6
src/ai/mod.rs Normal file
View file

@ -0,0 +1,6 @@
//! Enemy AI module
use bevy::prelude::*;
use bevy_rapier2d::prelude::*;
pub mod dummy;

View file

@ -5,7 +5,7 @@ use std::time::Duration;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_rapier2d::prelude::*; use bevy_rapier2d::prelude::*;
use crate::{GROUP_ATTACK, GROUP_INTERACTIVE, GROUP_STATIC}; use crate::{GROUP_ATTACK, GROUP_INTERACTIVE, GROUP_STATIC, combat::Health};
/// Contains logic for attack-related calculations /// Contains logic for attack-related calculations
#[derive(Component, Clone, Debug, Reflect)] #[derive(Component, Clone, Debug, Reflect)]
@ -27,6 +27,7 @@ pub struct AttackAreaOrigin;
impl AttackArea { impl AttackArea {
/// Constructs new attack area /// Constructs new attack area
#[inline(always)]
pub fn new(accuracy: f32, damage: f32, duration: Duration, half_area: Vec2) -> Self { 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(); let max_distance = ((half_area.x * 2.).powi(2) + half_area.y.powi(2)).sqrt();
Self { Self {
@ -58,6 +59,9 @@ impl AttackArea {
GROUP_STATIC | GROUP_INTERACTIVE | affinity, GROUP_STATIC | GROUP_INTERACTIVE | affinity,
), ),
Collider::cuboid(half_area.x, half_area.y), Collider::cuboid(half_area.x, half_area.y),
ActiveCollisionTypes::all(),
Sleeping::disabled(),
ActiveEvents::COLLISION_EVENTS,
// AttackAreaOrigin // AttackAreaOrigin
Children::spawn(( Children::spawn((
@ -70,11 +74,19 @@ impl AttackArea {
} }
/// Updates timer and returns true if attack has ended /// Updates timer and returns true if attack has ended
#[inline(always)]
pub fn tick(&mut self, dt: Duration) -> bool { pub fn tick(&mut self, dt: Duration) -> bool {
self.timer.tick(dt).is_finished() self.timer.tick(dt).is_finished()
} }
/// Calls [Timer::almost_finish]
#[inline(always)]
pub fn almost_finish(&mut self) {
self.timer.almost_finish();
}
/// Returns damage for given distance to origin /// Returns damage for given distance to origin
#[inline(always)]
pub fn damage(&self, distance_to_origin: f32) -> f32 { pub fn damage(&self, distance_to_origin: f32) -> f32 {
let distance_ratio = 1. - distance_to_origin.min(self.max_distance) / self.max_distance; let distance_ratio = 1. - distance_to_origin.min(self.max_distance) / self.max_distance;
let total_multiplier = self.accuracy * distance_ratio; let total_multiplier = self.accuracy * distance_ratio;
@ -82,11 +94,42 @@ impl AttackArea {
} }
} }
/// System that updates AttackArea timers and despawns those who timed out fn apply_attack(
pub fn update_attack_areas(mut commands: Commands, time: Res<Time>, areas: Query<(Entity, &mut AttackArea)>) { commands: &mut Commands,
for (area_id, mut area) in areas { areas: &mut Query<(Entity, &mut AttackArea)>,
healths: &mut Query<&mut Health>,
first: &Entity,
second: &Entity,
) -> bool {
if let Ok((area_id, mut area)) = areas.get_mut(*first) &&
let Ok(mut health) = healths.get_mut(*second) {
health.apply(area.damage(0.), commands.entity(*second));
info!("{area_id} hit {second}, health: {}", health.to_string());
area.almost_finish();
return true;
}
false
}
/// System that applies attacks and despawns timed out [AttackArea]s
pub fn update_attack_areas(
mut commands: Commands,
time: Res<Time>,
mut areas: Query<(Entity, &mut AttackArea)>,
mut healths: Query<&mut Health>,
mut collision_events: MessageReader<CollisionEvent>,
) {
for (area_id, mut area) in areas.iter_mut() {
if area.tick(time.delta()) { if area.tick(time.delta()) {
commands.entity(area_id).despawn(); commands.entity(area_id).despawn();
} }
} }
for event in collision_events.read() {
if let CollisionEvent::Started(first, second, _) = event {
if !apply_attack(&mut commands, &mut areas, &mut healths, first, second) {
apply_attack(&mut commands, &mut areas, &mut healths, second, first);
}
}
}
} }

View file

@ -1,5 +1,7 @@
//! This module contains core concepts of the combat system //! This module contains core concepts of the combat system
use std::fmt::Display;
use bevy::prelude::*; use bevy::prelude::*;
pub mod attack; pub mod attack;
@ -9,17 +11,57 @@ pub mod attack;
#[reflect(Component, Clone, PartialEq, Debug, Default)] #[reflect(Component, Clone, PartialEq, Debug, Default)]
pub struct Health { pub struct Health {
/// Current health value /// Current health value
pub current: f32, current: f32,
/// Maximum health value /// Maximum health value
pub max: f32, max: f32,
}
/// Event that occurs when entity health reaches zero
#[derive(EntityEvent, Clone, Copy, PartialEq, Eq, Debug, Reflect)]
#[reflect(Event, Clone, PartialEq, Debug)]
pub struct HealthDepletedEvent {
/// Event target
#[event_target] pub entity: Entity,
} }
impl Health { impl Health {
/// Constructs new health component /// Constructs new health component
#[inline(always)]
pub fn new(max: f32) -> Self { pub fn new(max: f32) -> Self {
Self { Self {
current: max, current: max,
max, max,
} }
} }
/// Returns current health
#[inline(always)]
pub fn get_current(&self) -> f32 {
self.current
}
/// Returns max health
#[inline(always)]
pub fn get_max(&self) -> f32 {
self.max
}
/// Applies health change and invokes [HealthDepletedEvent] if needed
pub fn apply(&mut self, amount: f32, mut commands: EntityCommands) {
self.current = (self.current - amount).min(self.max);
if self.current <= 0. {
commands.trigger(|entity| HealthDepletedEvent { entity } );
}
}
}
impl Display for Health {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(format!("{:.2}/{:.2}", self.current, self.max).as_str())
}
}
/// Standard event observer that despawns entity whose health was depleted
pub fn despawn_on_health_depleted(event: On<HealthDepletedEvent>, mut commands: Commands) {
commands.entity(event.entity).despawn();
} }

View file

@ -5,6 +5,7 @@
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub mod ai;
pub mod anim; pub mod anim;
pub mod combat; pub mod combat;
pub mod graph; pub mod graph;
@ -18,7 +19,7 @@ use std::{collections::HashMap, time::Duration};
use bevy::prelude::*; use bevy::prelude::*;
use bevy_rapier2d::prelude::*; use bevy_rapier2d::prelude::*;
use crate::{anim::sprite::{AnimatedSprite, AnimationData}, player::Player, timer::BpmTimer}; use crate::{ai::dummy::Dummy, anim::sprite::{AnimatedSprite, AnimationData}, player::Player, timer::BpmTimer};
const PIXELS_PER_METER: f32 = 16.; const PIXELS_PER_METER: f32 = 16.;
@ -42,11 +43,11 @@ 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));
} }
/// Test function to setup [AnimatedSprite] /// Test function to setup [Dummy] with [AnimatedSprite]
pub fn setup_animated_sprite( pub fn setup_animated_sprite_dummy(
mut commands: Commands, mut commands: Commands,
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>> mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) { ) {
let image = asset_server.load("sprites/animated_placeholder.png"); let image = asset_server.load("sprites/animated_placeholder.png");
@ -58,9 +59,10 @@ pub fn setup_animated_sprite(
data.insert("default".into(), AnimationData::new(image.clone(), layout.clone(), 5.)); data.insert("default".into(), AnimationData::new(image.clone(), layout.clone(), 5.));
let id = commands.spawn(( let id = commands.spawn((
Dummy::bundle(vec2(meters(4.), 0.), 10.),
AnimatedSprite::new(data, Duration::from_millis(500)), AnimatedSprite::new(data, Duration::from_millis(500)),
Sprite::from_atlas_image(image, TextureAtlas { layout: layout, index: 0 }), )).observe(combat::despawn_on_health_depleted)
)).id(); .id();
commands.run_system_cached_with(| commands.run_system_cached_with(|
In(entity_id): In<Entity>, In(entity_id): In<Entity>,

View file

@ -6,7 +6,7 @@ use leafwing_input_manager::prelude::*;
use seldom_state::prelude::*; use seldom_state::prelude::*;
use crate::{ use crate::{
GROUP_ATTACK, GROUP_ENEMY, GROUP_FRIENDLY, GROUP_INTERACTIVE, GROUP_STATIC, graph::action::ActionGraph, input::{ GROUP_ATTACK, GROUP_ENEMY, GROUP_FRIENDLY, GROUP_INTERACTIVE, GROUP_STATIC, combat::Health, graph::action::ActionGraph, input::{
DefaultInputMap, DefaultInputMap,
PlayerInput, PlayerInput,
}, meters, player::weapon::{AttackType, Weapon} }, meters, player::weapon::{AttackType, Weapon}
@ -45,6 +45,7 @@ impl Player {
// Basic // Basic
Name::new("Player"), Name::new("Player"),
Player::default(), Player::default(),
Health::new(100.),
// Visible // Visible
Sprite::from_image(image), Sprite::from_image(image),

View file

@ -26,7 +26,7 @@ impl Plugin for GamePlugin {
.insert_resource(BpmTimer::new(120.)) .insert_resource(BpmTimer::new(120.))
.add_systems(Startup, ( .add_systems(Startup, (
setup, setup,
setup_animated_sprite, setup_animated_sprite_dummy,
)) ))
.add_systems(Update, ( .add_systems(Update, (
anim::sprite::update_animated_sprites, anim::sprite::update_animated_sprites,