feat: 集成海龟就地筑巢功能(移植自 turtle-reproduce)
build / build (push) Has been cancelled

- 新增规则 turtleLocalNesting(默认 false)控制开关,关闭时走原版逻辑 & - 搬入 NestingSiteFinder(高性能就近沙滩搜索) & - 搬入 TurtleGoHomeGoalMixin / TurtleLayEggGoalMixin / TurtleAccessor / PathNavigationAccessor & - 原独立 mod turtle-reproduce 的功能并入本 Carpet 扩展 & - 补中英语言描述
This commit is contained in:
AxTps committed 2026-10-03 08:34:32 +08:00
1 parent 6687583c96
commit e1b1bed804
9 files changed
+474 -1

No files matched your search

@@ -68,4 +68,7 @@ public class SLSCarpetSettings {
@Rule(categories = {SLSA, RuleCategory.FEATURE})
public static int trialSpawnerCD = -1;
@Rule(categories = {SLSA, RuleCategory.FEATURE})
public static boolean turtleLocalNesting = false;
}
@@ -0,0 +1,11 @@
package com.github.zly2006.carpetslsaddition.mixin.turtle;
import net.minecraft.world.entity.ai.navigation.PathNavigation;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(PathNavigation.class)
public interface PathNavigationAccessor {
@Accessor("maxVisitedNodesMultiplier")
float turtleReproduce$getMaxVisitedNodesMultiplier();
}
@@ -0,0 +1,22 @@
package com.github.zly2006.carpetslsaddition.mixin.turtle;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.animal.turtle.Turtle;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
import org.spongepowered.asm.mixin.gen.Invoker;
@Mixin(Turtle.class)
public interface TurtleAccessor {
@Invoker("setHasEgg")
void turtleReproduce$setHasEgg(boolean hasEgg);
@Invoker("setLayingEgg")
void turtleReproduce$setLayingEgg(boolean layingEgg);
@Accessor("homePos")
BlockPos turtleReproduce$getHomePos();
@Accessor("goingHome")
boolean turtleReproduce$isGoingHome();
}
@@ -0,0 +1,33 @@
package com.github.zly2006.carpetslsaddition.mixin.turtle;
import com.github.zly2006.carpetslsaddition.SLSCarpetSettings;
import net.minecraft.world.entity.animal.turtle.Turtle;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(targets = "net.minecraft.world.entity.animal.turtle.Turtle$TurtleGoHomeGoal")
public abstract class TurtleGoHomeGoalMixin {
@Shadow @Final
private Turtle turtle;
// This goal has no MOVE flag, so priority alone cannot stop it fighting nesting.
@Inject(method = {"canUse", "canContinueToUse"}, at = @At("HEAD"), cancellable = true)
private void turtleReproduce$stayLocalWhileCarryingEggs(CallbackInfoReturnable<Boolean> cir) {
if (SLSCarpetSettings.turtleLocalNesting && this.turtle.hasEgg()) {
cir.setReturnValue(false);
}
}
@Inject(method = "stop", at = @At("TAIL"))
private void turtleReproduce$discardOldHomeRoute(CallbackInfo ci) {
if (SLSCarpetSettings.turtleLocalNesting && this.turtle.hasEgg()) {
// The vanilla stop only clears goingHome, leaving its last route active.
this.turtle.getNavigation().stop();
}
}
}
@@ -0,0 +1,142 @@
package com.github.zly2006.carpetslsaddition.mixin.turtle;
import com.github.zly2006.carpetslsaddition.SLSCarpetSettings;
import com.github.zly2006.carpetslsaddition.turtle.NestingSiteFinder;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.PathfinderMob;
import net.minecraft.world.entity.ai.goal.MoveToBlockGoal;
import net.minecraft.world.entity.ai.navigation.PathNavigation;
import net.minecraft.world.entity.animal.turtle.Turtle;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.pathfinder.Path;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(targets = "net.minecraft.world.entity.animal.turtle.Turtle$TurtleLayEggGoal")
public abstract class TurtleLayEggGoalMixin extends MoveToBlockGoal {
@Unique private static final int PATH_SEARCH_RANGE = 48;
@Unique private static final int PATH_ATTEMPTS_PER_CHECK = 16;
@Shadow @Final
private Turtle turtle;
@Unique private List<NestingSiteFinder.Candidate> turtleReproduce$candidates = List.of();
@Unique private int turtleReproduce$candidateIndex;
@Unique private BlockPos turtleReproduce$searchOrigin = BlockPos.ZERO;
@Unique private Path turtleReproduce$nestingPath;
protected TurtleLayEggGoalMixin(PathfinderMob mob, double speed, int range) {
super(mob, speed, range);
}
@Inject(method = "canUse", at = @At("HEAD"), cancellable = true)
private void turtleReproduce$findLocalShore(CallbackInfoReturnable<Boolean> cir) {
if (!SLSCarpetSettings.turtleLocalNesting) return;
cir.setReturnValue(this.turtle.hasEgg() && !this.turtle.isBaby() && super.canUse());
}
@Inject(method = "canContinueToUse", at = @At("HEAD"), cancellable = true)
private void turtleReproduce$continueAwayFromHome(CallbackInfoReturnable<Boolean> cir) {
if (!SLSCarpetSettings.turtleLocalNesting) return;
cir.setReturnValue(this.turtle.hasEgg() && super.canContinueToUse());
}
@Inject(method = "isValidTarget", at = @At("HEAD"), cancellable = true)
private void turtleReproduce$requireDrySandNearWater(LevelReader level, BlockPos pos,
CallbackInfoReturnable<Boolean> cir) {
if (!SLSCarpetSettings.turtleLocalNesting) return;
cir.setReturnValue(NestingSiteFinder.isValidSite(level, pos));
}
@Override
protected boolean findNearestBlock() {
if (!SLSCarpetSettings.turtleLocalNesting) {
return super.findNearestBlock();
}
if (this.turtleReproduce$candidateIndex >= this.turtleReproduce$candidates.size()
|| this.turtle.blockPosition().distSqr(this.turtleReproduce$searchOrigin) > 16.0) {
this.turtleReproduce$candidates = NestingSiteFinder.findCandidates(this.turtle);
this.turtleReproduce$candidateIndex = 0;
this.turtleReproduce$searchOrigin = this.turtle.blockPosition();
}
// Spread expensive path checks over AI updates when a shore is inaccessible.
int attempts = 0;
while (this.turtleReproduce$candidateIndex < this.turtleReproduce$candidates.size()
&& attempts++ < PATH_ATTEMPTS_PER_CHECK) {
BlockPos sand = this.turtleReproduce$candidates.get(this.turtleReproduce$candidateIndex++).sandPos();
if (!this.turtle.isWithinHome(sand) || !NestingSiteFinder.isValidSite(this.turtle.level(), sand)) {
continue;
}
Vec3 destination = Vec3.atBottomCenterOf(sand.above());
AABB destinationBounds = this.turtle.getBoundingBox().move(destination.subtract(this.turtle.position()));
// Adult turtles are wider than one block. An air block alone does not
// guarantee that their body is clear of adjacent water or walls.
if (!this.turtle.level().noCollision(this.turtle, destinationBounds)
|| this.turtle.level().containsAnyLiquid(destinationBounds.deflate(0.001))) {
continue;
}
Path path = this.turtleReproduce$createNestingPath(sand.above());
if (path != null && path.canReach()) {
this.blockPos = sand;
this.turtleReproduce$nestingPath = path;
this.turtleReproduce$candidates = List.of();
return true;
}
}
if (this.turtleReproduce$candidateIndex < this.turtleReproduce$candidates.size()) {
this.nextStartTick = 1;
}
return false;
}
@Unique
private Path turtleReproduce$createNestingPath(BlockPos destination) {
PathNavigation navigation = this.turtle.getNavigation();
float previousMultiplier = ((PathNavigationAccessor) navigation).turtleReproduce$getMaxVisitedNodesMultiplier();
// A larger distance alone leaves vanilla's small node budget unchanged,
// incorrectly rejecting shores that require a detour around an obstacle.
navigation.setMaxVisitedNodesMultiplier(Math.max(previousMultiplier, 8.0F));
try {
return navigation.createPath(destination, 0, PATH_SEARCH_RANGE);
} finally {
navigation.setMaxVisitedNodesMultiplier(previousMultiplier);
}
}
@Override
protected void moveMobToBlock() {
if (!SLSCarpetSettings.turtleLocalNesting) {
super.moveMobToBlock();
return;
}
this.turtle.getNavigation().moveTo(this.turtleReproduce$nestingPath, this.speedModifier);
}
@Inject(method = "tick", at = @At("TAIL"))
private void turtleReproduce$retryLongerPath(CallbackInfo ci) {
if (!SLSCarpetSettings.turtleLocalNesting) return;
if (!this.isReachedTarget() && this.tryTicks % 40 == 0 && this.turtle.getNavigation().isDone()) {
this.turtleReproduce$nestingPath = this.turtleReproduce$createNestingPath(this.blockPos.above());
this.moveMobToBlock();
}
}
@Override
public void stop() {
super.stop();
if (!SLSCarpetSettings.turtleLocalNesting) return;
this.turtle.getNavigation().stop();
((TurtleAccessor) this.turtle).turtleReproduce$setLayingEgg(false);
this.turtleReproduce$nestingPath = null;
this.turtleReproduce$candidates = List.of();
}
}
@@ -0,0 +1,254 @@
package com.github.zly2006.carpetslsaddition.turtle;
import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongArrayList;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.entity.animal.turtle.Turtle;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.TurtleEggBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Finds dry nesting blocks beside nearby, exposed source water.
*
* <p>This class was rewritten for throughput. The scan is the hot path: one call
* covers 73 x 73 columns x 19 vertical steps, and a pregnant turtle re-runs it
* whenever it drifts more than four blocks from the previous search origin or
* exhausts its cached candidate list.
*
* <p>Every result-affecting rule is unchanged:
* <ul>
* <li>the same box is visited (32 in X/Z for nests, plus a 4 block halo for
* water, 8 up/down for nests, plus 1 for water);</li>
* <li>the same conditions are tested, in the same order;</li>
* <li>unloaded chunks are still skipped, and are still never loaded or
* generated ({@code loadOrGenerate = false});</li>
* <li>the candidate list is still ordered by the same comparator, which ends
* in the block coordinates and therefore is a total order. Visit order no
* longer matters, so the sort result is identical.</li>
* </ul>
*
* <p>What changed, and why it is equivalent:
* <ul>
* <li>blocks are read through the chunk section that holds them instead of
* {@code Level#getBlockState}, which re-resolves the chunk, the section
* index and the build-height check on every single block. Sections that
* {@code hasOnlyAir()} are skipped entirely: they cannot contribute water
* or sand;</li>
* <li>the same availability test as {@code Level#hasChunk} is used, but the
* resolved chunk is kept for the section reads instead of being thrown
* away and looked up again per block;</li>
* <li>sand positions are collected as packed longs instead of thousands of
* {@code BlockPos} objects, so a scan allocates no per-block garbage;</li>
* <li>{@code isValidSite} hoists its loop invariants and remembers the last
* chunk availability answer; the disk it walks spans at most four chunks,
* so this only removes repeated lookups.</li>
* </ul>
*/
public final class NestingSiteFinder {
public static final int SEARCH_RADIUS = 32;
public static final int VERTICAL_SEARCH_RADIUS = 8;
public static final int WATER_RADIUS = 4;
public static final int WATER_VERTICAL_RADIUS = 1;
private static final Comparator<Candidate> CANDIDATE_ORDER = Comparator
.comparingDouble(Candidate::waterDistanceSquared)
.thenComparingDouble(Candidate::sandDistanceSquared)
.thenComparingInt(candidate -> candidate.sandPos().getX())
.thenComparingInt(candidate -> candidate.sandPos().getY())
.thenComparingInt(candidate -> candidate.sandPos().getZ());
private NestingSiteFinder() {
}
/**
* Searches a square extending 32 blocks in X/Z and 8 blocks in Y from the
* turtle. A four-block halo includes water beside nests at the boundary.
* The caller must check the turtle's collision box and navigation path.
*/
public static List<Candidate> findCandidates(Turtle turtle) {
Level level = turtle.level();
BlockPos origin = turtle.blockPosition();
int originX = origin.getX();
int originY = origin.getY();
int originZ = origin.getZ();
int scanRadius = SEARCH_RADIUS + WATER_RADIUS;
int minY = Math.max(level.getMinY(), originY - VERTICAL_SEARCH_RADIUS - WATER_VERTICAL_RADIUS);
int maxY = Math.min(level.getMaxY() - 1, originY + VERTICAL_SEARCH_RADIUS + WATER_VERTICAL_RADIUS);
int nestMinX = originX - SEARCH_RADIUS;
int nestMaxX = originX + SEARCH_RADIUS;
int nestMinZ = originZ - SEARCH_RADIUS;
int nestMaxZ = originZ + SEARCH_RADIUS;
int nestMinY = originY - VERTICAL_SEARCH_RADIUS;
int nestMaxY = originY + VERTICAL_SEARCH_RADIUS;
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
LongArrayList sandBlocks = new LongArrayList();
Long2DoubleOpenHashMap waterDistances = new Long2DoubleOpenHashMap();
waterDistances.defaultReturnValue(Double.POSITIVE_INFINITY);
// Read each block once, instead of repeatedly querying the world around
// every sand block. Unloaded chunks are never requested or generated.
if (minY <= maxY) {
Vec3 turtlePos = turtle.position();
int minSection = level.getSectionIndex(minY);
int maxSection = level.getSectionIndex(maxY);
for (int chunkX = (originX - scanRadius) >> 4; chunkX <= (originX + scanRadius) >> 4; chunkX++) {
int fromX = Math.max(originX - scanRadius, chunkX << 4);
int toX = Math.min(originX + scanRadius, (chunkX << 4) + 15);
for (int chunkZ = (originZ - scanRadius) >> 4; chunkZ <= (originZ + scanRadius) >> 4; chunkZ++) {
// The same test Level#hasChunk performs, except the chunk is
// kept: loadOrGenerate = false never loads or generates.
ChunkAccess chunk = level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, false);
if (chunk == null) {
continue;
}
int fromZ = Math.max(originZ - scanRadius, chunkZ << 4);
int toZ = Math.min(originZ + scanRadius, (chunkZ << 4) + 15);
LevelChunkSection[] sections = chunk.getSections();
for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) {
if (sectionIndex < 0 || sectionIndex >= sections.length) {
continue;
}
LevelChunkSection section = sections[sectionIndex];
// An all-air section holds neither water nor sand, so
// skipping it reads no block the loops could have used.
if (section == null || section.hasOnlyAir()) {
continue;
}
int sectionMinY = SectionPos.sectionToBlockCoord(level.getMinSectionY() + sectionIndex);
int fromY = Math.max(minY, sectionMinY);
int toY = Math.min(maxY, sectionMinY + 15);
boolean nestSection = fromY <= nestMaxY && toY >= nestMinY;
for (int x = fromX; x <= toX; x++) {
int localX = x & 15;
boolean nestX = x >= nestMinX && x <= nestMaxX;
for (int z = fromZ; z <= toZ; z++) {
boolean inNestRange = nestSection && nestX && z >= nestMinZ && z <= nestMaxZ;
int localZ = z & 15;
for (int y = fromY; y <= toY; y++) {
BlockState state = section.getBlockState(localX, y & 15, localZ);
if (isExposedSourceWater(level, cursor, x, y, z, state)) {
cursor.set(x, y, z);
waterDistances.put(BlockPos.asLong(x, y, z),
cursor.distToCenterSqr(turtlePos));
}
if (inNestRange && y >= nestMinY && y <= nestMaxY
&& state.is(BlockTags.SAND)
&& level.isEmptyBlock(cursor.set(x, y + 1, z))) {
sandBlocks.add(BlockPos.asLong(x, y, z));
}
}
}
}
}
}
}
}
List<Candidate> candidates = new ArrayList<>();
if (waterDistances.isEmpty()) {
return candidates;
}
Vec3 turtlePos = turtle.position();
for (int index = 0; index < sandBlocks.size(); index++) {
long packedSand = sandBlocks.getLong(index);
int sandX = BlockPos.getX(packedSand);
int sandY = BlockPos.getY(packedSand);
int sandZ = BlockPos.getZ(packedSand);
double nearestWaterDistance = Double.POSITIVE_INFINITY;
for (int dx = -WATER_RADIUS; dx <= WATER_RADIUS; dx++) {
for (int dz = -WATER_RADIUS; dz <= WATER_RADIUS; dz++) {
if (dx * dx + dz * dz > WATER_RADIUS * WATER_RADIUS) {
continue;
}
for (int dy = -WATER_VERTICAL_RADIUS; dy <= WATER_VERTICAL_RADIUS; dy++) {
long waterPosition = BlockPos.asLong(sandX + dx, sandY + dy, sandZ + dz);
nearestWaterDistance = Math.min(nearestWaterDistance, waterDistances.get(waterPosition));
}
}
}
if (Double.isFinite(nearestWaterDistance)) {
cursor.set(sandX, sandY, sandZ);
candidates.add(new Candidate(new BlockPos(sandX, sandY, sandZ), nearestWaterDistance,
cursor.distToCenterSqr(turtlePos)));
}
}
candidates.sort(CANDIDATE_ORDER);
return candidates;
}
/** Rechecks a chosen nest, including changes to its sand, air, or water. */
public static boolean isValidSite(LevelReader level, BlockPos sandPos) {
if (!level.isInsideBuildHeight(sandPos) || !level.isInsideBuildHeight(sandPos.above())
|| !level.hasChunkAt(sandPos)
|| !TurtleEggBlock.isSand(level, sandPos)
|| !level.isEmptyBlock(sandPos.above())) {
return false;
}
int sandX = sandPos.getX();
int sandY = sandPos.getY();
int sandZ = sandPos.getZ();
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
int lastChunkX = Integer.MIN_VALUE;
int lastChunkZ = Integer.MIN_VALUE;
boolean lastChunkLoaded = false;
for (int dx = -WATER_RADIUS; dx <= WATER_RADIUS; dx++) {
for (int dz = -WATER_RADIUS; dz <= WATER_RADIUS; dz++) {
if (dx * dx + dz * dz > WATER_RADIUS * WATER_RADIUS) {
continue;
}
int x = sandX + dx;
int z = sandZ + dz;
int chunkX = x >> 4;
int chunkZ = z >> 4;
// The disk above spans at most four chunks, so this only removes
// repeated queries for columns that share a chunk.
if (chunkX != lastChunkX || chunkZ != lastChunkZ) {
lastChunkX = chunkX;
lastChunkZ = chunkZ;
lastChunkLoaded = level.hasChunk(chunkX, chunkZ);
}
if (!lastChunkLoaded) {
continue;
}
for (int dy = -WATER_VERTICAL_RADIUS; dy <= WATER_VERTICAL_RADIUS; dy++) {
int y = sandY + dy;
cursor.set(x, y, z);
if (level.isInsideBuildHeight(y) && level.isInsideBuildHeight(y + 1)
&& isExposedSourceWater(level, cursor, x, y, z, level.getBlockState(cursor))) {
return true;
}
}
}
}
return false;
}
private static boolean isExposedSourceWater(LevelReader level, BlockPos.MutableBlockPos cursor,
int x, int y, int z, BlockState state) {
// Requiring a liquid block excludes waterlogged fences, slabs and other
// solid blocks even though their fluid state may report source water.
if (!state.liquid()) {
return false;
}
FluidState fluid = state.getFluidState();
return fluid.is(FluidTags.WATER) && fluid.isSource() && level.isEmptyBlock(cursor.set(x, y + 1, z));
}
public record Candidate(BlockPos sandPos, double waterDistanceSquared, double sandDistanceSquared) {
}
}
@@ -50,6 +50,8 @@
"carpet.rule.spectatorCannotUseLeash.desc": "If a player is a spectator, they cannot use the leash.",
"carpet.rule.offlineFakePlayers.desc": "假人强制离线uuid",
"carpet.rule.armadilloImmediateDespawns.desc": "Armadillos will despawn immediately if they are 128 blocks away from the player.",
"carpet.rule.turtleLocalNesting.name": "Turtle Local Nesting",
"carpet.rule.turtleLocalNesting.desc": "Turtles carrying eggs will lay them on reachable sand near the closest local water source instead of returning to their birthplace.",
"carpet.rule.trialSpawnerCD.desc": "trialSpawnerCD.",
"carpet.rule.skipTicksForJoblessVillager.extra.0": "Set to 0 to maintain the vanilla behavior",
@@ -26,6 +26,8 @@
"carpet.rule.botMaxOnlineTime.name": "Bot最大存活时间",
"carpet.rule.spectatorCannotUseLeash.name": "观察者模式禁用拴绳",
"carpet.rule.armadilloImmediateDespawns.desc": "当犰狳远离玩家128格时,立即消失",
"carpet.rule.turtleLocalNesting.name": "海龟就地筑巢",
"carpet.rule.turtleLocalNesting.desc": "有蛋的海龟会在附近的水边沙滩下蛋,而不是返回出生地",
"carpet.rule.obtainableReinforcedDeepSlate.desc": "当铁砧砸在9*64个深板岩的掉落物上时,可以获得强化深板岩",
"carpet.rule.skipTicksForJoblessVillager.desc": "对于无业/傻子村民,执行设定数量的tick运算后,跳过1tick的运算",
+5 -1
View File
@@ -32,7 +32,11 @@
"shulker.MixinServerPlayNetworkHandler",
"shulker.MixinSlot",
"world.MixinSleepManager",
"world.MixinSpawnHelper"
"world.MixinSpawnHelper",
"turtle.PathNavigationAccessor",
"turtle.TurtleAccessor",
"turtle.TurtleGoHomeGoalMixin",
"turtle.TurtleLayEggGoalMixin"
],
"injectors": {
"defaultRequire": 1