Skip to main content

steel_core/world/
level_effects.rs

1use std::ptr;
2
3use super::{
4    Arc, BLOCK_BEHAVIORS, BlockLootContext, BlockPos, BlockStateExt, BlockStateId, CLevelEvent,
5    CLevelParticles, CSound, ChunkPos, ConnectionProtocol, DVec3, EncodedPacket, Entity,
6    GLOBAL_SOUND_EVENTS, GameEventContext, ItemStack, LevelReader, LootContext, NetworkConnection,
7    ParticleData, Player, REGISTRY, RandomizationType, RegistryExt, SectionPos, SoundEventRef,
8    SoundSource, UpdateFlags, World, WorldEntityManager, entity_loot_ref, fluid_state_to_block,
9    level_events, vanilla_blocks, vanilla_game_events,
10};
11use crate::entity::loot_equipment;
12use crate::inventory::lock::{ContainerLockGuard, ContainerRef};
13use steel_registry::sound_event::SoundEventHolder;
14use steel_registry::vanilla_particle_types::{BUBBLE, SPLASH};
15
16pub(super) fn sound_is_within_range(
17    sound: &SoundEventHolder,
18    volume: f32,
19    distance_squared: f64,
20) -> bool {
21    let range = f64::from(sound.range(volume));
22    distance_squared < range * range
23}
24
25fn sound_packet_for_player(
26    pos: DVec3,
27    player_pos: DVec3,
28    max_distance_squared: f64,
29    volume: f32,
30    min_volume: f32,
31) -> Option<(DVec3, f32)> {
32    let delta = pos - player_pos;
33    let distance_squared = delta.length_squared();
34    if distance_squared <= max_distance_squared {
35        return Some((pos, volume));
36    }
37    if min_volume <= 0.0 {
38        return None;
39    }
40    Some((
41        player_pos + delta / distance_squared.sqrt() * 2.0,
42        min_volume,
43    ))
44}
45
46impl World {
47    /// Broadcasts a level event to nearby players within 64 blocks.
48    ///
49    /// Level events trigger sounds, particles, and animations on the client.
50    /// See `steel_registry::level_events` for available event type constants.
51    ///
52    /// # Arguments
53    /// * `event_type` - The event type ID from `steel_registry::level_events`
54    /// * `pos` - The position where the event occurs
55    /// * `data` - Event-specific data (e.g., block state ID for block destruction)
56    /// * `exclude` - Optional entity ID to exclude from receiving the event
57    pub fn level_event(&self, event_type: i32, pos: BlockPos, data: i32, exclude: Option<i32>) {
58        let packet = CLevelEvent::new(event_type, pos, data, false);
59        let Ok(encoded) =
60            EncodedPacket::from_bare(packet, self.compression, ConnectionProtocol::Play)
61        else {
62            log::warn!("Failed to encode level event packet");
63            return;
64        };
65
66        self.players.iter_players(|_, player| {
67            if exclude != Some(player.id())
68                && Self::recipient_within_64_blocks(player.position(), pos)
69            {
70                player.connection.send_encoded(encoded.clone());
71            }
72            true
73        });
74    }
75
76    pub(super) fn recipient_within_64_blocks(player_pos: DVec3, event_pos: BlockPos) -> bool {
77        const MAX_DISTANCE_SQ: f64 = 64.0 * 64.0;
78
79        let dx = f64::from(event_pos.x()) - player_pos.x;
80        let dy = f64::from(event_pos.y()) - player_pos.y;
81        let dz = f64::from(event_pos.z()) - player_pos.z;
82        dx * dx + dy * dy + dz * dz < MAX_DISTANCE_SQ
83    }
84
85    /// Sends a particle distribution to every player within Vanilla's normal
86    /// 32-block particle radius.
87    pub fn send_particles(
88        &self,
89        particle: ParticleData,
90        position: DVec3,
91        count: i32,
92        spread: DVec3,
93        speed: f64,
94    ) -> i32 {
95        self.send_particles_with_options(particle, false, false, position, count, spread, speed)
96    }
97
98    /// Sends a particle distribution with the packet visibility flags selected
99    /// explicitly. `override_limiter` also expands the server recipient radius
100    /// from 32 to 512 blocks, matching `ServerLevel.sendParticles`.
101    #[expect(
102        clippy::too_many_arguments,
103        reason = "keeps Vanilla's two particle visibility flags explicit"
104    )]
105    pub fn send_particles_with_options(
106        &self,
107        particle: ParticleData,
108        override_limiter: bool,
109        always_show: bool,
110        position: DVec3,
111        count: i32,
112        spread: DVec3,
113        speed: f64,
114    ) -> i32 {
115        let packet = CLevelParticles {
116            override_limiter,
117            always_show,
118            x: position.x,
119            y: position.y,
120            z: position.z,
121            x_dist: spread.x as f32,
122            y_dist: spread.y as f32,
123            z_dist: spread.z as f32,
124            x_max_speed: speed as f32,
125            y_max_speed: speed as f32,
126            z_max_speed: speed as f32,
127            count,
128            particle,
129            randomization_type: RandomizationType::default(),
130        };
131        let Ok(encoded) =
132            EncodedPacket::from_bare(packet, self.compression, ConnectionProtocol::Play)
133        else {
134            log::warn!("Failed to encode level particles packet");
135            return 0;
136        };
137        let mut sent = 0;
138        self.players.iter_players(|_, player| {
139            if Self::particle_recipient_in_range(
140                player.block_position(),
141                position,
142                override_limiter,
143            ) {
144                player.connection.send_encoded(encoded.clone());
145                sent += 1;
146            }
147            true
148        });
149        sent
150    }
151
152    /// Sends a particle distribution to one player if they are in this world
153    /// and within Vanilla's particle recipient radius.
154    #[expect(
155        clippy::too_many_arguments,
156        reason = "mirrors Vanilla ServerLevel.sendParticles"
157    )]
158    pub fn send_particles_to(
159        self: &Arc<Self>,
160        player: &Player,
161        particle: ParticleData,
162        override_limiter: bool,
163        always_show: bool,
164        position: DVec3,
165        count: i32,
166        spread: DVec3,
167        speed: f64,
168    ) -> bool {
169        if !Arc::ptr_eq(self, &player.get_world())
170            || !Self::particle_recipient_in_range(
171                player.block_position(),
172                position,
173                override_limiter,
174            )
175        {
176            return false;
177        }
178
179        let packet = CLevelParticles {
180            override_limiter,
181            always_show,
182            x: position.x,
183            y: position.y,
184            z: position.z,
185            x_dist: spread.x as f32,
186            y_dist: spread.y as f32,
187            z_dist: spread.z as f32,
188            x_max_speed: speed as f32,
189            y_max_speed: speed as f32,
190            z_max_speed: speed as f32,
191            count,
192            particle,
193            randomization_type: RandomizationType::default(),
194        };
195        let Ok(encoded) =
196            EncodedPacket::from_bare(packet, self.compression, ConnectionProtocol::Play)
197        else {
198            log::warn!("Failed to encode level particles packet");
199            return false;
200        };
201        player.connection.send_encoded(encoded);
202        true
203    }
204
205    pub(super) fn particle_recipient_in_range(
206        player_block_pos: BlockPos,
207        particle_pos: DVec3,
208        override_limiter: bool,
209    ) -> bool {
210        let (x, y, z) = player_block_pos.get_center();
211        let radius = if override_limiter { 512.0 } else { 32.0 };
212        DVec3::new(x, y, z).distance_squared(particle_pos) < radius * radius
213    }
214
215    /// Sends bubble column particles at the position
216    pub fn send_bubble_column_particles(&self, pos: BlockPos) {
217        let x = f64::from(pos.x());
218        let y = f64::from(pos.y()) + 1.0;
219        let z = f64::from(pos.z());
220        for _ in 0..2 {
221            self.send_particles(
222                ParticleData::simple(&SPLASH),
223                DVec3::new(x + rand::random::<f64>(), y, z + rand::random::<f64>()),
224                1,
225                DVec3::ZERO,
226                1.0,
227            );
228            self.send_particles(
229                ParticleData::simple(&BUBBLE),
230                DVec3::new(x + rand::random::<f64>(), y, z + rand::random::<f64>()),
231                1,
232                DVec3::new(0.0, 0.01, 0.0),
233                0.2,
234            );
235        }
236    }
237
238    /// Broadcasts a global level event to all players in the world.
239    ///
240    /// When `global_sound_events` is disabled, vanilla falls back to a normal
241    /// nearby level event with the packet's global flag unset.
242    ///
243    /// # Arguments
244    /// * `event_type` - The event type ID from `steel_registry::level_events`
245    /// * `pos` - The position where the event occurs
246    /// * `data` - Event-specific data
247    pub fn global_level_event(&self, event_type: i32, pos: BlockPos, data: i32) {
248        if !self.get_game_rule(&GLOBAL_SOUND_EVENTS) {
249            self.level_event(event_type, pos, data, None);
250            return;
251        }
252
253        let packet = CLevelEvent::new(event_type, pos, data, true);
254        self.players.iter_players(|_, player| {
255            player.send_packet(packet.clone());
256            true
257        });
258    }
259
260    /// Broadcasts block destruction particles and sound for a destroyed block.
261    ///
262    /// # Arguments
263    /// * `pos` - The position of the destroyed block
264    /// * `block_state_id` - The block state ID of the destroyed block
265    /// * `exclude` - Optional entity ID to exclude from receiving the event
266    pub fn destroy_block_effect(&self, pos: BlockPos, block_state_id: u32, exclude: Option<i32>) {
267        self.level_event(
268            level_events::PARTICLES_AND_SOUND_DESTROY_BLOCK,
269            pos,
270            block_state_id as i32,
271            exclude,
272        );
273    }
274
275    /// Destroys a block at the given position, optionally dropping its loot.
276    ///
277    /// Sends destruction particles (skipping fire blocks), optionally drops
278    /// resources via loot table, then replaces with air.
279    ///
280    /// Defaults to [`Self::UPDATE_LIMIT`].
281    pub fn destroy_block(self: &Arc<Self>, pos: BlockPos, drop_items: bool) -> bool {
282        self.destroy_block_with_limit(pos, drop_items, Self::UPDATE_LIMIT)
283    }
284
285    /// Replaces a block with its fluid state's legacy block.
286    ///
287    /// Mirrors vanilla `Level.removeBlock`, including the piston-move update flag.
288    pub fn remove_block(self: &Arc<Self>, pos: BlockPos, moved_by_piston: bool) -> bool {
289        let state = self.get_block_state(pos);
290        let replacement = fluid_state_to_block(state.get_fluid_state());
291        let mut flags = UpdateFlags::UPDATE_ALL;
292        if moved_by_piston {
293            flags |= UpdateFlags::UPDATE_MOVE_BY_PISTON;
294        }
295        self.set_block(pos, replacement, flags)
296    }
297
298    /// Destroys a block with an entity source for game-event context.
299    pub fn destroy_block_by_entity(
300        self: &Arc<Self>,
301        pos: BlockPos,
302        drop_items: bool,
303        entity: &dyn Entity,
304    ) -> bool {
305        self.destroy_block_with_limit_and_entity(pos, drop_items, Self::UPDATE_LIMIT, Some(entity))
306    }
307
308    /// Destroys a block at the given position, optionally dropping its loot.
309    ///
310    /// Sends destruction particles (skipping fire blocks), optionally drops
311    /// resources via loot table, then replaces with air.
312    pub fn destroy_block_with_limit(
313        self: &Arc<Self>,
314        pos: BlockPos,
315        drop_items: bool,
316        recursion_left: i32,
317    ) -> bool {
318        self.destroy_block_with_limit_and_entity(pos, drop_items, recursion_left, None)
319    }
320
321    pub(super) fn destroy_block_with_limit_and_entity(
322        self: &Arc<Self>,
323        pos: BlockPos,
324        drop_items: bool,
325        recursion_left: i32,
326        entity: Option<&dyn Entity>,
327    ) -> bool {
328        let state = self.get_block_state(pos);
329        if state.is_air() {
330            return false;
331        }
332
333        let block = state.get_block();
334        let is_fire = block == &vanilla_blocks::FIRE || block == &vanilla_blocks::SOUL_FIRE;
335        if !is_fire {
336            self.destroy_block_effect(pos, u32::from(state.0), None);
337        }
338
339        if drop_items {
340            self.drop_resources_with_entity(state, pos, entity);
341            // TODO: This only covers the `drop_items` path. In vanilla, container
342            // content dropping runs unconditionally on any block-entity removal
343            // (BlockEntity.preRemoveSideEffects via LevelChunk.setBlockState) —
344            // independent of drop_items — so explosions, pistons, etc. still need
345            // a similar hook once Steel's block-update pipeline has one.
346            if let Some(block_entity) = self.get_block_entity(pos)
347                && let Some(container_ref) = ContainerRef::from_block_entity(block_entity)
348            {
349                let mut guard = ContainerLockGuard::lock_all(&[&container_ref]);
350                if let Some(container) = guard.get_mut(container_ref.container_id()) {
351                    for slot in 0..container.get_container_size() {
352                        let item = container.remove_item_no_update(slot);
353                        if !item.is_empty() {
354                            self.pop_resource(pos, item);
355                        }
356                    }
357                }
358            }
359        }
360
361        // Vanilla parity: fluidState.createLegacyBlock() — breaking a waterlogged
362        // block leaves water behind instead of air.
363        let replacement = fluid_state_to_block(state.get_fluid_state());
364        let destroyed =
365            self.set_block_with_limit(pos, replacement, UpdateFlags::UPDATE_ALL, recursion_left);
366        if destroyed {
367            self.game_event(
368                &vanilla_game_events::BLOCK_DESTROY,
369                pos,
370                &GameEventContext::new(entity, Some(state)),
371            );
372        }
373        destroyed
374    }
375
376    /// Drops the loot for a block using its loot table.
377    ///
378    /// This is the no-tool/no-entity overload. Player block breaking uses
379    /// `block_breaking::drop_block_loot` which includes tool context for
380    /// fortune/silk touch.
381    // TODO: block entity and entity drops
382    pub fn drop_resources(self: &Arc<Self>, state: BlockStateId, pos: BlockPos) {
383        self.drop_resources_with_entity(state, pos, None);
384    }
385
386    pub(super) fn drop_resources_with_entity(
387        self: &Arc<Self>,
388        state: BlockStateId,
389        pos: BlockPos,
390        entity: Option<&dyn Entity>,
391    ) {
392        let context = BlockLootContext::new(self, pos).with_entity(entity);
393        for item in context.get_drops(state) {
394            if !item.is_empty() {
395                self.pop_resource(pos, item);
396            }
397        }
398        BLOCK_BEHAVIORS
399            .get_behavior(state.get_block())
400            .spawn_after_break(state, self, pos, &ItemStack::empty(), true);
401    }
402
403    pub(crate) fn block_drops(
404        state: BlockStateId,
405        context: &BlockLootContext<'_>,
406    ) -> Vec<ItemStack> {
407        let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
408        behavior
409            .get_drops(state, context)
410            .unwrap_or_else(|| Self::default_block_drops(state, context))
411    }
412
413    pub(super) fn default_block_drops(
414        state: BlockStateId,
415        context: &BlockLootContext<'_>,
416    ) -> Vec<ItemStack> {
417        let block = state.get_block();
418        let loot_table = if let Some(loot_table) = block.config.loot_table {
419            loot_table
420        } else {
421            let loot_key = steel_utils::Identifier::vanilla(format!("blocks/{}", block.key.path));
422            let Some(loot_table) = REGISTRY.loot_tables.by_key(&loot_key) else {
423                return Vec::new();
424            };
425            loot_table
426        };
427
428        let mut rng = rand::rng();
429        let mut ctx = LootContext::new(&mut rng)
430            .with_luck(context.luck())
431            .with_block_state(state)
432            .with_origin(
433                f64::from(context.pos().x()),
434                f64::from(context.pos().y()),
435                f64::from(context.pos().z()),
436            );
437        if let Some(tool) = context.tool() {
438            ctx = ctx.with_tool(tool);
439        }
440        let entity_equipment = context.entity().and_then(loot_equipment);
441        if let Some(entity) = context.entity() {
442            ctx = ctx.with_this_entity(entity_loot_ref(entity, entity_equipment.as_ref()));
443        }
444        if let Some(radius) = context.explosion_radius() {
445            ctx = ctx.with_explosion(radius);
446        }
447
448        loot_table.get_random_items(&mut ctx)
449    }
450
451    /// Plays a sound at a specific position, broadcasting to nearby players.
452    ///
453    /// The sound is sent to players within its vanilla range, except for the
454    /// excluded player (if any). The excluded player is typically the one who
455    /// triggered the sound, as they hear it client-side.
456    ///
457    /// # Arguments
458    /// * `sound` - The sound event to play
459    /// * `source` - The sound source category
460    /// * `pos` - The block position (sound plays at center of block)
461    /// * `volume` - Volume multiplier (1.0 = normal)
462    /// * `pitch` - Pitch multiplier (1.0 = normal)
463    /// * `exclude` - Optional entity ID to exclude from receiving the sound
464    pub fn play_sound(
465        &self,
466        sound: SoundEventRef,
467        source: SoundSource,
468        pos: BlockPos,
469        volume: f32,
470        pitch: f32,
471        exclude: Option<i32>,
472    ) {
473        self.play_sound_at(
474            sound,
475            source,
476            DVec3::new(
477                f64::from(pos.x()) + 0.5,
478                f64::from(pos.y()) + 0.5,
479                f64::from(pos.z()) + 0.5,
480            ),
481            volume,
482            pitch,
483            exclude,
484        );
485    }
486
487    /// Plays a sound at an exact world position, broadcasting to nearby players.
488    pub fn play_sound_at(
489        &self,
490        sound: SoundEventRef,
491        source: SoundSource,
492        pos: DVec3,
493        volume: f32,
494        pitch: f32,
495        exclude: Option<i32>,
496    ) {
497        self.play_sound_holder_at(
498            &SoundEventHolder::Registry(sound),
499            source,
500            pos,
501            volume,
502            pitch,
503            exclude,
504        );
505    }
506
507    /// broadcasts a registry or direct sound holder at an exact world position
508    pub fn play_sound_holder_at(
509        &self,
510        sound: &SoundEventHolder,
511        source: SoundSource,
512        pos: DVec3,
513        volume: f32,
514        pitch: f32,
515        exclude: Option<i32>,
516    ) {
517        let chunk = ChunkPos::new(
518            SectionPos::block_to_section_coord(pos.x.floor() as i32),
519            SectionPos::block_to_section_coord(pos.z.floor() as i32),
520        );
521
522        // Generate a random seed for sound variations
523        let seed = rand::random::<i64>();
524        let packet = CSound::new_holder(sound.clone(), source, pos, volume, pitch, seed);
525        let Ok(encoded) =
526            EncodedPacket::from_bare(packet, self.compression, ConnectionProtocol::Play)
527        else {
528            log::warn!("Failed to encode sound packet");
529            return;
530        };
531
532        // Get players tracking this chunk, then apply vanilla's strict range check.
533        for entity_id in self.player_area_map.get_tracking_players(chunk) {
534            // Skip excluded player (they hear the sound client-side)
535            if exclude == Some(entity_id) {
536                continue;
537            }
538            if let Some(player) = self.players.get_by_entity_id(entity_id) {
539                let player_pos = player.position();
540                let dx = player_pos.x - pos.x;
541                let dy = player_pos.y - pos.y;
542                let dz = player_pos.z - pos.z;
543                let dist_sq = dx * dx + dy * dy + dz * dz;
544
545                if sound_is_within_range(sound, volume, dist_sq) {
546                    player.connection.send_encoded(encoded.clone());
547                }
548            }
549        }
550    }
551
552    /// Plays a sound for explicit player targets using vanilla's range fallback.
553    ///
554    /// Returns the players that received a packet, in target order.
555    #[expect(
556        clippy::too_many_arguments,
557        reason = "keeps the vanilla playsound parameters explicit"
558    )]
559    pub fn play_sound_to_players(
560        &self,
561        sound: &SoundEventHolder,
562        source: SoundSource,
563        pos: DVec3,
564        volume: f32,
565        pitch: f32,
566        min_volume: f32,
567        targets: &[Arc<Player>],
568    ) -> Vec<Arc<Player>> {
569        let max_distance = sound.range(volume);
570        let max_distance_squared = f64::from(max_distance * max_distance);
571        let seed = rand::random::<i64>();
572        let mut played_for = Vec::new();
573
574        for player in targets {
575            let player_world = player.get_world();
576            if !ptr::eq(self, player_world.as_ref()) {
577                continue;
578            }
579
580            let player_pos = player.position();
581            let Some((packet_pos, packet_volume)) =
582                sound_packet_for_player(pos, player_pos, max_distance_squared, volume, min_volume)
583            else {
584                continue;
585            };
586
587            player.send_packet(CSound::new_holder(
588                sound.clone(),
589                source,
590                packet_pos,
591                packet_volume,
592                pitch,
593                seed,
594            ));
595            played_for.push(Arc::clone(player));
596        }
597
598        played_for
599    }
600
601    /// Plays a block sound at a specific position.
602    ///
603    /// Convenience method that uses the BLOCKS sound source and applies
604    /// the sound type's volume and pitch modifiers.
605    ///
606    /// # Arguments
607    /// * `sound` - The sound event to play
608    /// * `pos` - The block position
609    /// * `volume` - Base volume (typically from `SoundType`)
610    /// * `pitch` - Base pitch (typically from `SoundType`)
611    /// * `exclude` - Optional entity ID to exclude from receiving the sound
612    pub fn play_block_sound(
613        &self,
614        sound: SoundEventRef,
615        pos: BlockPos,
616        volume: f32,
617        pitch: f32,
618        exclude: Option<i32>,
619    ) {
620        self.play_sound(sound, SoundSource::Blocks, pos, volume, pitch, exclude);
621    }
622
623    /// Returns the runtime entity manager.
624    #[must_use]
625    pub(crate) const fn entity_manager(&self) -> &WorldEntityManager {
626        &self.entity_manager
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use glam::DVec3;
633
634    use super::sound_packet_for_player;
635
636    #[test]
637    fn sound_range_fallback_matches_vanilla_boundary_and_relocation() {
638        assert_eq!(
639            sound_packet_for_player(DVec3::ZERO, DVec3::new(16.0, 0.0, 0.0), 256.0, 1.0, 0.0),
640            Some((DVec3::ZERO, 1.0))
641        );
642        assert_eq!(
643            sound_packet_for_player(DVec3::ZERO, DVec3::new(17.0, 0.0, 0.0), 256.0, 1.0, 0.0),
644            None
645        );
646        assert_eq!(
647            sound_packet_for_player(DVec3::ZERO, DVec3::new(17.0, 0.0, 0.0), 256.0, 1.0, 0.25),
648            Some((DVec3::new(15.0, 0.0, 0.0), 0.25))
649        );
650    }
651}