Skip to main content

steel_core/entity/entities/objects/vehicles/
minecart_behavior.rs

1use std::sync::Arc;
2
3use glam::DVec3;
4use glam::Vec3Swizzles;
5use steel_math::{DEGREE_180, DEGREE_360, RAD_TO_DEG_F64, trig::atan2, wrap_degrees};
6use steel_registry::blocks::block_state_ext::BlockStateExt;
7use steel_registry::blocks::properties::{
8    BlockStateProperties, BoolProperty, EnumProperty, RailShape,
9};
10use steel_registry::vanilla_block_tags::BlockTag;
11use steel_registry::vanilla_blocks;
12use steel_registry::vanilla_entities;
13use steel_utils::{BlockPos, Direction};
14
15use super::abstract_minecart::{AbstractMinecart, rail_exits};
16use crate::behavior::blocks::BaseRailBlock;
17use crate::entity::SharedEntity;
18use crate::physics::MoverType;
19use crate::world::World;
20
21const RAIL_SHAPE: &EnumProperty<RailShape> = &BlockStateProperties::RAIL_SHAPE;
22const POWERED: &BoolProperty = &BlockStateProperties::POWERED;
23
24const RAIL_HEIGHT_OFFSET: f64 = 0.0625;
25const SLIDE_SPEED: f64 = 0.007_812_5;
26const WATER_SLIDE_SPEED: f64 = SLIDE_SPEED * 0.2;
27const MAX_SPEED_IN_WATER: f64 = 0.2;
28const MAX_SPEED_ON_LAND: f64 = 0.4;
29const ABSOLUTE_MAX_SPEED: f64 = 0.4;
30const MAX_RAIL_ALIGNMENT_VELOCITY: f64 = 2.0;
31const POWERED_RAIL_ACCEL: f64 = 0.06;
32const POWERED_RAIL_KICKSTART: f64 = 0.02;
33const RIDDEN_SLOWDOWN_FACTOR: f64 = 0.997;
34const EMPTY_SLOWDOWN_FACTOR: f64 = 0.96;
35const OCCUPIED_MOVEMENT_SCALE: f64 = 0.75;
36const PLAYER_NUDGE_ACCELERATION: f64 = 0.001;
37const PLAYER_NUDGE_MAX_SPEED_SQUARED: f64 = 0.01;
38const MINECART_RIDABLE_THRESHOLD: f64 = 0.01;
39const MIN_ROTATION_DISTANCE_SQUARED: f64 = 0.001;
40const ENTITY_INTERACTION_INFLATION: f64 = 0.2_f32 as f64;
41const UNPOWERED_RAIL_STOP_THRESHOLD: f64 = 0.03;
42const UNPOWERED_RAIL_SLOWDOWN_FACTOR: f64 = 0.5;
43const POWERED_RAIL_ACCELERATION_THRESHOLD: f64 = 0.01;
44const SLOPE_SPEED_FACTOR: f64 = 0.05;
45const ROTATION_FLIP_THRESHOLD_DEGREES: f32 = 170.0;
46const RAIL_EXIT_OFFSET_SCALE: f64 = 0.5;
47const CORNER_RAIL_PROJECTION_SCALE: f64 = 2.0;
48const SLOPE_HEIGHT_SCALE: f64 = 2.0;
49
50/// Shared minecart movement behavior.
51#[derive(Debug, Default)]
52pub struct MinecartBehavior;
53
54impl MinecartBehavior {
55    pub(crate) fn tick(minecart: &dyn AbstractMinecart, entity: &SharedEntity, world: &World) {
56        minecart.apply_gravity();
57        let pos = minecart.current_block_pos_or_rail_below(world);
58        let block_state = world.get_block_state(pos);
59        let on_rails = BaseRailBlock::is_rail_state(block_state);
60        minecart.minecart_base().set_on_rails(on_rails);
61
62        if on_rails {
63            Self::move_along_track(minecart, entity, world);
64            if block_state.get_block() == &vanilla_blocks::ACTIVATOR_RAIL {
65                minecart.activate_minecart(world, pos, block_state.get_value(POWERED));
66            }
67        } else {
68            minecart.come_off_track(entity, world);
69        }
70
71        minecart.apply_effects_from_blocks();
72        Self::update_rotation_from_movement(minecart);
73        Self::push_and_pickup_entities(minecart);
74    }
75
76    pub(crate) fn motion_direction(minecart: &dyn AbstractMinecart) -> Direction {
77        let direction = minecart.direction_yaw();
78
79        if minecart.is_flipped() {
80            direction.opposite().rotate_y_clockwise()
81        } else {
82            direction.rotate_y_clockwise()
83        }
84    }
85
86    /// Returns the minecart movement used when processing block contacts.
87    pub(crate) fn known_movement(known_movement: DVec3) -> DVec3 {
88        if known_movement.x.is_nan() || known_movement.y.is_nan() || known_movement.z.is_nan() {
89            return DVec3::ZERO;
90        }
91
92        DVec3::new(
93            known_movement
94                .x
95                .clamp(-ABSOLUTE_MAX_SPEED, ABSOLUTE_MAX_SPEED),
96            known_movement.y,
97            known_movement
98                .z
99                .clamp(-ABSOLUTE_MAX_SPEED, ABSOLUTE_MAX_SPEED),
100        )
101    }
102
103    pub(crate) fn max_speed(minecart: &dyn AbstractMinecart, _world: &World) -> f64 {
104        if minecart.is_in_water() {
105            MAX_SPEED_IN_WATER
106        } else {
107            MAX_SPEED_ON_LAND
108        }
109    }
110
111    pub(crate) fn slowdown_factor(minecart: &dyn AbstractMinecart) -> f64 {
112        if minecart.is_vehicle() {
113            RIDDEN_SLOWDOWN_FACTOR
114        } else {
115            EMPTY_SLOWDOWN_FACTOR
116        }
117    }
118
119    #[expect(
120        clippy::too_many_lines,
121        reason = "keeping it together makes it easier to audit"
122    )]
123    fn move_along_track(minecart: &dyn AbstractMinecart, entity: &SharedEntity, world: &World) {
124        let pos = minecart.current_block_pos_or_rail_below(world);
125        let state = world.get_block_state(pos);
126        minecart.reset_fall_distance();
127
128        let position = minecart.position();
129        let old_projected = Self::get_pos(world, position.x, position.y, position.z, None);
130        let mut y = f64::from(pos.y());
131
132        let block = state.get_block();
133        let (power_track, mut halt_track) = if block == &vanilla_blocks::POWERED_RAIL {
134            let powered = state.get_value(POWERED);
135            (powered, !powered)
136        } else {
137            (false, false)
138        };
139
140        let slide_speed = if minecart.is_in_water() {
141            WATER_SLIDE_SPEED
142        } else {
143            SLIDE_SPEED
144        };
145        let shape = state.get_value(RAIL_SHAPE);
146        let mut velocity = minecart.velocity();
147        match shape {
148            RailShape::AscendingEast => velocity.x -= slide_speed,
149            RailShape::AscendingWest => velocity.x += slide_speed,
150            RailShape::AscendingNorth => velocity.z += slide_speed,
151            RailShape::AscendingSouth => velocity.z -= slide_speed,
152            _ => {}
153        }
154        if shape.is_slope() {
155            y += 1.0;
156        }
157        minecart.set_velocity(velocity);
158
159        let velocity = minecart.velocity();
160        let ((ex0, ey0, ez0), (ex1, ey1, ez1)) = rail_exits(shape);
161        let (mut xd, mut zd) = (f64::from(ex1 - ex0), f64::from(ez1 - ez0));
162        let length = (xd * xd + zd * zd).sqrt();
163        if velocity.x * xd + velocity.z * zd < 0.0 {
164            xd = -xd;
165            zd = -zd;
166        }
167        let pow = velocity.xz().length().min(MAX_RAIL_ALIGNMENT_VELOCITY);
168        minecart.set_velocity(DVec3::new(pow * xd / length, velocity.y, pow * zd / length));
169
170        if let Some(passenger) = minecart.first_passenger()
171            && let Some(player) = passenger.as_player()
172        {
173            let move_intent = player.last_client_move_intent();
174
175            if move_intent.length_squared() > 0.0 {
176                let rider_movement = move_intent.normalize();
177                let velocity = minecart.velocity();
178                let own_distance_squared = velocity.xz().length_squared();
179
180                if rider_movement.length_squared() > 0.0
181                    && own_distance_squared < PLAYER_NUDGE_MAX_SPEED_SQUARED
182                {
183                    minecart.set_velocity(
184                        velocity
185                            + DVec3::new(
186                                move_intent.x * PLAYER_NUDGE_ACCELERATION,
187                                0.0,
188                                move_intent.z * PLAYER_NUDGE_ACCELERATION,
189                            ),
190                    );
191                    halt_track = false;
192                }
193            }
194        }
195
196        if halt_track {
197            let velocity = minecart.velocity();
198            let speed_length = velocity.xz().length();
199            let velocity = if speed_length < UNPOWERED_RAIL_STOP_THRESHOLD {
200                DVec3::ZERO
201            } else {
202                velocity
203                    * DVec3::new(
204                        UNPOWERED_RAIL_SLOWDOWN_FACTOR,
205                        0.0,
206                        UNPOWERED_RAIL_SLOWDOWN_FACTOR,
207                    )
208            };
209            minecart.set_velocity(velocity);
210        }
211
212        if let Some(projected) = Self::get_pos(
213            world,
214            position.x,
215            position.y,
216            position.z,
217            Some((pos, shape)),
218        ) && !Self::set_position(
219            minecart,
220            DVec3::new(projected.x, y, projected.z),
221            "project onto rail",
222        ) {
223            return;
224        }
225
226        let scale = if minecart.is_vehicle() {
227            OCCUPIED_MOVEMENT_SCALE
228        } else {
229            1.0
230        };
231        let max_speed = minecart.max_speed(world);
232        let velocity = minecart.velocity();
233        let clamped = DVec3::new(
234            (scale * velocity.x).clamp(-max_speed, max_speed),
235            0.0,
236            (scale * velocity.z).clamp(-max_speed, max_speed),
237        );
238        if Arc::clone(entity)
239            .move_entity(MoverType::SelfMovement, clamped)
240            .is_none()
241        {
242            return;
243        }
244
245        let position = minecart.position();
246        let (floor_x, floor_z) = (position.x.floor() as i32, position.z.floor() as i32);
247        let exit_y = if ey0 != 0 && floor_x - pos.x() == ex0 && floor_z - pos.z() == ez0 {
248            Some(ey0)
249        } else if ey1 != 0 && floor_x - pos.x() == ex1 && floor_z - pos.z() == ez1 {
250            Some(ey1)
251        } else {
252            None
253        };
254
255        if let Some(exit_y) = exit_y {
256            let position = minecart.position();
257            let raised = DVec3::new(position.x, position.y + f64::from(exit_y), position.z);
258            if !Self::set_position(minecart, raised, "raise to rail exit height") {
259                return;
260            }
261        }
262
263        minecart.set_velocity(minecart.apply_natural_slowdown(minecart.velocity()));
264
265        let position = minecart.position();
266        if let (Some(new_projected), Some(old_projected)) = (
267            Self::get_pos(world, position.x, position.y, position.z, None),
268            old_projected,
269        ) {
270            let slope_speed_adjustment = (old_projected.y - new_projected.y) * SLOPE_SPEED_FACTOR;
271            let velocity = minecart.velocity();
272            let horizontal = velocity.xz().length();
273            if horizontal > 0.0 {
274                let factor = (horizontal + slope_speed_adjustment) / horizontal;
275                minecart.set_velocity(DVec3::new(
276                    velocity.x * factor,
277                    velocity.y,
278                    velocity.z * factor,
279                ));
280            }
281            let position = minecart.position();
282            let corrected = DVec3::new(position.x, new_projected.y, position.z);
283            if !Self::set_position(minecart, corrected, "correct rail height") {
284                return;
285            }
286        }
287
288        let position = minecart.position();
289        let (xn, zn) = (position.x.floor() as i32, position.z.floor() as i32);
290        if xn != pos.x() || zn != pos.z() {
291            let velocity = minecart.velocity();
292            let horizontal = velocity.xz().length();
293            minecart.set_velocity(DVec3::new(
294                horizontal * f64::from(xn - pos.x()),
295                velocity.y,
296                horizontal * f64::from(zn - pos.z()),
297            ));
298        }
299
300        if power_track {
301            let velocity = minecart.velocity();
302            let speed_length = velocity.xz().length();
303            if speed_length > POWERED_RAIL_ACCELERATION_THRESHOLD {
304                minecart.set_velocity(
305                    velocity
306                        + DVec3::new(
307                            velocity.x / speed_length * POWERED_RAIL_ACCEL,
308                            0.0,
309                            velocity.z / speed_length * POWERED_RAIL_ACCEL,
310                        ),
311                );
312            } else {
313                let (mut dx, mut dz) = (velocity.x, velocity.z);
314                match shape {
315                    RailShape::EastWest => {
316                        if minecart.is_redstone_conductor(world, pos.relative(Direction::West)) {
317                            dx = POWERED_RAIL_KICKSTART;
318                        } else if minecart
319                            .is_redstone_conductor(world, pos.relative(Direction::East))
320                        {
321                            dx = -POWERED_RAIL_KICKSTART;
322                        }
323                    }
324                    RailShape::NorthSouth => {
325                        if minecart.is_redstone_conductor(world, pos.relative(Direction::North)) {
326                            dz = POWERED_RAIL_KICKSTART;
327                        } else if minecart
328                            .is_redstone_conductor(world, pos.relative(Direction::South))
329                        {
330                            dz = -POWERED_RAIL_KICKSTART;
331                        }
332                    }
333                    _ => return,
334                }
335                minecart.set_velocity(DVec3::new(dx, velocity.y, dz));
336            }
337        }
338    }
339
340    /// Moves `minecart` to `position`, logging `step` and returning `false` if the move was
341    /// rejected so the caller can end the tick.
342    fn set_position(minecart: &dyn AbstractMinecart, position: DVec3, step: &str) -> bool {
343        let Err(error) = minecart.try_set_position(position) else {
344            return true;
345        };
346        log::debug!("failed to {step} for minecart {}: {error}", minecart.id());
347        false
348    }
349
350    /// Returns whether the minecart should continue its movement after pushing or picking up entities.
351    fn push_and_pickup_entities(minecart: &dyn AbstractMinecart) -> bool {
352        let Some(world) = minecart.level() else {
353            return false;
354        };
355        let Some(minecart_shared) = world.get_entity_by_id(minecart.id()) else {
356            return false;
357        };
358
359        let hitbox = minecart.bounding_box().inflate_xyz(
360            ENTITY_INTERACTION_INFLATION,
361            0.0,
362            ENTITY_INTERACTION_INFLATION,
363        );
364
365        if minecart.is_rideable()
366            && minecart.velocity().xz().length_squared() >= MINECART_RIDABLE_THRESHOLD
367        {
368            let entities = world.get_pushable_entities(minecart.as_entity_event_source(), &hitbox);
369
370            for entity in entities {
371                let is_player = entity.entity_type() == &vanilla_entities::PLAYER;
372                let is_iron_golem = entity.entity_type() == &vanilla_entities::IRON_GOLEM;
373                let is_minecart = entity.entity_type().is_abstract_minecart;
374
375                if !is_player
376                    && !is_iron_golem
377                    && !is_minecart
378                    && !minecart.is_vehicle()
379                    && !entity.is_passenger()
380                {
381                    entity.start_riding(&minecart_shared);
382                } else {
383                    entity.push_entity(minecart.as_entity_event_source());
384                }
385            }
386        } else {
387            let entities =
388                world.get_entities_in_aabb_matching(&hitbox, |entity| entity.id() != minecart.id());
389
390            for entity in entities {
391                if !minecart.has_passenger(entity.as_ref())
392                    && entity.is_pushable()
393                    && entity.entity_type().is_abstract_minecart
394                {
395                    entity.push_entity(minecart.as_entity_event_source());
396                }
397            }
398        }
399
400        false
401    }
402
403    fn update_rotation_from_movement(minecart: &dyn AbstractMinecart) {
404        minecart.set_rotation((minecart.rotation().0, 0.0));
405        let (old_position, position) = (minecart.old_position(), minecart.position());
406        let (x_diff, z_diff) = (old_position.x - position.x, old_position.z - position.z);
407        if x_diff * x_diff + z_diff * z_diff > MIN_ROTATION_DISTANCE_SQUARED {
408            let mut yaw = (atan2(z_diff, x_diff) * RAD_TO_DEG_F64) as f32;
409            if minecart.is_flipped() {
410                yaw += DEGREE_180;
411            }
412            minecart.set_rotation((yaw, minecart.rotation().1));
413        }
414
415        let rot_diff = wrap_degrees(minecart.rotation().0 - minecart.base().old_rotation().0);
416        if rot_diff < -ROTATION_FLIP_THRESHOLD_DEGREES
417            || rot_diff >= ROTATION_FLIP_THRESHOLD_DEGREES
418        {
419            minecart.minecart_base().toggle_flipped();
420            minecart.set_rotation((minecart.rotation().0 + DEGREE_180, minecart.rotation().1));
421        }
422        minecart.set_rotation((
423            minecart.rotation().0 % DEGREE_360,
424            minecart.rotation().1 % DEGREE_360,
425        ));
426    }
427
428    /// Returns the minecart's position with the correct rail offset, if there is a rail.
429    #[must_use]
430    fn get_pos(
431        world: &World,
432        x: f64,
433        y: f64,
434        z: f64,
435        rail: Option<(BlockPos, RailShape)>,
436    ) -> Option<DVec3> {
437        let (pos, shape) = if let Some(rail) = rail {
438            rail
439        } else {
440            let (xt, mut yt, zt) = (x.floor() as i32, y.floor() as i32, z.floor() as i32);
441            if world
442                .get_block_state(BlockPos::new(xt, yt - 1, zt))
443                .get_block()
444                .has_tag(&BlockTag::RAILS)
445            {
446                yt -= 1;
447            }
448
449            let pos = BlockPos::new(xt, yt, zt);
450            let state = world.get_block_state(pos);
451            if !BaseRailBlock::is_rail_state(state) {
452                return None;
453            }
454
455            (pos, state.get_value(RAIL_SHAPE))
456        };
457
458        let (xt, yt, zt) = (pos.x(), pos.y(), pos.z());
459        let ((ex0, ey0, ez0), (ex1, ey1, ez1)) = rail_exits(shape);
460        let (x0, y0, z0) = (
461            f64::from(xt) + 0.5 + f64::from(ex0) * RAIL_EXIT_OFFSET_SCALE,
462            f64::from(yt) + RAIL_HEIGHT_OFFSET + f64::from(ey0) * RAIL_EXIT_OFFSET_SCALE,
463            f64::from(zt) + 0.5 + f64::from(ez0) * RAIL_EXIT_OFFSET_SCALE,
464        );
465        let (x1, y1, z1) = (
466            f64::from(xt) + 0.5 + f64::from(ex1) * RAIL_EXIT_OFFSET_SCALE,
467            f64::from(yt) + RAIL_HEIGHT_OFFSET + f64::from(ey1) * RAIL_EXIT_OFFSET_SCALE,
468            f64::from(zt) + 0.5 + f64::from(ez1) * RAIL_EXIT_OFFSET_SCALE,
469        );
470        let (dx, dz) = (x1 - x0, z1 - z0);
471        let dy = (y1 - y0) * SLOPE_HEIGHT_SCALE;
472
473        let progress = if dx == 0.0 {
474            z - f64::from(zt)
475        } else if dz == 0.0 {
476            x - f64::from(xt)
477        } else {
478            ((x - x0) * dx + (z - z0) * dz) * CORNER_RAIL_PROJECTION_SCALE
479        };
480
481        let mut projected = DVec3::new(x0 + dx * progress, y0 + dy * progress, z0 + dz * progress);
482        if dy < 0.0 {
483            projected.y += 1.0;
484        } else if dy > 0.0 {
485            projected.y += 0.5;
486        }
487        Some(projected)
488    }
489}
490
491#[cfg(test)]
492#[path = "tests/minecart_behavior.rs"]
493mod tests;