Compare commits

...

2 commits

Author SHA1 Message Date
a41cf4879e feat: Dummy enemy and attack hit detection 2026-04-21 17:08:33 +03:00
be36504a00 feat: More AnimatedSprite methods 2026-04-21 13:09:03 +03:00
8 changed files with 195 additions and 21 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,6 +5,8 @@ use std::{collections::HashMap, time::Duration};
use bevy::prelude::*;
use bevy_ecs::system::EntityEntryCommands;
type SpriteCommands<'a> = EntityEntryCommands<'a, Sprite>;
/// Data for [AnimatedSprite]
#[derive(Clone, PartialEq, Debug, Default, Reflect)]
#[reflect(Clone, PartialEq, Debug, Default)]
@ -19,6 +21,7 @@ pub struct AnimationData {
impl AnimationData {
/// Construct new [AnimationData] from given [Handle<TextureAtlasLayout>]
#[inline(always)]
pub fn new(image: Handle<Image>, frames: Handle<TextureAtlasLayout>, duration_multiplier: f32) -> Self {
Self { image, frames, duration_multiplier }
}
@ -33,36 +36,41 @@ pub struct AnimatedSprite {
current: Option<AnimationData>,
timer: Timer,
duration: Duration,
backwards: bool,
}
impl AnimatedSprite {
/// Constructs new AnimatedSprite
#[inline(always)]
pub fn new(data: HashMap<String, AnimationData>, duration: Duration) -> Self {
let timer = Timer::new(duration, TimerMode::Once);
Self { data, current: None, timer, duration }
Self { data, current: None, timer, duration, backwards: false }
}
/// Add new animation to sprite
#[inline(always)]
pub fn add(&mut self, data: AnimationData, label: String) {
self.data.insert(label, data);
}
/// Sets internal duration
#[inline(always)]
pub fn set_duration(&mut self, duration: Duration) {
self.duration = duration;
}
/// Finds animation by its label and returns it if started playing
pub fn play(
fn play_inner(
&mut self,
label: &str,
mode: TimerMode,
mut sprite: EntityEntryCommands<Sprite>,
mut sprite: SpriteCommands,
backwards: bool,
) -> Option<&AnimationData> {
self.data.get(label).map(|data| {
self.current = Some(data.clone());
self.timer = Timer::new(self.duration.mul_f32(data.duration_multiplier), mode);
self.backwards = backwards;
let sprite_image = data.image.clone();
let layout = data.frames.clone();
@ -75,15 +83,55 @@ impl AnimatedSprite {
data
})
}
/// Finds animation by its label, plays it and returns it if started playing
#[inline(always)]
pub fn play(
&mut self,
label: &str,
mode: TimerMode,
sprite: SpriteCommands,
) -> Option<&AnimationData> {
self.play_inner(label, mode, sprite, false)
}
/// Same as [play](AnimatedSprite::play), but animation plays from the end to the beginning
#[inline(always)]
pub fn play_backwards(
&mut self,
label: &str,
mode: TimerMode,
sprite: SpriteCommands,
) -> Option<&AnimationData> {
self.play_inner(label, mode, sprite, true)
}
/// Stops the animation
#[inline(always)]
pub fn stop(&mut self) {
self.current = None;
self.timer.reset();
}
/// Updates animation timer
#[inline(always)]
pub fn tick(&mut self, dt: Duration) {
self.timer.tick(dt);
}
/// Returns inner [Timer::fraction]
/// Returns inner [Timer::fraction] or [Timer::fraction_remaining] if playing backwards
#[inline(always)]
pub fn fraction(&self) -> f32 {
self.timer.fraction()
match self.backwards {
true => self.timer.fraction_remaining(),
false => self.timer.fraction(),
}
}
/// Returns [Timer::mode]
#[inline(always)]
pub fn mode(&self) -> TimerMode {
self.timer.mode()
}
}
@ -107,7 +155,7 @@ pub fn update_animated_sprites(
let max_frames = atlas.len() as f32;
let current_frame = (max_frames * animation.fraction()) as usize;
let current_frame = (max_frames * animation.fraction()).min(max_frames - 1.) as usize;
if let Some(atlas) = &mut sprite.texture_atlas {
atlas.index = current_frame;

View file

@ -5,7 +5,7 @@ use std::time::Duration;
use bevy::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
#[derive(Component, Clone, Debug, Reflect)]
@ -27,6 +27,7 @@ pub struct AttackAreaOrigin;
impl AttackArea {
/// Constructs new attack area
#[inline(always)]
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 {
@ -58,6 +59,9 @@ impl AttackArea {
GROUP_STATIC | GROUP_INTERACTIVE | affinity,
),
Collider::cuboid(half_area.x, half_area.y),
ActiveCollisionTypes::all(),
Sleeping::disabled(),
ActiveEvents::COLLISION_EVENTS,
// AttackAreaOrigin
Children::spawn((
@ -70,11 +74,19 @@ impl AttackArea {
}
/// Updates timer and returns true if attack has ended
#[inline(always)]
pub fn tick(&mut self, dt: Duration) -> bool {
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
#[inline(always)]
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;
@ -82,11 +94,42 @@ impl AttackArea {
}
}
/// System that updates AttackArea timers and despawns those who timed out
pub fn update_attack_areas(mut commands: Commands, time: Res<Time>, areas: Query<(Entity, &mut AttackArea)>) {
for (area_id, mut area) in areas {
fn apply_attack(
commands: &mut Commands,
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()) {
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
use std::fmt::Display;
use bevy::prelude::*;
pub mod attack;
@ -9,17 +11,57 @@ pub mod attack;
#[reflect(Component, Clone, PartialEq, Debug, Default)]
pub struct Health {
/// Current health value
pub current: f32,
current: f32,
/// 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 {
/// Constructs new health component
#[inline(always)]
pub fn new(max: f32) -> Self {
Self {
current: 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)]
mod tests;
pub mod ai;
pub mod anim;
pub mod combat;
pub mod graph;
@ -18,7 +19,7 @@ use std::{collections::HashMap, time::Duration};
use bevy::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.;
@ -42,11 +43,11 @@ pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Player::bundle(&asset_server, Vec2::ZERO));
}
/// Test function to setup [AnimatedSprite]
pub fn setup_animated_sprite(
/// Test function to setup [Dummy] with [AnimatedSprite]
pub fn setup_animated_sprite_dummy(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>>
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
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.));
let id = commands.spawn((
Dummy::bundle(vec2(meters(4.), 0.), 10.),
AnimatedSprite::new(data, Duration::from_millis(500)),
Sprite::from_atlas_image(image, TextureAtlas { layout: layout, index: 0 }),
)).id();
)).observe(combat::despawn_on_health_depleted)
.id();
commands.run_system_cached_with(|
In(entity_id): In<Entity>,

View file

@ -6,7 +6,7 @@ use leafwing_input_manager::prelude::*;
use seldom_state::prelude::*;
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,
PlayerInput,
}, meters, player::weapon::{AttackType, Weapon}
@ -45,6 +45,7 @@ impl Player {
// Basic
Name::new("Player"),
Player::default(),
Health::new(100.),
// Visible
Sprite::from_image(image),

View file

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