Use RSMOD LOS in combat movement

- Add reusable RSMOD line-of-sight helpers for projectile checks
- Avoid hot movement prediction list allocations during combat ticks
- Use direct projectile LOS checks for combat movement, swings, and peltables
- Reuse loaded LOS windows during ranged and magic approach filtering
- Extend pathfinder tests for the direct RSMOD LOS helpers
This commit is contained in:
dam 2026-05-06 21:06:15 +03:00
parent bee7395f3e
commit 9c5a4c88d5
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
7 changed files with 328 additions and 118 deletions

View file

@ -7,7 +7,6 @@ import core.game.node.entity.impl.Projectile
import core.game.node.entity.player.Player
import core.game.node.item.Item
import core.game.system.task.Pulse
import core.game.world.map.path.Pathfinder
import core.game.world.update.flag.context.Graphics
import org.rs09.consts.Items
@ -47,7 +46,7 @@ class PlayerPeltables : InteractionListener {
val other = node.asPlayer()
if (!Pathfinder.find(player, other, false, Pathfinder.PROJECTILE).isSuccessful) {
if (!hasLineOfSight(player, other)) {
sendDialogue(player, "You can't reach them!")
return true
}
@ -112,4 +111,4 @@ class PlayerPeltables : InteractionListener {
return equipped
}
}
}

View file

@ -126,18 +126,13 @@ object CombatMovementIntents {
if (attacker.locks.isMovementLocked) {
return false
}
val targetSteps = CombatMovementPlanner.movementStepsThisTick(target)
if (targetSteps == 0) {
return false
}
val predictedTargetLocation = CombatMovementPlanner.predictTargetLocations(target, targetSteps).lastOrNull()
?: target.location
val predictedTargetLocation = CombatMovementPlanner.predictedMovementLocation(target) ?: return false
val borderTiles = CombatMovementPlanner.borderTiles(target, predictedTargetLocation, attacker.size())
val attackTiles = borderTiles.filter { RegionManager.isTeleportPermitted(it) }.ifEmpty { borderTiles }
if (attacker.location in attackTiles) {
return false
}
val projectedAttackerLocation = CombatMovementPlanner.predictTargetLocations(attacker).lastOrNull()
val projectedAttackerLocation = CombatMovementPlanner.predictedMovementLocation(attacker) ?: attacker.location
return projectedAttackerLocation !in attackTiles
}
@ -185,8 +180,8 @@ object CombatMovementIntents {
val candidates = movementDestinationsFor(attacker, target, targetLocation)
val standingOnCandidate = candidates.any { it.location == attacker.location }
val projectedAttackerLocation = CombatMovementPlanner.predictTargetLocations(attacker).lastOrNull()
if (projectedAttackerLocation != null && canAttackFrom(attacker, target, projectedAttackerLocation, targetLocation)) {
val projectedAttackerLocation = CombatMovementPlanner.predictedMovementLocation(attacker) ?: attacker.location
if (projectedAttackerLocation != attacker.location && canAttackFrom(attacker, target, projectedAttackerLocation, targetLocation)) {
attacker.face(target)
projectedLocations[attacker] = projectedAttackerLocation
reservedTiles.addAll(occupiedTiles(attacker, projectedAttackerLocation))
@ -282,10 +277,10 @@ object CombatMovementIntents {
if (range <= CombatReach.meleeDistance(attacker)) {
return true
}
if (CombatMovementPlanner.movementStepsThisTick(target) > 0) {
if (CombatMovementPlanner.hasMovementStepThisTick(target)) {
return true
}
return attacker.location.getDistance(closestOccupiedTile(target, targetLocation, attacker.location)) > range
return distanceSquaredToClosestOccupiedTile(target, targetLocation, attacker.location) > range * range
}
private fun playerAttackRangeDestinations(
@ -342,21 +337,21 @@ object CombatMovementIntents {
if (!RegionManager.isTeleportPermitted(tile)) {
continue
}
val closestTargetTile = closestOccupiedTile(target, targetLocation, tile)
if (tile.getDistance(closestTargetTile) <= range) {
if (distanceSquaredToClosestOccupiedTile(target, targetLocation, tile) <= range * range) {
tiles.add(tile)
}
}
}
tiles.sortWith(
compareBy<Location> { it.getDistance(attacker.location) }
.thenBy { it.getDistance(closestOccupiedTile(target, targetLocation, it)) }
compareBy<Location> { distanceSquared(it, attacker.location) }
.thenBy { distanceSquaredToClosestOccupiedTile(target, targetLocation, it) }
.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)) {
if (!hasProjectileLineOfSight(tile, attacker.size(), target, targetLocation, loadWindow = false)) {
continue
}
attackTiles.add(tile)
@ -378,7 +373,7 @@ object CombatMovementIntents {
return false
}
if (distance > 1 &&
attackerLocation.getDistance(closestOccupiedTile(target, targetLocation, attackerLocation)) > distance
distanceSquaredToClosestOccupiedTile(target, targetLocation, attackerLocation) > distance * distance
) {
return false
}
@ -406,7 +401,7 @@ object CombatMovementIntents {
if (attacker.properties.combatPulse.style == CombatStyle.MAGIC) 10 else 7
)
}
return attackerLocation.getDistance(closestOccupiedTile(target, targetLocation, attackerLocation)) <= range &&
return distanceSquaredToClosestOccupiedTile(target, targetLocation, attackerLocation) <= range * range &&
hasProjectileLineOfSight(attackerLocation, attacker.size(), target, targetLocation)
}
@ -478,42 +473,40 @@ object CombatMovementIntents {
attackerSize: Int,
target: Entity,
targetLocation: Location,
checkClose: Boolean = false
checkClose: Boolean = false,
loadWindow: Boolean = true
): 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 && (!checkClose || path.points.size <= 1)) {
return true
}
}
}
}
val maxRaySteps = if (checkClose) 1 else Int.MAX_VALUE
if (!loadWindow) {
return RsmodPathfinder.hasLineOfSightBetweenLoaded(
attackerLocation,
attackerSize,
targetLocation,
target.size(),
maxRaySteps = maxRaySteps
)
}
return false
return RsmodPathfinder.hasLineOfSightBetween(
attackerLocation,
attackerSize,
targetLocation,
target.size(),
maxRaySteps = maxRaySteps
)
}
private fun closestOccupiedTile(entity: Entity, location: Location, from: Location): Location {
return Location.create(
from.x.coerceIn(location.x, location.x + entity.size() - 1),
from.y.coerceIn(location.y, location.y + entity.size() - 1),
location.z
)
private fun distanceSquared(first: Location, second: Location): Int {
val dx = first.x - second.x
val dy = first.y - second.y
return dx * dx + dy * dy
}
private fun distanceSquaredToClosestOccupiedTile(entity: Entity, location: Location, from: Location): Int {
val closestX = from.x.coerceIn(location.x, location.x + entity.size() - 1)
val closestY = from.y.coerceIn(location.y, location.y + entity.size() - 1)
val dx = from.x - closestX
val dy = from.y - closestY
return dx * dx + dy * dy
}
private fun dumbNpcAttackDestinations(attacker: NPC, target: Entity): List<Location> {
@ -675,9 +668,7 @@ object CombatMovementIntents {
}
private fun targetLocationFor(attacker: Entity, target: Entity): Location {
val targetSteps = CombatMovementPlanner.movementStepsThisTick(target)
val predictionSteps = targetSteps.coerceAtMost(movementStepsFor(attacker))
return CombatMovementPlanner.predictTargetLocations(target, predictionSteps).lastOrNull() ?: target.location
return CombatMovementPlanner.predictedTargetLocation(target, movementStepsFor(attacker))
}
private fun movementStepsFor(attacker: Entity): Int {
@ -697,7 +688,7 @@ object CombatMovementIntents {
target: Entity,
exhaustedLocalApproach: Boolean = false
): Boolean {
return attacker is Player && (exhaustedLocalApproach || CombatMovementPlanner.movementStepsThisTick(target) == 0)
return attacker is Player && (exhaustedLocalApproach || !CombatMovementPlanner.hasMovementStepThisTick(target))
}
@JvmStatic

View file

@ -38,7 +38,12 @@ object CombatMovementPlanner {
@JvmStatic
fun predictedTargetLocation(target: Entity): Location {
return predictTargetLocations(target).lastOrNull() ?: target.location
return predictedTargetLocation(target, 2)
}
@JvmStatic
fun predictedTargetLocation(target: Entity, maxSteps: Int): Location {
return predictedMovementLocation(target, maxSteps) ?: target.location
}
@JvmStatic
@ -51,30 +56,58 @@ object CombatMovementPlanner {
if (maxSteps <= 0) {
return emptyList()
}
val queued = movementPoints(target, 2)
if (queued.isEmpty()) {
var first: Point? = null
var second: Point? = null
for (point in target.walkingQueue.queue) {
if (point.direction == null) {
continue
}
if (first == null) {
first = point
} else {
second = point
break
}
}
val firstPoint = first ?: return emptyList()
val steps = movementStepsThisTick(target, firstPoint, second).coerceAtMost(maxSteps)
if (steps <= 0) {
return emptyList()
}
val steps = if (canMoveTwoStepsThisTick(target, queued)) maxSteps.coerceAtMost(2) else 1
val predicted = ArrayList<Location>(steps)
for (i in 0 until steps) {
val point = queued[i]
predicted.add(Location.create(point.x, point.y, target.location.z))
predicted.add(Location.create(firstPoint.x, firstPoint.y, target.location.z))
if (steps > 1 && second != null) {
predicted.add(Location.create(second.x, second.y, target.location.z))
}
return predicted
}
@JvmStatic
fun movementStepsThisTick(target: Entity): Int {
val queued = movementPoints(target, 2)
if (queued.isEmpty()) {
return 0
}
return if (canMoveTwoStepsThisTick(target, queued)) {
2
} else {
1
var first: Point? = null
var second: Point? = null
for (point in target.walkingQueue.queue) {
if (point.direction == null) {
continue
}
if (first == null) {
first = point
} else {
second = point
break
}
}
return movementStepsThisTick(target, first ?: return 0, second)
}
@JvmStatic
fun hasMovementStepThisTick(target: Entity): Boolean {
return firstMovementPoint(target) != null
}
@JvmStatic
fun predictedMovementLocation(target: Entity): Location? {
return predictedMovementLocation(target, 2)
}
@JvmStatic
@ -135,22 +168,45 @@ object CombatMovementPlanner {
return border.toList()
}
private fun movementPoints(target: Entity, limit: Int): List<Point> {
val points = ArrayList<Point>(limit)
@JvmStatic
fun predictedMovementLocation(target: Entity, maxSteps: Int): Location? {
if (maxSteps <= 0) {
return null
}
var first: Point? = null
var second: Point? = null
for (point in target.walkingQueue.queue) {
if (point.direction == null) {
continue
}
points.add(point)
if (points.size >= limit) {
if (first == null) {
first = point
} else {
second = point
break
}
}
return points
val firstPoint = first ?: return null
val steps = movementStepsThisTick(target, firstPoint, second).coerceAtMost(maxSteps)
val point = if (steps > 1 && second != null) second else firstPoint
return Location.create(point.x, point.y, target.location.z)
}
private fun canMoveTwoStepsThisTick(target: Entity, queued: List<Point>): Boolean {
if (queued.size <= 1 || queued.first().isRunDisabled || !target.walkingQueue.isRunningBoth) {
private fun firstMovementPoint(target: Entity): Point? {
for (point in target.walkingQueue.queue) {
if (point.direction != null) {
return point
}
}
return null
}
private fun movementStepsThisTick(target: Entity, first: Point, second: Point?): Int {
return if (canMoveTwoStepsThisTick(target, first, second)) 2 else 1
}
private fun canMoveTwoStepsThisTick(target: Entity, first: Point, second: Point?): Boolean {
if (second == null || first.isRunDisabled || !target.walkingQueue.isRunningBoth) {
return false
}
return target !is Player || target.settings.runEnergy >= 1.0

View file

@ -117,7 +117,7 @@ class CombatPulse(
false
} else {
val timedOut = combatTimeOut++ > entity.properties.combatTimeOut
if (timedOut && entity is Player && CombatMovementPlanner.movementStepsThisTick(victim!!) == 0) {
if (timedOut && entity is Player && !CombatMovementPlanner.hasMovementStepThisTick(victim!!)) {
CombatMovementIntents.stopUnreachableCombat(entity)
}
timedOut
@ -209,7 +209,7 @@ class CombatPulse(
return false
}
if (CombatMovementPlanner.exceedsCombatChaseDistance(attacker, target)) {
if (attacker is Player && CombatMovementPlanner.movementStepsThisTick(target) == 0) {
if (attacker is Player && !CombatMovementPlanner.hasMovementStepThisTick(target)) {
CombatMovementIntents.stopUnreachableCombat(attacker)
} else {
stop()

View file

@ -13,8 +13,7 @@ import core.game.node.entity.skill.Skills
import content.global.skill.summoning.familiar.Familiar
import core.api.log
import core.api.playGlobalAudio
import core.game.world.map.RegionManager
import core.game.world.map.path.Pathfinder.*
import core.game.world.map.path.RsmodPathfinder
import core.game.world.update.flag.context.Animation
import core.tools.RandomFunction
import core.game.system.config.ItemConfigParser
@ -576,21 +575,14 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
*/
@JvmStatic
fun isProjectileClipped(entity: Node, victim: Node?, checkClose: Boolean): Boolean {
for(x1 in 0 until entity.size()) {
for(y1 in 0 until entity.size()) {
val src = entity.location.transform(x1, y1, 0)
for(x2 in 0 until victim!!.size()) {
for(y2 in 0 until victim!!.size()) {
val dst = victim!!.location.transform(x2, y2, 0)
val path = PROJECTILE.find(src, 1, dst, 1, 1, 0, 0, 0, false, RegionManager::getClippingFlag)
if(path.isSuccessful && (!checkClose || path.points.size <= 1)) {
return true
}
}
}
}
}
return false
val target = requireNotNull(victim)
return RsmodPathfinder.hasLineOfSightBetween(
entity.location,
entity.size(),
target.location,
target.size(),
maxRaySteps = if (checkClose) 1 else Int.MAX_VALUE
)
}
}

View file

@ -5,16 +5,24 @@ import core.api.utils.Vector
import core.game.world.map.Location
import core.game.world.map.Point
import core.game.world.map.RegionManager
import org.rsmod.game.pathfinder.LinePathFinder
import org.rsmod.game.pathfinder.PathFinder
import org.rsmod.game.pathfinder.RayCast
import org.rsmod.game.pathfinder.collision.CollisionFlagMap
import org.rsmod.game.pathfinder.reach.ReachStrategy
import kotlin.math.floor
private const val SEARCH_MAP_SIZE = 128
private const val RING_BUFFER_SIZE = 4096
class RsmodPathfinder : Pathfinder() {
class RsmodPathfinder(
private val maxWaypoints: Int = 25
) : Pathfinder() {
private val routeFinder = ThreadLocal.withInitial { RouteFinderState() }
private val defaultFinder = ThreadLocal.withInitial {
PathFinder(RegionManager.RSMOD_CLIPPING_FLAGS, SEARCH_MAP_SIZE, RING_BUFFER_SIZE)
}
private val suppliedFinder = ThreadLocal.withInitial { RouteFinderState() }
override fun find(
start: Location?,
@ -30,16 +38,13 @@ class RsmodPathfinder : Pathfinder() {
): Path {
val source = requireNotNull(start)
val destination = requireNotNull(dest)
val supplier = clipMaskSupplier ?: ClipMaskSupplier { z, x, y ->
RegionManager.getClippingFlag(z, x, y)
}
val path = Path()
var end = destination
val vector = Vector.betweenLocs(source, destination)
val magnitude = floor(vector.magnitude())
if (magnitude > ServerConstants.MAX_PATHFIND_DISTANCE) {
if (magnitude < 50.0) {
if (canAttempt(source, destination)) {
end = source.transform(vector.normalized() * (ServerConstants.MAX_PATHFIND_DISTANCE - 1))
} else {
path.isMoveNear = true
@ -47,15 +52,16 @@ class RsmodPathfinder : Pathfinder() {
}
}
val state = routeFinder.get()
state.loadCollisionWindow(source, supplier)
val shape = when {
type != 0 -> type - 1
sizeX != 0 && sizeY != 0 -> 10
else -> -1
val shape = routeShape(type, sizeX, sizeY)
val finder = if (clipMaskSupplier == null) {
RegionManager.loadClippingWindow(source, SEARCH_MAP_SIZE)
defaultFinder.get()
} else {
val state = suppliedFinder.get()
state.loadCollisionWindow(source, clipMaskSupplier)
state.finder
}
val route = state.finder.findPath(
val route = finder.findPath(
level = source.z,
srcX = source.x,
srcZ = source.y,
@ -67,26 +73,180 @@ class RsmodPathfinder : Pathfinder() {
objRot = rotation,
objShape = shape,
moveNear = near,
blockAccessFlags = walkingFlag
blockAccessFlags = walkingFlag,
maxWaypoints = maxWaypoints
)
if (route.failed) {
return path
}
var currentX = source.x
var currentY = source.y
path.points.add(Point(currentX, currentY))
for (waypoint in route.waypoints) {
path.points.add(Point(waypoint.x, waypoint.z))
while (currentX != waypoint.x || currentY != waypoint.z) {
currentX += waypoint.x.compareTo(currentX)
currentY += waypoint.z.compareTo(currentY)
path.points.add(Point(currentX, currentY))
}
}
path.setSuccesful(true)
path.isMoveNear = route.alternative || end != destination
return path
}
private class RouteFinderState {
val flags = CollisionFlagMap()
val finder = PathFinder(flags, SEARCH_MAP_SIZE, RING_BUFFER_SIZE)
companion object {
@JvmStatic
fun canAttempt(start: Location, dest: Location): Boolean {
val distance = floor(Vector.betweenLocs(start, dest).magnitude())
return distance < ServerConstants.MAX_PATHFIND_DISTANCE * 2.0
}
fun loadCollisionWindow(start: Location, supplier: ClipMaskSupplier) {
@JvmStatic
fun canReach(
srcX: Int,
srcY: Int,
moverSize: Int,
destX: Int,
destY: Int,
destWidth: Int,
destHeight: Int,
rotation: Int,
type: Int,
walkingFlag: Int,
z: Int,
clipMaskSupplier: ClipMaskSupplier?
): Boolean {
val flags = if (clipMaskSupplier == null) {
RegionManager.loadClippingWindow(Location.create(srcX, srcY, z), SEARCH_MAP_SIZE)
RegionManager.RSMOD_CLIPPING_FLAGS
} else {
CollisionFlagMap().also {
loadCollisionWindow(
flags = it,
start = Location.create(srcX, srcY, z),
supplier = clipMaskSupplier
)
}
}
return ReachStrategy.reached(
flags = flags,
level = z,
srcX = srcX,
srcZ = srcY,
destX = destX,
destZ = destY,
destWidth = if (destWidth == 0) 1 else destWidth,
destHeight = if (destHeight == 0) 1 else destHeight,
srcSize = moverSize,
objRot = rotation,
objShape = routeShape(type, destWidth, destHeight),
blockAccessFlags = walkingFlag
)
}
@JvmStatic
fun lineOfSight(
start: Location,
dest: Location,
moverSize: Int,
destWidth: Int,
destHeight: Int
): RayCast {
RegionManager.loadClippingWindow(start, SEARCH_MAP_SIZE)
return lineOfSightLoaded(start, dest, moverSize, destWidth, destHeight)
}
private fun lineOfSightLoaded(
start: Location,
dest: Location,
moverSize: Int,
destWidth: Int,
destHeight: Int
): RayCast {
return projectileLineFinder.get().lineOfSight(
level = start.z,
srcX = start.x,
srcZ = start.y,
destX = dest.x,
destZ = dest.y,
srcSize = moverSize,
destWidth = destWidth,
destHeight = destHeight
)
}
@JvmStatic
fun hasLineOfSight(
start: Location,
dest: Location,
moverSize: Int,
destWidth: Int,
destHeight: Int,
maxRaySteps: Int = Int.MAX_VALUE
): Boolean {
val rayCast = lineOfSight(start, dest, moverSize, destWidth, destHeight)
return rayCast.success && rayCast.coordinates.size <= maxRaySteps
}
@JvmStatic
fun hasLineOfSightBetween(
sourceLocation: Location,
sourceSize: Int,
targetLocation: Location,
targetSize: Int,
maxRaySteps: Int = Int.MAX_VALUE
): Boolean {
RegionManager.loadClippingWindow(sourceLocation, SEARCH_MAP_SIZE)
return hasLineOfSightBetweenLoaded(sourceLocation, sourceSize, targetLocation, targetSize, maxRaySteps)
}
@JvmStatic
fun loadLineOfSightWindow(center: Location) {
RegionManager.loadClippingWindow(center, SEARCH_MAP_SIZE)
}
@JvmStatic
fun hasLineOfSightBetweenLoaded(
sourceLocation: Location,
sourceSize: Int,
targetLocation: Location,
targetSize: Int,
maxRaySteps: Int = Int.MAX_VALUE
): Boolean {
for (sourceX in 0 until sourceSize) {
for (sourceY in 0 until sourceSize) {
val source = sourceLocation.transform(sourceX, sourceY, 0)
for (targetX in 0 until targetSize) {
for (targetY in 0 until targetSize) {
val destination = targetLocation.transform(targetX, targetY, 0)
val rayCast = lineOfSightLoaded(source, destination, 1, 1, 1)
if (rayCast.success && rayCast.coordinates.size <= maxRaySteps) {
return true
}
}
}
}
}
return false
}
private fun routeShape(type: Int, sizeX: Int, sizeY: Int): Int = when {
type >= 0 -> type
sizeX != 0 && sizeY != 0 -> 10
else -> -1
}
private val projectileLineFinder = ThreadLocal.withInitial {
LinePathFinder(RegionManager.RSMOD_PROJECTILE_FLAGS)
}
private fun loadCollisionWindow(
flags: CollisionFlagMap,
start: Location,
supplier: ClipMaskSupplier
) {
val baseX = start.x - (SEARCH_MAP_SIZE / 2)
val baseY = start.y - (SEARCH_MAP_SIZE / 2)
for (x in baseX until baseX + SEARCH_MAP_SIZE) {
@ -96,4 +256,13 @@ class RsmodPathfinder : Pathfinder() {
}
}
}
private class RouteFinderState {
val flags = CollisionFlagMap()
val finder = PathFinder(flags, SEARCH_MAP_SIZE, RING_BUFFER_SIZE)
fun loadCollisionWindow(start: Location, supplier: ClipMaskSupplier) {
loadCollisionWindow(flags, start, supplier)
}
}
}

View file

@ -103,6 +103,9 @@ class PathfinderTests {
val blocked = Pathfinder.PROJECTILE.find(start, 1, dest, 0, 0, 0, -1, 0, false, null)
Assertions.assertFalse(blocked.isSuccessful)
Assertions.assertFalse(RsmodPathfinder.hasLineOfSightBetween(start, 1, dest, 1))
RsmodPathfinder.loadLineOfSightWindow(start)
Assertions.assertFalse(RsmodPathfinder.hasLineOfSightBetweenLoaded(start, 1, dest, 1))
} finally {
RegionManager.setRsmodFlag(0, 3201, 3200, true, 0)
}