discontinued

This commit is contained in:
Alexey 2026-04-29 13:34:14 +03:00
commit e8b1499bba
10 changed files with 370 additions and 12 deletions

106
src/ai/jumper/mod.rs Normal file
View file

@ -0,0 +1,106 @@
//! Jumper enemy module
use super::*;
use states::*;
use triggers::*;
use crate::{GROUP_ATTACK, GROUP_ENEMY, GROUP_FRIENDLY, GROUP_INTERACTIVE, GROUP_STATIC, ai::jumper::states::Approaching, combat::Health, curve::BallisticCurve, meters, timer::WantsTickEvent};
pub mod states;
pub mod systems;
pub mod triggers;
/// Applies impulse to entity using its direction to [AiTarget]
#[derive(Clone, Copy, PartialEq, Debug, Default, Reflect)]
#[reflect(Clone, PartialEq, Debug, Default)]
pub struct ApplyDirectedImpulse {
impulse: Vec2,
torque_impulse: f32,
}
impl EntityCommand for ApplyDirectedImpulse {
fn apply(self, mut entity: EntityWorldMut) -> () {
let Some(entity_t) = entity.get::<Transform>() else {
return;
};
let Some(AiTarget(target_id)) = entity.get::<AiTarget>() else {
return;
};
let Some(target_t) = entity.world().get::<Transform>(*target_id) else {
return;
};
let direction = (target_t.translation.x - entity_t.translation.x).signum();
let impulse = ExternalImpulse {
impulse: self.impulse.with_x(self.impulse.x * direction),
torque_impulse: self.torque_impulse,
};
info!("Sent {impulse:#?}");
entity.insert(impulse);
}
}
/// Jumper enemy
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug, Default, Reflect)]
#[reflect(Component, Clone, PartialEq, Debug, Default)]
#[require(Health, StateMachine, Transform, WantsTickEvent)]
pub struct Jumper;
impl Jumper {
/// Constructs [Jumper] bundle
pub fn bundle(position: Vec2, health: f32) -> impl Bundle {
(
// Basic
Jumper,
Health::new(health),
// World
Transform::from_xyz(position.x, position.y, 0.),
// Collision
RigidBody::Dynamic,
LockedAxes::ROTATION_LOCKED,
Collider::cuboid(meters(0.5), meters(0.5)),
ActiveEvents::COLLISION_EVENTS,
ActiveCollisionTypes::all(),
CollisionGroups::new(
GROUP_ENEMY,
GROUP_STATIC | GROUP_INTERACTIVE | GROUP_FRIENDLY | GROUP_ATTACK,
),
Velocity::zero(),
Friction {
coefficient: 0.,
combine_rule: CoefficientCombineRule::Min,
},
// StateMachine
states::Idle::default(),
StateMachine::default()
// On enter Idle
.on_enter::<Idle>(|e| {
e.insert(Velocity::zero());
})
// Idle -> Approaching
.trans::<Idle, _>(target_in_range(Idle::MIN_DISTANCE), Approaching)
// Approaching -> Attacking
.trans::<Approaching, _>(
ticked
.ignore_and(target_in_range(Approaching::MIN_DISTANCE).not())
.ignore_and(target_in_range(Approaching::MAX_DISTANCE)),
Attacking::new(),
)
// On enter Attacking
.on_enter::<Attacking>(|e| {
e.insert(Velocity::zero());
e.queue(ApplyDirectedImpulse {
impulse: vec2(12000., 9000.),
..default()
});
})
// Approaching -> Idle
.trans::<Approaching, _>(target_in_range(Approaching::MAX_RANGE).not(), Idle)
// Attacking -> Approaching
.trans::<Attacking, _>(done(None), Approaching)
.set_trans_logging(true),
)
}
}

51
src/ai/jumper/states.rs Normal file
View file

@ -0,0 +1,51 @@
//! Jumper states
use bevy::time::Stopwatch;
use super::*;
/// Entity awaits until [AiTarget] comes closer, then transits to [Approaching]
#[derive(Component, Clone, Copy, PartialEq, Debug, Default, Reflect)]
#[reflect(Component, Clone, PartialEq, Debug, Default)]
pub struct Idle;
impl Idle {
/// Minimal distance required for next state in pixels
pub const MIN_DISTANCE: f32 = meters(4.);
}
/// Approaches distance, required to jump, awaits for next [BpmTimer](crate::timer::BpmTimer) tick, then transits to [Attacking]
/// If target distances from entity very far, transits to [Idle]
#[derive(Component, Clone, Copy, PartialEq, Debug, Reflect, Default)]
#[reflect(Component, Clone, PartialEq, Debug, Default)]
pub struct Approaching;
impl Approaching {
/// Minimal distance in pixels, when entity should transit to [Attacking]
pub const MIN_DISTANCE: f32 = meters(2.);
/// Maximal distance in pixels, when entity should transit to [Attacking]
pub const MAX_DISTANCE: f32 = meters(3.);
/// Distance in pixels, when entity should transit to [Idle]
pub const MAX_RANGE: f32 = meters(6.);
/// Approaching speed, px/s
pub const SPEED: f32 = meters(0.5);
}
/// Jumps at the [AiTarget]
/// If target was reached and jumper was not hit, it deals damage
/// Transits to [Approaching] when reaches the floor
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component, Clone, Debug)]
pub struct Attacking {
/// May deal damage to [AiTarget]
pub may_attack: bool,
}
impl Attacking {
/// Constructs new [Attacking] state
pub fn new() -> Self {
Self { may_attack: true }
}
/// Damage applied to the [AiTarget]
pub const DAMAGE: f32 = 20.;
}

78
src/ai/jumper/systems.rs Normal file
View file

@ -0,0 +1,78 @@
//! Jumper systems
use std::time::Duration;
use crate::combat::attack::AttackArea;
use super::*;
/// Update in [Approaching] or [Attacking] state
pub fn update(
mut commands: Commands,
time: Res<Time>,
jumpers: Query<(
Entity,
&Transform,
&mut Sprite,
&AiTarget,
&mut Velocity,
Option<&Approaching>,
Option<&mut Attacking>,
), With<Jumper>>,
targets: Query<&Transform, Without<Jumper>>,
mut collision_events: MessageReader<CollisionEvent>,
) {
for (
jumper_id,
transform,
mut sprite,
AiTarget(target_id),
mut velocity,
maybe_approaching,
maybe_attacking
) in jumpers {
if maybe_approaching.is_some() {
let Ok(target_transform) = targets.get(*target_id) else {
error!("Entity {target_id} does not exist, but is target for jumper {jumper_id}");
return;
};
let distance = transform.translation.x - target_transform.translation.x;
let (direction_sign, flip_x) = if distance.abs() <= Approaching::MIN_DISTANCE + 1. { (1., false) }
else { (-1., true) };
let x_velocity = Approaching::SPEED * direction_sign;
velocity.linvel.x = x_velocity;
sprite.flip_x = flip_x;
} else if let Some(mut attacking) = maybe_attacking {
for event in collision_events.read() {
if let CollisionEvent::Started(first, second, _) = event {
info!("{first} hit {second}");
if first == &jumper_id && second == target_id ||
first == target_id && second == &jumper_id {
info!("jumper hit target");
if attacking.may_attack {
attacking.may_attack = false;
let attack_area = AttackArea::new(
1.,
Attacking::DAMAGE,
Duration::from_secs(1),
vec2(meters(0.6), 1.)
);
commands.entity(jumper_id).with_child(attack_area.into_bundle(
vec2(0., 0.),
false,
GROUP_FRIENDLY,
));
}
} else {
commands.entity(jumper_id).insert(Done::Success);
}
}
}
}
}
}

26
src/ai/jumper/triggers.rs Normal file
View file

@ -0,0 +1,26 @@
//! Jumper state machine triggers
use crate::timer::{BpmTimer, TickEvent};
use super::*;
/// Returns true if entity currently has [TickEvent]
pub fn ticked(In(entity): In<Entity>, query: Query<Has<TickEvent>>) -> bool {
query.get(entity).map_or(false, |has_event| has_event)
}
/// Returns signed difference in position.x to target if it is in the given range
pub fn target_in_range(min_distance: f32) -> impl EntityTrigger<Out = Option<f32>> {
(move |In(my_id): In<Entity>, transforms: Query<&Transform>, targets: Query<&AiTarget>| {
let Ok(AiTarget(target)) = targets.get(my_id) else {
return None;
};
transforms.get(*target).map_or(None, |t1| {
transforms.get(my_id).map_or(None, |t2| {
let result = t1.translation.x - t2.translation.x;
if result.abs() <= min_distance { Some(result) } else { None }
})
})
}).into_trigger()
}

