generated from 2ndbeam/bevy-template
Compare commits
2 commits
f3b2d0313e
...
a41cf4879e
| Author | SHA1 | Date | |
|---|---|---|---|
| a41cf4879e | |||
| be36504a00 |
8 changed files with 195 additions and 21 deletions
32
src/ai/dummy.rs
Normal file
32
src/ai/dummy.rs
Normal 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
6
src/ai/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
//! Enemy AI module
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_rapier2d::prelude::*;
|
||||
|
||||
pub mod dummy;
|
||||
|
|
@ -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();
|
||||
|
|
@ -76,14 +84,54 @@ impl AnimatedSprite {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
14
src/lib.rs
14
src/lib.rs
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue