1use std::sync::LazyLock;
5
6use glam::IVec3;
7use steel_registry::biome::{BiomeRef, TemperatureModifier};
8use steel_registry::structure::{
9 LiquidSettingsData, RuinedPortalPlacementData, RuinedPortalSetupData, StructureConfigData,
10 StructureData,
11};
12use steel_utils::random::legacy_random::LegacyRandom;
13use steel_utils::random::{Random, RandomSource};
14use steel_utils::{BoundingBox, Direction, Identifier, Rotation};
15use steel_worldgen::noise::PerlinSimplexNoise;
16
17use crate::structure::{
18 GenerationStub, RuinedPortalProperties, Structure, StructureBlockIgnore,
19 StructureGenerationContext, StructureMirror, StructurePiece, StructurePiecePayload,
20 TemplateMarkerHandling, TemplatePieceData, TemplatePlacementAdjustment, TemplatePlacementClip,
21 TemplatePostProcess, TemplateProcessorList,
22};
23
24static TEMPERATURE_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
25 let mut random = RandomSource::Legacy(LegacyRandom::from_seed(1234));
26 PerlinSimplexNoise::new(&mut random, &[0])
27});
28
29static FROZEN_TEMPERATURE_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
30 let mut random = RandomSource::Legacy(LegacyRandom::from_seed(3456));
31 PerlinSimplexNoise::new(&mut random, &[-2, -1, 0])
32});
33
34static BIOME_INFO_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
35 let mut random = RandomSource::Legacy(LegacyRandom::from_seed(2345));
36 PerlinSimplexNoise::new(&mut random, &[0])
37});
38
39const PORTAL_TEMPLATES: [&str; 10] = [
40 "ruined_portal/portal_1",
41 "ruined_portal/portal_2",
42 "ruined_portal/portal_3",
43 "ruined_portal/portal_4",
44 "ruined_portal/portal_5",
45 "ruined_portal/portal_6",
46 "ruined_portal/portal_7",
47 "ruined_portal/portal_8",
48 "ruined_portal/portal_9",
49 "ruined_portal/portal_10",
50];
51
52const GIANT_PORTAL_TEMPLATES: [&str; 3] = [
53 "ruined_portal/giant_portal_1",
54 "ruined_portal/giant_portal_2",
55 "ruined_portal/giant_portal_3",
56];
57
58pub enum TerrainQuery {
60 SurfaceHeight {
62 x: i32,
64 z: i32,
66 ocean_floor: bool,
68 },
69 IsOpaque {
71 x: i32,
73 y: i32,
75 z: i32,
77 ocean_floor: bool,
79 },
80}
81
82pub enum TerrainResult {
84 Height(i32),
86 Opaque(bool),
88}
89
90pub struct PortalResult {
92 pub biome_check_pos: (i32, i32, i32),
94 pub bounding_box: BoundingBox,
96 pub template_id: Identifier,
98 pub template_size: IVec3,
100 pub rotation: Rotation,
102 pub mirror: StructureMirror,
104 pub rotation_pivot: IVec3,
106 pub vertical_placement: RuinedPortalPlacementData,
108 pub properties: RuinedPortalProperties,
110 pub can_be_cold: bool,
112}
113
114#[expect(
117 clippy::too_many_lines,
118 reason = "inlines vanilla's setup → size → rotation → mirror → placement pipeline"
119)]
120pub fn find_generation_point(
121 rng: &mut LegacyRandom,
122 chunk_x: i32,
123 chunk_z: i32,
124 setups: &[RuinedPortalSetupData],
125 min_y: i32,
126 templates: &mut (impl FnMut(&Identifier) -> Option<[i32; 3]> + ?Sized),
127 terrain: &mut (impl FnMut(TerrainQuery) -> TerrainResult + ?Sized),
128) -> Option<PortalResult> {
129 if setups.is_empty() {
130 return None;
131 }
132
133 let base_x = chunk_x * 16;
134 let base_z = chunk_z * 16;
135
136 let setup = if setups.len() > 1 {
137 let total: f32 = setups.iter().map(|s| s.weight).sum();
138 let mut pick = rng.next_f32();
139 let mut chosen_idx = setups.len() - 1;
140 for (i, s) in setups.iter().enumerate() {
141 pick -= s.weight / total;
142 if pick < 0.0 {
143 chosen_idx = i;
144 break;
145 }
146 }
147 &setups[chosen_idx]
148 } else {
149 &setups[0]
150 };
151
152 let air_pocket = if setup.air_pocket_probability <= 0.0 {
153 false
154 } else if setup.air_pocket_probability >= 1.0 {
155 true
156 } else {
157 rng.next_f32() < setup.air_pocket_probability
158 };
159
160 let template_id = if rng.next_f32() < 0.05 {
161 let index = rng.next_i32_bounded(GIANT_PORTAL_TEMPLATES.len() as i32) as usize;
162 Identifier::vanilla_static(GIANT_PORTAL_TEMPLATES[index])
163 } else {
164 let index = rng.next_i32_bounded(PORTAL_TEMPLATES.len() as i32) as usize;
165 Identifier::vanilla_static(PORTAL_TEMPLATES[index])
166 };
167 let template_size_arr = templates(&template_id)?;
168 let [sx, sy, sz] = template_size_arr;
169
170 let rotation = Rotation::get_random(rng);
171 let mirror = if rng.next_f32() < 0.5 {
172 StructureMirror::None
173 } else {
174 StructureMirror::FrontBack
175 };
176 let mirror_front_back = mirror == StructureMirror::FrontBack;
177 let pivot_x = sx / 2;
178 let pivot_z = sz / 2;
179 let bb = rotation.get_bounding_box_full(
180 IVec3::new(base_x, 0, base_z),
181 IVec3::new(sx, sy, sz),
182 IVec3::new(pivot_x, 0, pivot_z),
183 mirror_front_back,
184 );
185
186 let bb_center_x = bb.min_x() + (bb.max_x() - bb.min_x() + 1) / 2;
187 let bb_center_z = bb.min_z() + (bb.max_z() - bb.min_z() + 1) / 2;
188 let ocean_floor = matches!(setup.placement, RuinedPortalPlacementData::OnOceanFloor);
189 let surface_y = match terrain(TerrainQuery::SurfaceHeight {
190 x: bb_center_x,
191 z: bb_center_z,
192 ocean_floor,
193 }) {
194 TerrainResult::Height(h) => h,
195 TerrainResult::Opaque(_) => unreachable!(),
196 } - 1;
197
198 let min_y_threshold = min_y + 15;
199 let new_y = match setup.placement {
200 RuinedPortalPlacementData::OnLandSurface | RuinedPortalPlacementData::OnOceanFloor => {
201 surface_y
202 }
203 RuinedPortalPlacementData::Underground => {
204 let max_y = surface_y - sy;
205 if min_y_threshold < max_y {
206 rng.next_i32_between(min_y_threshold, max_y)
207 } else {
208 max_y
209 }
210 }
211 RuinedPortalPlacementData::InMountain => {
212 let max_y = surface_y - sy;
213 if 70 < max_y {
214 rng.next_i32_between(70, max_y)
215 } else {
216 max_y
217 }
218 }
219 RuinedPortalPlacementData::PartlyBuried => surface_y - sy + rng.next_i32_between(2, 8),
220 RuinedPortalPlacementData::InNether => {
221 if air_pocket {
222 rng.next_i32_between(32, 100)
223 } else if rng.next_f32() < 0.5 {
224 rng.next_i32_between(27, 29)
225 } else {
226 rng.next_i32_between(29, 100)
227 }
228 }
229 };
230
231 let corners = [
232 (bb.min_x(), bb.min_z()),
233 (bb.max_x(), bb.min_z()),
234 (bb.min_x(), bb.max_z()),
235 (bb.max_x(), bb.max_z()),
236 ];
237 let mut projected_y = new_y;
238 'scan: while projected_y > min_y_threshold {
239 let mut solid_count = 0;
240 for &(cx, cz) in &corners {
241 if matches!(
242 terrain(TerrainQuery::IsOpaque {
243 x: cx,
244 y: projected_y,
245 z: cz,
246 ocean_floor,
247 }),
248 TerrainResult::Opaque(true)
249 ) {
250 solid_count += 1;
251 if solid_count == 3 {
252 break 'scan;
253 }
254 }
255 }
256 projected_y -= 1;
257 }
258
259 Some(PortalResult {
260 biome_check_pos: (base_x, projected_y, base_z),
261 bounding_box: rotation.get_bounding_box_full(
262 IVec3::new(base_x, projected_y, base_z),
263 IVec3::new(sx, sy, sz),
264 IVec3::new(pivot_x, 0, pivot_z),
265 mirror_front_back,
266 ),
267 template_id,
268 template_size: IVec3::new(sx, sy, sz),
269 rotation,
270 mirror,
271 rotation_pivot: IVec3::new(pivot_x, 0, pivot_z),
272 vertical_placement: setup.placement,
273 properties: RuinedPortalProperties {
274 cold: false,
275 mossiness: setup.mossiness,
276 air_pocket,
277 overgrown: setup.overgrown,
278 vines: setup.vines,
279 replace_with_blackstone: setup.replace_with_blackstone,
280 },
281 can_be_cold: setup.can_be_cold,
282 })
283}
284
285#[expect(
286 clippy::too_many_arguments,
287 reason = "ruined portal piece construction mirrors vanilla template-piece fields"
288)]
289fn make_ruined_portal_piece(
290 template_id: Identifier,
291 position: IVec3,
292 rotation: Rotation,
293 mirror: StructureMirror,
294 rotation_pivot: IVec3,
295 size: IVec3,
296 vertical_placement: RuinedPortalPlacementData,
297 properties: RuinedPortalProperties,
298) -> StructurePiece {
299 let mirror_front_back = mirror == StructureMirror::FrontBack;
300 let bounding_box =
301 rotation.get_bounding_box_full(position, size, rotation_pivot, mirror_front_back);
302 StructurePiece {
303 piece_type: Identifier::new_static("minecraft", "rupo"),
304 bounding_box,
305 gen_depth: 0,
306 orientation: Some(Direction::North),
307 payload: StructurePiecePayload::Template(TemplatePieceData {
308 template_id,
309 template_position: position,
310 rotation,
311 mirror,
312 rotation_pivot,
313 block_ignore: if properties.air_pocket {
314 StructureBlockIgnore::StructureBlock
315 } else {
316 StructureBlockIgnore::StructureAndAir
317 },
318 late_block_ignore: StructureBlockIgnore::None,
319 processors: TemplateProcessorList::RuinedPortal {
320 vertical_placement,
321 properties,
322 },
323 liquid_settings: LiquidSettingsData::ApplyWaterlogging,
324 marker_handling: TemplateMarkerHandling::Ignore,
325 placement_adjustment: TemplatePlacementAdjustment::None,
326 placement_clip:
327 TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate,
328 post_process: TemplatePostProcess::RuinedPortal,
329 }),
330 ground_level_delta: 0,
331 junctions: Vec::new(),
332 projection: None,
333 }
334}
335
336fn cold_enough_to_snow(biome: BiomeRef, sea_level: i32, pos: (i32, i32, i32)) -> bool {
337 biome_temperature(biome, sea_level, pos) < 0.15
338}
339
340fn biome_temperature(biome: BiomeRef, sea_level: i32, pos: (i32, i32, i32)) -> f32 {
341 let modified_temperature = match biome.temperature_modifier {
342 TemperatureModifier::None => biome.temperature,
343 TemperatureModifier::Frozen => {
344 let large = f64::from(
345 FROZEN_TEMPERATURE_NOISE
346 .get_value(f64::from(pos.0) * 0.05, f64::from(pos.2) * 0.05)
347 * 7.0,
348 );
349 let edge = f64::from(
350 BIOME_INFO_NOISE.get_value(f64::from(pos.0) * 0.2, f64::from(pos.2) * 0.2),
351 );
352 if large + edge < 0.3 {
353 let small = f64::from(
354 BIOME_INFO_NOISE.get_value(f64::from(pos.0) * 0.09, f64::from(pos.2) * 0.09),
355 );
356 if small < 0.8 { 0.2 } else { biome.temperature }
357 } else {
358 biome.temperature
359 }
360 }
361 };
362
363 let snow_level = sea_level + 17;
364 if pos.1 <= snow_level {
365 return modified_temperature;
366 }
367
368 let value = TEMPERATURE_NOISE
369 .get_value(f64::from(pos.0 as f32 / 8.0), f64::from(pos.2 as f32 / 8.0))
370 * 8.0;
371 modified_temperature - (value + pos.1 as f32 - snow_level as f32) * 0.05 / 40.0
372}
373
374pub struct RuinedPortalStructure;
379
380impl Structure for RuinedPortalStructure {
381 fn find_generation_point(
382 &self,
383 ctx: &mut dyn StructureGenerationContext,
384 structure: &StructureData,
385 rng: &mut LegacyRandom,
386 ) -> Option<GenerationStub> {
387 let mut template_choices = Vec::with_capacity(
388 PORTAL_TEMPLATES
389 .len()
390 .saturating_add(GIANT_PORTAL_TEMPLATES.len()),
391 );
392 for path in PORTAL_TEMPLATES.iter().chain(GIANT_PORTAL_TEMPLATES.iter()) {
393 let id = Identifier::vanilla_static(path);
394 let size = ctx.templates().get(&id)?.size;
395 template_choices.push((id, size));
396 }
397
398 let mut terrain = |q: TerrainQuery| -> TerrainResult {
399 match q {
400 TerrainQuery::SurfaceHeight { x, z, ocean_floor } => {
401 TerrainResult::Height(ctx.terrain_surface_height(x, z, ocean_floor))
402 }
403 TerrainQuery::IsOpaque {
404 x,
405 y,
406 z,
407 ocean_floor,
408 } => TerrainResult::Opaque(ctx.terrain_is_opaque(x, y, z, ocean_floor)),
409 }
410 };
411
412 let StructureConfigData::RuinedPortal { setups } = &structure.config else {
413 return None;
414 };
415 if setups.is_empty() {
416 return None;
417 }
418
419 let result = find_generation_point(
420 rng,
421 ctx.chunk_x(),
422 ctx.chunk_z(),
423 setups,
424 ctx.min_y(),
425 &mut |id| {
426 template_choices
427 .iter()
428 .find(|(template_id, _)| template_id == id)
429 .map(|(_, size)| *size)
430 },
431 &mut terrain,
432 )?;
433
434 let (bx, by, bz) = result.biome_check_pos;
435 let biome = ctx.biome_at(bx, by, bz);
436 if !structure.allowed_biomes.contains(&biome.key) {
437 return None;
438 }
439
440 let mut properties = result.properties;
441 if result.can_be_cold {
442 properties.cold = cold_enough_to_snow(biome, ctx.sea_level(), result.biome_check_pos);
443 }
444
445 Some(GenerationStub {
446 position: result.biome_check_pos,
447 pieces: vec![make_ruined_portal_piece(
448 result.template_id,
449 IVec3::new(
450 result.biome_check_pos.0,
451 result.biome_check_pos.1,
452 result.biome_check_pos.2,
453 ),
454 result.rotation,
455 result.mirror,
456 result.rotation_pivot,
457 result.template_size,
458 result.vertical_placement,
459 properties,
460 )],
461 })
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn ruined_portal_piece_uses_template_payload_with_processors_and_postprocess() {
471 let position = IVec3::new(64, 72, -32);
472 let size = IVec3::new(11, 17, 16);
473 let properties = RuinedPortalProperties {
474 cold: true,
475 mossiness: 0.8,
476 air_pocket: false,
477 overgrown: true,
478 vines: true,
479 replace_with_blackstone: false,
480 };
481
482 let piece = make_ruined_portal_piece(
483 Identifier::vanilla_static("ruined_portal/giant_portal_1"),
484 position,
485 Rotation::Clockwise90,
486 StructureMirror::FrontBack,
487 IVec3::new(size.x / 2, 0, size.z / 2),
488 size,
489 RuinedPortalPlacementData::OnOceanFloor,
490 properties,
491 );
492
493 assert_eq!(
494 piece.piece_type,
495 Identifier::new_static("minecraft", "rupo")
496 );
497 assert_eq!(piece.gen_depth, 0);
498 assert_eq!(piece.orientation, Some(Direction::North));
499 assert_eq!(
500 piece.bounding_box,
501 Rotation::Clockwise90.get_bounding_box_full(
502 position,
503 size,
504 IVec3::new(size.x / 2, 0, size.z / 2),
505 true,
506 ),
507 );
508
509 let StructurePiecePayload::Template(data) = piece.payload else {
510 panic!("ruined portal piece should be template-backed");
511 };
512 assert_eq!(
513 data.template_id,
514 Identifier::vanilla_static("ruined_portal/giant_portal_1")
515 );
516 assert_eq!(data.template_position, position);
517 assert_eq!(data.rotation, Rotation::Clockwise90);
518 assert_eq!(data.mirror, StructureMirror::FrontBack);
519 assert_eq!(data.rotation_pivot, IVec3::new(size.x / 2, 0, size.z / 2));
520 assert_eq!(data.block_ignore, StructureBlockIgnore::StructureAndAir);
521 assert_eq!(data.late_block_ignore, StructureBlockIgnore::None);
522 assert_eq!(
523 data.processors,
524 TemplateProcessorList::RuinedPortal {
525 vertical_placement: RuinedPortalPlacementData::OnOceanFloor,
526 properties,
527 }
528 );
529 assert_eq!(data.liquid_settings, LiquidSettingsData::ApplyWaterlogging);
530 assert_eq!(data.marker_handling, TemplateMarkerHandling::Ignore);
531 assert_eq!(data.placement_adjustment, TemplatePlacementAdjustment::None);
532 assert_eq!(
533 data.placement_clip,
534 TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate
535 );
536 assert_eq!(data.post_process, TemplatePostProcess::RuinedPortal);
537 }
538}