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}