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}