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