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:
@@ -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 1–2) 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°).
|
||||
|
||||
Reference in New Issue
Block a user