Skip to main content

ai_duel/
lib.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2026 Jorge Andre Castro <georgeandrec@gmail.com>
3
4//! Plugin [Bevy](https://bevyengine.org) pour un duel joueur / IA.
5//!
6//! Deux combattants (ou plus, avec plusieurs ennemis), de la santé, des
7//! factions pour savoir qui peut blesser qui, et une IA simple qui fonce sur
8//! le joueur et attaque dès qu'elle est à portée.
9//!
10//! # Ce que le plugin gère
11//!
12//! - le comportement de l'IA ;
13//! - les animations (course / attaque) ;
14//! - la hitbox d'attaque, les dégâts et l'invulnérabilité après un coup ;
15//! - la mort des combattants.
16//!
17//! # Ce qu'il ne gère pas
18//!
19//! - le chargement des assets : vous devez construire la ressource
20//!   [`FighterAssets`] ;
21//! - l'input du joueur : appelez [`start_attack`] depuis votre propre système,
22//!   planifié `.before(`[`AiDuelSet`]`)`.
23//!
24//! # Organisation du code
25//!
26//! | Module         | Contenu                                         |
27//! |----------------|-------------------------------------------------|
28//! | `components`   | configuration, assets, composants               |
29//! | `systems`      | systèmes exécutés par le plugin                 |
30//! | racine (`lib`) | plugin, [`AiDuelSet`], [`spawn_fighter`], [`start_attack`] |
31//!
32//! # Exemple minimal
33//!
34//! ```no_run
35//! use bevy::prelude::*;
36//! use ai_duel::prelude::*;
37//!
38//! fn main() {
39//!     App::new()
40//!         .add_plugins(DefaultPlugins)
41//!         .add_plugins(AiDuelPlugin)
42//!         .add_systems(Startup, setup)
43//!         .run();
44//! }
45//!
46//! fn setup(
47//!     mut commands: Commands,
48//!     asset_server: Res<AssetServer>,
49//!     mut layouts: ResMut<Assets<TextureAtlasLayout>>,
50//!     config: Res<AiDuelConfig>,
51//! ) {
52//!     commands.spawn(Camera2d);
53//!
54//!     let assets = FighterAssets {
55//!         run_sheet: asset_server.load("run.png"),
56//!         run_layout: layouts.add(TextureAtlasLayout::from_grid(
57//!             UVec2::new(96, 80), 8, 1, None, None)),
58//!         attack_sheet: asset_server.load("attack.png"),
59//!         attack_layout: layouts.add(TextureAtlasLayout::from_grid(
60//!             UVec2::new(96, 80), 6, 1, None, None)),
61//!     };
62//!
63//!     // le joueur, à gauche
64//!     spawn_fighter(&mut commands, &assets, &config,
65//!         Vec3::new(-250.0, 0.0, 0.0), Faction::Player, None);
66//!
67//!     // un ennemi piloté par l'IA, à droite
68//!     spawn_fighter(&mut commands, &assets, &config,
69//!         Vec3::new(250.0, 0.0, 0.0), Faction::Enemy,
70//!         Some(AiController::default()));
71//!
72//!     commands.insert_resource(assets);
73//! }
74//! ```
75
76#![forbid(unsafe_code)]
77#![warn(missing_docs)]
78
79pub mod components;
80pub mod systems;
81
82pub use components::*;
83
84use bevy::prelude::*;
85
86/// Import pratique : `use ai_duel::prelude::*;`.
87pub mod prelude {
88    pub use crate::components::*;
89    pub use crate::{spawn_fighter, start_attack, AiDuelPlugin, AiDuelSet};
90}
91
92/// Ensemble de systèmes du duel, exécutés dans l'ordre
93/// cooldowns → IA → animation → hitbox → collisions → santé → mort.
94///
95/// Utilisez-le pour ordonner vos propres systèmes par rapport au plugin :
96///
97/// ```no_run
98/// # use bevy::prelude::*;
99/// # use ai_duel::{AiDuelPlugin, AiDuelSet};
100/// # fn player_input() {}
101/// # fn main() {
102/// App::new()
103///     .add_plugins(AiDuelPlugin)
104///     .add_systems(Update, player_input.before(AiDuelSet));
105/// # }
106/// ```
107#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
108pub struct AiDuelSet;
109
110/// À ajouter après `DefaultPlugins`. Enregistre la configuration par défaut
111/// ([`AiDuelConfig`]) et tous les systèmes du duel.
112///
113/// Les systèmes sont chaînés (`.chain()`) car chaque étape dépend du résultat
114/// de la précédente.
115pub struct AiDuelPlugin;
116
117impl Plugin for AiDuelPlugin {
118    fn build(&self, app: &mut App) {
119        app.init_resource::<AiDuelConfig>().add_systems(
120            Update,
121            (
122                systems::tick_cooldowns,
123                systems::ai_behavior,
124                systems::animate_fighters,
125                systems::update_hitboxes,
126                systems::resolve_hits,
127                systems::tick_health_and_flash,
128                systems::check_death,
129            )
130                .chain()
131                .in_set(AiDuelSet),
132        );
133    }
134}
135
136/// Bascule un combattant en attaque : change l'animation et remet le compteur
137/// de frame à zéro. Ne fait rien s'il attaque déjà (une attaque n'est pas
138/// interruptible).
139///
140/// À appeler depuis votre système d'input pour piloter le joueur.
141pub fn start_attack(
142    state: &mut CurrentState,
143    anim: &mut AnimationTimer,
144    sprite: &mut Sprite,
145    assets: &FighterAssets,
146    config: &AiDuelConfig,
147) {
148    if state.0 == FighterState::Attacking {
149        return;
150    }
151    state.0 = FighterState::Attacking;
152    anim.frame_index = 0;
153    anim.max_frames = config.attack_frames.max(1);
154    anim.timer = Timer::from_seconds(config.attack_frame_time, TimerMode::Repeating);
155
156    if let Some(atlas) = &mut sprite.texture_atlas {
157        atlas.layout = assets.attack_layout.clone();
158        atlas.index = 0;
159    }
160    sprite.image = assets.attack_sheet.clone();
161}
162
163/// Spawne un combattant complet (sprite, vie, hurtbox, cooldown, état...) et
164/// sa hitbox en enfant, inactive au départ.
165///
166/// Passez `ai = Some(...)` pour un ennemi piloté automatiquement, ou `None`
167/// pour un combattant que vous contrôlez vous-même (typiquement le joueur).
168/// Retourne l'entité du combattant.
169pub fn spawn_fighter(
170    commands: &mut Commands,
171    assets: &FighterAssets,
172    config: &AiDuelConfig,
173    pos: Vec3,
174    faction: Faction,
175    ai: Option<AiController>,
176) -> Entity {
177    let mut fighter = commands.spawn((
178        Sprite::from_atlas_image(
179            assets.run_sheet.clone(),
180            TextureAtlas {
181                layout: assets.run_layout.clone(),
182                index: 0,
183            },
184        ),
185        Transform::from_translation(pos).with_scale(Vec3::splat(config.sprite_scale)),
186        CurrentState(FighterState::Running),
187        AnimationTimer {
188            timer: Timer::from_seconds(config.run_frame_time, TimerMode::Repeating),
189            frame_index: 0,
190            max_frames: config.run_frames.max(1),
191        },
192        faction,
193        Health::full(config.default_max_health),
194        Hurtbox { half_size: config.hurtbox_half_size },
195        AttackCooldown(0.0),
196        WantsToMove(false),
197    ));
198
199    if faction == Faction::Player {
200        fighter.insert(Player);
201    }
202    if let Some(ai) = ai {
203        fighter.insert(ai);
204    }
205    let parent = fighter.id();
206
207    // Le décalage est exprimé en unités monde ; la transformation locale est
208    // divisée par l'échelle du parent, qui la multiplie (et la retourne
209    // automatiquement quand le sprite est tourné vers la gauche).
210    let local_offset = config.hitbox_offset / config.sprite_scale.max(f32::EPSILON);
211
212    let mut hitbox = commands.spawn((
213        Transform::from_xyz(local_offset.x, local_offset.y, 0.5),
214        Hitbox {
215            owner: parent,
216            faction,
217            half_size: config.hitbox_half_size,
218            damage: config.attack_damage,
219            active: false,
220        },
221    ));
222    if config.debug_hitboxes {
223        hitbox.insert(Sprite {
224            color: Color::srgba(1.0, 0.2, 0.2, 0.35),
225            custom_size: Some(config.hitbox_half_size * 2.0 / config.sprite_scale),
226            ..default()
227        });
228    }
229    let hitbox = hitbox.id();
230
231    commands.entity(parent).add_child(hitbox);
232    parent
233}