Skip to main content

steel_core/block_entity/entities/
potent_sulfur.rs

1//! `PotentSulfurBlockEntity` for geyser eruption
2
3use std::sync::{Arc, Weak};
4
5use simdnbt::borrow::{BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView};
6use simdnbt::owned::NbtCompound;
7use steel_registry::blocks::block_state_ext::BlockStateExt as _;
8use steel_registry::blocks::properties::{BlockStateProperties, PotentSulfurState};
9use steel_registry::vanilla_block_entity_types;
10use steel_registry::vanilla_game_events;
11use steel_registry::{
12    REGISTRY, TaggedRegistryExt as _, vanilla_blocks, vanilla_entity_type_tags::EntityTypeTag,
13};
14use steel_utils::random::xoroshiro::Xoroshiro;
15use steel_utils::random::{PositionalRandom, Random, RandomSource, RandomSplitter};
16use steel_utils::types::UpdateFlags;
17use steel_utils::{
18    BlockPos, BlockStateId, DowncastType, DowncastTypeKey, WorldAabb, locks::SyncMutex,
19};
20
21use crate::behavior::{BLOCK_BEHAVIORS, BlockCollisionContext};
22use crate::block_entity::{BlockEntity, BlockEntityBase, BlockEntityLifecycleExt as _};
23use crate::fluid::FluidStateExt as _;
24use crate::world::World;
25
26const GEYSER_SALT: i64 = -904_011_478;
27const COUNTDOWN_FREQUENCY_TICKS: i64 = 20;
28const LAUNCH_FORCE: f64 = 0.2;
29const BASE_VELOCITY_THRESHOLD: f64 = 0.3;
30const VELOCITY_THRESHOLD_SCALE: f64 = 0.1;
31const MAX_WATER_BLOCKS_ABOVE: i32 = 4;
32const FORCE_HEIGHT_MULTIPLIER: i32 = 6;
33
34/// Block entity for `potent_sulfur` blocks
35pub struct PotentSulfurBlockEntity {
36    base: BlockEntityBase,
37    sulfur: SyncMutex<PotentSulfurData>,
38}
39
40struct PotentSulfurData {
41    /// Countdown for 20 tick steps before the state toggles. -1 is uninitialized
42    waiting_countdown: i32,
43    /// Game tick at which the current eruption started
44    eruption_tick: i64,
45}
46
47// SAFETY: This key is owned by Steel and uniquely identifies `PotentSulfurBlockEntity`.
48unsafe impl DowncastType for PotentSulfurBlockEntity {
49    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/potent_sulfur");
50}
51
52impl PotentSulfurBlockEntity {
53    /// Creates a new block entity
54    #[must_use]
55    pub fn new(world: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
56        let eruption_tick = world.upgrade().map_or(-1, |w| w.game_time());
57        Self {
58            base: BlockEntityBase::new(
59                &vanilla_block_entity_types::POTENT_SULFUR,
60                world,
61                pos,
62                state,
63            ),
64            sulfur: SyncMutex::new(PotentSulfurData {
65                waiting_countdown: -1,
66                eruption_tick,
67            }),
68        }
69    }
70
71    /// Resets the countdown so it reinitializes on the next tick
72    pub fn reset_countdown(&self) {
73        self.sulfur.lock().waiting_countdown = -1;
74    }
75
76    /// Records the tick at which an eruption started.
77    pub fn set_eruption_tick(&self, eruption_tick: i64) {
78        self.sulfur.lock().eruption_tick = eruption_tick;
79    }
80
81    fn geyser_positional_rng(seed: i64, pos: BlockPos) -> Xoroshiro {
82        let mut base = Xoroshiro::from_seed((seed ^ GEYSER_SALT) as u64);
83        let RandomSplitter::Xoroshiro(splitter) = base.next_positional() else {
84            unreachable!("Xoroshiro always produces Xoroshiro splitter")
85        };
86        match splitter.at(pos.x(), pos.y(), pos.z()) {
87            RandomSource::Xoroshiro(r) => r,
88            RandomSource::Legacy(_) => {
89                unreachable!("XoroshiroSplitter::at always returns Xoroshiro")
90            }
91        }
92    }
93
94    fn is_geyser_passable(world: &World, pos: BlockPos, context: BlockCollisionContext) -> bool {
95        let state = world.get_block_state(pos);
96        if state.is_air() || state.get_block() == &vanilla_blocks::WATER {
97            return true;
98        }
99
100        let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
101        behavior
102            .get_collision_shape(state, world, pos, context)
103            .is_empty()
104    }
105
106    fn find_source_block(world: &World, origin: BlockPos) -> Option<BlockPos> {
107        let max_y = origin.y() + MAX_WATER_BLOCKS_ABOVE + 1;
108        let geyser_position_context =
109            BlockCollisionContext::position_context(f64::from(origin.y()));
110        let mut pos = BlockPos::new(origin.x(), origin.y() + 1, origin.z());
111
112        while pos.y() <= max_y {
113            let state = world.get_block_state(pos);
114            let fluid = state.get_fluid_state();
115            let is_water_source = fluid.is_source() && fluid.is_water();
116
117            if is_water_source
118                && (state.get_block() == &vanilla_blocks::WATER
119                    || Self::is_geyser_passable(world, pos, geyser_position_context))
120            {
121                pos = BlockPos::new(pos.x(), pos.y() + 1, pos.z());
122                continue;
123            }
124
125            if state.is_air() || Self::is_geyser_passable(world, pos, geyser_position_context) {
126                return Some(pos);
127            }
128
129            break; // Solid obstruction
130        }
131
132        None
133    }
134
135    fn unobstructed_block_count(world: &World, start: BlockPos, water_blocks: i32) -> i32 {
136        let max_height = FORCE_HEIGHT_MULTIPLIER * water_blocks;
137        let geyser_position_context =
138            BlockCollisionContext::position_context(f64::from(start.y() - 1));
139        for i in 0..max_height {
140            let check = BlockPos::new(start.x(), start.y() + i, start.z());
141            if !Self::is_geyser_passable(world, check, geyser_position_context) {
142                return i;
143            }
144        }
145        max_height
146    }
147
148    fn tick_countdown(
149        &self,
150        world: &World,
151        pos: BlockPos,
152        state: BlockStateId,
153    ) -> Option<(BlockStateId, bool)> {
154        let source = Self::find_source_block(world, pos)?;
155        let water_blocks = source.y() - pos.y() - 1;
156        let mut rng = Self::geyser_positional_rng(world.seed(), pos);
157        let game_time = world.game_time();
158        let mut sulfur = self.sulfur.lock();
159
160        if sulfur.waiting_countdown <= 0 {
161            let current_state = state.get_value(&BlockStateProperties::POTENT_SULFUR_STATE);
162
163            sulfur.waiting_countdown = if current_state == PotentSulfurState::Dormant {
164                10 * (water_blocks - 1) + rng.next_i32_between(15, 30)
165            } else {
166                rng.next_i32();
167                (water_blocks - 1) + rng.next_i32_between(1, 2)
168            };
169        }
170
171        if sulfur.waiting_countdown > 0 {
172            sulfur.waiting_countdown -= 1;
173        }
174
175        if sulfur.waiting_countdown == 0 {
176            let current_state = state.get_value(&BlockStateProperties::POTENT_SULFUR_STATE);
177            let next_state = if current_state == PotentSulfurState::Dormant {
178                PotentSulfurState::Erupting
179            } else {
180                PotentSulfurState::Dormant
181            };
182            let deactivates = next_state == PotentSulfurState::Dormant;
183            let activates = next_state == PotentSulfurState::Erupting;
184            let new_state = state.set_value(&BlockStateProperties::POTENT_SULFUR_STATE, next_state);
185            if activates {
186                sulfur.eruption_tick = game_time;
187            }
188            return Some((new_state, deactivates));
189        }
190
191        None
192    }
193
194    fn tick_launch(world: &Arc<World>, pos: BlockPos) {
195        let Some(source) = Self::find_source_block(world, pos) else {
196            return;
197        };
198
199        let water_blocks = source.y() - pos.y() - 1;
200        let above = BlockPos::new(pos.x(), pos.y() + 1, pos.z());
201        let force_height = Self::unobstructed_block_count(world, above, water_blocks);
202
203        let aabb = WorldAabb::new(
204            f64::from(pos.x()),
205            f64::from(pos.y() + 1),
206            f64::from(pos.z()),
207            f64::from(pos.x() + 1),
208            f64::from(pos.y() + 1) + f64::from(force_height),
209            f64::from(pos.z() + 1),
210        );
211
212        let velocity_threshold =
213            BASE_VELOCITY_THRESHOLD + f64::from(water_blocks) * VELOCITY_THRESHOLD_SCALE;
214
215        for entity in world.get_entities_in_aabb(&aabb) {
216            if !entity.is_alive() || entity.is_spectator() {
217                continue;
218            }
219            let vel = entity.velocity();
220            entity.check_fall_distance_accumulation();
221
222            if !entity.can_simulate_movement() {
223                continue;
224            }
225            if entity.is_flying_player() {
226                continue;
227            }
228            if entity.is_passenger() {
229                continue;
230            }
231            if REGISTRY.entity_types.is_in_tag(
232                entity.entity_type(),
233                &EntityTypeTag::NOT_AFFECTED_BY_GEYSERS,
234            ) {
235                continue;
236            }
237            if vel.y >= velocity_threshold {
238                continue;
239            }
240
241            entity.set_velocity(glam::DVec3::new(vel.x, vel.y + LAUNCH_FORCE, vel.z));
242            entity.mark_velocity_sync();
243        }
244    }
245}
246
247impl BlockEntity for PotentSulfurBlockEntity {
248    fn base(&self) -> &BlockEntityBase {
249        &self.base
250    }
251
252    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
253        let nbt: NbtCompoundView<'_, '_> = nbt.into();
254        if let Some(countdown) = nbt.int("countdown") {
255            self.sulfur.lock().waiting_countdown = countdown;
256        }
257    }
258
259    fn save_additional(&self, nbt: &mut NbtCompound) {
260        nbt.insert("countdown", self.sulfur.lock().waiting_countdown);
261    }
262
263    fn tick(&self, world: &Arc<World>) {
264        let pos = self.get_block_pos();
265        let state = world.get_block_state(pos);
266        if state.get_block() != &vanilla_blocks::POTENT_SULFUR {
267            self.set_removed();
268            return;
269        }
270
271        let current = state.get_value(&BlockStateProperties::POTENT_SULFUR_STATE);
272
273        if current == PotentSulfurState::Dry {
274            return;
275        }
276
277        let game_time = world.game_time();
278
279        // TODO: Add nausea ticker (WET / DORMANT states, every 10 ticks) after the mob-effect refactor adds timed instances and sync.
280
281        let action = if matches!(
282            &current,
283            PotentSulfurState::Dormant | PotentSulfurState::Erupting
284        ) && game_time % COUNTDOWN_FREQUENCY_TICKS == 0
285        {
286            self.tick_countdown(world, pos, state)
287        } else {
288            None
289        };
290
291        if matches!(
292            &current,
293            PotentSulfurState::Erupting | PotentSulfurState::Continuous
294        ) {
295            Self::tick_launch(world, pos);
296        }
297
298        if let Some((new_state, deactivates)) = action {
299            world.set_block(pos, new_state, UpdateFlags::UPDATE_ALL);
300            if deactivates {
301                world.game_event(
302                    &vanilla_game_events::BLOCK_DEACTIVATE,
303                    pos,
304                    &crate::world::game_event::GameEventContext::new(None, Some(state)),
305                );
306            }
307        }
308    }
309}