diff --git a/docs/PORTING.md b/docs/PORTING.md index 9d7fcb4..82f2d60 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -340,6 +340,11 @@ Age-seeded biomes in Legacy's diagonal checkerboard layout. Each cell is one 16-by-16-block chunk, and the built-in serializable biome source reconstructs the same layout whenever the Age reloads. +Version 0.2.24.0 applies Tiny Biome Distribution using three deterministic +Age-seeded biomes and softly bounded seeded regions. Tiny begins at Legacy's +smallest 16-block nominal scale; the serializable biome source retains its +seed, selected biomes, and scale across Age reloads. + Version 0.2.14.0 restores the legacy Linking and Descriptive Book artwork for inventory items and gives each book its matching open-book texture when placed on a Book Stand. Return Linking Books created inside another Age also retain and diff --git a/gradle.properties b/gradle.properties index 71620a2..e3315a5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,7 +35,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.2.23.0 +mod_version=0.2.24.0 immersive_portals_version=6.0.7 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 8854ddb..fc6e911 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -7,6 +7,7 @@ import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.registry.ModSounds; import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModBlockEntities; +import net.dnorberg.rivencraft.registry.ModBiomeSources; import net.dnorberg.rivencraft.event.BookStandConversionHandler; import net.dnorberg.rivencraft.network.ModNetworking; import net.dnorberg.rivencraft.config.RivencraftConfig; @@ -31,6 +32,7 @@ public final class Rivencraft { modContainer.registerConfig(ModConfig.Type.CLIENT, RivencraftClientConfig.SPEC); modEventBus.addListener(ModNetworking::register); ModDataComponents.register(modEventBus); + ModBiomeSources.register(modEventBus); ModBlocks.register(modEventBus); ModBlockEntities.register(modEventBus); ModItems.register(modEventBus); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java index d14f31a..3771b38 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java @@ -21,7 +21,7 @@ public record AgeDefinition( public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath( Rivencraft.MOD_ID, "random_vanilla" ); - public static final int CURRENT_GENERATION_VERSION = 11; + public static final int CURRENT_GENERATION_VERSION = 12; public AgeDefinition { Objects.requireNonNull(id, "id"); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java index e965e41..6dcb794 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -29,6 +29,7 @@ import net.minecraft.world.level.biome.CheckerboardColumnBiomeSource; import net.minecraft.world.level.biome.FixedBiomeSource; import net.minecraft.world.level.biome.MultiNoiseBiomeSource; import net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterLists; +import net.dnorberg.rivencraft.world.ScaledBiomeSource; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.dimension.BuiltinDimensionTypes; import net.minecraft.world.level.dimension.DimensionType; @@ -307,6 +308,15 @@ public final class AgeDimensionService { : TILED_BIOME_SCALE ); } + if (distribution == AgeTraits.BiomeDistribution.TINY && biomes.size() >= 3) { + List> selected = new java.util.ArrayList<>(biomes); + Collections.shuffle(selected, new java.util.Random(age.seed() ^ 0x54494E5942494F4DL)); + return new ScaledBiomeSource( + HolderSet.direct(List.of(selected.get(0), selected.get(1), selected.get(2))), + age.seed(), + 0 + ); + } return MultiNoiseBiomeSource.createFromPreset( biomeParameters.getOrThrow(MultiNoiseBiomeSourceParameterLists.OVERWORLD)); } diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 0c3138d..a7fddfd 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -183,6 +183,15 @@ public final class LinkingGameTests { selectedTraits.celestialAngle(), selectedTraits.celestialPhase() ) + : profile.equals(AgeGenerationProfiles.CAVE) + ? new AgeTraits( + AgeTraits.BiomeDistribution.TINY, + selectedTraits.lighting(), + selectedTraits.weather(), + selectedTraits.celestialPeriod(), + selectedTraits.celestialAngle(), + selectedTraits.celestialPhase() + ) : selectedTraits; AgeDefinition definition = new AgeDefinition( id, @@ -245,6 +254,30 @@ public final class LinkingGameTests { helper.assertTrue(origin.equals(diagonalChunk), "Diagonal Grid chunks must repeat the biome"); } + if (profile.equals(AgeGenerationProfiles.CAVE)) { + var biomeSource = level.getChunkSource().getGenerator().getBiomeSource(); + helper.assertTrue(biomeSource instanceof net.dnorberg.rivencraft.world.ScaledBiomeSource, + "Tiny Biome Distribution must use the serializable scaled biome source"); + helper.assertValueEqual( + biomeSource.possibleBiomes().size(), + 3, + "Tiny Biome Distribution must expose exactly three seeded biomes" + ); + java.util.Set> sampled = + new java.util.HashSet<>(); + for (int x = -32; x <= 32; x += 4) { + for (int z = -32; z <= 32; z += 4) { + sampled.add(biomeSource.getNoiseBiome(x, 0, z, null)); + } + } + helper.assertTrue(sampled.size() >= 2, + "Tiny Biome Distribution must form multiple seeded regions near spawn"); + helper.assertValueEqual( + biomeSource.getNoiseBiome(7, 0, -5, null), + biomeSource.getNoiseBiome(7, 0, -5, null), + "Tiny biome sampling must be deterministic" + ); + } if (definition.traits().lighting() == AgeTraits.Lighting.DARK) { helper.assertFalse(level.dimensionType().hasSkyLight(), "Dark Lighting Ages must suppress dimension skylight"); diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBiomeSources.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBiomeSources.java new file mode 100644 index 0000000..36cc94f --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBiomeSources.java @@ -0,0 +1,26 @@ +package net.dnorberg.rivencraft.registry; + +import com.mojang.serialization.MapCodec; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.world.ScaledBiomeSource; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.biome.BiomeSource; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.registries.DeferredRegister; + +/** Serializable biome-source types used by generated Ages. */ +public final class ModBiomeSources { + private static final DeferredRegister> BIOME_SOURCES = + DeferredRegister.create(Registries.BIOME_SOURCE, Rivencraft.MOD_ID); + + static { + BIOME_SOURCES.register("scaled", () -> ScaledBiomeSource.CODEC); + } + + private ModBiomeSources() { + } + + public static void register(IEventBus modEventBus) { + BIOME_SOURCES.register(modEventBus); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/world/ScaledBiomeSource.java b/src/main/java/net/dnorberg/rivencraft/world/ScaledBiomeSource.java new file mode 100644 index 0000000..1b73b8d --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/ScaledBiomeSource.java @@ -0,0 +1,89 @@ +package net.dnorberg.rivencraft.world; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.stream.Stream; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeSource; +import net.minecraft.world.level.biome.Climate; + +/** Seeded, softly bounded biome regions at the five Mystcraft distribution scales. */ +public final class ScaledBiomeSource extends BiomeSource { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + Biome.LIST_CODEC.fieldOf("biomes").forGetter(source -> source.biomes), + Codec.LONG.fieldOf("seed").forGetter(source -> source.seed), + Codec.intRange(0, 4).fieldOf("scale").forGetter(source -> source.scale) + ).apply(instance, ScaledBiomeSource::new)); + + private final HolderSet biomes; + private final long seed; + private final int scale; + private final int cellWidth; + + public ScaledBiomeSource(HolderSet biomes, long seed, int scale) { + if (biomes.size() < 3) { + throw new IllegalArgumentException("Scaled biome distributions require at least three biomes"); + } + this.biomes = biomes; + this.seed = seed; + this.scale = scale; + // BiomeSource coordinates are quart positions. Legacy applies two zooms + // before the selected scale, yielding 16-block Tiny regions that double + // with every subsequent distribution size. + this.cellWidth = 4 << scale; + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + protected Stream> collectPossibleBiomes() { + return biomes.stream(); + } + + @Override + public Holder getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { + int baseX = Math.floorDiv(x, cellWidth); + int baseZ = Math.floorDiv(z, cellWidth); + int closestX = baseX; + int closestZ = baseZ; + double closestDistance = Double.POSITIVE_INFINITY; + + for (int cellX = baseX - 1; cellX <= baseX + 1; cellX++) { + for (int cellZ = baseZ - 1; cellZ <= baseZ + 1; cellZ++) { + long cellSeed = mix(seed, cellX, cellZ); + double centerX = (cellX + 0.5D + jitter(cellSeed) * 0.8D) * cellWidth; + double centerZ = (cellZ + 0.5D + jitter(mix64(cellSeed)) * 0.8D) * cellWidth; + double deltaX = x - centerX; + double deltaZ = z - centerZ; + double distance = deltaX * deltaX + deltaZ * deltaZ; + if (distance < closestDistance) { + closestDistance = distance; + closestX = cellX; + closestZ = cellZ; + } + } + } + + return biomes.get(Math.floorMod((int)mix(seed ^ 0x42494F4D45534CL, closestX, closestZ), biomes.size())); + } + + private static double jitter(long value) { + return ((value >>> 11) * 0x1.0p-53) - 0.5D; + } + + private static long mix(long seed, int x, int z) { + return mix64(seed ^ (long)x * 0x9E3779B97F4A7C15L ^ (long)z * 0xC2B2AE3D27D4EB4FL); + } + + private static long mix64(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } +}