Fixed combat pathing around unreachable targets

This commit is contained in:
dam 2026-05-01 15:18:39 +03:00
parent c52de9464d
commit dd56c211de
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
2 changed files with 171 additions and 15 deletions

View file

@ -24,6 +24,7 @@ import java.util.LinkedHashMap
*/
object CombatMovementIntents {
private const val MAX_RANGED_APPROACH_CANDIDATES = 128
private const val MAX_PLAYER_COMBAT_PATH_DETOUR = 6.0
private data class Intent(val attacker: Entity, val target: Entity)
private data class MovementDestination(
@ -127,6 +128,7 @@ object CombatMovementIntents {
return
}
val targetLocation = targetLocationFor(attacker, target)
val projectedTargetLocation = projectedLocations[target]
if (projectedTargetLocation != null && canAttackFrom(attacker, target, attacker.location, projectedTargetLocation)) {
attacker.walkingQueue.reset()
@ -137,7 +139,8 @@ object CombatMovementIntents {
}
val candidates = movementDestinationsFor(attacker, target)
if (candidates.any { it.location == attacker.location }) {
val standingOnCandidate = candidates.any { it.location == attacker.location }
if (standingOnCandidate && canAttackFrom(attacker, target, attacker.location, targetLocation)) {
attacker.walkingQueue.reset()
attacker.face(target)
projectedLocations[attacker] = attacker.location
@ -145,7 +148,7 @@ object CombatMovementIntents {
return
}
val projectedAttackerLocation = CombatMovementPlanner.predictTargetLocations(attacker).lastOrNull()
if (projectedAttackerLocation != null && candidates.any { it.location == projectedAttackerLocation }) {
if (projectedAttackerLocation != null && canAttackFrom(attacker, target, projectedAttackerLocation, targetLocation)) {
attacker.face(target)
projectedLocations[attacker] = projectedAttackerLocation
reservedTiles.addAll(occupiedTiles(attacker, projectedAttackerLocation))
@ -167,7 +170,7 @@ object CombatMovementIntents {
reservedTiles.addAll(projectedTiles)
return
}
if (!blockedByReservation && shouldStopUnreachableCombat(attacker, target)) {
if (!blockedByReservation && shouldStopUnreachableCombat(attacker, target, standingOnCandidate)) {
stopUnreachableCombat(attacker)
}
}
@ -197,7 +200,13 @@ object CombatMovementIntents {
if (attackerLocation.z != targetLocation.z) {
return false
}
return attackerLocation in CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation)
if (occupiedTilesOverlap(attacker, attackerLocation, target, targetLocation)) {
return false
}
return when (attacker.properties.combatPulse.style) {
CombatStyle.RANGE, CombatStyle.MAGIC -> canAttackFromRange(attacker, target, attackerLocation, targetLocation)
else -> canAttackFromMelee(attacker, target, attackerLocation, targetLocation)
}
}
private fun movementDestinationsFor(attacker: Entity, target: Entity): List<MovementDestination> {
@ -225,9 +234,8 @@ object CombatMovementIntents {
} else {
emptyList()
}
return playerAttackRangeDestinations(attacker, target, targetLocation, pathfinder) +
destinations +
targetFallback
val rangedDestinations = playerAttackRangeDestinations(attacker, target, targetLocation, pathfinder)
return rangedDestinations + destinations + targetFallback
}
return destinations
}
@ -311,11 +319,118 @@ object CombatMovementIntents {
).take(MAX_RANGED_APPROACH_CANDIDATES)
}
private fun canAttackFromMelee(
attacker: Entity,
target: Entity,
attackerLocation: Location,
targetLocation: Location
): Boolean {
val distance = CombatReach.meleeDistance(attacker)
if (distance == 1 && !isAdjacentToTarget(attacker, attackerLocation, target, targetLocation)) {
return false
}
if (distance > 1 &&
attackerLocation.getDistance(closestOccupiedTile(target, targetLocation, attackerLocation)) > distance
) {
return false
}
return hasProjectileLineOfSight(
attackerLocation,
attacker.size(),
target,
targetLocation,
checkClose = !CombatReach.isUsingHalberd(attacker)
)
}
private fun canAttackFromRange(
attacker: Entity,
target: Entity,
attackerLocation: Location,
targetLocation: Location
): Boolean {
val range = if (attacker is Player) {
playerAttackRange(attacker)
} else {
CombatReach.combatDistance(
attacker,
target,
if (attacker.properties.combatPulse.style == CombatStyle.MAGIC) 10 else 7
)
}
return attackerLocation.getDistance(closestOccupiedTile(target, targetLocation, attackerLocation)) <= range &&
hasProjectileLineOfSight(attackerLocation, attacker.size(), target, targetLocation)
}
private fun isAdjacentToTarget(
attacker: Entity,
attackerLocation: Location,
target: Entity,
targetLocation: Location
): Boolean {
for (i in 0 until attacker.size()) {
if (Pathfinder.isStandingIn(
attackerLocation.x - 1,
attackerLocation.y + i,
1,
1,
targetLocation.x,
targetLocation.y,
target.size(),
target.size()
)
) {
return true
}
if (Pathfinder.isStandingIn(
attackerLocation.x + attacker.size(),
attackerLocation.y + i,
1,
1,
targetLocation.x,
targetLocation.y,
target.size(),
target.size()
)
) {
return true
}
if (Pathfinder.isStandingIn(
attackerLocation.x + i,
attackerLocation.y - 1,
1,
1,
targetLocation.x,
targetLocation.y,
target.size(),
target.size()
)
) {
return true
}
if (Pathfinder.isStandingIn(
attackerLocation.x + i,
attackerLocation.y + attacker.size(),
1,
1,
targetLocation.x,
targetLocation.y,
target.size(),
target.size()
)
) {
return true
}
}
return false
}
private fun hasProjectileLineOfSight(
attackerLocation: Location,
attackerSize: Int,
target: Entity,
targetLocation: Location
targetLocation: Location,
checkClose: Boolean = false
): Boolean {
for (sourceX in 0 until attackerSize) {
for (sourceY in 0 until attackerSize) {
@ -335,7 +450,7 @@ object CombatMovementIntents {
false,
RegionManager::getClippingFlag
)
if (path.isSuccessful) {
if (path.isSuccessful && (!checkClose || path.points.size <= 1)) {
return true
}
}
@ -416,11 +531,31 @@ object CombatMovementIntents {
if (!path.reaches(destination.location) && (!destination.allowPartialPath || path.points.isEmpty())) {
return null
}
if (attacker is Player && !destination.allowPartialPath && isExcessiveCombatDetour(attacker, destination, path)) {
return null
}
val steps = immediateMovementSteps(attacker, path)
if (attacker is Player && destination.allowPartialPath && !partialPathMovesCloser(attacker, destination, steps)) {
return null
}
if (attacker is Player && steps.any { !RegionManager.isTeleportPermitted(Location.create(it.x, it.y, attacker.location.z)) }) {
return null
}
val projected = steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) } ?: return null
return CandidatePath(steps, projected)
}
private fun isExcessiveCombatDetour(attacker: Player, destination: MovementDestination, path: Path): Boolean {
val pathLength = (path.points.size - 1).coerceAtLeast(0)
val directDistance = attacker.location.getDistance(destination.location)
return pathLength > directDistance + MAX_PLAYER_COMBAT_PATH_DETOUR
}
private fun partialPathMovesCloser(attacker: Player, destination: MovementDestination, steps: List<Point>): Boolean {
val projected = steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) } ?: return false
return projected.getDistance(destination.location) < attacker.location.getDistance(destination.location)
}
private fun Path.reaches(destination: Location): Boolean {
if (!isSuccessful || isMoveNear) {
return false
@ -467,8 +602,12 @@ object CombatMovementIntents {
return attacker is Player && attacker.walkingQueue.isRunningBoth && attacker.settings.runEnergy >= 1.0
}
private fun shouldStopUnreachableCombat(attacker: Entity, target: Entity): Boolean {
return attacker is Player && CombatMovementPlanner.movementStepsThisTick(target) == 0
private fun shouldStopUnreachableCombat(
attacker: Entity,
target: Entity,
exhaustedLocalApproach: Boolean = false
): Boolean {
return attacker is Player && (exhaustedLocalApproach || CombatMovementPlanner.movementStepsThisTick(target) == 0)
}
@JvmStatic
@ -491,10 +630,19 @@ object CombatMovementIntents {
}
private fun occupiedTilesOverlap(first: Entity, second: Entity): Boolean {
return first.location.x < second.location.x + second.size() &&
first.location.x + first.size() > second.location.x &&
first.location.y < second.location.y + second.size() &&
first.location.y + first.size() > second.location.y
return occupiedTilesOverlap(first, first.location, second, second.location)
}
private fun occupiedTilesOverlap(
first: Entity,
firstLocation: Location,
second: Entity,
secondLocation: Location
): Boolean {
return firstLocation.x < secondLocation.x + second.size() &&
firstLocation.x + first.size() > secondLocation.x &&
firstLocation.y < secondLocation.y + second.size() &&
firstLocation.y + first.size() > secondLocation.y
}
private fun pathfinderFor(attacker: Entity): Pathfinder {

View file

@ -670,6 +670,10 @@ class CombatMovementTests {
"Stopping blocked autocast should not leave a chase path queued."
)
}
assertTrue(
player.location.getDistance(duck.location) <= start.getDistance(duck.location),
"Blocked autocast should not route away around the river before resolving combat."
)
} finally {
duck.clear()
CombatMovementIntents.clear()
@ -716,6 +720,10 @@ class CombatMovementTests {
"location=${player.location}, distance=${player.location.getDistance(duck.location)}, " +
"isAttacking=${player.properties.combatPulse.isAttacking}"
)
assertTrue(
player.location.getDistance(duck.location) <= startDistance,
"Melee combat should not route away around the river before rejecting the duck."
)
} finally {
duck.clear()
CombatMovementIntents.clear()