use bevy::prelude::*;
#[derive(Resource, Clone, Debug)]
pub struct AiDuelConfig {
pub run_frames: usize,
pub run_frame_time: f32,
pub attack_frames: usize,
pub attack_frame_time: f32,
pub attack_cooldown: f32,
pub hit_cooldown: f32,
pub attack_damage: f32,
pub hitbox_active_from: usize,
pub hitbox_active_to: usize,
pub hitbox_half_size: Vec2,
pub default_max_health: f32,
pub sprite_scale: f32,
}
impl Default for AiDuelConfig {
fn default() -> Self {
Self {
run_frames: 8,
run_frame_time: 0.10,
attack_frames: 6,
attack_frame_time: 0.08,
attack_cooldown: 0.60,
hit_cooldown: 0.40,
attack_damage: 10.0,
hitbox_active_from: 2,
hitbox_active_to: 4,
hitbox_half_size: Vec2::new(35.0, 30.0),
default_max_health: 100.0,
sprite_scale: 1.5,
}
}
}
#[derive(Resource, Clone, Debug)]
pub struct FighterAssets {
pub run_sheet: Handle<Image>,
pub run_layout: Handle<TextureAtlasLayout>,
pub attack_sheet: Handle<Image>,
pub attack_layout: Handle<TextureAtlasLayout>,
}
#[derive(Component, Debug)]
pub struct Player;
#[derive(Component, PartialEq, Eq, Clone, Copy, Debug)]
pub enum Faction {
Player,
Enemy,
}
#[derive(Component, Debug)]
pub struct Health {
pub current: f32,
pub max: f32,
pub hit_cooldown: f32,
}
impl Health {
pub fn full(max: f32) -> Self {
Self { current: max, max, hit_cooldown: 0.0 }
}
}
#[derive(Component, Debug)]
pub struct AttackCooldown(pub f32);
#[derive(Component, Debug)]
pub struct Hitbox {
pub owner: Entity,
pub faction: Faction,
pub half_size: Vec2,
pub damage: f32,
pub active: bool,
}
#[derive(Component, Debug)]
pub struct AiController {
pub aggression_range: f32,
pub stop_range: f32,
pub speed: f32,
}
#[derive(Component, Debug)]
pub struct WantsToMove(pub bool);
#[derive(Component, Debug)]
pub struct AnimationTimer {
pub timer: Timer,
pub frame_index: usize,
pub max_frames: usize,
}
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
pub enum FighterState {
Running,
Attacking,
}
#[derive(Component, Debug)]
pub struct CurrentState(pub FighterState);
pub struct AiDuelPlugin;
impl Plugin for AiDuelPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AiDuelConfig>()
.add_systems(
Update,
(
systems::tick_cooldowns,
systems::ai_behavior,
systems::animate_fighters,
systems::update_hitboxes,
systems::resolve_hits,
systems::tick_health_and_flash,
systems::check_death,
)
.chain(),
);
}
}
pub fn start_attack(
state: &mut CurrentState,
anim: &mut AnimationTimer,
sprite: &mut Sprite,
assets: &FighterAssets,
config: &AiDuelConfig,
) {
if state.0 == FighterState::Attacking {
return;
}
state.0 = FighterState::Attacking;
anim.frame_index = 0;
anim.max_frames = config.attack_frames;
anim.timer = Timer::from_seconds(config.attack_frame_time, TimerMode::Repeating);
if let Some(atlas) = &mut sprite.texture_atlas {
atlas.layout = assets.attack_layout.clone();
atlas.index = 0;
}
sprite.image = assets.attack_sheet.clone();
}
pub fn spawn_fighter(
commands: &mut Commands,
assets: &FighterAssets,
config: &AiDuelConfig,
pos: Vec3,
faction: Faction,
ai: Option<AiController>,
) -> Entity {
let mut ec = commands.spawn((
Sprite::from_atlas_image(
assets.run_sheet.clone(),
TextureAtlas {
layout: assets.run_layout.clone(),
index: 0,
},
),
Transform::from_translation(pos).with_scale(Vec3::splat(config.sprite_scale)),
CurrentState(FighterState::Running),
AnimationTimer {
timer: Timer::from_seconds(config.run_frame_time, TimerMode::Repeating),
frame_index: 0,
max_frames: config.run_frames,
},
faction,
Health::full(config.default_max_health),
AttackCooldown(0.0),
WantsToMove(false),
));
if faction == Faction::Player {
ec.insert(Player);
}
if let Some(ai) = ai {
ec.insert(ai);
}
let parent = ec.id();
let hitbox = commands
.spawn((
Sprite {
color: Color::srgba(1.0, 0.2, 0.2, 0.0),
custom_size: Some(config.hitbox_half_size * 2.0),
..default()
},
Transform::from_xyz(0.0, 0.0, 0.5),
Hitbox {
owner: parent,
faction,
half_size: config.hitbox_half_size,
damage: config.attack_damage,
active: false,
},
))
.id();
commands.entity(parent).add_child(hitbox);
parent
}
pub mod systems {
use super::*;
pub fn tick_cooldowns(time: Res<Time>, mut q: Query<&mut AttackCooldown>) {
for mut cd in &mut q {
cd.0 = (cd.0 - time.delta_secs()).max(0.0);
}
}
pub fn ai_behavior(
time: Res<Time>,
assets: Res<FighterAssets>,
config: Res<AiDuelConfig>,
mut ai_q: Query<
(
&mut Transform,
&mut CurrentState,
&mut AnimationTimer,
&mut Sprite,
&mut WantsToMove,
&mut AttackCooldown,
&AiController,
),
Without<Player>,
>,
player_q: Query<&Transform, With<Player>>,
) {
let Ok(player_tf) = player_q.single() else {
return;
};
for (mut tf, mut state, mut anim, mut sprite, mut wants, mut cd, ai) in &mut ai_q {
if state.0 == FighterState::Attacking {
wants.0 = false;
continue;
}
let to_player = player_tf.translation.truncate() - tf.translation.truncate();
let dist = to_player.length();
if dist < ai.aggression_range && cd.0 <= 0.0 {
crate::start_attack(&mut state, &mut anim, &mut sprite, &assets, &config);
cd.0 = config.attack_cooldown;
wants.0 = false;
continue;
}
if dist > ai.stop_range {
let dir = to_player.normalize_or_zero();
tf.translation.x += dir.x * ai.speed * time.delta_secs();
tf.translation.y += dir.y * ai.speed * time.delta_secs();
wants.0 = true;
if dir.x.abs() > 0.1 {
tf.scale.x = config.sprite_scale * dir.x.signum();
}
} else {
wants.0 = false;
}
}
}
pub fn animate_fighters(
time: Res<Time>,
assets: Res<FighterAssets>,
config: Res<AiDuelConfig>,
mut q: Query<(
&mut AnimationTimer,
&mut Sprite,
&mut CurrentState,
&WantsToMove,
)>,
) {
for (mut anim, mut sprite, mut state, wants) in &mut q {
anim.timer.tick(time.delta());
if !anim.timer.just_finished() {
continue;
}
anim.frame_index += 1;
if state.0 == FighterState::Attacking {
if anim.frame_index >= anim.max_frames {
state.0 = FighterState::Running;
anim.frame_index = 0;
anim.max_frames = config.run_frames;
anim.timer =
Timer::from_seconds(config.run_frame_time, TimerMode::Repeating);
if let Some(atlas) = &mut sprite.texture_atlas {
atlas.layout = assets.run_layout.clone();
atlas.index = 0;
}
sprite.image = assets.run_sheet.clone();
} else if let Some(atlas) = &mut sprite.texture_atlas {
atlas.index = anim.frame_index;
}
} else if wants.0 {
anim.frame_index %= anim.max_frames;
if let Some(atlas) = &mut sprite.texture_atlas {
atlas.index = anim.frame_index;
}
}
}
}
pub fn update_hitboxes(
config: Res<AiDuelConfig>,
mut hitbox_q: Query<&mut Hitbox>,
fighter_q: Query<(&CurrentState, &AnimationTimer)>,
) {
for mut hb in &mut hitbox_q {
let Ok((state, anim)) = fighter_q.get(hb.owner) else {
continue;
};
hb.active = state.0 == FighterState::Attacking
&& anim.frame_index >= config.hitbox_active_from
&& anim.frame_index <= config.hitbox_active_to;
}
}
pub fn resolve_hits(
hitbox_q: Query<(&Hitbox, &GlobalTransform)>,
mut target_q: Query<(&GlobalTransform, &Faction, &mut Health)>,
) {
for (hb, hb_tf) in &hitbox_q {
if !hb.active {
continue;
}
let center = hb_tf.translation().truncate();
let half = hb.half_size;
for (tf, faction, mut hp) in &mut target_q {
if *faction == hb.faction {
continue; }
if hp.current <= 0.0 || hp.hit_cooldown > 0.0 {
continue; }
let p = tf.translation().truncate();
let overlap =
(p.x - center.x).abs() < half.x && (p.y - center.y).abs() < half.y;
if overlap {
hp.current = (hp.current - hb.damage).max(0.0);
hp.hit_cooldown = 0.4;
info!(
"Touché ! HP restants : {:.0}/{:.0}",
hp.current, hp.max
);
}
}
}
}
pub fn tick_health_and_flash(time: Res<Time>, mut q: Query<(&mut Health, &mut Sprite)>) {
for (mut hp, mut sprite) in &mut q {
if hp.hit_cooldown > 0.0 {
hp.hit_cooldown = (hp.hit_cooldown - time.delta_secs()).max(0.0);
sprite.color = Color::srgba(1.0, 0.3, 0.3, 0.85);
} else {
sprite.color = Color::WHITE;
}
}
}
pub fn check_death(mut commands: Commands, q: Query<(Entity, &Health)>) {
for (e, hp) in &q {
if hp.current <= 0.0 {
info!("Un combattant est tombé !");
commands.entity(e).despawn();
}
}
}
}