diff --git a/docs/AGE_PROFILES.md b/docs/AGE_PROFILES.md index 77ef083..3a6cc35 100644 --- a/docs/AGE_PROFILES.md +++ b/docs/AGE_PROFILES.md @@ -73,6 +73,23 @@ common than in the first test build (one source chunk in 128 instead of 64). Sky Islands should retain its terrain islands and gain occasional additional feature islands. Try Cave terrain too; the Binder should reject that pairing. +The **Tendrils** feature adapts Legacy's winding, branching cave-node geometry +into solid tubes. It is additive on all current terrain families, including +Flat, Void, and Cave; it does not change biome selection. A preceding full- +collision Block page chooses the tendril material. Otherwise the Age seed +chooses a valid Block Pages material, consistent with other authored material +features. Page-free random Ages can select Tendrils as a solid-material feature, +including on Cave terrain where the surface material features are unavailable. +The shape and placement use only the Age seed and source-chunk coordinates, so +revisiting an Age recreates the same geometry. + +For a clear visual check, bind a Void Age with a Glowstone Block page followed +by Tendrils. Fly around world Y=0–120 across several chunks: branching solid +forms should appear in the otherwise empty dimension, continue across chunk +borders, and remain in place after leaving and returning. Repeat with a lone +Tendrils page to confirm its seed-chosen material, and with Cave terrain to +confirm the feature does not require a surface biome. + ## Written biome selection (generation version 16) Biome pages follow the terrain page and precede the distribution-control page diff --git a/docs/LEGACY_AGE_GRAMMAR_AUDIT.md b/docs/LEGACY_AGE_GRAMMAR_AUDIT.md index 3b3a906..43259bf 100644 --- a/docs/LEGACY_AGE_GRAMMAR_AUDIT.md +++ b/docs/LEGACY_AGE_GRAMMAR_AUDIT.md @@ -18,7 +18,7 @@ Legacy does **not** reject a book just because it omits terrain, biome, weather, | `WeatherNorm`, `Fast`, `Slow`, `Off`, `On`, `Cloudy`, `Rain`, `Snow`, `Storm` | `AgeTraits.Weather` has matching names except Legacy `On` maps to `ALWAYS` | Compile weather semantics separately from biome selection. `Snow` must remain deferred until optional season integration; do not silently substitute another weather mode. | | Suns, moons, starfields; angle, phase, period, color/gradient/sunset modifiers | One `celestialPeriod`, `celestialAngle`, `celestialPhase` on `AgeTraits` | Compile only the representable single-cycle subset first. Multiple celestial bodies, independent periods, custom stars, gradients and sunsets need a richer definition and renderer, not flattening into one value. | | Sky/fog/cloud/grass/foliage/water colors, natural variants, horizon, rainbow | Existing sky/dimension visuals and preview | Require an explicit modern client/biome-color design; metadata-only symbol pages must not pretend to change rendering. | -| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile and vanilla data-pack generation | Compile only features for which registered 1.21.1 placed features/structures exist. Defer bespoke structures and polish to milestone 7. | +| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, vanilla data-pack generation, and authored Floating Islands/Tendrils | Compile supported vanilla placements and port bespoke shapes explicitly. Remaining bespoke structures and polish stay in later issues. | | `EnvAccel`, explosions, lightning, meteors, scorching; PvP and dummy suppression pages | No equivalent Age-definition effect | Defer environmental hazards/instability effects to milestone 6, PvP rule to a separately scoped server policy; retain authored IDs without fake behavior. | The Legacy symbol list and page names come from [ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L129-L240) and [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L19-L137). Rivencraft's [catalog](../src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java) currently holds display metadata only; its built-in set is just standard terrain, native biomes, normal weather, normal lighting, normal time, and caves. [OrderedWriting](../src/main/java/net/dnorberg/rivencraft/writing/OrderedWriting.java) preserves page order, but no compiler yet connects it to [AgeTraits](../src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java) or [Age profiles](AGE_PROFILES.md). diff --git a/gradle.properties b/gradle.properties index 3e586bf..94ac9ac 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.4.11.7 +mod_version=0.4.12.0 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=B6nTjh4b diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java index e65052c..419423e 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java @@ -24,7 +24,8 @@ import net.minecraft.world.level.block.Blocks; /** Validates and freezes supported feature pages and their preceding material modifiers. */ public final class AgeFeatureSelection { - private static final List BLOCK_FEATURES = List.of(id("rocks"), id("floating_islands")); + private static final List BLOCK_FEATURES = List.of( + id("rocks"), id("floating_islands"), id("tendrils")); private static final List FLUID_FEATURES = List.of( id("surface_lakes"), id("deep_lakes"), id("lava_lakes")); @@ -112,6 +113,7 @@ public final class AgeFeatureSelection { } private static boolean compatible(ResourceLocation feature, AgeRuntimeProfile profile) { + if (tendrils(feature)) return true; if (AgeBiomeSelection.isCave(profile)) return false; return floatingIslands(feature) || profile.terrain() == AgeRuntimeProfile.Terrain.NOISE; } @@ -124,7 +126,8 @@ public final class AgeFeatureSelection { for (Choice choice : choices(plan.symbols().stream().map(symbol -> symbol.semantics()).toList(), plan.runtimeProfile(), plan.ageSeed())) { if (choice.material().isPresent() - || !isRock(choice.id()) && !isLake(choice.id()) && !floatingIslands(choice.id())) { + || !isRock(choice.id()) && !isLake(choice.id()) + && !floatingIslands(choice.id()) && !tendrils(choice.id())) { resolved.add(choice); continue; } @@ -147,7 +150,7 @@ public final class AgeFeatureSelection { List choices = new ArrayList<>(); for (ResourceLocation feature : features) { if (choices.size() >= count) break; - var category = isRock(feature) || floatingIslands(feature) + var category = isRock(feature) || floatingIslands(feature) || tendrils(feature) ? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY; List materials = BlockSymbolPages.availableBlocks(category).stream() .filter(id -> materialError(new Choice(feature, Optional.of(id))) == null).toList(); @@ -158,7 +161,7 @@ public final class AgeFeatureSelection { } private static List shuffledMaterials(long ageSeed, ResourceLocation feature) { - var category = isRock(feature) || floatingIslands(feature) + var category = isRock(feature) || floatingIslands(feature) || tendrils(feature) ? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY; List pool = new ArrayList<>(BlockSymbolPages.availableBlocks(category).stream() .filter(id -> materialError(new Choice(feature, Optional.of(id))) == null).toList()); @@ -178,6 +181,7 @@ public final class AgeFeatureSelection { } public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) { + if (tendrils(choice)) return null; if (floatingIslands(choice)) { if (AgeBiomeSelection.isCave(profile)) return "Feature " + choice + " cannot form above Cave terrain"; @@ -216,7 +220,7 @@ public final class AgeFeatureSelection { if (block.isEmpty() || block.orElseThrow() == Blocks.AIR) { return "Feature " + choice.id() + " references missing block material " + material; } - if (isRock(choice.id()) || floatingIslands(choice.id())) { + if (isRock(choice.id()) || floatingIslands(choice.id()) || tendrils(choice.id())) { if (!BlockSymbolPages.isRockMaterial(block.orElseThrow())) { return "Feature " + choice.id() + " needs a full solid block without a block entity: " + material; } @@ -256,6 +260,10 @@ public final class AgeFeatureSelection { return choice.equals(ResourceLocation.fromNamespaceAndPath("rivencraft", "floating_islands")); } + public static boolean tendrils(ResourceLocation choice) { + return choice.equals(id("tendrils")); + } + public static boolean supportsSurfaceVegetation(ResourceLocation id, RegistryAccess registries) { var biome = registries.lookupOrThrow(Registries.BIOME) .get(ResourceKey.create(Registries.BIOME, id)); diff --git a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java index cc4a9b7..0a6f2d9 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java @@ -32,7 +32,7 @@ public final class FeatureWritingGameTests { Set pages = Set.of("feature/caves", "feature/flowers", "feature/trees", "feature/huge_trees", "feature/rocks", "feature/lava_lakes", "feature/surface_lakes", "feature/deep_lakes", "feature/ravines", - "feature/floating_islands"); + "feature/floating_islands", "feature/tendrils"); helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(pages), "Supported feature pages must appear in the creative and JEI symbol feed"); for (String path : pages) { @@ -59,7 +59,8 @@ public final class FeatureWritingGameTests { AgeFeatureSelection.resolved(plan), "Features must survive the saved-plan round trip"); for (AgeFeatureSelection.Choice choice : AgeFeatureSelection.resolved(plan)) { if (AgeFeatureSelection.caves(choice.id()) || AgeFeatureSelection.ravines(choice.id()) - || AgeFeatureSelection.floatingIslands(choice.id())) continue; + || AgeFeatureSelection.floatingIslands(choice.id()) + || AgeFeatureSelection.tendrils(choice.id())) continue; var placement = AgeFeatureSelection.placedFeature(choice.id()); helper.assertTrue(registries.lookupOrThrow(Registries.PLACED_FEATURE) .get(ResourceKey.create(Registries.PLACED_FEATURE, placement)).isPresent(), @@ -136,6 +137,47 @@ public final class FeatureWritingGameTests { helper.succeed(); } + @GameTest(template = "empty") + public static void tendrilsAreAuthoredAndCanAppearInRandomAges(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + AgeRuntimeProfiles.profiles().forEach((profileId, profile) -> + helper.assertTrue(AgeFeatureSelection.error(id("tendrils"), profile, registries) == null, + "Tendrils must support loaded terrain profile " + profileId)); + for (String terrain : List.of("standard", "amplified", "island", "flat", "void", "cave")) { + OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/" + terrain), + page("feature/tendrils"))); + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 450L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(450L), registries); + List choices = AgeFeatureSelection.resolved(plan); + helper.assertValueEqual(choices.size(), 1, "Tendrils must compile on " + terrain + " terrain"); + helper.assertValueEqual(choices.getFirst().id(), id("tendrils"), + "Tendrils must not replace the terrain page"); + helper.assertTrue(choices.getFirst().material().isPresent() + && AgeFeatureSelection.materialError(choices.getFirst()) == null, + "Unmodified Tendrils must choose a full-block material"); + helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), choices, + "The Tendrils material must survive a plan reload"); + } + OrderedWriting materialWriting = new OrderedWriting(List.of(panel(), page("terrain/void"), + block("glowstone"), page("feature/tendrils"))); + AgeGenerationPlan materialPlan = AgeGenerationPlan.compile(materialWriting, 450L, + AgeGenerationProfiles.STANDARD, AgeTraits.select(450L), registries); + helper.assertValueEqual(AgeFeatureSelection.resolved(materialPlan), List.of( + new AgeFeatureSelection.Choice(id("tendrils"), Optional.of( + ResourceLocation.withDefaultNamespace("glowstone")))), + "A preceding Block page must set the Tendrils material"); + var caveProfile = AgeRuntimeProfiles.resolve(id("cave")); + boolean found = false; + for (long seed = 0; seed < 12; seed++) { + List random = AgeFeatureSelection.randomChoices(seed, caveProfile, registries); + helper.assertTrue(random.stream().allMatch(choice -> AgeFeatureSelection.tendrils(choice.id())), + "Cave random Ages must not choose incompatible surface features"); + if (!random.isEmpty()) found = true; + } + helper.assertTrue(found, "A page-free random Cave Age must be able to roll Tendrils"); + helper.succeed(); + } + @GameTest(template = "empty") public static void voidBiomesAreIndependentOfFloatingIslands(GameTestHelper helper) { OrderedWriting voidDefault = new OrderedWriting(List.of(panel(), page("terrain/void"))); @@ -275,7 +317,8 @@ public final class FeatureWritingGameTests { helper.assertTrue(material.equals("water") ? AgeFeatureSelection.isLake(choices.getFirst().id()) : AgeFeatureSelection.isRock(choices.getFirst().id()) - || AgeFeatureSelection.floatingIslands(choices.getFirst().id()), + || AgeFeatureSelection.floatingIslands(choices.getFirst().id()) + || AgeFeatureSelection.tendrils(choices.getFirst().id()), "The inferred feature must accept the material type"); helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), choices, "The inferred feature must be deterministic across a plan reload"); diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java index faff787..a996d6e 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java @@ -75,13 +75,18 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { public CompletableFuture fillFromNoise(Blender blender, RandomState random, StructureManager structures, ChunkAccess chunk) { CompletableFuture filled = super.fillFromNoise(blender, random, structures, chunk); - if (features.stream().noneMatch(choice -> AgeFeatureSelection.floatingIslands(choice.id()))) return filled; + if (features.stream().noneMatch(choice -> AgeFeatureSelection.floatingIslands(choice.id()) + || AgeFeatureSelection.tendrils(choice.id()))) return filled; return filled.thenApply(result -> { - int occurrence = 0; + Map occurrences = new HashMap<>(); for (AgeFeatureSelection.Choice choice : features) { - if (!AgeFeatureSelection.floatingIslands(choice.id())) continue; - FloatingIslandFeature.generate(result, getBiomeSource(), random, - ageSeed ^ (long) occurrence++ * 0x9E3779B97F4A7C15L, choice); + if (!AgeFeatureSelection.floatingIslands(choice.id()) + && !AgeFeatureSelection.tendrils(choice.id())) continue; + int occurrence = occurrences.merge(choice.id(), 1, Integer::sum) - 1; + long featureSeed = ageSeed ^ (long) occurrence * 0x9E3779B97F4A7C15L; + if (AgeFeatureSelection.floatingIslands(choice.id())) + FloatingIslandFeature.generate(result, getBiomeSource(), random, featureSeed, choice); + else TendrilFeature.generate(result, featureSeed, choice); } return result; }); diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java index fc4b820..48f3881 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java @@ -3,7 +3,9 @@ package net.dnorberg.rivencraft.world; import com.mojang.serialization.Codec; import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.stream.Stream; import net.dnorberg.rivencraft.book.AgeFeatureSelection; @@ -12,6 +14,7 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.core.HolderLookup; import net.minecraft.core.HolderSet; +import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.WorldGenRegion; import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.NoiseColumn; @@ -97,11 +100,15 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator { } } } - int islandOccurrence = 0; + Map occurrences = new HashMap<>(); for (AgeFeatureSelection.Choice choice : features) { - if (!AgeFeatureSelection.floatingIslands(choice.id())) continue; - FloatingIslandFeature.generate(chunk, biomeSource, random, - ageSeed ^ (long) islandOccurrence++ * 0x9E3779B97F4A7C15L, choice); + if (!AgeFeatureSelection.floatingIslands(choice.id()) + && !AgeFeatureSelection.tendrils(choice.id())) continue; + int occurrence = occurrences.merge(choice.id(), 1, Integer::sum) - 1; + long featureSeed = ageSeed ^ (long) occurrence * 0x9E3779B97F4A7C15L; + if (AgeFeatureSelection.floatingIslands(choice.id())) + FloatingIslandFeature.generate(chunk, biomeSource, random, featureSeed, choice); + else TendrilFeature.generate(chunk, featureSeed, choice); } return CompletableFuture.completedFuture(chunk); } diff --git a/src/main/java/net/dnorberg/rivencraft/world/TendrilFeature.java b/src/main/java/net/dnorberg/rivencraft/world/TendrilFeature.java new file mode 100644 index 0000000..50358ff --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/TendrilFeature.java @@ -0,0 +1,150 @@ +package net.dnorberg.rivencraft.world; + +import java.util.Random; +import net.dnorberg.rivencraft.book.AgeFeatureSelection; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.util.Mth; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; + +/** + * Winding solid volumes adapted from Mystcraft Legacy's SymbolTendrils and + * MapGenCavesMyst (LGPL-3.0-or-later). Generation stays inside the target chunk, + * while source-chunk seeds make the same tendril meet seamlessly at its borders. + */ +final class TendrilFeature { + private static final long SALT = 0x54454E4452494C53L; + private static final int SOURCE_RANGE = 8; + private static final int SOURCE_RATE = 15; + private static final int SOURCE_SIZE = 18; + + private TendrilFeature() {} + + static void generate(ChunkAccess chunk, long ageSeed, AgeFeatureSelection.Choice choice) { + BlockState material = choice.material() + .map(id -> BuiltInRegistries.BLOCK.get(id).defaultBlockState()) + .orElse(Blocks.OAK_LOG.defaultBlockState()); + ChunkPos target = chunk.getPos(); + Random master = new Random(ageSeed ^ SALT); + long xSeed = master.nextLong(); + long zSeed = master.nextLong(); + for (int sourceX = target.x - SOURCE_RANGE; sourceX <= target.x + SOURCE_RANGE; sourceX++) { + for (int sourceZ = target.z - SOURCE_RANGE; sourceZ <= target.z + SOURCE_RANGE; sourceZ++) { + Random source = new Random((long) sourceX * xSeed ^ (long) sourceZ * zSeed ^ ageSeed ^ SALT); + int nodes = source.nextInt(source.nextInt(source.nextInt(SOURCE_SIZE) + 1) + 1); + if (source.nextInt(SOURCE_RATE) != 0) continue; + for (int node = 0; node < nodes; node++) { + double x = sourceX * 16 + source.nextInt(16); + double y = source.nextInt(source.nextInt(120) + 8); + double z = sourceZ * 16 + source.nextInt(16); + int branches = 1; + if (source.nextInt(4) == 0) { + trace(chunk, material, source.nextLong(), x, y, z, + 1.0F + source.nextFloat() * 6.0F, 0.0F, 0.0F, -1, -1, 0.5D); + branches += source.nextInt(4); + } + for (int branch = 0; branch < branches; branch++) { + float yaw = source.nextFloat() * Mth.TWO_PI; + float pitch = (source.nextFloat() - 0.5F) / 4.0F; + float radius = source.nextFloat() * 2.0F + source.nextFloat(); + if (source.nextInt(10) == 0) + radius *= source.nextFloat() * source.nextFloat() * 3.0F + 1.0F; + trace(chunk, material, source.nextLong(), x, y, z, + radius, yaw, pitch, 0, 0, 1.0D); + } + } + } + } + } + + private static void trace(ChunkAccess chunk, BlockState material, long seed, + double x, double y, double z, float radius, float yaw, float pitch, + int step, int length, double verticalScale) { + Random random = new Random(seed); + if (length <= 0) { + int maximum = SOURCE_RANGE * 16 - 16; + length = maximum - random.nextInt(maximum / 4); + } + boolean singleNode = step == -1; + if (singleNode) step = length / 2; + int splitAt = random.nextInt(length / 2) + length / 4; + boolean slowPitch = random.nextInt(6) == 0; + float yawDrift = 0.0F; + float pitchDrift = 0.0F; + double middleX = chunk.getPos().getMinBlockX() + 8; + double middleZ = chunk.getPos().getMinBlockZ() + 8; + + for (; step < length; step++) { + double horizontalRadius = 1.5D + Math.sin(step * Math.PI / length) * radius; + double verticalRadius = horizontalRadius * verticalScale; + float horizontalMotion = Mth.cos(pitch); + x += Mth.cos(yaw) * horizontalMotion; + y += Mth.sin(pitch); + z += Mth.sin(yaw) * horizontalMotion; + pitch *= slowPitch ? 0.92F : 0.7F; + pitch += pitchDrift * 0.1F; + yaw += yawDrift * 0.1F; + pitchDrift = pitchDrift * 0.9F + + (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 2.0F; + yawDrift = yawDrift * 0.75F + + (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 4.0F; + + if (!singleNode && step == splitAt && radius > 1.0F) { + trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F, + yaw - Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D); + trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F, + yaw + Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D); + return; + } + if (!singleNode && random.nextInt(4) == 0) continue; + double distanceX = x - middleX; + double distanceZ = z - middleZ; + double remaining = length - step; + double reach = radius + 18.0D; + if (distanceX * distanceX + distanceZ * distanceZ - remaining * remaining > reach * reach) + return; + if (x >= middleX - 16.0D - horizontalRadius * 2.0D + && x <= middleX + 16.0D + horizontalRadius * 2.0D + && z >= middleZ - 16.0D - horizontalRadius * 2.0D + && z <= middleZ + 16.0D + horizontalRadius * 2.0D) { + fillNode(chunk, material, x, y, z, horizontalRadius, verticalRadius); + } + if (singleNode) break; + } + } + + private static void fillNode(ChunkAccess chunk, BlockState material, double centerX, double centerY, + double centerZ, double horizontalRadius, double verticalRadius) { + int originX = chunk.getPos().getMinBlockX(); + int originZ = chunk.getPos().getMinBlockZ(); + int minX = Math.max(0, Mth.floor(centerX - horizontalRadius) - originX - 1); + int maxX = Math.min(16, Mth.floor(centerX + horizontalRadius) - originX + 2); + int minZ = Math.max(0, Mth.floor(centerZ - horizontalRadius) - originZ - 1); + int maxZ = Math.min(16, Mth.floor(centerZ + horizontalRadius) - originZ + 2); + int minY = Math.max(chunk.getMinBuildHeight() + 1, Mth.floor(centerY - verticalRadius) - 1); + int maxY = Math.min(chunk.getMaxBuildHeight(), Mth.floor(centerY + verticalRadius) + 2); + Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG); + Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int worldY = minY; worldY < maxY; worldY++) { + double dy = (worldY + 0.5D - centerY) / verticalRadius; + if (dy <= -0.7D || dy * dy >= 1.0D) continue; + for (int localZ = minZ; localZ < maxZ; localZ++) { + double dz = (originZ + localZ + 0.5D - centerZ) / horizontalRadius; + for (int localX = minX; localX < maxX; localX++) { + double dx = (originX + localX + 0.5D - centerX) / horizontalRadius; + if (dx * dx + dy * dy + dz * dz >= 1.0D) continue; + pos.set(originX + localX, worldY, originZ + localZ); + if (chunk.getBlockState(pos).is(Blocks.BEDROCK)) continue; + chunk.setBlockState(pos, material, false); + surface.update(localX, worldY, localZ, material); + floor.update(localX, worldY, localZ, material); + } + } + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java index 7c863f3..9d625ee 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java @@ -77,7 +77,7 @@ public final class WritingGrammar { pendingBlock = parsed; } else if (role == Role.FEATURE && pendingBlock != null) { ResourceLocation feature = ((SymbolSemantics.ResourceValue) parsed.semantics().value()).id(); - if (!Set.of("rocks", "surface_lakes", "deep_lakes", "lava_lakes", "floating_islands") + if (!Set.of("rocks", "surface_lakes", "deep_lakes", "lava_lakes", "floating_islands", "tendrils") .contains(feature.getPath()) || !feature.getNamespace().equals("rivencraft")) { diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingBlock.index(), "This feature cannot consume a material page")); diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index bff8053..345e1ad 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -245,6 +245,8 @@ "symbol.rivencraft.feature.ravines.description": "Add vanilla canyon carving to noise terrain.", "symbol.rivencraft.feature.floating_islands": "Floating Islands", "symbol.rivencraft.feature.floating_islands.description": "Add high floating islands above the chosen surface terrain.", + "symbol.rivencraft.feature.tendrils": "Tendrils", + "symbol.rivencraft.feature.tendrils.description": "Add winding solid tendrils through the Age, using a random block or a preceding Block page.", "symbol.rivencraft.feature.lava_lakes": "Small Lakes", "symbol.rivencraft.feature.lava_lakes.description": "Add small surface lakes using a random fluid, or specify one with a fluid page.", "generator.rivencraft.standard": "Standard", diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/feature/tendrils.json b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/tendrils.json new file mode 100644 index 0000000..592c589 --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/tendrils.json @@ -0,0 +1,8 @@ +{ + "schema_version": 2, + "name": "symbol.rivencraft.feature.tendrils", + "description": "symbol.rivencraft.feature.tendrils.description", + "category": "rivencraft:feature", + "semantics": { "role": "feature", "value": "rivencraft:tendrils" }, + "poem": "Terrain Thread Structure Winding" +}