diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index fc843ca..3050bf3 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -9,6 +9,7 @@ import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.entity.living.FinalizeSpawnEvent; import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; import net.neoforged.neoforge.event.entity.EntityLeaveLevelEvent; +import net.neoforged.neoforge.event.entity.EntityTravelToDimensionEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; import net.neoforged.neoforge.event.level.BlockEvent; import net.neoforged.neoforge.event.level.ChunkEvent; @@ -38,6 +39,7 @@ import net.minecraft.world.entity.monster.Monster; import net.minecraft.world.entity.MobSpawnType; import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.Biomes; import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity; @@ -163,7 +165,8 @@ public class LivingWorldMod { bootstrap.getWorldEffectsModule().registerConsumer( new NeoForgeWorldEffectExecutor( () -> minecraftServer, - coord -> !bootstrap.isPollinatorCollapse(coord))); + coord -> !bootstrap.isPollinatorCollapse(coord), + bootstrap::notifyAncientRuinsExposed)); bootstrap.setOverworldRaining( () -> minecraftServer != null && minecraftServer.overworld().isRaining()); bootstrap.setAbsoluteDaySupplier( @@ -369,6 +372,16 @@ public class LivingWorldMod { RegionCoordinate coord = trackedPassiveMobs.remove(event.getEntity().getUUID()); if (coord != null) bootstrap.recordPassiveMobDeparture(coord); }); + NeoForge.EVENT_BUS.addListener(EntityTravelToDimensionEvent.class, event -> { + if (!bootstrap.isServerReady() || !(event.getEntity().level() instanceof ServerLevel level)) return; + boolean toNether = event.getDimension() == Level.NETHER; + boolean toEnd = event.getDimension() == Level.END; + if (!toNether && !toEnd) return; + BlockPos pos = event.getEntity().blockPosition(); + bootstrap.registerPortalTravel( + level.dimension().location().toString(), + pos.getX(), pos.getZ(), toNether); + }); // Step 4: Agriculture — bone meal boosts soil fertility. NeoForge.EVENT_BUS.addListener(PlayerInteractEvent.RightClickBlock.class, event -> { @@ -754,6 +767,11 @@ public class LivingWorldMod { return true; } } + if (bootstrap.isNetherBleedActive(coord) && random.nextInt(6) == 0) { + playAmbientSound(player, Holder.direct(SoundEvents.FIRE_AMBIENT), + 0.18f, 0.75f + random.nextFloat() * 0.25f); + return true; + } float pitch = 0.75f + random.nextFloat() * 0.5f; if (stage != null && stage.ordinal() <= SuccessionStage.SPARSE_GRASS.ordinal() diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index 5173eb2..01e7afb 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -165,6 +165,15 @@ public final class LivingWorldBootstrap { private final Set exhaustedFarmlandRegions = new HashSet<>(); private final Map farmingInactiveCycles = new HashMap<>(); private int undergroundCycleTick = 0; + private enum PortalInfluence { NETHER, END } + private record PortalSite(String dimensionId, int blockX, int blockZ, PortalInfluence influence) {} + private final Set portalSites = new HashSet<>(); + private final Map netherExposureCycles = new HashMap<>(); + private final Map endExposureCycles = new HashMap<>(); + private final Set netherBleedRegions = new HashSet<>(); + private final Set endCorruptionRegions = new HashSet<>(); + private final Map erosionExposureCycles = new HashMap<>(); + private final Set exposedRuinsRegions = new HashSet<>(); private PlatformAdapter platformAdapter; private Path worldSaveDirectory; @@ -362,6 +371,13 @@ public final class LivingWorldBootstrap { exhaustedFarmlandRegions.clear(); farmingInactiveCycles.clear(); undergroundCycleTick = 0; + portalSites.clear(); + netherExposureCycles.clear(); + endExposureCycles.clear(); + netherBleedRegions.clear(); + endCorruptionRegions.clear(); + erosionExposureCycles.clear(); + exposedRuinsRegions.clear(); simSpeedMultiplier = 1; serverReady = false; LivingWorldLogger.info( @@ -536,6 +552,89 @@ public final class LivingWorldBootstrap { applyLongCycleEffects(); applyPlayerFeedbackLoops(); applyUndergroundSystems(); + applyFantasticalExtensions(); + } + + public void registerPortalTravel( + String dimensionId, int blockX, int blockZ, boolean netherPortal) { + if (dimensionId == null) return; + portalSites.add(new PortalSite( + dimensionId, blockX, blockZ, + netherPortal ? PortalInfluence.NETHER : PortalInfluence.END)); + } + + public boolean isNetherBleedActive(RegionCoordinate coord) { + return coord != null && netherBleedRegions.contains(coord); + } + + public void notifyAncientRuinsExposed(RegionCoordinate coord) { + if (coord != null && exposedRuinsRegions.add(coord)) { + pendingEventMessages.add("[LW] Ancient ruins uncovered at (" + + coord.x() + "," + coord.z() + ")!"); + } + } + + private void applyFantasticalExtensions() { + if (worldEffectsModule == null) return; + for (Region region : regionManager.getActiveRegions()) { + RegionCoordinate coord = region.getCoordinate(); + boolean nearNether = false; + boolean nearEnd = false; + int minX = coord.x() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16; + int minZ = coord.z() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16; + int maxX = minX + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16 - 1; + int maxZ = minZ + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16 - 1; + for (PortalSite site : portalSites) { + if (!site.dimensionId().equals(coord.dimensionId())) continue; + int dx = site.blockX() < minX ? minX - site.blockX() + : site.blockX() > maxX ? site.blockX() - maxX : 0; + int dz = site.blockZ() < minZ ? minZ - site.blockZ() + : site.blockZ() > maxZ ? site.blockZ() - maxZ : 0; + if (Math.max(dx, dz) > 48) continue; + nearNether |= site.influence() == PortalInfluence.NETHER; + nearEnd |= site.influence() == PortalInfluence.END; + } + + if (nearNether) { + int cycles = netherExposureCycles.merge(coord, 1, Integer::sum); + if (cycles >= 50) { + netherBleedRegions.add(coord); + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.NETHER_BLEED, coord, + Math.min(1.0, cycles / 150.0))); + } + } + if (nearEnd) { + int cycles = endExposureCycles.merge(coord, 1, Integer::sum); + if (cycles >= 50) { + endCorruptionRegions.add(coord); + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.END_CORRUPTION, coord, + Math.min(1.0, cycles / 150.0))); + } + } + + double flow = riverFlowIntensity.getOrDefault(coord, 0.0); + boolean majorQuake = false; + for (ClimateEvent event : activeClimateEvents) { + if (event.getType() == ClimateEventType.EARTHQUAKE + && event.getSeverity() >= 0.8 + && event.getAffectedRegions().contains(coord)) { + majorQuake = true; + break; + } + } + int erosionCycles = flow > RIVER_CARVE_THRESHOLD + ? erosionExposureCycles.merge(coord, 1, Integer::sum) : 0; + if (flow <= RIVER_CARVE_THRESHOLD) erosionExposureCycles.remove(coord); + if ((erosionCycles >= 20 || majorQuake) + && !exposedRuinsRegions.contains(coord) + && windRandom.nextDouble() < 0.05) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.RUINS_EXPOSED, coord, + majorQuake ? 1.0 : Math.min(1.0, flow / 160.0))); + } + } } private void applyUndergroundSystems() { diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java index dfdc5ab..e65e25f 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java @@ -220,4 +220,13 @@ public enum WorldEffectType { /** Wet dripstone cave ceilings grow a pointed stalactite segment. */ STALACTITE_GROWS, + + /** Long-lived Nether portal influence converts nearby surface terrain. */ + NETHER_BLEED, + + /** End portal influence introduces end stone, chorus growth and obsidian. */ + END_CORRUPTION, + + /** Erosion or earthquakes uncover a recognized buried structure. */ + RUINS_EXPOSED, } diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java index ee8de1d..463b0e6 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import java.util.function.Supplier; import java.util.function.Predicate; +import java.util.function.Consumer; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.core.particles.ParticleTypes; @@ -45,19 +46,29 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { private final Supplier serverSupplier; private final Random random = new Random(); private final Predicate flowersAllowed; + private final Consumer ruinsExposedCallback; private record TimedWater(BlockPos pos, long expiresAt) {} private final Map, List> geyserWater = new HashMap<>(); private final Map, List> floodWater = new HashMap<>(); public NeoForgeWorldEffectExecutor(Supplier serverSupplier) { - this(serverSupplier, ignored -> true); + this(serverSupplier, ignored -> true, ignored -> {}); } public NeoForgeWorldEffectExecutor( Supplier serverSupplier, Predicate flowersAllowed) { + this(serverSupplier, flowersAllowed, ignored -> {}); + } + + public NeoForgeWorldEffectExecutor( + Supplier serverSupplier, + Predicate flowersAllowed, + Consumer ruinsExposedCallback) { this.serverSupplier = serverSupplier; this.flowersAllowed = flowersAllowed != null ? flowersAllowed : ignored -> true; + this.ruinsExposedCallback = ruinsExposedCallback != null + ? ruinsExposedCallback : ignored -> {}; } @Override @@ -183,6 +194,12 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { shiftMineralVein(level, baseX, baseZ, request.intensity()); case STALACTITE_GROWS -> growStalactite(level, baseX, baseZ, request.intensity()); + case NETHER_BLEED -> + netherBleed(level, baseX, baseZ, request.intensity()); + case END_CORRUPTION -> + endCorruption(level, baseX, baseZ, request.intensity()); + case RUINS_EXPOSED -> + exposeRuins(level, request.region(), baseX, baseZ, request.intensity()); } } @@ -1397,6 +1414,93 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { } } + private void netherBleed(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.20) return; + for (int i = 0; i < Math.max(2, (int) (intensity * 8)); i++) { + BlockPos pos = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS), + baseZ + random.nextInt(REGION_BLOCKS)); + if (pos == null) continue; + var state = level.getBlockState(pos); + if (state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.DIRT) + || state.is(Blocks.STONE) || state.is(Blocks.GRAVEL)) { + Block replacement = random.nextInt(5) == 0 ? Blocks.SOUL_SAND : Blocks.NETHERRACK; + level.setBlock(pos, replacement.defaultBlockState(), Block.UPDATE_ALL); + BlockPos above = pos.above(); + if (replacement == Blocks.NETHERRACK && level.getBlockState(above).isAir() + && random.nextDouble() < 0.35) { + level.setBlock(above, random.nextBoolean() + ? Blocks.CRIMSON_FUNGUS.defaultBlockState() + : Blocks.WARPED_FUNGUS.defaultBlockState(), Block.UPDATE_ALL); + } + } + } + } + + private void endCorruption(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.20) return; + for (int i = 0; i < Math.max(2, (int) (intensity * 8)); i++) { + BlockPos pos = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS), + baseZ + random.nextInt(REGION_BLOCKS)); + if (pos == null) continue; + var state = level.getBlockState(pos); + if (state.is(Blocks.STONE) || state.is(Blocks.GRASS_BLOCK) + || state.is(Blocks.DIRT)) { + level.setBlock(pos, Blocks.END_STONE.defaultBlockState(), Block.UPDATE_ALL); + BlockPos above = pos.above(); + if (level.getBlockState(above).isAir()) { + if (random.nextDouble() < 0.20) { + level.setBlock(above, Blocks.CHORUS_FLOWER.defaultBlockState(), Block.UPDATE_ALL); + } else if (random.nextDouble() < 0.12) { + level.setBlock(above, Blocks.OBSIDIAN.defaultBlockState(), Block.UPDATE_ALL); + } + } + } else if (state.is(Blocks.OBSIDIAN) && intensity > 0.7 + && level.getBlockState(pos.above()).isAir()) { + level.setBlock(pos.above(), Blocks.OBSIDIAN.defaultBlockState(), Block.UPDATE_ALL); + } + } + } + + private void exposeRuins(ServerLevel level, + com.livingworld.regions.RegionCoordinate region, + int baseX, int baseZ, double intensity) { + for (int attempt = 0; attempt < 20; attempt++) { + int x = baseX + random.nextInt(REGION_BLOCKS); + int z = baseZ + random.nextInt(REGION_BLOCKS); + int surfaceY = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1; + for (int depth = 2; depth <= 10; depth++) { + BlockPos structure = new BlockPos(x, surfaceY - depth, z); + if (!level.isLoaded(structure) || !isAncientStructure(level.getBlockState(structure))) { + continue; + } + int writes = 0; + for (int y = surfaceY; y > structure.getY() && writes < 9; y--) { + BlockPos cover = new BlockPos(x, y, z); + if (isNaturalTerrain(level.getBlockState(cover))) { + level.setBlock(cover, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + writes++; + } + } + BlockPos marker = structure.above(); + if (writes < 10 && level.isLoaded(marker) && level.getBlockState(marker).isAir()) { + level.setBlock(marker, Blocks.LANTERN.defaultBlockState(), Block.UPDATE_ALL); + } + ruinsExposedCallback.accept(region); + return; + } + } + } + + private boolean isAncientStructure(net.minecraft.world.level.block.state.BlockState state) { + return state.is(Blocks.MOSSY_COBBLESTONE) + || state.is(Blocks.CHISELED_STONE_BRICKS) + || state.is(Blocks.CRACKED_STONE_BRICKS) + || state.is(Blocks.MOSSY_STONE_BRICKS) + || state.is(Blocks.CHISELED_DEEPSLATE) + || state.is(Blocks.DEEPSLATE_TILES) + || state.is(Blocks.CRACKED_DEEPSLATE_TILES); + } + private BlockPos surfaceAt(ServerLevel level, int x, int z) { int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1; if (y < level.getMinBuildHeight()) {