From 43fef21d873dfb59b31eeeacfb60f88f61cfa01c Mon Sep 17 00:00:00 2001 From: AlephCubed Date: Sun, 25 Jan 2026 10:27:55 -0800 Subject: [PATCH 1/4] Start on `EffectStacks` component. Still needs testing and/or an example. Could also use more documentation. --- src/component.rs | 5 +++ src/component/stack.rs | 62 ++++++++++++++++++++++++++++++++++++ src/{ => component}/timer.rs | 4 +-- src/lib.rs | 9 +++--- 4 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 src/component.rs create mode 100644 src/component/stack.rs rename src/{ => component}/timer.rs (99%) diff --git a/src/component.rs b/src/component.rs new file mode 100644 index 0000000..059730b --- /dev/null +++ b/src/component.rs @@ -0,0 +1,5 @@ +mod stack; +mod timer; + +pub use stack::*; +pub use timer::*; diff --git a/src/component/stack.rs b/src/component/stack.rs new file mode 100644 index 0000000..3464c00 --- /dev/null +++ b/src/component/stack.rs @@ -0,0 +1,62 @@ +use crate::EffectMergeRegistry; +use bevy_app::{App, Plugin}; +use bevy_ecs::prelude::ReflectComponent; +use bevy_ecs::prelude::{Component, Entity, EntityWorldMut}; +use bevy_reflect::prelude::ReflectDefault; +use bevy_reflect::Reflect; +use std::ops::{Add, AddAssign, Deref, DerefMut}; + +pub(crate) struct StackPlugin; + +impl Plugin for StackPlugin { + fn build(&self, app: &mut App) { + app.world_mut() + .resource_mut::() + .register::(merge_effect_stacks); + } +} + +/// Tracks the number stacks of a [merge effect](crate::EffectMode::Merge) that have been applied to an entity. +#[derive(Component, Reflect, Eq, PartialEq, Ord, PartialOrd, Debug, Copy, Clone)] +#[reflect(Component, Default, PartialEq, Debug, Clone)] +pub struct EffectStacks(pub u8); + +impl Default for EffectStacks { + fn default() -> Self { + Self(1) + } +} + +impl Deref for EffectStacks { + type Target = u8; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for EffectStacks { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Add for EffectStacks { + type Output = Self; + + fn add(self, rhs: u8) -> Self::Output { + Self(self.0 + rhs) + } +} + +impl AddAssign for EffectStacks { + fn add_assign(&mut self, rhs: u8) { + self.0 += rhs + } +} + +/// Merge logic for [`EffectStacks`]. +fn merge_effect_stacks(mut new: EntityWorldMut, outgoing: Entity) { + let outgoing = *new.world().get::(outgoing).unwrap(); + *new.get_mut::().unwrap() += outgoing.0; +} diff --git a/src/timer.rs b/src/component/timer.rs similarity index 99% rename from src/timer.rs rename to src/component/timer.rs index dea46d5..f037da7 100644 --- a/src/timer.rs +++ b/src/component/timer.rs @@ -1,5 +1,5 @@ -use crate::ReflectComponent; use crate::registry::EffectMergeRegistry; +use crate::ReflectComponent; use bevy_app::{App, Plugin, PreUpdate}; use bevy_ecs::component::Mutable; use bevy_ecs::prelude::{Commands, Component, Entity, Query, Res}; @@ -9,7 +9,7 @@ use bevy_reflect::Reflect; use bevy_time::{Time, Timer, TimerMode}; use std::time::Duration; -pub(super) struct TimerPlugin; +pub(crate) struct TimerPlugin; impl Plugin for TimerPlugin { fn build(&self, app: &mut App) { diff --git a/src/lib.rs b/src/lib.rs index 7ac1c06..aa13687 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,20 +2,20 @@ mod bundle; mod command; +mod component; mod registry; mod relation; -mod timer; use bevy_app::{App, Plugin}; use bevy_ecs::prelude::*; -use bevy_reflect::Reflect; use bevy_reflect::prelude::ReflectDefault; +use bevy_reflect::Reflect; pub use bundle::*; pub use command::*; +pub use component::*; pub use registry::*; pub use relation::*; -pub use timer::*; /// Setup required types and systems for `bevy_alchemy`. pub struct AlchemyPlugin; @@ -29,7 +29,8 @@ impl Plugin for AlchemyPlugin { .register_type::() .register_type::() .init_resource::() - .add_plugins(TimerPlugin); + .add_plugins(TimerPlugin) + .add_plugins(StackPlugin); } } From 9eb74fea33efac041719c80feb08889b8f8ceb44 Mon Sep 17 00:00:00 2001 From: AlephCubed Date: Sun, 25 Jan 2026 12:04:28 -0800 Subject: [PATCH 2/4] Added a `poison_falloff` example that uses `EffectStacks`. --- Cargo.toml | 4 + examples/immediate_stats/decaying_speed.rs | 3 +- .../decaying_speed_auto_plugin.rs | 6 +- examples/poison.rs | 4 + examples/poison_falloff.rs | 109 ++++++++++++++++++ src/component/stack.rs | 2 +- src/component/timer.rs | 2 +- src/lib.rs | 2 +- tests/spawn_syntax.rs | 6 +- 9 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 examples/poison_falloff.rs diff --git a/Cargo.toml b/Cargo.toml index b300220..4d3a094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,10 @@ unused_qualifications = "warn" name = "poison" path = "examples/poison.rs" +[[example]] +name = "poison_falloff" +path = "examples/poison_falloff.rs" + [[example]] name = "decaying_speed" path = "examples/immediate_stats/decaying_speed.rs" diff --git a/examples/immediate_stats/decaying_speed.rs b/examples/immediate_stats/decaying_speed.rs index ba1642d..2a95fc9 100644 --- a/examples/immediate_stats/decaying_speed.rs +++ b/examples/immediate_stats/decaying_speed.rs @@ -2,7 +2,8 @@ //! to add a decaying movement speed buff. //! This means that the strength of the buff decreases throughout its duration. //! -//! This uses [`EffectMode::Merge`], which prevents having multiple of the effect applied at the same time (no 10x speed multiplier for you). +//! This uses [`EffectMode::Merge`], which prevents having multiple of the effect applied at the +//! same time (no 10x speed multiplier for you). //! //! There is a second version of this example, which uses Bevy Auto Plugin. diff --git a/examples/immediate_stats/decaying_speed_auto_plugin.rs b/examples/immediate_stats/decaying_speed_auto_plugin.rs index 7c43d24..71c0b5d 100644 --- a/examples/immediate_stats/decaying_speed_auto_plugin.rs +++ b/examples/immediate_stats/decaying_speed_auto_plugin.rs @@ -1,8 +1,10 @@ //! This example shows using [Immediate Stats](https://github.com/AlephCubed/immediate_stats) -//! to add a decaying movement speed buff, using Bevy Auto Plugin (there is a second version of this example which just uses normal Bevy). +//! to add a decaying movement speed buff, using Bevy Auto Plugin +//! (there is a second version of this example which just uses normal Bevy). //! This means that the strength of the buff decreases throughout its duration. //! -//! This uses [`EffectMode::Merge`], which prevents having multiple of the effect applied at the same time (no 10x speed multiplier for you). +//! This uses [`EffectMode::Merge`], which prevents having multiple of the effect applied at the +//! same time (no 10x speed multiplier for you). use bevy::prelude::*; use bevy_alchemy::*; diff --git a/examples/poison.rs b/examples/poison.rs index 045a6c5..f7b4cbe 100644 --- a/examples/poison.rs +++ b/examples/poison.rs @@ -1,4 +1,8 @@ //! A simple damage-over-time effect. +//! +//! Each application of the effect is its own entity, meaning an entity can be poisoned multiple times. +//! This can be changed by using a different [`EffectMode`](bevy_alchemy::EffectMode). +//! The `poison_falloff` example shows a different way to handle effect stacking. use bevy::prelude::*; use bevy_alchemy::{ diff --git a/examples/poison_falloff.rs b/examples/poison_falloff.rs new file mode 100644 index 0000000..f15ae8e --- /dev/null +++ b/examples/poison_falloff.rs @@ -0,0 +1,109 @@ +//! A damage-over-time effect where the damage falls off as more stacks are added. +//! +//! When an entity is already poisoned, subsequent applications deal less damage. +//! In this case the first stack deals 5 damage, the next 4, then 3, and so on. +//! +//! This works by [merging](EffectMode::Merge) the effects into a single entity and using the +//! [number of stacks](EffectStacks) in damage calculations. +//! A slightly simpler version is available in the `poison` example. + +use bevy::prelude::*; +use bevy_alchemy::{ + AlchemyPlugin, Delay, EffectBundle, EffectCommandsExt, EffectMode, EffectStacks, EffectTimer, + Effecting, Lifetime, +}; + +fn main() { + App::new() + .add_plugins((DefaultPlugins, AlchemyPlugin)) + .add_systems(Startup, init_scene) + .add_systems(Update, (on_space_pressed, deal_poison_damage)) + .add_systems(PostUpdate, update_ui) + .run(); +} + +#[derive(Component)] +struct Health(i32); + +/// Deals damage over time to the target entity. +#[derive(Component, Default)] +struct Poison { + damage: i32, +} + +/// Spawn a target on startup. +fn init_scene(mut commands: Commands) { + commands.spawn((Name::new("Target"), Health(500))); + commands.spawn(Text::default()); + commands.spawn(Camera2d); +} + +/// When space is pressed, apply poison to the target. +fn on_space_pressed( + mut commands: Commands, + keyboard_input: Res>, + target: Single>, +) { + if !keyboard_input.just_pressed(KeyCode::Space) { + return; + } + + commands.entity(*target).with_effect(EffectBundle { + mode: EffectMode::Merge, // Stack tracking requires effect merging. + bundle: ( + EffectStacks::default(), // Enable stack tracking. + Lifetime::from_seconds(4.0), // The duration of the effect. + Delay::from_seconds(1.0), // The time between damage ticks. + Poison { damage: 5 }, // The amount of damage to apply per tick. + ), + ..default() + }); +} + +/// Runs every frame and deals the poison damage. +fn deal_poison_damage( + effects: Query<(&Effecting, &EffectStacks, &Delay, &Poison)>, + mut targets: Query<&mut Health>, +) { + for (target, stacks, delay, poison) in effects { + // We wait until the delay finishes to apply the damage. + if !delay.timer.is_finished() { + continue; + } + + // Skip if the target doesn't have health. + let Ok(mut health) = targets.get_mut(target.0) else { + continue; + }; + + // Otherwise, deal the damage scaled with the number of stacks. + // Each subsequent stack has a decreasing effect, the first deals 5 damage, the next 4, then 3, and so on. + let stacks = poison.damage.min(stacks.0 as i32); // Clamp stacks to prevent negative damage. + let sub = (stacks * (stacks - 1)) / 2; + let damage = (poison.damage * stacks - sub).max(0); + + info!("Dealt {damage} damage!"); + + health.0 -= damage; + } +} + +fn update_ui( + mut ui: Single<&mut Text>, + target: Single<&Health>, + effects: Query<(Entity, &EffectStacks, &Lifetime, &Delay), With>, +) { + ui.0 = "Press Space to apply poison\n\n".to_string(); + + ui.0 += &format!("Health: {}\n\n", target.0); + + for (entity, stacks, lifetime, delay) in &effects { + ui.0 += &format!( + "{}, {} stacks - {:.1}s (tick in {:.1}s)\n", + entity, + stacks.0, + lifetime.timer.remaining_secs(), + delay.timer.remaining_secs() + ); + } +} diff --git a/src/component/stack.rs b/src/component/stack.rs index 3464c00..ab717b1 100644 --- a/src/component/stack.rs +++ b/src/component/stack.rs @@ -2,8 +2,8 @@ use crate::EffectMergeRegistry; use bevy_app::{App, Plugin}; use bevy_ecs::prelude::ReflectComponent; use bevy_ecs::prelude::{Component, Entity, EntityWorldMut}; -use bevy_reflect::prelude::ReflectDefault; use bevy_reflect::Reflect; +use bevy_reflect::prelude::ReflectDefault; use std::ops::{Add, AddAssign, Deref, DerefMut}; pub(crate) struct StackPlugin; diff --git a/src/component/timer.rs b/src/component/timer.rs index f037da7..73e3db3 100644 --- a/src/component/timer.rs +++ b/src/component/timer.rs @@ -1,5 +1,5 @@ -use crate::registry::EffectMergeRegistry; use crate::ReflectComponent; +use crate::registry::EffectMergeRegistry; use bevy_app::{App, Plugin, PreUpdate}; use bevy_ecs::component::Mutable; use bevy_ecs::prelude::{Commands, Component, Entity, Query, Res}; diff --git a/src/lib.rs b/src/lib.rs index aa13687..7771492 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,8 +8,8 @@ mod relation; use bevy_app::{App, Plugin}; use bevy_ecs::prelude::*; -use bevy_reflect::prelude::ReflectDefault; use bevy_reflect::Reflect; +use bevy_reflect::prelude::ReflectDefault; pub use bundle::*; pub use command::*; diff --git a/tests/spawn_syntax.rs b/tests/spawn_syntax.rs index 4a33c19..3e7d5ba 100644 --- a/tests/spawn_syntax.rs +++ b/tests/spawn_syntax.rs @@ -28,7 +28,7 @@ fn spawnable_list_stack() { let effects: Vec = world .query::<&MyEffect>() - .iter(&mut world) + .iter(&world) .map(|c| c.0) .collect(); @@ -60,7 +60,7 @@ fn spawnable_list_insert() { let effects: Vec = world .query::<&MyEffect>() - .iter(&mut world) + .iter(&world) .map(|c| c.0) .collect(); @@ -100,7 +100,7 @@ fn spawnable_list_mixed() { let effects: Vec = world .query::<&MyEffect>() - .iter(&mut world) + .iter(&world) .map(|c| c.0) .collect(); From 5f0cd7662dade59edf657893566850e936c38635 Mon Sep 17 00:00:00 2001 From: AlephCubed Date: Sun, 25 Jan 2026 12:09:45 -0800 Subject: [PATCH 3/4] Update version number. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eaa4097..92ced39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,7 +338,7 @@ dependencies = [ [[package]] name = "bevy_alchemy" -version = "0.2.0" +version = "0.2.1" dependencies = [ "bevy", "bevy_app", diff --git a/Cargo.toml b/Cargo.toml index 4d3a094..40c8feb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bevy_alchemy" -version = "0.2.0" +version = "0.2.1" edition = "2024" description = "An experimental, status effects-as-entities system for Bevy." categories = ["game-development"] From ed30d03c8d911a3505b6eb6637aeee551e9b6804 Mon Sep 17 00:00:00 2001 From: AlephCubed Date: Sun, 25 Jan 2026 15:25:50 -0800 Subject: [PATCH 4/4] Add `poison_falloff` to examples readme. --- examples/README.md | 9 +++++---- examples/poison_falloff.rs | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/README.md b/examples/README.md index 4c0caeb..d456cd9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,12 +1,13 @@ # Examples -| Example | Description | -|-----------------------|-----------------------------------| -| [`poison`](poison.rs) | A simple damage-over-time effect. | +| Example | Description | +|---------------------------------------|--------------------------------------------------------------------------------| +| [`poison`](poison.rs) | A simple damage-over-time effect. | +| [`poison_falloff`](poison_falloff.rs) | A damage-over-time effect where the damage falls off as more stacks are added. | ## Immediate Stats Examples in the `immediate_stats` subdirectory utilize the [`immediate_stats`](https://github.com/AlephCubed/immediate_stats) crate, which I also created. -Some of these examples include a copy that utilizes [`bevy_auto_plugin`](https://github.com/StrikeForceZero/bevy_auto_plugin), which should behave exactly the same. +Some of these examples include a version that utilizes [`bevy_auto_plugin`](https://github.com/StrikeForceZero/bevy_auto_plugin), which should behave exactly the same. | Example | Description | |--------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------| diff --git a/examples/poison_falloff.rs b/examples/poison_falloff.rs index f15ae8e..e27a6b5 100644 --- a/examples/poison_falloff.rs +++ b/examples/poison_falloff.rs @@ -80,7 +80,7 @@ fn deal_poison_damage( // Each subsequent stack has a decreasing effect, the first deals 5 damage, the next 4, then 3, and so on. let stacks = poison.damage.min(stacks.0 as i32); // Clamp stacks to prevent negative damage. let sub = (stacks * (stacks - 1)) / 2; - let damage = (poison.damage * stacks - sub).max(0); + let damage = poison.damage * stacks - sub; info!("Dealt {damage} damage!");