Skip to main content

steel_core/world/
level_effects.rs

1use super::{
2    Arc, BLOCK_BEHAVIORS, BlockLootContext, BlockPos, BlockStateExt, BlockStateId, CLevelEvent,
3    CLevelParticles, CSound, ChunkPos, ConnectionProtocol, DVec3, EncodedPacket, Entity,
4    GLOBAL_SOUND_EVENTS, GameEventContext, ItemStack, LevelReader, LootContext, NetworkConnection,
5    ParticleData, Player, REGISTRY, RegistryExt, SectionPos, SoundEventRef, SoundSource,
6    UpdateFlags, World, WorldEntityManager, entity_loot_ref, fluid_state_to_block, level_events,
7    vanilla_blocks, vanilla_game_events,
8};
9
10pub(super) fn sound_is_within_range(
11    sound: SoundEventRef,
12    volume: f32,
13    distance_squared: f64,
14) -> bool {
15    let range = f64::from(sound.range(volume));
16    distance_squared < range * range
17}
18
19impl World {
20    /// Broadcasts a level event to nearby players within 64 blocks.
21    ///
22    /// Level events trigger sounds, particles, and animations on the client.
23    /// See `steel_registry::level_events` for available event type constants.
24    ///
25    /// # Arguments
26    /// * `event_type` - The event type ID from `steel_registry::level_events`
27    /// * `pos` - The position where the event occurs
28    /// * `data` - Event-specific data (e.g., block state ID for block destruction)
29    /// * `exclude` - Optional entity ID to exclude from receiving the event
30    pub fn level_event(&self, event_type: i32, pos: BlockPos, data: i32, exclude: Option<i32>) {
31        let packet = CLevelEvent::new(event_type, pos, data, false);
32        let Ok(encoded) =
33            EncodedPacket::from_bare(packet, self.compression, ConnectionProtocol::Play)
34        else {
35            log::warn!("Failed to encode level event packet");
36            return;
37        };
38
39        self.players.iter_players(|_, player| {
40            if exclude != Some(player.id())
41                && Self::recipient_within_64_blocks(player.position(), pos)
42            {
43                player.connection.send_encoded(encoded.clone());
44            }
45            true
46        });
47    }
48
49    pub(super) fn recipient_within_64_blocks(player_pos: DVec3, event_pos: BlockPos) -> bool {
50        const MAX_DISTANCE_SQ: f64 = 64.0 * 64.0;
51
52        let dx = f64::from(event_pos.x()) - player_pos.x;
53        let dy = f64::from(event_pos.y()) - player_pos.y;
54        let dz = f64::from(event_pos.z()) - player_pos.z;
55        dx * dx + dy * dy + dz * dz < MAX_DISTANCE_SQ
56    }
57
58    /// Sends a particle distribution to every player within Vanilla's normal
59    /// 32-block particle radius.
60    pub fn send_particles(
61        &self,
62        particle: ParticleData,
63        position: DVec3,
64        count: i32,
65        spread: DVec3,
66        speed: f64,
67    ) -> i32 {
68        self.send_particles_with_options(particle, false, false, position, count, spread, speed)
69    }
70
71    /// Sends a particle distribution with the packet visibility flags selected
72    /// explicitly. `override_limiter` also expands the server recipient radius
73    /// from 32 to 512 blocks, matching `ServerLevel.sendParticles`.
74    #[expect(
75        clippy::too_many_arguments,
76        reason = "keeps Vanilla's two particle visibility flags explicit"
77    )]
78    pub fn send_particles_with_options(
79        &self,
80        particle: ParticleData,
81        override_limiter: bool,
82        always_show: bool,
83        position: DVec3,
84        count: i32,
85        spread: DVec3,
86        speed: f64,
87    ) -> i32 {
88        let packet = CLevelParticles {
89            override_limiter,
90            always_show,
91            x: position.x,
92            y: position.y,
93            z: position.z,
94            x_dist: spread.x as f32,
95            y_dist: spread.y as f32,
96            z_dist: spread.z as f32,
97            max_speed: speed as f32,
98            count,
99            particle,
100        };
101        let Ok(encoded) =
102            EncodedPacket::from_bare(packet, self.compression, ConnectionProtocol::Play)
103        else {
104            log::warn!("Failed to encode level particles packet");
105            return 0;
106        };
107        let mut sent = 0;
108        self.players.iter_players(|_, player| {
109            if Self::particle_recipient_in_range(
110                player.block_position(),
111                position,
112                override_limiter,
113            ) {
114                player.connection.send_encoded(encoded.clone());
115                sent += 1;
116            }
117            true
118        });
119        sent
120    }
121
122    /// Sends a particle distribution to one player if they are in this world
123    /// and within Vanilla's particle recipient radius.
124    #[expect(
125        clippy::too_many_arguments,
126        reason = "mirrors Vanilla ServerLevel.sendParticles"
127    )]
128    pub fn send_particles_to(
129        self: &Arc<Self>,
130        player: &Player,
131        particle: ParticleData,
132        override_limiter: bool,
133        always_show: bool,
134        position: DVec3,
135        count: i32,
136        spread: DVec3,
137        speed: f64,
138    ) -> bool {
139        if !Arc::ptr_eq(self, &player.get_world())
140            || !Self::particle_recipient_in_range(
141                player.block_position(),
142                position,
143                override_limiter,
144            )
145        {
146            return false;
147        }
148
149        let packet = CLevelParticles {
150            override_limiter,
151            always_show,
152            x: position.x,
153            y: position.y,
154            z: position.z,
155            x_dist: spread.x as f32,
156            y_dist: spread.y as f32,
157            z_dist: spread.z as f32,
158            max_speed: speed as f32,
159            count,
160            particle,
161        };
162        let Ok(encoded) =
163            EncodedPacket::from_bare(packet, self.compression, ConnectionProtocol::Play)
164        else {
165            log::warn!("Failed to encode level particles packet");
166            return false;
167        };
168        player.connection.send_encoded(encoded);
169        true
170    }
171
172    pub(super) fn particle_recipient_in_range(
173        player_block_pos: BlockPos,
174        particle_pos: DVec3,
175        override_limiter: bool,
176    ) -> bool {
177        let (x, y, z) = player_block_pos.get_center();
178        let radius = if override_limiter { 512.0 } else { 32.0 };
179        DVec3::new(x, y, z).distance_squared(particle_pos) < radius * radius
180    }
181
182    /// Broadcasts a global level event to all players in the world.
183    ///
184    /// When `global_sound_events` is disabled, vanilla falls back to a normal
185    /// nearby level event with the packet's global flag unset.
186    ///
187    /// # Arguments
188    /// * `event_type` - The event type ID from `steel_registry::level_events`
189    /// * `pos` - The position where the event occurs
190    /// * `data` - Event-specific data
191    pub fn global_level_event(&self, event_type: i32, pos: BlockPos, data: i32) {
192        if !self.get_game_rule(&GLOBAL_SOUND_EVENTS) {
193            self.level_event(event_type, pos, data, None);
194            return;
195        }
196
197        let packet = CLevelEvent::new(event_type, pos, data, true);
198        self.players.iter_players(|_, player| {
199            player.send_packet(packet.clone());
200            true
201        });
202    }
203
204    /// Broadcasts block destruction particles and sound for a destroyed block.
205    ///
206    /// This is a convenience method that sends the `PARTICLES_DESTROY_BLOCK` level event.
207    ///
208    /// # Arguments
209    /// * `pos` - The position of the destroyed block
210    /// * `block_state_id` - The block state ID of the destroyed block
211    /// * `exclude` - Optional entity ID to exclude from receiving the event
212    pub fn destroy_block_effect(&self, pos: BlockPos, block_state_id: u32, exclude: Option<i32>) {
213        self.level_event(
214            level_events::PARTICLES_DESTROY_BLOCK,
215            pos,
216            block_state_id as i32,
217            exclude,
218        );
219    }
220
221    /// Destroys a block at the given position, optionally dropping its loot.
222    ///
223    /// Sends destruction particles (skipping fire blocks), optionally drops
224    /// resources via loot table, then replaces with air.
225    ///
226    /// Defaults to recursion limit of 512
227    pub fn destroy_block(self: &Arc<Self>, pos: BlockPos, drop_items: bool) -> bool {
228        self.destroy_block_with_limit(pos, drop_items, 512)
229    }
230
231    /// Replaces a block with its fluid state's legacy block.
232    ///
233    /// Mirrors vanilla `Level.removeBlock`, including the piston-move update flag.
234    pub fn remove_block(self: &Arc<Self>, pos: BlockPos, moved_by_piston: bool) -> bool {
235        let state = self.get_block_state(pos);
236        let replacement = fluid_state_to_block(state.get_fluid_state());
237        let mut flags = UpdateFlags::UPDATE_ALL;
238        if moved_by_piston {
239            flags |= UpdateFlags::UPDATE_MOVE_BY_PISTON;
240        }
241        self.set_block(pos, replacement, flags)
242    }
243
244    /// Destroys a block with an entity source for game-event context.
245    pub fn destroy_block_by_entity(
246        self: &Arc<Self>,
247        pos: BlockPos,
248        drop_items: bool,
249        entity: &dyn Entity,
250    ) -> bool {
251        self.destroy_block_with_limit_and_entity(pos, drop_items, 512, Some(entity))
252    }
253
254    /// Destroys a block at the given position, optionally dropping its loot.
255    ///
256    /// Sends destruction particles (skipping fire blocks), optionally drops
257    /// resources via loot table, then replaces with air.
258    pub fn destroy_block_with_limit(
259        self: &Arc<Self>,
260        pos: BlockPos,
261        drop_items: bool,
262        recursion_left: i32,
263    ) -> bool {
264        self.destroy_block_with_limit_and_entity(pos, drop_items, recursion_left, None)
265    }
266
267    pub(super) fn destroy_block_with_limit_and_entity(
268        self: &Arc<Self>,
269        pos: BlockPos,
270        drop_items: bool,
271        recursion_left: i32,
272        entity: Option<&dyn Entity>,
273    ) -> bool {
274        let state = self.get_block_state(pos);
275        if state.is_air() {
276            return false;
277        }
278
279        let block = state.get_block();
280        let is_fire = block == &vanilla_blocks::FIRE || block == &vanilla_blocks::SOUL_FIRE;
281        if !is_fire {
282            self.destroy_block_effect(pos, u32::from(state.0), None);
283        }
284
285        if drop_items {
286            self.drop_resources_with_entity(state, pos, entity);
287            // TODO: block entity drops
288        }
289
290        // Vanilla parity: fluidState.createLegacyBlock() — breaking a waterlogged
291        // block leaves water behind instead of air.
292        let replacement = fluid_state_to_block(state.get_fluid_state());
293        let destroyed =
294            self.set_block_with_limit(pos, replacement, UpdateFlags::UPDATE_ALL, recursion_left);
295        if destroyed {
296            self.game_event(
297                &vanilla_game_events::BLOCK_DESTROY,
298                pos,
299                &GameEventContext::new(entity, Some(state)),
300            );
301        }
302        destroyed
303    }
304
305    /// Drops the loot for a block using its loot table.
306    ///
307    /// This is the no-tool/no-entity overload. Player block breaking uses
308    /// `block_breaking::drop_block_loot` which includes tool context for
309    /// fortune/silk touch.
310    // TODO: block entity and entity drops
311    pub fn drop_resources(self: &Arc<Self>, state: BlockStateId, pos: BlockPos) {
312        self.drop_resources_with_entity(state, pos, None);
313    }
314
315    pub(super) fn drop_resources_with_entity(
316        self: &Arc<Self>,
317        state: BlockStateId,
318        pos: BlockPos,
319        entity: Option<&dyn Entity>,
320    ) {
321        let context = BlockLootContext::new(self, pos).with_entity(entity);
322        for item in context.get_drops(state) {
323            if !item.is_empty() {
324                self.pop_resource(pos, item);
325            }
326        }
327        BLOCK_BEHAVIORS
328            .get_behavior(state.get_block())
329            .spawn_after_break(state, self, pos, &ItemStack::empty(), true);
330    }
331
332    pub(crate) fn block_drops(
333        state: BlockStateId,
334        context: &BlockLootContext<'_>,
335    ) -> Vec<ItemStack> {
336        let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
337        behavior
338            .get_drops(state, context)
339            .unwrap_or_else(|| Self::default_block_drops(state, context))
340    }
341
342    pub(super) fn default_block_drops(
343        state: BlockStateId,
344        context: &BlockLootContext<'_>,
345    ) -> Vec<ItemStack> {
346        let block = state.get_block();
347        let loot_key = steel_utils::Identifier::vanilla(format!("blocks/{}", block.key.path));
348
349        let Some(loot_table) = REGISTRY.loot_tables.by_key(&loot_key) else {
350            return Vec::new();
351        };
352
353        let mut rng = rand::rng();
354        let mut ctx = LootContext::new(&mut rng)
355            .with_luck(context.luck())
356            .with_block_state(state)
357            .with_origin(
358                f64::from(context.pos().x()),
359                f64::from(context.pos().y()),
360                f64::from(context.pos().z()),
361            );
362        if let Some(tool) = context.tool() {
363            ctx = ctx.with_tool(tool);
364        }
365        if let Some(entity) = context.entity() {
366            ctx = ctx.with_this_entity(entity_loot_ref(entity));
367        }
368
369        loot_table.get_random_items(&mut ctx)
370    }
371
372    /// Plays a sound at a specific position, broadcasting to nearby players.
373    ///
374    /// The sound is sent to players within its vanilla range, except for the
375    /// excluded player (if any). The excluded player is typically the one who
376    /// triggered the sound, as they hear it client-side.
377    ///
378    /// # Arguments
379    /// * `sound` - The sound event to play
380    /// * `source` - The sound source category
381    /// * `pos` - The block position (sound plays at center of block)
382    /// * `volume` - Volume multiplier (1.0 = normal)
383    /// * `pitch` - Pitch multiplier (1.0 = normal)
384    /// * `exclude` - Optional entity ID to exclude from receiving the sound
385    pub fn play_sound(
386        &self,
387        sound: SoundEventRef,
388        source: SoundSource,
389        pos: BlockPos,
390        volume: f32,
391        pitch: f32,
392        exclude: Option<i32>,
393    ) {
394        self.play_sound_at(
395            sound,
396            source,
397            DVec3::new(
398                f64::from(pos.x()) + 0.5,
399                f64::from(pos.y()) + 0.5,
400                f64::from(pos.z()) + 0.5,
401            ),
402            volume,
403            pitch,
404            exclude,
405        );
406    }
407
408    /// Plays a sound at an exact world position, broadcasting to nearby players.
409    pub fn play_sound_at(
410        &self,
411        sound: SoundEventRef,
412        source: SoundSource,
413        pos: DVec3,
414        volume: f32,
415        pitch: f32,
416        exclude: Option<i32>,
417    ) {
418        let chunk = ChunkPos::new(
419            SectionPos::block_to_section_coord(pos.x.floor() as i32),
420            SectionPos::block_to_section_coord(pos.z.floor() as i32),
421        );
422
423        // Generate a random seed for sound variations
424        let seed = rand::random::<i64>();
425        let packet = CSound::new(sound, source, pos, volume, pitch, seed);
426        let Ok(encoded) =
427            EncodedPacket::from_bare(packet, self.compression, ConnectionProtocol::Play)
428        else {
429            log::warn!("Failed to encode sound packet");
430            return;
431        };
432
433        // Get players tracking this chunk, then apply vanilla's strict range check.
434        for entity_id in self.player_area_map.get_tracking_players(chunk) {
435            // Skip excluded player (they hear the sound client-side)
436            if exclude == Some(entity_id) {
437                continue;
438            }
439            if let Some(player) = self.players.get_by_entity_id(entity_id) {
440                let player_pos = player.position();
441                let dx = player_pos.x - pos.x;
442                let dy = player_pos.y - pos.y;
443                let dz = player_pos.z - pos.z;
444                let dist_sq = dx * dx + dy * dy + dz * dz;
445
446                if sound_is_within_range(sound, volume, dist_sq) {
447                    player.connection.send_encoded(encoded.clone());
448                }
449            }
450        }
451    }
452
453    /// Plays a block sound at a specific position.
454    ///
455    /// Convenience method that uses the BLOCKS sound source and applies
456    /// the sound type's volume and pitch modifiers.
457    ///
458    /// # Arguments
459    /// * `sound` - The sound event to play
460    /// * `pos` - The block position
461    /// * `volume` - Base volume (typically from `SoundType`)
462    /// * `pitch` - Base pitch (typically from `SoundType`)
463    /// * `exclude` - Optional entity ID to exclude from receiving the sound
464    pub fn play_block_sound(
465        &self,
466        sound: SoundEventRef,
467        pos: BlockPos,
468        volume: f32,
469        pitch: f32,
470        exclude: Option<i32>,
471    ) {
472        self.play_sound(sound, SoundSource::Blocks, pos, volume, pitch, exclude);
473    }
474
475    /// Returns the runtime entity manager.
476    #[must_use]
477    pub(crate) const fn entity_manager(&self) -> &WorldEntityManager {
478        &self.entity_manager
479    }
480}