Skip to main content

steel_core/worldgen/feature/features/
speleothem.rs

1use steel_registry::vanilla_block_tags::BlockTag;
2
3use super::super::prelude::*;
4use super::super::runner::FeatureDecorationRunner;
5
6impl FeatureDecorationRunner {
7    pub(in crate::worldgen::feature) fn place_speleothem_feature(
8        region: &mut WorldGenRegion<'_>,
9        random: &mut WorldgenRandom,
10        config: &SpeleothemConfiguration,
11        origin: BlockPos,
12    ) -> bool {
13        let Some(tip_direction) = Self::speleothem_tip_direction(region, random, config, origin)
14        else {
15            return false;
16        };
17
18        let root_pos = origin.relative(tip_direction.opposite());
19        Self::create_patch_of_speleothem_base_blocks(region, random, root_pos, config);
20        let height = if random.next_f32() < config.chance_of_taller_generation
21            && Self::speleothem_is_empty_or_water(
22                region.block_state(origin.relative(tip_direction)),
23            ) {
24            2
25        } else {
26            1
27        };
28        Self::grow_speleothem(
29            region,
30            origin,
31            tip_direction,
32            height,
33            false,
34            config.base_block.block,
35            config.pointed_block.block,
36            &config.replaceable_blocks,
37        );
38        true
39    }
40
41    pub(in crate::worldgen::feature) fn place_speleothem_cluster_feature(
42        region: &mut WorldGenRegion<'_>,
43        random: &mut WorldgenRandom,
44        config: &SpeleothemClusterConfiguration,
45        origin: BlockPos,
46    ) -> bool {
47        if !Self::speleothem_is_empty_or_water(region.block_state(origin)) {
48            return false;
49        }
50
51        let height = config.height.sample(random);
52        let wetness = config.wetness.sample(random);
53        let density = config.density.sample(random);
54        let x_radius = config.radius.sample(random);
55        let z_radius = config.radius.sample(random);
56
57        for dx in -x_radius..=x_radius {
58            for dz in -z_radius..=z_radius {
59                let chance = Self::chance_of_speleothem(x_radius, z_radius, dx, dz, config);
60                let pos = origin.offset(dx, 0, dz);
61                Self::place_speleothem_cluster_column(
62                    region, random, pos, dx, dz, wetness, chance, height, density, config,
63                );
64            }
65        }
66
67        true
68    }
69
70    fn speleothem_tip_direction(
71        region: &WorldGenRegion<'_>,
72        random: &mut WorldgenRandom,
73        config: &SpeleothemConfiguration,
74        pos: BlockPos,
75    ) -> Option<Direction> {
76        let can_place_above = Self::is_speleothem_base(
77            region.block_state(pos.above()),
78            config.base_block.block,
79            &config.replaceable_blocks,
80        );
81        let can_place_below = Self::is_speleothem_base(
82            region.block_state(pos.below()),
83            config.base_block.block,
84            &config.replaceable_blocks,
85        );
86        if can_place_above && can_place_below {
87            Some(if random.next_bool() {
88                Direction::Down
89            } else {
90                Direction::Up
91            })
92        } else if can_place_above {
93            Some(Direction::Down)
94        } else if can_place_below {
95            Some(Direction::Up)
96        } else {
97            None
98        }
99    }
100
101    fn create_patch_of_speleothem_base_blocks(
102        region: &mut WorldGenRegion<'_>,
103        random: &mut WorldgenRandom,
104        pos: BlockPos,
105        config: &SpeleothemConfiguration,
106    ) {
107        Self::place_speleothem_base_block_if_possible(
108            region,
109            pos,
110            config.base_block.block,
111            &config.replaceable_blocks,
112        );
113
114        for direction in Self::VANILLA_HORIZONTAL_DIRECTIONS {
115            if random.next_f32() > config.chance_of_directional_spread {
116                continue;
117            }
118
119            let pos1 = pos.relative(direction);
120            Self::place_speleothem_base_block_if_possible(
121                region,
122                pos1,
123                config.base_block.block,
124                &config.replaceable_blocks,
125            );
126            if random.next_f32() > config.chance_of_spread_radius2 {
127                continue;
128            }
129
130            let pos2 = pos1.relative(Self::random_speleothem_direction(random));
131            Self::place_speleothem_base_block_if_possible(
132                region,
133                pos2,
134                config.base_block.block,
135                &config.replaceable_blocks,
136            );
137            if random.next_f32() > config.chance_of_spread_radius3 {
138                continue;
139            }
140
141            let pos3 = pos2.relative(Self::random_speleothem_direction(random));
142            Self::place_speleothem_base_block_if_possible(
143                region,
144                pos3,
145                config.base_block.block,
146                &config.replaceable_blocks,
147            );
148        }
149    }
150
151    #[expect(
152        clippy::too_many_arguments,
153        reason = "mirrors vanilla SpeleothemClusterFeature.placeColumn state"
154    )]
155    #[expect(
156        clippy::too_many_lines,
157        reason = "mirrors vanilla SpeleothemClusterFeature.placeColumn flow"
158    )]
159    fn place_speleothem_cluster_column(
160        region: &mut WorldGenRegion<'_>,
161        random: &mut WorldgenRandom,
162        pos: BlockPos,
163        dx: i32,
164        dz: i32,
165        chance_of_water: f32,
166        chance_of_stalagmite_or_stalactite: f64,
167        cluster_height: i32,
168        density: f32,
169        config: &SpeleothemClusterConfiguration,
170    ) {
171        let Some(base_column) = Self::scan_dripstone_column(
172            region,
173            pos,
174            config.floor_to_ceiling_search_range,
175            Self::speleothem_is_empty_or_water,
176            Self::is_neither_empty_nor_water,
177        ) else {
178            return;
179        };
180
181        let ceiling = base_column.ceiling;
182        let base_floor = base_column.floor;
183        if ceiling.is_none() && base_floor.is_none() {
184            return;
185        }
186
187        let want_pool = random.next_f32() < chance_of_water;
188        let column = if let Some(base_floor_y) = base_floor {
189            if want_pool && Self::can_place_speleothem_pool(region, pos.at_y(base_floor_y), config)
190            {
191                let _ = region.set_block_state(
192                    pos.at_y(base_floor_y),
193                    vanilla_blocks::WATER.default_state(),
194                    UpdateFlags::UPDATE_CLIENTS,
195                );
196                let mut column = base_column;
197                column.floor = Some(base_floor_y - 1);
198                column
199            } else {
200                base_column
201            }
202        } else {
203            base_column
204        };
205
206        let floor = column.floor;
207        let want_stalactite = random.next_f64() < chance_of_stalagmite_or_stalactite;
208        let stalactite_height = if let Some(ceiling_y) = ceiling {
209            if want_stalactite && !Self::is_lava(region, pos.at_y(ceiling_y)) {
210                let ceiling_thickness = config.speleothem_block_layer_thickness.sample(random);
211                Self::replace_blocks_with_speleothem_base_blocks(
212                    region,
213                    pos.at_y(ceiling_y),
214                    ceiling_thickness,
215                    Direction::Up,
216                    config,
217                );
218                let max_height = if let Some(floor_y) = floor {
219                    cluster_height.min(ceiling_y - floor_y)
220                } else {
221                    cluster_height
222                };
223                Self::speleothem_cluster_height(random, dx, dz, density, max_height, config)
224            } else {
225                0
226            }
227        } else {
228            0
229        };
230
231        let want_stalagmite = random.next_f64() < chance_of_stalagmite_or_stalactite;
232        let stalagmite_height = if let Some(floor_y) = floor {
233            if want_stalagmite && !Self::is_lava(region, pos.at_y(floor_y)) {
234                let floor_thickness = config.speleothem_block_layer_thickness.sample(random);
235                Self::replace_blocks_with_speleothem_base_blocks(
236                    region,
237                    pos.at_y(floor_y),
238                    floor_thickness,
239                    Direction::Down,
240                    config,
241                );
242                if ceiling.is_some() {
243                    (stalactite_height
244                        + random.next_i32_between(
245                            -config.max_stalagmite_stalactite_height_diff,
246                            config.max_stalagmite_stalactite_height_diff,
247                        ))
248                    .max(0)
249                } else {
250                    Self::speleothem_cluster_height(random, dx, dz, density, cluster_height, config)
251                }
252            } else {
253                0
254            }
255        } else {
256            0
257        };
258
259        let (actual_stalactite_height, actual_stalagmite_height) =
260            if let (Some(ceiling_y), Some(floor_y)) = (ceiling, floor) {
261                if ceiling_y - stalactite_height <= floor_y + stalagmite_height {
262                    let lowest_stalactite_bottom = (ceiling_y - stalactite_height).max(floor_y + 1);
263                    let highest_stalagmite_top = (floor_y + stalagmite_height).min(ceiling_y - 1);
264                    let actual_stalactite_bottom = random
265                        .next_i32_between(lowest_stalactite_bottom, highest_stalagmite_top + 1);
266                    let actual_stalagmite_top = actual_stalactite_bottom - 1;
267                    (
268                        ceiling_y - actual_stalactite_bottom,
269                        actual_stalagmite_top - floor_y,
270                    )
271                } else {
272                    (stalactite_height, stalagmite_height)
273                }
274            } else {
275                (stalactite_height, stalagmite_height)
276            };
277
278        let merge_tips = random.next_bool()
279            && actual_stalactite_height > 0
280            && actual_stalagmite_height > 0
281            && speleothem_column_height(column.floor, column.ceiling).is_some_and(|height| {
282                actual_stalactite_height + actual_stalagmite_height == height
283            });
284
285        if let Some(ceiling_y) = ceiling {
286            Self::grow_speleothem(
287                region,
288                pos.at_y(ceiling_y - 1),
289                Direction::Down,
290                actual_stalactite_height,
291                merge_tips,
292                config.base_block.block,
293                config.pointed_block.block,
294                &config.replaceable_blocks,
295            );
296        }
297
298        if let Some(floor_y) = floor {
299            Self::grow_speleothem(
300                region,
301                pos.at_y(floor_y + 1),
302                Direction::Up,
303                actual_stalagmite_height,
304                merge_tips,
305                config.base_block.block,
306                config.pointed_block.block,
307                &config.replaceable_blocks,
308            );
309        }
310    }
311
312    #[expect(
313        clippy::too_many_arguments,
314        reason = "keeps vanilla speleothem growth parameters explicit"
315    )]
316    fn grow_speleothem(
317        region: &mut WorldGenRegion<'_>,
318        start_pos: BlockPos,
319        tip_direction: Direction,
320        height: i32,
321        merged_tip: bool,
322        base_block: BlockRef,
323        pointed_block: BlockRef,
324        replaceable_blocks: &BlockHolderSet,
325    ) {
326        if !Self::is_speleothem_base(
327            region.block_state(start_pos.relative(tip_direction.opposite())),
328            base_block,
329            replaceable_blocks,
330        ) {
331            return;
332        }
333
334        let mut pos = start_pos;
335        Self::build_base_to_tip_speleothem_column(
336            tip_direction,
337            height,
338            merged_tip,
339            pointed_block,
340            |state| {
341                let state = if state.get_block() == pointed_block {
342                    state.set_value(
343                        &BlockStateProperties::WATERLOGGED,
344                        get_fluid_state_from_block(region.block_state(pos)).is_water(),
345                    )
346                } else {
347                    state
348                };
349
350                let _ = region.set_block_state(pos, state, UpdateFlags::UPDATE_CLIENTS);
351                pos = pos.relative(tip_direction);
352            },
353        );
354    }
355
356    fn build_base_to_tip_speleothem_column(
357        direction: Direction,
358        total_length: i32,
359        merged_tip: bool,
360        pointed_block: BlockRef,
361        mut consumer: impl FnMut(BlockStateId),
362    ) {
363        if total_length >= 3 {
364            consumer(Self::create_pointed_speleothem(
365                pointed_block,
366                direction,
367                SpeleothemThickness::Base,
368            ));
369
370            for _ in 0..total_length - 3 {
371                consumer(Self::create_pointed_speleothem(
372                    pointed_block,
373                    direction,
374                    SpeleothemThickness::Middle,
375                ));
376            }
377        }
378
379        if total_length >= 2 {
380            consumer(Self::create_pointed_speleothem(
381                pointed_block,
382                direction,
383                SpeleothemThickness::Frustum,
384            ));
385        }
386
387        if total_length >= 1 {
388            let thickness = if merged_tip {
389                SpeleothemThickness::TipMerge
390            } else {
391                SpeleothemThickness::Tip
392            };
393            consumer(Self::create_pointed_speleothem(
394                pointed_block,
395                direction,
396                thickness,
397            ));
398        }
399    }
400
401    fn create_pointed_speleothem(
402        pointed_block: BlockRef,
403        direction: Direction,
404        thickness: SpeleothemThickness,
405    ) -> BlockStateId {
406        pointed_block
407            .default_state()
408            .set_value(&BlockStateProperties::VERTICAL_DIRECTION, direction)
409            .set_value(&BlockStateProperties::SPELEOTHEM_THICKNESS, thickness)
410    }
411
412    fn place_speleothem_base_block_if_possible(
413        region: &mut WorldGenRegion<'_>,
414        pos: BlockPos,
415        base_block: BlockRef,
416        replaceable_blocks: &BlockHolderSet,
417    ) -> bool {
418        if !Self::block_matches_holder_set(region.block_state(pos).get_block(), replaceable_blocks)
419        {
420            return false;
421        }
422
423        region.set_block_state(pos, base_block.default_state(), UpdateFlags::UPDATE_CLIENTS)
424    }
425
426    fn is_speleothem_base(
427        state: BlockStateId,
428        base_block: BlockRef,
429        replaceable_blocks: &BlockHolderSet,
430    ) -> bool {
431        let block = state.get_block();
432        block == base_block || Self::block_matches_holder_set(block, replaceable_blocks)
433    }
434
435    fn can_place_speleothem_pool(
436        region: &WorldGenRegion<'_>,
437        pos: BlockPos,
438        config: &SpeleothemClusterConfiguration,
439    ) -> bool {
440        let state = region.block_state(pos);
441        let block = state.get_block();
442        if block == &vanilla_blocks::WATER
443            || block == config.base_block.block
444            || block == config.pointed_block.block
445        {
446            return false;
447        }
448
449        if get_fluid_state_from_block(region.block_state(pos.above())).is_water() {
450            return false;
451        }
452
453        for direction in Self::VANILLA_HORIZONTAL_DIRECTIONS {
454            if !Self::can_be_adjacent_to_speleothem_pool_water(region, pos.relative(direction)) {
455                return false;
456            }
457        }
458
459        Self::can_be_adjacent_to_speleothem_pool_water(region, pos.below())
460    }
461
462    fn can_be_adjacent_to_speleothem_pool_water(
463        region: &WorldGenRegion<'_>,
464        pos: BlockPos,
465    ) -> bool {
466        let state = region.block_state(pos);
467        state.get_block().has_tag(&BlockTag::BASE_STONE_OVERWORLD)
468            || get_fluid_state_from_block(state).is_water()
469    }
470
471    fn replace_blocks_with_speleothem_base_blocks(
472        region: &mut WorldGenRegion<'_>,
473        mut pos: BlockPos,
474        max_count: i32,
475        direction: Direction,
476        config: &SpeleothemClusterConfiguration,
477    ) {
478        for _ in 0..max_count {
479            if !Self::place_speleothem_base_block_if_possible(
480                region,
481                pos,
482                config.base_block.block,
483                &config.replaceable_blocks,
484            ) {
485                return;
486            }
487
488            pos = pos.relative(direction);
489        }
490    }
491
492    fn speleothem_cluster_height(
493        random: &mut WorldgenRandom,
494        dx: i32,
495        dz: i32,
496        density: f32,
497        max_height: i32,
498        config: &SpeleothemClusterConfiguration,
499    ) -> i32 {
500        if random.next_f32() > density {
501            return 0;
502        }
503
504        let distance_from_center = dx.abs() + dz.abs();
505        let height_mean = Self::clamped_map_f32(
506            distance_from_center as f32,
507            0.0,
508            config.max_distance_from_center_affecting_height_bias as f32,
509            max_height as f32 / 2.0,
510            0.0,
511        );
512        Self::speleothem_random_between_biased(
513            random,
514            0.0,
515            max_height as f32,
516            height_mean,
517            config.height_deviation as f32,
518        ) as i32
519    }
520
521    fn chance_of_speleothem(
522        x_radius: i32,
523        z_radius: i32,
524        dx: i32,
525        dz: i32,
526        config: &SpeleothemClusterConfiguration,
527    ) -> f64 {
528        let x_distance_from_edge = x_radius - dx.abs();
529        let z_distance_from_edge = z_radius - dz.abs();
530        let distance_from_edge = x_distance_from_edge.min(z_distance_from_edge);
531        f64::from(Self::clamped_map_f32(
532            distance_from_edge as f32,
533            0.0,
534            config.max_distance_from_edge_affecting_chance_of_speleothem as f32,
535            config.chance_of_speleothem_at_max_distance_from_center,
536            1.0,
537        ))
538    }
539
540    fn speleothem_random_between_biased(
541        random: &mut WorldgenRandom,
542        min: f32,
543        max: f32,
544        mean: f32,
545        deviation: f32,
546    ) -> f32 {
547        let sample = mean + deviation * random.next_gaussian() as f32;
548        sample.clamp(min, max)
549    }
550
551    fn speleothem_is_empty_or_water(state: BlockStateId) -> bool {
552        state.is_air() || state.get_block() == &vanilla_blocks::WATER
553    }
554
555    fn random_speleothem_direction(random: &mut WorldgenRandom) -> Direction {
556        Self::VANILLA_DIRECTION_VALUES[random.next_i32_bounded(6) as usize]
557    }
558}
559
560const fn speleothem_column_height(floor: Option<i32>, ceiling: Option<i32>) -> Option<i32> {
561    match (floor, ceiling) {
562        (Some(floor), Some(ceiling)) => Some(ceiling - floor - 1),
563        _ => None,
564    }
565}