From 54a6be91de8a9ab6451d3de1578d1fd9d8d395bb Mon Sep 17 00:00:00 2001 From: George Date: Wed, 10 Jun 2026 18:14:25 +0100 Subject: [PATCH] Simulate everywhere via ChunkEvent.Load; realistic moon-phase tides; mob currents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit World simulation no longer tied to player proximity: - ChunkEvent.Load listener registers a region for EVERY chunk loaded in ANY dimension — player movement, spawn chunks, forceloaded areas, entities dragging chunks into memory — all trigger simulation automatically - Elevation sampled from the loaded chunk itself (4 points within its 16×16 bounds) so we never query unloaded chunk heights - Biome also sampled from the loaded chunk; succession cap and soil/water init fire immediately on first load - Spawn region pre-activation (5×5 grid around origin) on ServerStartedEvent covers the always-loaded spawn chunks that never fire ChunkEvent.Load Realistic tidal system (was: 12 random scans, ±1 block, every 60 s): - Interval: 200 ticks (~10 s) for visibly smooth waterline movement - Amplitude: ±2.5 × moon-phase factor — full moon = ±3.5 blocks (spring tide), new moon = ±1.5 blocks (neap tide); players will notice the moon matters - Coverage: 8×8 systematic grid with per-cell jitter = 64 positions per region scanning the full intertidal zone (seaLevel ± amplitude + 1), not just one Y slice - Logic: place water when Y ≤ tideHeight, drain source blocks when Y > tideHeight, only on natural base blocks (sand/gravel/stone/sandstone/grass/dirt) Three-part current physics (applyRiverCurrents every 4 ticks): 1. River: FluidState.getFlow() → player velocity in flowing water (unchanged) 2. Ocean: mid-tide push (tidal current ∝ derivative of tide) applied to players in source blocks in coastal regions; direction follows wind angle 3. Mobs: animals/monsters within 48 blocks of a player also pushed by river flow (swept downstream, natural behavior for animals crossing rivers) Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/livingworld/LivingWorldMod.java | 262 +++++++++++++----- .../bootstrap/LivingWorldBootstrap.java | 3 + 2 files changed, 195 insertions(+), 70 deletions(-) diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index d4f291e..140b596 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -9,6 +9,8 @@ import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.entity.living.FinalizeSpawnEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; import net.neoforged.neoforge.event.level.BlockEvent; +import net.neoforged.neoforge.event.level.ChunkEvent; +import net.minecraft.world.level.ChunkPos; import net.neoforged.neoforge.event.server.ServerStartedEvent; import net.neoforged.neoforge.event.server.ServerStartingEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent; @@ -95,11 +97,14 @@ public class LivingWorldMod { /** Hostile mobs suppressed in regions with ecosystem health above this. */ private static final double HOSTILE_SUPPRESS_HEALTH = 60.0; - private static final int TIDE_CHECK_INTERVAL = 1200; // ~1 real minute - /** Apply river current forces to players every N ticks. */ + /** Tidal block updates run every 10 seconds for smooth, visible waterline movement. */ + private static final int TIDE_CHECK_INTERVAL = 200; + /** Apply river current forces to players/mobs every N ticks. */ private static final int RIVER_CURRENT_INTERVAL = 4; /** Additional horizontal push (m/tick²) per unit of normalised flow speed. */ private static final double CURRENT_STRENGTH = 0.045; + /** Ocean tidal current push per unit of tidal velocity. */ + private static final double TIDAL_CURRENT_STRENGTH = 0.007; private final LivingWorldBootstrap bootstrap; private final Random random = new Random(); @@ -149,6 +154,62 @@ public class LivingWorldMod { () -> minecraftServer != null && minecraftServer.overworld().isRaining()); bootstrap.setAbsoluteDaySupplier( () -> minecraftServer != null ? minecraftServer.overworld().getGameTime() / 24000L : 0L); + // Pre-activate the central regions near spawn — these are always loaded as + // spawn chunks and will never trigger ChunkEvent.Load after startup. + ServerLevel overworld = event.getServer().overworld(); + for (int rx = -2; rx <= 2; rx++) { + for (int rz = -2; rz <= 2; rz++) { + RegionCoordinate coord = new RegionCoordinate("minecraft:overworld", rx, rz); + bootstrap.notifyPlayerInRegion(coord); + if (!elevationInitialized.contains(coord)) { + double elev = sampleRegionElevation(overworld, coord); + bootstrap.setRegionElevation(coord, elev); + elevationInitialized.add(coord); + bootstrap.setRegionBiomeCap(coord, deriveBiomeCap(overworld, coord)); + initializeRegionFromBiome(overworld, coord); + biomeInitialized.add(coord); + } + } + } + }); + + // Register regions for EVERY chunk that loads in ANY dimension, for any reason + // (player movement, spawn chunks, forceloaded chunks, entity-driven loads, etc.). + // This is what makes the simulation run everywhere, not just near players. + NeoForge.EVENT_BUS.addListener(ChunkEvent.Load.class, event -> { + if (!bootstrap.isServerReady()) return; + if (!(event.getLevel() instanceof ServerLevel level)) return; + ChunkPos cp = event.getChunk().getPos(); + String dimId = level.dimension().location().toString(); + int regionX = Math.floorDiv(cp.x, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); + int regionZ = Math.floorDiv(cp.z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); + RegionCoordinate coord = new RegionCoordinate(dimId, regionX, regionZ); + bootstrap.notifyPlayerInRegion(coord); + // Sample elevation and biome from THIS loaded chunk so we never read + // unloaded chunk data (getHeight is safe only for loaded chunks). + if (!elevationInitialized.contains(coord)) { + // Quick 4-sample elevation estimate from the loaded chunk itself + double total = 0; + for (int i = 0; i < 4; i++) { + total += level.getHeight(Heightmap.Types.WORLD_SURFACE, + cp.getMinBlockX() + random.nextInt(16), + cp.getMinBlockZ() + random.nextInt(16)); + } + bootstrap.setRegionElevation(coord, total / 4.0); + elevationInitialized.add(coord); + } + if (!biomeInitialized.contains(coord)) { + int bx = cp.getMiddleBlockX(); + int bz = cp.getMiddleBlockZ(); + int by = level.getHeight(Heightmap.Types.WORLD_SURFACE, bx, bz); + net.minecraft.core.Holder biome = + level.getBiome(new BlockPos(bx, by, bz)); + float temp = biome.value().getBaseTemperature(); + float downfall = biome.value().getModifiedClimateSettings().downfall(); + bootstrap.initializeRegionBiomeValues(coord, temp, downfall); + bootstrap.setRegionBiomeCap(coord, deriveBiomeCap(level, coord)); + biomeInitialized.add(coord); + } }); NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> { bootstrap.onServerStopping(); @@ -400,32 +461,79 @@ public class LivingWorldMod { } /** - * Applies realistic river current forces to players and non-player entities standing - * in flowing water. Uses {@link net.minecraft.world.level.material.FluidState#getFlow} - * — the same directional vector Minecraft computes internally — so currents always - * follow the actual water flow path, not a simulation approximation. - * Mountain rivers (level 7, close to source) push hard; lowland channels (level 1-2) - * are gentle enough to wade through. Heavy armour players notice the difference. + * Three-part current system applied every {@link #RIVER_CURRENT_INTERVAL} ticks: + * + *
    + *
  1. River currents — players and nearby mobs in flowing water are pushed by + * {@link net.minecraft.world.level.material.FluidState#getFlow}, the exact flow + * vector Minecraft uses internally. Mountain rivers (level 7) push hard; lowland + * channels (level 1–2) are gentle.
  2. + *
  3. Ocean/tidal currents — during mid-tide (when the tide is changing fastest) + * players standing in ocean source blocks are pushed proportionally to the tidal + * velocity, in the wind direction (which approximates surface-current direction).
  4. + *
  5. Mob currents — animals, monsters and other mobs within 48 blocks of a + * player are also affected by river flow so they can be carried downstream.
  6. + *
*/ private void applyRiverCurrents() { if (minecraftServer == null) return; + + // -- Pre-compute tidal current for this tick -- + ServerLevel overworld = minecraftServer.overworld(); + long dayTime = overworld.getDayTime(); + int seaLevel = overworld.getSeaLevel(); + int moonPhase = overworld.getMoonPhase(); + double springNeap = 1.0 + 0.40 * Math.cos(moonPhase * Math.PI / 4.0); + // Tidal current magnitude = derivative of tide height (max at mid-tide, zero at high/low) + double tidalCurrentMag = Math.cos(dayTime / 12000.0 * Math.PI) * springNeap; + double windAngle = bootstrap.getWindAngle(); + double tidalPushX = Math.cos(windAngle) * tidalCurrentMag * TIDAL_CURRENT_STRENGTH; + double tidalPushZ = Math.sin(windAngle) * tidalCurrentMag * TIDAL_CURRENT_STRENGTH; + boolean hasTidalCurrent = Math.abs(tidalCurrentMag) > 0.1; + for (ServerPlayer player : minecraftServer.getPlayerList().getPlayers()) { if (!(player.level() instanceof ServerLevel level)) continue; BlockPos pos = player.blockPosition(); var fluid = level.getFluidState(pos); - // Only flowing water — source blocks carry no directional current - if (!fluid.is(FluidTags.WATER) || fluid.isSource()) continue; - net.minecraft.world.phys.Vec3 flowVec = fluid.getFlow(level, pos); - if (flowVec.lengthSqr() < 0.0001) continue; - // Fluid amount: 8 = source, decreases with distance. - // Amount 7 = one block from source (fastest); amount 1 = farthest (slowest). - double flowStrength = fluid.getAmount() / 8.0; - double force = CURRENT_STRENGTH * flowStrength; - var movement = player.getDeltaMovement(); - player.setDeltaMovement( - movement.x + flowVec.x * force, - movement.y, - movement.z + flowVec.z * force); + + if (fluid.is(FluidTags.WATER)) { + if (!fluid.isSource()) { + // 1. River current: flowing water has a directional vector + var flowVec = fluid.getFlow(level, pos); + if (flowVec.lengthSqr() > 0.0001) { + double force = CURRENT_STRENGTH * (fluid.getAmount() / 8.0); + var mv = player.getDeltaMovement(); + player.setDeltaMovement(mv.x + flowVec.x * force, mv.y, mv.z + flowVec.z * force); + } + } else if (hasTidalCurrent) { + // 2. Ocean/tidal current: source water in coastal regions + RegionCoordinate coord = new RegionCoordinate( + level.dimension().location().toString(), + (int) Math.floor(player.getX() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0)), + (int) Math.floor(player.getZ() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0))); + Double elev = bootstrap.getRegionElevation(coord); + if (elev != null && elev <= seaLevel + 6) { + var mv = player.getDeltaMovement(); + player.setDeltaMovement(mv.x + tidalPushX, mv.y, mv.z + tidalPushZ); + } + } + } + + // 3. Push nearby mobs in flowing water (animals getting swept downstream, etc.) + if (!(player.level() instanceof ServerLevel entityLevel)) continue; + var nearbyMobs = entityLevel.getEntitiesOfClass( + net.minecraft.world.entity.LivingEntity.class, + player.getBoundingBox().inflate(48), + e -> !(e instanceof ServerPlayer)); + for (var mob : nearbyMobs) { + var mobFluid = entityLevel.getFluidState(mob.blockPosition()); + if (!mobFluid.is(FluidTags.WATER) || mobFluid.isSource()) continue; + var mobFlow = mobFluid.getFlow(entityLevel, mob.blockPosition()); + if (mobFlow.lengthSqr() < 0.0001) continue; + double mobForce = CURRENT_STRENGTH * (mobFluid.getAmount() / 8.0) * 0.55; + var mm = mob.getDeltaMovement(); + mob.setDeltaMovement(mm.x + mobFlow.x * mobForce, mm.y, mm.z + mobFlow.z * mobForce); + } } } @@ -489,78 +597,92 @@ public class LivingWorldMod { // ----------------------------------------------------------------------- /** - * Runs every {@link #TIDE_CHECK_INTERVAL} ticks. Computes the current tidal level - * from the in-game time (two full tidal cycles per Minecraft day) and: + * Runs every {@link #TIDE_CHECK_INTERVAL} ticks (~10 seconds). Computes the current + * tidal height using: + *
    + *
  • Two full tidal cycles per Minecraft day (sine wave)
  • + *
  • Moon-phase amplitude: spring tides at full moon (±3 blocks), neap at new moon (±1.5 blocks)
  • + *
+ * For every loaded coastal region it: *
    - *
  1. Adjusts coastal region water-availability scores so droughts ease during high tide.
  2. - *
  3. Physically places/removes water source blocks at the shoreline so the tidal - * boundary moves visibly up and down by one block over the cycle.
  4. + *
  5. Adjusts water-availability scores in the data model.
  6. + *
  7. Runs a systematic 8×8 grid scan across the intertidal zone (sea level ± amplitude) + * placing and removing water source blocks to produce a clearly visible waterline.
  8. *
*/ private void updateTidalEffects() { if (minecraftServer == null) return; ServerLevel overworld = minecraftServer.overworld(); long dayTime = overworld.getDayTime(); - // Two complete tidal cycles per Minecraft day (24 000 ticks) - double tideLevel = Math.sin(dayTime / 12000.0 * Math.PI); // -1 low … +1 high - double delta = tideLevel - lastTideLevel; - lastTideLevel = tideLevel; - int seaLevel = overworld.getSeaLevel(); - boolean risingTide = delta > 0.05; - boolean fallingTide = delta < -0.05; + + // Moon phase (0=full, 4=new); full moon = maximum spring tides + int moonPhase = overworld.getMoonPhase(); + double springNeapFactor = 1.0 + 0.40 * Math.cos(moonPhase * Math.PI / 4.0); + // Full moon: 1.40, Quarter moons: 1.0, New moon: 0.60 + double maxAmplitude = 2.5 * springNeapFactor; // max ±3.5 at full moon, ±1.5 at new moon + + // Two tidal cycles per Minecraft day; tideHeight is the Y of the waterline + double tideHeight = seaLevel + Math.sin(dayTime / 12000.0 * Math.PI) * maxAmplitude; + double delta = tideHeight - lastTideLevel; + lastTideLevel = tideHeight; for (Region region : bootstrap.getActiveRegions()) { if (!region.getCoordinate().dimensionId().equals("minecraft:overworld")) continue; Double elev = bootstrap.getRegionElevation(region.getCoordinate()); - if (elev == null || elev > seaLevel + 6) continue; // only coastal/ocean + if (elev == null || elev > seaLevel + (int) maxAmplitude + 4) continue; // coastal only - // Update data model (water availability) - bootstrap.applyTidalEffect(region.getCoordinate(), delta * 7.0); + // Data model update (water availability) + bootstrap.applyTidalEffect(region.getCoordinate(), delta * 6.0); - // Physical block changes at the shoreline - if ((risingTide || fallingTide) && Math.abs(delta) > 0.15) { - applyTideBlocks(overworld, region.getCoordinate(), seaLevel, risingTide); - } + // Physical block changes across the full intertidal zone + applyTideBlocks(overworld, region.getCoordinate(), seaLevel, tideHeight); } } /** - * Scans sample positions in the region at sea level and places/removes water blocks - * to simulate the tidal boundary moving by one block. + * Applies the tidal waterline to a coastal region using a systematic 8×8 grid scan + * rather than random sampling. For each grid cell, checks every block in the intertidal + * zone and sets it to water or air based on whether that Y is currently submerged. + * This produces a clearly visible tide that fills and drains the entire shoreline. */ - private void applyTideBlocks(ServerLevel level, RegionCoordinate coord, int seaLevel, boolean rising) { + private void applyTideBlocks(ServerLevel level, RegionCoordinate coord, int seaLevel, double tideHeight) { int baseX = coord.x() * REGION_BLOCKS; int baseZ = coord.z() * REGION_BLOCKS; - int scans = 12; - for (int i = 0; i < scans; i++) { - int x = baseX + random.nextInt(REGION_BLOCKS); - int z = baseZ + random.nextInt(REGION_BLOCKS); - BlockPos tidePos = new BlockPos(x, seaLevel, z); - if (!level.isLoaded(tidePos)) continue; + int step = REGION_BLOCKS / 8; // 32-block grid → 8×8 = 64 positions covering the region + int tideY = (int) Math.round(tideHeight); + int minY = Math.min(seaLevel - 3, tideY - 1); + int maxY = Math.max(seaLevel + 3, tideY + 1); - var stateAtTide = level.getBlockState(tidePos); - var stateBelow = level.getBlockState(tidePos.below()); + for (int xi = 0; xi < 8; xi++) { + for (int zi = 0; zi < 8; zi++) { + // Add small random jitter so consecutive calls don't always hit the same columns + int x = baseX + xi * step + random.nextInt(Math.max(1, step / 2)); + int z = baseZ + zi * step + random.nextInt(Math.max(1, step / 2)); - if (rising) { - // Rising tide: fill air at sea-level above natural shoreline blocks with water - if (stateAtTide.isAir() - && (stateBelow.is(net.minecraft.world.level.block.Blocks.SAND) - || stateBelow.is(net.minecraft.world.level.block.Blocks.GRAVEL) - || stateBelow.is(net.minecraft.world.level.block.Blocks.STONE) - || stateBelow.is(net.minecraft.world.level.block.Blocks.SANDSTONE))) { - level.setBlock(tidePos, net.minecraft.world.level.block.Blocks.WATER.defaultBlockState(), - net.minecraft.world.level.block.Block.UPDATE_ALL); - } - } else { - // Falling tide: remove water source blocks sitting on natural terrain at sea-level - if (stateAtTide.is(net.minecraft.world.level.block.Blocks.WATER) - && stateAtTide.getFluidState().isSource() - && (stateBelow.is(net.minecraft.world.level.block.Blocks.SAND) - || stateBelow.is(net.minecraft.world.level.block.Blocks.GRAVEL) - || stateBelow.is(net.minecraft.world.level.block.Blocks.STONE))) { - level.setBlock(tidePos, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), - net.minecraft.world.level.block.Block.UPDATE_ALL); + for (int y = minY; y <= maxY; y++) { + BlockPos pos = new BlockPos(x, y, z); + if (!level.isLoaded(pos)) continue; + + boolean submerged = y <= tideHeight; + var state = level.getBlockState(pos); + var below = level.getBlockState(pos.below()); + boolean naturalBase = below.is(net.minecraft.world.level.block.Blocks.SAND) + || below.is(net.minecraft.world.level.block.Blocks.GRAVEL) + || below.is(net.minecraft.world.level.block.Blocks.STONE) + || below.is(net.minecraft.world.level.block.Blocks.SANDSTONE) + || below.is(net.minecraft.world.level.block.Blocks.GRASS_BLOCK) + || below.is(net.minecraft.world.level.block.Blocks.DIRT); + + if (submerged && (state.isAir() + || state.is(net.minecraft.world.level.block.Blocks.SHORT_GRASS)) && naturalBase) { + level.setBlock(pos, net.minecraft.world.level.block.Blocks.WATER.defaultBlockState(), + net.minecraft.world.level.block.Block.UPDATE_ALL); + } else if (!submerged && state.is(net.minecraft.world.level.block.Blocks.WATER) + && state.getFluidState().isSource() && naturalBase) { + level.setBlock(pos, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), + net.minecraft.world.level.block.Block.UPDATE_ALL); + } } } } diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index 00db451..1eedc77 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -1411,6 +1411,9 @@ public final class LivingWorldBootstrap { private static double clampD(double v) { return Math.min(100, Math.max(0, v)); } + /** Returns the current wind angle in radians (used for tidal current direction). */ + public double getWindAngle() { return windAngle; } + /** Returns a formatted wind status string for the {@code /lw wind} command. */ public String getWindInfo() { // Map angle to 8-point compass (N=0°, E=90°, S=180°, W=270°).