mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2026-08-28 05:45:10 -06:00
Lazy ranged path batching, projectile edge case fix, Bork fix
This commit is contained in:
parent
9331e40eb4
commit
b1231c749f
8 changed files with 326 additions and 53 deletions
|
|
@ -27,7 +27,7 @@ import kotlin.math.sqrt
|
|||
* ticked.
|
||||
*/
|
||||
object CombatMovementIntents {
|
||||
private const val MAX_RANGED_APPROACH_CANDIDATES = 16
|
||||
private const val RANGED_APPROACH_BATCH_SIZE = 16
|
||||
private const val MAX_PLAYER_COMBAT_PATH_DETOUR = 6.0
|
||||
private const val MAX_DIRECT_COMBAT_PATH_DISTANCE = 32
|
||||
|
||||
|
|
@ -376,13 +376,16 @@ object CombatMovementIntents {
|
|||
|
||||
val candidates =
|
||||
movementDestinationsFor(attacker, target, targetLocation, pathfinder, trace)
|
||||
val standingOnCandidate = candidates.any { it.location == attacker.location }
|
||||
|
||||
val queueStationaryContinuation =
|
||||
attacker.properties.combatPulse.style == CombatStyle.MELEE &&
|
||||
!CombatMovementPlanner.hasMovementStepThisTick(target)
|
||||
var standingOnCandidate = false
|
||||
var blockedByReservation = false
|
||||
for (candidate in candidates) {
|
||||
if (candidate.location == attacker.location) {
|
||||
standingOnCandidate = true
|
||||
}
|
||||
val candidatePath =
|
||||
pathTo(attacker, candidate, trace, queueStationaryContinuation) ?: continue
|
||||
val attackPath =
|
||||
|
|
@ -475,7 +478,7 @@ object CombatMovementIntents {
|
|||
targetLocation: Location,
|
||||
pathfinder: Pathfinder,
|
||||
trace: IntentTrace,
|
||||
): List<MovementDestination> {
|
||||
): Sequence<MovementDestination> {
|
||||
if (attacker is NPC && pathfinder === Pathfinder.DUMB) {
|
||||
val destinations = ArrayList<MovementDestination>(4)
|
||||
if (occupiedTilesOverlap(attacker, target)) {
|
||||
|
|
@ -507,21 +510,11 @@ object CombatMovementIntents {
|
|||
}
|
||||
}
|
||||
trace.candidateCount += destinations.size
|
||||
return destinations
|
||||
return destinations.asSequence()
|
||||
}
|
||||
|
||||
val attackTiles = attackTilesFor(attacker, target, targetLocation, trace)
|
||||
if (attacker is Player) {
|
||||
val rangedTiles =
|
||||
playerAttackRangeDestinations(attacker, target, targetLocation, pathfinder, trace)
|
||||
val capacity = rangedTiles.size + attackTiles.size + 1
|
||||
val destinations = ArrayList<MovementDestination>(capacity)
|
||||
destinations.addAll(rangedTiles)
|
||||
for (location in attackTiles) {
|
||||
destinations.add(
|
||||
MovementDestination(location, pathfinder, allowPartialPath = false)
|
||||
)
|
||||
}
|
||||
val targetFallback =
|
||||
if (
|
||||
!occupiedTilesOverlap(attacker, target) &&
|
||||
|
|
@ -535,11 +528,25 @@ object CombatMovementIntents {
|
|||
} else {
|
||||
null
|
||||
}
|
||||
if (targetFallback != null) {
|
||||
destinations.add(targetFallback)
|
||||
return sequence {
|
||||
yieldAll(
|
||||
playerAttackRangeDestinations(
|
||||
attacker,
|
||||
target,
|
||||
targetLocation,
|
||||
pathfinder,
|
||||
trace,
|
||||
)
|
||||
)
|
||||
for (location in attackTiles) {
|
||||
trace.candidateCount++
|
||||
yield(MovementDestination(location, pathfinder, allowPartialPath = false))
|
||||
}
|
||||
if (targetFallback != null) {
|
||||
trace.candidateCount++
|
||||
yield(targetFallback)
|
||||
}
|
||||
}
|
||||
trace.candidateCount += destinations.size
|
||||
return destinations
|
||||
}
|
||||
|
||||
val destinations = ArrayList<MovementDestination>(attackTiles.size)
|
||||
|
|
@ -547,7 +554,7 @@ object CombatMovementIntents {
|
|||
destinations.add(MovementDestination(location, pathfinder, allowPartialPath = false))
|
||||
}
|
||||
trace.candidateCount += destinations.size
|
||||
return destinations
|
||||
return destinations.asSequence()
|
||||
}
|
||||
|
||||
private fun attackTilesFor(
|
||||
|
|
@ -605,17 +612,16 @@ object CombatMovementIntents {
|
|||
targetLocation: Location,
|
||||
pathfinder: Pathfinder,
|
||||
trace: IntentTrace,
|
||||
): List<MovementDestination> {
|
||||
): Sequence<MovementDestination> {
|
||||
val range = playerAttackRange(attacker)
|
||||
if (range <= CombatReach.meleeDistance(attacker)) {
|
||||
return emptyList()
|
||||
return emptySequence()
|
||||
}
|
||||
val attackTiles = attackRangeTiles(attacker, target, targetLocation, range, trace)
|
||||
val destinations = ArrayList<MovementDestination>(attackTiles.size)
|
||||
for (tile in attackTiles) {
|
||||
destinations.add(MovementDestination(tile, pathfinder, allowPartialPath = false))
|
||||
return attackTiles.map { tile ->
|
||||
trace.candidateCount++
|
||||
MovementDestination(tile, pathfinder, allowPartialPath = false)
|
||||
}
|
||||
return destinations
|
||||
}
|
||||
|
||||
private fun playerAttackRange(attacker: Player): Int {
|
||||
|
|
@ -654,7 +660,7 @@ object CombatMovementIntents {
|
|||
targetLocation: Location,
|
||||
range: Int,
|
||||
trace: IntentTrace,
|
||||
): List<Location> {
|
||||
): Sequence<Location> {
|
||||
val tiles = ArrayList<Location>()
|
||||
val minX = targetLocation.x - range
|
||||
val maxX = targetLocation.x + target.size() - 1 + range
|
||||
|
|
@ -685,27 +691,29 @@ object CombatMovementIntents {
|
|||
.thenBy { it.x }
|
||||
.thenBy { it.y }
|
||||
)
|
||||
val attackTiles = ArrayList<Location>(minOf(MAX_RANGED_APPROACH_CANDIDATES, tiles.size))
|
||||
RsmodPathfinder.loadLineOfSightWindow(targetLocation)
|
||||
for (tile in tiles) {
|
||||
if (
|
||||
!hasProjectileLineOfSight(
|
||||
tile,
|
||||
attacker.size(),
|
||||
target,
|
||||
targetLocation,
|
||||
loadWindow = false,
|
||||
trace = trace,
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
attackTiles.add(tile)
|
||||
if (attackTiles.size >= MAX_RANGED_APPROACH_CANDIDATES) {
|
||||
break
|
||||
return sequence {
|
||||
var nextTile = 0
|
||||
while (nextTile < tiles.size) {
|
||||
val batch = ArrayList<Location>(RANGED_APPROACH_BATCH_SIZE)
|
||||
RsmodPathfinder.loadLineOfSightWindow(targetLocation)
|
||||
while (nextTile < tiles.size && batch.size < RANGED_APPROACH_BATCH_SIZE) {
|
||||
val tile = tiles[nextTile++]
|
||||
if (
|
||||
hasProjectileLineOfSight(
|
||||
tile,
|
||||
attacker.size(),
|
||||
target,
|
||||
targetLocation,
|
||||
loadWindow = false,
|
||||
trace = trace,
|
||||
)
|
||||
) {
|
||||
batch.add(tile)
|
||||
}
|
||||
}
|
||||
yieldAll(batch)
|
||||
}
|
||||
}
|
||||
return attackTiles
|
||||
}
|
||||
|
||||
private fun canAttackFromMelee(
|
||||
|
|
@ -731,7 +739,7 @@ object CombatMovementIntents {
|
|||
) {
|
||||
return false
|
||||
}
|
||||
if (CombatReach.isUsingHalberd(attacker)) {
|
||||
if (CombatReach.hasExtendedMeleeReach(attacker)) {
|
||||
return hasProjectileLineOfSight(
|
||||
attackerLocation,
|
||||
attacker.size(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import core.game.world.map.path.RsmodPathfinder
|
|||
|
||||
/** Shared combat reach calculations. */
|
||||
object CombatReach {
|
||||
private const val BORK_LEGION_ID = 7135
|
||||
|
||||
@JvmStatic
|
||||
fun isUsingHalberd(entity: Entity): Boolean {
|
||||
if (entity is Player) {
|
||||
|
|
@ -28,7 +30,12 @@ object CombatReach {
|
|||
|
||||
@JvmStatic
|
||||
fun meleeDistance(entity: Entity): Int {
|
||||
return if (isUsingHalberd(entity)) 2 else 1
|
||||
return if (hasExtendedMeleeReach(entity)) 2 else 1
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun hasExtendedMeleeReach(entity: Entity): Boolean {
|
||||
return isUsingHalberd(entity) || entity is NPC && entity.id == BORK_LEGION_ID
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
|
@ -37,7 +44,7 @@ object CombatReach {
|
|||
if (victim == null) {
|
||||
return false
|
||||
}
|
||||
if (entity.id == 7135 && entity.location.withinDistance(victim.location, 2)) {
|
||||
if (entity.id == BORK_LEGION_ID && entity.location.withinDistance(victim.location, 2)) {
|
||||
return true
|
||||
}
|
||||
if (occupiedAreasOverlap(entity, victim)) {
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
|
|||
return InteractionType.NO_INTERACT
|
||||
}
|
||||
|
||||
if (type == CombatStyle.MELEE && !CombatReach.isUsingHalberd(entity)) {
|
||||
if (type == CombatStyle.MELEE && !CombatReach.hasExtendedMeleeReach(entity)) {
|
||||
val stepType = canStepTowards(entity, victim)
|
||||
if (stepType != InteractionType.STILL_INTERACT) return stepType
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ open class MeleeSwingHandler(vararg flags: SwingHandlerFlag)
|
|||
return type
|
||||
} else if (goodRange) {
|
||||
if (
|
||||
!CombatReach.isUsingHalberd(entity) &&
|
||||
!CombatReach.hasExtendedMeleeReach(entity) &&
|
||||
canStepTowards(entity, victim) == InteractionType.NO_INTERACT
|
||||
)
|
||||
return InteractionType.NO_INTERACT
|
||||
|
|
@ -76,7 +76,7 @@ open class MeleeSwingHandler(vararg flags: SwingHandlerFlag)
|
|||
victim: Entity,
|
||||
type: InteractionType,
|
||||
): Boolean {
|
||||
if (CombatReach.isUsingHalberd(entity)) {
|
||||
if (CombatReach.hasExtendedMeleeReach(entity)) {
|
||||
return isProjectileClipped(entity, victim, false)
|
||||
}
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -206,8 +206,8 @@ class RsmodPathfinder(private val maxWaypoints: Int = 25) : Pathfinder() {
|
|||
destX = dest.x,
|
||||
destZ = dest.y,
|
||||
srcSize = moverSize,
|
||||
destWidth = destWidth,
|
||||
destHeight = destHeight,
|
||||
destWidth = destWidth.coerceAtLeast(1),
|
||||
destHeight = destHeight.coerceAtLeast(1),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ 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.game.world.map.path.RsmodPathfinder
|
||||
import core.net.packet.PacketProcessor
|
||||
import core.net.packet.`in`.Packet
|
||||
import core.plugin.Plugin
|
||||
|
|
@ -967,6 +968,143 @@ class CombatMovementTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rangedMovementShouldTryAnotherApproachBatchWhenTheFirstIsUnrouteable() {
|
||||
TestUtils.getMockPlayer("combat_ranged_later_batch").use { player ->
|
||||
val origin = openHorizontalOrigin()
|
||||
val npcLocation = origin.transform(6, 0, 0)
|
||||
place(player, origin)
|
||||
configureRanged(player)
|
||||
disableRun(player)
|
||||
player.playerFlags.setUpdateSceneGraph(false)
|
||||
|
||||
val npc = stationaryNpc(100, npcLocation)
|
||||
val projectileFlag = CollisionFlag.WALL_WEST_PROJECTILE_BLOCKER
|
||||
val fencedEdges = ArrayList<Pair<Location, Location>>()
|
||||
try {
|
||||
RegionManager.addClippingFlag(
|
||||
npcLocation.z,
|
||||
npcLocation.x,
|
||||
npcLocation.y,
|
||||
true,
|
||||
projectileFlag,
|
||||
)
|
||||
assertFalse(
|
||||
CombatSwingHandler.isProjectileClipped(player, npc, false),
|
||||
"The starting tile must be in nominal range but projectile-blocked.",
|
||||
)
|
||||
|
||||
val orderedCandidates = rangedApproachCandidates(player, npc, range = 7)
|
||||
assertTrue(
|
||||
orderedCandidates.size > 16,
|
||||
"The fixture needs a routeable ranged candidate after the first batch.",
|
||||
)
|
||||
val enclosedTiles =
|
||||
LinkedHashSet<Location>().apply {
|
||||
addAll(orderedCandidates.take(32))
|
||||
addAll(
|
||||
CombatMovementPlanner.candidateAttackTiles(
|
||||
player,
|
||||
npc,
|
||||
npc.location,
|
||||
)
|
||||
)
|
||||
}
|
||||
fencedEdges.addAll(perimeterEdges(enclosedTiles))
|
||||
for ((inside, outside) in fencedEdges) {
|
||||
setFence(inside, outside, add = true)
|
||||
}
|
||||
|
||||
val fencedCandidates = rangedApproachCandidates(player, npc, range = 7)
|
||||
val firstBatch = fencedCandidates.take(16)
|
||||
assertTrue(
|
||||
firstBatch.none { candidate ->
|
||||
Pathfinder.SMART.find(
|
||||
player.location,
|
||||
player.size(),
|
||||
candidate,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
-1,
|
||||
0,
|
||||
false,
|
||||
null,
|
||||
).isSuccessful
|
||||
},
|
||||
"Every candidate in the first ranged batch must be unrouteable.",
|
||||
)
|
||||
player.attack(npc)
|
||||
TestUtils.advanceTicks(1, false)
|
||||
|
||||
val summary = CombatMovementIntents.lastResolveSummary()
|
||||
val routeCalls =
|
||||
Regex("rsmodRouteCalls=(\\d+)")
|
||||
.find(summary)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.toInt()
|
||||
?: 0
|
||||
assertTrue(
|
||||
routeCalls >= 32,
|
||||
"Ranged movement must continue checking routes after the first 16 fail. $summary",
|
||||
)
|
||||
} finally {
|
||||
for ((inside, outside) in fencedEdges) {
|
||||
setFence(inside, outside, add = false)
|
||||
}
|
||||
RegionManager.removeClippingFlag(
|
||||
npcLocation.z,
|
||||
npcLocation.x,
|
||||
npcLocation.y,
|
||||
true,
|
||||
projectileFlag,
|
||||
)
|
||||
npc.clear()
|
||||
CombatMovementIntents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun borkLegionShouldAttackFromItsAuthenticTwoTileMeleeReach() {
|
||||
TestUtils.getMockPlayer("combat_bork_legion_target").use { player ->
|
||||
val origin = openHorizontalOrigin()
|
||||
val legionLocation = origin.transform(2, 0, 0)
|
||||
place(player, origin)
|
||||
|
||||
val legion = NPC.create(7135, legionLocation, player)
|
||||
legion.init()
|
||||
try {
|
||||
configureMelee(legion)
|
||||
|
||||
assertTrue(CombatReach.hasExtendedMeleeReach(legion))
|
||||
assertEquals(2, CombatReach.meleeDistance(legion))
|
||||
assertEquals(
|
||||
InteractionType.STILL_INTERACT,
|
||||
legion.getSwingHandler(false).canSwing(legion, player),
|
||||
"Bork's legion should be able to swing from two clear tiles away.",
|
||||
)
|
||||
|
||||
legion.attack(player)
|
||||
CombatMovementIntents.clear()
|
||||
CombatMovementIntents.request(legion, player)
|
||||
CombatMovementIntents.resolve()
|
||||
legion.walkingQueue.update()
|
||||
|
||||
assertEquals(
|
||||
legionLocation,
|
||||
legion.location,
|
||||
"The combat movement intent must not force Bork's legion into adjacency.",
|
||||
)
|
||||
assertTrue(legion.properties.combatPulse.isAttacking)
|
||||
} finally {
|
||||
legion.clear()
|
||||
CombatMovementIntents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun magicAutocastShouldPathToProjectileClearLumbridgeRiverDuckTile() {
|
||||
TestUtils.getMockPlayer("combat_magic_duck_attacker").use { player ->
|
||||
|
|
@ -1849,6 +1987,76 @@ class CombatMovementTests {
|
|||
)
|
||||
}
|
||||
|
||||
private fun rangedApproachCandidates(
|
||||
attacker: Entity,
|
||||
target: Entity,
|
||||
range: Int,
|
||||
): List<Location> {
|
||||
val targetLocation = target.location
|
||||
val rangeSquared = range * range
|
||||
val candidates = ArrayList<Location>()
|
||||
for (x in targetLocation.x - range..targetLocation.x + target.size() - 1 + range) {
|
||||
for (y in targetLocation.y - range..targetLocation.y + target.size() - 1 + range) {
|
||||
val tile = Location.create(x, y, targetLocation.z)
|
||||
val closestX = x.coerceIn(targetLocation.x, targetLocation.x + target.size() - 1)
|
||||
val closestY = y.coerceIn(targetLocation.y, targetLocation.y + target.size() - 1)
|
||||
val targetDx = x - closestX
|
||||
val targetDy = y - closestY
|
||||
if (
|
||||
targetDx * targetDx + targetDy * targetDy <= rangeSquared &&
|
||||
RegionManager.isTeleportPermitted(tile)
|
||||
) {
|
||||
candidates.add(tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
.sortedWith(
|
||||
compareBy<Location> {
|
||||
val dx = it.x - attacker.location.x
|
||||
val dy = it.y - attacker.location.y
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
.thenBy {
|
||||
val closestX =
|
||||
it.x.coerceIn(targetLocation.x, targetLocation.x + target.size() - 1)
|
||||
val closestY =
|
||||
it.y.coerceIn(targetLocation.y, targetLocation.y + target.size() - 1)
|
||||
val dx = it.x - closestX
|
||||
val dy = it.y - closestY
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
.thenBy { it.x }
|
||||
.thenBy { it.y }
|
||||
)
|
||||
.filter {
|
||||
RsmodPathfinder.hasLineOfSightBetween(
|
||||
it,
|
||||
attacker.size(),
|
||||
targetLocation,
|
||||
target.size(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun perimeterEdges(tiles: Set<Location>): List<Pair<Location, Location>> {
|
||||
val edges = ArrayList<Pair<Location, Location>>()
|
||||
for (tile in tiles) {
|
||||
for (neighbour in
|
||||
listOf(
|
||||
tile.transform(0, 1, 0),
|
||||
tile.transform(1, 0, 0),
|
||||
tile.transform(0, -1, 0),
|
||||
tile.transform(-1, 0, 0),
|
||||
)) {
|
||||
if (neighbour !in tiles) {
|
||||
edges.add(tile to neighbour)
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
private fun enablePvp(first: Entity, second: Entity) {
|
||||
core.game.world.GameWorld.settings!!.wild_pvp_enabled = true
|
||||
first.asPlayer().skullManager.isWilderness = true
|
||||
|
|
|
|||
|
|
@ -151,6 +151,56 @@ class PathfinderTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun projectilePathfinderShouldTreatZeroSizedLocationDestinationsAsOneTile() {
|
||||
val start = Location.create(3204, 3204, 0)
|
||||
val destinations =
|
||||
listOf(
|
||||
Location.create(3202, 3204, 0),
|
||||
Location.create(3204, 3202, 0),
|
||||
)
|
||||
val fixtureTiles =
|
||||
(3201..3204).flatMap { x ->
|
||||
(3201..3204).map { y -> Location.create(x, y, 0) }
|
||||
}
|
||||
RegionManager.loadClippingWindow(start, 128)
|
||||
val originalFlags = fixtureTiles.associateWith {
|
||||
RegionManager.getProjectileFlag(it.z, it.x, it.y)
|
||||
}
|
||||
try {
|
||||
for (tile in fixtureTiles) {
|
||||
RegionManager.setRsmodFlag(tile.z, tile.x, tile.y, true, 0)
|
||||
}
|
||||
|
||||
for (destination in destinations) {
|
||||
val path =
|
||||
Pathfinder.PROJECTILE.find(
|
||||
start,
|
||||
1,
|
||||
destination,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
-1,
|
||||
0,
|
||||
false,
|
||||
null,
|
||||
)
|
||||
|
||||
Assertions.assertTrue(path.isSuccessful)
|
||||
Assertions.assertEquals(
|
||||
destination,
|
||||
Location.create(path.points.last.x, path.points.last.y, start.z),
|
||||
"A west/south projectile ray must stop on its zero-sized Location destination.",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
for ((tile, flag) in originalFlags) {
|
||||
RegionManager.setRsmodFlag(tile.z, tile.x, tile.y, true, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metadataSceneryInteractionShouldTriggerWhenAlreadyAtRsmodApproachTile() {
|
||||
TestUtils.getMockPlayer("bankBoothApproach").use { p ->
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue