Simulate everywhere via ChunkEvent.Load; realistic moon-phase tides; mob currents

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 <noreply@anthropic.com>
This commit is contained in:
George
2026-06-10 18:14:25 +01:00
parent fde7264815
commit 54a6be91de
2 changed files with 195 additions and 70 deletions
+192 -70
View File
@@ -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<net.minecraft.world.level.biome.Biome> 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:
*
* <ol>
* <li><b>River currents</b> — 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 12) are gentle.</li>
* <li><b>Ocean/tidal currents</b> — 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).</li>
* <li><b>Mob currents</b> — animals, monsters and other mobs within 48 blocks of a
* player are also affected by river flow so they can be carried downstream.</li>
* </ol>
*/
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:
* <ul>
* <li>Two full tidal cycles per Minecraft day (sine wave)</li>
* <li>Moon-phase amplitude: spring tides at full moon (±3 blocks), neap at new moon (±1.5 blocks)</li>
* </ul>
* For every loaded coastal region it:
* <ol>
* <li>Adjusts coastal region water-availability scores so droughts ease during high tide.</li>
* <li>Physically places/removes water source blocks at the shoreline so the tidal
* boundary moves visibly up and down by one block over the cycle.</li>
* <li>Adjusts water-availability scores in the data model.</li>
* <li>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.</li>
* </ol>
*/
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);
}
}
}
}
@@ -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°).