Skip to main content

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

1//! Shared vanilla `AbstractMinecart` state and behavior.
2use std::sync::Arc;
3
4use glam::DVec3;
5use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView;
6use simdnbt::owned::{NbtCompound, NbtTag};
7use steel_math::trig;
8use steel_registry::blocks::block_state_ext::BlockStateExt as _;
9use steel_registry::blocks::properties::{Direction, RailShape};
10use steel_registry::entity_type::EntityTypeRef;
11use steel_registry::item_stack::ItemStack;
12use steel_registry::vanilla_block_tags::BlockTag;
13use steel_registry::{vanilla_blocks, vanilla_entities};
14use steel_utils::axis::Axis;
15use steel_utils::block_util::FoundRectangle;
16use steel_utils::locks::SyncMutex;
17use steel_utils::nbt::NbtNumeric;
18use steel_utils::{BlockPos, BlockStateId};
19
20use super::minecart_behavior::MinecartBehavior;
21use super::vehicle_entity::VehicleEntity;
22use crate::block_entity::block_state_nbt;
23use crate::entity::reset_forward_direction_of_relative_portal_position;
24use crate::entity::{Entity, EntityMovementEmission};
25use crate::entity::{
26    EntitySpawnError, SharedEntity, apply_item_stack_components, create_entity_instance,
27};
28use crate::physics::{MoveResult, MoverType};
29use crate::player::Player;
30use crate::portal::portal_shape::PortalShape;
31use crate::world::{SignalGetter as _, World};
32
33const LOWERED_PASSENGER_ATTACHMENT: DVec3 = DVec3::ZERO;
34const WATER_SLOWDOWN_FACTOR: f32 = 0.95;
35const LAVA_FALL_DISTANCE_FACTOR: f64 = 0.5;
36const MIN_PUSH_DISTANCE_SQUARED: f32 = 1.0e-4;
37const MIN_COLLISION_ALIGNMENT: f32 = 0.8;
38const GRAVITY_IN_WATER: f64 = 0.005;
39const DEFAULT_GRAVITY: f64 = 0.04;
40const OFF_TRACK_GROUND_SLOWDOWN_FACTOR: f64 = 0.5;
41const PUSH_FORCE: f32 = 0.1;
42const COLLISION_VELOCITY_RETENTION: f64 = 0.2;
43const COLLISION_PUSH_SCALE: f64 = 0.5;
44const FURNACE_COLLISION_VELOCITY_RETENTION: f64 = 0.95;
45const NON_MINECART_PUSH_DIVISOR: f64 = 4.0;
46
47/// The two vanilla `AbstractMinecart` fields with no `SynchedEntityData`
48/// backing (`onRails`, `flipped`).
49#[derive(Debug, Default)]
50pub struct AbstractMinecartBase {
51    state: SyncMutex<AbstractMinecartState>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55struct AbstractMinecartState {
56    on_rails: bool,
57    flipped: bool,
58}
59
60impl AbstractMinecartBase {
61    /// Creates a new [`AbstractMinecartBase`] with default values.
62    #[must_use]
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Returns whether the minecart is on rails.
68    #[must_use]
69    pub fn on_rails(&self) -> bool {
70        self.state.lock().on_rails
71    }
72
73    /// Sets whether the minecart is on rails.
74    pub fn set_on_rails(&self, value: bool) {
75        self.state.lock().on_rails = value;
76    }
77
78    fn flipped(&self) -> bool {
79        self.state.lock().flipped
80    }
81
82    /// Sets whether the minecart's movement-facing rotation is flipped.
83    pub fn set_flipped(&self, flipped: bool) {
84        self.state.lock().flipped = flipped;
85    }
86
87    /// Toggles whether the minecart's movement-facing rotation is flipped.
88    pub fn toggle_flipped(&self) {
89        let mut state = self.state.lock();
90        state.flipped = !state.flipped;
91    }
92}
93
94/// Object-safe access to an abstract minecart trait object from default [`AbstractMinecart`] methods.
95pub trait AbstractMinecartEventSource {
96    /// Returns this entity as an abstract minecart.
97    fn as_abstract_minecart_event_source(&self) -> &dyn AbstractMinecart;
98}
99
100impl<T: AbstractMinecart> AbstractMinecartEventSource for T {
101    fn as_abstract_minecart_event_source(&self) -> &dyn AbstractMinecart {
102        self
103    }
104}
105
106/// Shared behavior for every minecart variant entity.
107pub trait AbstractMinecart: VehicleEntity + AbstractMinecartEventSource {
108    /// Returns the minecart's shared non-synchronized minecart state.
109    fn minecart_base(&self) -> &AbstractMinecartBase;
110
111    /// Returns the custom display block, if any.
112    fn custom_display_block(&self) -> Option<BlockStateId>;
113
114    /// Sets the custom display block.
115    fn set_custom_display_block(&self, value: Option<BlockStateId>);
116
117    /// Returns the default vertical display-block offset, in sixteenths of a block.
118    fn default_display_offset(&self) -> i32 {
119        6
120    }
121
122    /// Returns the minecart's max speed.
123    fn max_speed(&self, world: &World) -> f64 {
124        MinecartBehavior::max_speed(self.as_abstract_minecart_event_source(), world)
125    }
126
127    /// Returns the default gravity of this minecart.
128    fn minecart_default_gravity(&self) -> f64 {
129        if self.is_in_water() {
130            GRAVITY_IN_WATER
131        } else {
132            DEFAULT_GRAVITY
133        }
134    }
135
136    /// Returns the default air drag of this minecart.
137    fn minecart_air_drag(&self) -> f64 {
138        f64::from(0.95_f32)
139    }
140
141    /// Returns whether this minecart can be targeted by picking.
142    fn minecart_is_pickable(&self) -> bool {
143        !self.is_removed()
144    }
145
146    /// Returns whether this minecart can be pushed.
147    fn minecart_is_pushable(&self) -> bool {
148        true
149    }
150
151    /// Returns the movement side effects emitted by this minecart.
152    fn minecart_movement_emission(&self) -> EntityMovementEmission {
153        EntityMovementEmission::Events
154    }
155
156    /// Returns whether this minecart obstructs block placement.
157    fn minecart_blocks_building(&self) -> bool {
158        true
159    }
160
161    /// Returns whether this minecart can collide with `other`.
162    fn minecart_can_collide_with(&self, other: &dyn Entity) -> bool {
163        (other.can_be_collided_with(Some(self.as_entity_event_source())) || other.is_pushable())
164            && !self.is_passenger_of_same_vehicle(other)
165    }
166
167    /// Returns the vertical offset of the minecart's display block.
168    fn display_offset(&self) -> i32;
169
170    /// Sets the vertical offset of the minecart's display block.
171    fn set_display_offset(&self, value: i32);
172
173    /// Returns the custom display block, or the default block state if none is set.
174    fn display_block_state(&self) -> BlockStateId {
175        self.custom_display_block()
176            .unwrap_or_else(|| self.default_display_block_state())
177    }
178
179    /// Returns air as the default display block.
180    fn default_display_block_state(&self) -> BlockStateId {
181        vanilla_blocks::AIR.default_state()
182    }
183
184    /// Handles contact with an activator rail. Default does nothing.
185    fn activate_minecart(&self, _world: &World, _pos: BlockPos, _powered: bool) {}
186
187    /// Returns whether this minecart is rideable.
188    fn is_rideable(&self) -> bool {
189        false
190    }
191
192    /// Returns whether this minecart's movement-facing rotation is flipped.
193    fn is_flipped(&self) -> bool {
194        self.minecart_base().flipped()
195    }
196
197    /// Returns whether the block at `pos` is a redstone conductor.
198    fn is_redstone_conductor(&self, world: &World, pos: BlockPos) -> bool {
199        let state = world.get_block_state(pos);
200        world.is_redstone_conductor(state, pos)
201    }
202
203    /// Returns whether this minecart is a furnace minecart.
204    fn is_furnace(&self) -> bool {
205        false
206    }
207
208    /// Returns the block below when it contains rails, otherwise the minecart's current block position.
209    fn current_block_pos_or_rail_below(&self, world: &World) -> BlockPos {
210        let pos = self.position();
211        let (xt, yt, zt) = (
212            pos.x.floor() as i32,
213            pos.y.floor() as i32,
214            pos.z.floor() as i32,
215        );
216        let below = BlockPos::new(xt, yt - 1, zt);
217        if world
218            .get_block_state(below)
219            .get_block()
220            .has_tag(&BlockTag::RAILS)
221        {
222            below
223        } else {
224            BlockPos::new(xt, yt, zt)
225        }
226    }
227
228    /// Returns 1.0 on rails, otherwise the default block speed factor.
229    fn minecart_block_speed_factor(&self) -> f32 {
230        let Some(world) = self.level() else {
231            return 1.0;
232        };
233
234        let state = world.get_block_state(self.block_position());
235        if state.get_block().has_tag(&BlockTag::RAILS) {
236            1.0
237        } else {
238            self.default_block_speed_factor()
239        }
240    }
241
242    /// Applies block-contact effects at the current position and clears movement.
243    fn minecart_apply_effects_from_blocks(&self) {
244        let position = self.position();
245        self.apply_effects_from_blocks_between(position, position);
246        self.base().clear_movement_this_tick();
247    }
248
249    /// Moves this minecart using default entity movement, then applies block-contact effects.
250    fn minecart_move_entity(
251        self: Arc<Self>,
252        mover_type: MoverType,
253        delta: DVec3,
254    ) -> Option<MoveResult> {
255        let result = Arc::clone(&self).default_move_entity(mover_type, delta);
256        self.minecart_apply_effects_from_blocks();
257        result
258    }
259
260    /// Advances minecart movement, damage decay, portal handling, and fluid effects.
261    fn tick_minecart(&self, entity: &SharedEntity) {
262        let Some(world) = self.level() else {
263            return;
264        };
265
266        if self.hurt_time() > 0 {
267            self.set_hurt_time(self.hurt_time() - 1);
268        }
269        if self.damage() > 0.0 {
270            self.set_damage(self.damage() - 1.0);
271        }
272
273        self.check_below_world();
274        self.base().compute_known_speed();
275        self.handle_portal();
276        MinecartBehavior::tick(self.as_abstract_minecart_event_source(), entity, &world);
277        self.update_fluid_interaction();
278
279        if self.is_in_lava() {
280            self.lava_ignite();
281            self.lava_hurt();
282            self.set_fall_distance(self.fall_distance() * LAVA_FALL_DISTANCE_FACTOR);
283        }
284
285        self.set_first_tick(false);
286    }
287
288    /// Applies natural slowdown to the minecart movement.
289    fn apply_natural_slowdown(&self, movement: DVec3) -> DVec3 {
290        let slowdown = MinecartBehavior::slowdown_factor(self.as_abstract_minecart_event_source());
291        let mut result = movement * DVec3::new(slowdown, 0.0, slowdown);
292        if self.is_in_water() {
293            result *= f64::from(WATER_SLOWDOWN_FACTOR);
294        }
295        result
296    }
297
298    /// Slows down the minecart when it comes off the track.
299    fn come_off_track(&self, entity: &SharedEntity, world: &World) {
300        let max_speed = self.max_speed(world);
301        let velocity = self.velocity();
302        let mut velocity = DVec3::new(
303            velocity.x.clamp(-max_speed, max_speed),
304            velocity.y,
305            velocity.z.clamp(-max_speed, max_speed),
306        );
307        if self.on_ground() {
308            velocity *= OFF_TRACK_GROUND_SLOWDOWN_FACTOR;
309        }
310        self.set_velocity(velocity);
311        if Arc::clone(entity)
312            .move_entity(MoverType::SelfMovement, self.velocity())
313            .is_none()
314        {
315            return;
316        }
317
318        if !self.on_ground() {
319            self.set_velocity(self.velocity() * self.get_air_drag());
320        }
321    }
322
323    #[expect(
324        clippy::neg_cmp_op_on_partial_ord,
325        reason = "negated comparisons preserve Vanilla's NaN branch behavior"
326    )]
327    /// Applies collision impulses between this minecart and another entity.
328    fn minecart_push_entity(&self, other: &dyn Entity) {
329        if other.no_physics() || self.no_physics() || self.has_passenger(other) {
330            return;
331        }
332
333        let mut xa = other.position().x - self.position().x;
334        let mut za = other.position().z - self.position().z;
335        let dd = xa * xa + za * za;
336        if !(dd >= f64::from(MIN_PUSH_DISTANCE_SQUARED)) {
337            return;
338        }
339
340        let dd = dd.sqrt();
341        xa /= dd;
342        za /= dd;
343        let pow = (1.0 / dd).min(1.0);
344        xa *= pow;
345        za *= pow;
346        xa *= f64::from(PUSH_FORCE);
347        za *= f64::from(PUSH_FORCE);
348        xa *= COLLISION_PUSH_SCALE;
349        za *= COLLISION_PUSH_SCALE;
350
351        if let Some(other_minecart) = other.as_abstract_minecart() {
352            self.push_other_minecart(other_minecart, xa, za);
353        } else {
354            self.push_impulse(DVec3::new(-xa, 0.0, -za));
355            other.push_impulse(DVec3::new(
356                xa / NON_MINECART_PUSH_DIVISOR,
357                0.0,
358                za / NON_MINECART_PUSH_DIVISOR,
359            ));
360        }
361    }
362
363    #[expect(
364        clippy::manual_midpoint,
365        reason = "midpoint would change overflow vs vanilla"
366    )]
367    /// Updates both cart's horizontal velocities after an aligned collision, handling
368    /// special cases for furnace minecarts.
369    fn push_other_minecart(&self, other_minecart: &dyn AbstractMinecart, xa: f64, za: f64) {
370        let xo = other_minecart.position().x - self.position().x;
371        let zo = other_minecart.position().z - self.position().z;
372
373        let dir = DVec3::new(xo, 0.0, zo).normalize_or_zero();
374        let yaw = f64::from(self.rotation().0.to_radians());
375        let facing = DVec3::new(f64::from(trig::cos(yaw)), 0.0, f64::from(trig::sin(yaw)))
376            .normalize_or_zero();
377        let dot = dir.dot(facing).abs();
378
379        if dot < f64::from(MIN_COLLISION_ALIGNMENT) {
380            return;
381        }
382
383        let movement = self.velocity();
384        let other_movement = other_minecart.velocity();
385        let normal_scale = DVec3::new(
386            COLLISION_VELOCITY_RETENTION,
387            1.0,
388            COLLISION_VELOCITY_RETENTION,
389        );
390        let furnace_scale = DVec3::new(
391            FURNACE_COLLISION_VELOCITY_RETENTION,
392            1.0,
393            FURNACE_COLLISION_VELOCITY_RETENTION,
394        );
395
396        if other_minecart.is_furnace() && !self.is_furnace() {
397            self.set_velocity(movement * normal_scale);
398            self.push_impulse(DVec3::new(
399                other_movement.x - xa,
400                0.0,
401                other_movement.z - za,
402            ));
403            other_minecart.set_velocity(other_movement * furnace_scale);
404        } else if !other_minecart.is_furnace() && self.is_furnace() {
405            other_minecart.set_velocity(other_movement * normal_scale);
406            other_minecart.push_impulse(DVec3::new(movement.x + xa, 0.0, movement.z + za));
407            self.set_velocity(movement * furnace_scale);
408        } else {
409            let xdd = (other_movement.x + movement.x) / 2.0;
410            let zdd = (other_movement.z + movement.z) / 2.0;
411            self.set_velocity(movement * normal_scale);
412            self.push_impulse(DVec3::new(xdd - xa, 0.0, zdd - za));
413            other_minecart.set_velocity(other_movement * normal_scale);
414            other_minecart.push_impulse(DVec3::new(xdd + xa, 0.0, zdd + za));
415        }
416    }
417
418    /// Returns the passenger attachment point used by this minecart.
419    ///
420    /// Villagers and wandering traders use vanilla's lowered attachment point.
421    fn minecart_passenger_attachment_point(&self, passenger: &dyn Entity) -> DVec3 {
422        let passenger_type = passenger.entity_type();
423
424        if passenger_type == &vanilla_entities::VILLAGER
425            || passenger_type == &vanilla_entities::WANDERING_TRADER
426        {
427            LOWERED_PASSENGER_ATTACHMENT
428        } else {
429            self.default_passenger_attachment_point(passenger)
430        }
431    }
432
433    /// Saves display settings, flipped rotation, and first-tick state to NBT.
434    fn save_abstract_minecart_data(&self, nbt: &mut NbtCompound) {
435        if let Some(display_state) = self.custom_display_block() {
436            nbt.insert(
437                "DisplayState",
438                NbtTag::Compound(block_state_nbt::save(display_state)),
439            );
440        }
441        if self.display_offset() != self.default_display_offset() {
442            nbt.insert("DisplayOffset", self.display_offset());
443        }
444        nbt.insert("FlippedRotation", i8::from(self.minecart_base().flipped()));
445        nbt.insert("HasTicked", i8::from(self.is_first_tick()));
446    }
447
448    /// Loads display settings, flipped rotation, and first-tick state from NBT.
449    fn load_abstract_minecart_data(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
450        self.set_custom_display_block(nbt.compound("DisplayState").and_then(block_state_nbt::load));
451        self.set_display_offset(
452            nbt.get("DisplayOffset")
453                .and_then(|tag| tag.int_value())
454                .unwrap_or_else(|| self.default_display_offset()),
455        );
456
457        let flipped = nbt
458            .get("FlippedRotation")
459            .and_then(|tag| tag.byte_value())
460            .is_some_and(|value| value != 0);
461        self.minecart_base().set_flipped(flipped);
462        self.set_first_tick(
463            nbt.get("HasTicked")
464                .and_then(|tag| tag.byte_value())
465                .is_some_and(|value| value != 0),
466        );
467    }
468}
469
470/// Returns the block-relative exit offsets connected by the rail shape.
471#[must_use]
472pub const fn rail_exits(shape: RailShape) -> ((i32, i32, i32), (i32, i32, i32)) {
473    const fn below((x, y, z): (i32, i32, i32)) -> (i32, i32, i32) {
474        (x, y - 1, z)
475    }
476
477    let north = Direction::North.offset();
478    let south = Direction::South.offset();
479    let west = Direction::West.offset();
480    let east = Direction::East.offset();
481    match shape {
482        RailShape::NorthSouth => (north, south),
483        RailShape::EastWest => (west, east),
484        RailShape::AscendingEast => (below(west), east),
485        RailShape::AscendingWest => (west, below(east)),
486        RailShape::AscendingNorth => (north, below(south)),
487        RailShape::AscendingSouth => (below(north), south),
488        RailShape::SouthEast => (south, east),
489        RailShape::SouthWest => (south, west),
490        RailShape::NorthWest => (north, west),
491        RailShape::NorthEast => (north, east),
492    }
493}
494
495/// Vanilla `AbstractMinecart.getRelativePortalPosition`.
496pub(crate) fn minecart_relative_portal_position(
497    minecart: &dyn Entity,
498    axis: Axis,
499    portal_area: FoundRectangle,
500) -> DVec3 {
501    reset_forward_direction_of_relative_portal_position(PortalShape::get_relative_position(
502        portal_area,
503        axis,
504        minecart.position(),
505        minecart.dimensions_for_pose(minecart.pose()),
506    ))
507}
508
509/// Why [`create_minecart`] produced no minecart.
510#[derive(Debug)]
511pub(crate) enum CreateMinecartError {
512    /// This minecart variant has no [`AbstractMinecart`] implementation yet.
513    Unsupported,
514    /// Creating the entity or applying the item's components failed.
515    Spawn(EntitySpawnError),
516}
517
518/// Creates and configures a minecart without adding it to the world.
519pub(crate) fn create_minecart(
520    world: &Arc<World>,
521    position: DVec3,
522    entity_type: EntityTypeRef,
523    item_stack: &ItemStack,
524    player: Option<&Player>,
525) -> Result<SharedEntity, CreateMinecartError> {
526    let cart =
527        create_entity_instance(world, entity_type, position).map_err(|error| match error {
528            EntitySpawnError::MissingFactory => CreateMinecartError::Unsupported,
529            error => CreateMinecartError::Spawn(error),
530        })?;
531
532    if cart.as_abstract_minecart().is_none() {
533        return Err(CreateMinecartError::Unsupported);
534    }
535
536    cart.base().set_position_local(position);
537    cart.set_old_position_to_current();
538
539    apply_item_stack_components(&cart, item_stack, player.is_some_and(Player::is_operator))
540        .map_err(CreateMinecartError::Spawn)?;
541
542    Ok(cart)
543}
544
545#[cfg(test)]
546#[path = "tests/abstract_minecart.rs"]
547mod tests;