azalea_entity/plugin/
mod.rs

1pub mod indexing;
2mod relative_updates;
3
4use std::collections::HashSet;
5
6use azalea_block::{BlockState, fluid_state::FluidKind};
7use azalea_core::{
8    position::{BlockPos, ChunkPos, Vec3},
9    tick::GameTick,
10};
11use azalea_world::{InstanceContainer, InstanceName, MinecraftEntityId};
12use bevy_app::{App, Plugin, PostUpdate, Update};
13use bevy_ecs::prelude::*;
14use derive_more::{Deref, DerefMut};
15use indexing::EntityUuidIndex;
16pub use relative_updates::RelativeEntityUpdate;
17use tracing::debug;
18
19use crate::{
20    Crouching, Dead, EntityKindComponent, FluidOnEyes, LocalEntity, LookDirection, OnClimbable,
21    Physics, Pose, Position,
22    dimensions::{EntityDimensions, calculate_dimensions},
23    metadata::Health,
24};
25
26/// A Bevy [`SystemSet`] for various types of entity updates.
27#[derive(SystemSet, Debug, Hash, Eq, PartialEq, Clone)]
28pub enum EntityUpdateSet {
29    /// Create search indexes for entities.
30    Index,
31    /// Remove despawned entities from search indexes.
32    Deindex,
33}
34
35/// Plugin handling some basic entity functionality.
36pub struct EntityPlugin;
37impl Plugin for EntityPlugin {
38    fn build(&self, app: &mut App) {
39        // entities get added pre-update
40        // added to indexes during update (done by this plugin)
41        // modified during update
42        // despawned post-update (done by this plugin)
43        app.add_systems(
44            PostUpdate,
45            indexing::remove_despawned_entities_from_indexes.in_set(EntityUpdateSet::Deindex),
46        )
47        .add_systems(
48            Update,
49            (
50                (
51                    indexing::update_entity_chunk_positions,
52                    indexing::insert_entity_chunk_position,
53                )
54                    .chain()
55                    .in_set(EntityUpdateSet::Index),
56                (
57                    relative_updates::debug_detect_updates_received_on_local_entities,
58                    debug_new_entity,
59                    add_dead,
60                    clamp_look_direction,
61                    update_on_climbable,
62                    (update_dimensions, update_bounding_box, update_fluid_on_eyes).chain(),
63                    update_crouching,
64                ),
65            ),
66        )
67        .add_systems(GameTick, update_in_loaded_chunk)
68        .init_resource::<EntityUuidIndex>();
69    }
70}
71
72fn debug_new_entity(query: Query<(Entity, Option<&LocalEntity>), Added<MinecraftEntityId>>) {
73    for (entity, local) in query.iter() {
74        if local.is_some() {
75            debug!("new local entity: {:?}", entity);
76        } else {
77            debug!("new entity: {:?}", entity);
78        }
79    }
80}
81
82/// System that adds the [`Dead`] marker component if an entity's health is set
83/// to 0 (or less than 0). This will be present if an entity is doing the death
84/// animation.
85///
86/// Entities that are dead cannot be revived.
87pub fn add_dead(mut commands: Commands, query: Query<(Entity, &Health), Changed<Health>>) {
88    for (entity, health) in query.iter() {
89        if **health <= 0.0 {
90            commands.entity(entity).insert(Dead);
91        }
92    }
93}
94
95pub fn update_fluid_on_eyes(
96    mut query: Query<(
97        &mut FluidOnEyes,
98        &Position,
99        &EntityDimensions,
100        &InstanceName,
101    )>,
102    instance_container: Res<InstanceContainer>,
103) {
104    for (mut fluid_on_eyes, position, dimensions, instance_name) in query.iter_mut() {
105        let Some(instance) = instance_container.get(instance_name) else {
106            continue;
107        };
108
109        let adjusted_eye_y = position.y + (dimensions.eye_height as f64) - 0.1111111119389534;
110        let eye_block_pos = BlockPos::from(Vec3::new(position.x, adjusted_eye_y, position.z));
111        let fluid_at_eye = instance
112            .read()
113            .get_fluid_state(eye_block_pos)
114            .unwrap_or_default();
115        let fluid_cutoff_y = (eye_block_pos.y as f32 + fluid_at_eye.height()) as f64;
116        if fluid_cutoff_y > adjusted_eye_y {
117            **fluid_on_eyes = fluid_at_eye.kind;
118        } else {
119            **fluid_on_eyes = FluidKind::Empty;
120        }
121    }
122}
123
124pub fn update_on_climbable(
125    mut query: Query<(&mut OnClimbable, &Position, &InstanceName), With<LocalEntity>>,
126    instance_container: Res<InstanceContainer>,
127) {
128    for (mut on_climbable, position, instance_name) in query.iter_mut() {
129        // TODO: there's currently no gamemode component that can be accessed from here,
130        // maybe LocalGameMode should be replaced with two components, maybe called
131        // EntityGameMode and PreviousGameMode?
132
133        // if game_mode == GameMode::Spectator {
134        //     continue;
135        // }
136
137        let Some(instance) = instance_container.get(instance_name) else {
138            continue;
139        };
140
141        let instance = instance.read();
142
143        let block_pos = BlockPos::from(position);
144        let block_state_at_feet = instance.get_block_state(block_pos).unwrap_or_default();
145        let block_at_feet = Box::<dyn azalea_block::BlockTrait>::from(block_state_at_feet);
146        let registry_block_at_feet = block_at_feet.as_registry_block();
147
148        **on_climbable = azalea_registry::tags::blocks::CLIMBABLE.contains(&registry_block_at_feet)
149            || (azalea_registry::tags::blocks::TRAPDOORS.contains(&registry_block_at_feet)
150                && is_trapdoor_useable_as_ladder(block_state_at_feet, block_pos, &instance));
151    }
152}
153
154fn is_trapdoor_useable_as_ladder(
155    block_state: BlockState,
156    block_pos: BlockPos,
157    instance: &azalea_world::Instance,
158) -> bool {
159    // trapdoor must be open
160    if !block_state
161        .property::<azalea_block::properties::Open>()
162        .unwrap_or_default()
163    {
164        return false;
165    }
166
167    // block below must be a ladder
168    let block_below = instance
169        .get_block_state(block_pos.down(1))
170        .unwrap_or_default();
171    let registry_block_below =
172        Box::<dyn azalea_block::BlockTrait>::from(block_below).as_registry_block();
173    if registry_block_below != azalea_registry::Block::Ladder {
174        return false;
175    }
176    // and the ladder must be facing the same direction as the trapdoor
177    let ladder_facing = block_below
178        .property::<azalea_block::properties::FacingCardinal>()
179        .expect("ladder block must have facing property");
180    let trapdoor_facing = block_state
181        .property::<azalea_block::properties::FacingCardinal>()
182        .expect("trapdoor block must have facing property");
183    if ladder_facing != trapdoor_facing {
184        return false;
185    }
186
187    true
188}
189
190/// A component that lists all the local player entities that have this entity
191/// loaded. If this is empty, the entity will be removed from the ECS.
192#[derive(Component, Clone, Deref, DerefMut)]
193pub struct LoadedBy(pub HashSet<Entity>);
194
195pub fn clamp_look_direction(mut query: Query<&mut LookDirection>) {
196    for mut look_direction in &mut query {
197        *look_direction = apply_clamp_look_direction(*look_direction);
198    }
199}
200pub fn apply_clamp_look_direction(mut look_direction: LookDirection) -> LookDirection {
201    look_direction.x_rot = look_direction.x_rot.clamp(-90., 90.);
202
203    look_direction
204}
205
206/// Sets the position of the entity. This doesn't update the cache in
207/// azalea-world, and should only be used within azalea-world!
208///
209/// # Safety
210/// Cached position in the world must be updated.
211#[allow(clippy::type_complexity)]
212pub fn update_bounding_box(
213    mut query: Query<
214        (&mut Physics, &Position, &EntityDimensions),
215        Or<(Changed<Position>, Changed<EntityDimensions>)>,
216    >,
217) {
218    for (mut physics, position, dimensions) in query.iter_mut() {
219        let bounding_box = dimensions.make_bounding_box(**position);
220        physics.bounding_box = bounding_box;
221    }
222}
223
224#[allow(clippy::type_complexity)]
225pub fn update_dimensions(
226    mut query: Query<
227        (&mut EntityDimensions, &EntityKindComponent, &Pose),
228        Or<(Changed<EntityKindComponent>, Changed<Pose>)>,
229    >,
230) {
231    for (mut dimensions, kind, pose) in query.iter_mut() {
232        *dimensions = calculate_dimensions(**kind, *pose);
233    }
234}
235
236pub fn update_crouching(query: Query<(&mut Crouching, &Pose), Without<LocalEntity>>) {
237    for (mut crouching, pose) in query {
238        let new_crouching = *pose == Pose::Crouching;
239        // avoid triggering change detection
240        if **crouching != new_crouching {
241            **crouching = new_crouching;
242        }
243    }
244}
245
246/// Marks an entity that's in a loaded chunk. This is updated at the beginning
247/// of every tick.
248///
249/// Internally, this is only used for player physics. Not to be confused with
250/// the somewhat similarly named [`LoadedBy`].
251#[derive(Component, Clone, Debug, Copy)]
252pub struct InLoadedChunk;
253
254/// Update the [`InLoadedChunk`] component for all entities in the world.
255pub fn update_in_loaded_chunk(
256    mut commands: bevy_ecs::system::Commands,
257    query: Query<(Entity, &InstanceName, &Position)>,
258    instance_container: Res<InstanceContainer>,
259) {
260    for (entity, instance_name, position) in &query {
261        let player_chunk_pos = ChunkPos::from(position);
262        let Some(instance_lock) = instance_container.get(instance_name) else {
263            commands.entity(entity).remove::<InLoadedChunk>();
264            continue;
265        };
266
267        let in_loaded_chunk = instance_lock.read().chunks.get(&player_chunk_pos).is_some();
268        if in_loaded_chunk {
269            commands.entity(entity).insert(InLoadedChunk);
270        } else {
271            commands.entity(entity).remove::<InLoadedChunk>();
272        }
273    }
274}
275
276/// A component that indicates whether the client has loaded.
277///
278/// This is updated by a system in `azalea-client`.
279#[derive(Component)]
280pub struct HasClientLoaded;
281
282#[cfg(test)]
283mod tests {
284    use azalea_block::{
285        blocks::{Ladder, OakTrapdoor},
286        properties::{FacingCardinal, TopBottom},
287    };
288    use azalea_core::position::{BlockPos, ChunkPos};
289    use azalea_world::{Chunk, ChunkStorage, Instance, PartialInstance};
290
291    use super::is_trapdoor_useable_as_ladder;
292
293    #[test]
294    fn test_is_trapdoor_useable_as_ladder() {
295        let mut partial_instance = PartialInstance::default();
296        let mut chunks = ChunkStorage::default();
297        partial_instance.chunks.set(
298            &ChunkPos { x: 0, z: 0 },
299            Some(Chunk::default()),
300            &mut chunks,
301        );
302        partial_instance.chunks.set_block_state(
303            BlockPos::new(0, 0, 0),
304            azalea_registry::Block::Stone.into(),
305            &chunks,
306        );
307
308        let ladder = Ladder {
309            facing: FacingCardinal::East,
310            waterlogged: false,
311        };
312        partial_instance
313            .chunks
314            .set_block_state(BlockPos::new(0, 0, 0), ladder.into(), &chunks);
315
316        let trapdoor = OakTrapdoor {
317            facing: FacingCardinal::East,
318            half: TopBottom::Bottom,
319            open: true,
320            powered: false,
321            waterlogged: false,
322        };
323        partial_instance
324            .chunks
325            .set_block_state(BlockPos::new(0, 1, 0), trapdoor.into(), &chunks);
326
327        let instance = Instance::from(chunks);
328        let trapdoor_matches_ladder = is_trapdoor_useable_as_ladder(
329            instance
330                .get_block_state(BlockPos::new(0, 1, 0))
331                .unwrap_or_default(),
332            BlockPos::new(0, 1, 0),
333            &instance,
334        );
335
336        assert!(trapdoor_matches_ladder);
337    }
338}