An experimental, status effects-as-entities system for Bevy.
1//! A damage-over-time effect where the damage falls off as more stacks are added.
2//!
3//! When an entity is already poisoned, subsequent applications deal less damage.
4//! In this case the first stack deals 5 damage, the next 4, then 3, and so on.
5//!
6//! This works by [merging](EffectMode::Merge) the effects into a single entity and using the
7//! [number of stacks](EffectStacks) in damage calculations.
8//! A slightly simpler version is available in the `poison` example.
9
10use bevy::prelude::*;
11use bevy_alchemy::{
12 AlchemyPlugin, Delay, EffectCommandsExt, EffectMode, EffectStacks, EffectTimer, Effecting,
13 Lifetime,
14};
15
16fn main() {
17 App::new()
18 .add_plugins((DefaultPlugins, AlchemyPlugin))
19 .add_systems(Startup, init_scene)
20 .add_systems(Update, (on_space_pressed, deal_poison_damage))
21 .add_systems(PostUpdate, update_ui)
22 .run();
23}
24
25#[derive(Component)]
26struct Health(i32);
27
28/// Deals damage over time to the target entity.
29#[derive(Component, Default, Clone)]
30struct Poison {
31 damage: i32,
32}
33
34/// Spawn a target on startup.
35fn init_scene(mut commands: Commands) {
36 commands.spawn((Name::new("Target"), Health(500)));
37 commands.spawn((
38 Node {
39 margin: UiRect::all(Val::Px(10.0)),
40 ..default()
41 },
42 Text::default(),
43 ));
44 commands.spawn(Camera2d);
45}
46
47/// When space is pressed, apply poison to the target.
48fn on_space_pressed(
49 mut commands: Commands,
50 keyboard_input: Res<ButtonInput<KeyCode>>,
51 target: Single<Entity, With<Health>>,
52) {
53 if !keyboard_input.just_pressed(KeyCode::Space) {
54 return;
55 }
56
57 commands.entity(*target).with_effect((
58 EffectMode::Merge, // Stack tracking requires effect merging.
59 EffectStacks::default(), // Enable stack tracking.
60 Lifetime::from_seconds(3.0), // The duration of the effect.
61 Delay::from_seconds(1.0) // The time between damage ticks.
62 .trigger_immediately(), // Make damage tick immediately when the effect is applied.
63 Poison { damage: 5 }, // The amount of damage to apply per tick.
64 ));
65}
66
67/// Runs every frame and deals the poison damage.
68fn deal_poison_damage(
69 effects: Query<(&Effecting, &EffectStacks, &Delay, &Poison)>,
70 mut targets: Query<&mut Health>,
71) {
72 for (target, stacks, delay, poison) in effects {
73 // We wait until the delay finishes to apply the damage.
74 if !delay.timer.is_finished() {
75 continue;
76 }
77
78 // Skip if the target doesn't have health.
79 let Ok(mut health) = targets.get_mut(target.0) else {
80 continue;
81 };
82
83 // Otherwise, deal the damage scaled with the number of stacks.
84 // Each subsequent stack has a decreasing effect, the first deals 5 damage, the next 4, then 3, and so on.
85 let stacks = poison.damage.min(stacks.0 as i32); // Clamp stacks to prevent negative damage.
86 let sub = (stacks * (stacks - 1)) / 2;
87 let damage = poison.damage * stacks - sub;
88
89 info!("Dealt {damage} damage!");
90
91 health.0 -= damage;
92 }
93}
94
95fn update_ui(
96 mut ui: Single<&mut Text>,
97 target: Single<&Health>,
98 effects: Query<(Entity, &EffectStacks, &Lifetime, &Delay), With<Poison>>,
99) {
100 ui.0 = "Press Space to apply poison\n\n".to_string();
101
102 ui.0 += &format!("Health: {}\n\n", target.0);
103
104 for (entity, stacks, lifetime, delay) in &effects {
105 ui.0 += &format!(
106 "{}, {} stacks - {:.1}s (tick in {:.1}s)\n",
107 entity,
108 stacks.0,
109 lifetime.timer.remaining_secs(),
110 delay.timer.remaining_secs()
111 );
112 }
113}