mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2026-08-28 05:45:10 -06:00
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:
parent
7751971c15
commit
5422d0ba2d
3 changed files with 287 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue