mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2026-08-28 05:45:10 -06:00
Fixed dumb NPC melee safespot pathing
Default melee NPCs now use constrained target-facing combat tiles instead of scanning every border tile, preserving safespots around obstacles. Diagonal chases may use the two corner-adjacent sides so NPCs can step into range when a direct-ish tile is open, without routing around blocked corners. Added combat movement regressions for static safespots, one-tile obstacle chases, and diagonal corner cases.
This commit is contained in:
parent
64b6d858a0
commit
9f8faa3547
3 changed files with 249 additions and 17 deletions
|
|
@ -3,11 +3,12 @@ package core.game.node.entity.combat
|
|||
import core.game.node.entity.Entity
|
||||
import core.game.node.entity.npc.NPC
|
||||
import core.game.node.entity.player.Player
|
||||
import core.game.world.repository.Repository
|
||||
import core.game.world.map.Direction
|
||||
import core.game.world.map.Location
|
||||
import core.game.world.map.Point
|
||||
import core.game.world.map.path.Path
|
||||
import core.game.world.map.path.Pathfinder
|
||||
import core.game.world.repository.Repository
|
||||
import java.util.LinkedHashMap
|
||||
|
||||
/**
|
||||
|
|
@ -16,6 +17,11 @@ import java.util.LinkedHashMap
|
|||
*/
|
||||
object CombatMovementIntents {
|
||||
private data class Intent(val attacker: Entity, val target: Entity)
|
||||
private data class MovementDestination(
|
||||
val location: Location,
|
||||
val pathfinder: Pathfinder,
|
||||
val allowPartialPath: Boolean
|
||||
)
|
||||
private data class CandidatePath(val steps: List<Point>, val projectedLocation: Location)
|
||||
|
||||
private val intents = LinkedHashMap<Entity, Intent>()
|
||||
|
|
@ -99,8 +105,7 @@ object CombatMovementIntents {
|
|||
return
|
||||
}
|
||||
|
||||
val targetLocation = targetLocationFor(attacker, target)
|
||||
val candidates = CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation)
|
||||
val candidates = movementDestinationsFor(attacker, target)
|
||||
var blockedByReservation = false
|
||||
for (candidate in candidates) {
|
||||
val candidatePath = pathTo(attacker, candidate) ?: continue
|
||||
|
|
@ -115,7 +120,7 @@ object CombatMovementIntents {
|
|||
reservedTiles.addAll(projectedTiles)
|
||||
return
|
||||
}
|
||||
if (!blockedByReservation && CombatMovementPlanner.movementStepsThisTick(target) == 0) {
|
||||
if (!blockedByReservation && shouldStopUnreachableCombat(attacker, target)) {
|
||||
stopUnreachableCombat(attacker)
|
||||
}
|
||||
}
|
||||
|
|
@ -137,13 +142,76 @@ object CombatMovementIntents {
|
|||
return attacker !is NPC || !attacker.isNeverWalks
|
||||
}
|
||||
|
||||
private fun pathTo(attacker: Entity, destination: Location): CandidatePath? {
|
||||
if (attacker.location == destination) {
|
||||
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 {
|
||||
MovementDestination(it, pathfinder, allowPartialPath = true)
|
||||
}
|
||||
}
|
||||
|
||||
val targetLocation = targetLocationFor(attacker, target)
|
||||
return CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation).map {
|
||||
MovementDestination(it, pathfinder, allowPartialPath = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dumbNpcAttackDestinations(attacker: NPC, target: Entity): List<Location> {
|
||||
val directions = LinkedHashSet<Direction>()
|
||||
directions.add(Direction.getLogicalDirection(target.centerLocation, attacker.centerLocation))
|
||||
|
||||
if (attacker.centerLocation.x < target.centerLocation.x) {
|
||||
directions.add(Direction.WEST)
|
||||
} else if (attacker.centerLocation.x > target.centerLocation.x) {
|
||||
directions.add(Direction.EAST)
|
||||
}
|
||||
if (attacker.centerLocation.y < target.centerLocation.y) {
|
||||
directions.add(Direction.SOUTH)
|
||||
} else if (attacker.centerLocation.y > target.centerLocation.y) {
|
||||
directions.add(Direction.NORTH)
|
||||
}
|
||||
|
||||
return directions.map { dumbNpcAttackDestination(attacker, target, it) }
|
||||
}
|
||||
|
||||
private fun dumbNpcAttackDestination(attacker: NPC, target: Entity, direction: Direction): Location {
|
||||
val targetLocation = target.location
|
||||
val minAlignedX = targetLocation.x - attacker.size() + 1
|
||||
val maxAlignedX = targetLocation.x + target.size() - 1
|
||||
val minAlignedY = targetLocation.y - attacker.size() + 1
|
||||
val maxAlignedY = targetLocation.y + target.size() - 1
|
||||
|
||||
return when (direction) {
|
||||
Direction.WEST -> Location.create(
|
||||
targetLocation.x - attacker.size(),
|
||||
attacker.location.y.coerceIn(minAlignedY, maxAlignedY),
|
||||
targetLocation.z
|
||||
)
|
||||
Direction.EAST -> Location.create(
|
||||
targetLocation.x + target.size(),
|
||||
attacker.location.y.coerceIn(minAlignedY, maxAlignedY),
|
||||
targetLocation.z
|
||||
)
|
||||
Direction.SOUTH -> Location.create(
|
||||
attacker.location.x.coerceIn(minAlignedX, maxAlignedX),
|
||||
targetLocation.y - attacker.size(),
|
||||
targetLocation.z
|
||||
)
|
||||
else -> Location.create(
|
||||
attacker.location.x.coerceIn(minAlignedX, maxAlignedX),
|
||||
targetLocation.y + target.size(),
|
||||
targetLocation.z
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pathTo(attacker: Entity, destination: MovementDestination): CandidatePath? {
|
||||
if (attacker.location == destination.location) {
|
||||
return null
|
||||
}
|
||||
|
||||
val path = Pathfinder.find(attacker, destination, false, pathfinderFor(attacker))
|
||||
if (!path.reaches(destination)) {
|
||||
val path = Pathfinder.find(attacker, destination.location, destination.allowPartialPath, destination.pathfinder)
|
||||
if (!path.reaches(destination.location) && (!destination.allowPartialPath || path.points.isEmpty())) {
|
||||
return null
|
||||
}
|
||||
val steps = immediateMovementSteps(attacker, path)
|
||||
|
|
@ -197,6 +265,10 @@ 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 stopUnreachableCombat(attacker: Entity) {
|
||||
attacker.properties.combatPulse.stop()
|
||||
attacker.walkingQueue.reset()
|
||||
|
|
|
|||
|
|
@ -463,6 +463,155 @@ class CombatMovementTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dumbMeleeNpcShouldNotRouteAroundSafespotObstacle() {
|
||||
TestUtils.getMockPlayer("combat_safespot_target").use { player ->
|
||||
val origin = arenaOrigin()
|
||||
val blockedTiles = listOf(
|
||||
origin.transform(1, 0, 0),
|
||||
origin.transform(0, -1, 0)
|
||||
)
|
||||
place(player, origin.transform(4, 0, 0))
|
||||
|
||||
val npc = NPC.create(100, origin)
|
||||
npc.init()
|
||||
try {
|
||||
configureMelee(npc)
|
||||
blockMovementTiles(blockedTiles)
|
||||
|
||||
npc.attack(player)
|
||||
CombatMovementIntents.clear()
|
||||
CombatMovementIntents.request(npc, player)
|
||||
CombatMovementIntents.resolve()
|
||||
npc.walkingQueue.update()
|
||||
|
||||
assertEquals(
|
||||
origin,
|
||||
npc.location,
|
||||
"Default dumb NPC combat pathing should not choose alternate border tiles to route around safespots."
|
||||
)
|
||||
assertTrue(npc.properties.combatPulse.isAttacking)
|
||||
} finally {
|
||||
unblockMovementTiles(blockedTiles)
|
||||
npc.clear()
|
||||
CombatMovementIntents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dumbMeleeNpcShouldUseOpenCornerSideWhenDiagonallyFacingTarget() {
|
||||
TestUtils.getMockPlayer("combat_diagonal_corner_target").use { player ->
|
||||
val origin = arenaOrigin()
|
||||
val northSide = origin.transform(0, 1, 0)
|
||||
val westSide = origin.transform(-1, 0, 0)
|
||||
place(player, origin)
|
||||
|
||||
val npc = NPC.create(100, origin.transform(-1, 1, 0))
|
||||
npc.init()
|
||||
try {
|
||||
configureMelee(npc)
|
||||
blockMovementTiles(listOf(northSide))
|
||||
|
||||
npc.attack(player)
|
||||
CombatMovementIntents.clear()
|
||||
CombatMovementIntents.request(npc, player)
|
||||
CombatMovementIntents.resolve()
|
||||
npc.walkingQueue.update()
|
||||
|
||||
assertEquals(
|
||||
westSide,
|
||||
npc.location,
|
||||
"A diagonal dumb NPC should try the other corner-adjacent side when its preferred side is blocked."
|
||||
)
|
||||
assertTrue(meleeReach(npc, player))
|
||||
assertTrue(npc.properties.combatPulse.isAttacking)
|
||||
} finally {
|
||||
unblockMovementTiles(listOf(northSide))
|
||||
npc.clear()
|
||||
CombatMovementIntents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dumbMeleeNpcShouldNotRouteAroundWhenBothCornerSidesAreBlocked() {
|
||||
TestUtils.getMockPlayer("combat_blocked_diagonal_corner_target").use { player ->
|
||||
val origin = arenaOrigin()
|
||||
val blockedTiles = listOf(
|
||||
origin.transform(0, 1, 0),
|
||||
origin.transform(-1, 0, 0)
|
||||
)
|
||||
val npcStart = origin.transform(-1, 1, 0)
|
||||
place(player, origin)
|
||||
|
||||
val npc = NPC.create(100, npcStart)
|
||||
npc.init()
|
||||
try {
|
||||
configureMelee(npc)
|
||||
blockMovementTiles(blockedTiles)
|
||||
|
||||
npc.attack(player)
|
||||
CombatMovementIntents.clear()
|
||||
CombatMovementIntents.request(npc, player)
|
||||
CombatMovementIntents.resolve()
|
||||
npc.walkingQueue.update()
|
||||
|
||||
assertEquals(
|
||||
npcStart,
|
||||
npc.location,
|
||||
"A diagonal dumb NPC should not rotate to the far side when both corner-adjacent attack tiles are blocked."
|
||||
)
|
||||
assertTrue(npc.properties.combatPulse.isAttacking)
|
||||
} finally {
|
||||
unblockMovementTiles(blockedTiles)
|
||||
npc.clear()
|
||||
CombatMovementIntents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dumbMeleeNpcShouldPrioritizeDirectSideWhenTargetRunsBehindObstacle() {
|
||||
TestUtils.getMockPlayer("combat_running_safespot_target").use { player ->
|
||||
val origin = arenaOrigin()
|
||||
val obstacle = origin.transform(1, 0, 0)
|
||||
val playerEnd = origin.transform(2, 0, 0)
|
||||
val playerPath = listOf(
|
||||
origin.transform(0, 1, 0),
|
||||
origin.transform(1, 1, 0),
|
||||
origin.transform(2, 1, 0),
|
||||
playerEnd
|
||||
)
|
||||
place(player, origin)
|
||||
enableRun(player)
|
||||
|
||||
val npc = NPC.create(100, origin.transform(-1, 0, 0))
|
||||
npc.init()
|
||||
try {
|
||||
configureMelee(npc)
|
||||
blockMovementTiles(listOf(obstacle))
|
||||
|
||||
npc.attack(player)
|
||||
queueRunPath(player, playerPath)
|
||||
TestUtils.advanceTicks(6, false)
|
||||
|
||||
assertEquals(playerEnd, player.location)
|
||||
assertEquals(
|
||||
origin,
|
||||
npc.location,
|
||||
"Default dumb NPCs should pursue the target-facing side, then stall when that side is blocked."
|
||||
)
|
||||
assertFalse(meleeReach(npc, player))
|
||||
assertTrue(npc.properties.combatPulse.isAttacking)
|
||||
} finally {
|
||||
unblockMovementTiles(listOf(obstacle))
|
||||
npc.clear()
|
||||
CombatMovementIntents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun arenaOrigin(): Location {
|
||||
return Location.create(3200, 3600, 0)
|
||||
}
|
||||
|
|
@ -478,6 +627,13 @@ class CombatMovementTests {
|
|||
entity.walkingQueue.addPath(destination.x, destination.y)
|
||||
}
|
||||
|
||||
private fun queueRunPath(entity: Entity, path: List<Location>) {
|
||||
entity.walkingQueue.reset(true)
|
||||
for (location in path) {
|
||||
entity.walkingQueue.addPath(location.x, location.y)
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureMelee(entity: Entity) {
|
||||
entity.properties.attackStyle = WeaponInterface.AttackStyle(
|
||||
WeaponInterface.STYLE_AGGRESSIVE,
|
||||
|
|
|
|||
|
|
@ -128,10 +128,10 @@ Initial targets:
|
|||
still rely on their existing swing-distance checks to stop movement once they
|
||||
are in range; the shared intent resolver owns the chase path.
|
||||
- 2026-04-29: Enabled the first pending combat movement scenario:
|
||||
`meleeAttackerShouldMirrorRunningVictimAndKeepAttackPressure`. The fixture now
|
||||
uses an open wilderness arena and explicitly enables wilderness PvP for player
|
||||
versus player movement tests so combat pulses do not stop at attackability
|
||||
checks before movement can be exercised.
|
||||
`meleeAttackerShouldMirrorRunningVictimWhenRunEnabledAndKeepAttackPressure`.
|
||||
The fixture now uses an open wilderness arena and explicitly enables
|
||||
wilderness PvP for player versus player movement tests so combat pulses do not
|
||||
stop at attackability checks before movement can be exercised.
|
||||
- 2026-04-29: Enabled the remaining pending combat movement scenarios:
|
||||
mutual melee approach, player versus moving melee NPC chase, movement-locked
|
||||
in-range melee, and large-target occupied-tile melee reach. The focused
|
||||
|
|
@ -139,11 +139,9 @@ Initial targets:
|
|||
disabled tests.
|
||||
- 2026-04-29: Fixed the remaining melee chase gap against running targets. A
|
||||
melee attacker now submits chase pressure even while currently in melee range
|
||||
if the target's queued movement would leave that range, and combat intent
|
||||
resolution force-runs the attacker's chase path when the target's next
|
||||
movement tick is a run. Added a regression covering the active pulse/intent
|
||||
handoff so a player attacking a running-away target stays adjacent after both
|
||||
walking queues advance.
|
||||
if the target's queued movement would leave that range. Added a regression
|
||||
covering the active pulse/intent handoff so a run-enabled player attacking a
|
||||
running-away target stays adjacent after both walking queues advance.
|
||||
- 2026-04-29: Fixed the live PvP run-click ordering gap. The previous pressure
|
||||
check only happened inside the attacker's `CombatPulse`, so a victim's normal
|
||||
map-click `MovementPulse` could be queued later in the same pulser batch; the
|
||||
|
|
@ -153,3 +151,9 @@ Initial targets:
|
|||
tick listeners, then resolves intents before entity walking queues step. Added
|
||||
a regression that uses the real `WorldspaceWalk` packet path with both players
|
||||
run-enabled and wielding dragon scimitars.
|
||||
- 2026-04-30: Corrected combat intent movement so PvP melee chase respects the
|
||||
attacker's own run state. The resolver now clamps target prediction to the
|
||||
attacker's current walk/run capacity, treats zero run energy as walking, and
|
||||
no longer forces `WalkingQueue.reset(true)` when the attacker has run toggled
|
||||
off. If a moving target temporarily blocks an exact walking path, combat waits
|
||||
for the next tick instead of stopping as unreachable.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue