mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2026-08-28 05:45:10 -06:00
Fixed combat pathing around blocked attack tiles
Filtered combat chase destinations so melee avoids interaction-blocked border tiles, and ranged/magic approach tiles require projectile line of sight before being selected. This fixes looping when attacking through the opened Lumbridge house door and when autocasting against Lumbridge river ducks from projectile-blocked spell range.
This commit is contained in:
parent
b7d0aa86f7
commit
7751971c15
3 changed files with 233 additions and 5 deletions
|
|
@ -22,7 +22,7 @@ import java.util.LinkedHashMap
|
|||
* walking queues are ticked.
|
||||
*/
|
||||
object CombatMovementIntents {
|
||||
private const val MAX_RANGED_APPROACH_CANDIDATES = 32
|
||||
private const val MAX_RANGED_APPROACH_CANDIDATES = 128
|
||||
|
||||
private data class Intent(val attacker: Entity, val target: Entity)
|
||||
private data class MovementDestination(
|
||||
|
|
@ -170,13 +170,29 @@ object CombatMovementIntents {
|
|||
MovementDestination(it, pathfinder, allowPartialPath = false)
|
||||
}
|
||||
if (attacker is Player) {
|
||||
val targetFallback = if (shouldAllowPartialTargetPath(attacker, target, targetLocation)) {
|
||||
listOf(MovementDestination(target, pathfinder, allowPartialPath = true))
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return playerAttackRangeDestinations(attacker, target, targetLocation, pathfinder) +
|
||||
destinations +
|
||||
MovementDestination(target, pathfinder, allowPartialPath = true)
|
||||
targetFallback
|
||||
}
|
||||
return destinations
|
||||
}
|
||||
|
||||
private fun shouldAllowPartialTargetPath(attacker: Player, target: Entity, targetLocation: Location): Boolean {
|
||||
val range = playerAttackRange(attacker)
|
||||
if (range <= CombatReach.meleeDistance(attacker)) {
|
||||
return true
|
||||
}
|
||||
if (CombatMovementPlanner.movementStepsThisTick(target) > 0) {
|
||||
return true
|
||||
}
|
||||
return attacker.location.getDistance(closestOccupiedTile(target, targetLocation, attacker.location)) > range
|
||||
}
|
||||
|
||||
private fun playerAttackRangeDestinations(
|
||||
attacker: Player,
|
||||
target: Entity,
|
||||
|
|
@ -237,7 +253,7 @@ object CombatMovementIntents {
|
|||
}
|
||||
}
|
||||
}
|
||||
return tiles.sortedWith(
|
||||
return tiles.filter { hasProjectileLineOfSight(it, attacker.size(), target, targetLocation) }.sortedWith(
|
||||
compareBy<Location> { it.getDistance(attacker.location) }
|
||||
.thenBy { it.getDistance(closestOccupiedTile(target, targetLocation, it)) }
|
||||
.thenBy { it.x }
|
||||
|
|
@ -245,6 +261,40 @@ object CombatMovementIntents {
|
|||
).take(MAX_RANGED_APPROACH_CANDIDATES)
|
||||
}
|
||||
|
||||
private fun hasProjectileLineOfSight(
|
||||
attackerLocation: Location,
|
||||
attackerSize: Int,
|
||||
target: Entity,
|
||||
targetLocation: Location
|
||||
): Boolean {
|
||||
for (sourceX in 0 until attackerSize) {
|
||||
for (sourceY in 0 until attackerSize) {
|
||||
val source = attackerLocation.transform(sourceX, sourceY, 0)
|
||||
for (targetX in 0 until target.size()) {
|
||||
for (targetY in 0 until target.size()) {
|
||||
val destination = targetLocation.transform(targetX, targetY, 0)
|
||||
val path = Pathfinder.PROJECTILE.find(
|
||||
source,
|
||||
1,
|
||||
destination,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
RegionManager::getClippingFlag
|
||||
)
|
||||
if (path.isSuccessful) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun closestOccupiedTile(entity: Entity, location: Location, from: Location): Location {
|
||||
return Location.create(
|
||||
from.x.coerceIn(location.x, location.x + entity.size() - 1),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import core.game.node.entity.player.Player
|
|||
import core.game.world.map.Location
|
||||
import core.game.world.map.Point
|
||||
import core.game.world.map.RegionManager
|
||||
import core.game.world.map.path.Pathfinder
|
||||
|
||||
/**
|
||||
* Plans combat-specific chase targets without mutating walking queues.
|
||||
|
|
@ -83,14 +84,29 @@ object CombatMovementPlanner {
|
|||
@JvmStatic
|
||||
fun candidateAttackTiles(attacker: Entity, target: Entity, targetLocation: Location): List<Location> {
|
||||
val candidates = borderTiles(target, targetLocation, attacker.size())
|
||||
val reachable = candidates.filter { RegionManager.isTeleportPermitted(it) }
|
||||
return (reachable.ifEmpty { candidates }).sortedWith(
|
||||
val walkable = candidates.filter { RegionManager.isTeleportPermitted(it) }
|
||||
val attackable = walkable.filter { canInteractFrom(attacker, it, target, targetLocation) }
|
||||
return (attackable.ifEmpty { walkable.ifEmpty { candidates } }).sortedWith(
|
||||
compareBy<Location> { it.getDistance(attacker.location) }
|
||||
.thenBy { it.x }
|
||||
.thenBy { it.y }
|
||||
)
|
||||
}
|
||||
|
||||
private fun canInteractFrom(attacker: Entity, location: Location, target: Entity, targetLocation: Location): Boolean {
|
||||
return Pathfinder.canInteract(
|
||||
location.x,
|
||||
location.y,
|
||||
attacker.size(),
|
||||
targetLocation.x,
|
||||
targetLocation.y,
|
||||
target.size(),
|
||||
target.size(),
|
||||
0,
|
||||
targetLocation.z
|
||||
) { z, x, y -> RegionManager.getClippingFlag(z, x, y) }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun borderTiles(target: Entity, targetLocation: Location, attackerSize: Int): List<Location> {
|
||||
val targetSize = target.size()
|
||||
|
|
|
|||
|
|
@ -3,19 +3,27 @@ package content
|
|||
import MockSession
|
||||
import TestUtils
|
||||
import core.api.EquipmentSlot
|
||||
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.CombatSwingHandler
|
||||
import core.game.node.entity.combat.CombatMovementIntents
|
||||
import core.game.node.entity.combat.CombatMovementPlanner
|
||||
import core.game.node.entity.combat.equipment.WeaponInterface
|
||||
import core.game.node.entity.combat.spell.CombatSpell
|
||||
import core.game.node.entity.combat.spell.SpellType
|
||||
import core.game.node.entity.npc.NPC
|
||||
import core.game.node.entity.player.Player
|
||||
import core.game.node.entity.skill.Skills
|
||||
import core.game.node.item.Item
|
||||
import core.game.node.scenery.SceneryBuilder
|
||||
import core.game.system.config.DoorConfigLoader
|
||||
import core.game.world.GameWorld
|
||||
import core.game.world.map.Location
|
||||
import core.game.world.map.RegionManager
|
||||
import core.game.world.map.path.Pathfinder
|
||||
import core.plugin.Plugin
|
||||
import core.net.packet.PacketProcessor
|
||||
import core.net.packet.`in`.Packet
|
||||
import org.rs09.consts.Items
|
||||
|
|
@ -397,6 +405,74 @@ class CombatMovementTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun magicAutocastShouldPathToProjectileClearLumbridgeRiverDuckTile() {
|
||||
TestUtils.getMockPlayer("combat_magic_duck_attacker").use { player ->
|
||||
val duckLocation = Location.create(3238, 3244, 0)
|
||||
val start = walkableTileNear(duckLocation, minDistance = 11, maxRadius = 16)
|
||||
place(player, start)
|
||||
configureMagicAutocast(player)
|
||||
enableRun(player)
|
||||
|
||||
val duck = stationaryNpc(46, duckLocation)
|
||||
try {
|
||||
assertTrue(RegionManager.isTeleportPermitted(start), "Test start tile must be walkable.")
|
||||
assertFalse(RegionManager.isTeleportPermitted(duckLocation), "Lumbridge river duck spawn should be on water.")
|
||||
|
||||
player.attack(duck)
|
||||
TestUtils.advanceTicks(12, false)
|
||||
|
||||
assertTrue(player.properties.combatPulse.isAttacking)
|
||||
assertTrue(
|
||||
magicReach(player, duck),
|
||||
"Autocast should stop on a projectile-clear tile instead of dancing within blocked range. " +
|
||||
"player=${player.location}, duck=${duck.location}, distance=${player.location.getDistance(duck.location)}"
|
||||
)
|
||||
assertFalse(receivedMessage(player, "I can't reach that!"))
|
||||
} finally {
|
||||
duck.clear()
|
||||
CombatMovementIntents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun magicAutocastShouldNotLoopWhenLumbridgeRiverDuckIsOnlyInBlockedRange() {
|
||||
TestUtils.getMockPlayer("combat_magic_blocked_duck_attacker").use { player ->
|
||||
val start = Location.create(3240, 3242, 0)
|
||||
val duckLocation = Location.create(3235, 3259, 0)
|
||||
place(player, start)
|
||||
configureMagicAutocast(player)
|
||||
enableRun(player)
|
||||
|
||||
val duck = stationaryNpc(46, duckLocation)
|
||||
try {
|
||||
assertTrue(RegionManager.isTeleportPermitted(start), "Test start tile must be walkable.")
|
||||
assertFalse(RegionManager.isTeleportPermitted(duckLocation), "Lumbridge river duck spawn should be on water.")
|
||||
|
||||
player.attack(duck)
|
||||
TestUtils.advanceTicks(18, false)
|
||||
|
||||
assertFalse(
|
||||
player.properties.combatPulse.isAttacking && !magicReach(player, duck),
|
||||
"Autocast should not stay active while standing in projectile-blocked spell range. " +
|
||||
"player=${player.location}, duck=${duck.location}, distance=${player.location.getDistance(duck.location)}, " +
|
||||
"projectile=${CombatSwingHandler.isProjectileClipped(player, duck, false)}"
|
||||
)
|
||||
if (!player.properties.combatPulse.isAttacking) {
|
||||
assertTrue(receivedMessage(player, "I can't reach that!"))
|
||||
assertTrue(
|
||||
player.walkingQueue.queue.size <= 1,
|
||||
"Stopping blocked autocast should not leave a chase path queued."
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
duck.clear()
|
||||
CombatMovementIntents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun meleePlayerShouldPathTowardLumbridgeRiverDuckBeforeRejecting() {
|
||||
TestUtils.getMockPlayer("combat_melee_duck_attacker").use { player ->
|
||||
|
|
@ -723,6 +799,55 @@ class CombatMovementTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun meleePlayerShouldRouteAroundOpenedLumbridgeHouseDoorToReachNpc() {
|
||||
TestUtils.getMockPlayer("combat_lumbridge_house_door_attacker").use { player ->
|
||||
val start = Location.create(3230, 3236, 0)
|
||||
val expectedAttackTile = Location.create(3231, 3237, 0)
|
||||
val doorLocation = Location.create(3230, 3235, 0)
|
||||
place(player, start)
|
||||
configureMelee(player)
|
||||
disableRun(player)
|
||||
|
||||
val door = RegionManager.getObject(doorLocation.z, doorLocation.x, doorLocation.y, 36846)
|
||||
?: throw AssertionError("Expected Lumbridge house door 36846 at $doorLocation.")
|
||||
assertEquals(1, door.rotation, "Expected the Lumbridge house door to face east.")
|
||||
val doorConfig = DoorConfigLoader.forId(door.id)
|
||||
?: throw AssertionError("Expected door config for ${door.id}.")
|
||||
val openedDoorLocation = door.location.transform(0, 1, 0)
|
||||
DoorActionHandler.open(door, null, doorConfig.replaceId, -1, true, -1, doorConfig.isFence)
|
||||
|
||||
val npc = NPC.create(100, Location.create(3231, 3236, 0))
|
||||
npc.init()
|
||||
try {
|
||||
configureMelee(npc)
|
||||
player.attack(npc)
|
||||
TestUtils.advanceTicks(6, false)
|
||||
|
||||
assertEquals(
|
||||
expectedAttackTile,
|
||||
player.location,
|
||||
"Player should route north around the opened southern door instead of bouncing south."
|
||||
)
|
||||
assertTrue(player.properties.combatPulse.isAttacking)
|
||||
assertTrue(meleeReach(player, npc))
|
||||
assertFalse(receivedMessage(player, "I can't reach that!"))
|
||||
} finally {
|
||||
npc.clear()
|
||||
val openedDoor = RegionManager.getObject(
|
||||
openedDoorLocation.z,
|
||||
openedDoorLocation.x,
|
||||
openedDoorLocation.y,
|
||||
doorConfig.replaceId
|
||||
)
|
||||
if (openedDoor != null) {
|
||||
SceneryBuilder.replace(openedDoor, door)
|
||||
}
|
||||
CombatMovementIntents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun arenaOrigin(): Location {
|
||||
return Location.create(3200, 3600, 0)
|
||||
}
|
||||
|
|
@ -763,6 +888,18 @@ class CombatMovementTests {
|
|||
player.properties.combatPulse.updateStyle()
|
||||
}
|
||||
|
||||
private fun configureMagicAutocast(player: Player) {
|
||||
player.equipment.replace(Item(Items.STAFF_OF_AIR_1381), EquipmentSlot.WEAPON.ordinal)
|
||||
player.skills.setStaticLevel(Skills.MAGIC, 99)
|
||||
player.skills.setLevel(Skills.MAGIC, 99)
|
||||
player.properties.autocastSpell = TestAutocastSpell
|
||||
player.properties.attackStyle = WeaponInterface.AttackStyle(
|
||||
WeaponInterface.STYLE_CAST,
|
||||
WeaponInterface.BONUS_MAGIC
|
||||
)
|
||||
player.properties.combatPulse.updateStyle()
|
||||
}
|
||||
|
||||
private fun equipDragonScimitar(player: Player) {
|
||||
player.equipment.replace(Item(Items.DRAGON_SCIMITAR_4587), EquipmentSlot.WEAPON.ordinal)
|
||||
}
|
||||
|
|
@ -817,6 +954,31 @@ class CombatMovementTests {
|
|||
return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <= 1.0
|
||||
}
|
||||
|
||||
private fun magicReach(attacker: Entity, victim: Entity): Boolean {
|
||||
return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <= 10.0 &&
|
||||
CombatSwingHandler.isProjectileClipped(attacker, victim, false)
|
||||
}
|
||||
|
||||
private object TestAutocastSpell : CombatSpell() {
|
||||
init {
|
||||
spellId = 1
|
||||
}
|
||||
|
||||
override fun getMaximumImpact(entity: Entity, victim: Entity, state: BattleState): Int {
|
||||
return 1
|
||||
}
|
||||
|
||||
override fun visualize(entity: Entity, target: Node?) {
|
||||
}
|
||||
|
||||
override fun visualizeImpact(entity: Entity?, target: Entity?, state: BattleState?) {
|
||||
}
|
||||
|
||||
override fun newInstance(arg: SpellType?): Plugin<SpellType> {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
private fun blockMovementTiles(tiles: List<Location>) {
|
||||
for (tile in tiles) {
|
||||
RegionManager.addClippingFlag(tile.z, tile.x, tile.y, false, movementBlockFlag)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue