Skip to main content

steel_core/entity/entities/mobs/ambient/
bat.rs

1//! Vanilla Bat entity: rests on cave ceilings and flutters through the dark.
2
3use std::f32::consts::PI;
4use std::sync::{Arc, Weak};
5
6use glam::DVec3;
7use rand::{Rng, RngExt as _};
8use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView;
9use simdnbt::owned::NbtCompound;
10use steel_macros::entity_behavior;
11use steel_math::wrap_degrees;
12use steel_registry::blocks::block_state_ext::BlockStateExt as _;
13use steel_registry::entity_type::{EntityDimensions, EntityTypeRef};
14use steel_registry::sound_event::SoundEventRef;
15use steel_registry::vanilla_block_tags::BlockTag;
16use steel_registry::vanilla_entity_data::BatEntityData;
17use steel_registry::{level_events, sound_events, vanilla_attributes};
18use steel_utils::locks::SyncMutex;
19use steel_utils::nbt::NbtValueInput as _;
20use steel_utils::{BlockPos, BlockStateId, DowncastType, DowncastTypeKey};
21
22use crate::chunk::heightmap::HeightmapType;
23use crate::entity::ai::targeting::TargetingConditions;
24use crate::entity::damage::DamageSource;
25use crate::entity::{
26    DefaultEntityPush, Entity, EntityBase, EntityBaseLoad, EntityMovementEmission, EntityPose,
27    EntitySpawnReason, EntitySyncedData, LivingEntity, LivingEntityBase, LivingTravelInput, Mob,
28    MobBase, SharedEntity, SpawnRule,
29};
30use crate::physics::MoveResult;
31use crate::world::{LevelReader, World, is_redstone_conductor};
32
33const FLAG_RESTING: i8 = 1;
34const BAT_RESTING_TARGETING_RANGE: f64 = 4.0;
35const DEFAULT_STEP_HEIGHT: f32 = 0.6;
36
37/// Vanilla `Bat.customServerAiStep` chance divisors (`nextInt` bounds).
38const RESTING_YAW_ROLL_CHANCE: i32 = 200;
39const RESTING_YAW_RANGE: i32 = 360;
40const RESTING_RETARGET_CHANCE: i32 = 30;
41const RESTING_SETTLE_CHANCE: i32 = 100;
42
43/// Vanilla `Bat.customServerAiStep` target-selection bounds.
44const TARGET_HORIZONTAL_RANGE: i32 = 7;
45const TARGET_VERTICAL_RANGE: i32 = 6;
46const TARGET_VERTICAL_OFFSET: f64 = 2.0;
47const TARGET_RECENTER_DISTANCE: f64 = 2.0;
48
49/// Vanilla `Bat.customServerAiStep` steering constants.
50const STEER_TARGET_XZ: f64 = 0.5;
51const STEER_TARGET_Y: f32 = 0.7;
52const STEER_STEP: f32 = 0.1;
53/// Forward flight input vanilla assigns to `zza`.
54const FLIGHT_FORWARD_INPUT: f32 = 0.5;
55
56const AIRBORNE_Y_DRAG: f64 = 0.6;
57
58/// Vanilla bat entity.
59#[entity_behavior(class = "Bat", spawn_rule)]
60pub struct BatEntity {
61    base: EntityBase,
62    entity_type: EntityTypeRef,
63    living_base: LivingEntityBase,
64    mob_base: MobBase,
65    entity_data: SyncMutex<BatEntityData>,
66    target_position: SyncMutex<Option<BlockPos>>,
67}
68
69// SAFETY: This key is owned by Steel and uniquely identifies `BatEntity`.
70unsafe impl DowncastType for BatEntity {
71    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/bat");
72}
73
74impl BatEntity {
75    pub(crate) const SPAWN_RULE: SpawnRule = Self::check_bat_spawn_rules;
76
77    /// Bats spawn below the world surface in dark places on `bats_spawnable_on` blocks.
78    pub(crate) fn check_bat_spawn_rules(
79        entity_type: EntityTypeRef,
80        level: &dyn LevelReader,
81        spawn_reason: EntitySpawnReason,
82        pos: BlockPos,
83        random: &mut dyn Rng,
84    ) -> bool {
85        if pos.y() >= level.height_at(HeightmapType::WorldSurface, pos.x(), pos.z())
86            || random.random::<bool>()
87            || i32::from(level.max_local_raw_brightness(pos, level.sky_darkening()))
88                > random.random_range(0..4)
89        {
90            return false;
91        }
92        level
93            .get_block_state(pos.below())
94            .get_block()
95            .has_tag(&BlockTag::BATS_SPAWNABLE_ON)
96            && <Self as Mob>::check_mob_spawn_rules(entity_type, level, spawn_reason, pos)
97    }
98
99    /// Creates a new bat at runtime.
100    #[must_use]
101    pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
102        Self::new_with_base(
103            EntityBase::new(id, position, entity_type.dimensions, world),
104            entity_type,
105        )
106    }
107
108    /// Reconstructs a bat from persisted base entity state.
109    #[must_use]
110    pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
111        Self::new_with_base(
112            EntityBase::from_load(load, entity_type.dimensions),
113            entity_type,
114        )
115    }
116
117    fn new_with_base(base: EntityBase, entity_type: EntityTypeRef) -> Self {
118        let living_base = LivingEntityBase::new(entity_type);
119        let mob_base = MobBase::new();
120        let mut entity_data = BatEntityData::new();
121        living_base.initialize_synced_data(&mut entity_data);
122        entity_data.id_flags.set(FLAG_RESTING);
123
124        Self {
125            base,
126            entity_type,
127            living_base,
128            mob_base,
129            entity_data: SyncMutex::new(entity_data),
130            target_position: SyncMutex::new(None),
131        }
132    }
133
134    /// Returns if the bat is resting.
135    #[must_use]
136    pub fn is_resting(&self) -> bool {
137        (self.entity_data.lock().id_flags.get() & FLAG_RESTING) != 0
138    }
139
140    /// Sets if the bat is resting.
141    pub fn set_resting(&self, resting: bool) {
142        let mut data = self.entity_data.lock();
143        let current = *data.id_flags.get();
144        let updated = if resting {
145            current | FLAG_RESTING
146        } else {
147            current & !FLAG_RESTING // equal to -2
148        };
149        data.id_flags.set(updated);
150    }
151
152    fn update_dirty_mob_effect_entity_data(&self) {
153        if !self.living_base.take_effects_dirty() {
154            return;
155        }
156
157        let display = self.living_base.mob_effect_display_state();
158
159        {
160            let mut entity_data = self.entity_data.lock();
161            let living = entity_data.living_entity_mut();
162            living.effect_particles.set(display.particles);
163            living.effect_ambience.set(display.ambient);
164        }
165
166        self.entity_data.set_base_invisible_flag(display.invisible);
167    }
168
169    /// Ticks the AI while the bat is resting.
170    fn tick_resting_ai(&self, world: &World, pos: BlockPos, above: BlockPos) {
171        let silent = self.is_silent();
172        if is_redstone_conductor(world, world.get_block_state(above), pos) {
173            if rand::random_range(0..RESTING_YAW_ROLL_CHANCE) == 0 {
174                self.set_y_head_rot(rand::random_range(0..RESTING_YAW_RANGE) as f32);
175            }
176
177            let conditions =
178                TargetingConditions::for_non_combat().range(BAT_RESTING_TARGETING_RANGE);
179            let targeter: &dyn LivingEntity = self;
180            let player_near = world
181                .nearest_player(self.position(), BAT_RESTING_TARGETING_RANGE, |player| {
182                    conditions.test(world, Some(targeter), player)
183                })
184                .is_some();
185            if !player_near {
186                return;
187            }
188        }
189
190        self.set_resting(false);
191        if !silent {
192            world.level_event(level_events::SOUND_BAT_LIFTOFF, pos, 0, None);
193        }
194    }
195
196    /// Ticks the AI while the bat is flying.
197    fn tick_flying_ai(&self, world: &World, above: BlockPos) {
198        let mut target_position = *self.target_position.lock();
199        if target_position.is_some_and(|target| {
200            !world.get_block_state(target).is_air() || target.y() <= world.get_min_y()
201        }) {
202            target_position = None;
203        }
204
205        let needs_new_target = target_position.is_none()
206            || rand::random_range(0..RESTING_RETARGET_CHANCE) == 0
207            || target_position.is_some_and(|target| {
208                target.closer_to_center_than(self.position(), TARGET_RECENTER_DISTANCE)
209            });
210        if needs_new_target {
211            target_position = Some(self.random_target_position());
212        }
213        *self.target_position.lock() = target_position;
214
215        let Some(target) = target_position else {
216            return;
217        };
218
219        let position = self.position();
220        let dx = f64::from(target.x()) + 0.5 - position.x;
221        let dy = f64::from(target.y()) + 0.1 - position.y;
222        let dz = f64::from(target.z()) + 0.5 - position.z;
223
224        let movement = self.velocity();
225        let steer_step = f64::from(STEER_STEP);
226        let new_movement = movement
227            + DVec3::new(
228                (dx.signum() * STEER_TARGET_XZ - movement.x) * steer_step,
229                (dy.signum() * f64::from(STEER_TARGET_Y) - movement.y) * steer_step,
230                (dz.signum() * STEER_TARGET_XZ - movement.z) * steer_step,
231            );
232        self.set_velocity(new_movement);
233
234        let y_rot_d = (new_movement.z.atan2(new_movement.x) * 180.0 / f64::from(PI)) as f32 - 90.0;
235        let (yaw, pitch) = self.rotation();
236        let rot_diff = wrap_degrees(y_rot_d - yaw);
237
238        // Vanilla assigns `zza` before applying the yaw delta; the mob move control
239        // resets it later in the same server AI step, so keep the assignment for
240        // parity with the vanilla order.
241        let input = self.travel_input();
242        self.set_travel_input(LivingTravelInput::new(
243            input.sideways(),
244            input.vertical(),
245            FLIGHT_FORWARD_INPUT,
246        ));
247
248        self.set_rotation((yaw + rot_diff, pitch));
249
250        if rand::random_range(0..RESTING_SETTLE_CHANCE) == 0
251            && is_redstone_conductor(world, world.get_block_state(above), above)
252        {
253            self.set_resting(true);
254        }
255    }
256
257    /// Picks a random target block around the bat.
258    fn random_target_position(&self) -> BlockPos {
259        let position = self.position();
260        BlockPos::containing(
261            position.x
262                + f64::from(
263                    rand::random_range(0..TARGET_HORIZONTAL_RANGE)
264                        - rand::random_range(0..TARGET_HORIZONTAL_RANGE),
265                ),
266            position.y + f64::from(rand::random_range(0..TARGET_VERTICAL_RANGE))
267                - TARGET_VERTICAL_OFFSET,
268            position.z
269                + f64::from(
270                    rand::random_range(0..TARGET_HORIZONTAL_RANGE)
271                        - rand::random_range(0..TARGET_HORIZONTAL_RANGE),
272                ),
273        )
274    }
275}
276
277impl DefaultEntityPush for BatEntity {}
278
279impl Entity for BatEntity {
280    fn is_hard_collision_relevant(&self) -> bool {
281        false
282    }
283
284    fn as_default_entity_push(&self) -> Option<&dyn DefaultEntityPush> {
285        Some(self)
286    }
287
288    fn base(&self) -> &EntityBase {
289        &self.base
290    }
291
292    fn entity_type(&self) -> EntityTypeRef {
293        self.entity_type
294    }
295
296    fn base_tick(&self) {
297        Mob::base_tick_mob(self);
298    }
299
300    fn tick(self: Arc<Self>) {
301        let entity: SharedEntity = Arc::<Self>::clone(&self);
302        self.tick_living_entity(&entity);
303
304        if self.is_resting() {
305            self.set_velocity(DVec3::ZERO);
306            let height = f64::from(self.base().dimensions().height);
307            let current = self.position();
308            let snapped = DVec3::new(current.x, current.y.floor() + 1.0 - height, current.z);
309            if snapped != current
310                && let Err(error) = self.try_set_position(snapped)
311            {
312                tracing::debug!(
313                    entity_id = self.id(),
314                    "bat resting position update failed: {error}"
315                );
316            }
317        } else {
318            let velocity = self.velocity();
319            self.set_velocity(DVec3::new(
320                velocity.x,
321                velocity.y * AIRBORNE_Y_DRAG,
322                velocity.z,
323            ));
324        }
325    }
326
327    fn dimensions_for_pose(&self, _pose: EntityPose) -> EntityDimensions {
328        let scale = LivingEntity::get_scale(self);
329        if self.entity_type.fixed {
330            self.entity_type.dimensions
331        } else {
332            self.entity_type.dimensions.scale(scale)
333        }
334    }
335
336    fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
337        Some(&self.entity_data)
338    }
339
340    fn update_data_before_sync(&self) {
341        self.update_dirty_mob_effect_entity_data();
342    }
343
344    fn max_up_step(&self) -> f32 {
345        self.attributes()
346            .lock()
347            .get_value(vanilla_attributes::STEP_HEIGHT)
348            .unwrap_or(f64::from(DEFAULT_STEP_HEIGHT)) as f32
349    }
350
351    fn is_pushable(&self) -> bool {
352        false
353    }
354
355    fn is_ignoring_block_triggers(&self) -> bool {
356        true
357    }
358
359    fn movement_emission(&self) -> EntityMovementEmission {
360        EntityMovementEmission::Events
361    }
362
363    fn check_fall_damage(
364        self: Arc<Self>,
365        _vertical_movement: f64,
366        _on_ground: bool,
367        _on_state: BlockStateId,
368        _pos: BlockPos,
369        _world: &Arc<World>,
370    ) {
371    }
372
373    fn is_flapping(&self) -> bool {
374        !self.is_resting() && self.tick_count() % 10 == 0
375    }
376
377    fn hurt(&self, world: &World, source: &DamageSource, amount: f32) -> bool {
378        if !self.is_invulnerable_to(world, source) && self.is_resting() {
379            self.set_resting(false);
380        }
381        LivingEntity::hurt_server(self, world, source, amount)
382    }
383
384    fn save_additional(&self, nbt: &mut NbtCompound) {
385        self.save_mob(nbt);
386        nbt.insert("BatFlags", *self.entity_data.lock().id_flags.get());
387    }
388
389    fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
390        self.load_mob(nbt);
391        self.entity_data
392            .lock()
393            .id_flags
394            .set(nbt.byte_value("BatFlags").unwrap_or(0));
395    }
396}
397
398impl LivingEntity for BatEntity {
399    fn living_base(&self) -> &LivingEntityBase {
400        &self.living_base
401    }
402
403    fn get_health(&self) -> f32 {
404        *self.entity_data.lock().living_entity().health.get()
405    }
406
407    fn set_health(&self, health: f32) {
408        let max_health = self.get_max_health();
409        let clamped = health.clamp(0.0, max_health);
410        self.entity_data
411            .lock()
412            .living_entity_mut()
413            .health
414            .set(clamped);
415    }
416
417    fn sound_volume(&self) -> f32 {
418        0.1
419    }
420
421    fn voice_pitch(&self) -> f32 {
422        self.default_voice_pitch() * 0.95
423    }
424
425    fn hurt_sound(&self, _source: &DamageSource) -> Option<SoundEventRef> {
426        Some(&sound_events::ENTITY_BAT_HURT)
427    }
428
429    fn death_sound(&self) -> Option<SoundEventRef> {
430        Some(&sound_events::ENTITY_BAT_DEATH)
431    }
432
433    fn push_entities(&self) {}
434
435    fn server_ai_step(&self, entity: &SharedEntity) {
436        Mob::mob_server_ai_step(self, entity);
437    }
438
439    fn ai_step(&self, entity: &SharedEntity) -> Option<MoveResult> {
440        Mob::mob_ai_step(self, entity)
441    }
442}
443
444impl Mob for BatEntity {
445    fn mob_base(&self) -> &MobBase {
446        &self.mob_base
447    }
448
449    fn mob_can_be_leashed(&self) -> bool {
450        false
451    }
452
453    fn custom_server_ai_step(&self) {
454        let Some(world) = self.level() else {
455            return;
456        };
457        let pos = self.block_position();
458        let above = pos.above();
459        if self.is_resting() {
460            self.tick_resting_ai(&world, pos, above);
461        } else {
462            self.tick_flying_ai(&world, above);
463        }
464    }
465
466    fn ambient_sound(&self) -> Option<SoundEventRef> {
467        if self.is_resting() && rand::random_range(0..4) != 0 {
468            None
469        } else {
470            Some(&sound_events::ENTITY_BAT_AMBIENT)
471        }
472    }
473
474    fn mob_flags(&self) -> i8 {
475        *self.entity_data.lock().mob().mob_flags.get()
476    }
477
478    fn set_mob_flags(&self, flags: i8) {
479        self.entity_data.lock().mob_mut().mob_flags.set(flags);
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::BatEntity;
486    use crate::entity::{Entity, leash::Leashable};
487    use glam::DVec3;
488    use simdnbt::borrow::read_compound;
489    use simdnbt::owned::NbtCompound;
490    use std::io::Cursor;
491    use std::sync::Weak;
492    use steel_registry::{init_vanilla_registry, vanilla_entities};
493
494    #[test]
495    fn bat_starts_resting_and_round_trips_flags() {
496        init_vanilla_registry();
497
498        let bat = BatEntity::new(&vanilla_entities::BAT, 1, DVec3::ZERO, Weak::new());
499        assert!(bat.is_resting());
500        assert!(!bat.can_be_leashed());
501
502        bat.set_resting(false);
503        assert!(!bat.is_resting());
504
505        let mut nbt = NbtCompound::new();
506        bat.save_additional(&mut nbt);
507        assert_eq!(nbt.byte("BatFlags"), Some(0));
508
509        let loaded = BatEntity::new(&vanilla_entities::BAT, 2, DVec3::ZERO, Weak::new());
510        let mut bytes = Vec::new();
511        nbt.write(&mut bytes);
512        let borrowed = read_compound(&mut Cursor::new(&bytes))
513            .unwrap_or_else(|error| panic!("reborrow failed: {error}"));
514        loaded.load_additional((&borrowed).into());
515        assert!(!loaded.is_resting());
516    }
517
518    #[test]
519    fn bat_is_flapping_on_the_vanilla_tick_cadence() {
520        init_vanilla_registry();
521
522        let bat = BatEntity::new(&vanilla_entities::BAT, 1, DVec3::ZERO, Weak::new());
523        assert!(!bat.is_flapping(), "resting bats never flap");
524
525        bat.set_resting(false);
526        assert!(bat.is_flapping(), "tick count 0 is a multiple of 10");
527
528        bat.advance_tick_count();
529        assert!(!bat.is_flapping());
530    }
531}