Add dynamic region transformation, water runoff physics, and puddle formation

- Dynamic succession cap: desert regions (initially capped at SPARSE_GRASS)
  can now advance to MATURE_FOREST if water and soil conditions improve
  sustainably. applyDynamicCapUpdate() raises the cap each sim cycle;
  maxSuccessionStage is now persisted in the region codec so progress
  survives server restarts.

- Elevation-based water runoff: LivingWorldMod samples average surface
  height once per region on first entry. applyWaterRunoff() uses height
  differences to transfer water availability from high regions to low
  neighbours when it is raining, simulating drainage basin physics —
  valleys collect water, hills drain.

- Water pool formation (WATER_POOL_FORMS): WorldEffectsModule emits the
  new effect when a BARREN/SPARSE_GRASS region has regional rain > 0.3.
  NeoForgeWorldEffectExecutor finds the lowest sampled surface point and
  places water source blocks there; at high intensity the pool widens to
  adjacent blocks. These physical water blocks are then picked up by the
  periodic hasWaterBody() scan and feed back a hydration boost, enabling
  succession.

- Player water management: water bucket placement now invalidates the
  water-body scan cache for that region so player-built pools are detected
  within one player-check cycle. The scan itself is now periodic (every
  ~10 min) rather than one-shot, so natural pools from puddle formation
  are also re-evaluated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George