View file

@ -2,5 +2,12 @@
use bevy::prelude::*;
use bevy_rapier2d::prelude::*;
use seldom_state::prelude::*;
pub mod dummy;
pub mod jumper;
/// Component for marking target entity for enemies
#[derive(Component, Clone, Copy, PartialEq, Eq, Deref, DerefMut, Debug, Reflect)]
#[reflect(Component, Clone, PartialEq, Debug)]
pub struct AiTarget(pub Entity);

39
src/curve.rs Normal file
View file

@ -0,0 +1,39 @@
//! Specialized curves module
use bevy::prelude::*;
/// Curve that calculates [Vec2] velocity based on simple ballistics
#[derive(Clone, Copy, Debug, Reflect)]
#[reflect(Clone, Debug)]
pub struct BallisticCurve {
x_velocity: f32,
y_velocity: f32,
gravity: f32,
}
impl BallisticCurve {
/// Constructs new [BallisticCurve] using velocity and angle
pub fn new(velocity: f32, angle: f32, gravity: f32) -> Self {
let x_velocity = velocity * angle.cos();
let y_velocity = velocity * angle.sin();
Self { x_velocity, y_velocity, gravity }
}
/// Constructs new [BallisticCurve] using distance to highest point and time to reach it
pub fn from_point(point: Vec2, time: f32, gravity: f32) -> Self {
let x_velocity = point.x / time;
let y_velocity = point.y / time - gravity * time / 2.;
Self { x_velocity, y_velocity, gravity }
}
}
impl Curve<Vec2> for BallisticCurve {
fn domain(&self) -> Interval {
Interval::new(0., f32::INFINITY).unwrap()
}
fn sample_unchecked(&self, t: f32) -> Vec2 {
let y_velocity = self.y_velocity * t + self.gravity * t.powi(2) / 2.;
vec2(self.x_velocity, y_velocity)
}
}

View file

@ -8,6 +8,7 @@ mod tests;
pub mod ai;
pub mod anim;
pub mod combat;
pub mod curve;
pub mod graph;
pub mod input;
pub mod player;
@ -19,7 +20,7 @@ use std::{collections::HashMap, time::Duration};
use bevy::prelude::*;
use bevy_rapier2d::prelude::*;
use crate::{ai::dummy::Dummy, anim::sprite::{AnimatedSprite, AnimationData}, player::Player, timer::BpmTimer};
use crate::{ai::{AiTarget, jumper::Jumper}, anim::sprite::{AnimatedSprite, AnimationData}, player::Player, timer::BpmTimer};
const PIXELS_PER_METER: f32 = 16.;
@ -41,9 +42,17 @@ pub const GROUP_ATTACK: Group = Group::GROUP_5;
/// 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((
Collider::cuboid(meters(8.), meters(0.5)),
Friction {
coefficient: 0.,
combine_rule: CoefficientCombineRule::Min,
},
Transform::from_xyz(0., meters(-1.5), 0.),
));
}
/// Test function to setup [Dummy] with [AnimatedSprite]
/// Test function to setup [Jumper] with [AnimatedSprite]
pub fn setup_animated_sprite_dummy(
mut commands: Commands,
asset_server: Res<AssetServer>,
@ -59,7 +68,7 @@ pub fn setup_animated_sprite_dummy(
data.insert("default".into(), AnimationData::new(image.clone(), layout.clone(), 5.));
let id = commands.spawn((
Dummy::bundle(vec2(meters(4.), 0.), 10.),
Jumper::bundle(vec2(meters(6.), meters(-0.65625)), 10.),
AnimatedSprite::new(data, Duration::from_millis(500)),
)).observe(combat::despawn_on_health_depleted)
.id();
@ -67,10 +76,13 @@ pub fn setup_animated_sprite_dummy(
commands.run_system_cached_with(|
In(entity_id): In<Entity>,
mut commands: Commands,
mut query: Query<&mut AnimatedSprite>,
mut anim_query: Query<(&mut AnimatedSprite, &mut Transform)>,
player: Single<Entity, With<Player>>,
| {
let mut animation = query.get_mut(entity_id).unwrap();
let (mut animation, mut transform) = anim_query.get_mut(entity_id).unwrap();
animation.play("default", TimerMode::Repeating, commands.entity(entity_id).entry::<Sprite>());
transform.scale.y = 0.5;
commands.entity(entity_id).insert(AiTarget(*player));
}, id);
}

View file

@ -104,8 +104,7 @@ impl Player {
PlayerInput::LightAttack,
]),
triggers::from_awaiting_to_choosing,
)
.set_trans_logging(true),
),
// Weapon
weapon::knife::Knife::default(),

