Fixed melee combat pathing for overlapping actors

Treat overlapping occupied tiles as out of melee reach so combat movement must find a real adjacent attack tile. Improve intent resolution to preserve queued steps that already enter attack range and account for same-tick projected movement, preventing reciprocal melee movement loops.

Update dumb NPC overlap movement to consider all valid attack tiles instead of stalling on blocked preferred directions. Add regression coverage for player/NPC overlap cases, blocked-side fallback, and mutual melee movement.
This commit is contained in:
dam 2026-04-30 22:49:34 +03:00
parent 7751971c15
commit 5422d0ba2d
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
3 changed files with 287 additions and 9 deletions

View file

@ -71,8 +71,9 @@ object CombatMovementIntents {
intents.clear()
val reservedTiles = LinkedHashSet<Location>()
val projectedLocations = HashMap<Entity, Location>()
for (intent in pending) {
resolve(intent, reservedTiles)
resolve(intent, reservedTiles, projectedLocations)
}
}
@ -95,7 +96,12 @@ object CombatMovementIntents {
return false
}
val predictedTargetLocation = CombatMovementPlanner.predictedTargetLocation(target)
return attacker.location !in CombatMovementPlanner.candidateAttackTiles(attacker, target, predictedTargetLocation)
val attackTiles = CombatMovementPlanner.candidateAttackTiles(attacker, target, predictedTargetLocation)
if (attacker.location in attackTiles) {
return false
}
val projectedAttackerLocation = CombatMovementPlanner.predictTargetLocations(attacker).lastOrNull()
return projectedAttackerLocation !in attackTiles
}
private fun requestActiveMeleePressure(attacker: Entity) {
@ -109,14 +115,42 @@ object CombatMovementIntents {
}
}
private fun resolve(intent: Intent, reservedTiles: MutableSet<Location>) {
private fun resolve(
intent: Intent,
reservedTiles: MutableSet<Location>,
projectedLocations: MutableMap<Entity, Location>
) {
val attacker = intent.attacker
val target = intent.target
if (!canResolve(attacker, target)) {
return
}
val projectedTargetLocation = projectedLocations[target]
if (projectedTargetLocation != null && canAttackFrom(attacker, target, attacker.location, projectedTargetLocation)) {
attacker.walkingQueue.reset()
attacker.face(target)
projectedLocations[attacker] = attacker.location
reservedTiles.addAll(occupiedTiles(attacker, attacker.location))
return
}
val candidates = movementDestinationsFor(attacker, target)
if (candidates.any { it.location == attacker.location }) {
attacker.walkingQueue.reset()
attacker.face(target)
projectedLocations[attacker] = attacker.location
reservedTiles.addAll(occupiedTiles(attacker, attacker.location))
return
}
val projectedAttackerLocation = CombatMovementPlanner.predictTargetLocations(attacker).lastOrNull()
if (projectedAttackerLocation != null && candidates.any { it.location == projectedAttackerLocation }) {
attacker.face(target)
projectedLocations[attacker] = projectedAttackerLocation
reservedTiles.addAll(occupiedTiles(attacker, projectedAttackerLocation))
return
}
var blockedByReservation = false
for (candidate in candidates) {
val candidatePath = pathTo(attacker, candidate) ?: continue
@ -128,6 +162,7 @@ object CombatMovementIntents {
walkPath(attacker, candidatePath)
attacker.face(target)
projectedLocations[attacker] = candidatePath.projectedLocation
reservedTiles.addAll(projectedTiles)
return
}
@ -157,10 +192,22 @@ object CombatMovementIntents {
return attacker !is NPC || !attacker.isNeverWalks
}
private fun canAttackFrom(attacker: Entity, target: Entity, attackerLocation: Location, targetLocation: Location): Boolean {
if (attackerLocation.z != targetLocation.z) {
return false
}
return attackerLocation in CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation)
}
private fun movementDestinationsFor(attacker: Entity, target: Entity): List<MovementDestination> {
val pathfinder = pathfinderFor(attacker)
if (attacker is NPC && pathfinder === Pathfinder.DUMB) {
return dumbNpcAttackDestinations(attacker, target).map {
val destinations = if (occupiedTilesOverlap(attacker, target)) {
CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocationFor(attacker, target))
} else {
dumbNpcAttackDestinations(attacker, target)
}
return destinations.map {
MovementDestination(it, pathfinder, allowPartialPath = true)
}
}
@ -170,7 +217,9 @@ object CombatMovementIntents {
MovementDestination(it, pathfinder, allowPartialPath = false)
}
if (attacker is Player) {
val targetFallback = if (shouldAllowPartialTargetPath(attacker, target, targetLocation)) {
val targetFallback = if (!occupiedTilesOverlap(attacker, target) &&
shouldAllowPartialTargetPath(attacker, target, targetLocation)
) {
listOf(MovementDestination(target, pathfinder, allowPartialPath = true))
} else {
emptyList()
@ -435,6 +484,13 @@ object CombatMovementIntents {
return tiles
}
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
}
private fun pathfinderFor(attacker: Entity): Pathfinder {
return when (attacker) {
is Player -> Pathfinder.SMART

View file

@ -48,6 +48,9 @@ object CombatReach {
if (entity.id == 7135 && entity.location.withinDistance(victim.location, 2)) {
return true
}
if (occupiedAreasOverlap(entity, victim)) {
return false
}
val x = victim.location.x
val y = victim.location.y
val size = entity.size()
@ -66,9 +69,6 @@ object CombatReach {
return true
}
}
if (e == victim.location) {
return true
}
return victim.getSwingHandler(false).type == CombatStyle.MELEE &&
e.withinDistance(victim.location, 1) &&
victim.properties.combatPulse.getVictim() === entity &&
@ -80,6 +80,19 @@ object CombatReach {
)
}
private fun occupiedAreasOverlap(first: Entity, second: Entity): Boolean {
return Pathfinder.isStandingIn(
first.location.x,
first.location.y,
first.size(),
first.size(),
second.location.x,
second.location.y,
second.size(),
second.size()
)
}
@JvmStatic
fun combatDistance(entity: Entity, victim: Entity, rawDistance: Int): Int {
var distance = rawDistance

View file

@ -7,6 +7,7 @@ import core.game.global.action.DoorActionHandler
import core.game.node.Node
import core.game.node.entity.Entity
import core.game.node.entity.combat.BattleState
import core.game.node.entity.combat.CombatReach
import core.game.node.entity.combat.CombatSwingHandler
import core.game.node.entity.combat.CombatMovementIntents
import core.game.node.entity.combat.CombatMovementPlanner
@ -219,6 +220,209 @@ class CombatMovementTests {
}
}
@Test
fun meleeIntentShouldNotMoveAttackerAlreadyOnAttackTile() {
TestUtils.getMockPlayer("combat_already_adjacent_attacker").use { player ->
val origin = arenaOrigin()
val staleStep = origin.transform(1, 0, 0)
place(player, origin)
configureMelee(player)
disableRun(player)
val npc = NPC.create(100, origin.transform(0, 1, 0))
npc.init()
try {
configureMelee(npc)
player.attack(npc)
player.playerFlags.setUpdateSceneGraph(false)
queueWalk(player, staleStep)
CombatMovementIntents.clear()
CombatMovementIntents.request(player, npc)
CombatMovementIntents.resolve()
player.walkingQueue.update()
assertEquals(
origin,
player.location,
"A stale melee movement intent must not make an already-adjacent attacker sidestep."
)
assertTrue(meleeReach(player, npc))
assertTrue(player.properties.combatPulse.isAttacking)
} finally {
npc.clear()
CombatMovementIntents.clear()
}
}
}
@Test
fun mutualMeleeMovementShouldNotOverrideQueuedStepThatKeepsAttackRange() {
TestUtils.getMockPlayer("combat_synced_step_attacker").use { player ->
val origin = arenaOrigin()
val playerStep = origin.transform(1, 0, 0)
val npcStep = origin.transform(1, 1, 0)
place(player, origin)
configureMelee(player)
disableRun(player)
val npc = NPC.create(100, origin.transform(0, 1, 0))
npc.init()
try {
configureMelee(npc)
player.attack(npc)
npc.attack(player)
player.playerFlags.setUpdateSceneGraph(false)
queueWalk(player, playerStep)
queueWalk(npc, npcStep)
assertFalse(
CombatMovementIntents.shouldMaintainMeleePressure(player, npc),
"The player's existing step should already keep melee range."
)
assertFalse(
CombatMovementIntents.shouldMaintainMeleePressure(npc, player),
"The NPC's existing step should already keep melee range."
)
CombatMovementIntents.clear()
CombatMovementIntents.requestActiveMeleePressure()
CombatMovementIntents.resolve()
npc.walkingQueue.update()
player.walkingQueue.update()
assertEquals(playerStep, player.location)
assertEquals(npcStep, npc.location)
assertTrue(meleeReach(player, npc))
assertTrue(meleeReach(npc, player))
} finally {
npc.clear()
CombatMovementIntents.clear()
}
}
}
@Test
fun overlappingMeleePlayerShouldStepToOpenAttackTileInsteadOfStopping() {
TestUtils.getMockPlayer("combat_overlap_player_escape").use { player ->
val origin = arenaOrigin()
val blockedTiles = listOf(
origin.transform(0, 1, 0),
origin.transform(1, 0, 0)
)
place(player, origin)
configureMelee(player)
disableRun(player)
val npc = NPC.create(100, origin)
npc.init()
try {
configureMelee(npc)
blockMovementTiles(blockedTiles)
TestUtils.advanceTicks(1, true)
CombatMovementIntents.clear()
player.playerFlags.lastSceneGraph = origin
player.playerFlags.setUpdateSceneGraph(false)
player.attack(npc)
TestUtils.advanceTicks(1, false)
assertTrue(
player.location == origin.transform(-1, 0, 0) || player.location == origin.transform(0, -1, 0),
"An overlapped melee player should step to the open west/south side instead of stopping. " +
"player=${player.location}, npc=${npc.location}"
)
assertTrue(CombatReach.canMelee(player, npc, CombatReach.meleeDistance(player)))
assertTrue(player.properties.combatPulse.isAttacking)
assertFalse(receivedMessage(player, "I can't reach that!"))
} finally {
unblockMovementTiles(blockedTiles)
npc.clear()
CombatMovementIntents.clear()
}
}
}
@Test
fun overlappingMeleeNpcShouldStepToOpenAttackTileInsteadOfStalling() {
TestUtils.getMockPlayer("combat_overlap_npc_target").use { player ->
val origin = arenaOrigin()
val blockedTiles = listOf(
origin.transform(0, 1, 0),
origin.transform(1, 0, 0)
)
place(player, origin)
configureMelee(player)
disableRun(player)
val npc = NPC.create(100, origin)
npc.init()
try {
configureMelee(npc)
blockMovementTiles(blockedTiles)
npc.attack(player)
TestUtils.advanceTicks(1, false)
assertTrue(
npc.location == origin.transform(-1, 0, 0) || npc.location == origin.transform(0, -1, 0),
"An overlapped dumb melee NPC should try the open west/south side, not only north/east. " +
"npc=${npc.location}, player=${player.location}"
)
assertTrue(CombatReach.canMelee(npc, player, CombatReach.meleeDistance(npc)))
assertTrue(npc.properties.combatPulse.isAttacking)
} finally {
unblockMovementTiles(blockedTiles)
npc.clear()
CombatMovementIntents.clear()
}
}
}
@Test
fun mutualOverlappedMeleeCombatShouldOnlyMoveOneActorIntoAttackRange() {
TestUtils.getMockPlayer("combat_overlap_mutual_player").use { player ->
val origin = arenaOrigin()
val blockedTiles = listOf(
origin.transform(0, 1, 0),
origin.transform(1, 0, 0)
)
place(player, origin)
configureMelee(player)
disableRun(player)
val npc = NPC.create(100, origin)
npc.init()
try {
configureMelee(npc)
blockMovementTiles(blockedTiles)
TestUtils.advanceTicks(1, true)
CombatMovementIntents.clear()
player.playerFlags.lastSceneGraph = origin
player.playerFlags.setUpdateSceneGraph(false)
player.attack(npc)
npc.attack(player)
TestUtils.advanceTicks(1, false)
assertTrue(player.location != origin || npc.location != origin)
assertTrue(
player.location == origin || npc.location == origin,
"When both overlapped actors are attacking, one side stepping out is enough. " +
"player=${player.location}, npc=${npc.location}"
)
assertTrue(CombatReach.canMelee(player, npc, CombatReach.meleeDistance(player)))
assertTrue(CombatReach.canMelee(npc, player, CombatReach.meleeDistance(npc)))
assertFalse(receivedMessage(player, "I can't reach that!"))
} finally {
unblockMovementTiles(blockedTiles)
npc.clear()
CombatMovementIntents.clear()
}
}
}
@Test
fun playerShouldChaseMovingMeleeNpcWithoutGenericInteractionMovementPulse() {
TestUtils.getMockPlayer("combat_npc_chaser").use { player ->
@ -863,6 +1067,11 @@ class CombatMovementTests {
entity.walkingQueue.addPath(destination.x, destination.y)
}
private fun queueWalk(entity: Entity, destination: Location) {
entity.walkingQueue.reset(false)
entity.walkingQueue.addPath(destination.x, destination.y)
}
private fun queueRunPath(entity: Entity, path: List<Location>) {
entity.walkingQueue.reset(true)
for (location in path) {
@ -951,7 +1160,7 @@ class CombatMovementTests {
}
private fun meleeReach(attacker: Entity, victim: Entity): Boolean {
return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <= 1.0
return CombatReach.canMelee(attacker, victim, CombatReach.meleeDistance(attacker))
}
private fun magicReach(attacker: Entity, victim: Entity): Boolean {