Skip to main content

steel_core/block_entity/entities/
conduit.rs

1//! Conduit block entity implementation.
2//!
3//! Vanilla's client-only `effectBlocks` list, `tickCount` and `activeRotation` are not kept;
4//! the server only needs the frame size from each scan.
5
6use std::sync::{Arc, Weak};
7
8use glam::DVec3;
9use simdnbt::borrow::{BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView};
10use simdnbt::owned::NbtCompound;
11use steel_protocol::packets::game::SoundSource;
12use steel_registry::blocks::block_state_ext::BlockStateExt as _;
13use steel_registry::{
14    REGISTRY, TaggedRegistryExt, sound_events, vanilla_block_entity_types, vanilla_block_tags,
15    vanilla_damage_types, vanilla_entities, vanilla_mob_effects,
16};
17use steel_utils::locks::SyncMutex;
18use steel_utils::{
19    BlockLocalAabb, BlockPos, BlockStateId, Downcast as _, DowncastType, DowncastTypeKey,
20};
21
22use crate::block_entity::{BlockEntity, BlockEntityBase};
23use crate::entity::damage::DamageSource;
24use crate::entity::{
25    Entity as _, EntityReference, LivingEntity as _, MobEffectInstance, SharedEntity,
26};
27use crate::fluid::{FluidStateExt as _, get_fluid_state};
28use crate::player::Player;
29use crate::world::World;
30
31const BLOCK_REFRESH_RATE: i64 = 40;
32const AMBIENT_SOUND_INTERVAL: i64 = 80;
33const EFFECT_DURATION: i32 = 13 * 20;
34const MIN_ACTIVE_SIZE: usize = 16;
35const MIN_KILL_SIZE: usize = 42;
36const KILL_RANGE: f64 = 8.0;
37const ATTACK_DAMAGE: f32 = 4.0;
38
39/// The frame positions around the conduit, in vanilla's x, y, z scan order: outside the
40/// 3x3x3 water cage, on one of the three axis-aligned rings of radius 2.
41const FRAME_OFFSETS: [(i32, i32, i32); MIN_KILL_SIZE] = {
42    let mut offsets = [(0, 0, 0); MIN_KILL_SIZE];
43    let mut count = 0;
44    let mut ox: i32 = -2;
45    while ox <= 2 {
46        let mut oy: i32 = -2;
47        while oy <= 2 {
48            let mut oz: i32 = -2;
49            while oz <= 2 {
50                let (ax, ay, az) = (ox.abs(), oy.abs(), oz.abs());
51                let outside_cage = ax > 1 || ay > 1 || az > 1;
52                let on_ring = ox == 0 && (ay == 2 || az == 2)
53                    || oy == 0 && (ax == 2 || az == 2)
54                    || oz == 0 && (ax == 2 || ay == 2);
55                if outside_cage && on_ring {
56                    offsets[count] = (ox, oy, oz);
57                    count += 1;
58                }
59                oz += 1;
60            }
61            oy += 1;
62        }
63        ox += 1;
64    }
65    assert!(
66        count == MIN_KILL_SIZE,
67        "a full conduit frame is the hunting size"
68    );
69    offsets
70};
71
72struct ConduitState {
73    is_active: bool,
74    is_hunting: bool,
75    destroy_target: Option<EntityReference>,
76    next_ambient_sound_activation: i64,
77}
78
79/// Conduit block entity.
80pub struct ConduitBlockEntity {
81    base: BlockEntityBase,
82    state: SyncMutex<ConduitState>,
83}
84
85// SAFETY: This key is owned by Steel and uniquely identifies `ConduitBlockEntity`.
86unsafe impl DowncastType for ConduitBlockEntity {
87    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/conduit");
88}
89
90impl ConduitBlockEntity {
91    /// Creates a new conduit block entity.
92    #[must_use]
93    pub fn new(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
94        Self {
95            base: BlockEntityBase::new(&vanilla_block_entity_types::CONDUIT, level, pos, state),
96            state: SyncMutex::new(ConduitState {
97                is_active: false,
98                is_hunting: false,
99                destroy_target: None,
100                next_ambient_sound_activation: 0,
101            }),
102        }
103    }
104
105    /// Returns whether the last shape check found a valid water cage and frame.
106    #[must_use]
107    pub fn is_active(&self) -> bool {
108        self.state.lock().is_active
109    }
110
111    /// Returns whether the last shape check found a frame large enough to attack enemies.
112    #[must_use]
113    pub fn is_hunting(&self) -> bool {
114        self.state.lock().is_hunting
115    }
116
117    /// Returns the number of frame blocks, or `0` when the surrounding 3x3x3 is not all water.
118    fn update_shape(world: &Arc<World>, pos: BlockPos) -> usize {
119        for ox in -1..=1 {
120            for oy in -1..=1 {
121                for oz in -1..=1 {
122                    if !get_fluid_state(world, pos.offset(ox, oy, oz)).is_water() {
123                        return 0;
124                    }
125                }
126            }
127        }
128
129        FRAME_OFFSETS
130            .iter()
131            .filter(|&&(ox, oy, oz)| {
132                let state = world.get_block_state(pos.offset(ox, oy, oz));
133                REGISTRY.blocks.is_in_tag(
134                    state.get_block(),
135                    &vanilla_block_tags::BlockTag::CONDUIT_EFFECT_BLOCK,
136                )
137            })
138            .count()
139    }
140
141    fn apply_effects(world: &Arc<World>, pos: BlockPos, active_size: usize) {
142        let effect_range = f64::from((active_size / 7 * 16) as i32);
143        let aabb = BlockLocalAabb::FULL_BLOCK
144            .at_block(pos)
145            .inflate(effect_range)
146            .expand_towards(DVec3::new(0.0, f64::from(world.get_height()), 0.0));
147
148        for entity in world.get_entities_in_aabb_matching(&aabb, |entity| {
149            entity.entity_type() == &vanilla_entities::PLAYER && !entity.is_spectator()
150        }) {
151            let Some(player) = entity.downcast_ref::<Player>() else {
152                continue;
153            };
154            if !pos.closer_than(player.block_position(), effect_range)
155                || !player.is_in_water_or_rain()
156            {
157                continue;
158            }
159            player.add_mob_effect(
160                MobEffectInstance::with_duration(
161                    vanilla_mob_effects::CONDUIT_POWER,
162                    EFFECT_DURATION,
163                    0,
164                )
165                .with_ambient(true)
166                .with_visible(true),
167            );
168        }
169    }
170
171    fn update_and_attack_target(&self, world: &Arc<World>, pos: BlockPos, is_hunting: bool) {
172        let current_target = self.state.lock().destroy_target.clone();
173        let new_target =
174            Self::update_destroy_target(current_target.as_ref(), world, pos, is_hunting);
175
176        if let Some(target) = new_target
177            .as_ref()
178            .and_then(|t| Self::living_entity(t, world))
179        {
180            world.play_sound_at(
181                &sound_events::BLOCK_CONDUIT_ATTACK_TARGET,
182                SoundSource::Blocks,
183                target.position(),
184                1.0,
185                1.0,
186                None,
187            );
188            if let Some(living) = target.as_living_entity() {
189                living.hurt_server(
190                    world,
191                    &DamageSource::environment(&vanilla_damage_types::MAGIC),
192                    ATTACK_DAMAGE,
193                );
194            }
195        }
196
197        let changed = {
198            let mut state = self.state.lock();
199            let changed = state.destroy_target.as_ref().map(EntityReference::uuid)
200                != new_target.as_ref().map(EntityReference::uuid);
201            if changed {
202                state.destroy_target = new_target;
203            }
204            changed
205        };
206        if changed {
207            world.send_block_updated(pos);
208        }
209    }
210
211    fn update_destroy_target(
212        target: Option<&EntityReference>,
213        world: &Arc<World>,
214        pos: BlockPos,
215        is_hunting: bool,
216    ) -> Option<EntityReference> {
217        if !is_hunting {
218            return None;
219        }
220
221        let Some(target) = target else {
222            return Self::select_new_target(world, pos);
223        };
224
225        let entity = Self::living_entity(target, world)?;
226        (entity.is_alive() && pos.closer_than(entity.block_position(), KILL_RANGE))
227            .then(|| target.clone())
228    }
229
230    fn select_new_target(world: &Arc<World>, pos: BlockPos) -> Option<EntityReference> {
231        let aabb = BlockLocalAabb::FULL_BLOCK.at_block(pos).inflate(KILL_RANGE);
232        let candidates = world.get_entities_in_aabb_matching(&aabb, |entity| {
233            entity.as_living_entity().is_some() && entity.is_enemy() && entity.is_in_water_or_rain()
234        });
235        if candidates.is_empty() {
236            return None;
237        }
238        let chosen = &candidates[rand::random_range(0..candidates.len())];
239        Some(EntityReference::from_entity(chosen))
240    }
241
242    fn living_entity(reference: &EntityReference, world: &World) -> Option<SharedEntity> {
243        reference
244            .get_entity(world)
245            .filter(|entity| entity.as_living_entity().is_some())
246    }
247}
248
249impl BlockEntity for ConduitBlockEntity {
250    fn base(&self) -> &BlockEntityBase {
251        &self.base
252    }
253
254    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
255        let nbt_view: NbtCompoundView<'_, '_> = nbt.into();
256        self.state.lock().destroy_target = EntityReference::read(&nbt_view, "Target");
257    }
258
259    fn save_additional(&self, nbt: &mut NbtCompound) {
260        if let Some(target) = &self.state.lock().destroy_target {
261            target.store(nbt, "Target");
262        }
263    }
264
265    fn get_update_tag(&self) -> Option<NbtCompound> {
266        Some(self.save_custom_only())
267    }
268
269    fn tick(&self, world: &Arc<World>) {
270        let pos = self.get_block_pos();
271        let game_time = world.game_time();
272
273        if game_time % BLOCK_REFRESH_RATE == 0 {
274            let effect_blocks = Self::update_shape(world, pos);
275            let active = effect_blocks >= MIN_ACTIVE_SIZE;
276            let hunting = effect_blocks >= MIN_KILL_SIZE;
277
278            let was_active = {
279                let mut state = self.state.lock();
280                let was_active = state.is_active;
281                state.is_active = active;
282                state.is_hunting = hunting;
283                was_active
284            };
285            if active != was_active {
286                let sound = if active {
287                    &sound_events::BLOCK_CONDUIT_ACTIVATE
288                } else {
289                    &sound_events::BLOCK_CONDUIT_DEACTIVATE
290                };
291                world.play_block_sound(sound, pos, 1.0, 1.0, None);
292            }
293
294            if active {
295                Self::apply_effects(world, pos, effect_blocks);
296                self.update_and_attack_target(world, pos, hunting);
297            }
298        }
299
300        let play_short_ambient = {
301            let mut state = self.state.lock();
302            if !state.is_active {
303                return;
304            }
305            let play = game_time > state.next_ambient_sound_activation;
306            if play {
307                state.next_ambient_sound_activation = game_time + 60 + rand::random_range(0..40);
308            }
309            play
310        };
311
312        if game_time % AMBIENT_SOUND_INTERVAL == 0 {
313            world.play_block_sound(&sound_events::BLOCK_CONDUIT_AMBIENT, pos, 1.0, 1.0, None);
314        }
315        if play_short_ambient {
316            world.play_block_sound(
317                &sound_events::BLOCK_CONDUIT_AMBIENT_SHORT,
318                pos,
319                1.0,
320                1.0,
321                None,
322            );
323        }
324    }
325}