From f8995392ebd876590c26f831494065c8f0c79fa3 Mon Sep 17 00:00:00 2001 From: George Date: Thu, 11 Jun 2026 18:06:17 +0100 Subject: [PATCH] Complete phase 2 atmospheric events --- .../java/com/livingworld/LivingWorldMod.java | 47 ++++++ .../bootstrap/LivingWorldBootstrap.java | 159 ++++++++++++++++++ .../livingworld/climate/ClimateEventType.java | 6 +- .../modules/worldeffects/WorldEffectType.java | 15 ++ .../neoforge/NeoForgeWorldEffectExecutor.java | 100 +++++++++++ 5 files changed, 326 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index f0c7cb4..ff1d397 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -216,6 +216,13 @@ public class LivingWorldMod { if (biome.is(BiomeTags.IS_OCEAN) || biome.is(BiomeTags.IS_DEEP_OCEAN)) { bootstrap.markOceanicRegion(coord); } + if (biome.is(BiomeTags.IS_BADLANDS) || temp >= 2.0f) { + bootstrap.markDesertRegion(coord); + } + if (biome.is(BiomeTags.IS_BEACH) || biome.is(BiomeTags.IS_OCEAN) + || biome.is(BiomeTags.IS_DEEP_OCEAN) || downfall > 0.8f) { + bootstrap.markCoastalOrWetlandRegion(coord); + } } }); NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> { @@ -488,6 +495,36 @@ public class LivingWorldMod { player.getX(), player.getY() + 1.0, player.getZ(), 4, 5.0, 2.0, 5.0, 0.01); } + double humidity = bootstrap.getRegionHumidity(coord); + double temperature = bootstrap.getRegionTemperature(coord); + double pollution = metricsOpt.map(RegionMetrics::getPollutionScore).orElse(100.0); + long localTime = ambLevel.getDayTime() % 24000L; + if (bootstrap.isCoastalOrWetlandRegion(coord) && humidity > 0.70 + && (atm == null || atm.getThunderLevel() < 0.15)) { + ambLevel.sendParticles(player, ParticleTypes.CLOUD, false, + player.getX(), player.getY() + 0.5, player.getZ(), + 5, 10.0, 1.5, 10.0, 0.005); + } + if ((bootstrap.isDesertRegion(coord) || pollution > 60.0) + && localTime < 12000L) { + ambLevel.sendParticles(player, ParticleTypes.FLAME, false, + player.getX(), player.getY() + 0.2, player.getZ(), + 2, 5.0, 0.1, 5.0, 0.01); + } + if (temperature < 0.15 && localTime > 13000L + && pollution < 10.0 && (atm == null || atm.getRainLevel() < 0.1)) { + ambLevel.sendParticles(player, ParticleTypes.END_ROD, false, + player.getX(), Math.min(220.0, Math.max(180.0, player.getY() + 100.0)), + player.getZ(), 5, 24.0, 4.0, 24.0, 0.01); + } + if (bootstrap.isClimateEventActive(coord, com.livingworld.climate.ClimateEventType.BLIZZARD)) { + double angle = bootstrap.getWindAngle(); + var movement = player.getDeltaMovement(); + player.setDeltaMovement(movement.x + Math.cos(angle) * 0.025, + movement.y, movement.z + Math.sin(angle) * 0.025); + player.addEffect(new MobEffectInstance( + MobEffects.MOVEMENT_SLOWDOWN, 60, 0, true, false)); + } } } } @@ -737,6 +774,16 @@ public class LivingWorldMod { float temp = biome.value().getBaseTemperature(); float downfall = biome.value().getModifiedClimateSettings().downfall(); bootstrap.initializeRegionBiomeValues(coord, temp, downfall); + if (biome.is(BiomeTags.IS_OCEAN) || biome.is(BiomeTags.IS_DEEP_OCEAN)) { + bootstrap.markOceanicRegion(coord); + } + if (biome.is(BiomeTags.IS_BADLANDS) || temp >= 2.0f) { + bootstrap.markDesertRegion(coord); + } + if (biome.is(BiomeTags.IS_BEACH) || biome.is(BiomeTags.IS_OCEAN) + || biome.is(BiomeTags.IS_DEEP_OCEAN) || downfall > 0.8f) { + bootstrap.markCoastalOrWetlandRegion(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 15ff70d..97c7306 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -127,6 +127,9 @@ public final class LivingWorldBootstrap { /** Ocean-floor regions; can develop submarine volcanoes regardless of surface elevation. */ private final Set oceanicRegions = new HashSet<>(); private final Map regionTemperatures = new HashMap<>(); + private final Map regionDownfall = new HashMap<>(); + private final Set desertRegions = new HashSet<>(); + private final Set coastalWetlandRegions = new HashSet<>(); private final Map oceanPollutionCycles = new HashMap<>(); private final Set deadZoneRegions = new HashSet<>(); private final Set ventedRegions = new HashSet<>(); @@ -291,6 +294,9 @@ public final class LivingWorldBootstrap { volcanicRegions.clear(); oceanicRegions.clear(); regionTemperatures.clear(); + regionDownfall.clear(); + desertRegions.clear(); + coastalWetlandRegions.clear(); oceanPollutionCycles.clear(); deadZoneRegions.clear(); ventedRegions.clear(); @@ -886,6 +892,38 @@ public final class LivingWorldBootstrap { return coord != null && oceanicRegions.contains(coord); } + public void markDesertRegion(RegionCoordinate coord) { + if (coord != null) desertRegions.add(coord); + } + + public void markCoastalOrWetlandRegion(RegionCoordinate coord) { + if (coord != null) coastalWetlandRegions.add(coord); + } + + public boolean isDesertRegion(RegionCoordinate coord) { + return coord != null && desertRegions.contains(coord); + } + + public boolean isCoastalOrWetlandRegion(RegionCoordinate coord) { + return coord != null && coastalWetlandRegions.contains(coord); + } + + public float getRegionTemperature(RegionCoordinate coord) { + return regionTemperatures.getOrDefault(coord, 0.8f); + } + + public float getRegionHumidity(RegionCoordinate coord) { + return regionDownfall.getOrDefault(coord, 0.4f); + } + + public boolean isClimateEventActive(RegionCoordinate coord, ClimateEventType type) { + if (coord == null || type == null) return false; + for (ClimateEvent event : activeClimateEvents) { + if (event.getType() == type && event.getAffectedRegions().contains(coord)) return true; + } + return false; + } + /** * Advances reefs, kelp forests, blooms and dead zones. All physical work is queued * through the bounded world-effect executor. @@ -1374,6 +1412,8 @@ public final class LivingWorldBootstrap { RecoveryRegionData recovery = region.getModuleData().get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElse(null); PollutionRegionData poll = region.getModuleData().get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null); if (water == null || atm == null || recovery == null) continue; + float temperature = regionTemperatures.getOrDefault(coord, 0.8f); + float humidity = regionDownfall.getOrDefault(coord, 0.4f); // --- DROUGHT trigger --- if (water.getWaterAvailability() < 20 && water.getDroughtRisk() > 65) { @@ -1426,6 +1466,52 @@ public final class LivingWorldBootstrap { + coord.x() + "," + coord.z() + ") overflowing into lowlands."); } } + + if (poll != null && poll.getAirPollution() > 70.0 && atm.getRainLevel() > 0.25) { + ClimateEvent acidRain = new ClimateEvent( + ClimateEventType.ACID_RAIN, coord, simTick, + Math.min(1.0, poll.getAirPollution() / 100.0)); + activeClimateEvents.add(acidRain); + coveredByEvent.add(coord); + pendingEventMessages.add("[LW] Acid rain falling at region (" + + coord.x() + "," + coord.z() + ")."); + continue; + } + + if (temperature < 0.15f && humidity > 0.6f + && atm.getRainLevel() > 0.45 && atm.getThunderLevel() > 0.25) { + ClimateEvent blizzard = new ClimateEvent( + ClimateEventType.BLIZZARD, coord, simTick, + Math.min(1.0, 0.4 + atm.getThunderLevel())); + activeClimateEvents.add(blizzard); + coveredByEvent.add(coord); + pendingEventMessages.add("[LW] Blizzard conditions at region (" + + coord.x() + "," + coord.z() + ")."); + continue; + } + + if (desertRegions.contains(coord) && humidity < 0.25f + && atm.getThunderLevel() > 0.25) { + ClimateEvent sandstorm = new ClimateEvent( + ClimateEventType.SANDSTORM, coord, simTick, + Math.min(1.0, 0.35 + atm.getThunderLevel())); + activeClimateEvents.add(sandstorm); + coveredByEvent.add(coord); + pendingEventMessages.add("[LW] Sandstorm crossing region (" + + coord.x() + "," + coord.z() + ")."); + continue; + } + + if (humidity < 0.30f && atm.getThunderLevel() > 0.55 + && windRandom.nextDouble() < 0.20) { + ClimateEvent lightning = new ClimateEvent( + ClimateEventType.LIGHTNING_STORM, coord, simTick, + Math.min(1.0, atm.getThunderLevel())); + activeClimateEvents.add(lightning); + coveredByEvent.add(coord); + pendingEventMessages.add("[LW] Dry lightning storm at region (" + + coord.x() + "," + coord.z() + ")."); + } } } @@ -1517,6 +1603,78 @@ public final class LivingWorldBootstrap { WorldEffectType.ALGAE_BLOOM, coord, ev.getSeverity())); } } + case ACID_RAIN -> { + AtmosphereRegionData atm = region.getModuleData() + .get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null); + PollutionRegionData pollution = region.getModuleData() + .get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null); + SoilRegionData soil = region.getModuleData() + .get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null); + if (atm == null || pollution == null + || atm.getRainLevel() < 0.1 || pollution.getAirPollution() < 70.0) { + shouldResolve = true; + break; + } + if (soil != null) { + soil.setContamination(Math.min(100, soil.getContamination() + 5.0)); + region.getModuleData().put(SoilModule.MODULE_ID, soil); + } + if (worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.ACID_RAIN_DAMAGE, coord, ev.getSeverity())); + } + regionManager.markDirty(region); + } + case BLIZZARD -> { + AtmosphereRegionData atm = region.getModuleData() + .get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null); + float temperature = regionTemperatures.getOrDefault(coord, 0.8f); + if (temperature > 0.15f || atm == null || atm.getRainLevel() < 0.2) { + shouldResolve = true; + if (temperature > 0.0f && worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.SNOW_MELTS, coord, 0.7)); + } + break; + } + if (worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.SNOW_ACCUMULATION, coord, ev.getSeverity())); + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.WATER_FREEZES, coord, ev.getSeverity())); + } + } + case SANDSTORM -> { + AtmosphereRegionData atm = region.getModuleData() + .get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null); + if (!desertRegions.contains(coord) || atm == null || atm.getThunderLevel() < 0.15) { + shouldResolve = true; + break; + } + if (worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.SAND_DEPOSIT, coord, ev.getSeverity())); + } + SoilRegionData soil = region.getModuleData() + .get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null); + if (soil != null) { + soil.setContamination(Math.min(100, soil.getContamination() + 0.1)); + region.getModuleData().put(SoilModule.MODULE_ID, soil); + regionManager.markDirty(region); + } + } + case LIGHTNING_STORM -> { + AtmosphereRegionData atm = region.getModuleData() + .get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null); + if (atm == null || atm.getThunderLevel() < 0.25) { + shouldResolve = true; + break; + } + if (worldEffectsModule != null && windRandom.nextDouble() < 0.45) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.WILDFIRE, coord, 0.25)); + } + } } } @@ -1568,6 +1726,7 @@ public final class LivingWorldBootstrap { public void initializeRegionBiomeValues(RegionCoordinate coord, float temp, float downfall) { if (!serverReady || coord == null) return; regionTemperatures.put(coord, temp); + regionDownfall.put(coord, downfall); regionManager.resolve(coord).ifPresent(region -> { SoilRegionData soil = region.getModuleData() .get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null); diff --git a/src/main/java/com/livingworld/climate/ClimateEventType.java b/src/main/java/com/livingworld/climate/ClimateEventType.java index 302afdc..db36be4 100644 --- a/src/main/java/com/livingworld/climate/ClimateEventType.java +++ b/src/main/java/com/livingworld/climate/ClimateEventType.java @@ -6,7 +6,11 @@ public enum ClimateEventType { WILDFIRE("Wildfire", "Fire spreading through drought-stressed forest regions"), FLOOD("Flood", "Heavy rainfall overwhelming low-lying terrain"), VOLCANIC_ERUPTION("Volcanic Eruption", "Lava flows and ash clouds from an active volcano"), - ALGAE_BLOOM("Algae Bloom", "Nutrient pollution causing a bloom and aquatic dead zone"); + ALGAE_BLOOM("Algae Bloom", "Nutrient pollution causing a bloom and aquatic dead zone"), + 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"); 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 b7b5d0b..2f30c38 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java @@ -130,4 +130,19 @@ public enum WorldEffectType { /** Volcanic seafloor activity creates a permanent bubbling vent field. */ HYDROTHERMAL_VENT, + + /** Polluted rainfall weathers exposed rock and kills surface vegetation. */ + ACID_RAIN_DAMAGE, + + /** Blizzard snowfall builds bounded snow layers on exposed terrain. */ + SNOW_ACCUMULATION, + + /** Blizzard conditions freeze exposed standing water. */ + WATER_FREEZES, + + /** Warm conditions remove accumulated seasonal snow one layer at a time. */ + SNOW_MELTS, + + /** Dry high winds deposit sand and strip fragile surface plants. */ + SAND_DEPOSIT, } diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java index d62cf7b..912152f 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -103,6 +103,16 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { deadZone(level, baseX, baseZ, request.intensity()); case HYDROTHERMAL_VENT -> hydrothermalVent(level, baseX, baseZ, request.intensity()); + case ACID_RAIN_DAMAGE -> + acidRainDamage(level, baseX, baseZ, request.intensity()); + case SNOW_ACCUMULATION -> + accumulateSnow(level, baseX, baseZ, request.intensity()); + case WATER_FREEZES -> + freezeWater(level, baseX, baseZ, request.intensity()); + case SNOW_MELTS -> + meltSnow(level, baseX, baseZ, request.intensity()); + case SAND_DEPOSIT -> + depositSand(level, baseX, baseZ, request.intensity()); } } @@ -711,6 +721,96 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { } } + private void acidRainDamage(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.25) 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 || !level.canSeeSky(pos.above())) continue; + var state = level.getBlockState(pos); + if (state.is(Blocks.STONE) || state.is(Blocks.COBBLESTONE)) { + level.setBlock(pos, Blocks.GRAVEL.defaultBlockState(), Block.UPDATE_ALL); + } else if (state.is(Blocks.GRASS_BLOCK)) { + level.setBlock(pos, Blocks.DIRT.defaultBlockState(), Block.UPDATE_ALL); + } + BlockPos above = pos.above(); + if (level.getBlockState(above).is(BlockTags.FLOWERS) + || level.getBlockState(above).is(Blocks.SHORT_GRASS) + || level.getBlockState(above).is(BlockTags.SAPLINGS)) { + level.setBlock(above, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + } + } + + private void accumulateSnow(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.35) return; + for (int i = 0; i < Math.max(2, (int) (intensity * 8)); i++) { + BlockPos surface = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS), + baseZ + random.nextInt(REGION_BLOCKS)); + if (surface == null) continue; + BlockPos above = surface.above(); + if (!level.canSeeSky(above)) continue; + var state = level.getBlockState(above); + if (state.isAir()) { + level.setBlock(above, Blocks.SNOW.defaultBlockState(), Block.UPDATE_ALL); + } else if (state.is(Blocks.SNOW) + && state.getValue(net.minecraft.world.level.block.SnowLayerBlock.LAYERS) < 4) { + level.setBlock(above, state.setValue( + net.minecraft.world.level.block.SnowLayerBlock.LAYERS, + state.getValue(net.minecraft.world.level.block.SnowLayerBlock.LAYERS) + 1), + Block.UPDATE_ALL); + } + } + } + + private void freezeWater(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.25) return; + for (int i = 0; i < Math.max(1, (int) (intensity * 6)); i++) { + int x = baseX + random.nextInt(REGION_BLOCKS); + int z = baseZ + random.nextInt(REGION_BLOCKS); + int y = level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z) - 1; + BlockPos pos = new BlockPos(x, y, z); + if (level.isLoaded(pos) && level.getBlockState(pos).is(Blocks.WATER) + && level.getFluidState(pos).isSource() && level.canSeeSky(pos.above())) { + level.setBlock(pos, Blocks.ICE.defaultBlockState(), Block.UPDATE_ALL); + } + } + } + + private void meltSnow(ServerLevel level, int baseX, int baseZ, double intensity) { + 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 || !level.getBlockState(pos).is(Blocks.SNOW)) continue; + var state = level.getBlockState(pos); + int layers = state.getValue(net.minecraft.world.level.block.SnowLayerBlock.LAYERS); + level.setBlock(pos, layers > 1 + ? state.setValue(net.minecraft.world.level.block.SnowLayerBlock.LAYERS, layers - 1) + : Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + } + + private void depositSand(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.30) return; + for (int i = 0; i < Math.max(2, (int) (intensity * 8)); i++) { + BlockPos surface = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS), + baseZ + random.nextInt(REGION_BLOCKS)); + if (surface == null) continue; + BlockPos above = surface.above(); + var aboveState = level.getBlockState(above); + if (aboveState.is(BlockTags.FLOWERS) || aboveState.is(Blocks.SHORT_GRASS) + || aboveState.is(Blocks.DEAD_BUSH)) { + level.setBlock(above, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } else if (aboveState.isAir() && level.canSeeSky(above) + && (level.getBlockState(surface).is(Blocks.SAND) + || level.getBlockState(surface).is(Blocks.RED_SAND) + || level.getBlockState(surface).is(Blocks.SANDSTONE) + || level.getBlockState(surface).is(Blocks.TERRACOTTA))) { + level.setBlock(above, Blocks.SAND.defaultBlockState(), Block.UPDATE_ALL); + } + } + } + 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()) {