2026-06-07 21:41:35 +01:00
parent 178d50883e
commit 32e413cc9f
5 changed files with 261 additions and 18 deletions
@@ -79,6 +79,8 @@ public class LivingWorldMod {
private static final double GROUND_POLLUTION_PER_CAMPFIRE = 0.05; private static final double GROUND_POLLUTION_PER_CAMPFIRE = 0.05;
private static final int PLAYER_CHECK_INTERVAL = 20; private static final int PLAYER_CHECK_INTERVAL = 20;
/** Re-scan water bodies every ~10 min so player-built pools are detected promptly. */
private static final int WATER_BODY_RESCAN_INTERVAL = 600; // × PLAYER_CHECK_INTERVAL ticks
/** Passive mobs suppressed in regions with ecosystem health below this. */ /** Passive mobs suppressed in regions with ecosystem health below this. */
private static final double PASSIVE_SUPPRESS_HEALTH = 30.0; private static final double PASSIVE_SUPPRESS_HEALTH = 30.0;
@@ -93,7 +95,9 @@ public class LivingWorldMod {
private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>(); private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>();
private final Map<UUID, Boolean> playerRainState = new HashMap<>(); private final Map<UUID, Boolean> playerRainState = new HashMap<>();
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>(); private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
private final Set<RegionCoordinate> waterBodyInitialized = new HashSet<>(); /** Stores playerCheckTick value when a region was last water-body-scanned. */
private final Map<RegionCoordinate, Integer> waterBodyLastScan = new HashMap<>();
private final Set<RegionCoordinate> elevationInitialized = new HashSet<>();
public LivingWorldMod(IEventBus eventBus) { public LivingWorldMod(IEventBus eventBus) {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting..."); LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting...");
@@ -128,7 +132,8 @@ public class LivingWorldMod {
playerRegionCache.clear(); playerRegionCache.clear();
playerRainState.clear(); playerRainState.clear();
biomeInitialized.clear(); biomeInitialized.clear();
waterBodyInitialized.clear(); waterBodyLastScan.clear();
elevationInitialized.clear();
this.minecraftServer = null; this.minecraftServer = null;
}); });
@@ -168,10 +173,18 @@ public class LivingWorldMod {
// Step 4: Agriculture — bone meal boosts soil fertility. // Step 4: Agriculture — bone meal boosts soil fertility.
NeoForge.EVENT_BUS.addListener(PlayerInteractEvent.RightClickBlock.class, event -> { NeoForge.EVENT_BUS.addListener(PlayerInteractEvent.RightClickBlock.class, event -> {
if (!bootstrap.isServerReady()) return; if (!bootstrap.isServerReady()) return;
if (!event.getItemStack().is(Items.BONE_MEAL)) return;
if (!(event.getLevel() instanceof ServerLevel level)) return; if (!(event.getLevel() instanceof ServerLevel level)) return;
BlockPos pos = event.getPos(); BlockPos pos = event.getPos();
bootstrap.handleBoneMeal(level.dimension().location().toString(), pos.getX(), pos.getZ()); if (event.getItemStack().is(Items.BONE_MEAL)) {
bootstrap.handleBoneMeal(level.dimension().location().toString(), pos.getX(), pos.getZ());
} else if (event.getItemStack().is(Items.WATER_BUCKET)) {
// Player placed a water source — invalidate the water-body scan for this region
// so the next player-check cycle detects the new water body.
RegionCoordinate coord = RegionCoordinate.fromBlock(
level.dimension().location().toString(), pos.getX(), pos.getZ(),
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
waterBodyLastScan.remove(coord);
}
}); });
// Step 4: Agriculture — harvesting fully-grown crops drains soil fertility. // Step 4: Agriculture — harvesting fully-grown crops drains soil fertility.
@@ -240,22 +253,34 @@ public class LivingWorldMod {
bootstrap.notifyPlayerInRegion(coord); bootstrap.notifyPlayerInRegion(coord);
if (player.level() instanceof ServerLevel serverLevel) { if (player.level() instanceof ServerLevel serverLevel) {
// Biome-aware succession cap — derived once per region. // Biome-aware succession cap — derived once per region on first entry.
if (!biomeInitialized.contains(coord)) { if (!biomeInitialized.contains(coord)) {
SuccessionStage cap = deriveBiomeCap(serverLevel, coord); SuccessionStage cap = deriveBiomeCap(serverLevel, coord);
bootstrap.setRegionBiomeCap(coord, cap); bootstrap.setRegionBiomeCap(coord, cap);
biomeInitialized.add(coord); biomeInitialized.add(coord);
} }
// Water body passive boost — scanned once per region. // Elevation sampling — derived once per region; feeds water runoff physics.
if (!waterBodyInitialized.contains(coord)) { if (!elevationInitialized.contains(coord)) {
if (hasWaterBody(serverLevel, coord)) { double elev = sampleRegionElevation(serverLevel, coord);
bootstrap.applyWaterBodyBoost(coord); bootstrap.setRegionElevation(coord, elev);
} elevationInitialized.add(coord);
waterBodyInitialized.add(coord);
} }
} }
} }
// Water body scan — periodic so player-built pools are detected within ~10 min.
if (player.level() instanceof ServerLevel serverLevel) {
Integer lastScan = waterBodyLastScan.get(coord);
boolean needsScan = lastScan == null
|| (playerCheckTick - lastScan) >= WATER_BODY_RESCAN_INTERVAL;
if (needsScan) {
if (hasWaterBody(serverLevel, coord)) {
bootstrap.applyWaterBodyBoost(coord);
}
waterBodyLastScan.put(coord, playerCheckTick);
}
}
// Regional weather — send per-player rain/thunder packets so each region // Regional weather — send per-player rain/thunder packets so each region
// has its own sky independently of global Minecraft weather. // has its own sky independently of global Minecraft weather.
AtmosphereRegionData atm = bootstrap.getRegionalWeather(coord).orElse(null); AtmosphereRegionData atm = bootstrap.getRegionalWeather(coord).orElse(null);
@@ -315,6 +340,21 @@ public class LivingWorldMod {
private static final int REGION_BLOCKS = private static final int REGION_BLOCKS =
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16; LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16;
/** Samples 25 random surface heights in the region and returns the average Y. */
private double sampleRegionElevation(ServerLevel level, RegionCoordinate coord) {
int baseX = coord.x() * REGION_BLOCKS;
int baseZ = coord.z() * REGION_BLOCKS;
double total = 0;
int count = 0;
for (int i = 0; i < 25; i++) {
int x = baseX + random.nextInt(REGION_BLOCKS);
int z = baseZ + random.nextInt(REGION_BLOCKS);
total += level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z);
count++;
}
return count > 0 ? total / count : 64.0;
}
/** Scans 30 random surface positions in the region; returns true if ≥5 are water. */ /** Scans 30 random surface positions in the region; returns true if ≥5 are water. */
private boolean hasWaterBody(ServerLevel level, RegionCoordinate coord) { private boolean hasWaterBody(ServerLevel level, RegionCoordinate coord) {
int baseX = coord.x() * REGION_BLOCKS; int baseX = coord.x() * REGION_BLOCKS;
@@ -88,6 +88,8 @@ public final class LivingWorldBootstrap {
private LongSupplier absoluteDaySupplier = () -> 0L; private LongSupplier absoluteDaySupplier = () -> 0L;
private boolean initialized; private boolean initialized;
private boolean serverReady; private boolean serverReady;
/** Average surface elevation (block Y) per region, sampled once by the platform layer. */
private final Map<RegionCoordinate, Double> regionElevations = new HashMap<>();
/** /**
* Called once during mod construction. * Called once during mod construction.
@@ -209,6 +211,7 @@ public final class LivingWorldBootstrap {
moduleRegistry.shutdownAll(); moduleRegistry.shutdownAll();
climateTracker.save(worldSaveDirectory.resolve("living_world/global_climate.dat")); climateTracker.save(worldSaveDirectory.resolve("living_world/global_climate.dat"));
hudEnabledPlayers.clear(); hudEnabledPlayers.clear();
regionElevations.clear();
serverReady = false; serverReady = false;
LivingWorldLogger.info( LivingWorldLogger.info(
DiagnosticCategory.BOOTSTRAP, DiagnosticCategory.BOOTSTRAP,
@@ -344,6 +347,8 @@ public final class LivingWorldBootstrap {
applySeasonalEffects(); applySeasonalEffects();
climateTracker.update(regionManager.getActiveRegions()); climateTracker.update(regionManager.getActiveRegions());
applyClimateWarmingEffects(); applyClimateWarmingEffects();
applyWaterRunoff();
applyDynamicCapUpdate();
regionManager.saveDirtyRegions(); regionManager.saveDirtyRegions();
} }
} }
@@ -431,6 +436,122 @@ public final class LivingWorldBootstrap {
} }
} }
/**
* Stores the sampled average surface elevation for a region so that
* {@link #applyWaterRunoff()} can model water flowing downhill between neighbours.
* Called once per region by the platform layer when a player first enters it.
*/
public void setRegionElevation(RegionCoordinate coord, double elevation) {
if (coord != null) regionElevations.put(coord, elevation);
}
/**
* Transfers water availability from higher-elevation regions to lower neighbours.
* Simulates rainfall runoff: valleys collect water, hilltops drain. The transfer
* only occurs while regional rain is falling (rain level > 0.1) so dry seasons
* don't create permanent hydration differences from a single past rain event.
*/
private void applyWaterRunoff() {
Collection<Region> active = regionManager.getActiveRegions();
if (active.size() < 2) return;
Map<RegionCoordinate, Region> byCoord = new HashMap<>();
for (Region r : active) byCoord.put(r.getCoordinate(), r);
int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (Region region : active) {
RegionCoordinate coord = region.getCoordinate();
Double myElevation = regionElevations.get(coord);
if (myElevation == null) continue;
AtmosphereRegionData myAtm = region.getModuleData()
.get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class)
.orElse(null);
if (myAtm == null || myAtm.getRainLevel() < 0.1) continue;
WaterRegionData myWater = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class)
.orElse(null);
if (myWater == null) continue;
for (int[] off : offsets) {
RegionCoordinate nCoord = new RegionCoordinate(
coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]);
Region neighbour = byCoord.get(nCoord);
if (neighbour == null) continue;
Double nElevation = regionElevations.get(nCoord);
if (nElevation == null) continue;
double heightDiff = myElevation - nElevation;
if (heightDiff <= 2.0) continue; // only meaningful slopes trigger runoff
// Transfer rate: proportional to slope and rain, capped at a small value
// so the simulation doesn't drain a region in one cycle.
double runoff = Math.min(heightDiff / 200.0, 0.2) * myAtm.getRainLevel();
myWater.setWaterAvailability(Math.max(0, myWater.getWaterAvailability() - runoff * 0.4));
WaterRegionData nWater = neighbour.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class)
.orElse(null);
if (nWater == null) continue;
nWater.setWaterAvailability(Math.min(100, nWater.getWaterAvailability() + runoff));
nWater.setDroughtRisk(Math.max(0, nWater.getDroughtRisk() - runoff * 0.5));
neighbour.getModuleData().put(WaterModule.MODULE_ID, nWater);
regionManager.markDirty(neighbour);
}
region.getModuleData().put(WaterModule.MODULE_ID, myWater);
regionManager.markDirty(region);
}
}
/**
* Raises the succession cap for regions where physical conditions have improved enough
* to support more advanced ecosystems. The cap can only increase — regression is handled
* separately by damage accumulation within {@link com.livingworld.modules.recovery.RecoveryModule}.
*
* <p>This is the mechanism that allows a desert (initial cap = SPARSE_GRASS) to eventually
* support a MATURE_FOREST if the player (or natural runoff) sustains good water and soil
* conditions for long enough.
*/
private void applyDynamicCapUpdate() {
for (Region region : regionManager.getActiveRegions()) {
RecoveryRegionData recovery = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
.orElse(null);
if (recovery == null) continue;
WaterRegionData water = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class)
.orElse(null);
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElse(null);
if (water == null || soil == null) continue;
SuccessionStage current = recovery.getMaxSuccessionStage();
SuccessionStage computed = computeDynamicCap(water, soil);
if (computed.ordinal() > current.ordinal()) {
recovery.setMaxSuccessionStage(computed);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
regionManager.markDirty(region);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
"Region " + region.getCoordinate() + " succession cap raised to " + computed);
}
}
}
private static SuccessionStage computeDynamicCap(WaterRegionData water, SoilRegionData soil) {
// Net water score: availability minus half the drought penalty.
double w = water.getWaterAvailability() - water.getDroughtRisk() * 0.5;
// Net soil score: fertility minus half the contamination penalty.
double s = soil.getFertility() - soil.getContamination() * 0.5;
if (w >= 60 && s >= 60) return SuccessionStage.MATURE_FOREST;
if (w >= 42 && s >= 42) return SuccessionStage.YOUNG_WOODLAND;
if (w >= 28 && s >= 28) return SuccessionStage.GRASSLAND;
if (w >= 15 || s >= 15) return SuccessionStage.SCRUBLAND;
return SuccessionStage.SPARSE_GRASS;
}
/** Returns the current atmospheric state for a region, if it has been initialised. */ /** Returns the current atmospheric state for a region, if it has been initialised. */
public Optional<AtmosphereRegionData> getRegionalWeather(RegionCoordinate coord) { public Optional<AtmosphereRegionData> getRegionalWeather(RegionCoordinate coord) {
if (!serverReady || coord == null) return Optional.empty(); if (!serverReady || coord == null) return Optional.empty();
@@ -476,12 +597,20 @@ public final class LivingWorldBootstrap {
RegionCoordinate coord = RegionCoordinate.fromBlock( RegionCoordinate coord = RegionCoordinate.fromBlock(
dimId, (int) pos.x, (int) pos.z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); dimId, (int) pos.x, (int) pos.z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
Season season = getCurrentSeason(); Season season = getCurrentSeason();
return regionManager.resolve(coord) Optional<Region> regionOpt = regionManager.resolve(coord);
.flatMap(r -> r.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class)) String successionLine = regionOpt.flatMap(r -> r.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class))
.map(rec -> String.format(" | Stage: %s (cap: %s)",
rec.getSuccessionStage().name(), rec.getMaxSuccessionStage().name()))
.orElse("");
Double elev = regionElevations.get(coord);
String elevLine = elev != null ? String.format(" | Elev: %.0f", elev) : "";
return regionOpt.flatMap(r -> r.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class))
.map(atm -> String.format( .map(atm -> String.format(
"Region (%d,%d) | Season: %s | Rain: %.0f%% | Storm: %.0f%%", "Region (%d,%d) | Season: %s | Rain: %.0f%% | Storm: %.0f%%%s%s",
coord.x(), coord.z(), season.displayName(), coord.x(), coord.z(), season.displayName(),
atm.getRainLevel() * 100, atm.getThunderLevel() * 100)) atm.getRainLevel() * 100, atm.getThunderLevel() * 100,
successionLine, elevLine))
.orElse(String.format("Region (%d,%d) — atmosphere not yet computed. Season: %s", .orElse(String.format("Region (%d,%d) — atmosphere not yet computed. Season: %s",
coord.x(), coord.z(), season.displayName())); coord.x(), coord.z(), season.displayName()));
} }
@@ -711,14 +840,16 @@ public final class LivingWorldBootstrap {
(data, w) -> { (data, w) -> {
RecoveryRegionData d = data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class) RecoveryRegionData d = data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
.orElseGet(RecoveryRegionData::defaults); .orElseGet(RecoveryRegionData::defaults);
w.writeString("successionStage", d.getSuccessionStage().name()); w.writeString("successionStage", d.getSuccessionStage().name());
w.writeDouble("recoveryProgress", d.getRecoveryProgress()); w.writeDouble("recoveryProgress", d.getRecoveryProgress());
w.writeDouble("damageAccumulation", d.getDamageAccumulation()); w.writeDouble("damageAccumulation", d.getDamageAccumulation());
w.writeString("maxSuccessionStage", d.getMaxSuccessionStage().name());
}, },
(r, data) -> data.put(RecoveryModule.MODULE_ID, new RecoveryRegionData( (r, data) -> data.put(RecoveryModule.MODULE_ID, new RecoveryRegionData(
SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())), SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())),
r.readDouble("recoveryProgress", 0.0), r.readDouble("recoveryProgress", 0.0),
r.readDouble("damageAccumulation", 0.0)))); r.readDouble("damageAccumulation", 0.0),
SuccessionStage.valueOf(r.readString("maxSuccessionStage", SuccessionStage.MATURE_FOREST.name())))));
service.registerModuleCodec( service.registerModuleCodec(
EcosystemModule.MODULE_ID, EcosystemModule.MODULE_ID,
@@ -45,4 +45,12 @@ public enum WorldEffectType {
* on flammable surface blocks and spreads naturally via Minecraft fire tick. * on flammable surface blocks and spreads naturally via Minecraft fire tick.
*/ */
WILDFIRE, WILDFIRE,
/**
* Heavy regional rain on low-succession (barren / sparse-grass) terrain causes
* water to pool in low-lying spots. The platform adapter places water source blocks
* in surface depressions. Once present, the water body scan detects them and feeds
* back a hydration boost to the region, enabling succession toward fertile land.
*/
WATER_POOL_FORMS,
} }
@@ -206,6 +206,18 @@ public final class WorldEffectsModule implements SimulationModule {
emitted = true; emitted = true;
} }
// --- Effect 7: rain pools in arid low-succession terrain ---
// Heavy regional rain on barren/sparse-grass land cannot drain into vegetation;
// water collects in depressions, forming puddles visible as actual water blocks.
if (recovery != null && atm != null
&& recovery.getSuccessionStage().ordinal() <= SuccessionStage.SPARSE_GRASS.ordinal()
&& atm.getRainLevel() > 0.3) {
double intensity = computeIntensity(atm.getRainLevel() - 0.3, 0.5);
emit(new WorldEffectRequest(
WorldEffectType.WATER_POOL_FORMS, region.getCoordinate(), Math.max(0.1, intensity)));
emitted = true;
}
return emitted ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange(); return emitted ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
} }
@@ -8,6 +8,7 @@ import com.livingworld.modules.worldeffects.WorldEffectRequest;
import java.util.Random; import java.util.Random;
import java.util.function.Supplier; import java.util.function.Supplier;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.particles.ParticleTypes; import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceKey;
@@ -69,6 +70,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred
case WILDFIRE -> case WILDFIRE ->
igniteVegetation(level, baseX, baseZ, request.intensity()); igniteVegetation(level, baseX, baseZ, request.intensity());
case WATER_POOL_FORMS ->
formWaterPool(level, baseX, baseZ, request.intensity());
} }
} }
@@ -185,6 +188,55 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
} }
} }
/**
* Places water source blocks in the lowest depression found among random surface samples.
* Fires ~5% of times it is called so pools build gradually rather than flooding the region.
* Once the water body scan detects these blocks the region gains a hydration boost that
* drives succession — the core mechanism behind desert→grassland transformation.
*/
private void formWaterPool(ServerLevel level, int baseX, int baseZ, double intensity) {
if (random.nextDouble() > 0.05) return; // throttle: ~1 pool placement per 20 calls
// Find the lowest surface point among a set of random samples (depression heuristic).
int samples = Math.max(8, (int) (intensity * 20));
BlockPos lowestPos = null;
for (int i = 0; i < samples; i++) {
BlockPos candidate = surfaceAt(level,
baseX + random.nextInt(REGION_BLOCKS),
baseZ + random.nextInt(REGION_BLOCKS));
if (candidate != null && (lowestPos == null || candidate.getY() < lowestPos.getY())) {
lowestPos = candidate;
}
}
if (lowestPos == null) return;
// Only pool on permeable/soft surfaces — not stone floors.
var surface = level.getBlockState(lowestPos);
if (!surface.is(Blocks.SAND) && !surface.is(Blocks.RED_SAND)
&& !surface.is(Blocks.GRAVEL) && !surface.is(Blocks.DIRT)
&& !surface.is(Blocks.COARSE_DIRT) && !surface.is(Blocks.GRASS_BLOCK)) return;
BlockPos poolPos = lowestPos.above();
if (!level.isLoaded(poolPos)) return;
var poolState = level.getBlockState(poolPos);
if (!poolState.isAir() && !poolState.is(Blocks.WATER)) return;
level.setBlock(poolPos, Blocks.WATER.defaultBlockState(), Block.UPDATE_ALL);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect WATER_POOL_FORMS at " + poolPos);
// At high intensity the pool widens into adjacent depressions at the same height.
if (intensity > 0.6) {
for (Direction dir : Direction.Plane.HORIZONTAL) {
BlockPos adj = poolPos.relative(dir);
if (level.isLoaded(adj) && level.getBlockState(adj).isAir()
&& level.getBlockState(adj.below()).isSolid()
&& adj.getY() <= poolPos.getY()) {
level.setBlock(adj, Blocks.WATER.defaultBlockState(), Block.UPDATE_ALL);
}
}
}
}
private void spawnPollutionParticles( private void spawnPollutionParticles(
ServerLevel level, int baseX, int baseZ, double intensity) { ServerLevel level, int baseX, int baseZ, double intensity) {
int count = Math.max(1, (int) (intensity * 8)); int count = Math.max(1, (int) (intensity * 8));