Skip to main content

steel_core/entity/entities/objects/effects/
area_effect_cloud.rs

1//! Lingering cloud that applies its potion effects to living entities standing inside it.
2
3use std::sync::{Arc, Weak};
4
5use glam::DVec3;
6use rustc_hash::FxHashMap;
7use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView;
8use simdnbt::owned::NbtCompound;
9use simdnbt::{FromNbtTag as _, ToNbtTag as _};
10use steel_macros::entity_behavior;
11use steel_registry::MobEffectInstance as RegistryMobEffectInstance;
12use steel_registry::blocks::behavior::PushReaction;
13use steel_registry::data_components::{PotionContents, vanilla_components};
14use steel_registry::entity_data::EntityPose;
15use steel_registry::entity_type::{EntityDimensions, EntityTypeRef};
16use steel_registry::item_stack::ItemStack;
17use steel_registry::particle_type::{ColorParticleOption, ParticleData};
18use steel_registry::vanilla_entity_data::AreaEffectCloudEntityData;
19use steel_registry::vanilla_particle_types;
20use steel_utils::locks::SyncMutex;
21use steel_utils::nbt::NbtValueInput as _;
22use steel_utils::{DowncastType, DowncastTypeKey, RgbColor};
23
24use crate::behavior::MOB_EFFECT_BEHAVIORS;
25use crate::entity::damage::DamageSource;
26use crate::entity::potion_contents::to_runtime_instance;
27use crate::entity::{
28    Entity, EntityBase, EntityBaseLoad, EntityReference, EntitySyncedData, LivingEntity,
29    RemovalReason, SharedEntity,
30};
31use crate::world::World;
32
33/// Ticks between two effect application passes.
34const TIME_BETWEEN_APPLICATIONS: i32 = 5;
35const MAX_RADIUS: f32 = 32.0;
36/// The cloud is discarded once its radius shrinks below this.
37const MINIMAL_RADIUS: f32 = 0.5;
38const DEFAULT_RADIUS: f32 = 3.0;
39const HEIGHT: f32 = 0.5;
40/// `Duration` value for a cloud that never expires.
41const INFINITE_DURATION: i32 = -1;
42const DEFAULT_WAIT_TIME: i32 = 20;
43const DEFAULT_REAPPLICATION_DELAY: i32 = 20;
44const DEFAULT_POTION_DURATION_SCALE: f32 = 1.0;
45/// Scale passed to instantaneous effects (instant health/damage) applied by the cloud.
46const INSTANTANEOUS_EFFECT_SCALE: f64 = 0.5;
47
48/// Server-side cloud state that is persisted but not synced.
49struct AreaEffectCloudState {
50    potion_contents: PotionContents,
51    potion_duration_scale: f32,
52    /// Entity id to the tick count at which it may be affected again.
53    victims: FxHashMap<i32, i32>,
54    duration: i32,
55    wait_time: i32,
56    reapplication_delay: i32,
57    duration_on_use: i32,
58    radius_on_use: f32,
59    radius_per_tick: f32,
60    owner: Option<EntityReference>,
61}
62
63impl AreaEffectCloudState {
64    fn new() -> Self {
65        Self {
66            potion_contents: PotionContents::empty(),
67            potion_duration_scale: DEFAULT_POTION_DURATION_SCALE,
68            victims: FxHashMap::default(),
69            duration: INFINITE_DURATION,
70            wait_time: DEFAULT_WAIT_TIME,
71            reapplication_delay: DEFAULT_REAPPLICATION_DELAY,
72            duration_on_use: 0,
73            radius_on_use: 0.0,
74            radius_per_tick: 0.0,
75            owner: None,
76        }
77    }
78}
79
80/// An area effect cloud, spawned by lingering potions.
81// TODO: Persist `custom_particle` once particle options have an NBT codec. Vanilla
82// only sets it for dragon breath and commands; potion clouds always derive their
83// particle from the potion color.
84#[entity_behavior(class = "AreaEffectCloud")]
85pub struct AreaEffectCloudEntity {
86    base: EntityBase,
87    entity_type: EntityTypeRef,
88    entity_data: SyncMutex<AreaEffectCloudEntityData>,
89    state: SyncMutex<AreaEffectCloudState>,
90}
91
92// SAFETY: This key is owned by Steel and uniquely identifies `AreaEffectCloudEntity`.
93unsafe impl DowncastType for AreaEffectCloudEntity {
94    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/area_effect_cloud");
95}
96
97impl AreaEffectCloudEntity {
98    /// Creates a new area effect cloud with vanilla defaults.
99    #[must_use]
100    pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
101        Self {
102            base: EntityBase::new(id, position, entity_type.dimensions, world),
103            entity_type,
104            entity_data: SyncMutex::new(AreaEffectCloudEntityData::new()),
105            state: SyncMutex::new(AreaEffectCloudState::new()),
106        }
107    }
108
109    /// Creates an area effect cloud from saved base data.
110    #[must_use]
111    pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
112        Self {
113            base: EntityBase::from_load(load, entity_type.dimensions),
114            entity_type,
115            entity_data: SyncMutex::new(AreaEffectCloudEntityData::new()),
116            state: SyncMutex::new(AreaEffectCloudState::new()),
117        }
118    }
119
120    /// Returns the current radius.
121    #[must_use]
122    pub fn radius(&self) -> f32 {
123        *self.entity_data.lock().radius.get()
124    }
125
126    /// Sets the radius, clamped to `0..=32`, and resizes the bounding box.
127    pub fn set_radius(&self, radius: f32) {
128        self.entity_data
129            .lock()
130            .radius
131            .set(radius.clamp(0.0, MAX_RADIUS));
132        self.refresh_dimensions();
133    }
134
135    /// Returns whether the cloud is still in its initial wait period.
136    #[must_use]
137    pub fn is_waiting(&self) -> bool {
138        *self.entity_data.lock().waiting.get()
139    }
140
141    fn set_waiting(&self, waiting: bool) {
142        self.entity_data.lock().waiting.set(waiting);
143    }
144
145    /// Sets the potion contents and updates the synced particle color.
146    pub fn set_potion_contents(&self, contents: PotionContents) {
147        let color = contents.get_color();
148        self.state.lock().potion_contents = contents;
149        self.update_particle(color);
150    }
151
152    /// Sets the duration scale applied to the potion's effects.
153    pub fn set_potion_duration_scale(&self, scale: f32) {
154        self.state.lock().potion_duration_scale = scale;
155    }
156
157    fn update_particle(&self, potion_color: i32) {
158        let color = RgbColor::new(potion_color).with_alpha(u8::MAX);
159        self.entity_data.lock().particle.set(ParticleData::new(
160            &vanilla_particle_types::ENTITY_EFFECT,
161            ColorParticleOption::new(color),
162        ));
163    }
164
165    /// Returns the duration in ticks, or `-1` for a cloud that never expires.
166    #[must_use]
167    pub fn duration(&self) -> i32 {
168        self.state.lock().duration
169    }
170
171    /// Sets the duration in ticks, counted after the wait time.
172    pub fn set_duration(&self, duration: i32) {
173        self.state.lock().duration = duration;
174    }
175
176    /// Sets the radius change applied each time an entity is affected.
177    pub fn set_radius_on_use(&self, radius_on_use: f32) {
178        self.state.lock().radius_on_use = radius_on_use;
179    }
180
181    /// Sets the radius change applied every tick after the wait time.
182    pub fn set_radius_per_tick(&self, radius_per_tick: f32) {
183        self.state.lock().radius_per_tick = radius_per_tick;
184    }
185
186    /// Sets the number of ticks before the cloud starts applying effects.
187    pub fn set_wait_time(&self, wait_time: i32) {
188        self.state.lock().wait_time = wait_time;
189    }
190
191    /// Sets the living entity credited for the cloud's effects.
192    pub fn set_owner(&self, owner: Option<&SharedEntity>) {
193        self.state.lock().owner = owner
194            .filter(|owner| owner.as_living_entity().is_some())
195            .map(EntityReference::from_entity);
196    }
197
198    /// Resolves the owner, if it is a loaded living entity.
199    #[must_use]
200    pub fn get_owner(&self) -> Option<SharedEntity> {
201        let world = self.level()?;
202        let owner = self.state.lock().owner.clone()?;
203        owner
204            .get_entity(&world)
205            .filter(|owner| owner.as_living_entity().is_some())
206    }
207
208    fn server_tick(self: &Arc<Self>, world: &World) {
209        let tick_count = self.tick_count();
210        let (duration, wait_time, radius_per_tick) = {
211            let state = self.state.lock();
212            (state.duration, state.wait_time, state.radius_per_tick)
213        };
214        if duration != INFINITE_DURATION && tick_count.wrapping_sub(wait_time) >= duration {
215            self.set_removed(RemovalReason::Discarded);
216            return;
217        }
218
219        let should_wait = tick_count < wait_time;
220        if self.is_waiting() != should_wait {
221            self.set_waiting(should_wait);
222        }
223        if should_wait {
224            return;
225        }
226
227        let mut radius = self.radius();
228        if radius_per_tick != 0.0 {
229            radius += radius_per_tick;
230            if radius < MINIMAL_RADIUS {
231                self.set_removed(RemovalReason::Discarded);
232                return;
233            }
234            self.set_radius(radius);
235        }
236
237        if tick_count % TIME_BETWEEN_APPLICATIONS == 0 {
238            self.apply_effects(world, tick_count, radius);
239        }
240    }
241
242    /// Applies the cloud's effects to every eligible living entity inside it.
243    fn apply_effects(self: &Arc<Self>, world: &World, tick_count: i32, mut radius: f32) {
244        let (effects, reapplication_delay) = {
245            let mut state = self.state.lock();
246            state.victims.retain(|_, until| tick_count < *until);
247            if !state.potion_contents.has_effects() {
248                state.victims.clear();
249                return;
250            }
251            let scale = state.potion_duration_scale;
252            let effects: Vec<_> = state
253                .potion_contents
254                .all_effects()
255                .iter()
256                .map(|effect| effect.with_scaled_duration(scale))
257                .collect();
258            (effects, state.reapplication_delay)
259        };
260
261        let entities = world.get_entities_in_aabb_matching(&self.bounding_box(), |entity| {
262            !entity.is_spectator() && entity.as_living_entity().is_some()
263        });
264        if entities.is_empty() {
265            return;
266        }
267
268        let this: SharedEntity = Arc::<Self>::clone(self);
269        let owner = self.get_owner();
270        let position = self.position();
271
272        for entity in entities {
273            let Some(living) = entity.as_living_entity() else {
274                continue;
275            };
276            if self.state.lock().victims.contains_key(&entity.id())
277                || !living.is_affected_by_potions()
278                || !effects.iter().any(|effect| {
279                    living.can_be_affected(&to_runtime_instance(effect, effect.duration()))
280                })
281            {
282                continue;
283            }
284
285            let offset = entity.position() - position;
286            let dist = offset.x * offset.x + offset.z * offset.z;
287            if dist > f64::from(radius * radius) {
288                continue;
289            }
290
291            let (radius_on_use, duration_on_use) = {
292                let mut state = self.state.lock();
293                state
294                    .victims
295                    .insert(entity.id(), tick_count.wrapping_add(reapplication_delay));
296                (state.radius_on_use, state.duration_on_use)
297            };
298
299            for effect in &effects {
300                self.apply_effect(world, living, effect, &this, owner.as_ref());
301            }
302
303            if radius_on_use != 0.0 {
304                radius += radius_on_use;
305                if radius < MINIMAL_RADIUS {
306                    self.set_removed(RemovalReason::Discarded);
307                    return;
308                }
309                self.set_radius(radius);
310            }
311
312            if duration_on_use != 0 && self.add_duration(duration_on_use) {
313                self.set_removed(RemovalReason::Discarded);
314                return;
315            }
316        }
317    }
318
319    fn apply_effect(
320        &self,
321        world: &World,
322        living: &dyn LivingEntity,
323        effect: &RegistryMobEffectInstance,
324        this: &SharedEntity,
325        owner: Option<&SharedEntity>,
326    ) {
327        let behavior = MOB_EFFECT_BEHAVIORS.get_behavior(effect.effect());
328        if let Some(instantaneous) = behavior.as_instantaneous() {
329            instantaneous.apply_instantaneous(
330                world,
331                living,
332                effect.amplifier(),
333                Some(this),
334                owner,
335                INSTANTANEOUS_EFFECT_SCALE,
336            );
337        } else {
338            living.add_mob_effect_with_source(
339                to_runtime_instance(effect, effect.duration()),
340                Some(self as &dyn Entity),
341            );
342        }
343    }
344
345    /// Adds `delta` to a finite duration, returning whether the cloud has expired.
346    fn add_duration(&self, delta: i32) -> bool {
347        let mut state = self.state.lock();
348        if state.duration == INFINITE_DURATION {
349            return false;
350        }
351        state.duration = state.duration.wrapping_add(delta);
352        state.duration <= 0
353    }
354}
355
356impl Entity for AreaEffectCloudEntity {
357    fn base(&self) -> &EntityBase {
358        &self.base
359    }
360
361    fn entity_type(&self) -> EntityTypeRef {
362        self.entity_type
363    }
364
365    fn tick(self: Arc<Self>) {
366        self.default_tick();
367        // Client-side particle rendering is driven by the synced radius, waiting
368        // flag and particle, so only the server tick runs here.
369        if let Some(world) = self.level() {
370            self.server_tick(&world);
371        }
372    }
373
374    fn no_physics(&self) -> bool {
375        true
376    }
377
378    fn piston_push_reaction(&self) -> PushReaction {
379        PushReaction::IgnoreEntity
380    }
381
382    fn dimensions_for_pose(&self, _pose: EntityPose) -> EntityDimensions {
383        EntityDimensions::with_default_eye_height(self.radius() * 2.0, HEIGHT)
384    }
385
386    fn hurt(&self, _world: &World, _source: &DamageSource, _amount: f32) -> bool {
387        false
388    }
389
390    fn restore_owner_reference(&self, owner: &SharedEntity) {
391        if let Some(reference) = &self.state.lock().owner {
392            reference.cache_entity(owner);
393        }
394    }
395
396    fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
397        Some(&self.entity_data)
398    }
399
400    fn apply_implicit_item_components(&self, item_stack: &ItemStack) {
401        if let Some(contents) = item_stack.get(vanilla_components::POTION_CONTENTS) {
402            self.set_potion_contents(contents.clone());
403        }
404        if let Some(scale) = item_stack.get(vanilla_components::POTION_DURATION_SCALE) {
405            self.set_potion_duration_scale(*scale);
406        }
407    }
408
409    fn save_additional(&self, nbt: &mut NbtCompound) {
410        nbt.insert("Age", self.tick_count());
411        let state = self.state.lock();
412        nbt.insert("Duration", state.duration);
413        nbt.insert("WaitTime", state.wait_time);
414        nbt.insert("ReapplicationDelay", state.reapplication_delay);
415        nbt.insert("DurationOnUse", state.duration_on_use);
416        nbt.insert("RadiusOnUse", state.radius_on_use);
417        nbt.insert("RadiusPerTick", state.radius_per_tick);
418        nbt.insert("Radius", self.radius());
419        if let Some(owner) = &state.owner {
420            owner.store(nbt, "Owner");
421        }
422        if state.potion_contents != PotionContents::empty() {
423            nbt.insert(
424                "potion_contents",
425                state.potion_contents.clone().to_nbt_tag(),
426            );
427        }
428        if state.potion_duration_scale != DEFAULT_POTION_DURATION_SCALE {
429            nbt.insert("potion_duration_scale", state.potion_duration_scale);
430        }
431    }
432
433    fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
434        self.base.set_tick_count(nbt.int_value("Age").unwrap_or(0));
435        {
436            let mut state = self.state.lock();
437            state.duration = nbt.int_value("Duration").unwrap_or(INFINITE_DURATION);
438            state.wait_time = nbt.int_value("WaitTime").unwrap_or(DEFAULT_WAIT_TIME);
439            state.reapplication_delay = nbt
440                .int_value("ReapplicationDelay")
441                .unwrap_or(DEFAULT_REAPPLICATION_DELAY);
442            state.duration_on_use = nbt.int_value("DurationOnUse").unwrap_or(0);
443            state.radius_on_use = nbt.float_value("RadiusOnUse").unwrap_or(0.0);
444            state.radius_per_tick = nbt.float_value("RadiusPerTick").unwrap_or(0.0);
445            state.owner = EntityReference::read(&nbt, "Owner");
446            state.potion_duration_scale = nbt
447                .float_value("potion_duration_scale")
448                .unwrap_or(DEFAULT_POTION_DURATION_SCALE);
449        }
450        self.set_radius(nbt.float_value("Radius").unwrap_or(DEFAULT_RADIUS));
451        self.set_potion_contents(
452            nbt.get("potion_contents")
453                .and_then(PotionContents::from_nbt_tag)
454                .unwrap_or_else(PotionContents::empty),
455        );
456    }
457}
458
459#[cfg(test)]
460mod tests;