diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index ff1d397..579be97 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -33,6 +33,7 @@ import net.minecraft.world.entity.animal.Squid; import net.minecraft.world.entity.monster.Monster; import net.minecraft.world.item.Items; import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.Biomes; import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.CampfireBlockEntity; @@ -223,6 +224,10 @@ public class LivingWorldMod { || biome.is(BiomeTags.IS_DEEP_OCEAN) || downfall > 0.8f) { bootstrap.markCoastalOrWetlandRegion(coord); } + int caveY = Math.max(level.getMinBuildHeight() + 1, by - 32); + if (level.getBiome(new BlockPos(bx, caveY, bz)).is(Biomes.DRIPSTONE_CAVES)) { + bootstrap.markGeothermalRegion(coord); + } } }); NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> { @@ -525,6 +530,14 @@ public class LivingWorldMod { player.addEffect(new MobEffectInstance( MobEffects.MOVEMENT_SLOWDOWN, 60, 0, true, false)); } + if (bootstrap.isClimateEventActive( + coord, com.livingworld.climate.ClimateEventType.EARTHQUAKE)) { + var movement = player.getDeltaMovement(); + player.setDeltaMovement( + movement.x + (random.nextDouble() - 0.5) * 0.35, + Math.max(movement.y, 0.12), + movement.z + (random.nextDouble() - 0.5) * 0.35); + } } } } @@ -784,6 +797,10 @@ public class LivingWorldMod { || biome.is(BiomeTags.IS_DEEP_OCEAN) || downfall > 0.8f) { bootstrap.markCoastalOrWetlandRegion(coord); } + int caveY = Math.max(level.getMinBuildHeight() + 1, cy - 32); + if (level.getBiome(new BlockPos(cx, caveY, cz)).is(Biomes.DRIPSTONE_CAVES)) { + bootstrap.markGeothermalRegion(coord); + } } private static final int REGION_BLOCKS = diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index 97c7306..33bef3f 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -130,12 +130,16 @@ public final class LivingWorldBootstrap { private final Map regionDownfall = new HashMap<>(); private final Set desertRegions = new HashSet<>(); private final Set coastalWetlandRegions = new HashSet<>(); + private final Set geothermalRegions = new HashSet<>(); private final Map oceanPollutionCycles = new HashMap<>(); private final Set deadZoneRegions = new HashSet<>(); private final Set ventedRegions = new HashSet<>(); /** Regions whose terrain has changed significantly and need an elevation re-sample. */ private final Set pendingElevationResample = new HashSet<>(); private int volcanicActivityTick = 0; + private record GeyserSite(int cycleTick, boolean active) {} + private final Map geyserSites = new HashMap<>(); + private int geologicalActivityTick = 0; private PlatformAdapter platformAdapter; private Path worldSaveDirectory; @@ -297,11 +301,14 @@ public final class LivingWorldBootstrap { regionDownfall.clear(); desertRegions.clear(); coastalWetlandRegions.clear(); + geothermalRegions.clear(); oceanPollutionCycles.clear(); deadZoneRegions.clear(); ventedRegions.clear(); pendingElevationResample.clear(); volcanicActivityTick = 0; + geyserSites.clear(); + geologicalActivityTick = 0; simSpeedMultiplier = 1; serverReady = false; LivingWorldLogger.info( @@ -464,6 +471,95 @@ public final class LivingWorldBootstrap { if (++volcanicActivityTick % VOLCANO_TICK_INTERVAL == 0) { applyVolcanicActivity(); } + if (++geologicalActivityTick % 4 == 0) { + applyGeologicalActivity(); + } + } + + private void applyGeologicalActivity() { + Collection active = regionManager.getActiveRegions(); + if (active.isEmpty() || worldEffectsModule == null) return; + long simTick = simulationManager.getSimulationTickCounter(); + Map byCoord = new HashMap<>(); + for (Region region : active) byCoord.put(region.getCoordinate(), region); + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + for (Region region : active) { + RegionCoordinate coord = region.getCoordinate(); + double elevation = regionElevations.getOrDefault(coord, 64.0); + VolcanoPhase phase = volcanoPhase.get(coord); + + boolean volcanicAdjacent = volcanicRegions.contains(coord); + for (int[] offset : offsets) { + RegionCoordinate neighbour = new RegionCoordinate( + coord.dimensionId(), coord.x() + offset[0], coord.z() + offset[1]); + volcanicAdjacent |= volcanicRegions.contains(neighbour); + } + if (!geyserSites.containsKey(coord) + && (volcanicAdjacent || elevation > 100.0 || geothermalRegions.contains(coord)) + && windRandom.nextDouble() < 0.02) { + geyserSites.put(coord, new GeyserSite(0, false)); + } + + GeyserSite site = geyserSites.get(coord); + if (site != null) { + int cycle = (site.cycleTick() + 1) % 165; + boolean activeNow = cycle >= 150; + geyserSites.put(coord, new GeyserSite(cycle, activeNow)); + if (activeNow) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.GEYSER_ERUPT, coord, 0.8)); + if (cycle == 150) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.TRAVERTINE_DEPOSIT, coord, 0.7)); + } + } + } + + double quakeChance = phase == VolcanoPhase.BUILDING || phase == VolcanoPhase.ERUPTING + ? 0.003 : elevation > 90.0 ? 0.001 : 0.0; + if (quakeChance > 0 && windRandom.nextDouble() < quakeChance + && !isClimateEventActive(coord, ClimateEventType.EARTHQUAKE)) { + double magnitude = 1.0 + windRandom.nextDouble() * 4.0; + ClimateEvent quake = new ClimateEvent( + ClimateEventType.EARTHQUAKE, coord, simTick, magnitude / 5.0); + activeClimateEvents.add(quake); + pendingEventMessages.add("[LW] Earthquake magnitude " + + String.format("%.1f", magnitude) + " at region (" + + coord.x() + "," + coord.z() + ")."); + } + + double groundwater = groundwaterLevel.getOrDefault(coord, 0.0); + if (groundwater > 80.0 && windRandom.nextDouble() < 0.0005 + && !isClimateEventActive(coord, ClimateEventType.SINKHOLE)) { + ClimateEvent sinkhole = new ClimateEvent( + ClimateEventType.SINKHOLE, coord, simTick, 0.8); + activeClimateEvents.add(sinkhole); + pendingEventMessages.add("[LW] Sinkhole collapse at region (" + + coord.x() + "," + coord.z() + ")."); + } + + AtmosphereRegionData atmosphere = region.getModuleData() + .get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null); + if (atmosphere != null && atmosphere.getRainLevel() > 0.55) { + for (int[] offset : offsets) { + RegionCoordinate neighbour = new RegionCoordinate( + coord.dimensionId(), coord.x() + offset[0], coord.z() + offset[1]); + Double neighbourElevation = regionElevations.get(neighbour); + if (neighbourElevation != null && elevation - neighbourElevation > 15.0 + && windRandom.nextDouble() < 0.01) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.LANDSLIDE, coord, 0.7)); + break; + } + } + } + + if (phase == VolcanoPhase.COOLING && windRandom.nextDouble() < 0.001) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.LAVA_TUBE_COLLAPSE, coord, 0.8)); + } + } } /** Sets the simulation speed multiplier (1 = real-time, max 100). */ @@ -900,6 +996,10 @@ public final class LivingWorldBootstrap { if (coord != null) coastalWetlandRegions.add(coord); } + public void markGeothermalRegion(RegionCoordinate coord) { + if (coord != null) geothermalRegions.add(coord); + } + public boolean isDesertRegion(RegionCoordinate coord) { return coord != null && desertRegions.contains(coord); } @@ -1675,6 +1775,26 @@ public final class LivingWorldBootstrap { WorldEffectType.WILDFIRE, coord, 0.25)); } } + case EARTHQUAKE -> { + if (ev.getTicksActive() == 1 && worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + ev.getSeverity() >= 0.8 + ? WorldEffectType.FISSURE_OPENS : WorldEffectType.GROUND_CRACK, + coord, ev.getSeverity())); + if (ev.getSeverity() >= 0.8) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.LANDSLIDE, coord, ev.getSeverity())); + } + } + if (ev.getTicksActive() >= 3) shouldResolve = true; + } + case SINKHOLE -> { + if (ev.getTicksActive() == 1 && worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.SINKHOLE_COLLAPSES, coord, ev.getSeverity())); + } + if (ev.getTicksActive() >= 2) shouldResolve = true; + } } } diff --git a/src/main/java/com/livingworld/climate/ClimateEventType.java b/src/main/java/com/livingworld/climate/ClimateEventType.java index db36be4..923752d 100644 --- a/src/main/java/com/livingworld/climate/ClimateEventType.java +++ b/src/main/java/com/livingworld/climate/ClimateEventType.java @@ -10,7 +10,9 @@ public enum ClimateEventType { ACID_RAIN("Acid Rain", "Polluted rainfall damaging soil, stone and vegetation"), BLIZZARD("Blizzard", "Wind-driven snow accumulation and freezing water"), SANDSTORM("Sandstorm", "Dry high winds stripping plants and depositing sand"), - LIGHTNING_STORM("Lightning Storm", "Dry thunderstorm producing isolated ignition strikes"); + LIGHTNING_STORM("Lightning Storm", "Dry thunderstorm producing isolated ignition strikes"), + EARTHQUAKE("Earthquake", "Seismic rupture opening cracks and unstable slopes"), + SINKHOLE("Sinkhole", "Groundwater-driven collapse into a shallow cave"); private final String displayName; private final String description; diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java index 2f30c38..6fca053 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java @@ -145,4 +145,25 @@ public enum WorldEffectType { /** Dry high winds deposit sand and strip fragile surface plants. */ SAND_DEPOSIT, + + /** A registered geothermal site ejects a temporary column of water. */ + GEYSER_ERUPT, + + /** Repeated geyser eruptions build a calcite and tuff travertine ring. */ + TRAVERTINE_DEPOSIT, + + /** Low-magnitude seismic activity removes a short line of surface blocks. */ + GROUND_CRACK, + + /** High-magnitude seismic activity opens a narrow, deep fissure. */ + FISSURE_OPENS, + + /** Saturated ground collapses into a detected shallow cave. */ + SINKHOLE_COLLAPSES, + + /** Unstable steep terrain transfers loose surface material downhill. */ + LANDSLIDE, + + /** A cooling volcanic roof collapses to reveal a lava-adjacent cavity. */ + LAVA_TUBE_COLLAPSE, } diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java index 912152f..064921c 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -6,6 +6,10 @@ import com.livingworld.debug.LivingWorldLogger; import com.livingworld.modules.worldeffects.WorldEffectConsumer; import com.livingworld.modules.worldeffects.WorldEffectRequest; import java.util.Random; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.function.Supplier; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; @@ -38,6 +42,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { private final Supplier serverSupplier; private final Random random = new Random(); + private record TimedWater(BlockPos pos, long expiresAt) {} + private final Map, List> geyserWater = new HashMap<>(); public NeoForgeWorldEffectExecutor(Supplier serverSupplier) { this.serverSupplier = serverSupplier; @@ -58,6 +64,7 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { } int baseX = request.region().x() * REGION_BLOCKS; int baseZ = request.region().z() * REGION_BLOCKS; + cleanExpiredGeyserWater(level, dimensionKey); switch (request.type()) { case GRASS_DEGRADES_TO_DIRT -> @@ -113,6 +120,20 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { meltSnow(level, baseX, baseZ, request.intensity()); case SAND_DEPOSIT -> depositSand(level, baseX, baseZ, request.intensity()); + case GEYSER_ERUPT -> + eruptGeyser(level, dimensionKey, baseX, baseZ, request.intensity()); + case TRAVERTINE_DEPOSIT -> + depositTravertine(level, baseX, baseZ, request.intensity()); + case GROUND_CRACK -> + openGroundCrack(level, baseX, baseZ, request.intensity(), false); + case FISSURE_OPENS -> + openGroundCrack(level, baseX, baseZ, request.intensity(), true); + case SINKHOLE_COLLAPSES -> + collapseSinkhole(level, baseX, baseZ, request.intensity()); + case LANDSLIDE -> + landslide(level, baseX, baseZ, request.intensity()); + case LAVA_TUBE_COLLAPSE -> + collapseLavaTube(level, baseX, baseZ, request.intensity()); } } @@ -811,6 +832,165 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { } } + private void cleanExpiredGeyserWater(ServerLevel level, ResourceKey dimensionKey) { + List entries = geyserWater.get(dimensionKey); + if (entries == null) return; + long now = level.getGameTime(); + entries.removeIf(entry -> { + if (entry.expiresAt() > now) return false; + if (level.isLoaded(entry.pos()) && level.getBlockState(entry.pos()).is(Blocks.WATER)) { + level.setBlock(entry.pos(), Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + return true; + }); + if (entries.isEmpty()) geyserWater.remove(dimensionKey); + } + + private void eruptGeyser(ServerLevel level, ResourceKey dimensionKey, + int baseX, int baseZ, double intensity) { + int x = baseX + REGION_BLOCKS / 2; + int z = baseZ + REGION_BLOCKS / 2; + int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z); + List entries = geyserWater.computeIfAbsent(dimensionKey, ignored -> new ArrayList<>()); + int height = 3 + random.nextInt(3); + for (int i = 0; i < height; i++) { + BlockPos pos = new BlockPos(x, y + i, z); + if (!level.isLoaded(pos) || (!level.getBlockState(pos).isAir() + && !level.getBlockState(pos).is(Blocks.WATER))) break; + level.setBlock(pos, Blocks.WATER.defaultBlockState(), Block.UPDATE_ALL); + entries.add(new TimedWater(pos, level.getGameTime() + 40)); + level.sendParticles(ParticleTypes.BUBBLE_COLUMN_UP, + x + 0.5, y + i + 0.5, z + 0.5, 3, 0.2, 0.3, 0.2, 0.05); + } + } + + private void depositTravertine(ServerLevel level, int baseX, int baseZ, double intensity) { + int x = baseX + REGION_BLOCKS / 2; + int z = baseZ + REGION_BLOCKS / 2; + int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1; + int radius = 2 + random.nextInt(3); + int writes = 0; + for (int dx = -radius; dx <= radius && writes < 10; dx++) { + for (int dz = -radius; dz <= radius && writes < 10; dz++) { + if (Math.abs(Math.sqrt(dx * dx + dz * dz) - radius) > 0.75) continue; + BlockPos pos = new BlockPos(x + dx, y, z + dz); + if (!level.isLoaded(pos)) continue; + var state = level.getBlockState(pos); + if (state.is(Blocks.DIRT) || state.is(Blocks.GRASS_BLOCK) + || state.is(Blocks.STONE) || state.is(Blocks.GRAVEL)) { + level.setBlock(pos, random.nextBoolean() ? Blocks.CALCITE.defaultBlockState() + : Blocks.TUFF.defaultBlockState(), Block.UPDATE_ALL); + writes++; + } + } + } + } + + private void openGroundCrack(ServerLevel level, int baseX, int baseZ, + double intensity, boolean fissure) { + int x = baseX + random.nextInt(REGION_BLOCKS); + int z = baseZ + random.nextInt(REGION_BLOCKS); + int dx = random.nextBoolean() ? 1 : 0; + int dz = dx == 0 ? 1 : 0; + int writes = 0; + int length = fissure ? 5 : 3; + int depth = fissure ? 8 + random.nextInt(8) : 1; + for (int step = 0; step < length && writes < 10; step++) { + int surfaceY = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + x + dx * step, z + dz * step) - 1; + for (int d = 0; d < depth && writes < 10; d++) { + BlockPos pos = new BlockPos(x + dx * step, surfaceY - d, z + dz * step); + if (!level.isLoaded(pos) || !isNaturalTerrain(level.getBlockState(pos))) continue; + level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + writes++; + } + } + } + + private void collapseSinkhole(ServerLevel level, int baseX, int baseZ, double intensity) { + int centerX = baseX + random.nextInt(REGION_BLOCKS); + int centerZ = baseZ + random.nextInt(REGION_BLOCKS); + int surfaceY = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, centerX, centerZ) - 1; + int caveY = Integer.MIN_VALUE; + for (int y = surfaceY - 3; y >= Math.max(level.getMinBuildHeight(), surfaceY - 15); y--) { + BlockPos probe = new BlockPos(centerX, y, centerZ); + if (level.isLoaded(probe) && level.getBlockState(probe).isAir()) { + caveY = y; + break; + } + } + if (caveY == Integer.MIN_VALUE) return; + int radius = 1 + random.nextInt(2); + int writes = 0; + for (int dx = -radius; dx <= radius && writes < 10; dx++) { + for (int dz = -radius; dz <= radius && writes < 10; dz++) { + for (int y = surfaceY; y > caveY && writes < 10; y--) { + BlockPos pos = new BlockPos(centerX + dx, y, centerZ + dz); + if (!level.isLoaded(pos) || !isNaturalTerrain(level.getBlockState(pos))) continue; + level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + writes++; + } + } + } + } + + private void landslide(ServerLevel level, int baseX, int baseZ, double intensity) { + BlockPos highest = null; + BlockPos lowest = null; + for (int i = 0; i < 10; i++) { + BlockPos sample = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS), + baseZ + random.nextInt(REGION_BLOCKS)); + if (sample == null) continue; + if (highest == null || sample.getY() > highest.getY()) highest = sample; + if (lowest == null || sample.getY() < lowest.getY()) lowest = sample; + } + if (highest == null || lowest == null || highest.getY() - lowest.getY() < 8) return; + for (int i = 0; i < 2; i++) { + BlockPos source = highest.below(i); + var state = level.getBlockState(source); + if (!state.is(Blocks.DIRT) && !state.is(Blocks.GRAVEL) && !state.is(Blocks.SAND)) continue; + BlockPos target = lowest.above(i + 1); + if (!level.isLoaded(target) || !level.getBlockState(target).isAir()) continue; + level.setBlock(source, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + level.setBlock(target, state, Block.UPDATE_ALL); + } + } + + private void collapseLavaTube(ServerLevel level, int baseX, int baseZ, double intensity) { + for (int attempt = 0; attempt < 10; 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 y = surfaceY - 2; y >= Math.max(level.getMinBuildHeight(), surfaceY - 20); y--) { + BlockPos air = new BlockPos(x, y, z); + if (!level.isLoaded(air) || !level.getBlockState(air).isAir()) continue; + boolean lavaAdjacent = false; + for (Direction direction : Direction.values()) { + if (level.getBlockState(air.relative(direction)).is(Blocks.LAVA)) { + lavaAdjacent = true; + break; + } + } + if (lavaAdjacent) { + BlockPos ceiling = air.above(); + if (isNaturalTerrain(level.getBlockState(ceiling))) { + level.setBlock(ceiling, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + return; + } + } + } + } + + private boolean isNaturalTerrain(net.minecraft.world.level.block.state.BlockState state) { + return state.is(Blocks.STONE) || state.is(Blocks.DEEPSLATE) + || state.is(Blocks.DIRT) || state.is(Blocks.GRASS_BLOCK) + || state.is(Blocks.GRAVEL) || state.is(Blocks.SAND) + || state.is(Blocks.TUFF) || state.is(Blocks.ANDESITE) + || state.is(Blocks.DIORITE) || state.is(Blocks.GRANITE) + || state.is(Blocks.BASALT); + } + 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()) {