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