Skip to main content

steel_core/entity/projectile/
throwable.rs

1//! Vanilla `ThrowableProjectile` — the gravity/drag movement loop.
2use std::sync::Arc;
3use steel_registry::{blocks::block_state_ext::BlockStateExt as _, vanilla_blocks};
4use steel_utils::{BlockPos, axis::Axis};
5
6use crate::behavior::BLOCK_BEHAVIORS;
7use crate::entity::projectile::Projectile;
8use crate::entity::{InsideBlockEffectCollector, RemovalReason};
9
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    /// Air drag multiplier applied to velocity each tick while airborne (0.99, i.e. 1% loss).
18    fn get_air_drag(&self) -> f32 {
19        0.99
20    }
21
22    /// Downward acceleration applied each tick while this projectile is airborne.
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(ThrowableProjectile::get_air_drag(self))
34        };
35        self.set_velocity(self.velocity() * inertia);
36    }
37
38    /// Reached from a subclass's `tick` as `super.tick()`. Applies gravity and
39    /// drag, raycasts the move vector, moves to the hit (or full move), updates
40    /// rotation, runs the `Projectile`/`Entity` base tick, then resolves the hit.
41    fn throwable_projectile_tick(self: Arc<Self>) {
42        // Vanilla `Entity.setOldPosAndRot()` is run by the level before ticking;
43        // capture it here so `old_position()`/`old_rotation()` hold the pre-move
44        // state used by `onHit` (teleport target) and `updateRotation` (lerp base).
45        self.set_old_position_to_current();
46        self.base().set_old_rotation_to_current();
47
48        self.handle_first_tick_bubble_column();
49        self.apply_gravity();
50        self.apply_inertia();
51
52        let hit = self.get_hit_result_on_move_vector();
53        let new_position = match &hit {
54            Some(result) => result.location(),
55            None => self.position() + self.velocity(),
56        };
57
58        if let Err(error) = self.try_set_position(new_position) {
59            log::debug!("failed to advance projectile {}: {error}", self.id());
60            self.set_removed(RemovalReason::Discarded);
61            return;
62        }
63
64        self.update_rotation();
65        self.apply_effects_from_blocks();
66        self.projectile_base_tick();
67
68        if let Some(result) = hit
69            && self.is_alive()
70            && !self.is_world_change_pending()
71        {
72            self.hit_target_or_deflect_self(&result);
73        }
74    }
75
76    /// Applies bubble-column effects to this projectile on its first tick.
77    fn handle_first_tick_bubble_column(&self) {
78        if !self.is_first_tick() {
79            return;
80        }
81
82        let Some(world) = self.level() else {
83            return;
84        };
85
86        let bounds = self.bounding_box();
87        let min = BlockPos::containing(
88            bounds.min(Axis::X),
89            bounds.min(Axis::Y),
90            bounds.min(Axis::Z),
91        );
92        let max = BlockPos::containing(
93            bounds.max(Axis::X),
94            bounds.max(Axis::Y),
95            bounds.max(Axis::Z),
96        );
97
98        let mut ignored_effects = InsideBlockEffectCollector::new();
99
100        for pos in BlockPos::between_closed(min, max) {
101            let state = world.get_block_state(pos);
102            if state.get_block() != &vanilla_blocks::BUBBLE_COLUMN {
103                continue;
104            }
105
106            BLOCK_BEHAVIORS
107                .get_behavior(state.get_block())
108                .entity_inside(
109                    state,
110                    &world,
111                    pos,
112                    self.as_entity_event_source(),
113                    &mut ignored_effects,
114                    true,
115                );
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use glam::DVec3;
123    use std::sync::Arc;
124
125    use steel_registry::blocks::{
126        block_state_ext::BlockStateExt as _, properties::BlockStateProperties,
127    };
128    use steel_registry::{init_vanilla_registry, vanilla_blocks, vanilla_entities};
129    use steel_utils::types::UpdateFlags;
130    use steel_utils::{BlockPos, ChunkPos};
131
132    use crate::behavior::init_behaviors;
133    use crate::entity::entities::SnowballEntity;
134    use crate::entity::{Entity, SharedEntity};
135    use crate::test_support::{fresh_test_world, insert_ready_full_chunk};
136
137    use super::ThrowableProjectile as _;
138
139    #[test]
140    fn bubble_column_affects_throwable_projectile_before_its_first_movement() {
141        init_vanilla_registry();
142        init_behaviors();
143
144        let world_fixture = fresh_test_world("throwable_first_tick_bubble_column");
145        let world = &world_fixture.world;
146        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
147
148        let bubble_pos = BlockPos::new(8, 65, 8);
149        let initial_position = DVec3::new(8.5, 65.0, 8.5);
150        let bubble_column = vanilla_blocks::BUBBLE_COLUMN
151            .default_state()
152            .set_value(&BlockStateProperties::DRAG, false);
153
154        assert!(world.set_block(bubble_pos, bubble_column, UpdateFlags::UPDATE_NONE));
155
156        let snowball = Arc::new(SnowballEntity::new(
157            &vanilla_entities::SNOWBALL,
158            1,
159            initial_position,
160            Arc::downgrade(world),
161        ));
162        world
163            .try_add_entity(Arc::clone(&snowball) as SharedEntity)
164            .expect("snowball should attach to the loaded chunk");
165
166        assert!(snowball.is_first_tick());
167
168        Arc::clone(&snowball).tick();
169
170        assert!(!snowball.is_first_tick());
171        assert!(
172            snowball.position().y > initial_position.y,
173            "the first-tick bubble-column effect should push the projectile upward before movement"
174        );
175
176        snowball
177            .try_set_position(initial_position)
178            .expect("snowball should return to its initial position");
179        snowball.set_velocity(DVec3::ZERO);
180
181        Arc::clone(&snowball).tick();
182
183        assert!(
184            snowball.position().y < initial_position.y,
185            "after the first tick, the bubble-column effect should occur after movement"
186        );
187    }
188
189    #[test]
190    fn first_tick_bubble_column_does_not_clamp_projectile_velocity() {
191        init_vanilla_registry();
192        init_behaviors();
193
194        let world_fixture = fresh_test_world("throwable_first_tick_bubble_column_velocity");
195        let world = &world_fixture.world;
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}