View file

@ -26,14 +26,20 @@ impl Plugin for GamePlugin {
.insert_resource(BpmTimer::new(120.))
.add_systems(Startup, (
setup,
setup_animated_sprite_dummy,
setup_animated_sprite_dummy.after(setup),
))
.add_systems(PreUpdate, (
combat::attack::update_attack_areas,
))
.add_systems(Update, (
ai::jumper::systems::update,
anim::sprite::update_animated_sprites,
combat::attack::update_attack_areas,
player::systems::handle_input,
timer::update_bpm_timer,
))
.add_systems(PostUpdate, (
timer::remove_tick_events,
))
.register_component_as::<dyn Weapon, knife::Knife>();
}
}

View file

@ -35,6 +35,7 @@ impl BpmTimer {
}
/// Updates internal timer and returns true if it just ticked
#[inline(always)]
pub fn tick(&mut self, dt: Duration) -> bool {
let ticked = self.timer.tick(dt).just_finished();
if ticked { self.elapsed += self.timer.times_finished_this_tick() as f32; }
@ -46,6 +47,12 @@ impl BpmTimer {
pub fn beats_elapsed(&self) -> f32 {
self.elapsed + self.timer.fraction()
}
/// Returns internal timer duration
#[inline(always)]
pub fn get_duration(&self) -> Duration {
self.timer.duration()
}
/// Get beats per minute value of timer
#[inline(always)]
@ -54,23 +61,50 @@ impl BpmTimer {
}
/// Set new BPM value
#[inline(always)]
pub fn set_bpm(&mut self, new_bpm: f32) {
self.timer.set_duration(duration_from_bpm(new_bpm));
}
/// Resets elapsed beats
#[inline(always)]
pub fn reset(&mut self) {
self.timer.reset();
self.elapsed = 0.;
}
/// Returns how close current state is to rhythm
#[inline(always)]
pub fn accuracy(&self) -> f32 {
(self.timer.fraction() - 0.5).abs() * 2.
}
}
/// System that ticks [BpmTimer]
pub fn update_bpm_timer(time: Res<Time>, mut timer: ResMut<BpmTimer>) {
timer.tick(time.delta());
/// Indicates that [BpmTimer] just ticked
#[derive(Component, Event, Clone, Copy, PartialEq, Eq, Debug, Default, Reflect)]
#[reflect(Component, Event, Clone, PartialEq, Debug, Default)]
pub struct TickEvent;
/// Indicated that entity will receive [TickEvent] components
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug, Default, Reflect)]
#[reflect(Component, Clone, PartialEq, Debug, Default)]
pub struct WantsTickEvent;
/// System that ticks [BpmTimer] and inserts [TickEvent] for entities with [WantsTickEvent]
pub fn update_bpm_timer(
mut commands: Commands,
time: Res<Time>,
mut timer: ResMut<BpmTimer>,
query: Query<Entity, With<WantsTickEvent>>,
) {
if timer.tick(time.delta()) {
commands.trigger(TickEvent);
info!("Tick");
query.iter().for_each(|e| { commands.entity(e).insert(TickEvent); } );
};
}
/// Remove [TickEvent] from each entity. Runs in [PostUpdate]
pub fn remove_tick_events(mut commands: Commands, query: Query<Entity, With<TickEvent>>) {
query.iter().for_each(|e| { commands.entity(e).remove::<TickEvent>(); } );
}