Skip to main content

steel_core/entity/projectile/
throwable.rs

1//! Vanilla `ThrowableProjectile` — the gravity/drag movement loop.
2use steel_registry::{blocks::block_state_ext::BlockStateExt as _, vanilla_blocks};
3use steel_utils::{BlockPos, axis::Axis};
4
5use crate::behavior::BLOCK_BEHAVIORS;
6use crate::entity::projectile::Projectile;
7use crate::entity::{InsideBlockEffectCollector, RemovalReason};
8
9/// Vanilla `ThrowableProjectile.getDefaultGravity`.
10const DEFAULT_GRAVITY: f64 = 0.03;
11
12/// Vanilla drag multiplier while submerged (`ThrowableProjectile.applyInertia`).
13const WATER_INERTIA: f64 = 0.8;
14
15/// Vanilla-shaped behavior shared by entities that extend `ThrowableProjectile`.
16pub trait ThrowableProjectile: Projectile {
17    /// Vanilla `ThrowableProjectile.getAirDrag`.
18    fn get_air_drag(&self) -> f32 {
19        0.99
20    }
21
22    /// Vanilla `ThrowableProjectile.getDefaultGravity` (0.03).
23    fn throwable_default_gravity(&self) -> f64 {
24        DEFAULT_GRAVITY
25    }
26
27    /// Vanilla `ThrowableProjectile.applyInertia` (water vs air drag).
28    fn apply_inertia(&self) {
29        let inertia = if self.is_in_water() {
30            // VANILLA CLIENT-LOCAL: `ThrowableProjectile.tick` creates the trailing bubbles.
31            WATER_INERTIA
32        } else {
33            f64::from(self.get_air_drag())
34        };
35        self.set_velocity(self.velocity() * inertia);
36    }
37
38    /// Vanilla `ThrowableProjectile.tick`.
39    ///
40    /// Reached from a subclass's `tick` as `super.tick()`. Applies gravity and
41    /// drag, raycasts the move vector, moves to the hit (or full move), updates
42    /// rotation, runs the `Projectile`/`Entity` base tick, then resolves the hit.
43    fn throwable_projectile_tick(&self) {
44        // Vanilla `Entity.setOldPosAndRot()` is run by the level before ticking;
45        // capture it here so `old_position()`/`old_rotation()` hold the pre-move
46        // state used by `onHit` (teleport target) and `updateRotation` (lerp base).
47        self.set_old_position_to_current();
48        self.base().set_old_rotation_to_current();
49
50        self.handle_first_tick_bubble_column();
51        self.apply_gravity();
52        self.apply_inertia();
53
54        let hit = self.get_hit_result_on_move_vector();
55        let new_position = match &hit {
56            Some(result) => result.location(),
57            None => self.position() + self.velocity(),
58        };
59
60        if let Err(error) = self.try_set_position(new_position) {
61            log::debug!("failed to advance projectile {}: {error}", self.id());
62            self.set_removed(RemovalReason::Discarded);
63            return;
64        }
65
66        self.update_rotation();
67        self.apply_effects_from_blocks();
68        self.projectile_base_tick();
69
70        if let Some(result) = hit
71            && self.is_alive()
72            && !self.is_world_change_pending()
73        {
74            self.hit_target_or_deflect_self(&result);
75        }
76    }
77
78    /// Applies bubble-column effects to this projectile on its first tick.
79    fn handle_first_tick_bubble_column(&self) {
80        if !self.is_first_tick() {
81            return;
82        }
83
84        let Some(world) = self.level() else {
85            return;
86        };
87
88        let bounds = self.bounding_box();
89        let min = BlockPos::containing(
90            bounds.min(Axis::X),
91            bounds.min(Axis::Y),
92            bounds.min(Axis::Z),
93        );
94        let max = BlockPos::containing(
95            bounds.max(Axis::X),
96            bounds.max(Axis::Y),
97            bounds.max(Axis::Z),
98        );
99
100        let mut ignored_effects = InsideBlockEffectCollector::new();
101
102        for pos in BlockPos::between_closed(min, max) {
103            let state = world.get_block_state(pos);
104            if state.get_block() != &vanilla_blocks::BUBBLE_COLUMN {
105                continue;
106            }
107
108            BLOCK_BEHAVIORS
109                .get_behavior(state.get_block())
110                .entity_inside(
111                    state,
112                    &world,
113                    pos,
114                    self.as_entity_event_source(),
115                    &mut ignored_effects,
116                    true,
117                );
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use glam::DVec3;
125    use std::sync::Arc;
126
127    use steel_registry::blocks::{
128        block_state_ext::BlockStateExt as _, properties::BlockStateProperties,
129    };
130    use steel_registry::{init_vanilla_registry, vanilla_blocks, vanilla_entities};
131    use steel_utils::types::UpdateFlags;
132    use steel_utils::{BlockPos, ChunkPos};
133
134    use crate::behavior::init_behaviors;
135    use crate::entity::entities::SnowballEntity;
136    use crate::entity::{Entity, SharedEntity};
137    use crate::test_support::{fresh_test_world, insert_ready_full_chunk};
138
139    use super::ThrowableProjectile as _;
140
141    #[test]
142    fn bubble_column_affects_throwable_projectile_before_its_first_movement() {
143        init_vanilla_registry();
144        init_behaviors();
145
146        let world = fresh_test_world("throwable_first_tick_bubble_column");
147        insert_ready_full_chunk(&world, ChunkPos::new(0, 0));
148
149        let bubble_pos = BlockPos::new(8, 65, 8);
150        let initial_position = DVec3::new(8.5, 65.0, 8.5);
151        let bubble_column = vanilla_blocks::BUBBLE_COLUMN
152            .default_state()
153            .set_value(&BlockStateProperties::DRAG, false);
154
155        assert!(world.set_block(bubble_pos, bubble_column, UpdateFlags::UPDATE_NONE));
156
157        let snowball = Arc::new(SnowballEntity::new(
158            &vanilla_entities::SNOWBALL,
159            1,
160            initial_position,
161            Arc::downgrade(&world),
162        ));
163        world
164            .try_add_entity(Arc::clone(&snowball) as SharedEntity)
165            .expect("snowball should attach to the loaded chunk");
166
167        assert!(snowball.is_first_tick());
168
169        snowball.tick();
170
171        assert!(!snowball.is_first_tick());
172        assert!(
173            snowball.position().y > initial_position.y,
174            "the first-tick bubble-column effect should push the projectile upward before movement"
175        );
176
177        snowball
178            .try_set_position(initial_position)
179            .expect("snowball should return to its initial position");
180        snowball.set_velocity(DVec3::ZERO);
181
182        snowball.tick();
183
184        assert!(
185            snowball.position().y < initial_position.y,
186            "after the first tick, the bubble-column effect should occur after movement"
187        );
188    }
189
190    #[test]
191    fn first_tick_bubble_column_does_not_clamp_projectile_velocity() {
192        init_vanilla_registry();
193        init_behaviors();
194
195        let world = fresh_test_world("throwable_first_tick_bubble_column_velocity");
196        insert_ready_full_chunk(&world, ChunkPos::new(0, 0));
197
198        let bubble_pos = BlockPos::new(8, 65, 8);
199        let initial_position = DVec3::new(8.5, 65.0, 8.5);
200
201        for (open_above, drag_down, initial_y, expected_y) in [
202            (true, false, 2.0, 2.1),
203            (true, true, -2.0, -2.03),
204            (false, false, 2.0, 2.06),
205            (false, true, -2.0, -2.03),
206        ] {
207            let bubble_column = vanilla_blocks::BUBBLE_COLUMN
208                .default_state()
209                .set_value(&BlockStateProperties::DRAG, drag_down);
210
211            assert!(world.set_block(bubble_pos, bubble_column, UpdateFlags::UPDATE_NONE,));
212
213            let above_state = if open_above {
214                vanilla_blocks::AIR.default_state()
215            } else {
216                vanilla_blocks::WATER.default_state()
217            };
218
219            // The block may already have this state from the previous case.
220            world.set_block(bubble_pos.above(), above_state, UpdateFlags::UPDATE_NONE);
221
222            let snowball = SnowballEntity::new(
223                &vanilla_entities::SNOWBALL,
224                1,
225                initial_position,
226                Arc::downgrade(&world),
227            );
228            snowball.set_velocity(DVec3::new(0.25, initial_y, -0.25));
229
230            assert!(snowball.is_first_tick());
231            snowball.handle_first_tick_bubble_column();
232
233            let expected = DVec3::new(0.25, expected_y, -0.25);
234            let actual = snowball.velocity();
235
236            assert!(
237                (actual - expected).abs().max_element() < 1.0e-12,
238                "open_above={open_above}, drag_down={drag_down}: \
239                 expected {expected:?}, got {actual:?}"
240            );
241        }
242    }
243}