Skip to main content

steel_core/portal/
portal_shape.rs

1//! Portal shape detection for validating obsidian frames.
2
3use glam::DVec3;
4use std::sync::Arc;
5use steel_math::inverse_lerp;
6use steel_registry::blocks::BlockRef;
7use steel_registry::blocks::block_state_ext::BlockStateExt;
8use steel_registry::blocks::properties::BlockStateProperties;
9use steel_registry::entity_type::EntityDimensions;
10use steel_registry::vanilla_block_tags::BlockTag;
11use steel_registry::vanilla_blocks;
12use steel_utils::axis::Axis;
13use steel_utils::block_util::FoundRectangle;
14use steel_utils::types::UpdateFlags;
15use steel_utils::{BlockPos, Direction, WorldAabb};
16
17use crate::entity::Entity;
18use crate::physics::WorldCollisionProvider;
19use crate::world::{LevelReader, World};
20
21/// A detected portal shape with axis, position, and dimensions.
22pub struct PortalShape {
23    /// The axis of the portal (X or Z).
24    pub axis: Axis,
25    /// Bottom-left corner of the portal interior.
26    pub bottom_left: BlockPos,
27    /// Width of the interior (2-21).
28    pub width: u32,
29    /// Height of the interior (3-21).
30    pub height: u32,
31    /// The horizontal direction along which width is measured.
32    pub right_dir: Direction,
33    /// The block type of the portal.
34    pub portal: BlockRef,
35    /// Number of portal blocks found in the interior.
36    /// Used by `is_complete` to verify the portal is fully filled.
37    num_portal_blocks: u32,
38}
39
40/// Definition of a portal shape in rectangular form, like the nether portal frame.
41pub struct PortalFrameConfig {
42    /// min size of the portal in x direction
43    pub min_width: u32,
44    /// max size of the portal in x direction
45    pub max_width: u32,
46    /// min size of the portal in y direction
47    pub min_height: u32,
48    /// max size of the portal in y direction
49    pub max_height: u32,
50    /// The block type of the frame.
51    pub frame: BlockRef,
52    /// The block type of the portal.
53    pub portal: BlockRef,
54}
55
56/// Returns the standard nether portal frame configuration.
57#[must_use]
58pub fn nether_portal_config() -> PortalFrameConfig {
59    PortalFrameConfig {
60        min_width: 2,
61        max_width: 21,
62        min_height: 3,
63        max_height: 21,
64        frame: &vanilla_blocks::OBSIDIAN,
65        portal: &vanilla_blocks::NETHER_PORTAL,
66    }
67}
68
69/// Matches vanilla's `PortalShape.isEmpty`: any air variant, any block in the `fire` tag,
70/// or the portal block itself.
71fn is_empty(
72    world: &(impl LevelReader + ?Sized),
73    pos: BlockPos,
74    config: &PortalFrameConfig,
75) -> bool {
76    let state = world.get_block_state(pos);
77    if state.is_air() {
78        return true;
79    }
80    let block = state.get_block();
81    block.has_tag(&BlockTag::FIRE) || block == config.portal
82}
83
84const fn block_pos_axis(pos: BlockPos, axis: Axis) -> i32 {
85    match axis {
86        Axis::X => pos.x(),
87        Axis::Y => pos.y(),
88        Axis::Z => pos.z(),
89    }
90}
91
92const fn vec_axis(pos: DVec3, axis: Axis) -> f64 {
93    match axis {
94        Axis::X => pos.x,
95        Axis::Y => pos.y,
96        Axis::Z => pos.z,
97    }
98}
99
100impl PortalShape {
101    /// Finds an empty portal frame starting with X axis preferred.
102    /// Matches vanilla's `findEmptyPortalShape` as called from `BaseFireBlock.onPlace`.
103    pub fn find_empty_portal_shape(
104        world: &(impl LevelReader + ?Sized),
105        fire_pos: BlockPos,
106        config: &PortalFrameConfig,
107    ) -> Option<Self> {
108        Self::find_empty_portal_shape_with_axis(world, fire_pos, Axis::X, config)
109    }
110
111    /// Finds an empty portal frame trying `preferred_axis` first, then the other.
112    /// Matches vanilla's `findPortalShape` with the empty-portal predicate.
113    pub fn find_empty_portal_shape_with_axis(
114        world: &(impl LevelReader + ?Sized),
115        fire_pos: BlockPos,
116        preferred_axis: Axis,
117        config: &PortalFrameConfig,
118    ) -> Option<Self> {
119        let other_axis = if preferred_axis == Axis::X {
120            Axis::Z
121        } else {
122            Axis::X
123        };
124        Self::try_axis(world, fire_pos, preferred_axis, config)
125            .filter(|s| s.num_portal_blocks == 0)
126            .or_else(|| {
127                Self::try_axis(world, fire_pos, other_axis, config)
128                    .filter(|s| s.num_portal_blocks == 0)
129            })
130    }
131
132    /// Finds a portal shape on a specific axis.
133    /// Used by `update_shape` to check if the portal is still complete.
134    pub fn find_any_shape(
135        world: &(impl LevelReader + ?Sized),
136        pos: BlockPos,
137        axis: Axis,
138        config: &PortalFrameConfig,
139    ) -> Option<Self> {
140        Self::try_axis(world, pos, axis, config)
141    }
142
143    /// Tries to find a valid portal on a single axis, matching vanilla's detection algorithm.
144    fn try_axis(
145        world: &(impl LevelReader + ?Sized),
146        pos: BlockPos,
147        axis: Axis,
148        config: &PortalFrameConfig,
149    ) -> Option<Self> {
150        // Vanilla: rightDir is WEST for X-axis, SOUTH for Z-axis
151        let right_dir: Direction = match axis {
152            Axis::X => Direction::West,
153            Axis::Z => Direction::South,
154            Axis::Y => return None,
155        };
156
157        let bottom_left = Self::calculate_bottom_left(world, pos, right_dir, config)?;
158
159        let width = Self::calculate_width(world, bottom_left, right_dir, config);
160        if width == 0 {
161            return None;
162        }
163
164        let mut num_portal_blocks = 0;
165        let height = Self::calculate_height(
166            world,
167            bottom_left,
168            width,
169            right_dir,
170            config,
171            &mut num_portal_blocks,
172        );
173        if height < config.min_height {
174            return None;
175        }
176
177        if !Self::has_top_frame(world, bottom_left, height, width, right_dir, config) {
178            return None;
179        }
180
181        Some(Self {
182            axis,
183            bottom_left,
184            width,
185            height,
186            right_dir,
187            portal: config.portal,
188            num_portal_blocks,
189        })
190    }
191
192    /// Returns the number of valid interior blocks in `direction` from `pos`, matching vanilla's
193    /// `getDistanceUntilEdgeAboveFrame`. Each position must be empty and have a frame block
194    /// below it. Returns 0 if the terminating block is not a frame block.
195    fn get_distance_until_edge(
196        world: &(impl LevelReader + ?Sized),
197        pos: BlockPos,
198        direction: Direction,
199        config: &PortalFrameConfig,
200    ) -> u32 {
201        for i in 0..=config.max_width {
202            let next = pos.relative_n(direction, i as i32);
203            if !is_empty(world, next, config) {
204                // Edge must be a frame block, otherwise the interior is unbounded
205                return if Self::is_frame_block(world, next, config) {
206                    i
207                } else {
208                    0
209                };
210            }
211            if !Self::is_frame_block(world, next.below(), config) {
212                return 0;
213            }
214        }
215        0
216    }
217
218    /// Finds the bottom-left corner of the portal interior.
219    fn calculate_bottom_left(
220        world: &(impl LevelReader + ?Sized),
221        pos: BlockPos,
222        right_dir: Direction,
223        config: &PortalFrameConfig,
224    ) -> Option<BlockPos> {
225        // Scan down to find the lowest empty block above frame
226        let mut cur = pos;
227        for _ in 0..config.max_height {
228            let next = cur.below();
229            if !is_empty(world, next, config) {
230                break;
231            }
232            cur = next;
233        }
234
235        // Scan in opposite of right_dir to find the left edge
236        let left_dir = right_dir.opposite();
237        let dist = Self::get_distance_until_edge(world, cur, left_dir, config);
238        if dist == 0 {
239            return None;
240        }
241        Some(cur.relative_n(left_dir, (dist - 1) as i32))
242    }
243
244    /// Calculates the width of the portal interior from the bottom-left corner.
245    fn calculate_width(
246        world: &(impl LevelReader + ?Sized),
247        bottom_left: BlockPos,
248        right_dir: Direction,
249        config: &PortalFrameConfig,
250    ) -> u32 {
251        let dist = Self::get_distance_until_edge(world, bottom_left, right_dir, config);
252        if dist < config.min_width || dist > config.max_width {
253            return 0;
254        }
255        dist
256    }
257
258    /// Calculates the height while validating side columns and interior.
259    /// Also counts portal blocks in the interior via `portal_block_count`.
260    ///
261    /// Matches vanilla's `getDistanceUntilTop`: always uses `isEmpty` (air/fire/portal)
262    /// for interior validation regardless of the outer interior check strategy.
263    fn calculate_height(
264        world: &(impl LevelReader + ?Sized),
265        bottom_left: BlockPos,
266        width: u32,
267        right_dir: Direction,
268        config: &PortalFrameConfig,
269        portal_block_count: &mut u32,
270    ) -> u32 {
271        let mut height = 0;
272        'outer: for h in 0..config.max_height {
273            let row_start = bottom_left.above_n(h as i32);
274
275            // Check left frame column (one block left of bottom_left)
276            if !Self::is_frame_block(world, row_start.relative(right_dir.opposite()), config) {
277                break;
278            }
279            // Check right frame column (one block past the width)
280            if !Self::is_frame_block(world, row_start.relative_n(right_dir, width as i32), config) {
281                break;
282            }
283
284            // Check interior and count portal blocks
285            for w in 0..width {
286                let interior_pos = row_start.relative_n(right_dir, w as i32);
287                if !is_empty(world, interior_pos, config) {
288                    break 'outer;
289                }
290                if world.get_block_state(interior_pos).get_block() == config.portal {
291                    *portal_block_count += 1;
292                }
293            }
294            height = h + 1;
295        }
296        height
297    }
298
299    /// Checks that the top frame row is complete.
300    fn has_top_frame(
301        world: &(impl LevelReader + ?Sized),
302        bottom_left: BlockPos,
303        height: u32,
304        width: u32,
305        right_dir: Direction,
306        config: &PortalFrameConfig,
307    ) -> bool {
308        let top_row = bottom_left.above_n(height as i32);
309        for w in 0..width {
310            if !Self::is_frame_block(world, top_row.relative_n(right_dir, w as i32), config) {
311                return false;
312            }
313        }
314        true
315    }
316
317    fn is_frame_block(
318        world: &(impl LevelReader + ?Sized),
319        pos: BlockPos,
320        config: &PortalFrameConfig,
321    ) -> bool {
322        world.get_block_state(pos).get_block() == config.frame
323    }
324
325    /// Returns `true` if the portal interior is entirely filled with portal blocks.
326    #[must_use]
327    pub const fn is_complete(&self) -> bool {
328        self.num_portal_blocks == self.width * self.height
329    }
330
331    /// Fills the interior with nether portal blocks.
332    /// Vanilla uses flag 18 (`UPDATE_CLIENTS` | `UPDATE_KNOWN_SHAPE`) to avoid redundant neighbor
333    /// updates during bulk placement.
334    pub fn place_portal_blocks(&self, world: &Arc<World>) {
335        let portal_state = self
336            .portal
337            .default_state()
338            .set_value(&BlockStateProperties::HORIZONTAL_AXIS, self.axis);
339        let flags = UpdateFlags::UPDATE_CLIENTS.union(UpdateFlags::UPDATE_KNOWN_SHAPE);
340        for w in 0..self.width {
341            for h in 0..self.height {
342                world.set_block(
343                    self.bottom_left
344                        .above_n(h as i32)
345                        .relative_n(self.right_dir, w as i32),
346                    portal_state,
347                    flags,
348                );
349            }
350        }
351    }
352
353    /// Returns an entity's position within the portal opening, normalized to
354    /// [0.0, 1.0] on the width and height axes, for placing it correctly
355    /// through a differently sized portal on the other side.
356    #[must_use]
357    pub fn get_relative_position(
358        largest_rectangle_around: FoundRectangle,
359        axis: Axis,
360        position: DVec3,
361        dimensions: EntityDimensions,
362    ) -> DVec3 {
363        let width = f64::from(largest_rectangle_around.axis1_size) - f64::from(dimensions.width);
364        let height = f64::from(largest_rectangle_around.axis2_size) - f64::from(dimensions.height);
365        let bottom_min = largest_rectangle_around.min_corner;
366        let relative_right = if width > 0.0 {
367            let bottom_start =
368                f64::from(block_pos_axis(bottom_min, axis)) + f64::from(dimensions.width) / 2.0;
369            inverse_lerp(vec_axis(position, axis) - bottom_start, 0.0, width).clamp(0.0, 1.0)
370        } else {
371            0.5
372        };
373
374        let relative_up = if height > 0.0 {
375            inverse_lerp(
376                vec_axis(position, Axis::Y) - f64::from(block_pos_axis(bottom_min, Axis::Y)),
377                0.0,
378                height,
379            )
380            .clamp(0.0, 1.0)
381        } else {
382            0.0
383        };
384
385        let forward_axis = if axis == Axis::X { Axis::Z } else { Axis::X };
386        let relative_forward = vec_axis(position, forward_axis)
387            - (f64::from(block_pos_axis(bottom_min, forward_axis)) + 0.5);
388        DVec3::new(relative_right, relative_up, relative_forward)
389    }
390
391    /// Nudges an entity's exit position sideways to a nearby collision-free
392    /// spot in front of the portal, if the entity doesn't fit as-is.
393    #[must_use]
394    pub fn find_collision_free_position(
395        bottom_center: DVec3,
396        world: &Arc<World>,
397        entity: &dyn Entity,
398        dimensions: EntityDimensions,
399    ) -> DVec3 {
400        if dimensions.width > 4.0 || dimensions.height > 4.0 {
401            return bottom_center;
402        }
403
404        let width = f64::from(dimensions.width);
405        let height = f64::from(dimensions.height);
406        let half_height = height / 2.0;
407        let center = bottom_center + DVec3::new(0.0, half_height, 0.0);
408        let allowed_centers = [WorldAabb::of_size(center, width, 0.0, width)
409            .expand_towards(DVec3::Y)
410            .inflate(1.0E-6)];
411
412        WorldCollisionProvider::for_entity(world, entity)
413            .find_free_position(&allowed_centers, center, width, height, width)
414            .map_or(bottom_center, |pos| pos - DVec3::new(0.0, half_height, 0.0))
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use glam::DVec3;
421    use steel_registry::entity_type::EntityDimensions;
422    use steel_utils::BlockPos;
423    use steel_utils::axis::Axis;
424    use steel_utils::block_util::FoundRectangle;
425
426    use super::PortalShape;
427
428    #[test]
429    fn relative_portal_position_matches_vanilla_axis_math() {
430        let rectangle = FoundRectangle {
431            min_corner: BlockPos::new(10, 64, 20),
432            axis1_size: 4,
433            axis2_size: 5,
434        };
435        let position = DVec3::new(12.0, 66.0, 20.75);
436        let dimensions = EntityDimensions::new(1.0, 2.0, 1.62);
437
438        let relative = PortalShape::get_relative_position(rectangle, Axis::X, position, dimensions);
439
440        assert!((relative.x - 0.5).abs() < f64::EPSILON);
441        assert!((relative.y - (2.0 / 3.0)).abs() < f64::EPSILON);
442        assert!((relative.z - 0.25).abs() < f64::EPSILON);
443    }
444
445    #[test]
446    fn relative_portal_position_clamps_right_and_up_offsets() {
447        let rectangle = FoundRectangle {
448            min_corner: BlockPos::new(10, 64, 20),
449            axis1_size: 4,
450            axis2_size: 5,
451        };
452        let position = DVec3::new(8.0, 80.0, 20.5);
453        let dimensions = EntityDimensions::new(1.0, 2.0, 1.62);
454
455        let relative = PortalShape::get_relative_position(rectangle, Axis::X, position, dimensions);
456
457        assert!((relative.x - 0.0).abs() < f64::EPSILON);
458        assert!((relative.y - 1.0).abs() < f64::EPSILON);
459    }
460
461    #[test]
462    fn relative_portal_position_uses_vanilla_fallbacks_when_entity_fills_rectangle() {
463        let rectangle = FoundRectangle {
464            min_corner: BlockPos::new(10, 64, 20),
465            axis1_size: 2,
466            axis2_size: 3,
467        };
468        let position = DVec3::new(11.0, 65.0, 20.25);
469        let dimensions = EntityDimensions::new(3.0, 4.0, 1.62);
470
471        let relative = PortalShape::get_relative_position(rectangle, Axis::Z, position, dimensions);
472
473        assert!((relative.x - 0.5).abs() < f64::EPSILON);
474        assert!((relative.y - 0.0).abs() < f64::EPSILON);
475        assert!((relative.z - 0.5).abs() < f64::EPSILON);
476    }
477}