Skip to main content

steel_core/behavior/blocks/portal/
fire.rs

1//! Fire block behavior implementation.
2//!
3//! Vanilla splits fire into `BaseFireBlock` (portal logic, placement checks) and `FireBlock`
4//! (spreading, aging). This combines the portal-relevant parts from `BaseFireBlock`.
5
6use std::sync::Arc;
7use steel_macros::block_behavior;
8use steel_registry::blocks::BlockRef;
9use steel_registry::blocks::block_state_ext::BlockStateExt;
10use steel_registry::level_events;
11use steel_registry::vanilla_block_tags::BlockTag;
12use steel_registry::vanilla_blocks;
13use steel_registry::vanilla_damage_types;
14use steel_registry::vanilla_dimension_types;
15use steel_utils::axis::Axis;
16use steel_utils::types::UpdateFlags;
17use steel_utils::{BlockPos, BlockStateId, Direction};
18
19use crate::behavior::block::BlockBehavior;
20use crate::behavior::context::BlockPlaceContext;
21use crate::entity::damage::DamageSource;
22use crate::entity::{Entity, InsideBlockEffectCollector, InsideBlockEffectType};
23use crate::player::Player;
24use crate::portal::portal_shape::{PortalShape, nether_portal_config};
25use crate::world::{LevelReader, ScheduledTickAccess, World};
26/// Behavior for fire blocks.
27#[block_behavior]
28pub struct FireBlock {
29    block: BlockRef,
30}
31
32impl FireBlock {
33    /// Creates a new fire block behavior.
34    #[must_use]
35    pub const fn new(block: BlockRef) -> Self {
36        Self { block }
37    }
38
39    /// Returns true if the world supports nether portal creation.
40    ///
41    /// Vanilla expresses this in terms of dimensions; Steel checks the loaded
42    /// world's vanilla dimension type.
43    pub(crate) fn in_portal_world(world: &World) -> bool {
44        world.dimension_type == &vanilla_dimension_types::OVERWORLD
45            || world.dimension_type == &vanilla_dimension_types::THE_NETHER
46    }
47
48    /// Checks if fire can be placed at `pos`, matching vanilla's `BaseFireBlock.canBePlacedAt`.
49    /// Position must be air AND (fire can survive there OR it's a valid portal location).
50    pub(crate) fn can_be_placed_at(
51        world: &Arc<World>,
52        pos: BlockPos,
53        forward_dir: Direction,
54    ) -> bool {
55        if !world.get_block_state(pos).is_air() {
56            return false;
57        }
58        Self::selected_fire_can_survive_at(world.as_ref(), pos)
59            || Self::is_portal(world, pos, forward_dir)
60    }
61
62    /// Steel equivalent of vanilla's `BaseFireBlock.getState` for selecting
63    /// between soul fire and regular fire.
64    pub(crate) fn get_state(world: &dyn LevelReader, pos: BlockPos) -> BlockStateId {
65        if SoulFireBlock::can_survive_at(world, pos) {
66            vanilla_blocks::SOUL_FIRE.default_state()
67        } else {
68            vanilla_blocks::FIRE.default_state()
69        }
70    }
71
72    fn selected_fire_can_survive_at(world: &dyn LevelReader, pos: BlockPos) -> bool {
73        SoulFireBlock::can_survive_at(world, pos) || Self::can_survive_at(world, pos)
74    }
75
76    /// Matches vanilla's `FireBlock.canSurvive`: block below has a sturdy top face,
77    /// or an adjacent block is flammable.
78    fn can_survive_at(world: &dyn LevelReader, pos: BlockPos) -> bool {
79        let below_pos = pos.below();
80        world.is_face_sturdy(world.get_block_state(below_pos), below_pos, Direction::Up)
81        // TODO: Include adjacent flammable blocks once the flammability system exists.
82    }
83
84    /// Matches vanilla's `BaseFireBlock.isPortal`: checks if placing fire here could form a portal.
85    /// Requires a portal-capable world, adjacent obsidian, and a valid empty portal shape.
86    fn is_portal(world: &Arc<World>, pos: BlockPos, forward_dir: Direction) -> bool {
87        if !Self::in_portal_world(world) {
88            return false;
89        }
90
91        let has_obsidian = Direction::ALL.iter().any(|&dir| {
92            world.get_block_state(pos.relative(dir)).get_block() == &vanilla_blocks::OBSIDIAN
93        });
94        if !has_obsidian {
95            return false;
96        }
97
98        let preferred_axis = if forward_dir.get_axis().is_horizontal() {
99            forward_dir.rotate_y_counter_clockwise().get_axis()
100        } else if rand::random::<bool>() {
101            Axis::X
102        } else {
103            Axis::Z
104        };
105
106        let config = nether_portal_config();
107        PortalShape::find_empty_portal_shape_with_axis(world, pos, preferred_axis, &config)
108            .is_some()
109    }
110
111    fn queue_entity_contact_effects(
112        effect_collector: &mut InsideBlockEffectCollector,
113        fire_damage: f32,
114    ) {
115        effect_collector.apply(InsideBlockEffectType::ClearFreeze);
116        effect_collector.apply(InsideBlockEffectType::FireIgnite);
117        effect_collector.run_after(
118            InsideBlockEffectType::FireIgnite,
119            Box::new(move |entity| {
120                if !entity.fire_immune()
121                    && let Some(entity_world) = entity.level()
122                {
123                    entity.hurt(
124                        &entity_world,
125                        &DamageSource::environment(&vanilla_damage_types::IN_FIRE),
126                        fire_damage,
127                    );
128                }
129            }),
130        );
131    }
132}
133
134impl BlockBehavior for FireBlock {
135    // TODO: Implement vanilla random tick behavior for fire spreading and aging
136    /// Removes fire when its supporting block no longer allows it to survive
137    fn update_shape(
138        &self,
139        state: BlockStateId,
140        world: &dyn ScheduledTickAccess,
141        pos: BlockPos,
142        direction: Direction,
143        _neighbor_pos: BlockPos,
144        _neighbor_state: BlockStateId,
145    ) -> BlockStateId {
146        if direction == Direction::Down && !self.can_survive(state, world, pos) {
147            return vanilla_blocks::AIR.default_state();
148        }
149        state
150    }
151    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
152        if SoulFireBlock::can_survive_at(context.world.as_ref(), context.place_pos()) {
153            Some(vanilla_blocks::SOUL_FIRE.default_state())
154        } else {
155            Some(self.block.default_state())
156        }
157    }
158
159    fn can_survive(&self, _state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool {
160        Self::can_survive_at(world, pos)
161    }
162
163    fn entity_inside(
164        &self,
165        _state: BlockStateId,
166        _world: &Arc<World>,
167        _pos: BlockPos,
168        _entity: &dyn Entity,
169        effect_collector: &mut InsideBlockEffectCollector,
170        _is_precise: bool,
171    ) {
172        Self::queue_entity_contact_effects(effect_collector, 1.0);
173    }
174
175    fn on_place(
176        &self,
177        state: BlockStateId,
178        world: &Arc<World>,
179        pos: BlockPos,
180        old_state: BlockStateId,
181        _moved_by_piston: bool,
182    ) {
183        // Only attempt portal creation when fire is newly placed, not when replacing itself
184        if old_state.get_block() == state.get_block() {
185            return;
186        }
187
188        if Self::in_portal_world(world)
189            && let Some(shape) =
190                PortalShape::find_empty_portal_shape(world, pos, &nether_portal_config())
191        {
192            shape.place_portal_blocks(world);
193            return;
194        }
195
196        if !self.can_survive(state, world, pos) {
197            world.set_block(
198                pos,
199                vanilla_blocks::AIR.default_state(),
200                UpdateFlags::UPDATE_ALL,
201            );
202        }
203    }
204    fn player_will_destroy(
205        &self,
206        state: BlockStateId,
207        world: &Arc<World>,
208        pos: BlockPos,
209        _player: &Player,
210    ) -> BlockStateId {
211        world.level_event(level_events::SOUND_EXTINGUISH_FIRE, pos, 0, None);
212        state
213    }
214}
215
216/// Behavior for soul fire survival.
217///
218/// Vanilla keeps this as `SoulFireBlock`, separate from normal `FireBlock`.
219
220#[block_behavior]
221pub struct SoulFireBlock {
222    block: BlockRef,
223}
224
225impl SoulFireBlock {
226    /// Creates a new soul fire block behavior.
227    #[must_use]
228    pub const fn new(block: BlockRef) -> Self {
229        Self { block }
230    }
231
232    fn can_survive_at(world: &dyn LevelReader, pos: BlockPos) -> bool {
233        let block_below = world.get_block_state(pos.below()).get_block();
234        block_below.has_tag(&BlockTag::SOUL_FIRE_BASE_BLOCKS)
235    }
236}
237
238impl BlockBehavior for SoulFireBlock {
239    /// Removes soul fire when its supporting block no longer allows it to survive
240    fn update_shape(
241        &self,
242        state: BlockStateId,
243        world: &dyn ScheduledTickAccess,
244        pos: BlockPos,
245        direction: Direction,
246        _neighbor_pos: BlockPos,
247        _neighbor_state: BlockStateId,
248    ) -> BlockStateId {
249        if direction == Direction::Down && !self.can_survive(state, world, pos) {
250            return vanilla_blocks::AIR.default_state();
251        }
252        state
253    }
254
255    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
256        let state = self.block.default_state();
257        self.can_survive(state, context.world, context.place_pos())
258            .then_some(state)
259    }
260
261    fn can_survive(&self, _state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool {
262        Self::can_survive_at(world, pos)
263    }
264
265    fn entity_inside(
266        &self,
267        _state: BlockStateId,
268        _world: &Arc<World>,
269        _pos: BlockPos,
270        _entity: &dyn Entity,
271        effect_collector: &mut InsideBlockEffectCollector,
272        _is_precise: bool,
273    ) {
274        FireBlock::queue_entity_contact_effects(effect_collector, 2.0);
275    }
276    fn player_will_destroy(
277        &self,
278        state: BlockStateId,
279        world: &Arc<World>,
280        pos: BlockPos,
281        _player: &Player,
282    ) -> BlockStateId {
283        world.level_event(level_events::SOUND_EXTINGUISH_FIRE, pos, 0, None);
284        state
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use steel_registry::{
291        blocks::block_state_ext::BlockStateExt, init_vanilla_registry, vanilla_blocks,
292    };
293    use steel_utils::{BlockPos, BlockStateId, Direction};
294
295    use crate::behavior::block::BlockBehavior;
296    use crate::test_support::TestLevel;
297
298    use super::{FireBlock, SoulFireBlock};
299
300    const POS: BlockPos = BlockPos::new(0, 64, 0);
301
302    fn level_with_support(support_state: BlockStateId) -> TestLevel {
303        TestLevel::default()
304            .with_min_y(0)
305            .with_block(POS.below(), support_state)
306    }
307
308    #[test]
309    fn get_state_selects_soul_fire_on_soul_fire_base_block() {
310        init_vanilla_registry();
311
312        let level = level_with_support(vanilla_blocks::SOUL_SAND.default_state());
313
314        assert_eq!(
315            FireBlock::get_state(&level, POS).get_block(),
316            &vanilla_blocks::SOUL_FIRE
317        );
318        assert!(FireBlock::selected_fire_can_survive_at(&level, POS));
319    }
320
321    #[test]
322    fn get_state_selects_regular_fire_otherwise() {
323        init_vanilla_registry();
324
325        let level = level_with_support(vanilla_blocks::STONE.default_state());
326
327        assert_eq!(
328            FireBlock::get_state(&level, POS).get_block(),
329            &vanilla_blocks::FIRE
330        );
331    }
332    #[test]
333    fn update_shape_removes_unsupported_fire() {
334        init_vanilla_registry();
335        let behavior = FireBlock::new(&vanilla_blocks::FIRE);
336        let state = vanilla_blocks::FIRE.default_state();
337        let level = TestLevel::default();
338        let result = behavior.update_shape(
339            state,
340            &level,
341            POS,
342            Direction::Down,
343            POS.below(),
344            vanilla_blocks::AIR.default_state(),
345        );
346        assert_eq!(result.get_block(), &vanilla_blocks::AIR);
347    }
348
349    #[test]
350    fn update_shape_removes_unsupported_soul_fire() {
351        init_vanilla_registry();
352        let behavior = SoulFireBlock::new(&vanilla_blocks::SOUL_FIRE);
353        let state = vanilla_blocks::SOUL_FIRE.default_state();
354        let level = TestLevel::default();
355        let result = behavior.update_shape(
356            state,
357            &level,
358            POS,
359            Direction::Down,
360            POS.below(),
361            vanilla_blocks::AIR.default_state(),
362        );
363        assert_eq!(result.get_block(), &vanilla_blocks::AIR);
364    }
365}