mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2026-08-28 05:45:10 -06:00
Added combat movement intent resolution
Replaced CombatPulse's private MovementPulse pathing with an engine-side CombatMovementIntents queue resolved before normal walking queues. Exposed candidate attack tiles from CombatMovementPlanner so intents can select and reserve deterministic melee approach paths. Enabled the first pending combat movement scenario using an open wilderness fixture, added intent resolver coverage, and updated the combat movement rewrite living document.
This commit is contained in:
parent
71e8812124
commit
eed4fd4888
6 changed files with 187 additions and 21 deletions
|
|
@ -0,0 +1,138 @@
|
|||
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.map.Location
|
||||
import core.game.world.map.Point
|
||||
import core.game.world.map.path.Path
|
||||
import core.game.world.map.path.Pathfinder
|
||||
import java.util.LinkedHashMap
|
||||
|
||||
/**
|
||||
* Collects combat movement requests during pulse updates and applies them before
|
||||
* walking queues are ticked.
|
||||
*/
|
||||
object CombatMovementIntents {
|
||||
private data class Intent(val attacker: Entity, val target: Entity)
|
||||
private data class CandidatePath(val path: Path, val projectedLocation: Location)
|
||||
|
||||
private val intents = LinkedHashMap<Entity, Intent>()
|
||||
|
||||
@JvmStatic
|
||||
fun request(attacker: Entity, target: Entity) {
|
||||
if (!attacker.isActive || !target.isActive || attacker.locks.isMovementLocked) {
|
||||
return
|
||||
}
|
||||
if (attacker is NPC && attacker.isNeverWalks) {
|
||||
return
|
||||
}
|
||||
intents[attacker] = Intent(attacker, target)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun resolve() {
|
||||
if (intents.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val pending = intents.values.sortedWith(
|
||||
compareBy<Intent> { it.attacker.index }
|
||||
.thenBy { it.target.index }
|
||||
)
|
||||
intents.clear()
|
||||
|
||||
val reservedTiles = LinkedHashSet<Location>()
|
||||
for (intent in pending) {
|
||||
resolve(intent, reservedTiles)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun clear() {
|
||||
intents.clear()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun pendingCount(): Int {
|
||||
return intents.size
|
||||
}
|
||||
|
||||
private fun resolve(intent: Intent, reservedTiles: MutableSet<Location>) {
|
||||
val attacker = intent.attacker
|
||||
val target = intent.target
|
||||
if (!canResolve(attacker, target)) {
|
||||
return
|
||||
}
|
||||
|
||||
val plan = CombatMovementPlanner.plan(attacker, target)
|
||||
val candidates = CombatMovementPlanner.candidateAttackTiles(attacker, target, plan.targetLocation)
|
||||
for (candidate in candidates) {
|
||||
val candidatePath = pathTo(attacker, candidate) ?: continue
|
||||
val projectedTiles = occupiedTiles(attacker, candidatePath.projectedLocation)
|
||||
if (projectedTiles.any { it in reservedTiles }) {
|
||||
continue
|
||||
}
|
||||
|
||||
candidatePath.path.walk(attacker)
|
||||
attacker.face(target)
|
||||
reservedTiles.addAll(projectedTiles)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private fun canResolve(attacker: Entity, target: Entity): Boolean {
|
||||
if (!attacker.isActive || !target.isActive || attacker.location == null || target.location == null) {
|
||||
return false
|
||||
}
|
||||
if (attacker.location.z != target.location.z || attacker.locks.isMovementLocked) {
|
||||
return false
|
||||
}
|
||||
if (attacker.properties.combatPulse.getVictim() !== target || !attacker.properties.combatPulse.isAttacking) {
|
||||
return false
|
||||
}
|
||||
return attacker !is NPC || !attacker.isNeverWalks
|
||||
}
|
||||
|
||||
private fun pathTo(attacker: Entity, destination: Location): CandidatePath? {
|
||||
if (attacker.location == destination) {
|
||||
return null
|
||||
}
|
||||
|
||||
val path = Pathfinder.find(attacker, destination, false, pathfinderFor(attacker))
|
||||
val projected = projectedMovementLocation(attacker, path) ?: return null
|
||||
return CandidatePath(path, projected)
|
||||
}
|
||||
|
||||
private fun projectedMovementLocation(attacker: Entity, path: Path): Location? {
|
||||
val points = path.points.filter { it.x != attacker.location.x || it.y != attacker.location.y }
|
||||
if (points.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val steps = if (attacker.walkingQueue.isRunningBoth && points.size > 1) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
val point: Point = points.take(steps).last()
|
||||
return Location.create(point.x, point.y, attacker.location.z)
|
||||
}
|
||||
|
||||
private fun occupiedTiles(entity: Entity, location: Location): List<Location> {
|
||||
val tiles = ArrayList<Location>(entity.size() * entity.size())
|
||||
for (x in 0 until entity.size()) {
|
||||
for (y in 0 until entity.size()) {
|
||||
tiles.add(location.transform(x, y, 0))
|
||||
}
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
private fun pathfinderFor(attacker: Entity): Pathfinder {
|
||||
return when (attacker) {
|
||||
is Player -> Pathfinder.SMART
|
||||
is NPC -> attacker.behavior.getPathfinderOverride(attacker) ?: Pathfinder.DUMB
|
||||
else -> Pathfinder.DUMB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,9 +66,14 @@ object CombatMovementPlanner {
|
|||
|
||||
@JvmStatic
|
||||
fun chooseTargetBorderTile(attacker: Entity, target: Entity, targetLocation: Location): Location? {
|
||||
return candidateAttackTiles(attacker, target, targetLocation).firstOrNull()
|
||||
}
|
||||
|
||||
@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 }).minWithOrNull(
|
||||
return (reachable.ifEmpty { candidates }).sortedWith(
|
||||
compareBy<Location> { it.getDistance(attacker.location) }
|
||||
.thenBy { it.x }
|
||||
.thenBy { it.y }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package core.game.node.entity.combat
|
|||
import content.global.ame.RandomEventNPC
|
||||
import content.global.handlers.item.equipment.special.SalamanderSwingHandler
|
||||
import core.game.container.impl.EquipmentContainer
|
||||
import core.game.interaction.MovementPulse
|
||||
import core.game.node.Node
|
||||
import core.game.node.entity.Entity
|
||||
import core.game.node.entity.combat.equipment.WeaponInterface
|
||||
|
|
@ -17,7 +16,6 @@ import core.game.world.GameWorld
|
|||
import core.game.world.update.flag.context.Animation
|
||||
import core.tools.RandomFunction
|
||||
import core.api.*
|
||||
import core.game.interaction.DestinationFlag
|
||||
import core.game.system.timer.impl.*
|
||||
|
||||
/**
|
||||
|
|
@ -93,11 +91,6 @@ class CombatPulse(
|
|||
*/
|
||||
private var combatTimeOut = 0
|
||||
|
||||
/**
|
||||
* The movement handling pulse.
|
||||
*/
|
||||
private val movement: MovementPulse
|
||||
|
||||
/**
|
||||
* The last attack sent.
|
||||
*/
|
||||
|
|
@ -217,7 +210,7 @@ class CombatPulse(
|
|||
if (entity == null || victim == null || entity.locks.isMovementLocked) {
|
||||
return false
|
||||
}
|
||||
movement.updatePath()
|
||||
CombatMovementIntents.request(entity, victim!!)
|
||||
return type == InteractionType.MOVE_INTERACT
|
||||
}
|
||||
|
||||
|
|
@ -311,8 +304,6 @@ class CombatPulse(
|
|||
*/
|
||||
fun setVictim(victim: Node?) {
|
||||
super.addNodeCheck(1, victim)
|
||||
movement.setLast(null)
|
||||
movement.setDestination(victim)
|
||||
this.victim = victim as Entity?
|
||||
combatTimeOut = 0
|
||||
}
|
||||
|
|
@ -472,11 +463,4 @@ class CombatPulse(
|
|||
}
|
||||
}
|
||||
|
||||
init {
|
||||
movement = object : MovementPulse(entity, null) {
|
||||
override fun pulse(): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import core.ServerConstants
|
|||
import core.ServerStore
|
||||
import core.api.log
|
||||
import core.api.submitWorldPulse
|
||||
import core.game.node.entity.combat.CombatMovementIntents
|
||||
import core.game.system.task.Pulse
|
||||
import core.game.world.GameWorld
|
||||
import core.game.world.repository.Repository
|
||||
|
|
@ -118,6 +119,7 @@ class MajorUpdateWorker {
|
|||
GameWorld.Pulser.updateAll()
|
||||
}
|
||||
GameWorld.tickListeners.forEach { it.tick() }
|
||||
CombatMovementIntents.resolve()
|
||||
|
||||
sequence.start()
|
||||
sequence.run()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package content
|
||||
|
||||
import TestUtils
|
||||
import core.ServerConstants
|
||||
import core.game.node.entity.Entity
|
||||
import core.game.node.entity.combat.equipment.WeaponInterface
|
||||
import core.game.node.entity.npc.NPC
|
||||
|
|
@ -13,7 +12,6 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
|||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@Disabled("Pending combat movement engine rewrite; see docs/combat-movement-rewrite.md")
|
||||
class CombatMovementTests {
|
||||
init {
|
||||
TestUtils.preTestSetup()
|
||||
|
|
@ -28,6 +26,7 @@ class CombatMovementTests {
|
|||
place(victim, origin.transform(1, 0, 0))
|
||||
configureMelee(attacker)
|
||||
configureMelee(victim)
|
||||
enablePvp(attacker, victim)
|
||||
|
||||
attacker.attack(victim)
|
||||
queueRun(victim, origin.transform(8, 0, 0))
|
||||
|
|
@ -42,6 +41,7 @@ class CombatMovementTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Disabled("Enable after the previous combat movement scenario is stable.")
|
||||
@Test
|
||||
fun mutualMeleeAttackersShouldApproachInsteadOfWaitingForTheOtherActor() {
|
||||
TestUtils.getMockPlayer("combat_meet_a").use { first ->
|
||||
|
|
@ -53,6 +53,7 @@ class CombatMovementTests {
|
|||
place(second, secondStart)
|
||||
configureMelee(first)
|
||||
configureMelee(second)
|
||||
enablePvp(first, second)
|
||||
|
||||
first.attack(second)
|
||||
second.attack(first)
|
||||
|
|
@ -70,6 +71,7 @@ class CombatMovementTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Disabled("Enable after the previous combat movement scenario is stable.")
|
||||
@Test
|
||||
fun playerShouldChaseMovingMeleeNpcWithoutGenericInteractionMovementPulse() {
|
||||
TestUtils.getMockPlayer("combat_npc_chaser").use { player ->
|
||||
|
|
@ -97,6 +99,7 @@ class CombatMovementTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Disabled("Enable after the previous combat movement scenario is stable.")
|
||||
@Test
|
||||
fun movementLockedMeleeAttackerShouldNotMoveButCanAttackIfAlreadyInRange() {
|
||||
TestUtils.getMockPlayer("combat_locked_attacker").use { attacker ->
|
||||
|
|
@ -106,6 +109,7 @@ class CombatMovementTests {
|
|||
place(victim, origin.transform(1, 0, 0))
|
||||
configureMelee(attacker)
|
||||
configureMelee(victim)
|
||||
enablePvp(attacker, victim)
|
||||
attacker.locks.lockMovement(10)
|
||||
|
||||
attacker.attack(victim)
|
||||
|
|
@ -120,6 +124,7 @@ class CombatMovementTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Disabled("Enable after the previous combat movement scenario is stable.")
|
||||
@Test
|
||||
fun meleeReachShouldUseOccupiedTilesForLargeTargets() {
|
||||
TestUtils.getMockPlayer("combat_large_target_attacker").use { player ->
|
||||
|
|
@ -148,7 +153,7 @@ class CombatMovementTests {
|
|||
}
|
||||
|
||||
private fun arenaOrigin(): Location {
|
||||
return ServerConstants.HOME_LOCATION!!.transform(32, 32, 0)
|
||||
return Location.create(3200, 3600, 0)
|
||||
}
|
||||
|
||||
private fun place(entity: Entity, location: Location) {
|
||||
|
|
@ -170,6 +175,14 @@ class CombatMovementTests {
|
|||
entity.properties.combatPulse.updateStyle()
|
||||
}
|
||||
|
||||
private fun enablePvp(first: Entity, second: Entity) {
|
||||
core.game.world.GameWorld.settings!!.wild_pvp_enabled = true
|
||||
first.asPlayer().skullManager.isWilderness = true
|
||||
first.asPlayer().skullManager.level = 50
|
||||
second.asPlayer().skullManager.isWilderness = true
|
||||
second.asPlayer().skullManager.level = 50
|
||||
}
|
||||
|
||||
private fun meleeReach(attacker: Entity, victim: Entity): Boolean {
|
||||
return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <= 1.0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import core.api.EquipmentSlot
|
|||
import core.game.container.impl.EquipmentContainer.updateBonuses
|
||||
import core.game.interaction.IntType
|
||||
import core.game.interaction.InteractionListeners
|
||||
import core.game.node.entity.combat.CombatMovementIntents
|
||||
import core.game.node.entity.combat.CombatMovementPlanner
|
||||
import core.game.node.entity.combat.CombatReach
|
||||
import core.game.node.entity.combat.MagicSwingHandler
|
||||
|
|
@ -183,4 +184,27 @@ class CombatTests {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun combatMovementIntentResolverAppliesPendingMeleePathBeforeEntityMovement() {
|
||||
TestUtils.getMockPlayer("combatIntentAttacker").use { attacker ->
|
||||
TestUtils.getMockPlayer("combatIntentVictim").use { victim ->
|
||||
val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0)
|
||||
attacker.location = origin
|
||||
victim.location = origin.transform(4, 0, 0)
|
||||
attacker.properties.attackStyle = WeaponInterface.AttackStyle(
|
||||
WeaponInterface.STYLE_AGGRESSIVE,
|
||||
WeaponInterface.BONUS_CRUSH
|
||||
)
|
||||
attacker.properties.combatPulse.updateStyle()
|
||||
attacker.attack(victim)
|
||||
|
||||
CombatMovementIntents.request(attacker, victim)
|
||||
CombatMovementIntents.resolve()
|
||||
|
||||
Assertions.assertTrue(attacker.walkingQueue.hasPath())
|
||||
Assertions.assertEquals(0, CombatMovementIntents.pendingCount())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue