diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt index 928c7e790..41dd254c2 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Fluid.kt @@ -1,6 +1,8 @@ package taboolib.module.navigation import org.bukkit.block.Block +import org.bukkit.block.data.Waterlogged +import taboolib.module.nms.MinecraftVersion /** * Navigation @@ -26,7 +28,13 @@ enum class Fluid { "WATER" -> WATER "STATIONARY_WATER" -> WATER "FLOWING_WATER" -> FLOWING_WATER - else -> EMPTY + else -> { + if (MinecraftVersion.isHigherOrEqual(MinecraftVersion.V1_13)) { + (blockData as? Waterlogged)?.takeIf { it.isWaterlogged }?.let { WATER } ?: EMPTY + } else { + EMPTY + } + } } fun String.getFluid() = when (this) { diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeEntity.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeEntity.kt index 64a022736..988ffb8fc 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeEntity.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeEntity.kt @@ -5,6 +5,7 @@ import org.bukkit.Location import org.bukkit.World import org.bukkit.block.BlockFace import org.bukkit.util.Vector +import taboolib.module.navigation.Fluid.Companion.getFluid import taboolib.platform.util.callRegion import java.util.* @@ -70,8 +71,9 @@ open class NodeEntity( } fun getWalkTargetValue(pos: Vector): Double { - return location.callRegion { - this.getWalkTargetValue(pos, location.world!!) + val world = location.world!! + return pos.toLocation(world).callRegion { + this.getWalkTargetValue(pos, world) } } @@ -98,7 +100,7 @@ open class NodeEntity( open fun isInWater(): Boolean { return location.callRegion { - location.block.isLiquid + location.block.getFluid().isWater() } } diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt index 22b44774a..8f74652ba 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/NodeReader.kt @@ -34,7 +34,7 @@ open class NodeReader(val entity: NodeEntity) { } fun getNode(x: Int, y: Int, z: Int): Node { - return nodes.computeIfAbsent(Node.createHash(x, y, z)) { Node(x, y, z) } + return getOrCreateNavigationNode(nodes, x, y, z) } fun getCachedBlockType(x: Int, y: Int, z: Int): PathType { @@ -58,37 +58,41 @@ open class NodeReader(val entity: NodeEntity) { private fun getStartAtRegion(): Node { val position = Vector(0, 0, 0) - var y = entity.location.blockY + val minHeight = world.navigationMinHeight() + val maxHeight = world.maxHeight + var y = entity.location.blockY.coerceIn(minHeight, maxHeight - 1) var block = world.getBlockAt(position.set(entity.location.blockX, y, entity.location.blockZ)) var blockposition: Vector if (!entity.canStandOnFluid(block.getFluid())) { if (entity.canFloat && entity.isInWater()) { - while (true) { - if (!block.isLiquid) { - --y - break - } + while (block.getFluid().isWater() && y < maxHeight - 1) { ++y block = world.getBlockAt(position.set(entity.location.blockX, y, entity.location.blockZ)) } + if (!block.getFluid().isWater()) { + --y + } } else if (entity.isOnGround()) { y = NumberConversions.floor(entity.location.y + 0.5) } else { - blockposition = entity.location.toVector() - while (!blockposition.toBlock(block.world).type.isSolid && blockposition.y > 0) { + blockposition = entity.location.toVector().apply { + setY(blockY.coerceIn(minHeight, maxHeight - 1).toDouble()) + } + var ground = blockposition.toBlock(block.world) + while (!ground.type.isSolid && blockposition.blockY > minHeight) { blockposition = blockposition.down() + ground = blockposition.toBlock(block.world) } - y = blockposition.up().blockY + y = if (ground.type.isSolid) blockposition.up().blockY.coerceAtMost(maxHeight - 1) else minHeight } } else { - while (true) { - if (!entity.canStandOnFluid(block.getFluid())) { - --y - break - } + while (entity.canStandOnFluid(block.getFluid()) && y < maxHeight - 1) { ++y block = world.getBlockAt(position.set(entity.location.blockX, y, entity.location.blockZ)) } + if (!entity.canStandOnFluid(block.getFluid())) { + --y + } } blockposition = entity.location.toVector() val blockPathType = getCachedBlockType(blockposition.blockX, y, blockposition.blockZ) @@ -164,7 +168,7 @@ open class NodeReader(val entity: NodeEntity) { if (getCachedBlockType(x, h - 1, z) != PathType.WATER) { return node } - while (h > 0) { + while (h > world.navigationMinHeight()) { --h pathTypes = getCachedBlockType(x, h, z) if (pathTypes != PathType.WATER) { @@ -181,7 +185,7 @@ open class NodeReader(val entity: NodeEntity) { var air = h while (pathTypes == PathType.OPEN) { --air - if (air < 0) { + if (air < world.navigationMinHeight()) { val node1 = getNode(x, air, z) node1.type = PathType.BLOCKED node1.costMalus = -1.0f @@ -318,3 +322,20 @@ open class NodeReader(val entity: NodeEntity) { return neighbors } } + +@JvmSynthetic +internal fun getOrCreateNavigationNode(nodes: MutableMap, x: Int, y: Int, z: Int): Node { + val initialKey = Node.createHash(x, y, z) + var key = initialKey + while (true) { + val existing = nodes[key] + if (existing == null) { + return Node(x, y, z).also { nodes[key] = it } + } + if (existing.x == x && existing.y == y && existing.z == z) { + return existing + } + key = key * 31 + 1 + check(key != initialKey) { "Unable to resolve navigation node hash collision" } + } +} diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt index 1eddd2602..cf8f22ccc 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/PathSmoothing.kt @@ -4,9 +4,11 @@ import org.bukkit.Location import org.bukkit.World import org.bukkit.util.Vector import taboolib.platform.util.callRegion +import kotlin.math.abs import kotlin.math.ceil import kotlin.math.floor -import kotlin.math.sqrt +import kotlin.math.max +import kotlin.math.min /** * 路径平滑后处理(String Pulling / 拉绳法) @@ -19,14 +21,14 @@ import kotlin.math.sqrt */ object PathSmoothing { - /** 视线检测采样步长(格) */ - private const val SAMPLE_STEP = 0.5 - /** * 对 A* 路径进行平滑处理 * 返回平滑后的世界坐标点列表(方块中心) */ fun smooth(path: Path, entity: NodeEntity): List { + if (path.nodes.isEmpty()) { + return emptyList() + } return entity.location.callRegion { smoothAtRegion(path, entity) } @@ -68,18 +70,37 @@ object PathSmoothing { private fun hasLineOfSightAtRegion(from: Vector, to: Vector, entity: NodeEntity, world: World): Boolean { val dx = to.x - from.x val dz = to.z - from.z - val dist = sqrt(dx * dx + dz * dz) - if (dist < 1e-6) return true - val steps = ceil(dist / SAMPLE_STEP).toInt() - for (i in 0..steps) { - val t = i.toDouble() / steps - val x = from.x + dx * t - val z = from.z + dz * t - if (!isStandableAtRegion(x, from.y, z, entity, world)) return false + if (abs(dx) < 1.0E-6 && abs(dz) < 1.0E-6) return true + val boundaries = sortedSetOf(0.0, 1.0) + addSweepBoundaries(from.x - entity.width / 2.0, dx, boundaries) + addSweepBoundaries(from.x + entity.width / 2.0, dx, boundaries) + addSweepBoundaries(from.z - entity.depth / 2.0, dz, boundaries) + addSweepBoundaries(from.z + entity.depth / 2.0, dz, boundaries) + val samples = boundaries.toList() + for (index in samples.indices) { + val t = samples[index] + if (!isStandableAtRegion(from.x + dx * t, from.y, from.z + dz * t, entity, world)) return false + if (index + 1 < samples.size) { + val midpoint = (t + samples[index + 1]) / 2.0 + if (!isStandableAtRegion(from.x + dx * midpoint, from.y, from.z + dz * midpoint, entity, world)) return false + } } return true } + private fun addSweepBoundaries(start: Double, delta: Double, boundaries: MutableSet) { + if (abs(delta) < 1.0E-6) return + val end = start + delta + val first = floor(min(start, end)).toInt() + val last = ceil(max(start, end)).toInt() + for (boundary in first..last) { + val t = (boundary - start) / delta + if (t > 0.0 && t < 1.0) { + boundaries += t + } + } + } + /** * 检查某个世界坐标位置是否可供实体站立 * - 脚下有支撑(非空气) @@ -95,26 +116,48 @@ object PathSmoothing { val halfWidth = entity.width / 2.0 val halfDepth = entity.depth / 2.0 val minBx = floor(x - halfWidth).toInt() - val maxBx = floor(x + halfWidth).toInt() + val maxBx = ceil(x + halfWidth).toInt() - 1 val minBz = floor(z - halfDepth).toInt() - val maxBz = floor(z + halfDepth).toInt() + val maxBz = ceil(z + halfDepth).toInt() - 1 val by = floor(y).toInt() val heightBlocks = ceil(entity.height).toInt() + if (!isWithinNavigationHeight(by, world.navigationMinHeight(), world.maxHeight) + || !isWithinNavigationHeight(by + heightBlocks - 1, world.navigationMinHeight(), world.maxHeight)) { + return false + } + val typeFactory = PathTypeFactory(entity) for (bx in minBx..maxBx) { for (bz in minBz..maxBz) { - // 脚下方块必须有支撑 val below = world.getBlockAtIfLoaded(Vector(bx, by - 1, bz)) ?: return false - if (below.type.isAirLegacy()) return false - // 实体身体占据的空间必须可通行 + val supportY = below.y + NMS.instance.getBlockHeight(below) + if (abs(supportY - y) > 1.0E-3) { + return false + } + val feetType = typeFactory.getTypeAsWalkable(world, Vector(bx, by, bz)) + if (!isSafeSmoothingFeetType(feetType, entity.getPathfindingMalus(feetType))) { + return false + } for (oy in 0 until heightBlocks) { - val block = world.getBlockAtIfLoaded(Vector(bx, by + oy, bz)) ?: return false - if (block.type.isSolid) return false + val bodyType = typeFactory.evaluateType(PathTypeFactory.getRawType(world, Vector(bx, by + oy, bz))) + if (!isSafeSmoothingBodyType(entity.getPathfindingMalus(bodyType))) { + return false + } } } } return true } + @JvmSynthetic + internal fun isSafeSmoothingFeetType(pathType: PathType, malus: Float): Boolean { + return pathType != PathType.OPEN && malus == 0.0f + } + + @JvmSynthetic + internal fun isSafeSmoothingBodyType(malus: Float): Boolean { + return malus == 0.0f + } + private fun nodeCenter(node: Node): Vector { return Vector(node.x + 0.5, node.y.toDouble(), node.z + 0.5) } diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/RandomPositionGenerator.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/RandomPositionGenerator.kt index cf0b8a58e..24314c953 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/RandomPositionGenerator.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/RandomPositionGenerator.kt @@ -136,7 +136,7 @@ object RandomPositionGenerator { } } var result = Vector((x + nodeEntity.x).toInt(), (y + nodeEntity.y).toInt(), (z + nodeEntity.z).toInt()) - if (result.y < 0) { + if (!isWithinNavigationHeight(result.blockY, world.navigationMinHeight(), world.maxHeight)) { return@repeat } if (hasRestriction && !nodeEntity.isWithinRestriction(result)) { @@ -146,7 +146,7 @@ object RandomPositionGenerator { return@repeat } if (aboveLand) { - result = moveUp(result, 0, 256) { + result = moveUp(result, 0, world.maxHeight) { if (Folia.isFolia) { world.getBlockAtIfLoaded(it)?.type?.isSolid == true } else { @@ -159,7 +159,7 @@ object RandomPositionGenerator { } else { world.getBlockAt(result.toLocation(world)).type } - if (onWater || blockType?.isWater() == true) { + if (acceptsNavigationSurface(onWater, blockType?.isWater() == true)) { val type = navigation.getTypeAsWalkable(world, result) if (nodeEntity.getPathfindingMalus(type) == 0.0f) { val walk = nodeEntity.getWalkTargetValue(result) @@ -200,6 +200,11 @@ object RandomPositionGenerator { } } + @JvmSynthetic + internal fun acceptsNavigationSurface(allowWater: Boolean, isWater: Boolean): Boolean { + return allowWater || !isWater + } + private fun randomDelta(random: Random, restrictX: Int, restrictY: Int, vector: Vector?): Vector? { return if (vector != null) { val size = atan2(vector.z, vector.x) - PI_OF_TWO diff --git a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Utils.kt b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Utils.kt index 5ef74bd76..434685ae7 100644 --- a/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Utils.kt +++ b/module/bukkit/bukkit-navigation/src/main/kotlin/taboolib/module/navigation/Utils.kt @@ -21,6 +21,9 @@ fun World.getBlockAtIfLoaded(position: Vector): Block? { val x = position.blockX val y = position.blockY val z = position.blockZ + if (!isWithinNavigationHeight(y, navigationMinHeight(), maxHeight)) { + return null + } return callRegion(x, y, z) { if (ChunkAccess.instance.isChunkLoaded(this, x shr 4, z shr 4)) { getBlockAt(x, y, z) @@ -30,6 +33,16 @@ fun World.getBlockAtIfLoaded(position: Vector): Block? { } } +@JvmSynthetic +internal fun World.navigationMinHeight(): Int { + return if (MinecraftVersion.isHigherOrEqual(MinecraftVersion.V1_17)) minHeight else 0 +} + +@JvmSynthetic +internal fun isWithinNavigationHeight(y: Int, minHeight: Int, maxHeight: Int): Boolean { + return y >= minHeight && y < maxHeight +} + fun Vector.toBlock(world: World) = toLocation(world).block fun Vector.down() = Vector(x, y - 1, z) @@ -115,7 +128,7 @@ fun Material.isAirLegacy(): Boolean { } fun Material.isWater(): Boolean { - return name.contains("WATER") + return name == "WATER" || name == "STATIONARY_WATER" || name == "FLOWING_WATER" } fun Block.isTrapdoorOpen(): Boolean { diff --git a/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt b/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt new file mode 100644 index 000000000..62e10cc5e --- /dev/null +++ b/module/bukkit/bukkit-navigation/src/test/kotlin/taboolib/module/navigation/NavigationCorrectnessTest.kt @@ -0,0 +1,67 @@ +package taboolib.module.navigation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class NavigationCorrectnessTest { + + @Test + fun `world height bounds include negative build height and exclude max height`() { + assertFalse(isWithinNavigationHeight(-65, -64, 320)) + assertTrue(isWithinNavigationHeight(-64, -64, 320)) + assertTrue(isWithinNavigationHeight(319, -64, 320)) + assertFalse(isWithinNavigationHeight(320, -64, 320)) + } + + @Test + fun `node cache resolves legacy hash collisions across modern world heights`() { + val nodes = HashMap() + assertEquals(Node.createHash(4, -64, 8), Node.createHash(4, 192, 8)) + + val low = getOrCreateNavigationNode(nodes, 4, -64, 8) + val high = getOrCreateNavigationNode(nodes, 4, 192, 8) + + assertNotSame(low, high) + assertEquals(-64, low.y) + assertEquals(192, high.y) + assertSame(low, getOrCreateNavigationNode(nodes, 4, -64, 8)) + assertSame(high, getOrCreateNavigationNode(nodes, 4, 192, 8)) + } + + @Test + fun `surface selection only rejects water when water is disabled`() { + assertTrue(RandomPositionGenerator.acceptsNavigationSurface(false, false)) + assertFalse(RandomPositionGenerator.acceptsNavigationSurface(false, true)) + assertTrue(RandomPositionGenerator.acceptsNavigationSurface(true, false)) + assertTrue(RandomPositionGenerator.acceptsNavigationSurface(true, true)) + } + + @Test + fun `fluid categories keep water and lava distinct`() { + assertTrue(Fluid.WATER.isWater()) + assertTrue(Fluid.FLOWING_WATER.isWater()) + assertFalse(Fluid.LAVA.isWater()) + assertFalse(Fluid.FLOWING_LAVA.isWater()) + assertTrue(Fluid.LAVA.isLava()) + assertTrue(Fluid.FLOWING_LAVA.isLava()) + assertFalse(Fluid.WATER.isLava()) + } + + @Test + fun `path smoothing rejects unsupported liquid dangerous and blocked cells`() { + assertTrue(PathSmoothing.isSafeSmoothingFeetType(PathType.WALKABLE, 0.0f)) + assertFalse(PathSmoothing.isSafeSmoothingFeetType(PathType.OPEN, 0.0f)) + assertFalse(PathSmoothing.isSafeSmoothingFeetType(PathType.WATER, PathType.WATER.malus)) + assertFalse(PathSmoothing.isSafeSmoothingFeetType(PathType.LAVA, PathType.LAVA.malus)) + assertFalse(PathSmoothing.isSafeSmoothingFeetType(PathType.DANGER_FIRE, PathType.DANGER_FIRE.malus)) + + assertTrue(PathSmoothing.isSafeSmoothingBodyType(PathType.OPEN.malus)) + assertFalse(PathSmoothing.isSafeSmoothingBodyType(PathType.WATER.malus)) + assertFalse(PathSmoothing.isSafeSmoothingBodyType(PathType.DAMAGE_FIRE.malus)) + assertFalse(PathSmoothing.isSafeSmoothingBodyType(PathType.BLOCKED.malus)) + } +}