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::vanilla_block_tags::BlockTag;
11use steel_registry::vanilla_blocks;
12use steel_registry::vanilla_damage_types;
13use steel_registry::vanilla_dimension_types;
14use steel_utils::axis::Axis;
15use steel_utils::types::UpdateFlags;
16use steel_utils::{BlockPos, BlockStateId, Direction};
17
18use crate::behavior::block::BlockBehavior;
19use crate::behavior::context::BlockPlaceContext;
20use crate::entity::damage::DamageSource;
21use crate::entity::{Entity, InsideBlockEffectCollector, InsideBlockEffectType};
22use crate::portal::portal_shape::{PortalShape, nether_portal_config};
23use crate::world::{LevelReader, World};
24
25/// Behavior for fire blocks.
26#[block_behavior]
27pub struct FireBlock {
28    block: BlockRef,
29}
30
31impl FireBlock {
32    /// Creates a new fire block behavior.
33    #[must_use]
34    pub const fn new(block: BlockRef) -> Self {
35        Self { block }
36    }
37
38    /// Returns true if the world supports nether portal creation.
39    ///
40    /// Vanilla expresses this in terms of dimensions; Steel checks the loaded
41    /// world's vanilla dimension type.
42    pub(crate) fn in_portal_world(world: &World) -> bool {
43        world.dimension_type == &vanilla_dimension_types::OVERWORLD
44            || world.dimension_type == &vanilla_dimension_types::THE_NETHER
45    }
46
47    /// Checks if fire can be placed at `pos`, matching vanilla's `BaseFireBlock.canBePlacedAt`.
48    /// Position must be air AND (fire can survive there OR it's a valid portal location).
49    pub(crate) fn can_be_placed_at(
50        world: &Arc<World>,
51        pos: BlockPos,
52        forward_dir: Direction,
53    ) -> bool {
54        if !world.get_block_state(pos).is_air() {
55            return false;
56        }
57        Self::selected_fire_can_survive_at(world.as_ref(), pos)
58            || Self::is_portal(world, pos, forward_dir)
59    }
60
61    /// Steel equivalent of vanilla's `BaseFireBlock.getState` for selecting
62    /// between soul fire and regular fire.
63    pub(crate) fn get_state(world: &dyn LevelReader, pos: BlockPos) -> BlockStateId {
64        if SoulFireBlock::can_survive_at(world, pos) {
65            vanilla_blocks::SOUL_FIRE.default_state()
66        } else {
67            vanilla_blocks::FIRE.default_state()
68        }
69    }
70
71    fn selected_fire_can_survive_at(world: &dyn LevelReader, pos: BlockPos) -> bool {
72        SoulFireBlock::can_survive_at(world, pos) || Self::can_survive_at(world, pos)
73    }
74
75    /// Matches vanilla's `FireBlock.canSurvive`: block below has a sturdy top face,
76    /// or an adjacent block is flammable.
77    fn can_survive_at(world: &dyn LevelReader, pos: BlockPos) -> bool {
78        let below_pos = pos.below();
79        world.is_face_sturdy(world.get_block_state(below_pos), below_pos, Direction::Up)
80        // TODO: || is_valid_fire_location (check adjacent flammable blocks once flammability exists)
81    }
82
83    /// Matches vanilla's `BaseFireBlock.isPortal`: checks if placing fire here could form a portal.
84    /// Requires a portal-capable world, adjacent obsidian, and a valid empty portal shape.
85    fn is_portal(world: &Arc<World>, pos: BlockPos, forward_dir: Direction) -> bool {
86        if !Self::in_portal_world(world) {
87            return false;
88        }
89
90        let has_obsidian = Direction::ALL.iter().any(|&dir| {
91            world.get_block_state(pos.relative(dir)).get_block() == &vanilla_blocks::OBSIDIAN
92        });
93        if !has_obsidian {
94            return false;
95        }
96
97        let preferred_axis = if forward_dir.get_axis().is_horizontal() {
98            forward_dir.rotate_y_counter_clockwise().get_axis()
99        } else if rand::random::<bool>() {
100            Axis::X
101        } else {
102            Axis::Z
103        };
104
105        let config = nether_portal_config();
106        PortalShape::find_empty_portal_shape_with_axis(world, pos, preferred_axis, &config)
107            .is_some()
108    }
109
110    fn queue_entity_contact_effects(
111        effect_collector: &mut InsideBlockEffectCollector,
112        fire_damage: f32,
113    ) {
114        effect_collector.apply(InsideBlockEffectType::ClearFreeze);
115        effect_collector.apply(InsideBlockEffectType::FireIgnite);
116        effect_collector.run_after(
117            InsideBlockEffectType::FireIgnite,
118            Box::new(move |entity| {
119                if !entity.fire_immune()
120                    && let Some(entity_world) = entity.level()
121                {
122                    entity.hurt(
123                        &entity_world,
124                        &DamageSource::environment(&vanilla_damage_types::IN_FIRE),
125                        fire_damage,
126                    );
127                }
128            }),
129        );
130    }
131}
132
133impl BlockBehavior for FireBlock {
134    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
135        if SoulFireBlock::can_survive_at(context.world.as_ref(), context.place_pos()) {
136            Some(vanilla_blocks::SOUL_FIRE.default_state())
137        } else {
138            Some(self.block.default_state())
139        }
140    }
141
142    fn can_survive(&self, _state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool {
143        Self::can_survive_at(world, pos)
144    }
145
146    fn entity_inside(
147        &self,
148        _state: BlockStateId,
149        _world: &Arc<World>,
150        _pos: BlockPos,
151        _entity: &dyn Entity,
152        effect_collector: &mut InsideBlockEffectCollector,
153        _is_precise: bool,
154    ) {
155        Self::queue_entity_contact_effects(effect_collector, 1.0);
156    }
157
158    fn on_place(
159        &self,
160        state: BlockStateId,
161        world: &Arc<World>,
162        pos: BlockPos,
163        old_state: BlockStateId,
164        _moved_by_piston: bool,
165    ) {
166        // Only attempt portal creation when fire is newly placed, not when replacing itself
167        if old_state.get_block() == state.get_block() {
168            return;
169        }
170
171        if Self::in_portal_world(world)
172            && let Some(shape) =
173                PortalShape::find_empty_portal_shape(world, pos, &nether_portal_config())
174        {
175            shape.place_portal_blocks(world);
176            return;
177        }
178
179        if !self.can_survive(state, world, pos) {
180            world.set_block(
181                pos,
182                vanilla_blocks::AIR.default_state(),
183                UpdateFlags::UPDATE_ALL,
184            );
185        }
186    }
187}
188
189/// Behavior for soul fire survival.
190///
191/// Vanilla keeps this as `SoulFireBlock`, separate from normal `FireBlock`.
192/// Spread/burn behavior is still TODO with the rest of fire ticking.
193// TODO: Implement full vanilla behavior beyond can_survive.
194#[block_behavior]
195pub struct SoulFireBlock {
196    block: BlockRef,
197}
198
199impl SoulFireBlock {
200    /// Creates a new soul fire block behavior.
201    #[must_use]
202    pub const fn new(block: BlockRef) -> Self {
203        Self { block }
204    }
205
206    fn can_survive_at(world: &dyn LevelReader, pos: BlockPos) -> bool {
207        let block_below = world.get_block_state(pos.below()).get_block();
208        block_below.has_tag(&BlockTag::SOUL_FIRE_BASE_BLOCKS)
209    }
210}
211
212impl BlockBehavior for SoulFireBlock {
213    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
214        let state = self.block.default_state();
215        self.can_survive(state, context.world, context.place_pos())
216            .then_some(state)
217    }
218
219    fn can_survive(&self, _state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool {
220        Self::can_survive_at(world, pos)
221    }
222
223    fn entity_inside(
224        &self,
225        _state: BlockStateId,
226        _world: &Arc<World>,
227        _pos: BlockPos,
228        _entity: &dyn Entity,
229        effect_collector: &mut InsideBlockEffectCollector,
230        _is_precise: bool,
231    ) {
232        FireBlock::queue_entity_contact_effects(effect_collector, 2.0);
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use steel_registry::{
239        blocks::block_state_ext::BlockStateExt, init_vanilla_registry, vanilla_blocks,
240    };
241    use steel_utils::{BlockPos, BlockStateId};
242
243    use crate::test_support::TestLevel;
244
245    use super::FireBlock;
246
247    const POS: BlockPos = BlockPos::new(0, 64, 0);
248
249    fn level_with_support(support_state: BlockStateId) -> TestLevel {
250        TestLevel::default()
251            .with_min_y(0)
252            .with_block(POS.below(), support_state)
253    }
254
255    #[test]
256    fn get_state_selects_soul_fire_on_soul_fire_base_block() {
257        init_vanilla_registry();
258
259        let level = level_with_support(vanilla_blocks::SOUL_SAND.default_state());
260
261        assert_eq!(
262            FireBlock::get_state(&level, POS).get_block(),
263            &vanilla_blocks::SOUL_FIRE
264        );
265        assert!(FireBlock::selected_fire_can_survive_at(&level, POS));
266    }
267
268    #[test]
269    fn get_state_selects_regular_fire_otherwise() {
270        init_vanilla_registry();
271
272        let level = level_with_support(vanilla_blocks::STONE.default_state());
273
274        assert_eq!(
275            FireBlock::get_state(&level, POS).get_block(),
276            &vanilla_blocks::FIRE
277        );
278    }
279}