From 9328947e42564da0615b25d1bdc3c4259caa971d Mon Sep 17 00:00:00 2001 From: Zach Toogood Date: Tue, 16 Jun 2026 12:11:10 +0100 Subject: [PATCH] Core: Refactor PathFind This commit does _many_ things. - Remove "Wallhack" was a PathFind flag. Replace it with some stall-checking and teleport logic, and some force-despawn logic to curtail abusers. This will be sufficient for now until we really lock down exactly how retail handles this. - Retool PathFind to actually use the points given to it by NavMesh. It was using a "good enough" approach when travelling between points, which would lead to the entity dropping down holes, etc. - Add chunked pathing as an implementation detail of PathFind, allowing cheap and reliable long-distance pathing while only specifying two points. - Add LOS caching in MobController, so we're not constantly hammering ximesh with requests if nothing has changed. - Some other caching bits and bobs. - General refactoring and cleanup of PathFind, NavMesh, XiMesh. - Removing stray ligatures. - Add NO_STUCK_TELEPORT mobmod, to control the "I think I'm stuck, I'm just going to teleport straight to you now so you can't cheese" logic - Removed calls and bindings for "setCarefulPathing". We'll still use it for drawIn since that's based on rough math. We'll eventually (maybe) replace this sort of thing with navmesh::moveAlongSurface() - This will probably all work fine with the current navmeshes, but everything would be improved if you updated your navmeshes using the rebuild commands I added recently (Will soon update submodule) --- .gitignore | 1 + documentation/ai_agents/README.md | 2 +- .../battlefields/Boneyard_Gully/head_wind.lua | 18 +- .../Boneyard_Gully/requiem_of_sin.lua | 18 +- .../Boneyard_Gully/tango_with_a_tracker.lua | 4 +- scripts/commands/cansee.lua | 6 +- scripts/commands/fafnir.lua | 13 +- scripts/data/maze.lua | 4 +- scripts/enum/mob_mod.lua | 1 + scripts/globals/assault/container.lua | 4 +- scripts/globals/dynamis.lua | 4 - scripts/mixins/families/chariot.lua | 2 +- scripts/specs/core/CBaseEntity.lua | 8 +- scripts/zones/Attohwa_Chasm/mobs/Tiamat.lua | 1 - .../mobs/Bluestreak_Gyugyuroon.lua | 2 +- .../zones/Ifrits_Cauldron/mobs/Ash_Dragon.lua | 1 - .../zones/King_Ranperres_Tomb/mobs/Vrtra.lua | 1 - .../mobs/Old_Professor_Mariselle.lua | 2 - .../Uleguerand_Range/mobs/Jormungand.lua | 1 - src/common/types/cached.h | 56 + src/common/utils.cpp | 23 + src/common/utils.h | 5 + src/map/CMakeLists.txt | 11 +- src/map/ai/controllers/mob_controller.cpp | 1668 +++++++++-------- src/map/ai/controllers/mob_controller.h | 51 +- src/map/ai/controllers/pet_controller.cpp | 6 +- .../controllers/player_charm_controller.cpp | 4 +- src/map/ai/controllers/trust_controller.cpp | 64 +- src/map/ai/helpers/CMakeLists.txt | 4 +- src/map/ai/helpers/pathfind.cpp | 650 ------- src/map/ai/helpers/pathfind.h | 140 +- src/map/ai/helpers/pathfind/CMakeLists.txt | 18 + src/map/ai/helpers/pathfind/chunked_path.cpp | 101 + src/map/ai/helpers/pathfind/chunked_path.h | 79 + .../ai/helpers/pathfind/entity_path_owner.cpp | 122 ++ .../ai/helpers/pathfind/entity_path_owner.h | 59 + src/map/ai/helpers/pathfind/path.cpp | 172 ++ src/map/ai/helpers/pathfind/path.h | 92 + src/map/ai/helpers/pathfind/path_builder.cpp | 129 ++ src/map/ai/helpers/pathfind/path_builder.h | 56 + src/map/ai/helpers/pathfind/path_owner.h | 64 + src/map/ai/helpers/pathfind/pathfind.cpp | 639 +++++++ src/map/ai/helpers/pathfind/pathfind.h | 185 ++ .../ai/helpers/pathfind/pathfind_constants.h | 36 + src/map/ai/helpers/pathfind/pathfind_step.cpp | 92 + src/map/ai/helpers/pathfind/pathfind_step.h | 40 + src/map/ai/helpers/pathfind/pathfind_types.h | 34 + src/map/entities/mobentity.cpp | 8 + src/map/lua/lua_baseentity.cpp | 32 +- src/map/lua/lua_baseentity.h | 4 +- src/map/lua/luautils.cpp | 11 +- src/map/mob_modifier.h | 1 + src/map/navmesh/detour_navmesh.cpp | 615 ++++++ src/map/navmesh/detour_navmesh.h | 86 + src/map/navmesh/inavmesh.h | 80 - src/map/navmesh/navmesh.cpp | 889 --------- src/map/navmesh/navmesh.h | 74 +- src/map/navmesh/navmesh_builder.cpp | 4 +- src/map/navmesh/navmesh_builder.h | 14 +- src/map/navmesh/null_navmesh.cpp | 57 + src/map/navmesh/null_navmesh.h | 38 + src/map/packet_system.cpp | 2 +- .../packets/s2c/0x048_link_concierge_header.h | 2 +- src/map/ximesh/iximesh.h | 112 -- src/map/ximesh/null_ximesh.cpp | 81 + src/map/ximesh/null_ximesh.h | 42 + src/map/ximesh/ximesh.h | 62 +- .../ximesh/{ximesh.cpp => ximesh_impl.cpp} | 64 +- src/map/ximesh/ximesh_impl.h | 69 + src/map/zone.cpp | 18 +- src/map/zone.h | 16 +- 71 files changed, 4185 insertions(+), 2889 deletions(-) create mode 100644 src/common/types/cached.h delete mode 100644 src/map/ai/helpers/pathfind.cpp create mode 100644 src/map/ai/helpers/pathfind/CMakeLists.txt create mode 100644 src/map/ai/helpers/pathfind/chunked_path.cpp create mode 100644 src/map/ai/helpers/pathfind/chunked_path.h create mode 100644 src/map/ai/helpers/pathfind/entity_path_owner.cpp create mode 100644 src/map/ai/helpers/pathfind/entity_path_owner.h create mode 100644 src/map/ai/helpers/pathfind/path.cpp create mode 100644 src/map/ai/helpers/pathfind/path.h create mode 100644 src/map/ai/helpers/pathfind/path_builder.cpp create mode 100644 src/map/ai/helpers/pathfind/path_builder.h create mode 100644 src/map/ai/helpers/pathfind/path_owner.h create mode 100644 src/map/ai/helpers/pathfind/pathfind.cpp create mode 100644 src/map/ai/helpers/pathfind/pathfind.h create mode 100644 src/map/ai/helpers/pathfind/pathfind_constants.h create mode 100644 src/map/ai/helpers/pathfind/pathfind_step.cpp create mode 100644 src/map/ai/helpers/pathfind/pathfind_step.h create mode 100644 src/map/ai/helpers/pathfind/pathfind_types.h create mode 100644 src/map/navmesh/detour_navmesh.cpp create mode 100644 src/map/navmesh/detour_navmesh.h delete mode 100644 src/map/navmesh/inavmesh.h delete mode 100644 src/map/navmesh/navmesh.cpp create mode 100644 src/map/navmesh/null_navmesh.cpp create mode 100644 src/map/navmesh/null_navmesh.h delete mode 100644 src/map/ximesh/iximesh.h create mode 100644 src/map/ximesh/null_ximesh.cpp create mode 100644 src/map/ximesh/null_ximesh.h rename src/map/ximesh/{ximesh.cpp => ximesh_impl.cpp} (91%) create mode 100644 src/map/ximesh/ximesh_impl.h diff --git a/.gitignore b/.gitignore index e1b5e25db35..1d8e55748ba 100644 --- a/.gitignore +++ b/.gitignore @@ -191,6 +191,7 @@ Makefile **/CMakeFiles cmake_install.cmake /cmake-**/** +.cpm-cache/ # ignore servers *-server diff --git a/documentation/ai_agents/README.md b/documentation/ai_agents/README.md index ea2a9d7c8ff..45816b37798 100644 --- a/documentation/ai_agents/README.md +++ b/documentation/ai_agents/README.md @@ -4,7 +4,7 @@ * This post was written by hand, zhuzhed up by AI, and then manually proofread, fact-checked, and polished by hand, exactly how it should be. -Even with careful oversight, I still had to alter the tone, correct statements, remove ligatures, +Even with careful oversight, I still had to alter the tone, correct statements, remove ligatures (→, etc.), remove outright lies and hallucinations, replace the telltale "em dashes"'s that AI is so fond of (—), etc. diff --git a/scripts/battlefields/Boneyard_Gully/head_wind.lua b/scripts/battlefields/Boneyard_Gully/head_wind.lua index 5c9fcf09387..f7e31ee04bd 100644 --- a/scripts/battlefields/Boneyard_Gully/head_wind.lua +++ b/scripts/battlefields/Boneyard_Gully/head_wind.lua @@ -49,20 +49,20 @@ local skillchainData = -- Shikaree X initiated [ID.mob.SHIKAREE_X_HW] = { - -- All shikaree are alive: X Evisceration → Y Vorpal Scythe → Z Impulse Drive + -- All shikaree are alive: X Evisceration -> Y Vorpal Scythe -> Z Impulse Drive [allAlive] = { { mobId = ID.mob.SHIKAREE_X_HW, skill = xi.mobSkill.EVISCERATION }, { mobId = ID.mob.SHIKAREE_Y_HW, skill = xi.mobSkill.VORPAL_SCYTHE }, { mobId = ID.mob.SHIKAREE_Z_HW, skill = xi.mobSkill.IMPULSE_DRIVE }, }, - -- Shikaree X and Y are alive: X Evisceration → Y Vorpal Scythe + -- Shikaree X and Y are alive: X Evisceration -> Y Vorpal Scythe [ID.mob.SHIKAREE_Y_HW] = { { mobId = ID.mob.SHIKAREE_X_HW, skill = xi.mobSkill.EVISCERATION }, { mobId = ID.mob.SHIKAREE_Y_HW, skill = xi.mobSkill.VORPAL_SCYTHE }, }, - -- Shikaree X and Z are alive: X Dancing Edge → Z Penta Thrust + -- Shikaree X and Z are alive: X Dancing Edge -> Z Penta Thrust [ID.mob.SHIKAREE_Z_HW] = { { mobId = ID.mob.SHIKAREE_X_HW, skill = xi.mobSkill.DANCING_EDGE }, @@ -73,20 +73,20 @@ local skillchainData = -- Shikaree Y initiated [ID.mob.SHIKAREE_Y_HW] = { - -- All shikaree are alive: Y Guillotine → X Shadowstitch → Z Wheeling Thrust + -- All shikaree are alive: Y Guillotine -> X Shadowstitch -> Z Wheeling Thrust [allAlive] = { { mobId = ID.mob.SHIKAREE_Y_HW, skill = xi.mobSkill.GUILLOTINE_1 }, { mobId = ID.mob.SHIKAREE_X_HW, skill = xi.mobSkill.SHADOWSTITCH }, { mobId = ID.mob.SHIKAREE_Z_HW, skill = xi.mobSkill.WHEELING_THRUST }, }, - -- Shikaree Y and X are alive: Y Spiral Hell → X Shadowstitch + -- Shikaree Y and X are alive: Y Spiral Hell -> X Shadowstitch [ID.mob.SHIKAREE_X_HW] = { { mobId = ID.mob.SHIKAREE_Y_HW, skill = xi.mobSkill.SPIRAL_HELL }, { mobId = ID.mob.SHIKAREE_X_HW, skill = xi.mobSkill.SHADOWSTITCH }, }, - -- Shikaree Y and Z are alive: Y Spiral Hell → Z Impulse Drive + -- Shikaree Y and Z are alive: Y Spiral Hell -> Z Impulse Drive [ID.mob.SHIKAREE_Z_HW] = { { mobId = ID.mob.SHIKAREE_Y_HW, skill = xi.mobSkill.SPIRAL_HELL }, @@ -97,20 +97,20 @@ local skillchainData = -- Shikaree Z initiated [ID.mob.SHIKAREE_Z_HW] = { - -- All shikaree are alive: Z Skewer → Y Spiral Hell → X Evisceration + -- All shikaree are alive: Z Skewer -> Y Spiral Hell -> X Evisceration [allAlive] = { { mobId = ID.mob.SHIKAREE_Z_HW, skill = xi.mobSkill.SKEWER }, { mobId = ID.mob.SHIKAREE_Y_HW, skill = xi.mobSkill.SPIRAL_HELL }, { mobId = ID.mob.SHIKAREE_X_HW, skill = xi.mobSkill.EVISCERATION }, }, - -- Shikaree Z and X are alive: Z Wheeling Thrust → X Shark Bite + -- Shikaree Z and X are alive: Z Wheeling Thrust -> X Shark Bite [ID.mob.SHIKAREE_X_HW] = { { mobId = ID.mob.SHIKAREE_Z_HW, skill = xi.mobSkill.WHEELING_THRUST }, { mobId = ID.mob.SHIKAREE_X_HW, skill = xi.mobSkill.SHARK_BITE }, }, - -- Shikaree Z and Y are alive: Z Skewer → Y Spiral Hell + -- Shikaree Z and Y are alive: Z Skewer -> Y Spiral Hell [ID.mob.SHIKAREE_Y_HW] = { { mobId = ID.mob.SHIKAREE_Z_HW, skill = xi.mobSkill.SKEWER }, diff --git a/scripts/battlefields/Boneyard_Gully/requiem_of_sin.lua b/scripts/battlefields/Boneyard_Gully/requiem_of_sin.lua index 9031bb4ba4e..b1ae2d011c8 100644 --- a/scripts/battlefields/Boneyard_Gully/requiem_of_sin.lua +++ b/scripts/battlefields/Boneyard_Gully/requiem_of_sin.lua @@ -66,20 +66,20 @@ local skillchainData = -- Shikaree X initiated [ID.mob.SHIKAREE_X_ROS_TWT] = { - -- All shikaree are alive: X Evisceration → Y Vorpal Scythe → Z Impulse Drive + -- All shikaree are alive: X Evisceration -> Y Vorpal Scythe -> Z Impulse Drive [allAlive] = { { mobId = ID.mob.SHIKAREE_X_ROS_TWT, skill = xi.mobSkill.EVISCERATION }, { mobId = ID.mob.SHIKAREE_Y_ROS_TWT, skill = xi.mobSkill.VORPAL_SCYTHE }, { mobId = ID.mob.SHIKAREE_Z_ROS, skill = xi.mobSkill.IMPULSE_DRIVE }, }, - -- Shikaree X and Y are alive: X Evisceration → Y Vorpal Scythe + -- Shikaree X and Y are alive: X Evisceration -> Y Vorpal Scythe [ID.mob.SHIKAREE_Y_ROS_TWT] = { { mobId = ID.mob.SHIKAREE_X_ROS_TWT, skill = xi.mobSkill.EVISCERATION }, { mobId = ID.mob.SHIKAREE_Y_ROS_TWT, skill = xi.mobSkill.VORPAL_SCYTHE }, }, - -- Shikaree X and Z are alive: X Dancing Edge → Z Penta Thrust + -- Shikaree X and Z are alive: X Dancing Edge -> Z Penta Thrust [ID.mob.SHIKAREE_Z_ROS] = { { mobId = ID.mob.SHIKAREE_X_ROS_TWT, skill = xi.mobSkill.DANCING_EDGE }, @@ -90,20 +90,20 @@ local skillchainData = -- Shikaree Y initiated [ID.mob.SHIKAREE_Y_ROS_TWT] = { - -- All shikaree are alive: Y Guillotine → X Shadowstitch → Z Wheeling Thrust + -- All shikaree are alive: Y Guillotine -> X Shadowstitch -> Z Wheeling Thrust [allAlive] = { { mobId = ID.mob.SHIKAREE_Y_ROS_TWT, skill = xi.mobSkill.GUILLOTINE_1 }, { mobId = ID.mob.SHIKAREE_X_ROS_TWT, skill = xi.mobSkill.SHADOWSTITCH }, { mobId = ID.mob.SHIKAREE_Z_ROS, skill = xi.mobSkill.WHEELING_THRUST }, }, - -- Shikaree Y and X are alive: Y Spiral Hell → X Shadowstitch + -- Shikaree Y and X are alive: Y Spiral Hell -> X Shadowstitch [ID.mob.SHIKAREE_X_ROS_TWT] = { { mobId = ID.mob.SHIKAREE_Y_ROS_TWT, skill = xi.mobSkill.SPIRAL_HELL }, { mobId = ID.mob.SHIKAREE_X_ROS_TWT, skill = xi.mobSkill.SHADOWSTITCH }, }, - -- Shikaree Y and Z are alive: Y Spiral Hell → Z Impulse Drive + -- Shikaree Y and Z are alive: Y Spiral Hell -> Z Impulse Drive [ID.mob.SHIKAREE_Z_ROS] = { { mobId = ID.mob.SHIKAREE_Y_ROS_TWT, skill = xi.mobSkill.SPIRAL_HELL }, @@ -114,20 +114,20 @@ local skillchainData = -- Shikaree Z initiated [ID.mob.SHIKAREE_Z_ROS] = { - -- All shikaree are alive: Z Skewer → Y Spiral Hell → X Evisceration + -- All shikaree are alive: Z Skewer -> Y Spiral Hell -> X Evisceration [allAlive] = { { mobId = ID.mob.SHIKAREE_Z_ROS, skill = xi.mobSkill.SKEWER }, { mobId = ID.mob.SHIKAREE_Y_ROS_TWT, skill = xi.mobSkill.SPIRAL_HELL }, { mobId = ID.mob.SHIKAREE_X_ROS_TWT, skill = xi.mobSkill.EVISCERATION }, }, - -- Shikaree Z and X are alive: Z Wheeling Thrust → X Shark Bite + -- Shikaree Z and X are alive: Z Wheeling Thrust -> X Shark Bite [ID.mob.SHIKAREE_X_ROS_TWT] = { { mobId = ID.mob.SHIKAREE_Z_ROS, skill = xi.mobSkill.WHEELING_THRUST }, { mobId = ID.mob.SHIKAREE_X_ROS_TWT, skill = xi.mobSkill.SHARK_BITE }, }, - -- Shikaree Z and Y are alive: Z Skewer → Y Spiral Hell + -- Shikaree Z and Y are alive: Z Skewer -> Y Spiral Hell [ID.mob.SHIKAREE_Y_ROS_TWT] = { { mobId = ID.mob.SHIKAREE_Z_ROS, skill = xi.mobSkill.SKEWER }, diff --git a/scripts/battlefields/Boneyard_Gully/tango_with_a_tracker.lua b/scripts/battlefields/Boneyard_Gully/tango_with_a_tracker.lua index ca2a9effc62..40868b699d2 100644 --- a/scripts/battlefields/Boneyard_Gully/tango_with_a_tracker.lua +++ b/scripts/battlefields/Boneyard_Gully/tango_with_a_tracker.lua @@ -35,14 +35,14 @@ local msgOffsets = -- Skillchain execution logic for 2 shikarees (Y and X only) local skillchainData = { - -- Shikaree X initiated: X Evisceration → Y Vorpal Scythe + -- Shikaree X initiated: X Evisceration -> Y Vorpal Scythe [ID.mob.SHIKAREE_X_ROS_TWT] = { { mobId = ID.mob.SHIKAREE_X_ROS_TWT, skill = xi.mobSkill.EVISCERATION }, { mobId = ID.mob.SHIKAREE_Y_ROS_TWT, skill = xi.mobSkill.VORPAL_SCYTHE }, }, - -- Shikaree Y initiated: Y Spiral Hell → X Shadowstitch + -- Shikaree Y initiated: Y Spiral Hell -> X Shadowstitch [ID.mob.SHIKAREE_Y_ROS_TWT] = { { mobId = ID.mob.SHIKAREE_Y_ROS_TWT, skill = xi.mobSkill.SPIRAL_HELL }, diff --git a/scripts/commands/cansee.lua b/scripts/commands/cansee.lua index 51520579ac1..3b39081dab5 100644 --- a/scripts/commands/cansee.lua +++ b/scripts/commands/cansee.lua @@ -8,17 +8,17 @@ local commandObj = {} commandObj.cmdprops = { permission = 1, - parameters = '' + parameters = 'i' } -commandObj.onTrigger = function(player) +commandObj.onTrigger = function(player, ignoreInvisibleBoundaries) local target = player:getCursorTarget() if not target then player:printToPlayer('No cursor target provided') return end - local str = player:canSee(target) and 'CAN' or 'CANNOT' + local str = player:canSee(target, ignoreInvisibleBoundaries == 1) and 'CAN' or 'CANNOT' player:printToPlayer(string.format('%s %s see %s', player:getName(), str, target:getName())) end diff --git a/scripts/commands/fafnir.lua b/scripts/commands/fafnir.lua index c86a1e4d09f..df2ab12efb8 100644 --- a/scripts/commands/fafnir.lua +++ b/scripts/commands/fafnir.lua @@ -41,18 +41,18 @@ commandObj.onTrigger = function(player) -- packetName = 'Fake Fafnir', -- Set the position using in-game x, y and z - x = player:getXPos(), - y = player:getYPos(), - z = player:getZPos(), + x = player:getXPos(), + y = player:getYPos(), + z = player:getZPos(), rotation = player:getRotPos(), -- Fafnir's entry in mob_groups: -- INSERT INTO `mob_groups` VALUES (5, 1280, 154, 'Fafnir', 0, 128, 805, 70000, 0, 90, 90, 0) -- groupId ---^ ^--- groupZoneId - groupId = 5, + groupId = 5, groupZoneId = 154, - minLevel = 90, - maxLevel = 90, + minLevel = 90, + maxLevel = 90, -- You can provide an onMobDeath function if you want: if you don't -- add one, an empty one will be inserted for you behind the scenes. onMobDeath = function(mob, playerArg, optParams) @@ -82,6 +82,7 @@ commandObj.onTrigger = function(player) mob:setSpawn(player:getXPos(), player:getYPos(), player:getZPos(), player:getRotPos()) mob:setDropID(0) -- No loot! mob:setMobMod(xi.mobMod.NO_DROPS, 1) + mob:setMobMod(xi.mobMod.NO_DESPAWN, 1) mob:spawn() player:printToPlayer(string.format('Spawning Fafnir (Lv: %i, HP: %i)\n%s', mob:getMainLvl(), mob:getMaxHP(), mob)) diff --git a/scripts/data/maze.lua b/scripts/data/maze.lua index 8e4571d4576..9c7fa29490c 100644 --- a/scripts/data/maze.lua +++ b/scripts/data/maze.lua @@ -85,9 +85,9 @@ xi.data.maze.runeShapeData = [xi.maze.runeShape.PLUS] = { 0x4E40, 0x4E40, 0x4E40, 0x4E40 }, } --- Rune item → shape and element mapping. +-- Rune item -> shape and element mapping. -- Source: Item DAT offset 0x14-0x15 --- byte[0x15] & 0xF = shape index, byte[0x14] & 0xF = element (0-7 → xi.element + 1, 0xF → NONE). +-- byte[0x15] & 0xF = shape index, byte[0x14] & 0xF = element (0-7 -> xi.element + 1, 0xF -> NONE). ---@type table xi.data.maze.runeInfo = { diff --git a/scripts/enum/mob_mod.lua b/scripts/enum/mob_mod.lua index 92d7601f179..df85766b3e1 100644 --- a/scripts/enum/mob_mod.lua +++ b/scripts/enum/mob_mod.lua @@ -105,4 +105,5 @@ xi.mobMod = FOLLOW_LEASH_RANGE = 94, -- Distance the leader can walk before their followers start moving. Applied to followers. FOLLOW_STOP_RANGE = 95, -- Distance the followers attempt to stop at once their leader stops moving. Applied to followers. TRUST_SHIELD_SIZE = 96, -- TRUSTS ONLY: Set the size of the mob's shield. 3 = Default size, only used for trusts that use shields. + NO_STUCK_TELEPORT = 97, -- Disable the stuck-repath teleport fallback (terrain avoidance is intentional for this encounter). } diff --git a/scripts/globals/assault/container.lua b/scripts/globals/assault/container.lua index cb9ece1a7f4..9eeade7b2fa 100644 --- a/scripts/globals/assault/container.lua +++ b/scripts/globals/assault/container.lua @@ -50,9 +50,9 @@ end -- - ancientBoxPos: (optional) { x, y, z, rot } position of the Ancient Lockbox NPC on completion -- - requiredProgress: (optional) Progress value at which the instance auto-completes -- - basePoints: (optional) Base assault points before bonuses and penalties --- - mobs: (optional) { { baseID = id, offset = n } } — spawns mobs from baseID to baseID+offset +-- - mobs: (optional) { { baseID = id, offset = n } } - spawns mobs from baseID to baseID+offset -- - npcs: (optional) Same format as mobs; sets animation to NORMAL on instance creation --- - wallNPCs: (optional) { npcID, ... } — NPCs set to OPEN_DOOR animation on instance creation +-- - wallNPCs: (optional) { npcID, ... } - NPCs set to OPEN_DOOR animation on instance creation -- - loot: (optional) Ancient Lockbox reward table. Omit to use the zone's Ancient_Lockbox.lua. -- - loot.appraisalReward = { { { itemId, weight }, ... } } group for unappraised gear -- - loot.bonusLoot = { { { itemId, weight }, ... }, ... } one or more groups for consumables diff --git a/scripts/globals/dynamis.lua b/scripts/globals/dynamis.lua index 1a8d2c31f9e..ce2677f6490 100644 --- a/scripts/globals/dynamis.lua +++ b/scripts/globals/dynamis.lua @@ -446,10 +446,6 @@ xi.dynamis.zoneOnInitialize = function(zone) SpawnMob(spawnId) end end - - for _, mob in pairs(zone:getMobs()) do - mob:setCarefulPathing(true) - end end xi.dynamis.zoneOnZoneIn = function(player, prevZone) diff --git a/scripts/mixins/families/chariot.lua b/scripts/mixins/families/chariot.lua index 4079283a62a..6c37da93679 100644 --- a/scripts/mixins/families/chariot.lua +++ b/scripts/mixins/families/chariot.lua @@ -17,7 +17,7 @@ g_mixins.families.chariot = function(chariotMob) local time = GetSystemTime() if time >= mob:getLocalVar('turnTime') then - -- mob:face() -- We lack a lua function for c++ FaceTarget. TODO: code it. + -- mob:face() -- We lack a lua function for c++ LookAtTarget. TODO: code it. mob:setLocalVar('turnTime', time + math.random(10, 30)) end diff --git a/scripts/specs/core/CBaseEntity.lua b/scripts/specs/core/CBaseEntity.lua index 8fdd598e40b..bdcc88fbadc 100644 --- a/scripts/specs/core/CBaseEntity.lua +++ b/scripts/specs/core/CBaseEntity.lua @@ -550,15 +550,11 @@ end function CBaseEntity:LimitDistance() end ----@param careful boolean ----@return nil -function CBaseEntity:setCarefulPathing(careful) -end - ---@nodiscard ---@param target CBaseEntity +---@param ignoreInvisibleBoundaries boolean? ---@return boolean -function CBaseEntity:canSee(target) +function CBaseEntity:canSee(target, ignoreInvisibleBoundaries) end ---@nodiscard diff --git a/scripts/zones/Attohwa_Chasm/mobs/Tiamat.lua b/scripts/zones/Attohwa_Chasm/mobs/Tiamat.lua index f3a55423581..c1dca37fe17 100644 --- a/scripts/zones/Attohwa_Chasm/mobs/Tiamat.lua +++ b/scripts/zones/Attohwa_Chasm/mobs/Tiamat.lua @@ -68,7 +68,6 @@ local function enterFlight(mob) end entity.onMobInitialize = function(mob) - mob:setCarefulPathing(true) -- Used for drawin mob:setMobMod(xi.mobMod.AOE_HIT_ALL, 1) xi.mob.updateNMSpawnPoint(mob) diff --git a/scripts/zones/Aydeewa_Subterrane/mobs/Bluestreak_Gyugyuroon.lua b/scripts/zones/Aydeewa_Subterrane/mobs/Bluestreak_Gyugyuroon.lua index 700306c5161..d676bc04829 100644 --- a/scripts/zones/Aydeewa_Subterrane/mobs/Bluestreak_Gyugyuroon.lua +++ b/scripts/zones/Aydeewa_Subterrane/mobs/Bluestreak_Gyugyuroon.lua @@ -29,7 +29,7 @@ local function getFleePosition(mob, target) end end - -- Pass 3: cornered escape — any direction, tiny step just to unstick + -- Pass 3: cornered escape - any direction, tiny step just to unstick for _ = 1, 12 do local pos = GetFurthestValidPosition(mob, math.random(2, 8), math.random() * 2 * math.pi) if pos then diff --git a/scripts/zones/Ifrits_Cauldron/mobs/Ash_Dragon.lua b/scripts/zones/Ifrits_Cauldron/mobs/Ash_Dragon.lua index 6cb7b6c3ab1..9ce216ec446 100644 --- a/scripts/zones/Ifrits_Cauldron/mobs/Ash_Dragon.lua +++ b/scripts/zones/Ifrits_Cauldron/mobs/Ash_Dragon.lua @@ -63,7 +63,6 @@ entity.onMobInitialize = function(mob) xi.mob.updateNMSpawnPoint(mob) mob:setRespawnTime(math.random(259200, 432000)) - mob:setCarefulPathing(true) mob:addImmunity(xi.immunity.LIGHT_SLEEP) mob:addImmunity(xi.immunity.DARK_SLEEP) mob:addImmunity(xi.immunity.SILENCE) diff --git a/scripts/zones/King_Ranperres_Tomb/mobs/Vrtra.lua b/scripts/zones/King_Ranperres_Tomb/mobs/Vrtra.lua index 75333a63afb..66f5ee50087 100644 --- a/scripts/zones/King_Ranperres_Tomb/mobs/Vrtra.lua +++ b/scripts/zones/King_Ranperres_Tomb/mobs/Vrtra.lua @@ -88,7 +88,6 @@ entity.onMobInitialize = function(mob) mob:addImmunity(xi.immunity.TERROR) mob:setMobMod(xi.mobMod.AOE_HIT_ALL, 1) - mob:setCarefulPathing(true) mob:setRespawnTime(math.random(144, 240) * 1800) -- 3 to 5 days in 30 minute windows end diff --git a/scripts/zones/Sacrarium/mobs/Old_Professor_Mariselle.lua b/scripts/zones/Sacrarium/mobs/Old_Professor_Mariselle.lua index 9941878cfc5..9d1776db8ca 100644 --- a/scripts/zones/Sacrarium/mobs/Old_Professor_Mariselle.lua +++ b/scripts/zones/Sacrarium/mobs/Old_Professor_Mariselle.lua @@ -28,8 +28,6 @@ entity.onMobInitialize = function(mob) end entity.onMobSpawn = function(mob) - mob:setCarefulPathing(true) - for i = 0, 5 do if GetNPCByID(ID.npc.QM_MARISELLE_OFFSET + i):getLocalVar('hasProfessorMariselle') == 1 then mob:setLocalVar('spawnLocation', i) diff --git a/scripts/zones/Uleguerand_Range/mobs/Jormungand.lua b/scripts/zones/Uleguerand_Range/mobs/Jormungand.lua index 59199fdcaf6..e31425a40aa 100644 --- a/scripts/zones/Uleguerand_Range/mobs/Jormungand.lua +++ b/scripts/zones/Uleguerand_Range/mobs/Jormungand.lua @@ -77,7 +77,6 @@ entity.onMobInitialize = function(mob) mob:addImmunity(xi.immunity.GRAVITY) mob:addImmunity(xi.immunity.TERROR) - mob:setCarefulPathing(true) mob:setMobMod(xi.mobMod.AOE_HIT_ALL, 1) xi.mob.updateNMSpawnPoint(mob) diff --git a/src/common/types/cached.h b/src/common/types/cached.h new file mode 100644 index 00000000000..d7cd2d127cb --- /dev/null +++ b/src/common/types/cached.h @@ -0,0 +1,56 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include +#include + +// +// A lazily-memoized value. +// +template +class Cached +{ +public: + template + auto getOrCompute(Fn&& fn) -> const T& + { + if (!value_.has_value()) + { + value_ = std::forward(fn)(); + } + return *value_; + } + + auto hasValue() const -> bool + { + return value_.has_value(); + } + + void reset() + { + value_.reset(); + } + +private: + std::optional value_; +}; diff --git a/src/common/utils.cpp b/src/common/utils.cpp index 48ca8afbbef..8f22ebbd6fc 100644 --- a/src/common/utils.cpp +++ b/src/common/utils.cpp @@ -233,6 +233,29 @@ position_t nearPosition(const position_t& A, float offset, float radian) return B; } +auto sidestepPosition(const position_t& from, const position_t& referencePoint, float offset) -> position_t +{ + // worldAngle + 64 (a quarter-turn in the game's 0..255 rotation byte) gives a vector + // perpendicular to "from -> referencePoint", which is what we want for shuffling aside. + const auto perpendicularAngle = worldAngle(from, referencePoint) + 64; + const auto radians = rotationToRadian(perpendicularAngle); + + return position_t{ + from.x - std::cosf(radians) * offset, + referencePoint.y, + from.z + std::sinf(radians) * offset, + 0, + 0, + }; +} + +auto isNear(const position_t& a, const position_t& b) -> bool +{ + // Below this, positions are effectively co-located and a path query would be trivial/empty. + constexpr float kNearThreshold = 1.0f; + return distance(a, b) < kNearThreshold; +} + /************************************************************************ * * * Methods for working with bit arrays. * diff --git a/src/common/utils.h b/src/common/utils.h index 72fc3b2e37f..52e08527b31 100644 --- a/src/common/utils.h +++ b/src/common/utils.h @@ -128,6 +128,11 @@ auto toEntitysLeft(const position_t& A, const position_t& B, uint8 coneAng auto toEntitysRight(const position_t& A, const position_t& B, uint8 coneAngle) -> bool; // true if A is to the right side of B within coneAngle degrees (from perspective of B) position_t nearPosition(const position_t& A, float offset, float radian); // Returns a position near the given position +auto sidestepPosition(const position_t& from, const position_t& referencePoint, float offset) -> position_t; + +// True when two positions are within ~1 yalm, i.e. effectively co-located. +auto isNear(const position_t& a, const position_t& b) -> bool; + int32 hasBit(uint16 value, const uint8* BitArray, uint32 size); // Check for the presence of a bit in the array int32 addBit(uint16 value, uint8* BitArray, uint32 size); // Adds a bit to the array int32 delBit(uint16 value, uint8* BitArray, uint32 size); // Deletes a bit from the array diff --git a/src/map/CMakeLists.txt b/src/map/CMakeLists.txt index 19e3d24a43a..d44018e649b 100644 --- a/src/map/CMakeLists.txt +++ b/src/map/CMakeLists.txt @@ -116,12 +116,14 @@ set(SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/modifier.h ${CMAKE_CURRENT_SOURCE_DIR}/monstrosity.cpp ${CMAKE_CURRENT_SOURCE_DIR}/monstrosity.h - ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/inavmesh.h - ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/navmesh.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/detour_navmesh.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/detour_navmesh.h ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/navmesh.h ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/navmesh_builder.cpp ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/navmesh_builder.h ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/navmesh_config.h + ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/null_navmesh.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/navmesh/null_navmesh.h ${CMAKE_CURRENT_SOURCE_DIR}/nominate_manager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/nominate_manager.h ${CMAKE_CURRENT_SOURCE_DIR}/notoriety_container.cpp @@ -168,9 +170,12 @@ set(SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/weapon_skill.h ${CMAKE_CURRENT_SOURCE_DIR}/ximesh/transformation_matrix.h ${CMAKE_CURRENT_SOURCE_DIR}/ximesh/vector3.h + ${CMAKE_CURRENT_SOURCE_DIR}/ximesh/null_ximesh.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ximesh/null_ximesh.h ${CMAKE_CURRENT_SOURCE_DIR}/ximesh/ximesh_structs.h - ${CMAKE_CURRENT_SOURCE_DIR}/ximesh/ximesh.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ximesh/ximesh.h + ${CMAKE_CURRENT_SOURCE_DIR}/ximesh/ximesh_impl.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ximesh/ximesh_impl.h ${CMAKE_CURRENT_SOURCE_DIR}/zone_entities.cpp ${CMAKE_CURRENT_SOURCE_DIR}/zone_entities.h ${CMAKE_CURRENT_SOURCE_DIR}/zone_instance.cpp diff --git a/src/map/ai/controllers/mob_controller.cpp b/src/map/ai/controllers/mob_controller.cpp index f5f64c0dd38..edf0c22faf0 100644 --- a/src/map/ai/controllers/mob_controller.cpp +++ b/src/map/ai/controllers/mob_controller.cpp @@ -56,12 +56,12 @@ auto CMobController::Ability(uint16 targid, uint16 abilityid) -> bool return false; } - if (POwner->PAI->CanChangeState()) + if (!POwner->PAI->CanChangeState()) { - return POwner->PAI->Internal_Ability(targid, abilityid); + return false; } - return false; + return POwner->PAI->Internal_Ability(targid, abilityid); } auto CMobController::Tick(const timer::time_point tick) -> Task @@ -71,16 +71,21 @@ auto CMobController::Tick(const timer::time_point tick) -> Task m_Tick = tick; - if (PMob->isAlive()) + // Invalidate the per-tick LOS cache so every tick starts with a fresh raycast. + targetLosCache_.reset(); + + if (!PMob->isAlive()) { - if (PMob->PAI->IsEngaged()) - { - co_await DoCombatTick(tick); - } - else if (!PMob->isDead()) - { - co_await DoRoamTick(tick); - } + co_return; + } + + if (PMob->PAI->IsEngaged()) + { + co_await DoCombatTick(tick); + } + else + { + co_await DoRoamTick(tick); } co_return; @@ -123,81 +128,90 @@ auto CMobController::TryDeaggro() -> bool return false; } -auto CMobController::CanPursueTarget(const CBattleEntity* PTarget) const -> bool +auto CMobController::CanTrackByScent(const CBattleEntity* PTarget) const -> bool { TracyZoneScoped; - if (PMob->getMobMod(MOBMOD_DETECTION) & DETECT_SCENT) + if (!(PMob->getMobMod(MOBMOD_DETECTION) & DETECT_SCENT)) { - // if mob is in water it will instant deaggro if target cannot be detected - if (!PMob->PAI->PathFind->InWater() && PTarget && !PTarget->StatusEffectContainer->HasStatusEffect(EFFECT_DEODORIZE)) - { - // certain weather / deodorize will turn on time deaggro - return !PMob->m_disableScent; - } + return false; } - return false; + + // Mobs underwater instantly deaggro if scent fails (deodorize / no target). + if (PMob->PAI->PathFind->InWater() || !PTarget || PTarget->StatusEffectContainer->HasStatusEffect(EFFECT_DEODORIZE)) + { + return false; + } + + // Certain weather / deodorize will turn on time deaggro. + return !PMob->m_disableScent; } auto CMobController::CheckHide(const CBattleEntity* PTarget) const -> bool { TracyZoneScoped; - if (PTarget && PTarget->GetMJob() == JOB_THF && PTarget->StatusEffectContainer->HasStatusEffect(EFFECT_HIDE)) + if (!PTarget || PTarget->GetMJob() != JOB_THF || !PTarget->StatusEffectContainer->HasStatusEffect(EFFECT_HIDE)) { - return !CanPursueTarget(PTarget) && !PMob->m_TrueDetection && !(PMob->getMobMod(MOBMOD_DETECTION) & DETECT_HEARING); + return false; } - return false; + + return !CanTrackByScent(PTarget) && !PMob->m_TrueDetection && !(PMob->getMobMod(MOBMOD_DETECTION) & DETECT_HEARING); } auto CMobController::CheckLock(CBattleEntity* PTarget) const -> bool { TracyZoneScoped; - if (PTarget) + if (!PTarget) + { + return false; + } + + // Resolve the (potentially pet-owning) character whose Locked flag we care about. + const auto* PChar = [&]() -> const CCharEntity* { if (PTarget->objtype == TYPE_PC) { - const auto* PChar = dynamic_cast(PTarget); - if (PChar && PChar->m_Locked) - { - return !CanPursueTarget(PTarget); - } + return dynamic_cast(PTarget); } - else if (PTarget->objtype == TYPE_PET) + + if (PTarget->objtype == TYPE_PET) { const auto* PPet = dynamic_cast(PTarget); - if (!PPet) - { - return false; - } + return PPet ? dynamic_cast(PPet->PMaster) : nullptr; + } - const auto* PChar = dynamic_cast(PPet->PMaster); - if (PChar == nullptr) - { - return false; - } + return nullptr; + }(); - if (PChar->m_Locked) - { - return !CanPursueTarget(PTarget); - } - } + if (!PChar || !PChar->m_Locked) + { + return false; } - return false; + + return !CanTrackByScent(PTarget); } auto CMobController::CheckDetection(CBattleEntity* PTarget) -> bool { TracyZoneScoped; - if (CanPursueTarget(PTarget) || CanDetectTarget(PTarget) || - PMob->StatusEffectContainer->HasStatusEffect({ EFFECT_BIND, EFFECT_SLEEP, EFFECT_SLEEP_II, EFFECT_LULLABY, EFFECT_PETRIFICATION })) + const bool isImmobilised = PMob->StatusEffectContainer->HasStatusEffect({ EFFECT_BIND, EFFECT_SLEEP, EFFECT_SLEEP_II, EFFECT_LULLABY, EFFECT_PETRIFICATION }); + if (CanTrackByScent(PTarget) || CanDetectTarget(PTarget) || isImmobilised) { TapDeaggroTime(); } - const auto additionalDeaggroTime = PMob->m_roamFlags & ROAMFLAG_WORM ? std::chrono::seconds(0) : std::chrono::seconds(settings::get("map.MOB_ADDITIONAL_TIME_TO_DEAGGRO")); + const auto additionalDeaggroTime = [&]() -> std::chrono::seconds + { + if (PMob->m_roamFlags & ROAMFLAG_WORM) + { + return std::chrono::seconds(0); + } + return std::chrono::seconds(settings::get("map.MOB_ADDITIONAL_TIME_TO_DEAGGRO")); + }(); + return PMob->CanDeaggro() && (m_Tick >= m_DeaggroTime + 25s + additionalDeaggroTime); } @@ -210,58 +224,62 @@ void CMobController::TryLink() return; } - // handle pet behavior on the targets behalf (faster than in ai_pet_dummy) - // Avatars defend masters by attacking mobs if the avatar isn't attacking anything currently (bodyguard behavior) - // Alexander, Odin and Atomos are passive and do not protect the master. - if (PTarget->PPet != nullptr && PTarget->PPet->GetBattleTargetID() == 0) + // Pet bodyguard: avatar pets engage the mob when their master is being attacked. + // Alexander, Odin and Atomos are passive and do not protect their master. + const auto tryAvatarBodyguard = [&]() { - if (PTarget->PPet->objtype == TYPE_PET) + if (PTarget->PPet == nullptr || PTarget->PPet->GetBattleTargetID() != 0 || PTarget->PPet->objtype != TYPE_PET) + { + return; + } + + const auto* PPetEntity = static_cast(PTarget->PPet); + const bool isProtectiveAvatar = PPetEntity->getPetType() == PET_TYPE::AVATAR && + PPetEntity->m_PetID != PETID_ALEXANDER && + PPetEntity->m_PetID != PETID_ODIN && + PPetEntity->m_PetID != PETID_ATOMOS; + if (!isProtectiveAvatar) { - const auto PPetEntity = static_cast(PTarget->PPet); - if (PPetEntity->getPetType() == PET_TYPE::AVATAR && - PPetEntity->m_PetID != PETID_ALEXANDER && - PPetEntity->m_PetID != PETID_ODIN && - PPetEntity->m_PetID != PETID_ATOMOS) + return; + } + + // PCs only get bodyguarded by the avatar they own; non-PC targets always do. + if (PTarget->objtype == TYPE_PC) + { + auto* const PChar = dynamic_cast(PTarget); + if (!PChar || !PChar->IsMobOwner(PMob)) { - if (PTarget->objtype == TYPE_PC) - { - auto* PChar = dynamic_cast(PTarget); - if (PChar && PChar->IsMobOwner(PMob)) - { - petutils::AttackTarget(PTarget, PMob); - } - } - else - { - petutils::AttackTarget(PTarget, PMob); - } + return; } } - } - // my pet should help as well + petutils::AttackTarget(PTarget, PMob); + }; + tryAvatarBodyguard(); + + // My pet should help as well. if (PMob->PPet != nullptr && PMob->PPet->PAI->IsRoaming()) { PMob->PPet->PAI->Engage(PTarget->targid); } - // Handle monster linking if they are close enough + // Pull in linked party members that are close enough. if (PMob->PParty != nullptr && !PMob->getMobMod(MOBMOD_ONE_WAY_LINKING)) { for (auto& member : PMob->PParty->members) { auto* PPartyMember = dynamic_cast(member); - // Note if the mob to link with this one is a pet then do not link - // Pets only link with their masters + + // Pets only link with their masters, never with the rest of the party. if (!PPartyMember || PPartyMember->PMaster || PPartyMember->isDead()) { continue; } - // Handle the case where a mob doesn't link with its own family but has a sublink - // This is needed because the sublink will cause like family members to be in the same - // party so that they are linked with sublinked families. - if (!PMob->ShouldForceLink() && !PMob->m_Link && PMob->m_Family == PPartyMember->m_Family) + // A sublink groups same-family mobs into the same party so they can link via *another* + // family. Skip same-family party members unless this mob actually wants to link with kin. + const bool sameFamilyButNoSelfLink = PMob->m_Family == PPartyMember->m_Family && !PMob->ShouldForceLink() && !PMob->m_Link; + if (sameFamilyButNoSelfLink) { continue; } @@ -273,22 +291,21 @@ void CMobController::TryLink() } } - // ask my master for help + // Ask my master for help. if (PMob->PMaster != nullptr && PMob->PMaster->PAI->IsRoaming()) { auto* PMaster = static_cast(PMob->PMaster); - - if (PMaster->PAI->IsRoaming() && PMaster->CanLink(&PMob->loc.p, PMob->getMobMod(MOBMOD_SUPERLINK))) + if (PMaster->CanLink(&PMob->loc.p, PMob->getMobMod(MOBMOD_SUPERLINK))) { PMaster->PAI->Engage(PTarget->targid); } } } -/** - * Checks if the mob can detect the target using it's detection (sight, sound, etc) - * This is used to aggro and deaggro (Mobs start to deaggro after failing to detect target). - **/ +// +// Checks if the mob can detect the target using it's detection (sight, sound, etc) +// This is used to aggro and deaggro (Mobs start to deaggro after failing to detect target). +// auto CMobController::CanDetectTarget(CBattleEntity* PTarget, const bool forceSight) const -> bool { TracyZoneScoped; @@ -300,34 +317,43 @@ auto CMobController::CanDetectTarget(CBattleEntity* PTarget, const bool forceSig const auto detects = PMob->getMobMod(MOBMOD_DETECTION); const auto currentDistance = distance(PTarget->loc.p, PMob->loc.p) + PTarget->getMod(Mod::STEALTH); + const bool detectSight = (detects & DETECT_SIGHT) || forceSight; - const bool detectSight = (detects & DETECT_SIGHT) || forceSight; - bool hasInvisible = false; - bool hasSneak = false; - - if (!PMob->m_TrueDetection) + // Determine which stealth effects are actually masking the target from us. True detection ignores + // both, but Illusion overrides true detection (true-sound Porrogos don't aggro with Illusion up). + // Mobs that "see through Illusion" still respect a player's actual sneak. + const auto [hasInvisible, hasSneak] = [&]() -> std::pair { - hasInvisible = PTarget->StatusEffectContainer->HasStatusEffectByFlag(EFFECTFLAG_INVISIBLE); - hasSneak = PTarget->StatusEffectContainer->HasStatusEffect(EFFECT_SNEAK); - } + bool invisible = false; + bool sneak = false; - // Illusion effect seems to ignore true detection (true sound Porrogos don't aggro with Illusion up) - // Additionally, mobs that would normally aggro you via sound that also ignore illusion must also ignore you with illusion if you have sneak up, - // Fish in Mamook will see you through Illusion but not if you have sneak up - if (PTarget->StatusEffectContainer->HasStatusEffect(EFFECT_ILLUSION)) - { - if (!PMob->getMobMod(MOBMOD_SEES_THROUGH_ILLUSION)) + if (!PMob->m_TrueDetection) { - hasInvisible = true; - hasSneak = true; + invisible = PTarget->StatusEffectContainer->HasStatusEffectByFlag(EFFECTFLAG_INVISIBLE); + sneak = PTarget->StatusEffectContainer->HasStatusEffect(EFFECT_SNEAK); } - } + const bool hasIllusion = PTarget->StatusEffectContainer->HasStatusEffect(EFFECT_ILLUSION); + if (hasIllusion && !PMob->getMobMod(MOBMOD_SEES_THROUGH_ILLUSION)) + { + invisible = true; + sneak = true; + } + + return { invisible, sneak }; + }(); + + // If this is already our battle target and we're in melee range, detection bypasses LOS. const bool isTargetAndInRange = PMob->GetBattleTargetID() == PTarget->targid && currentDistance <= PMob->GetMeleeRange(PTarget); - if (detectSight && !hasInvisible && currentDistance < PMob->getMobMod(MOBMOD_SIGHT_RANGE) && facing(PMob->loc.p, PTarget->loc.p, 64)) + const auto detected = [&]() -> bool { return isTargetAndInRange || PMob->CanSeeTarget(PTarget); + }; + + if (detectSight && !hasInvisible && currentDistance < PMob->getMobMod(MOBMOD_SIGHT_RANGE) && facing(PMob->loc.p, PTarget->loc.p, 64)) + { + return detected(); } if ((PMob->m_Behavior & BEHAVIOR_AGGRO_AMBUSH) && currentDistance < 3 && !hasSneak) @@ -337,16 +363,19 @@ auto CMobController::CanDetectTarget(CBattleEntity* PTarget, const bool forceSig if ((detects & DETECT_HEARING) && currentDistance < PMob->getMobMod(MOBMOD_SOUND_RANGE) && !hasSneak) { - return isTargetAndInRange || PMob->CanSeeTarget(PTarget); + return detected(); } - if ((detects & DETECT_MAGIC) && currentDistance < PMob->getMobMod(MOBMOD_MAGIC_RANGE) && - PTarget->PAI->IsCurrentState() && static_cast(PTarget->PAI->GetCurrentState())->GetSpell()->hasMPCost()) + const bool detectMagicCast = (detects & DETECT_MAGIC) && + currentDistance < PMob->getMobMod(MOBMOD_MAGIC_RANGE) && + PTarget->PAI->IsCurrentState() && + static_cast(PTarget->PAI->GetCurrentState())->GetSpell()->hasMPCost(); + if (detectMagicCast) { - return isTargetAndInRange || PMob->CanSeeTarget(PTarget); + return detected(); } - // everything below require distance to be below 20 + // The remaining detection types only fire at close range. if (currentDistance > 20) { return false; @@ -354,17 +383,17 @@ auto CMobController::CanDetectTarget(CBattleEntity* PTarget, const bool forceSig if ((detects & DETECT_LOWHP) && PTarget->GetHPP() < 75) { - return isTargetAndInRange || PMob->CanSeeTarget(PTarget); + return detected(); } if ((detects & DETECT_WEAPONSKILL) && PTarget->PAI->IsCurrentState()) { - return isTargetAndInRange || PMob->CanSeeTarget(PTarget); + return detected(); } if ((detects & DETECT_JOBABILITY) && PTarget->PAI->IsCurrentState()) { - return isTargetAndInRange || PMob->CanSeeTarget(PTarget); + return detected(); } return false; @@ -379,75 +408,84 @@ auto CMobController::MobSkill(int listId) -> bool return false; } - // Fetch skill list from mobmod if not set in database. - if (!listId) - { - listId = PMob->getMobMod(MOBMOD_SKILL_LIST); - } + // Fall back to the mob's default skill list if the caller didn't pick one. + const int resolvedListId = listId != 0 ? listId : PMob->getMobMod(MOBMOD_SKILL_LIST); - auto skillList{ battleutils::GetMobSkillList(listId) }; + auto skillList = battleutils::GetMobSkillList(resolvedListId); if (skillList.empty()) { return false; } - std::shuffle(skillList.begin(), skillList.end(), xirand::rng()); - CBattleEntity* PActionTarget{ nullptr }; - uint16 chosenSkillId = 0; - for (const auto skillId : skillList) + // Pick the first valid mob skill from the shuffled list. + const uint16 firstValidSkillId = [&]() -> uint16 { - auto* PMobSkill{ battleutils::GetMobSkill(skillId) }; - if (!PMobSkill) + for (const auto skillId : skillList) { - ShowError("CMobController::MobSkill -> Mobskill with ID (%i) [called from skill-list ID (%i)] isn't properly defined in mob_skills.sql", skillId, listId); - continue; + if (battleutils::GetMobSkill(skillId) != nullptr) + { + return skillId; + } + ShowError("CMobController::MobSkill -> Mobskill with ID (%i) [called from skill-list ID (%i)] isn't properly defined in mob_skills.sql", skillId, resolvedListId); } + return 0; + }(); - chosenSkillId = skillId; - break; - } + // Lua may override the chosen skill. + const uint16 chosenSkillId = [&]() -> uint16 + { + const auto overrideSkill = luautils::OnMobMobskillChoose(PMob, PTarget, firstValidSkillId); + return overrideSkill > 0 ? overrideSkill : firstValidSkillId; + }(); - if (auto overrideSkill = luautils::OnMobMobskillChoose(PMob, PTarget, chosenSkillId); overrideSkill > 0) + auto* PMobSkill = battleutils::GetMobSkill(chosenSkillId); + if (!PMobSkill) { - chosenSkillId = overrideSkill; + return false; } - auto* PMobSkill{ battleutils::GetMobSkill(chosenSkillId) }; - - if (PMobSkill->getValidTargets() & TARGET_ENEMY) // enemy + // Resolve the action target: enemy/self defaults, then let lua substitute. + CBattleEntity* PActionTarget = nullptr; + if (PMobSkill->getValidTargets() & TARGET_ENEMY) { PActionTarget = PTarget; } - else if (PMobSkill->getValidTargets() & TARGET_SELF) // self + else if (PMobSkill->getValidTargets() & TARGET_SELF) { PActionTarget = PMob; } - PActionTarget = luautils::OnMobSkillTarget(PActionTarget, PMob, PMobSkill); - Maybe mobSkillReadyTime = luautils::OnMobSkillReadyTime(PActionTarget, PMob, PMobSkill); + // NOTE: OnMobSkillReadyTime is intentionally invoked unconditionally so its Lua side effects fire + // even when the skill ultimately doesn't go off. + const auto mobSkillReadyTime = luautils::OnMobSkillReadyTime(PActionTarget, PMob, PMobSkill); - if (PActionTarget && !PMobSkill->isAstralFlow() && luautils::OnMobSkillCheck(PActionTarget, PMob, PMobSkill) == 0) // A script says that the move in question is valid + if (!PActionTarget || PMobSkill->isAstralFlow()) { - const float currentDistance = distance(PMob->loc.p, PActionTarget->loc.p); - if (currentDistance <= PMobSkill->getDistance()) - { - return MobSkill(PActionTarget->targid, PMobSkill->getID(), mobSkillReadyTime); - } + return false; } - return false; + // The Lua check returns 0 to mean "yes, this skill is valid here". + if (luautils::OnMobSkillCheck(PActionTarget, PMob, PMobSkill) != 0) + { + return false; + } + + const float currentDistance = distance(PMob->loc.p, PActionTarget->loc.p); + if (currentDistance > PMobSkill->getDistance()) + { + return false; + } + + return MobSkill(PActionTarget->targid, PMobSkill->getID(), mobSkillReadyTime); } auto CMobController::TrySpecialSkill() -> bool { TracyZoneScoped; - // get my special skill - CMobSkill* PSpecialSkill = battleutils::GetMobSkill(PMob->getMobMod(MOBMOD_SPECIAL_SKILL)); - CBattleEntity* PAbilityTarget = nullptr; - + auto* const PSpecialSkill = battleutils::GetMobSkill(PMob->getMobMod(MOBMOD_SPECIAL_SKILL)); if (PSpecialSkill == nullptr) { ShowError("CAIMobDummy::ActionSpawn Special skill was set but not found! (%d)", PMob->getMobMod(MOBMOD_SPECIAL_SKILL)); @@ -464,39 +502,38 @@ auto CMobController::TrySpecialSkill() -> bool return false; } - if (PSpecialSkill->getValidTargets() & TARGET_SELF) - { - PAbilityTarget = PMob; - } - else if (PTarget != nullptr) + // Resolve the ability target. Self-targeted skills always hit the mob; otherwise we need a + // PTarget within range. + CBattleEntity* const PAbilityTarget = [&]() -> CBattleEntity* { - // distance check for special skill - float currentDistance = distance(PMob->loc.p, PTarget->loc.p); - - if (currentDistance <= PSpecialSkill->getDistance()) + if (PSpecialSkill->getValidTargets() & TARGET_SELF) { - PAbilityTarget = PTarget; + return PMob; } - else + if (PTarget != nullptr && distance(PMob->loc.p, PTarget->loc.p) <= PSpecialSkill->getDistance()) { - return false; + return PTarget; } + return nullptr; + }(); + + if (PAbilityTarget == nullptr) + { + return false; } - else + + if (luautils::OnMobSkillCheck(PAbilityTarget, PMob, PSpecialSkill) != 0) { return false; } - if (luautils::OnMobSkillCheck(PAbilityTarget, PMob, PSpecialSkill) == 0) + if (!MobSkill(PAbilityTarget->targid, PSpecialSkill->getID(), std::nullopt)) { - if (MobSkill(PAbilityTarget->targid, PSpecialSkill->getID(), std::nullopt)) - { - m_LastSpecialTime = m_Tick; - return true; - } + return false; } - return false; + m_LastSpecialTime = m_Tick; + return true; } auto CMobController::TryCastSpell() -> bool @@ -508,39 +545,31 @@ auto CMobController::TryCastSpell() -> bool return false; // Can't cast spells. } - // Find random spell from list - Maybe chosenSpellId; - if (!PMob->PAI->IsEngaged()) + // Pick the initial spell candidate. Lua may still override this below via OnMobSpellChoose. + Maybe chosenSpellId = [&]() -> Maybe { - if (PMob->SpellContainer->HasBuffSpells()) + if (!PMob->PAI->IsEngaged()) { - chosenSpellId = PMob->SpellContainer->GetBuffSpell(); + // TODO: is this even possible to have a valid target without a buff spell? + return PMob->SpellContainer->HasBuffSpells() + ? PMob->SpellContainer->GetBuffSpell() + : PMob->SpellContainer->GetSpell(); } - else + + if (m_firstSpell) { - // is this even possible to have a valid target? - chosenSpellId = PMob->SpellContainer->GetSpell(); + // mob's first combat spell should be the aggro spell + m_firstSpell = false; + return PMob->SpellContainer->GetAggroSpell(); } - } - else if (m_firstSpell) - { - // mobs first combat spell, should be aggro spell - chosenSpellId = PMob->SpellContainer->GetAggroSpell(); - m_firstSpell = false; - } - else - { - chosenSpellId = PMob->SpellContainer->GetSpell(); - } - // Try to get an override spell from the script (if available) - // OnMobSpellChoose can also change PSpellTarget - auto PSpellTarget = PTarget ? PTarget : PMob; - auto possibleOverriddenSpell = luautils::OnMobSpellChoose(PMob, PSpellTarget, chosenSpellId); + return PMob->SpellContainer->GetSpell(); + }(); - Maybe maybeSpellOverride = std::get<0>(possibleOverriddenSpell); - Maybe maybeTargetOverride = std::get<1>(possibleOverriddenSpell); + // Lua's OnMobSpellChoose can override the spell and/or the target. + auto* const PSpellTarget = PTarget ? PTarget : PMob; + const auto [maybeSpellOverride, maybeTargetOverride] = luautils::OnMobSpellChoose(PMob, PSpellTarget, chosenSpellId); if (maybeSpellOverride.has_value()) { chosenSpellId = maybeSpellOverride.value(); @@ -548,52 +577,32 @@ auto CMobController::TryCastSpell() -> bool if (!chosenSpellId.has_value()) { - return false; // No spell Id. + return false; } - // Check if spell exists. - CSpell* PSpell = spell::GetSpell(chosenSpellId.value()); + auto* const PSpell = spell::GetSpell(chosenSpellId.value()); if (!PSpell) { - return false; // No spell object. + return false; } - // Check spell cooldown. if (PMob->PRecastContainer->Has(RECAST_MAGIC, static_cast(chosenSpellId.value()))) { return false; // Spell is on cooldown. } - // Check if mob can afford to cast this spell if (!battleutils::CanAffordSpell(PMob, PSpell, PSpell->getFlag())) { return false; // Not enough MP. } - // Target logic. - CBattleEntity* PCastTarget = nullptr; - - if (PSpell->getValidTarget() & TARGET_SELF) - { - PCastTarget = PMob; - } - else - { - PCastTarget = PTarget; - } - - if (maybeTargetOverride.has_value()) - { - PCastTarget = maybeTargetOverride.value(); - } - - // Check if target is in range before attempting to cast + // Resolve cast target. Lua override wins; otherwise self for self-targeted spells, else PTarget. + auto* const PCastTarget = maybeTargetOverride.value_or((PSpell->getValidTarget() & TARGET_SELF) ? PMob : PTarget); if (PCastTarget && distance(PMob->loc.p, PCastTarget->loc.p) > PSpell->getRange() + PMob->modelHitboxSize + PCastTarget->modelHitboxSize) { return false; // Target out of range. } - // Perform cast. CastSpell(chosenSpellId.value()); return true; } @@ -607,19 +616,17 @@ auto CMobController::CanCastSpells(IgnoreRecastsAndCosts ignoreRecastsAndCosts) return false; } - // check for spell blockers e.g. silence + // Spell blockers (silence, mute). if (PMob->StatusEffectContainer->HasStatusEffect({ EFFECT_SILENCE, EFFECT_MUTE })) { return false; } - // smn can only cast spells if it has no pet - if (PMob->GetMJob() == JOB_SMN) + // SMN can only cast while pet-less. + const bool smnHasLivePet = PMob->GetMJob() == JOB_SMN && PMob->PPet && !PMob->PPet->isDead(); + if (smnHasLivePet) { - if (PMob->PPet && !PMob->PPet->isDead()) - { - return false; - } + return false; } if (!IsMagicCastingEnabled()) @@ -639,58 +646,58 @@ void CMobController::CastSpell(SpellID spellid) { TracyZoneScoped; - const CSpell* PSpell = spell::GetSpell(spellid); + const auto* const PSpell = spell::GetSpell(spellid); if (PSpell == nullptr) { ShowWarning("ai_mob_dummy::CastSpell: SpellId <%i> is not found", static_cast(spellid)); + return; } - else + + // Pick a buff target. For self-targeted party buffs we sometimes redirect to the master or to a + // random nearby ally (only allies in the same engaged/idle state - engaged mobs don't buff idle + // mobs, see TODO). For non-self spells we always cast at PTarget. + const auto pickPartyBuffTarget = [&]() -> CBattleEntity* { - CBattleEntity* PCastTarget = nullptr; - // check valid targets - if (PSpell->getValidTarget() & TARGET_SELF) + if (PMob->PMaster != nullptr && xirand::GetRandomNumber(2) == 0) { - PCastTarget = PMob; - - // only buff other targets if i'm roaming - if ((PSpell->getValidTarget() & TARGET_PLAYER_PARTY)) - { - // chance to target my master - if (PMob->PMaster != nullptr && xirand::GetRandomNumber(2) == 0) - { - // target my master - PCastTarget = PMob->PMaster; - } - else if (xirand::GetRandomNumber(2) == 0) - { - // chance to target party - PMob->PAI->TargetFind->reset(); - PMob->PAI->TargetFind->findWithinArea(PMob, AOE_RADIUS::ATTACKER, PSpell->getRange(), FINDFLAGS_NONE, TARGET_NONE); + return PMob->PMaster; + } - if (!PMob->PAI->TargetFind->m_targets.empty()) - { - // randomly select a target - PCastTarget = PMob->PAI->TargetFind->m_targets[xirand::GetRandomNumber(PMob->PAI->TargetFind->m_targets.size())]; - - // revert target to self if not on same action - // TODO can engaged mobs buff idle mobs? - if (PMob->PAI->IsEngaged() != PCastTarget->PAI->IsEngaged()) - { - PCastTarget = PMob; - } - } - } - } + if (xirand::GetRandomNumber(2) != 0) + { + return PMob; } - else + + PMob->PAI->TargetFind->reset(); + PMob->PAI->TargetFind->findWithinArea(PMob, AOE_RADIUS::ATTACKER, PSpell->getRange(), FINDFLAGS_NONE, TARGET_NONE); + + const auto& candidates = PMob->PAI->TargetFind->m_targets; + if (candidates.empty()) { - PCastTarget = PTarget; + return PMob; } - if (PCastTarget) + auto* const PCandidate = candidates[xirand::GetRandomNumber(candidates.size())]; + // TODO: can engaged mobs buff idle mobs? + return PMob->PAI->IsEngaged() == PCandidate->PAI->IsEngaged() ? PCandidate : PMob; + }; + + auto* const PCastTarget = [&]() -> CBattleEntity* + { + if (!(PSpell->getValidTarget() & TARGET_SELF)) + { + return PTarget; + } + if (PSpell->getValidTarget() & TARGET_PLAYER_PARTY) { - Cast(PCastTarget->targid, spellid); + return pickPartyBuffTarget(); } + return PMob; + }(); + + if (PCastTarget) + { + Cast(PCastTarget->targid, spellid); } } @@ -698,16 +705,16 @@ auto CMobController::DoCombatTick(timer::time_point tick) -> Task { TracyZoneScopedC(0xFF0000); + // If the claiming player has moved on (PClaimedMob no longer points at us) for >3s, drop the + // claim so somebody else can engage. if (PMob->m_OwnerID.targid != 0) { - auto* POwner = dynamic_cast(PMob->GetEntity(PMob->m_OwnerID.targid)); - if (POwner && POwner->PClaimedMob != static_cast(PMob)) + const auto* const POwnerChar = dynamic_cast(PMob->GetEntity(PMob->m_OwnerID.targid)); + const bool claimStale = POwnerChar && POwnerChar->PClaimedMob != static_cast(PMob) && m_Tick >= m_DeclaimTime + 3s; + if (claimStale) { - if (m_Tick >= m_DeclaimTime + 3s) - { - PMob->m_OwnerID.clean(); - PMob->updatemask |= UPDATE_STATUS; - } + PMob->m_OwnerID.clean(); + PMob->updatemask |= UPDATE_STATUS; } } @@ -730,6 +737,7 @@ auto CMobController::DoCombatTick(timer::time_point tick) -> Task co_return; } + // Run-away follow: chase or terminate based on distance to the follow target. if (PFollowTarget != nullptr && m_followType == FollowType::RunAway) { if (distance(PMob->loc.p, PFollowTarget->loc.p) > FollowRunAwayDistance) @@ -748,6 +756,8 @@ auto CMobController::DoCombatTick(timer::time_point tick) -> Task co_return; } + // Try special / spell / TP / ranged actions in priority order. Each helper returns true if it + // produced an action this tick, in which case we yield until next tick. if (PTarget) { const float currentDistance = distance(PMob->loc.p, PTarget->loc.p); @@ -759,7 +769,8 @@ auto CMobController::DoCombatTick(timer::time_point tick) -> Task co_return; } - if (IsSpellReady(currentDistance, meleeAttackRange) && TryCastSpell()) // Try to spellcast (this is done first so things like Chainspell spam is prioritised over TP moves etc. + // Spellcast first so things like Chainspell spam are prioritised over TP moves. + if (IsSpellReady(currentDistance, meleeAttackRange) && TryCastSpell()) { co_return; } @@ -770,17 +781,19 @@ auto CMobController::DoCombatTick(timer::time_point tick) -> Task co_return; } - if (IsRangedAttackEnabled() && currentDistance <= rangedAttackRange && m_Tick >= PMob->m_LastRangedAttackTime && PMob->PAI->CanChangeState()) + const bool canFireRanged = IsRangedAttackEnabled() && + currentDistance <= rangedAttackRange && + m_Tick >= PMob->m_LastRangedAttackTime && + PMob->PAI->CanChangeState() && + PTarget != nullptr; + if (canFireRanged) { - if (PTarget != nullptr) + LookAtTarget(PTarget->targid); + if (POwner->PAI->Internal_RangedAttack(PTarget->targid)) { - FaceTarget(PTarget->targid); - if (POwner->PAI->Internal_RangedAttack(PTarget->targid)) - { - TapDeaggroTime(); - PMob->m_LastRangedAttackTime = m_Tick; - co_return; - } + TapDeaggroTime(); + PMob->m_LastRangedAttackTime = m_Tick; + co_return; } } } @@ -788,13 +801,14 @@ auto CMobController::DoCombatTick(timer::time_point tick) -> Task Move(); } -void CMobController::FaceTarget(const uint16 targid) const +void CMobController::LookAtTarget(const uint16 targid) const { TracyZoneScoped; - const uint16 resolvedTargid = targid != 0 ? targid : PMob->GetBattleTargetID(); - const auto* maybeTarget = PMob->GetEntity(resolvedTargid); - if (!(PMob->m_Behavior & BEHAVIOR_NO_TURN) && maybeTarget) + const uint16 resolvedTargid = targid != 0 ? targid : PMob->GetBattleTargetID(); + const auto* const maybeTarget = PMob->GetEntity(resolvedTargid); + + if (maybeTarget && !(PMob->m_Behavior & BEHAVIOR_NO_TURN)) { PMob->PAI->PathFind->LookAt(maybeTarget->loc.p); } @@ -810,49 +824,51 @@ void CMobController::Move() { return; } - if (PMob->PAI->PathFind->IsFollowingScriptedPath() && PMob->PAI->CanFollowPath()) + + if (PMob->PAI->PathFind->IsFollowingScriptedPath()) { PMob->PAI->PathFind->FollowPath(m_Tick); return; } - const bool move = PMob->PAI->PathFind->IsFollowingPath(); - float attack_range = PMob->GetMeleeRange(PTarget); - - if (PMob->getMobMod(MOBMOD_ATTACK_SKILL_LIST) > 0) + // Determine attack range. Ranged attacks override skill-list ranges since the + // mob skill lists are not fully audited. + const auto attackRange = [&]() -> float { - const auto skillList{ battleutils::GetMobSkillList(PMob->getMobMod(MOBMOD_ATTACK_SKILL_LIST)) }; + if (IsRangedAttackEnabled()) + { + return PMob->GetRangedAttackRange(); + } - if (!skillList.empty()) + if (PMob->getMobMod(MOBMOD_ATTACK_SKILL_LIST) > 0) { - const auto* skill{ battleutils::GetMobSkill(skillList.front()) }; - if (skill) + const auto skillList = battleutils::GetMobSkillList(PMob->getMobMod(MOBMOD_ATTACK_SKILL_LIST)); + if (!skillList.empty()) { - attack_range = skill->getDistance(); + if (const auto* skill = battleutils::GetMobSkill(skillList.front())) + { + return skill->getDistance(); + } } } - } - if (IsRangedAttackEnabled()) - { - // We need to set the range manually because the skill lists on mobs are not audited fully - attack_range = PMob->GetRangedAttackRange(); - } + return PMob->GetMeleeRange(PTarget); + }(); - const int16 offsetMod = PMob->getMobMod(MOBMOD_TARGET_DISTANCE_OFFSET); - const float offset = static_cast(offsetMod) / 10.0f; - float closeDistance = attack_range - (offsetMod == 0 ? 0.4f : offset); - - // No going negative on the final value. - if (closeDistance < 0.0f) + // How close PathInRange should stop short of the target. Defaults to attackRange minus a + // small slack (0.4y) so the mob ends up just inside melee range without overshooting; can + // be tuned per-mob via MOBMOD_TARGET_DISTANCE_OFFSET (units of 0.1y). + const float closeDistance = [&]() -> float { - closeDistance = 0.0f; - } + const int16 offsetMod = PMob->getMobMod(MOBMOD_TARGET_DISTANCE_OFFSET); + const float offset = offsetMod == 0 ? 0.4f : static_cast(offsetMod) / 10.0f; + return std::max(0.0f, attackRange - offset); + }(); + // Position-share mobs mirror their leader and skip all other movement logic. if (PMob->getMobMod(MOBMOD_SHARE_POS) > 0) { - const auto* posShare = static_cast(PMob->GetEntity(PMob->getMobMod(MOBMOD_SHARE_POS) + PMob->targid, TYPE_MOB)); - if (posShare) + if (const auto* posShare = static_cast(PMob->GetEntity(PMob->getMobMod(MOBMOD_SHARE_POS) + PMob->targid, TYPE_MOB))) { PMob->loc = posShare->loc; } @@ -860,118 +876,211 @@ void CMobController::Move() { ShowWarning("CMobController::Move() failed to get mob for MOBMOD_SHARE_POS"); } + + return; } - else if (PTarget) + + if (!PTarget) { - float currentDistance = distance(PMob->loc.p, PTarget->loc.p); + LookAtTarget(); + return; + } - // attempt to teleport (type 1) if target is out of melee range but within 30 distance - if (PMob->getMobMod(MOBMOD_TELEPORT_TYPE) == 1 && currentDistance > attack_range && currentDistance <= 30.0f) + const float currentDistance = distance(PMob->loc.p, PTarget->loc.p); + const bool isFollowingPath = PMob->PAI->PathFind->IsFollowingPath(); + + // Teleport type 1: jump in if out of melee range but within 30y and off cooldown. + if (PMob->getMobMod(MOBMOD_TELEPORT_TYPE) == 1 && + currentDistance > attackRange && + currentDistance <= 30.0f && + m_Tick >= m_LastSpecialTime + std::chrono::seconds(PMob->getMobMod(MOBMOD_TELEPORT_CD))) + { + if (const CMobSkill* teleportBegin = battleutils::GetMobSkill(PMob->getMobMod(MOBMOD_TELEPORT_START))) { - if (m_Tick >= m_LastSpecialTime + std::chrono::seconds(PMob->getMobMod(MOBMOD_TELEPORT_CD))) + m_LastSpecialTime = m_Tick; + MobSkill(PMob->targid, teleportBegin->getID(), std::nullopt); + } + } + + // Already in range and not actively moving: if we have line of sight, just face target. + // Re-check CanFollowPath because the teleport above may have changed AI state. + // + // The CanSeeTarget() check here is load-bearing: even when the mob is well inside melee range + // (<2.0y) we keep verifying LOS, so a mob that has slid up against a thin wall with the target + // on the other side won't sit there auto-attacking through the wall - it'll fall through to + // the movement logic below and try to reposition for an actual line of sight. + // + // CanSeeTarget() does a raycast, so it is intentionally placed last in the && chain - the + // cheap distance/state checks short-circuit it away in the common case. + const bool inAttackRange = currentDistance <= attackRange; + if (!PMob->PAI->CanFollowPath()) + { + LookAtTarget(); + return; + } + + if (inAttackRange && !isFollowingPath && CanSeeTargetCached()) + { + // The mob has line of sight and is in melee range. Most ticks we just settle here and + // attack. Two things can change that: + // + // 1. Mobs that aren't supposed to close (standback casters, leashed mobs, etc.) - we + // delegate to ShouldCloseToTarget for that decision. + // 2. The navmesh path to the target is much longer than the straight-line distance - + // e.g. the target is across a thin gap we can see across but can't walk across. In + // that case we'd rather walk the long way around than auto-attack across the gap. + // + // Probing the second case requires actually building the path so we can measure it; we + // skip the probe entirely for mobs that wouldn't follow it anyway (no speed / NO_MOVE, + // or ShouldCloseToTarget says no), so we don't strand a path on a non-mobile mob. + const bool canMove = PMob->GetSpeed() != 0 && PMob->getMobMod(MOBMOD_NO_MOVE) == 0 && m_Tick >= m_LastSpecialTime; + if (!canMove || !ShouldCloseToTarget(currentDistance)) + { + LookAtTarget(); + return; + } + + // Probe whether the navmesh path to the target is direct (no long detour around a wall). + // Re-run the probe only when the target entity changed or either party has moved far + // enough that the result could differ. Settled melee mobs otherwise re-use the last + // cached result so we don't pay a full findPath query every tick. + const bool targetChanged = PTarget != lastDirectProbeTarget_; + const bool mobMoved = !isWithinDistance(lastDirectProbePos_, PMob->loc.p, 1.0f); + const bool targetMoved = !isWithinDistance(lastDirectProbeTargetPos_, PTarget->loc.p, 1.0f); + if (targetChanged || mobMoved || targetMoved) + { + lastDirectProbeTarget_ = PTarget; + lastDirectProbePos_ = PMob->loc.p; + lastDirectProbeTargetPos_ = PTarget->loc.p; + + const auto projectedPosition = nearPosition(PTarget->loc.p, 0, rotationToRadian(worldAngle(PMob->loc.p, PTarget->loc.p))); + PMob->PAI->PathFind->PathInRange(projectedPosition, closeDistance, PATHFLAG_RUN); + lastDirectProbeWasDirect_ = PMob->PAI->PathFind->IsPathDirect(); + if (lastDirectProbeWasDirect_) { - if (const CMobSkill* teleportBegin = battleutils::GetMobSkill(PMob->getMobMod(MOBMOD_TELEPORT_START))) - { - m_LastSpecialTime = m_Tick; - MobSkill(PMob->targid, teleportBegin->getID(), std::nullopt); - } + PMob->PAI->PathFind->Clear(); } } - if (((currentDistance > attack_range) || move) && PMob->PAI->CanFollowPath()) + // Direct path (or cached result from last probe): settle here and attack. + // Non-direct: path is in place - fall through to FollowPath to walk the detour. + if (lastDirectProbeWasDirect_) + { + LookAtTarget(); + return; + } + } + + // Movement is gated by speed, NO_MOVE, and special-action cooldowns. + if (PMob->GetSpeed() == 0 || PMob->getMobMod(MOBMOD_NO_MOVE) != 0 || m_Tick < m_LastSpecialTime) + { + return; + } + + // Teleport type 2: instant warp to target if within the skill's distance. + if (PMob->getMobMod(MOBMOD_TELEPORT_TYPE) == 2) + { + if (const CMobSkill* teleportBegin = battleutils::GetMobSkill(PMob->getMobMod(MOBMOD_TELEPORT_START))) { - if (PMob->GetSpeed() != 0 && PMob->getMobMod(MOBMOD_NO_MOVE) == 0 && m_Tick >= m_LastSpecialTime) + if (currentDistance <= teleportBegin->getDistance()) { - // attempt to teleport to target (if in range) - if (PMob->getMobMod(MOBMOD_TELEPORT_TYPE) == 2) - { - CMobSkill* teleportBegin = battleutils::GetMobSkill(PMob->getMobMod(MOBMOD_TELEPORT_START)); + MobSkill(PMob->targid, teleportBegin->getID(), std::nullopt); + m_LastSpecialTime = m_Tick; + } + } + return; + } - if (teleportBegin && currentDistance <= teleportBegin->getDistance()) - { - MobSkill(PMob->targid, teleportBegin->getID(), std::nullopt); - m_LastSpecialTime = m_Tick; - return; - } - } - else if (CanMoveForward(currentDistance)) - { - if (!PMob->PAI->PathFind->IsFollowingPath()) - { - // out of melee range, try to path towards - if (currentDistance > attack_range) - { - auto projectedPosition = nearPosition(PTarget->loc.p, 0, rotationToRadian(worldAngle(PMob->loc.p, PTarget->loc.p))); - - // try to find path towards target - PMob->PAI->PathFind->PathInRange(projectedPosition, closeDistance, PATHFLAG_WALLHACK | PATHFLAG_RUN); - } - } - else if (!isWithinDistance(PMob->PAI->PathFind->GetDestination(), PTarget->loc.p, 0.1f)) // This checks against the previous frames distance, and can false positive for where we want to be _now_ - { - auto projectedPosition = nearPosition(PTarget->loc.p, 0, rotationToRadian(worldAngle(PMob->loc.p, PTarget->loc.p))); + if (!ShouldCloseToTarget(currentDistance)) + { + LookAtTarget(); + return; + } - // try to find path towards target - PMob->PAI->PathFind->PathInRange(projectedPosition, closeDistance, PATHFLAG_WALLHACK | PATHFLAG_RUN); - } + // (Re)compute a path towards the target when needed. + // - Already pathing: refresh if the destination has drifted from the target. + // (isWithinDistance compares against last frame's destination, so it can + // false-positive for where we want to be _now_.) + // - Not pathing: build a path when out of range or LOS is lost. Rate-limited to avoid + // hammering findPath at 50 Hz when the mob is stuck at the navmesh boundary (path + // finished, target still out of reach). Allow immediate re-path if the target moved. + // After 2 consecutive cooldown-triggered failures to close range, teleport to the target + // so exploiters can't park mobs against unreachable terrain for free wins (~4s window). + bool needNewPath = false; + bool isStuckRepath = false; + if (isFollowingPath) + { + needNewPath = !isWithinDistance(PMob->PAI->PathFind->GetDestination(), PTarget->loc.p, kPathDestinationDriftThreshold); + } + else + { + const bool outOfRange = currentDistance > attackRange; + const bool lostLOS = !CanSeeTargetCached(); + const bool targetMoved = !isWithinDistance(lastRePathTarget_, PTarget->loc.p, kPathDestinationDriftThreshold); + const bool cooldownDone = m_Tick >= rePathCooldownEnd_; + needNewPath = (outOfRange || lostLOS) && (targetMoved || cooldownDone); + isStuckRepath = needNewPath && !targetMoved && cooldownDone; + } - PMob->PAI->PathFind->FollowPath(m_Tick); + if (needNewPath) + { + lastRePathTarget_ = PTarget->loc.p; + rePathCooldownEnd_ = m_Tick + 2s; - if (!PMob->PAI->PathFind->IsFollowingPath()) - { - bool needToMove = false; + if (isStuckRepath) + { + ++stuckRePathCount_; + } + else + { + stuckRePathCount_ = 0; + } - // arrived at target - move if there is another mob under me - if (PTarget->objtype == TYPE_PC) - { - for (auto PSpawnedMob : static_cast(PTarget)->SpawnMOBList) - { - if (PSpawnedMob.second != PMob && !PSpawnedMob.second->PAI->PathFind->IsFollowingPath() && - distance(PSpawnedMob.second->loc.p, PMob->loc.p) < 1.0f) - { - auto angle = worldAngle(PMob->loc.p, PTarget->loc.p) + 64; - - position_t new_pos{ - PMob->loc.p.x - (cosf(rotationToRadian(angle)) * 1.5f), - PTarget->loc.p.y, - PMob->loc.p.z + (sinf(rotationToRadian(angle)) * 1.5f), - 0, - 0, - }; - - if (PMob->PAI->PathFind->ValidPosition(new_pos)) - { - PMob->PAI->PathFind->PathTo(new_pos, PATHFLAG_WALLHACK | PATHFLAG_RUN); - needToMove = true; - } - break; - } - } - } - - // Fix corner case where mob is attacking target at essentially exactly the distance that canMoveForward returns true at. - // where the mob doesn't rotate to face their target. - if (!needToMove) - { - FaceTarget(); - } - } - } - else - { - FaceTarget(); - } - } + if (stuckRePathCount_ >= 2 && PMob->getMobMod(MOBMOD_NO_STUCK_TELEPORT) == 0) + { + PMob->PAI->PathFind->WarpTo(PTarget->loc.p, closeDistance); + stuckRePathCount_ = 0; + rePathCooldownEnd_ = timer::time_point::min(); } else { - FaceTarget(); + const auto projectedPosition = nearPosition(PTarget->loc.p, 0, rotationToRadian(worldAngle(PMob->loc.p, PTarget->loc.p))); + PMob->PAI->PathFind->PathInRange(projectedPosition, closeDistance, PATHFLAG_RUN); } } - else + + PMob->PAI->PathFind->FollowPath(m_Tick); + + if (PMob->PAI->PathFind->IsFollowingPath()) { - FaceTarget(); + return; } + + // Arrived at the target. If another mob is stacked on top of us, shuffle aside. + if (PTarget->objtype == TYPE_PC) + { + for (const auto& [_, PSpawnedMob] : static_cast(PTarget)->SpawnMOBList) + { + if (PSpawnedMob == PMob || + PSpawnedMob->PAI->PathFind->IsFollowingPath() || + distance(PSpawnedMob->loc.p, PMob->loc.p) >= 1.0f) + { + continue; + } + + const auto newPos = sidestepPosition(PMob->loc.p, PTarget->loc.p, 1.5f); + if (PMob->PAI->PathFind->ValidPosition(newPos)) + { + PMob->PAI->PathFind->PathTo(newPos, PATHFLAG_RUN); + return; + } + break; + } + } + + // Corner case: mob attacking right at the ShouldCloseToTarget boundary - face target + // so we don't end up turned away from them. + LookAtTarget(); } void CMobController::HandleEnmity() @@ -979,128 +1088,156 @@ void CMobController::HandleEnmity() TracyZoneScoped; PMob->PEnmityContainer->DecayEnmity(); - auto* PHighestEnmityTarget{ PMob->PEnmityContainer->GetHighestEnmity() }; + auto* const PHighestEnmityTarget = PMob->PEnmityContainer->GetHighestEnmity(); - if (PMob->getMobMod(MOBMOD_SHARE_TARGET) > 0 && PMob->GetEntity(PMob->getMobMod(MOBMOD_SHARE_TARGET), TYPE_MOB)) - { - ChangeTarget(static_cast(PMob->GetEntity(PMob->getMobMod(MOBMOD_SHARE_TARGET), TYPE_MOB))->GetBattleTargetID()); + // SHARE_TARGET: copy the linked mob's battle target. Falls back to our own enmity list if the + // share resolves to nothing. + auto* const PShareSource = PMob->getMobMod(MOBMOD_SHARE_TARGET) > 0 + ? PMob->GetEntity(PMob->getMobMod(MOBMOD_SHARE_TARGET), TYPE_MOB) + : nullptr; - if (!PMob->GetBattleTargetID()) - { - if (PHighestEnmityTarget) - { - ChangeTarget(PHighestEnmityTarget->targid); - } - } + if (PShareSource) + { + ChangeTarget(static_cast(PShareSource)->GetBattleTargetID()); } - else + if ((!PShareSource || !PMob->GetBattleTargetID()) && PHighestEnmityTarget) { - if (PHighestEnmityTarget) - { - ChangeTarget(PHighestEnmityTarget->targid); - } + ChangeTarget(PHighestEnmityTarget->targid); } - // Bind special case - // Target the closest person on hate list with enmity + // Bind special case: when bound and unable to reach our current target, retarget to the closest + // attackable enmity owner instead. // TODO: do mobs with bind attack players *without* enmity if they are in the same party? // TODO: do jug pets do this? - // TODO: This code is assuming charmed mobs can do this -- they DO keep an enmity table, after all.. - if (PMob->objtype == TYPE_MOB && PMob->StatusEffectContainer && PMob->StatusEffectContainer->HasStatusEffect(EFFECT::EFFECT_BIND) && PMob->PAI->IsCurrentState()) + // TODO: this code assumes charmed mobs can do this -- they DO keep an enmity table, after all. + const bool isBoundAndAttacking = PMob->objtype == TYPE_MOB && + PMob->StatusEffectContainer && + PMob->StatusEffectContainer->HasStatusEffect(EFFECT::EFFECT_BIND) && + PMob->PAI->IsCurrentState(); + if (!isBoundAndAttacking) + { + return; + } + + auto* const PClosestAttackable = [&]() -> CBattleEntity* { - CBattleEntity* PNewTarget = nullptr; - std::unique_ptr m_errorMsg; // Ignored - if (PTarget && !PMob->CanAttack(PTarget, m_errorMsg) && PMob->PEnmityContainer) + std::unique_ptr errorMsg; // ignored + + if (!PTarget || PMob->CanAttack(PTarget, errorMsg) || !PMob->PEnmityContainer) { - float minDistance = 999999; - int32 totalEnmity = -1; + return nullptr; + } - auto enmityList = PMob->PEnmityContainer->GetEnmityList(); - for (auto enmityItem : *enmityList) + CBattleEntity* PBest = nullptr; + float minDistance = 999999.0f; + int32 bestEnmity = -1; + + for (const auto& [_, enmityObject] : *PMob->PEnmityContainer->GetEnmityList()) + { + auto* const PEnmityOwner = enmityObject.PEnmityOwner; + if (!PEnmityOwner) { - const EnmityObject_t& enmityObject = enmityItem.second; - CBattleEntity* PEnmityOwner = enmityObject.PEnmityOwner; + continue; + } - // Check total enmity first - if (PEnmityOwner && (enmityObject.CE + enmityObject.VE) > totalEnmity) - { - float targetDistance = distance(PEnmityOwner->loc.p, PMob->loc.p); + const int32 totalEnmity = enmityObject.CE + enmityObject.VE; + if (totalEnmity <= bestEnmity) + { + continue; + } - if (targetDistance < minDistance && PMob->CanAttack(PEnmityOwner, m_errorMsg)) - { - minDistance = targetDistance; - totalEnmity = enmityObject.CE + enmityObject.VE; - PNewTarget = PEnmityOwner; - } - } + const float targetDistance = distance(PEnmityOwner->loc.p, PMob->loc.p); + if (targetDistance >= minDistance || !PMob->CanAttack(PEnmityOwner, errorMsg)) + { + continue; } - } - if (PNewTarget) - { - ChangeTarget(PNewTarget->targid); + PBest = PEnmityOwner; + minDistance = targetDistance; + bestEnmity = totalEnmity; } - if (PTarget) - { - FaceTarget(PTarget->targid); - } + return PBest; + }(); + + if (PClosestAttackable) + { + ChangeTarget(PClosestAttackable->targid); + } + + if (PTarget) + { + LookAtTarget(PTarget->targid); } } auto CMobController::DoRoamTick(timer::time_point tick) -> Task { TracyZoneScopedC(0x00FF00); - // If there's someone on our enmity list, go from roaming -> engaging - if (PMob->PEnmityContainer->GetHighestEnmity() != nullptr && !(PMob->m_roamFlags & ROAMFLAG_IGNORE)) + + const bool ignoreAggro = (PMob->m_roamFlags & ROAMFLAG_IGNORE) != 0; + + // Anyone on our enmity list pulls us straight into combat. + if (auto* const PHighest = PMob->PEnmityContainer->GetHighestEnmity(); PHighest && !ignoreAggro) { - Engage(PMob->PEnmityContainer->GetHighestEnmity()->targid); + Engage(PHighest->targid); co_return; } - else if (PMob->m_OwnerID.id != 0 && !(PMob->m_roamFlags & ROAMFLAG_IGNORE)) + + // I'm claimed by someone - engage them if they still exist. + if (PMob->m_OwnerID.id != 0 && !ignoreAggro) { - // i'm claimed by someone and want to be fighting them PTarget = static_cast(PMob->GetEntity(PMob->m_OwnerID.targid, TYPE_PC | TYPE_MOB | TYPE_PET | TYPE_TRUST)); - if (PTarget != nullptr) { Engage(PTarget->targid); } - co_return; } - // TODO - else if (PMob->GetDespawnTime() > timer::time_point::min() && PMob->GetDespawnTime() < m_Tick) + + // TODO: investigate + if (PMob->GetDespawnTime() > timer::time_point::min() && PMob->GetDespawnTime() < m_Tick) { Despawn(); co_return; } - if (PMob->m_roamFlags & ROAMFLAG_IGNORE) + // Off-mesh guard: a mob that is off valid navmesh ground while IDLE should not exist. Two + // exemptions: + // 1) While it's following a path, do nothing - a path waypoint can momentarily read off-mesh on + // a poly seam (validPosition seam-strictness, not real clipping), and FollowPath will step it + // back on. Holding/despawning mid-path strands the mob: e.g. a long scripted pathTo whose + // chunk boundary lands on such a seam would freeze (or, before this guard honored NO_DESPAWN, + // despawn) at that chunk instead of continuing. A genuinely stuck mob still gets cleaned up: + // its chunked path eventually stalls and clears, after which this guard applies. + // 2) NO_DESPAWN mobs are never auto-removed (matching the far-from-home branch below). + // Arm a ~2-tick despawn timer otherwise. + if (!PMob->PAI->PathFind->IsFollowingPath() && !PMob->PAI->PathFind->ValidPosition(PMob->loc.p)) + { + const bool noDespawn = PMob->getMobMod(MOBMOD_NO_DESPAWN) != 0 || settings::get("map.MOB_NO_DESPAWN"); + if (!noDespawn && PMob->GetDespawnTime() == timer::time_point::min()) + { + PMob->SetDespawnTime(200ms); + } + co_return; + } + + // ROAMFLAG_IGNORE mobs never accept claim. + if (ignoreAggro) { - // don't claim me if I ignore PMob->m_OwnerID.clean(); } if (PFollowTarget != nullptr && m_followType == FollowType::Roam) { - float followRoamDistance = 4.0f; + const float followRoamDistance = PMob->getMobMod(MOBMOD_FOLLOW_LEASH_RANGE) > 0 ? PMob->getMobMod(MOBMOD_FOLLOW_LEASH_RANGE) : 4.0f; - if (PMob->getMobMod(MOBMOD_FOLLOW_LEASH_RANGE) > 0) - { - followRoamDistance = PMob->getMobMod(MOBMOD_FOLLOW_LEASH_RANGE); - } // Only path to leader if they're moving - if (distance(PMob->loc.p, PFollowTarget->loc.p) > followRoamDistance && - PFollowTarget->PAI->PathFind->IsFollowingPath()) + if (distance(PMob->loc.p, PFollowTarget->loc.p) > followRoamDistance && PFollowTarget->PAI->PathFind->IsFollowingPath()) { - float followStopRange = 2.0f; + const float followStopRange = PMob->getMobMod(MOBMOD_FOLLOW_STOP_RANGE) > 0 ? PMob->getMobMod(MOBMOD_FOLLOW_STOP_RANGE) : 2.0f; - if (PMob->getMobMod(MOBMOD_FOLLOW_STOP_RANGE) > 0) - { - followStopRange = PMob->getMobMod(MOBMOD_FOLLOW_STOP_RANGE); - } - PMob->PAI->PathFind->PathAround(PFollowTarget->loc.p, followStopRange, PATHFLAG_RUN | PATHFLAG_WALLHACK); + PMob->PAI->PathFind->PathAround(PFollowTarget->loc.p, followStopRange, PATHFLAG_RUN); } if (!PMob->PAI->PathFind->IsFollowingPath()) @@ -1109,18 +1246,17 @@ auto CMobController::DoRoamTick(timer::time_point tick) -> Task } } + // Recover 10% HP and lose TP every 10s while idle. if (m_Tick >= m_mobHealTime + 10s && PMob->getMobMod(MOBMOD_NO_REST) == 0 && PMob->CanRest()) { - // recover 10% health and lose tp if (PMob->Rest(0.1f)) { - // health updated PMob->updatemask |= UPDATE_HP; } + // At full HP, clear "dirty" exp tracking so the next engagement starts fresh. if (PMob->GetHPP() == 100) { - // at max health undirty exp PMob->m_HiPCLvl = 0; PMob->m_HiPartySize = 0; PMob->m_giveExp = true; @@ -1129,139 +1265,150 @@ auto CMobController::DoRoamTick(timer::time_point tick) -> Task m_mobHealTime = m_Tick; } - // skip roaming if waiting - if (m_Tick >= m_WaitTime) - { - // don't aggro a little bit after I just disengaged - PMob->m_neutral = PMob->CanBeNeutral() && m_Tick <= m_NeutralTime + 10s; + // + // Roam tick body + // - if (PMob->PAI->PathFind->IsFollowingPath()) + // Skip everything below until the wait timer elapses (e.g. after a special move). + if (m_Tick < m_WaitTime) + { + if (m_Tick >= m_LastRoamScript + 3s) { - FollowRoamPath(); + PMob->PAI->EventHandler.triggerListener("ROAM_TICK", PMob); + luautils::OnMobRoam(PMob); + m_LastRoamScript = m_Tick; } - else if (PMob->PAI->PathFind->IsPatrolling()) + co_return; + } + + // Don't aggro for ~10s after disengaging. + PMob->m_neutral = PMob->CanBeNeutral() && m_Tick <= m_NeutralTime + 10s; + + // If we already have a roam path going, just keep walking it. + if (PMob->PAI->PathFind->IsFollowingPath()) + { + FollowRoamPath(); + } + else if (PMob->PAI->PathFind->IsPatrolling()) + { + PMob->PAI->PathFind->ResumePatrol(); + FollowRoamPath(); + } + else if (m_Tick >= m_LastActionTime + std::chrono::seconds(PMob->getMobMod(MOBMOD_ROAM_COOL))) + { + if (PMob->GetCallForHelpFlag()) { - PMob->PAI->PathFind->ResumePatrol(); - FollowRoamPath(); + PMob->SetCallForHelpFlag(false); } - else if (m_Tick >= m_LastActionTime + std::chrono::seconds(PMob->getMobMod(MOBMOD_ROAM_COOL))) - { - // lets buff up or move around - if (PMob->GetCallForHelpFlag()) - { - PMob->SetCallForHelpFlag(false); - } - // if I just disengaged check if I should despawn - PMob->m_IsPathingHome = false; - if (!PMob->getMobMod(MOBMOD_DONT_ROAM_HOME) && PMob->IsFarFromHome()) - { - if (PMob->CanRoamHome()) - { - PMob->m_IsPathingHome = true; - // walk back to spawn if too far away - if (!PMob->PAI->PathFind->IsFollowingPath() && !PMob->PAI->PathFind->PathTo(PMob->m_SpawnPoint)) - { - PMob->PAI->PathFind->PathInRange(PMob->m_SpawnPoint, PMob->m_maxRoamDistance, PATHFLAG_RUN | PATHFLAG_WALLHACK); - } + PMob->m_IsPathingHome = false; - // limit total path to just 10 or - // else we'll move straight back to spawn - PMob->PAI->PathFind->LimitDistance(10.0f); + const bool wantsToHeadHome = !PMob->getMobMod(MOBMOD_DONT_ROAM_HOME) && PMob->IsFarFromHome(); + const bool noDespawn = PMob->getMobMod(MOBMOD_NO_DESPAWN) != 0; - FollowRoamPath(); + // Walk home or despawn after wandering too far. + if (wantsToHeadHome) + { + if (PMob->CanRoamHome()) + { + PMob->m_IsPathingHome = true; - // move back every 5 seconds - m_LastActionTime = m_Tick - (std::chrono::seconds(PMob->getMobMod(MOBMOD_ROAM_COOL)) + 10s); - } - else if (!(PMob->getMobMod(MOBMOD_NO_DESPAWN) != 0) && !settings::get("map.MOB_NO_DESPAWN")) + if (!PMob->PAI->PathFind->IsFollowingPath() && !PMob->PAI->PathFind->PathTo(PMob->m_SpawnPoint)) { - PMob->PAI->Despawn(); - // Override respawn timer set by CDespawnState for deaggro (60s instead of default) - PMob->loc.zone->spawnHandler()->registerForRespawn(PMob, 60s); - co_return; + PMob->PAI->PathFind->PathInRange(PMob->m_SpawnPoint, PMob->m_maxRoamDistance, PATHFLAG_RUN); } + + // Cap the path so we re-evaluate every few seconds instead of bee-lining home. + PMob->PAI->PathFind->LimitDistance(kRoamHomeStepDistance); + FollowRoamPath(); + + // Re-trigger the "head home" pulse roughly every 5 seconds. + m_LastActionTime = m_Tick - (std::chrono::seconds(PMob->getMobMod(MOBMOD_ROAM_COOL)) + 10s); } - else + else if (!noDespawn && !settings::get("map.MOB_NO_DESPAWN")) { - if (!(PMob->getMobMod(MOBMOD_NO_DESPAWN) != 0) && PMob->PMaster != nullptr && !PMob->PMaster->isAlive()) - { - // despawn pets if they are disengaged and master is dead - PMob->PAI->Despawn(); - co_return; - } + PMob->PAI->Despawn(); - // No longer including conditional for ROAMFLAG_AMBUSH now that using mixin to handle mob hiding - if (IsSpecialSkillReady(0) && TrySpecialSkill()) - { - // I spawned a pet - } - else if ( - (!PMob->PBattlefield || PMob->PBattlefield->GetStatus() != BATTLEFIELD_STATUS_OPEN) && - PMob->GetMJob() == JOB_SMN && CanCastSpells(IgnoreRecastsAndCosts::No) && - PMob->SpellContainer->HasBuffSpells() && m_Tick >= m_nextMagicTime) - { - // summon pet - // - initial summoner-mob pets in battlefields will happen via battlefield.lua, so the first player sees the action - // - Once battlefield is locked, behavior is back to normal with no rng added to pet summoning - TryCastSpell(); - } - else if (CanCastSpells(IgnoreRecastsAndCosts::No) && xirand::GetRandomNumber(10) < 3 && PMob->SpellContainer->HasBuffSpells()) - { - // cast buff - TryCastSpell(); - } - else if (PMob->m_roamFlags & ROAMFLAG_SCRIPTED) + // Override CDespawnState's deaggro respawn timer (60s instead of default). + PMob->loc.zone->spawnHandler()->registerForRespawn(PMob, 60s); + co_return; + } + } + // At/near home: despawn dead-master pets, otherwise pick an idle action. + else if (!noDespawn && PMob->PMaster != nullptr && !PMob->PMaster->isAlive()) + { + PMob->PAI->Despawn(); + co_return; + } + else + { + // Hiding mobs are now handled via mixin, so ROAMFLAG_AMBUSH is no longer special-cased here. + const bool battlefieldIsOpen = PMob->PBattlefield && PMob->PBattlefield->GetStatus() == BATTLEFIELD_STATUS_OPEN; + const bool wantsSummon = !battlefieldIsOpen && + PMob->GetMJob() == JOB_SMN && + CanCastSpells(IgnoreRecastsAndCosts::No) && + PMob->SpellContainer->HasBuffSpells() && + m_Tick >= m_nextMagicTime; + const bool wantsRandomBuff = CanCastSpells(IgnoreRecastsAndCosts::No) && + xirand::GetRandomNumber(10) < 3 && + PMob->SpellContainer->HasBuffSpells(); + + if (IsSpecialSkillReady(0) && TrySpecialSkill()) + { + // (Probably) spawned a pet via special skill. + } + else if (wantsSummon) + { + // Summon pet. The very first summoner-mob pet inside a battlefield is spawned via + // battlefield.lua so the first player sees the action; once the battlefield locks, + // pet summoning falls back to this normal path with no extra rng. + TryCastSpell(); + } + else if (wantsRandomBuff) + { + TryCastSpell(); + } + else if (PMob->m_roamFlags & ROAMFLAG_SCRIPTED) + { + // TODO: What is this tag? + // TODO: #AIToScript - let scripts handle the roam action entirely. + PMob->PAI->EventHandler.triggerListener("ROAM_ACTION", PMob); + luautils::OnMobRoamAction(PMob); + m_LastActionTime = m_Tick; + } + else if (PMob->CanRoam()) + { + // Worm: dive underground. Don't reset m_LastActionTime so we re-emerge promptly. + const bool isWormSurfacing = (PMob->m_roamFlags & ROAMFLAG_WORM) && !PMob->IsNameHidden(); + if (isWormSurfacing && !PMob->PAI->IsCurrentState()) { - // allow custom event action - PMob->PAI->EventHandler.triggerListener("ROAM_ACTION", PMob); - luautils::OnMobRoamAction(PMob); - m_LastActionTime = m_Tick; + PMob->animationsub = 1; + PMob->HideName(true); + PMob->SetUntargetable(true); + + // Sinking takes 2s; pad to 3s to let in-flight magic finish. + Wait(3s); + PMob->PAI->QueueAction( + queueAction_t( + 3s, + false, + [](CBaseEntity* MobEntity) + { + MobEntity->status = STATUS_TYPE::INVISIBLE; + })); } - else if (PMob->CanRoam()) + else if (!isWormSurfacing && + PMob->PAI->PathFind->RoamAround(PMob->m_SpawnPoint, PMob->GetRoamDistance(), static_cast(PMob->getMobMod(MOBMOD_ROAM_TURNS)), PMob->m_roamFlags)) { - // TODO: #AIToScript (event probably) - if (PMob->m_roamFlags & ROAMFLAG_WORM && !PMob->IsNameHidden()) - { - // don't reset m_LastActionTime until the roaming commences - if (!PMob->PAI->IsCurrentState()) - { - // move down - PMob->animationsub = 1; - PMob->HideName(true); - PMob->SetUntargetable(true); - - // don't move around until i'm fully in the ground - // Transition underground takes 2s, allow extra time for any magic effect to finish - Wait(3s); - PMob->PAI->QueueAction( - queueAction_t( - 3s, - false, - [](CBaseEntity* MobEntity) - { - MobEntity->status = STATUS_TYPE::INVISIBLE; - })); - } - } - else if (PMob->PAI->PathFind->RoamAround(PMob->m_SpawnPoint, PMob->GetRoamDistance(), static_cast(PMob->getMobMod(MOBMOD_ROAM_TURNS)), PMob->m_roamFlags)) + if (PMob->m_roamFlags & ROAMFLAG_STEALTH) { - if ((PMob->m_roamFlags & ROAMFLAG_STEALTH)) - { - // hidden name - PMob->HideName(true); - PMob->SetUntargetable(true); - - PMob->updatemask |= UPDATE_HP; - } - else - { - FollowRoamPath(); - } + PMob->HideName(true); + PMob->SetUntargetable(true); + PMob->updatemask |= UPDATE_HP; } else { - m_LastActionTime = m_Tick; + FollowRoamPath(); } } else @@ -1269,6 +1416,10 @@ auto CMobController::DoRoamTick(timer::time_point tick) -> Task m_LastActionTime = m_Tick; } } + else + { + m_LastActionTime = m_Tick; + } } } @@ -1282,15 +1433,15 @@ auto CMobController::DoRoamTick(timer::time_point tick) -> Task co_return; } -void CMobController::Wait(timer::duration _duration) +void CMobController::Wait(timer::duration duration) { if (m_Tick > m_WaitTime) { - m_WaitTime = m_Tick += _duration; + m_WaitTime = m_Tick += duration; } else { - m_WaitTime += _duration; + m_WaitTime += duration; } } @@ -1298,60 +1449,61 @@ void CMobController::FollowRoamPath() { TracyZoneScoped; - if (PMob->PAI->CanFollowPath()) + if (!PMob->PAI->CanFollowPath()) { - PMob->PAI->PathFind->FollowPath(m_Tick); + return; + } - CBattleEntity* PPet = PMob->PPet; - if (PPet != nullptr && PPet->PAI->IsSpawned() && !PPet->PAI->IsEngaged()) - { - // pet should follow me if roaming - position_t targetPoint = nearPosition(PMob->loc.p, 2.1f, (float)M_PI); + PMob->PAI->PathFind->FollowPath(m_Tick); - PPet->PAI->PathFind->PathTo(targetPoint); - } + // Pets shadow their roaming master at ~2.1y directly behind. + auto* const PPet = PMob->PPet; + if (PPet != nullptr && PPet->PAI->IsSpawned() && !PPet->PAI->IsEngaged()) + { + const auto targetPoint = nearPosition(PMob->loc.p, 2.1f, static_cast(M_PI)); + PPet->PAI->PathFind->PathTo(targetPoint); + } - // if I just finished reset my last action time - if (!PMob->PAI->PathFind->IsFollowingPath()) - { - const uint32 roamRandomness = std::clamp(static_cast(PMob->getMobMod(MOBMOD_ROAM_COOL) * 1000 / PMob->GetRoamRate()), 0, 120 * 1000); - m_LastActionTime = m_Tick - std::chrono::milliseconds(xirand::GetRandomNumber(roamRandomness)); + // Path just finished this tick: schedule the next wander and handle worm/spawn-rotation cases. + if (!PMob->PAI->PathFind->IsFollowingPath()) + { + const uint32 roamRandomness = std::clamp(static_cast(PMob->getMobMod(MOBMOD_ROAM_COOL) * 1000 / PMob->GetRoamRate()), 0, 120 * 1000); + m_LastActionTime = m_Tick - std::chrono::milliseconds(xirand::GetRandomNumber(roamRandomness)); - // i'm a worm pop back up - if (PMob->m_roamFlags & ROAMFLAG_WORM && PMob->PAI->IsUntargetable()) - { - // send a final position update before coming out of the ground to avoid a slight movement as it emerges - PMob->loc.zone->UpdateEntityPacket(PMob, ENTITY_UPDATE, UPDATE_POS); - - // don't re-enter this block, but don't roam until emerging - PMob->status = STATUS_TYPE::UPDATE; - PMob->SetUntargetable(false); - Wait(2s); - PMob->PAI->QueueAction( - queueAction_t( - 2s, - false, - [](CBaseEntity* MobEntity) - { - MobEntity->animationsub = 0; - MobEntity->HideName(false); - })); - } + // Worm finished its underground roam - pop back up. + if ((PMob->m_roamFlags & ROAMFLAG_WORM) && PMob->PAI->IsUntargetable()) + { + // Send a final position update before emerging so we don't visibly snap. + PMob->loc.zone->UpdateEntityPacket(PMob, ENTITY_UPDATE, UPDATE_POS); - // face spawn rotation if I just moved back to spawn - // used by dynamis mobs, bcnm mobs etc - if (PMob->getMobMod(MOBMOD_ROAM_RESET_FACING) && distance(PMob->loc.p, PMob->m_SpawnPoint) <= PMob->m_maxRoamDistance) - { - PMob->loc.p.rotation = PMob->m_SpawnPoint.rotation; - } + // Lock further roaming until emerge animation completes. + PMob->status = STATUS_TYPE::UPDATE; + PMob->SetUntargetable(false); + Wait(2s); + PMob->PAI->QueueAction( + queueAction_t( + 2s, + false, + [](CBaseEntity* MobEntity) + { + MobEntity->animationsub = 0; + MobEntity->HideName(false); + })); } - if (PMob->PAI->PathFind->OnPoint()) + // Snap to spawn rotation when we finish a path back near the spawn point. Used by dynamis / + // BCNM mobs that should always face a fixed direction at their spawn. + if (PMob->getMobMod(MOBMOD_ROAM_RESET_FACING) && distance(PMob->loc.p, PMob->m_SpawnPoint) <= PMob->m_maxRoamDistance) { - PMob->PAI->EventHandler.triggerListener("PATH", PMob); - luautils::OnPath(PMob); + PMob->loc.p.rotation = PMob->m_SpawnPoint.rotation; } } + + if (PMob->PAI->PathFind->OnPoint()) + { + PMob->PAI->EventHandler.triggerListener("PATH", PMob); + luautils::OnPath(PMob); + } } void CMobController::Despawn() @@ -1377,20 +1529,30 @@ void CMobController::Reset() PTarget = nullptr; ClearFollowTarget(); + + // Clear pathing state so a respawned mob doesn't inherit stale re-path / direct-probe caches. + rePathCooldownEnd_ = timer::time_point::min(); + lastRePathTarget_ = {}; + stuckRePathCount_ = 0; + + lastDirectProbeTarget_ = nullptr; + lastDirectProbePos_ = {}; + lastDirectProbeTargetPos_ = {}; + lastDirectProbeWasDirect_ = true; } auto CMobController::MobSkill(const uint16 targid, uint16 wsid, Maybe castTimeOverride) -> bool { TracyZoneScoped; - if (POwner) + if (!POwner) { - FaceTarget(targid); - PMob->PAI->EventHandler.triggerListener("WEAPONSKILL_BEFORE_USE", PMob, wsid); - return POwner->PAI->Internal_MobSkill(targid, wsid, castTimeOverride); + return false; } - return false; + LookAtTarget(targid); + PMob->PAI->EventHandler.triggerListener("WEAPONSKILL_BEFORE_USE", PMob, wsid); + return POwner->PAI->Internal_MobSkill(targid, wsid, castTimeOverride); } auto CMobController::Disengage() -> bool @@ -1402,6 +1564,15 @@ auto CMobController::Disengage() -> bool PMob->m_neutral = true; m_NeutralTime = m_Tick; + rePathCooldownEnd_ = timer::time_point::min(); + lastRePathTarget_ = {}; + stuckRePathCount_ = 0; + + lastDirectProbeTarget_ = nullptr; + lastDirectProbePos_ = {}; + lastDirectProbeTargetPos_ = {}; + lastDirectProbeWasDirect_ = true; + PMob->PAI->PathFind->Clear(); PMob->PEnmityContainer->Clear(); @@ -1425,38 +1596,47 @@ auto CMobController::Engage(const uint16 targid) -> bool { TracyZoneScoped; - auto ret = CController::Engage(targid); - if (ret) + const bool engaged = CController::Engage(targid); + if (!engaged) { - m_firstSpell = true; + return false; + } - if (PFollowTarget != nullptr && m_followType == FollowType::Roam) - { - ClearFollowTarget(); - } + m_firstSpell = true; + rePathCooldownEnd_ = timer::time_point::min(); + lastRePathTarget_ = {}; + stuckRePathCount_ = 0; - // Don't cast magic or use special ability right away - if (PMob->getMobMod(MOBMOD_MAGIC_DELAY) != 0) - { - m_nextMagicTime = - m_Tick + std::chrono::seconds(PMob->getMobMod(MOBMOD_MAGIC_COOL) + xirand::GetRandomNumber(PMob->getMobMod(MOBMOD_MAGIC_DELAY))); - } + lastDirectProbeTarget_ = nullptr; + lastDirectProbePos_ = {}; + lastDirectProbeTargetPos_ = {}; + lastDirectProbeWasDirect_ = true; - if (PMob->getMobMod(MOBMOD_SPECIAL_DELAY) != 0) - { - m_LastSpecialTime = m_Tick - std::chrono::seconds(PMob->getMobMod(MOBMOD_SPECIAL_COOL) + - xirand::GetRandomNumber(PMob->getMobMod(MOBMOD_SPECIAL_DELAY))); - } + if (PFollowTarget != nullptr && m_followType == FollowType::Roam) + { + ClearFollowTarget(); + } - m_tpThreshold = xirand::GetRandomNumber(1000, 3000); + // Optional opening delays so we don't immediately cast / use a special ability on engage. + if (PMob->getMobMod(MOBMOD_MAGIC_DELAY) != 0) + { + m_nextMagicTime = m_Tick + std::chrono::seconds(PMob->getMobMod(MOBMOD_MAGIC_COOL) + xirand::GetRandomNumber(PMob->getMobMod(MOBMOD_MAGIC_DELAY))); + } - // Pet should also fight the target if they can - if (PMob->PPet && !PMob->PPet->PAI->IsEngaged()) - { - PMob->PPet->PAI->Engage(targid); - } + if (PMob->getMobMod(MOBMOD_SPECIAL_DELAY) != 0) + { + m_LastSpecialTime = m_Tick - std::chrono::seconds(PMob->getMobMod(MOBMOD_SPECIAL_COOL) + xirand::GetRandomNumber(PMob->getMobMod(MOBMOD_SPECIAL_DELAY))); } - return ret; + + m_tpThreshold = xirand::GetRandomNumber(1000, 3000); + + // Pet engages the same target. + if (PMob->PPet && !PMob->PPet->PAI->IsEngaged()) + { + PMob->PPet->PAI->Engage(targid); + } + + return true; } auto CMobController::CanFollowTarget(CBattleEntity* PTarget) const -> bool @@ -1468,53 +1648,52 @@ auto CMobController::CanAggroTarget(CBattleEntity* PTarget) const -> bool { TracyZoneScoped; TracyZoneString(PMob->getName()); - if (PTarget) - { - TracyZoneString(PTarget->getName()); - if (PMob->getBattleID() != PTarget->getBattleID()) - { - return false; - } + if (!PTarget) + { + return false; + } + TracyZoneString(PTarget->getName()); - // Don't aggro I'm neutral - if ((PMob->getMobMod(MOBMOD_ALWAYS_AGGRO) == 0 && !PMob->m_Aggro) || PMob->m_neutral || PMob->isDead()) - { - return false; - } + if (PMob->getBattleID() != PTarget->getBattleID()) + { + return false; + } - // Don't aggro I'm special - if (PMob->getMobMod(MOBMOD_NO_AGGRO) > 0) - { - return false; - } + // I'm not in an aggressive state. + const bool nonAggressive = (PMob->getMobMod(MOBMOD_ALWAYS_AGGRO) == 0 && !PMob->m_Aggro) || PMob->m_neutral || PMob->isDead(); + if (nonAggressive) + { + return false; + } - // Do not aggro if a normal CoP Fomor and the player has low enough fomor hate - if (PMob->m_Family == 172 && !(PMob->m_Type & MOBTYPE_NOTORIOUS) && - (PMob->getZone() >= ZONE_LUFAISE_MEADOWS && PMob->getZone() <= ZONE_SACRARIUM) && - PTarget->objtype == TYPE_PC) - { - if (static_cast(PTarget)->getCharVar("FOMOR_HATE") < 8) - { - return false; - } - } + if (PMob->getMobMod(MOBMOD_NO_AGGRO) > 0) + { + return false; + } - // Don't aggro I'm an underground worm - if ((PMob->m_roamFlags & ROAMFLAG_WORM) && PMob->IsNameHidden()) - { - return false; - } + // CoP Fomors only aggro players with sufficient FOMOR_HATE; NMs ignore this and always aggro. + const bool isCopFomorZone = PMob->m_Family == 172 && + !(PMob->m_Type & MOBTYPE_NOTORIOUS) && + PMob->getZone() >= ZONE_LUFAISE_MEADOWS && + PMob->getZone() <= ZONE_SACRARIUM; + if (isCopFomorZone && PTarget->objtype == TYPE_PC && static_cast(PTarget)->getCharVar("FOMOR_HATE") < 8) + { + return false; + } - if (PTarget->isDead() || PTarget->isMounted()) - { - return false; - } + // Worms underground can't aggro anything. + if ((PMob->m_roamFlags & ROAMFLAG_WORM) && PMob->IsNameHidden()) + { + return false; + } - return PMob->PMaster == nullptr && PMob->PAI->IsSpawned() && !PMob->PAI->IsEngaged() && CanDetectTarget(PTarget); + if (PTarget->isDead() || PTarget->isMounted()) + { + return false; } - return false; + return PMob->PMaster == nullptr && PMob->PAI->IsSpawned() && !PMob->PAI->IsEngaged() && CanDetectTarget(PTarget); } void CMobController::TapDeaggroTime() @@ -1531,7 +1710,7 @@ auto CMobController::Cast(const uint16 targid, const SpellID spellid) -> bool { TracyZoneScoped; - FaceTarget(targid); + LookAtTarget(targid); return CController::Cast(targid, spellid); } @@ -1564,12 +1743,7 @@ void CMobController::SetFollowTarget(CBaseEntity* PTarget, const FollowType foll auto CMobController::HasFollowTarget() const -> bool { - if (PFollowTarget && m_followType != FollowType::None) - { - return true; - } - - return false; + return PFollowTarget != nullptr && m_followType != FollowType::None; } void CMobController::ClearFollowTarget() @@ -1580,45 +1754,50 @@ void CMobController::ClearFollowTarget() void CMobController::OnCastStopped(CMagicState& state, action_t& action) { - int32 magicCool = PMob->getMobMod(MOBMOD_MAGIC_COOL); - m_nextMagicTime = m_Tick + std::chrono::seconds(xirand::GetRandomNumber(magicCool / 2, magicCool)); + const int32 magicCool = PMob->getMobMod(MOBMOD_MAGIC_COOL); + m_nextMagicTime = m_Tick + std::chrono::seconds(xirand::GetRandomNumber(magicCool / 2, magicCool)); } -auto CMobController::CanMoveForward(const float currentDistance) -> bool +auto CMobController::ShouldCloseToTarget(const float currentDistance) -> bool { TracyZoneScoped; - uint16 standbackRange = 20; - - if (PMob->getMobMod(MOBMOD_STANDBACK_RANGE) > 0) - { - standbackRange = PMob->getMobMod(MOBMOD_STANDBACK_RANGE); - } + // Each LOS check below routes through CanSeeTargetCached() and is intentionally placed last + // in its && / || chain so the cheap distance/state predicates short-circuit it away in the + // common case. When the cache does fire, it shares a single raycast with the rest of the + // tick (e.g. Move()'s callsites). + const auto standbackRangeMod = PMob->getMobMod(MOBMOD_STANDBACK_RANGE); + const auto standbackHpThreshold = PMob->getMobMod(MOBMOD_HP_STANDBACK); + const auto standbackRange = standbackRangeMod > 0 ? static_cast(standbackRangeMod) : uint16{ 20 }; const bool isClosingToRangedAttackRange = IsRangedAttackEnabled() && currentDistance > PMob->GetRangedAttackRange(); + const bool isInsideStandbackRange = !isClosingToRangedAttackRange && currentDistance < standbackRange; - if (!isClosingToRangedAttackRange && PMob->m_Behavior & BEHAVIOR_STANDBACK && currentDistance < standbackRange && PMob->CanSeeTarget(PTarget)) + // Behavior-flag standback: hold position while we have line of sight to the target. + if (isInsideStandbackRange && (PMob->m_Behavior & BEHAVIOR_STANDBACK) && CanSeeTargetCached()) { return false; } - auto standbackThreshold = PMob->getMobMod(MOBMOD_HP_STANDBACK); - if (!isClosingToRangedAttackRange && - currentDistance < standbackRange && - standbackThreshold > 0 && - PMob->getMobMod(MOBMOD_NO_STANDBACK) == 0 && - PMob->GetHPP() >= standbackThreshold && - (PMob->GetMaxMP() == 0 || PMob->GetMPP() >= standbackThreshold)) + // HP/MP-threshold standback: a healthy caster mob prefers to stay back and cast. + const bool wantsHealthStandback = isInsideStandbackRange && + standbackHpThreshold > 0 && + PMob->getMobMod(MOBMOD_NO_STANDBACK) == 0 && + PMob->GetHPP() >= standbackHpThreshold && + (PMob->GetMaxMP() == 0 || PMob->GetMPP() >= standbackHpThreshold); + if (wantsHealthStandback) { - // Excluding Nins, mobs should not standback if can't cast magic + // Excluding NINs, mobs should not stand back if they can't actually cast something useful. return PMob->GetMJob() != JOB_NIN && PMob->SpellContainer->HasSpells() && !CanCastSpells(IgnoreRecastsAndCosts::Yes); } - if (PTarget && !PMob->CanSeeTarget(PTarget)) + // Lost line of sight: close in regardless of leash. + if (PTarget && !CanSeeTargetCached()) { return true; } + // Spawn leash: don't chase past the configured tether distance. if (PMob->getMobMod(MOBMOD_SPAWN_LEASH) > 0 && distance(PMob->loc.p, PMob->m_SpawnPoint) > PMob->getMobMod(MOBMOD_SPAWN_LEASH)) { return false; @@ -1627,6 +1806,29 @@ auto CMobController::CanMoveForward(const float currentDistance) -> bool return true; } +auto CMobController::CanSeeTargetCached() -> bool +{ + // No target -> no LOS. We also avoid emplacing the cache for a null target so the next + // tick's first call against a real target establishes a fresh entry. + if (!PTarget) + { + return false; + } + + // If the cached entry is for a different target (e.g. retargeting mid-tick), wipe both + // fields together via the wrapping Maybe so we never serve stale data. + if (!targetLosCache_.has_value() || targetLosCache_->target != PTarget) + { + targetLosCache_.emplace(TargetLOSCache{ PTarget, {} }); + } + + return targetLosCache_->canSeeTarget.getOrCompute( + [&]() + { + return PMob->CanSeeTarget(PTarget); + }); +} + auto CMobController::IsSpecialSkillReady(const float currentDistance) const -> bool { TracyZoneScoped; @@ -1641,12 +1843,8 @@ auto CMobController::IsSpecialSkillReady(const float currentDistance) const -> b return false; } - int32 bonusTime = 0; - if (currentDistance > 5) - { - // Mobs use ranged attacks quicker when standing back - bonusTime = PMob->getMobMod(MOBMOD_STANDBACK_COOL); - } + // Mobs use ranged attacks quicker when standing back. + const int32 bonusTime = currentDistance > 5 ? PMob->getMobMod(MOBMOD_STANDBACK_COOL) : 0; return m_Tick >= m_LastSpecialTime + std::chrono::seconds(PMob->getMobMod(MOBMOD_SPECIAL_COOL) - bonusTime); } diff --git a/src/map/ai/controllers/mob_controller.h b/src/map/ai/controllers/mob_controller.h index 00a3f62a546..d3c92aec48e 100644 --- a/src/map/ai/controllers/mob_controller.h +++ b/src/map/ai/controllers/mob_controller.h @@ -23,6 +23,9 @@ #include "controller.h" +#include "common/types/cached.h" +#include "common/types/maybe.h" + enum class FollowType : uint8 { None, @@ -65,7 +68,7 @@ class CMobController : public CController virtual void TryLink(); auto CanDetectTarget(CBattleEntity* PTarget, bool forceSight = false) const -> bool; - auto CanPursueTarget(const CBattleEntity* PTarget) const -> bool; + auto CanTrackByScent(const CBattleEntity* PTarget) const -> bool; auto CheckLock(CBattleEntity* PTarget) const -> bool; auto CheckDetection(CBattleEntity* PTarget) -> bool; virtual auto CanCastSpells(IgnoreRecastsAndCosts ignoreRecastsAndCosts) -> bool; @@ -73,21 +76,37 @@ class CMobController : public CController virtual void Move(); virtual auto DoCombatTick(timer::time_point tick) -> Task; - void FaceTarget(uint16 targid = 0) const; + void LookAtTarget(uint16 targid = 0) const; virtual void HandleEnmity(); virtual auto DoRoamTick(timer::time_point tick) -> Task; - void Wait(timer::duration _duration); + void Wait(timer::duration duration); void FollowRoamPath(); - auto CanMoveForward(float currentDistance) -> bool; - auto IsSpecialSkillReady(float currentDistance) const -> bool; - auto IsSpellReady(const float& currentDistance, const float& meleeRange) const -> bool; + auto ShouldCloseToTarget(float currentDistance) -> bool; + + // Per-tick line-of-sight cache. CanSeeTarget() is a navmesh raycast and several call sites + // in a single Move() tick may need the answer (in-range face check, ShouldCloseToTarget's + // standback / lost-LOS branches, the needNewPath check). Wrapping the cache *together with* + // its target in a Maybe lets us wipe both at once whenever the target swaps - we never serve + // a cached raycast for the wrong target. The whole thing is also reset at the start of each + // Tick() so cached values are never staler than one frame. + struct TargetLOSCache + { + CBattleEntity* target; + Cached canSeeTarget; + }; + Maybe targetLosCache_; + + auto CanSeeTargetCached() -> bool; + auto IsSpecialSkillReady(float currentDistance) const -> bool; + auto IsSpellReady(const float& currentDistance, const float& meleeRange) const -> bool; CBattleEntity* PTarget{ nullptr }; static constexpr float FollowRoamDistance{ 4.0f }; static constexpr float FollowRunAwayDistance{ 4.0f }; - CBaseEntity* PFollowTarget{ nullptr }; + + CBaseEntity* PFollowTarget{ nullptr }; private: CMobEntity* const PMob; @@ -106,4 +125,22 @@ class CMobController : public CController bool m_firstSpell{ true }; timer::time_point m_LastRoamScript{ timer::time_point::min() }; uint16_t m_tpThreshold{ 1000 }; + + // Re-path thrashing guard: when a mob is stuck at the navmesh boundary (path finished but + // target is still out of attack range), rate-limit PathInRange calls so we don't hammer + // findPath. Allow an immediate re-path when the target moves significantly. + // After 2 consecutive cooldown-triggered re-paths that don't progress, teleport the mob + // to the target so players can't exploit unreachable terrain for free wins. + timer::time_point rePathCooldownEnd_{ timer::time_point::min() }; + position_t lastRePathTarget_{}; + uint8_t stuckRePathCount_{ 0 }; + + // In-range LOS probe cache. When a mob is settled in melee range, the directness probe + // (PathInRange + IsPathDirect) is only re-run when the target entity changes or either + // party has moved enough that the result could differ. Avoids a navmesh query every tick + // for every engaged melee mob standing next to their target. + CBattleEntity* lastDirectProbeTarget_{ nullptr }; + position_t lastDirectProbePos_{}; + position_t lastDirectProbeTargetPos_{}; + bool lastDirectProbeWasDirect_{ true }; }; diff --git a/src/map/ai/controllers/pet_controller.cpp b/src/map/ai/controllers/pet_controller.cpp index 121d33834f0..f20e30b692d 100644 --- a/src/map/ai/controllers/pet_controller.cpp +++ b/src/map/ai/controllers/pet_controller.cpp @@ -162,8 +162,8 @@ auto CPetController::DoRoamTick(timer::time_point tick) -> Task if (!PPet->PAI->PathFind->IsFollowingPath() || distance(PPet->PAI->PathFind->GetDestination(), PPet->PMaster->loc.p) > 2.0f) { - if (!PPet->PAI->PathFind->PathAround(PPet->PMaster->loc.p, 2.0f, PATHFLAG_RUN | PATHFLAG_WALLHACK) && - !PPet->PAI->PathFind->PathInRange(PPet->PMaster->loc.p, 2.0f, PATHFLAG_RUN | PATHFLAG_WALLHACK)) + if (!PPet->PAI->PathFind->PathAround(PPet->PMaster->loc.p, 2.0f, PATHFLAG_RUN) && + !PPet->PAI->PathFind->PathInRange(PPet->PMaster->loc.p, 2.0f, PATHFLAG_RUN)) { // If we got here, the pet isn't able to path to master // But it cant, so maybe we teleported or dropped down a hole @@ -235,7 +235,7 @@ bool CPetController::PetSkill(uint16 targid, uint16 abilityid) if (POwner) { - FaceTarget(targid); + LookAtTarget(targid); PPet->PAI->EventHandler.triggerListener("WEAPONSKILL_BEFORE_USE", PPet, abilityid); return POwner->PAI->Internal_PetSkill(targid, abilityid); } diff --git a/src/map/ai/controllers/player_charm_controller.cpp b/src/map/ai/controllers/player_charm_controller.cpp index df25508124e..af3d9157ecf 100644 --- a/src/map/ai/controllers/player_charm_controller.cpp +++ b/src/map/ai/controllers/player_charm_controller.cpp @@ -83,7 +83,7 @@ void CPlayerCharmController::DoCombatTick(timer::time_point tick) { if (POwner->GetSpeed() > 0) { - POwner->PAI->PathFind->PathAround(PTarget->loc.p, 2.0f, PATHFLAG_WALLHACK | PATHFLAG_RUN); + POwner->PAI->PathFind->PathAround(PTarget->loc.p, 2.0f, PATHFLAG_RUN); POwner->PAI->PathFind->FollowPath(m_Tick); } } @@ -104,7 +104,7 @@ void CPlayerCharmController::DoRoamTick(timer::time_point tick) { if (POwner->PAI->PathFind) { - if (currentDistance < 35.0f && POwner->PAI->PathFind->PathAround(POwner->PMaster->loc.p, 2.0f, PATHFLAG_RUN | PATHFLAG_WALLHACK)) + if (currentDistance < 35.0f && POwner->PAI->PathFind->PathAround(POwner->PMaster->loc.p, 2.0f, PATHFLAG_RUN)) { POwner->PAI->PathFind->FollowPath(m_Tick); } diff --git a/src/map/ai/controllers/trust_controller.cpp b/src/map/ai/controllers/trust_controller.cpp index c2bb18d583d..1e6e697e291 100644 --- a/src/map/ai/controllers/trust_controller.cpp +++ b/src/map/ai/controllers/trust_controller.cpp @@ -213,7 +213,7 @@ auto CTrustController::DoCombatTick(timer::time_point tick) -> Task if (currentDistanceToTarget > RoamDistance) { if (currentDistanceToTarget < RoamDistance * 3.0f && - PTrust->PAI->PathFind->PathAround(PTarget->loc.p, RoamDistance, PATHFLAG_RUN | PATHFLAG_WALLHACK)) + PTrust->PAI->PathFind->PathAround(PTarget->loc.p, RoamDistance, PATHFLAG_RUN)) { PTrust->PAI->PathFind->FollowPath(m_Tick); } @@ -295,19 +295,13 @@ auto CTrustController::DoNonCombatTick(timer::time_point tick) -> Task distance(POtherTrust->loc.p, PTrust->loc.p) < 1.0f && !PTrust->PAI->PathFind->IsFollowingPath()) { - auto diff_angle = worldAngle(PTrust->loc.p, POtherTrust->loc.p) + 64; - auto amount = (currentPartyPos % 2) ? 1.0f : -1.0f; - - position_t new_pos = { - PTrust->loc.p.x - (cosf(rotationToRadian(diff_angle)) * amount), - POtherTrust->loc.p.y, - PTrust->loc.p.z + (sinf(rotationToRadian(diff_angle)) * amount), - 0, - 0, - }; + // Sidestep relative to the trust we're stacked on so we settle beside them. + // Odd/even party slots take opposite sides so the group spreads out predictably. + const float amount = (currentPartyPos % 2) ? 1.0f : -1.0f; + const auto new_pos = sidestepPosition(PTrust->loc.p, POtherTrust->loc.p, amount); if (PTrust->PAI->PathFind->ValidPosition(new_pos) && - PTrust->PAI->PathFind->PathAround(new_pos, desiredFollowDistance, PATHFLAG_RUN | PATHFLAG_WALLHACK)) + PTrust->PAI->PathFind->PathAround(new_pos, desiredFollowDistance, PATHFLAG_RUN)) { PTrust->PAI->PathFind->FollowPath(m_Tick); } @@ -322,7 +316,7 @@ auto CTrustController::DoNonCombatTick(timer::time_point tick) -> Task else if (currentDistance > desiredFollowDistance) { if (currentDistance < desiredFollowDistance * 3.0f && - PTrust->PAI->PathFind->PathAround(PFollowTarget->loc.p, desiredFollowDistance, PATHFLAG_RUN | PATHFLAG_WALLHACK)) + PTrust->PAI->PathFind->PathAround(PFollowTarget->loc.p, desiredFollowDistance, PATHFLAG_RUN)) { PTrust->PAI->PathFind->FollowPath(m_Tick); } @@ -397,7 +391,7 @@ auto CTrustController::DoRoamTick(timer::time_point tick) -> Task if (currentDistance < declumpDistance) { // Too close to follow target - push away to maintain formation spacing - if (PFollowTarget && POwner->PAI->PathFind->PathAround(PFollowTarget->loc.p, followTarget + 0.5f, PATHFLAG_RUN | PATHFLAG_WALLHACK)) + if (PFollowTarget && POwner->PAI->PathFind->PathAround(PFollowTarget->loc.p, followTarget + 0.5f, PATHFLAG_RUN)) { POwner->PAI->PathFind->FollowPath(m_Tick); } @@ -413,7 +407,7 @@ auto CTrustController::DoRoamTick(timer::time_point tick) -> Task else { // Path or step closer to follow target - if (currentDistance < RoamDistance * 3.0f && POwner->PAI->PathFind->PathAround(PFollowTarget->loc.p, followTarget, PATHFLAG_RUN | PATHFLAG_WALLHACK)) + if (currentDistance < RoamDistance * 3.0f && POwner->PAI->PathFind->PathAround(PFollowTarget->loc.p, followTarget, PATHFLAG_RUN)) { POwner->PAI->PathFind->FollowPath(m_Tick); } @@ -455,31 +449,25 @@ void CTrustController::Declump(CCharEntity* PMaster, CBattleEntity* PTarget) { TracyZoneScoped; - uint8 currentPartyPos = GetPartyPosition(); + const uint8 currentPartyPos = GetPartyPosition(); for (auto* POtherTrust : PMaster->PTrusts) { - if (POtherTrust != POwner && !POtherTrust->PAI->PathFind->IsFollowingPath() && distance(POtherTrust->loc.p, POwner->loc.p) < 1.5f) + if (POtherTrust == POwner || POtherTrust->PAI->PathFind->IsFollowingPath() || distance(POtherTrust->loc.p, POwner->loc.p) >= 1.5f) { - auto diffAngle = worldAngle(POwner->loc.p, PTarget->loc.p) + 64; - auto moveAmount = xirand::GetRandomNumber(0.0f, 1.5f) * ((currentPartyPos % 2) ? 1.0f : -1.0f); + continue; + } - // clang-format off - position_t newPos = - { - POwner->loc.p.x - (cosf(rotationToRadian(diffAngle)) * moveAmount), - PTarget->loc.p.y, - POwner->loc.p.z + (sinf(rotationToRadian(diffAngle)) * moveAmount), - 0, - 0, - }; - // clang-format on + // Sidestep relative to the shared target so the trust spreads out around the enemy rather + // than away from the other trust. Random magnitude + odd/even party-slot sign creates the + // varied "spread out" motion the player sees. + const float moveAmount = xirand::GetRandomNumber(0.0f, 1.5f) * ((currentPartyPos % 2) ? 1.0f : -1.0f); + const auto newPos = sidestepPosition(POwner->loc.p, PTarget->loc.p, moveAmount); - if (POwner->PAI->PathFind->ValidPosition(newPos)) - { - POwner->PAI->PathFind->PathTo(newPos, PATHFLAG_RUN | PATHFLAG_WALLHACK); - } - break; + if (POwner->PAI->PathFind->ValidPosition(newPos)) + { + POwner->PAI->PathFind->PathTo(newPos, PATHFLAG_RUN); } + break; } } @@ -537,11 +525,11 @@ void CTrustController::PathOutToDistance(CBattleEntity* PTarget, float amount) // Get somewhat close to the target destination if (distance(POwner->loc.p, target_position) > 2.0f && m_failedRepositionAttempts < 3) { - POwner->PAI->PathFind->PathTo(target_position, PATHFLAG_RUN | PATHFLAG_WALLHACK); + POwner->PAI->PathFind->PathTo(target_position, PATHFLAG_RUN); } else { - FaceTarget(PTarget->targid); + LookAtTarget(PTarget->targid); m_InTransit = false; } } @@ -575,7 +563,7 @@ bool CTrustController::RangedAttack(uint16 targid) if (m_Tick - m_LastRangedAttackTime > rangedDelay && !m_InTransit) { - FaceTarget(PTarget->targid); + LookAtTarget(PTarget->targid); if (POwner->PAI->CanChangeState() && POwner->PAI->Internal_RangedAttack(targid)) { m_LastRangedAttackTime = m_Tick; @@ -589,7 +577,7 @@ bool CTrustController::Cast(uint16 targid, SpellID spellid) { TracyZoneScoped; - FaceTarget(targid); + LookAtTarget(targid); if (static_cast(POwner)->PRecastContainer->Has(RECAST_MAGIC, static_cast(spellid))) { diff --git a/src/map/ai/helpers/CMakeLists.txt b/src/map/ai/helpers/CMakeLists.txt index 59a6ab2fc36..49800d5a1c5 100644 --- a/src/map/ai/helpers/CMakeLists.txt +++ b/src/map/ai/helpers/CMakeLists.txt @@ -1,3 +1,5 @@ +add_subdirectory(pathfind) + set(AI_HELPER_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/action_queue.cpp ${CMAKE_CURRENT_SOURCE_DIR}/action_queue.h @@ -5,8 +7,8 @@ set(AI_HELPER_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/event_handler.h ${CMAKE_CURRENT_SOURCE_DIR}/gambits_container.cpp ${CMAKE_CURRENT_SOURCE_DIR}/gambits_container.h - ${CMAKE_CURRENT_SOURCE_DIR}/pathfind.cpp ${CMAKE_CURRENT_SOURCE_DIR}/pathfind.h + ${PATHFIND_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/targetfind.cpp ${CMAKE_CURRENT_SOURCE_DIR}/targetfind.h PARENT_SCOPE diff --git a/src/map/ai/helpers/pathfind.cpp b/src/map/ai/helpers/pathfind.cpp deleted file mode 100644 index 0dce41a54f9..00000000000 --- a/src/map/ai/helpers/pathfind.cpp +++ /dev/null @@ -1,650 +0,0 @@ -/* -=========================================================================== - - Copyright (c) 2010-2015 Darkstar Dev Teams - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see http://www.gnu.org/licenses/ - -=========================================================================== -*/ - -#include "pathfind.h" - -#include "ai/ai_container.h" - -#include "common/utils.h" - -#include "entities/baseentity.h" -#include "entities/mobentity.h" - -#include "lua/luautils.h" - -#include "map/navmesh/navmesh.h" -#include "mob_modifier.h" -#include "status_effect_container.h" -#include "zone.h" - -namespace -{ - -bool arePositionsClose(const position_t& a, const position_t& b) -{ - return distance(a, b) < 1.0f; -} - -} // namespace - -CPathFind::CPathFind(CBaseEntity* PTarget) -: m_POwner(PTarget) -, m_distanceFromPoint(0.0f) -, m_pathFlags(0) -, m_patrolFlags(0) -, m_roamFlags(0) -, m_onPoint(false) -, m_currentPoint(0) -, m_currentTurn(0) -, m_distanceMoved(0.0f) -, m_maxDistance(0.0f) -, m_carefulPathing(false) -{ - m_originalPoint.x = 0.0f; - m_originalPoint.y = 0.0f; - m_originalPoint.z = 0.0f; - m_originalPoint.moving = 0; - m_originalPoint.rotation = 0; - - Clear(); -} - -CPathFind::~CPathFind() -{ - m_POwner = nullptr; - Clear(); -} - -bool CPathFind::RoamAround(const position_t& point, float maxRadius, uint8 maxTurns, uint16 roamFlags) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - Clear(); - - m_roamFlags = roamFlags; - - if (FindRandomPath(point, maxRadius, maxTurns, roamFlags)) - { - return true; - } - - Clear(); - return false; -} - -bool CPathFind::PathTo(const position_t& point, uint8 pathFlags, bool clear) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - // don't follow a new path if the current path has script flag and new path doesn't - if (IsFollowingPath() && (m_pathFlags & PATHFLAG_SCRIPT) && !(pathFlags & PATHFLAG_SCRIPT)) - { - return false; - } - - if (clear) - { - Clear(); - } - - m_pathFlags = pathFlags; - - bool result = false; - - if (m_pathFlags & PATHFLAG_WALLHACK) - { - result = FindClosestPath(m_POwner->loc.p, point); - } - else - { - result = FindPath(m_POwner->loc.p, point); - } - - if (!result) - { - Clear(); - } - - return result; -} - -bool CPathFind::PathInRange(const position_t& point, float range, uint8 pathFlags /*= 0*/, bool clear /*= true*/) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - if (clear) - { - Clear(); - } - - m_distanceFromPoint = range; - - bool result = PathTo(point, pathFlags, false); - - PrunePathWithin(range); - return result; -} - -bool CPathFind::PathAround(const position_t& point, float distanceFromPoint, uint8 pathFlags) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - Clear(); - - // save for sliding logic - m_originalPoint = point; - m_distanceFromPoint = distanceFromPoint; - - // Don't clear path so - // original point / distance are kept - return PathTo(point, pathFlags, false); -} - -bool CPathFind::PathThrough(std::vector&& points, uint8 pathFlags) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - Clear(); - - m_pathFlags = pathFlags; - - AddPoints(std::move(points), m_pathFlags & PATHFLAG_REVERSE); - - return true; -} - -bool CPathFind::WarpTo(const position_t& point, float maxDistance) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - Clear(); - - position_t newPoint = nearPosition(point, maxDistance, (float)M_PI); - - m_POwner->loc.p.x = newPoint.x; - m_POwner->loc.p.y = newPoint.y; - m_POwner->loc.p.z = newPoint.z; - m_POwner->loc.p.moving = 0; - - LookAt(point); - m_POwner->updatemask |= UPDATE_POS; - - return true; -} - -void CPathFind::ResumePatrol() -{ - if (m_patrolFlags & PATHFLAG_PATROL) - { - m_pathFlags = m_patrolFlags; - m_points = m_patrol; - m_currentPoint = 0; - float closestPoint = FLT_MAX; - for (size_t i = 0; i < m_points.size(); ++i) - { - const float distanceSq = distanceSquared(m_POwner->loc.p, m_points[i].position); - if (distanceSq < closestPoint) - { - m_currentPoint = (int16)i; - closestPoint = distanceSq; - } - } - } -} - -bool CPathFind::ValidPosition(const position_t& pos) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - return m_POwner->loc.zone->navMesh()->validPosition(pos); -} - -void CPathFind::LimitDistance(float maxLength) -{ - m_maxDistance = maxLength; -} - -void CPathFind::PrunePathWithin(float within) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - if (!IsFollowingPath()) - { - return; - } - - position_t targetPoint = m_points.back().position; - - while (m_points.size() > 1) - { - position_t secondLastPoint = m_points[m_points.size() - 2].position; - - if (distance(targetPoint, secondLastPoint) > within) - { - break; - } - m_points.erase(m_points.end() - 2); - } -} - -void CPathFind::FollowPath(timer::time_point tick) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - if (!IsFollowingPath()) - { - return; - } - - if (m_timeAtPoint != timer::time_point::min()) - { - // Continue to wait until full wait time has elapsed - if (tick >= m_timeAtPoint) - { - m_timeAtPoint = timer::time_point::min(); - ++m_currentPoint; - luautils::OnPathPoint(m_POwner); - if (m_currentPoint >= (int16)m_points.size()) - { - luautils::OnPathComplete(m_POwner); - FinishedPath(); - } - } - return; - } - - m_onPoint = false; - - pathpoint_t targetPoint = m_points[m_currentPoint]; - - if (m_carefulPathing) - { - m_POwner->loc.zone->navMesh()->snapToValidPosition(m_POwner->loc.p); - } - - if (m_maxDistance && m_distanceMoved >= m_maxDistance) - { - // if I have a max distance, check to stop me - Clear(); - m_onPoint = true; - return; - } - - // Iterate over points in the current path and find the first point - // that we haven't successfully arrived at already. - while (m_currentPoint < (int16)m_points.size()) - { - targetPoint = m_points[m_currentPoint]; - - if (AtPoint(targetPoint.position)) - { - m_onPoint = true; - if (targetPoint.setRotation) - { - m_POwner->loc.p.rotation = targetPoint.position.rotation; - m_POwner->updatemask |= UPDATE_POS; - } - if (targetPoint.wait != 0s && m_timeAtPoint == timer::time_point::min()) - { - m_timeAtPoint = tick + targetPoint.wait; - return; - } - - luautils::OnPathPoint(m_POwner); - m_currentPoint++; - } - else - { - break; - } - } - - StepTo(targetPoint.position, m_pathFlags & PATHFLAG_RUN); - - if (m_currentPoint >= (int16)m_points.size()) - { - luautils::OnPathComplete(m_POwner); - FinishedPath(); - m_onPoint = true; - } -} - -void CPathFind::StepTo(const position_t& pos, bool run) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - bool speedChange = m_POwner->GetSpeed() != m_POwner->UpdateSpeed(run); - float speed = m_POwner->GetSpeed(); - - if (const auto* PMobEntity = dynamic_cast(m_POwner)) - { - if (PMobEntity->GetSpeed() == 0 && (m_roamFlags & ROAMFLAG_WORM)) - { - speed = 20; - } - } - - float stepDistance = speed / (run ? 50 : 40); - float distanceTo = distance(m_POwner->loc.p, pos); - float diff_y = pos.y - m_POwner->loc.p.y; - - // face point mob is moving towards - LookAt(pos); - - if (distanceTo <= m_distanceFromPoint + stepDistance) - { - m_distanceMoved += distanceTo - m_distanceFromPoint; - - if (m_distanceFromPoint == 0) - { - m_POwner->loc.p.x = pos.x; - m_POwner->loc.p.y = pos.y; - m_POwner->loc.p.z = pos.z; - } - else - { - float radians = (1 - (float)m_POwner->loc.p.rotation / 256) * 2 * (float)M_PI; - - m_POwner->loc.p.x += cosf(radians) * (distanceTo - m_distanceFromPoint); - m_POwner->loc.p.z += sinf(radians) * (distanceTo - m_distanceFromPoint); - if (abs(diff_y) > .5f) - { - // Don't step too far vertically by simply utilizing the slope - float new_y = m_POwner->loc.p.y + stepDistance * (pos.y - m_POwner->loc.p.y) / distance(m_POwner->loc.p, pos, true); - float min_y = (pos.y + m_POwner->loc.p.y - abs(pos.y - m_POwner->loc.p.y)) / 2; - float max_y = (pos.y + m_POwner->loc.p.y + abs(pos.y - m_POwner->loc.p.y)) / 2; - // clamp new_y between start and end vertical position - new_y = new_y < min_y ? min_y : new_y; - m_POwner->loc.p.y = new_y > max_y ? max_y : new_y; - } - else - { - m_POwner->loc.p.y = pos.y; - } - } - } - else - { - m_distanceMoved += stepDistance; - // take a step towards target point - float radians = (1 - (float)m_POwner->loc.p.rotation / 256) * 2 * (float)M_PI; - - m_POwner->loc.p.x += cosf(radians) * stepDistance; - m_POwner->loc.p.z += sinf(radians) * stepDistance; - if (abs(diff_y) > .5f) - { - // Don't step too far vertically by simply utilizing the slope - float new_y = m_POwner->loc.p.y + stepDistance * (pos.y - m_POwner->loc.p.y) / distance(m_POwner->loc.p, pos, true); - float min_y = (pos.y + m_POwner->loc.p.y - abs(pos.y - m_POwner->loc.p.y)) / 2; - float max_y = (pos.y + m_POwner->loc.p.y + abs(pos.y - m_POwner->loc.p.y)) / 2; - // clamp new_y between start and end vertical position - new_y = new_y < min_y ? min_y : new_y; - m_POwner->loc.p.y = new_y > max_y ? max_y : new_y; - } - else - { - m_POwner->loc.p.y = pos.y; - } - } - - m_POwner->loc.p.moving += speedChange ? 0x28 : 0x35; - - m_POwner->loc.p.moving %= 0x2000; - - m_POwner->updatemask |= UPDATE_POS; -} - -bool CPathFind::FindPath(const position_t& start, const position_t& end) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - if (arePositionsClose(start, end)) - { - return false; - } - - m_points = m_POwner->loc.zone->navMesh()->findPath(start, end); - m_currentPoint = 0; - - if (m_points.empty()) - { - DebugNavmesh("CPathFind::FindPath Entity (%s - %d) could not find path", m_POwner->getName(), m_POwner->id); - return false; - } - - return true; -} - -bool CPathFind::FindRandomPath(const position_t& start, float maxRadius, uint8 maxTurns, uint16 roamFlags) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - auto m_turnLength = static_cast(xirand::GetRandomNumber(maxTurns) + 1); - - // Seemingly arbitrary value to pass for maxRadius, all values seem to give similar results, likely due to navmesh polygons being too dense? - float maxRadiusForPolyQuery = maxRadius / 10.0f; - position_t startPosition = start; - - // find end points for turns, iterate potentially twice as many times to account for erroneous turnPoints - for (int i = 0; i < m_turnLength * 2; i++) - { - // look for new turnPoint. findRandomPosition doesn't guarantee the new point is within the radius - auto status = m_POwner->loc.zone->navMesh()->findRandomPosition(startPosition, maxRadiusForPolyQuery); - - // couldn't find one point so just break out - if (status.first != 0) - { - return false; - } - - // only add the roam point if it's _actually_ within range of the spawn point... - if (isWithinDistance(startPosition, status.second, maxRadius, true)) - { - m_turnPoints.emplace_back(status.second); - } - // else - // { - // ShowDebug("CPathFind::FindRandomPath (%s - %d) random point too far: sq distance (%f)", m_POwner->GetName(), m_POwner->id, distSq); - // } - - if (m_turnPoints.size() >= m_turnLength) - { - break; - } - } - if (m_turnPoints.size() > 0) - { - m_points = m_POwner->loc.zone->navMesh()->findPath(start, m_turnPoints[0]); - m_currentPoint = 0; - } - - return !m_points.empty(); -} - -bool CPathFind::FindClosestPath(const position_t& start, const position_t& end) -{ - TracyZoneScoped; - TracyZoneString(m_POwner->getName()); - - if (arePositionsClose(start, end)) - { - return false; - } - - m_points = m_POwner->loc.zone->navMesh()->findPath(start, end); - m_currentPoint = 0; - m_points.emplace_back(pathpoint_t{ end, 0s, false }); // this prevents exploits with navmesh / impassible terrain - - /* this check requirement is never met as intended since m_points are never empty when mob has a path - if (m_points.empty()) - { - // this is a trick to make mobs go up / down impassible terrain - m_points.emplace_back(end); - } -*/ - - return true; -} - -void CPathFind::LookAt(const position_t& point) -{ - // Avoid unpredictable results if we're too close. - if (!isWithinDistance(m_POwner->loc.p, point, 0.1f, true)) - { - m_POwner->loc.p.rotation = worldAngle(m_POwner->loc.p, point); - m_POwner->updatemask |= UPDATE_POS; - } -} - -bool CPathFind::OnPoint() const -{ - return m_onPoint; -} - -bool CPathFind::IsFollowingPath() -{ - return !m_points.empty(); -} - -bool CPathFind::IsFollowingScriptedPath() -{ - return IsFollowingPath() && m_pathFlags & PATHFLAG_SCRIPT; -} - -bool CPathFind::IsPatrolling() -{ - return m_patrolFlags & PATHFLAG_PATROL; -} - -bool CPathFind::AtPoint(const position_t& pos) -{ - if (m_distanceFromPoint == 0) - { - return isWithinDistance(m_POwner->loc.p, pos, 0.1f); - } - else - { - return isWithinDistance(m_POwner->loc.p, pos, m_distanceFromPoint + 0.2f); - } -} - -bool CPathFind::InWater() -{ - const auto& pos = m_POwner->loc.p; - const auto terrain = m_POwner->loc.zone->xiMesh()->getTerrainAt(pos.x, pos.y, pos.z); - return terrain == TerrainType::ShallowWater || terrain == TerrainType::DeepWater; -} - -const position_t& CPathFind::GetDestination() const -{ - return m_points.back().position; -} - -void CPathFind::SetCarefulPathing(bool careful) -{ - m_carefulPathing = careful; -} - -void CPathFind::Clear() -{ - m_distanceFromPoint = 0; - m_pathFlags = 0; - m_roamFlags = 0; - - m_points.clear(); - - m_timeAtPoint = timer::time_point::min(); - - m_currentPoint = 0; - m_maxDistance = 0; - m_distanceMoved = 0; - - m_onPoint = true; - - m_currentTurn = 0; - m_turnPoints.clear(); -} - -void CPathFind::AddPoints(std::vector&& points, bool reverse) -{ - if (points.size() > MAX_PATH_POINTS && (m_pathFlags & PATHFLAG_PATROL) == 0) - { - ShowWarning("CPathFind::AddPoints Given too many points (%d). Limiting to max (%d)", points.size(), MAX_PATH_POINTS); - points.resize(MAX_PATH_POINTS); - } - - m_points = std::move(points); - - if (reverse) - { - std::reverse(m_points.begin(), m_points.end()); - } - - if (m_pathFlags & PATHFLAG_PATROL) - { - m_patrol = m_points; - m_patrolFlags = m_pathFlags; - } - else - { - m_patrol.clear(); - m_patrolFlags = 0; - } -} - -void CPathFind::FinishedPath() -{ - m_currentTurn++; - - if (m_currentTurn < m_turnPoints.size()) - { - // move on to next turn - position_t& nextTurn = m_turnPoints[m_currentTurn]; - - bool result = FindPath(m_POwner->loc.p, nextTurn); - - if (!result) - { - Clear(); - } - } - else if (IsPatrolling() && m_POwner->PAI->IsRoaming()) - { - m_currentPoint = 0; - m_currentTurn = 0; - } - else - { - Clear(); - } -} diff --git a/src/map/ai/helpers/pathfind.h b/src/map/ai/helpers/pathfind.h index f053d2c3c5c..dfce87c7f68 100644 --- a/src/map/ai/helpers/pathfind.h +++ b/src/map/ai/helpers/pathfind.h @@ -19,141 +19,7 @@ =========================================================================== */ -#ifndef _PATHFIND_H -#define _PATHFIND_H +#pragma once -#include "common/logging.h" -#include "common/mmo.h" -#include "common/timer.h" - -#include - -class CBaseEntity; - -// no path can be longer than this -#define MAX_PATH_POINTS 50 -#define MAX_TURN_POINTS 5 -#define VERTICAL_PATH_LIMIT 3.5 - -enum PATHFLAG -{ - PATHFLAG_NONE = 0x00, - PATHFLAG_RUN = 0x01, // run twice the speed - PATHFLAG_WALLHACK = 0x02, // run through walls if path is too long - PATHFLAG_REVERSE = 0x04, // reverse the path - PATHFLAG_SCRIPT = 0x08, // don't overwrite this path before completion (except via another script) - PATHFLAG_SLIDE = 0x10, // Slide to end point if close enough (so no over shoot) - PATHFLAG_PATROL = 0x20, // Automatically restart path once it is finished and resume when roaming - PATHFLAG_COORDS = 0x40, // Follows path until end, but will not repeat -}; - -class CPathFind -{ -public: - CPathFind(CBaseEntity* PTarget); - ~CPathFind(); - - // move to a random point around given point - bool RoamAround(const position_t& point, float maxRadius, uint8 maxTurns, uint16 roamFlags = 0); - - // find and walk to the given point - bool PathTo(const position_t& point, uint8 pathFlags = 0, bool clear = true); - // walk to the given point until in range - bool PathInRange(const position_t& point, float range, uint8 pathFlags = 0, bool clear = true); - - // move some where around the point - bool PathAround(const position_t& point, float distanceFromPoint, uint8 pathFlags = 0); - - // walk through the given points. No new points made. - bool PathThrough(std::vector&& points, uint8 pathFlags = 0); - - // instantly moves an entity to the point - // this will make sure you're not in a wall - bool WarpTo(const position_t& point, float maxDistance = 2.0f); - - void ResumePatrol(); - - // moves mob to next point - void FollowPath(timer::time_point tick); - - // returns true if entity is on a way point - bool OnPoint() const; - - // stops pathfinding after moving the given distance - // this can be used to prevent mobs from walking - // all the way to a point - void LimitDistance(float maxDistance); - - // Prunes the last points of a path, if they are within the given distance - void PrunePathWithin(float within); - - // tells entity to take one step towards position - void StepTo(const position_t& pos, bool run = false); - - // checks if mob is currently following a path - bool IsFollowingPath(); - bool IsFollowingScriptedPath(); - bool IsPatrolling(); - - // look at the given point - void LookAt(const position_t& point); - - // clear current path - void Clear(); - - bool ValidPosition(const position_t& pos); - - // checks if mob is at given point - bool AtPoint(const position_t& pos); - - // returns true if i'm in water - bool InWater(); - - // returns the final destination of the current path - const position_t& GetDestination() const; - - // If careful pathing is set, the owner will continually be "snapped" back - // onto a valid poly every time FollowPath() is called. - // THIS IS 4-5x MORE EXPENSIVE THAN A REGULAR CALL TO FollowPath()! - // YOU HAVE BEEN WARNED! - void SetCarefulPathing(bool careful); - -private: - // find a valid path using polys - bool FindPath(const position_t& start, const position_t& end); - - // cut some corners and find the fastest path - // this will make the mob run down cliffs - bool FindClosestPath(const position_t& start, const position_t& end); - - // finds a random path around the given point - bool FindRandomPath(const position_t& start, float maxRadius, uint8 maxTurns, uint16 roamFlags); - - void AddPoints(std::vector&& points, bool reverse = false); - - void FinishedPath(); - - CBaseEntity* m_POwner; - std::vector m_points; - std::vector m_patrol; - std::vector m_turnPoints; - position_t m_originalPoint; - float m_distanceFromPoint; - - uint8 m_pathFlags; - uint8 m_patrolFlags; - uint16 m_roamFlags; - bool m_onPoint; - int16 m_currentPoint; - - timer::time_point m_timeAtPoint; - - uint8 m_currentTurn; - - float m_distanceMoved; - float m_maxDistance; - - bool m_carefulPathing; -}; - -#endif +// Umbrella header for convenience +#include "pathfind/pathfind.h" diff --git a/src/map/ai/helpers/pathfind/CMakeLists.txt b/src/map/ai/helpers/pathfind/CMakeLists.txt new file mode 100644 index 00000000000..f6d7c6ff3ee --- /dev/null +++ b/src/map/ai/helpers/pathfind/CMakeLists.txt @@ -0,0 +1,18 @@ +set(PATHFIND_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/chunked_path.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/chunked_path.h + ${CMAKE_CURRENT_SOURCE_DIR}/entity_path_owner.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/entity_path_owner.h + ${CMAKE_CURRENT_SOURCE_DIR}/path.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/path.h + ${CMAKE_CURRENT_SOURCE_DIR}/path_builder.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/path_builder.h + ${CMAKE_CURRENT_SOURCE_DIR}/path_owner.h + ${CMAKE_CURRENT_SOURCE_DIR}/pathfind.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/pathfind.h + ${CMAKE_CURRENT_SOURCE_DIR}/pathfind_constants.h + ${CMAKE_CURRENT_SOURCE_DIR}/pathfind_step.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/pathfind_step.h + ${CMAKE_CURRENT_SOURCE_DIR}/pathfind_types.h + PARENT_SCOPE +) diff --git a/src/map/ai/helpers/pathfind/chunked_path.cpp b/src/map/ai/helpers/pathfind/chunked_path.cpp new file mode 100644 index 00000000000..f4729dc1687 --- /dev/null +++ b/src/map/ai/helpers/pathfind/chunked_path.cpp @@ -0,0 +1,101 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include + +#include + +namespace pathfind +{ + +auto ChunkedPath::clear() -> void +{ + active_ = false; + target_ = {}; + range_ = 0.0f; + lastDistance_ = 0.0f; + stallCount_ = 0; +} + +auto ChunkedPath::begin(const position_t& target, float range) -> void +{ + active_ = true; + target_ = target; + range_ = range; + lastDistance_ = 0.0f; + stallCount_ = 0; +} + +auto ChunkedPath::setRange(float range) -> void +{ + range_ = range; +} + +auto ChunkedPath::active() const -> bool +{ + return active_; +} + +auto ChunkedPath::range() const -> float +{ + return range_; +} + +auto ChunkedPath::target() const -> const position_t& +{ + return target_; +} + +auto ChunkedPath::evaluate(float distanceToTarget) -> Step +{ + if (!active_) + { + return Step::Inactive; + } + + // Effectively reached the destination. PathInRange uses range as the stop-short distance; plain + // PathTo has none, so a small slack covers the navmesh dropping us within a yalm of the end. + const float arrivalDistance = std::max(0.5f, range_); + if (distanceToTarget <= arrivalDistance) + { + return Step::Arrived; + } + + // Stall detection: if the previous chunk didn't move us closer, the navmesh is circling an + // obstacle we can't clear. Bail after a few consecutive non-progressing chunks. + if (lastDistance_ > 0.0f && distanceToTarget >= lastDistance_ - 0.5f) + { + ++stallCount_; + if (stallCount_ >= kMaxStalls) + { + return Step::Stalled; + } + } + else + { + stallCount_ = 0; + } + lastDistance_ = distanceToTarget; + + return Step::Continue; +} + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/chunked_path.h b/src/map/ai/helpers/pathfind/chunked_path.h new file mode 100644 index 00000000000..6b7d7f29e54 --- /dev/null +++ b/src/map/ai/helpers/pathfind/chunked_path.h @@ -0,0 +1,79 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include +#include + +namespace pathfind +{ + +// The navmesh poly buffer is finite, so findPath() to a far destination may return a partial path +// that stops short. ChunkedPath tracks the eventual destination across those partial chunks: +// CPathFind walks each chunk, then asks whether to request the next. It also detects chunks that +// make no progress (circling an obstacle) and aborts rather than looping forever. Pure controller - +// owns no waypoints and never touches the navmesh. +class ChunkedPath +{ +public: + enum class Step + { + Inactive, // no chunked sequence in flight + Arrived, // within arrival distance of the target - stop + Stalled, // too many consecutive non-progressing chunks - give up + Continue, // request another chunk toward the target + }; + + auto clear() -> void; + + // Begin a chunked sequence toward `target`. `range` is the stop-short distance (PathInRange), + // or 0 for a plain PathTo. + auto begin(const position_t& target, float range) -> void; + + // Override the stop-short range after begin() (PathInRange applies it post-PathToImpl). + auto setRange(float range) -> void; + + auto active() const -> bool; + auto range() const -> float; + auto target() const -> const position_t&; + + // Decide the next Step given the straight-line distance to the target. Updates stall + // bookkeeping; owns the arrival test (max(0.5, range)) and the consecutive-stall abort. + auto evaluate(float distanceToTarget) -> Step; + +private: + bool active_{ false }; + position_t target_{}; + float range_{ 0.0f }; + + // Straight-line distance to the target at the start of the previous chunk; used to detect + // chunks that make no progress. + float lastDistance_{ 0.0f }; + + // Consecutive chunks that didn't reduce distance to target. + uint8 stallCount_{ 0 }; + + // Abort after this many consecutive non-progressing chunks. + static constexpr uint8 kMaxStalls = 3; +}; + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/entity_path_owner.cpp b/src/map/ai/helpers/pathfind/entity_path_owner.cpp new file mode 100644 index 00000000000..6ba2d7b822e --- /dev/null +++ b/src/map/ai/helpers/pathfind/entity_path_owner.cpp @@ -0,0 +1,122 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include + +#include + +#include +#include +#include + +#include + +#include +#include + +namespace pathfind +{ + +EntityPathOwner::EntityPathOwner(CBaseEntity* entity) +: entity_(entity) +{ +} + +auto EntityPathOwner::position() -> position_t& +{ + return entity_->loc.p; +} + +auto EntityPathOwner::position() const -> const position_t& +{ + return entity_->loc.p; +} + +auto EntityPathOwner::navMesh() -> NavMesh& +{ + return *entity_->loc.zone->navMesh(); +} + +auto EntityPathOwner::markPositionDirty() -> void +{ + entity_->updatemask |= UPDATE_POS; +} + +auto EntityPathOwner::baseSpeed() const -> uint8 +{ + return entity_->GetSpeed(); +} + +auto EntityPathOwner::updateSpeed(bool run) -> uint8 +{ + return entity_->UpdateSpeed(run); +} + +auto EntityPathOwner::isMobEntity() const -> bool +{ + return dynamic_cast(entity_) != nullptr; +} + +auto EntityPathOwner::isRoaming() const -> bool +{ + return entity_->PAI->IsRoaming(); +} + +auto EntityPathOwner::inWater() const -> bool +{ + const auto& pos = entity_->loc.p; + const auto terrain = entity_->loc.zone->xiMesh()->getTerrainAt(pos.x, pos.y, pos.z); + return terrain == TerrainType::ShallowWater || terrain == TerrainType::DeepWater; +} + +auto EntityPathOwner::battleTargetPosition() const -> const position_t* +{ + if (auto* PBattleOwner = dynamic_cast(entity_)) + { + if (auto* PTarget = PBattleOwner->GetBattleTarget()) + { + return &PTarget->loc.p; + } + } + return nullptr; +} + +auto EntityPathOwner::onPathPoint() -> void +{ + luautils::OnPathPoint(entity_); +} + +auto EntityPathOwner::onPathComplete() -> void +{ + luautils::OnPathComplete(entity_); +} + +auto EntityPathOwner::name() const -> const std::string& +{ + return entity_->getName(); +} + +auto EntityPathOwner::id() const -> uint32 +{ + return entity_->id; +} + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/entity_path_owner.h b/src/map/ai/helpers/pathfind/entity_path_owner.h new file mode 100644 index 00000000000..b67a74f12f2 --- /dev/null +++ b/src/map/ai/helpers/pathfind/entity_path_owner.h @@ -0,0 +1,59 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include + +class CBaseEntity; + +namespace pathfind +{ + +class EntityPathOwner final : public PathOwner +{ +public: + explicit EntityPathOwner(CBaseEntity* entity); + + auto position() -> position_t& override; + auto position() const -> const position_t& override; + auto navMesh() -> NavMesh& override; + auto markPositionDirty() -> void override; + + auto baseSpeed() const -> uint8 override; + auto updateSpeed(bool run) -> uint8 override; + auto isMobEntity() const -> bool override; + auto isRoaming() const -> bool override; + auto inWater() const -> bool override; + + auto battleTargetPosition() const -> const position_t* override; + + auto onPathPoint() -> void override; + auto onPathComplete() -> void override; + + auto name() const -> const std::string& override; + auto id() const -> uint32 override; + +private: + CBaseEntity* entity_; +}; + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/path.cpp b/src/map/ai/helpers/pathfind/path.cpp new file mode 100644 index 00000000000..bb58c745c95 --- /dev/null +++ b/src/map/ai/helpers/pathfind/path.cpp @@ -0,0 +1,172 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include + +#include + +#include + +namespace pathfind +{ + +auto Path::clear() -> void +{ + points_.clear(); + currentPoint_ = 0; + isPartial_ = false; +} + +auto Path::empty() const -> bool +{ + return points_.empty(); +} + +auto Path::partial() const -> bool +{ + return isPartial_; +} + +auto Path::setPartial(bool partial) -> void +{ + isPartial_ = partial; +} + +auto Path::assign(std::vector points, bool partial) -> void +{ + points_ = std::move(points); + currentPoint_ = 0; + isPartial_ = partial; +} + +auto Path::points() const -> const std::vector& +{ + return points_; +} + +auto Path::append(const pathpoint_t& point) -> void +{ + points_.emplace_back(point); +} + +auto Path::size() const -> int16 +{ + return static_cast(points_.size()); +} + +auto Path::cursor() const -> int16 +{ + return currentPoint_; +} + +auto Path::advance() -> void +{ + ++currentPoint_; +} + +auto Path::restart() -> void +{ + currentPoint_ = 0; +} + +auto Path::consumed() const -> bool +{ + return currentPoint_ >= size(); +} + +auto Path::atLastIndex() const -> bool +{ + return currentPoint_ == size() - 1; +} + +auto Path::atOrPastLastIndex() const -> bool +{ + return currentPoint_ >= size() - 1; +} + +auto Path::current() const -> const pathpoint_t& +{ + return points_[currentPoint_]; +} + +auto Path::destination() const -> const position_t& +{ + return points_.back().position; +} + +auto Path::pruneTailWithin(float within) -> void +{ + if (points_.empty()) + { + return; + } + + // Drop intermediate waypoints within `within` of the destination (after PathInRange) so the + // entity stops short instead of walking the last few yalms onto the destination tile. + const position_t& destinationPos = points_.back().position; + while (points_.size() > 1) + { + const position_t& penultimate = points_[points_.size() - 2].position; + if (distance(destinationPos, penultimate) > within) + { + break; + } + points_.erase(points_.end() - 2); + } +} + +auto Path::isWithinRatio(const position_t& from, float ratio) const -> bool +{ + if (points_.empty() || currentPoint_ < 0 || currentPoint_ >= size()) + { + return false; + } + + const float directDistance = distance(from, points_.back().position); + if (directDistance < 0.01f) + { + return true; + } + + float pathLength = distance(from, points_[currentPoint_].position); + for (size_t i = static_cast(currentPoint_) + 1; i < points_.size(); ++i) + { + pathLength += distance(points_[i - 1].position, points_[i].position); + } + + return pathLength <= directDistance * ratio; +} + +auto Path::setCursorToNearest(const position_t& from) -> void +{ + float closestSq = std::numeric_limits::max(); + for (size_t i = 0; i < points_.size(); ++i) + { + const float distanceSq = distanceSquared(from, points_[i].position); + if (distanceSq < closestSq) + { + currentPoint_ = static_cast(i); + closestSq = distanceSq; + } + } +} + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/path.h b/src/map/ai/helpers/pathfind/path.h new file mode 100644 index 00000000000..984238ab9ab --- /dev/null +++ b/src/map/ai/helpers/pathfind/path.h @@ -0,0 +1,92 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include +#include + +#include + +namespace pathfind +{ + +// A waypoint sequence plus a cursor for progress, with pure geometry over them (tail pruning, +// directness). Owns no entity/navmesh state; CPathFind steps its owner toward current() until +// the cursor is consumed(). +class Path +{ +public: + auto clear() -> void; + auto empty() const -> bool; + + // Partial: stopped short (navmesh poly buffer ran out); chunked sequence requests the next leg. + // Complete: reaches its destination. + auto partial() const -> bool; + auto setPartial(bool partial) -> void; + + // Replace waypoints and reset the cursor to the first point. + auto assign(std::vector points, bool partial = false) -> void; + + // Underlying waypoints (e.g. to snapshot a patrol route). + auto points() const -> const std::vector&; + + // Append one waypoint (e.g. end-gap top-up after a complete build). + auto append(const pathpoint_t& point) -> void; + + // + // Cursor + // + + auto cursor() const -> int16; + auto advance() -> void; + auto restart() -> void; // reset cursor to first point (e.g. looping a patrol) + auto consumed() const -> bool; // cursor past the last point (whole path walked) + auto atLastIndex() const -> bool; // cursor exactly on the final point + auto atOrPastLastIndex() const -> bool; // cursor on or past the final point + auto current() const -> const pathpoint_t&; + auto destination() const -> const position_t&; // final waypoint's position + + // + // Geometry + // + + // Drop trailing waypoints within `within` of the destination so a stop-short entity halts before + // the exact destination tile. Keeps at least one point. + auto pruneTailWithin(float within) -> void; + + // True when the remaining path (from `from`, through points at/after the cursor) is no longer + // than `ratio` x the straight-line distance from `from` to the final point. False if empty or + // the cursor is out of range. + auto isWithinRatio(const position_t& from, float ratio) const -> bool; + + // Set cursor to the waypoint physically closest to `from` (patrol resume). + auto setCursorToNearest(const position_t& from) -> void; + +private: + auto size() const -> int16; + + std::vector points_; + int16 currentPoint_{ 0 }; + bool isPartial_{ false }; +}; + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/path_builder.cpp b/src/map/ai/helpers/pathfind/path_builder.cpp new file mode 100644 index 00000000000..cab91ba9a01 --- /dev/null +++ b/src/map/ai/helpers/pathfind/path_builder.cpp @@ -0,0 +1,129 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include + +#include +#include + +namespace pathfind +{ + +NavPathBuilder::NavPathBuilder(NavMesh& navMesh) +: navMesh_(navMesh) +{ +} + +auto NavPathBuilder::findPath(const position_t& start, const position_t& end) const -> Maybe +{ + auto result = navMesh_.findPath(start, end); + + // findPath uses a 2.5f XZ poly search radius per endpoint. Two fallbacks handle endpoints + // outside that radius (> 2.5f XZ from any poly): + // + // End off-mesh (e.g. player on a rock/ledge): path to the nearest on-mesh point to the target, + // found via the wide 30f radius. + // + // Start off-mesh (e.g. mob placed there by the stuck-repath teleport): build from the nearest + // on-mesh point to the mob. The mob steps toward that first waypoint and slides back onto the + // mesh within a tick or two - no hard snap, no position modification. + if (!result) + { + if (const auto closestEnd = navMesh_.findClosestValidPoint(end)) + { + if (!isNear(start, *closestEnd)) + { + result = navMesh_.findPath(start, *closestEnd); + } + } + } + + if (!result) + { + if (const auto closestStart = navMesh_.findClosestValidPoint(start)) + { + if (!isNear(*closestStart, end)) + { + result = navMesh_.findPath(*closestStart, end); + } + } + } + + if (!result) + { + return std::nullopt; + } + + // For complete (non-chunked) paths, close any gap between the last waypoint and the destination. + // findClosestValidPoint's wider 30f XZ search may beat the endpoint findPath's 2.5f-radius result. + if (!result->isPartial && !result->points.empty()) + { + if (const auto closestEnd = navMesh_.findClosestValidPoint(end)) + { + const float lastWaypointGap = distance(result->points.back().position, end); + const float closestGap = distance(*closestEnd, end); + if (closestGap < lastWaypointGap - 0.1f) + { + result->points.emplace_back(pathpoint_t{ *closestEnd, 0s, false }); + } + } + } + + return result; +} + +auto NavPathBuilder::findRoamTurnPoints(const position_t& start, float maxRadius, uint8 maxTurns) const -> Maybe> +{ + const auto desiredTurnCount = static_cast(xirand::GetRandomNumber(maxTurns) + 1); + + // Seemingly arbitrary divisor: most maxRadius inputs give similar results, likely because + // navmesh polys are dense enough that the poly query saturates quickly anyway. + const float maxRadiusForPolyQuery = maxRadius / 10.0f; + + std::vector turnPoints; + + // Pick `desiredTurnCount` random navmesh destinations. Allow up to 2x attempts since + // findRandomPosition may return a poly outside `maxRadius`. + const int maxAttempts = desiredTurnCount * 2; + for (int i = 0; i < maxAttempts; ++i) + { + const auto candidate = navMesh_.findRandomPosition(start, maxRadiusForPolyQuery); + if (!candidate) + { + // Hard navmesh failure - bail rather than return a partial turn list. + return std::nullopt; + } + + if (isWithinDistance(start, *candidate, maxRadius, true)) + { + turnPoints.emplace_back(*candidate); + } + + if (turnPoints.size() >= desiredTurnCount) + { + break; + } + } + + return turnPoints; +} + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/path_builder.h b/src/map/ai/helpers/pathfind/path_builder.h new file mode 100644 index 00000000000..e78e8786372 --- /dev/null +++ b/src/map/ai/helpers/pathfind/path_builder.h @@ -0,0 +1,56 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include +#include + +#include + +#include +#include + +namespace pathfind +{ + +// Builds paths from a navmesh; owns none of CPathFind's state. Holds a mesh reference, so construct +// on demand (the owner's zone navmesh can change). +class NavPathBuilder +{ +public: + explicit NavPathBuilder(NavMesh& navMesh); + + // Path from `start` to `end`. Direct query first, then falls back to the nearest on-mesh point + // to the end (target off-mesh) and to the start (mob off-mesh), then closes any remaining end-gap + // for complete paths. std::nullopt when no path can be built. + auto findPath(const position_t& start, const position_t& end) const -> Maybe; + + // Pick a random number (1..maxTurns) of roam destinations within `maxRadius` of `start`. + // std::nullopt on hard navmesh failure (caller bails rather than roam a partial set); otherwise + // the chosen turn points (possibly fewer than requested). + auto findRoamTurnPoints(const position_t& start, float maxRadius, uint8 maxTurns) const -> Maybe>; + +private: + NavMesh& navMesh_; +}; + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/path_owner.h b/src/map/ai/helpers/pathfind/path_owner.h new file mode 100644 index 00000000000..08cd9e2950a --- /dev/null +++ b/src/map/ai/helpers/pathfind/path_owner.h @@ -0,0 +1,64 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include +#include + +#include + +class NavMesh; + +namespace pathfind +{ + +// Everything CPathFind needs from "the thing being moved", behind one interface so path logic runs +// without a live entity/zone/AI/Lua. This will be useful for setting up tests later. +// +// See EntityPathOwner. +class PathOwner +{ +public: + virtual ~PathOwner() = default; + + virtual auto position() -> position_t& = 0; // loc.p (read/write: x/y/z/rotation/moving) + virtual auto position() const -> const position_t& = 0; // read-only overload for const queries + virtual auto navMesh() -> NavMesh& = 0; // loc.zone navmesh + virtual auto markPositionDirty() -> void = 0; // updatemask |= UPDATE_POS + + virtual auto baseSpeed() const -> uint8 = 0; // GetSpeed() + virtual auto updateSpeed(bool run) -> uint8 = 0; // UpdateSpeed(run) + virtual auto isMobEntity() const -> bool = 0; // is this a CMobEntity? + virtual auto isRoaming() const -> bool = 0; // PAI->IsRoaming() + virtual auto inWater() const -> bool = 0; + + // Position of the current battle target, or nullptr if there is none (used when paused). + virtual auto battleTargetPosition() const -> const position_t* = 0; + + virtual auto onPathPoint() -> void = 0; // luautils::OnPathPoint + virtual auto onPathComplete() -> void = 0; // luautils::OnPathComplete + + virtual auto name() const -> const std::string& = 0; // diagnostics (Tracy / logging) + virtual auto id() const -> uint32 = 0; // diagnostics +}; + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/pathfind.cpp b/src/map/ai/helpers/pathfind/pathfind.cpp new file mode 100644 index 00000000000..608e6dfbc96 --- /dev/null +++ b/src/map/ai/helpers/pathfind/pathfind.cpp @@ -0,0 +1,639 @@ +/* +=========================================================================== + + Copyright (c) 2010-2015 Darkstar Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +// CPathFind orchestrates the pieces that own the work: a PathOwner (the moved entity, behind an +// interface), a Path (waypoints + cursor + geometry), a ChunkedPath (re-chunk controller), a +// NavPathBuilder (navmesh queries), and pathfind::stepTowards (kinematics). Holds the public request +// API, the per-tick FollowPath loop, and the FinishedPath continuation. + +#include + +#include +#include +#include +#include + +#include +#include + +#include // ROAMFLAG_WORM + +#include +#include + +CPathFind::CPathFind(CBaseEntity* PTarget) +: CPathFind(std::make_unique(PTarget)) +{ +} + +CPathFind::CPathFind(std::unique_ptr owner) +: owner_(std::move(owner)) +, distanceFromPoint_(0.0f) +, pathFlags_(0) +, patrolFlags_(0) +, roamFlags_(0) +, onPoint_(false) +, currentTurn_(0) +, distanceMoved_(0.0f) +, maxDistance_(0.0f) +{ + Clear(); +} + +CPathFind::~CPathFind() +{ + Clear(); +} + +auto CPathFind::navMesh() const -> NavMesh& +{ + return owner_->navMesh(); +} + +auto CPathFind::PathToImpl(const position_t& point, uint8 pathFlags) -> bool +{ + pathFlags_ = pathFlags; + + // Single-destination PathTo (incl. scripted) always chunks: the navmesh poly buffer can't span a + // far destination in one query, so without chunking a long path would walk only the first leg and + // stop. (Multi-point authored routes go through AddPoints/PathThrough, not here.) + chunked_.begin(point, 0.0f); + chunkCount_ = 1; + + const bool found = FindPathInternal(owner_->position(), point); + if (!found) + { + Clear(); + } + return found; +} + +auto CPathFind::RoamAround(const position_t& point, float maxRadius, uint8 maxTurns, uint16 roamFlags) -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + Clear(); + + roamFlags_ = roamFlags; + + if (FindRandomPath(point, maxRadius, maxTurns, roamFlags)) + { + return true; + } + + Clear(); + return false; +} + +auto CPathFind::PathTo(const position_t& point, uint8 pathFlags) -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + // Don't overwrite an active scripted path with a non-scripted one - scripts must finish or be + // replaced by another script. + if (IsFollowingPath() && (pathFlags_ & PATHFLAG_SCRIPT) && !(pathFlags & PATHFLAG_SCRIPT)) + { + return false; + } + + Clear(); + return PathToImpl(point, pathFlags); +} + +auto CPathFind::PathInRange(const position_t& point, float range, uint8 pathFlags) -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + Clear(); + distanceFromPoint_ = range; + + // PathToImpl starts the chunked sequence; override the chunk range so re-chunks stop short. + const bool found = PathToImpl(point, pathFlags); + chunked_.setRange(range); + + path_.pruneTailWithin(range); + return found; +} + +auto CPathFind::PathAround(const position_t& point, float distanceFromPoint, uint8 pathFlags) -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + Clear(); + distanceFromPoint_ = distanceFromPoint; + return PathToImpl(point, pathFlags); +} + +auto CPathFind::PathThrough(std::vector&& points, uint8 pathFlags) -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + Clear(); + + pathFlags_ = pathFlags; + + AddPoints(std::move(points), pathFlags_ & PATHFLAG_REVERSE); + + return true; +} + +auto CPathFind::WarpTo(const position_t& point, float maxDistance) -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + Clear(); + + const auto newPoint = nearPosition(point, maxDistance, static_cast(M_PI)); + position_t& ownerPos = owner_->position(); + + ownerPos.x = newPoint.x; + ownerPos.y = newPoint.y; + ownerPos.z = newPoint.z; + ownerPos.moving = 0; + + LookAt(point); + owner_->markPositionDirty(); + + return true; +} + +auto CPathFind::ResumePatrol() -> void +{ + if (!(patrolFlags_ & PATHFLAG_PATROL)) + { + return; + } + + pathFlags_ = patrolFlags_; + path_.assign(patrol_); + + // Resume from the patrol waypoint we're currently closest to. + path_.setCursorToNearest(owner_->position()); +} + +auto CPathFind::ValidPosition(const position_t& pos) const -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + return navMesh().validPosition(pos); +} + +auto CPathFind::LimitDistance(float maxLength) -> void +{ + maxDistance_ = maxLength; +} + +auto CPathFind::FollowPath(timer::time_point tick) -> void +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + if (!IsFollowingPath()) + { + return; + } + + // Paused (e.g. during a TP move / animation lock). Hold position, keep facing the battle target + // if any, and bail until the pause elapses. + if (tick < unpauseTime_) + { + if (const position_t* targetPos = owner_->battleTargetPosition()) + { + LookAt(*targetPos); + } + return; + } + + // Sitting at a waypoint waiting out its `wait` duration. Once it elapses, advance to the next + // waypoint (and complete the path if that was the last one). + const bool waitingAtPoint = timeAtPoint_ != timer::time_point::min(); + if (waitingAtPoint) + { + if (tick < timeAtPoint_) + { + return; + } + + timeAtPoint_ = timer::time_point::min(); + path_.advance(); + owner_->onPathPoint(); + if (path_.consumed()) + { + // FinishedPath fires onPathComplete, but only at the true end of the journey (not on a + // re-chunk / roam turn / patrol loop). + FinishedPath(); + } + return; + } + + onPoint_ = false; + + // LimitDistance() caps total walked distance for this path; once exceeded, treat it as complete + // in-place. + if (maxDistance_ && distanceMoved_ >= maxDistance_) + { + Clear(); + onPoint_ = true; + return; + } + + // Walk forward through waypoints we've already arrived at, handling rotation snaps and `wait` + // pauses. Stops at the first waypoint we still need to step toward. + pathpoint_t targetPoint{}; + while (!path_.consumed()) + { + targetPoint = path_.current(); + + // Only the final waypoint honors the stop-short distance (distanceFromPoint_). Corner + // waypoints must be reached precisely; otherwise we "arrive" up to distanceFromPoint_ early + // and cut the corner, clipping the wall the navmesh corner was insetting us away from. + const bool isFinalPoint = path_.atLastIndex(); + if (!AtPoint(targetPoint.position, isFinalPoint)) + { + break; + } + + onPoint_ = true; + + if (targetPoint.setRotation) + { + owner_->position().rotation = targetPoint.position.rotation; + owner_->markPositionDirty(); + } + + if (targetPoint.wait != 0s) + { + // Stop here until the wait elapses; the next FollowPath() resumes. + timeAtPoint_ = tick + targetPoint.wait; + return; + } + + owner_->onPathPoint(); + path_.advance(); + } + + // Stop short only for the last waypoint; round corners precisely (stopShort = 0) so we don't + // cut across them. + const bool steppingToFinal = path_.atOrPastLastIndex(); + const float stopShort = steppingToFinal ? distanceFromPoint_ : 0.0f; + StepToInternal(targetPoint.position, pathFlags_ & PATHFLAG_RUN, stopShort); + + if (path_.consumed()) + { + // FinishedPath fires onPathComplete, only at the true end (not on a chunk/turn/patrol + // continuation). + FinishedPath(); + onPoint_ = true; + } +} + +auto CPathFind::StepTo(const position_t& pos, bool run) -> void +{ + // External callers (e.g. trusts stepping straight at a target) honor the configured stop-short + // distance, which is 0 unless a range was set. + StepToInternal(pos, run, distanceFromPoint_); +} + +auto CPathFind::StepToInternal(const position_t& pos, bool run, float stopShort) -> void +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + const bool speedChange = owner_->baseSpeed() != owner_->updateSpeed(run); + + // Worms underground get a synthetic speed (their normal speed is 0). + const float speed = [&]() -> float + { + const auto baseSpeed = owner_->baseSpeed(); + if (owner_->isMobEntity() && baseSpeed == 0 && (roamFlags_ & ROAMFLAG_WORM)) + { + return 20.0f; + } + return static_cast(baseSpeed); + }(); + + const float stepDistance = speed / (run ? 50.0f : 40.0f); + + // Free-step toward `pos`. Kinematics live in pathfind_step so tests can exercise the exact math; + // stepTowards() also faces the owner toward `pos`. + position_t& ownerPos = owner_->position(); + distanceMoved_ += pathfind::stepTowards(ownerPos, pos, stepDistance, stopShort); + + ownerPos.moving += speedChange ? 0x28 : 0x35; + ownerPos.moving %= 0x2000; + owner_->markPositionDirty(); +} + +auto CPathFind::FindPathInternal(const position_t& start, const position_t& end) -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + // Already co-located on the navmesh grid - a query would return a trivial/empty path. + if (isNear(start, end)) + { + path_.setPartial(false); + return false; + } + + const pathfind::NavPathBuilder builder{ navMesh() }; + auto built = builder.findPath(start, end); + + if (!built) + { + DebugNavmesh("CPathFind::FindPathInternal Entity (%s - %d) could not find path", owner_->name(), owner_->id()); + path_.clear(); + + return false; + } + + path_.assign(std::move(built->points), built->isPartial); + return !path_.empty(); +} + +auto CPathFind::BuildDirectPath(const position_t& end) -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + // Single-waypoint "path" straight to the destination, ignoring the navmesh (may clip through + // geometry). Opt-in wallhacking fallback; see the commented call in FindPathInternal. + path_.assign({ pathpoint_t{ end, 0s, false } }, false); + return true; +} + +auto CPathFind::FindRandomPath(const position_t& start, float maxRadius, uint8 maxTurns, uint16 roamFlags) -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + const pathfind::NavPathBuilder builder{ navMesh() }; + + auto turnPoints = builder.findRoamTurnPoints(start, maxRadius, maxTurns); + if (!turnPoints) + { + // Hard navmesh failure - bail rather than partially populate the turn list. + return false; + } + turnPoints_ = std::move(*turnPoints); + + // Path to the first turn; later turns chain in FinishedPath(). Random roams don't use chunked + // pathing, and partial-path detection is irrelevant since there's no specific destination. + if (!turnPoints_.empty()) + { + if (auto result = navMesh().findPath(start, turnPoints_[0])) + { + path_.assign(std::move(result->points), false); + } + } + + return !path_.empty(); +} + +auto CPathFind::LookAt(const position_t& point) -> void +{ + position_t& ownerPos = owner_->position(); + + // Avoid unpredictable rotation when too close. + if (!isWithinDistance(ownerPos, point, 0.1f, true)) + { + ownerPos.rotation = worldAngle(ownerPos, point); + owner_->markPositionDirty(); + } +} + +auto CPathFind::OnPoint() const -> bool +{ + return onPoint_; +} + +auto CPathFind::IsFollowingPath() const -> bool +{ + return !path_.empty(); +} + +auto CPathFind::IsFollowingScriptedPath() const -> bool +{ + return IsFollowingPath() && (pathFlags_ & PATHFLAG_SCRIPT); +} + +auto CPathFind::IsPatrolling() const -> bool +{ + return (patrolFlags_ & PATHFLAG_PATROL) != 0; +} + +auto CPathFind::AtPoint(const position_t& pos, bool isFinalPoint) const -> bool +{ + // Stop-short applies only to the final waypoint. Corner waypoints are reached precisely (0.1y) + // so we don't arrive early and cut the corner. Final point: 0.1y for an exact target, else the + // requested stop-short plus a small slack to avoid oscillating at the boundary. + const float threshold = (isFinalPoint && distanceFromPoint_ != 0) ? distanceFromPoint_ + 0.2f : 0.1f; + return isWithinDistance(owner_->position(), pos, threshold); +} + +auto CPathFind::InWater() const -> bool +{ + return owner_->inWater(); +} + +auto CPathFind::GetDestination() const -> const position_t& +{ + // For chunked paths the Path's last point is only the current chunk's end, not the real + // destination. Return the chunked target so callers comparing GetDestination() against the + // eventual goal (e.g. mob_controller's needNewPath drift check) are correct for both chunked + // and single-shot paths. + if (chunked_.active()) + { + return chunked_.target(); + } + return path_.destination(); +} + +auto CPathFind::IsPathDirect(float ratio) const -> bool +{ + // Partial (chunked) paths reach only the current chunk's end, never the eventual destination. + if (path_.partial()) + { + return false; + } + return path_.isWithinRatio(owner_->position(), ratio); +} + +auto CPathFind::ChunkCount() const -> int +{ + // Navmesh legs taken by the current PathTo journey (1 = single query, >1 = chunked). + // Diagnostic - handy in-game and used by the pathfind tests. + return chunkCount_; +} + +auto CPathFind::Clear() -> void +{ + distanceFromPoint_ = 0; + pathFlags_ = 0; + roamFlags_ = 0; + + path_.clear(); + + timeAtPoint_ = timer::time_point::min(); + unpauseTime_ = timer::time_point::min(); + + maxDistance_ = 0; + distanceMoved_ = 0; + + onPoint_ = true; + + currentTurn_ = 0; + turnPoints_.clear(); + + // Drop any in-flight chunked sequence; the next PathTo / PathInRange starts fresh. + chunked_.clear(); + chunkCount_ = 0; +} + +auto CPathFind::Pause(timer::duration pauseDuration) -> void +{ + // Zero pauses indefinitely (until Unpause()); otherwise pause until now + duration. + if (pauseDuration == timer::duration::zero()) + { + unpauseTime_ = timer::time_point::max(); + } + else + { + unpauseTime_ = timer::now() + pauseDuration; + } +} + +auto CPathFind::Unpause() -> void +{ + unpauseTime_ = timer::time_point::min(); +} + +auto CPathFind::AddPoints(std::vector&& points, bool reverse) -> void +{ + // Patrol paths may legitimately exceed kMaxPathPoints (long scripted routes); cap any other + // caller so a runaway path doesn't pin the AI on one mob for minutes. + const bool isPatrol = (pathFlags_ & PATHFLAG_PATROL) != 0; + if (points.size() > kMaxPathPoints && !isPatrol) + { + ShowWarning("CPathFind::AddPoints Given too many points (%d). Limiting to max (%d)", points.size(), kMaxPathPoints); + points.resize(kMaxPathPoints); + } + + if (reverse) + { + std::reverse(points.begin(), points.end()); + } + + path_.assign(std::move(points)); + + if (isPatrol) + { + patrol_ = path_.points(); + patrolFlags_ = pathFlags_; + } + else + { + patrol_.clear(); + patrolFlags_ = 0; + } +} + +auto CPathFind::FinishedPath() -> void +{ + // The chunk just consumed was partial - silently request the next chunk toward the eventual + // destination. NOT a completion, so it must NOT fire onPathComplete; checked first, before the + // hook below, so chunk boundaries stay invisible to scripts. turnPoints_ is empty for chunked + // PathTo, so this can't race the roam-turn logic. + if (chunked_.active() && path_.partial() && TryRequestNextChunk()) + { + return; + } + + // A genuine completion: single-shot arrival, roam turn, or patrol lap. Fire the script hook + // FIRST - its handler may set up the next path (e.g. a patrol NPC re-pathing on completion), + // and the continuation logic below must see that updated state. + owner_->onPathComplete(); + + ++currentTurn_; + + // Random-roam paths chain through turnPoints_ - set up the next leg if there is one. + if (currentTurn_ < turnPoints_.size()) + { + const position_t& nextTurn = turnPoints_[currentTurn_]; + if (!FindPathInternal(owner_->position(), nextTurn)) + { + Clear(); + } + return; + } + + // Patrol paths loop forever while still roaming. + if (IsPatrolling() && owner_->isRoaming()) + { + path_.restart(); + currentTurn_ = 0; + return; + } + + Clear(); +} + +auto CPathFind::TryRequestNextChunk() -> bool +{ + TracyZoneScoped; + TracyZoneString(owner_->name()); + + const float distanceToTarget = distance(owner_->position(), chunked_.target()); + + switch (chunked_.evaluate(distanceToTarget)) + { + case pathfind::ChunkedPath::Step::Continue: + break; + case pathfind::ChunkedPath::Step::Stalled: + DebugNavmesh("CPathFind::TryRequestNextChunk Entity (%s - %d) chunked path stalled, aborting", owner_->name(), owner_->id()); + return false; + default: // Inactive / Arrived + return false; + } + + if (!FindPathInternal(owner_->position(), chunked_.target())) + { + return false; + } + + ++chunkCount_; + + // Re-apply PathInRange's range pruning so chunked callers still stop short of the destination. + if (chunked_.range() > 0.0f) + { + distanceFromPoint_ = chunked_.range(); + path_.pruneTailWithin(chunked_.range()); + } + + return true; +} diff --git a/src/map/ai/helpers/pathfind/pathfind.h b/src/map/ai/helpers/pathfind/pathfind.h new file mode 100644 index 00000000000..db9d10a5c91 --- /dev/null +++ b/src/map/ai/helpers/pathfind/pathfind.h @@ -0,0 +1,185 @@ +/* +=========================================================================== + + Copyright (c) 2010-2015 Darkstar Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include +#include + +class CBaseEntity; +class NavMesh; + +namespace pathfind +{ +class PathOwner; +} + +class CPathFind +{ +public: + // Production: wraps the entity in an owned EntityPathOwner. + explicit CPathFind(CBaseEntity* PTarget); + + // General/tests: take ownership of an injected PathOwner (e.g. TestPathOwner). + explicit CPathFind(std::unique_ptr owner); + + ~CPathFind(); + + // Walk to a random point around the given point. + auto RoamAround(const position_t& point, float maxRadius, uint8 maxTurns, uint16 roamFlags = 0) -> bool; + + // Find and walk to the given point. + auto PathTo(const position_t& point, uint8 pathFlags = 0) -> bool; + + // Walk toward the point until within range. + auto PathInRange(const position_t& point, float range, uint8 pathFlags = 0) -> bool; + + // Walk to somewhere around the point. + auto PathAround(const position_t& point, float distanceFromPoint, uint8 pathFlags = 0) -> bool; + + // Walk through the given points; no new points are generated. + auto PathThrough(std::vector&& points, uint8 pathFlags = 0) -> bool; + + // Instantly move the entity to `point`, offset by `maxDistance` (default 2y). Skips the navmesh - + // caller must ensure the destination is valid. + auto WarpTo(const position_t& point, float maxDistance = 2.0f) -> bool; + + auto ResumePatrol() -> void; + + // Move the mob toward the next point. + auto FollowPath(timer::time_point tick) -> void; + + // True if the entity is on a waypoint. + auto OnPoint() const -> bool; + + // Stop pathfinding after this much distance has been walked. + auto LimitDistance(float maxDistance) -> void; + + // Take one step toward position. + auto StepTo(const position_t& pos, bool run = false) -> void; + + // True while the mob is following a path. + auto IsFollowingPath() const -> bool; + auto IsFollowingScriptedPath() const -> bool; + auto IsPatrolling() const -> bool; + + // Face the given point. + auto LookAt(const position_t& point) -> void; + + // Clear the current path. + auto Clear() -> void; + + // Pause pathing for the given duration; zero pauses indefinitely (until Unpause()). + // While paused the entity holds position but keeps facing its battle target, if any. + auto Pause(timer::duration pauseDuration = timer::duration::zero()) -> void; + + // Resume pathing immediately if currently paused. + auto Unpause() -> void; + + auto ValidPosition(const position_t& pos) const -> bool; + + // True if in water. + auto InWater() const -> bool; + + // Final destination of the current path. + auto GetDestination() const -> const position_t&; + + // True when the path length is within `ratio` x straight-line distance to the destination + // (not an unreasonable detour). False if no path is active, the path is partial (chunked), + // or the route exceeds `ratio` x direct distance. + auto IsPathDirect(float ratio = 2.0f) const -> bool; + + // Navmesh legs taken by the current PathTo journey (1 = single query, >1 = chunked). + // Diagnostic - useful in-game and in the pathfind tests. + auto ChunkCount() const -> int; + +private: + // The owner's zone navmesh. + auto navMesh() const -> NavMesh&; + + // Core of PathTo: activates chunked pathing, calls FindPathInternal. Does NOT call Clear() + // (all public entry points clear first) and does NOT guard against scripted paths. + auto PathToImpl(const position_t& point, uint8 pathFlags) -> bool; + + auto FindPathInternal(const position_t& start, const position_t& end) -> bool; + + auto BuildDirectPath(const position_t& end) -> bool; + + // Find a random path around the given point. + auto FindRandomPath(const position_t& start, float maxRadius, uint8 maxTurns, uint16 roamFlags) -> bool; + + // Core of StepTo: step toward `pos`, settling `stopShort` yalms short of it. StepTo forwards + // distanceFromPoint_; FollowPath passes stop-short only for the final waypoint, 0 for corners. + auto StepToInternal(const position_t& pos, bool run, float stopShort) -> void; + + auto AddPoints(std::vector&& points, bool reverse = false) -> void; + + auto FinishedPath() -> void; + + // Re-request the next chunk after a partial path. True if a new chunk was set up, false if + // we've arrived (or pathing failed). + auto TryRequestNextChunk() -> bool; + + // True when the owner is within the arrival threshold of `pos`. isFinalPoint selects the + // stop-short distance (final) vs a tight 0.1y threshold (intermediate corner). + auto AtPoint(const position_t& pos, bool isFinalPoint = true) const -> bool; + + // The moved entity (production: an EntityPathOwner). Owned here so the abstraction stays inside + // the pathfind module - see path_owner.h. + std::unique_ptr owner_; + + // The active path being walked (waypoints + cursor + geometry). + pathfind::Path path_; + + // Re-requests the next leg of a partial path toward the eventual destination (set by + // PathTo / PathInRange). See chunked_path.h. + pathfind::ChunkedPath chunked_; + + std::vector patrol_; + std::vector turnPoints_; + float distanceFromPoint_; + + uint8 pathFlags_; + uint8 patrolFlags_; + uint16 roamFlags_; + bool onPoint_; + + timer::time_point timeAtPoint_; + + // Path is held while tick < unpauseTime_ (see Pause/Unpause). time_point::min() = not paused. + timer::time_point unpauseTime_{ timer::time_point::min() }; + + uint8 currentTurn_; + + float distanceMoved_; + float maxDistance_; + + // Legs taken by the current PathTo journey (1 = single query, >1 = chunked). See ChunkCount(). + int chunkCount_{ 0 }; +}; diff --git a/src/map/ai/helpers/pathfind/pathfind_constants.h b/src/map/ai/helpers/pathfind/pathfind_constants.h new file mode 100644 index 00000000000..67985e0e855 --- /dev/null +++ b/src/map/ai/helpers/pathfind/pathfind_constants.h @@ -0,0 +1,36 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include + +// Cap on point lists passed to AddPoints (PathThrough). Patrol paths may exceed it (long scripted +// routes); other callers get truncated with a warning. +constexpr size_t kMaxPathPoints = 50; + +// Re-path once the target drifts this far from the current destination. Must be under the minimum +// melee range (GetMeleeRange base = 2.0f) so a player can't stand just out of reach without +// triggering a re-path. +constexpr float kPathDestinationDriftThreshold = 1.0f; + +// Distance walked toward spawn per roam-home tick before re-evaluating. +constexpr float kRoamHomeStepDistance = 10.0f; diff --git a/src/map/ai/helpers/pathfind/pathfind_step.cpp b/src/map/ai/helpers/pathfind/pathfind_step.cpp new file mode 100644 index 00000000000..24068bade1e --- /dev/null +++ b/src/map/ai/helpers/pathfind/pathfind_step.cpp @@ -0,0 +1,92 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include + +#include +#include + +#include +#include + +namespace pathfind +{ + +auto stepTowards(position_t& pos, const position_t& target, float stepDistance, float stopShort) -> float +{ + const float distanceTo = distance(pos, target); + const float diffY = target.y - pos.y; + + // Face the target (LookAt). Skip when basically on top of it to avoid unpredictable angles. + if (!isWithinDistance(pos, target, 0.1f, true)) + { + pos.rotation = worldAngle(pos, target); + } + + // FFXI's rotation byte is counter-clockwise; standard math convention is clockwise. + const float radians = 2.0f * static_cast(M_PI) - rotationToRadian(pos.rotation); + + // Step Y toward target.y along the slope, clamped so we never overshoot its height either way. + const auto stepY = [&]() -> void + { + if (std::abs(diffY) <= 0.5f) + { + pos.y = target.y; + return; + } + + const float startY = pos.y; + const float newY = startY + stepDistance * (target.y - startY) / distance(pos, target, true); + const float minY = std::min(startY, target.y); + const float maxY = std::max(startY, target.y); + pos.y = std::clamp(newY, minY, maxY); + }; + + if (distanceTo <= stopShort + stepDistance) + { + // Close enough to settle this tick. + const float advance = distanceTo - stopShort; + + if (stopShort == 0.0f) + { + pos.x = target.x; + pos.y = target.y; + pos.z = target.z; + } + else + { + pos.x += cosf(radians) * advance; + pos.z += sinf(radians) * advance; + stepY(); + } + + return advance; + } + + // Take one full step along our current heading. + pos.x += cosf(radians) * stepDistance; + pos.z += sinf(radians) * stepDistance; + stepY(); + + return stepDistance; +} + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/pathfind_step.h b/src/map/ai/helpers/pathfind/pathfind_step.h new file mode 100644 index 00000000000..274292aebb5 --- /dev/null +++ b/src/map/ai/helpers/pathfind/pathfind_step.h @@ -0,0 +1,40 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +struct position_t; + +namespace pathfind +{ + +// Advance `pos` one tick toward `target`. +// +// - stepDistance: max travel this tick (speed / 50.0 running, speed / 40.0 walking). +// - stopShort: settle this many yalms short of `target` along the heading; 0 lands exactly on it. +// Per-call: callers pass the path's stop-short ONLY for the final waypoint and 0 for +// intermediate corners, so the entity rounds corners instead of cutting across them. +// +// Returns the distance advanced this tick (for LimitDistance bookkeeping). Also sets `pos.rotation` +// to face `target`, matching CPathFind::LookAt. +auto stepTowards(position_t& pos, const position_t& target, float stepDistance, float stopShort) -> float; + +} // namespace pathfind diff --git a/src/map/ai/helpers/pathfind/pathfind_types.h b/src/map/ai/helpers/pathfind/pathfind_types.h new file mode 100644 index 00000000000..c7c5ce74ada --- /dev/null +++ b/src/map/ai/helpers/pathfind/pathfind_types.h @@ -0,0 +1,34 @@ +/* +=========================================================================== + + Copyright (c) 2010-2015 Darkstar Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +enum PATHFLAG +{ + PATHFLAG_NONE = 0x00, + PATHFLAG_RUN = 0x01, // run at double speed + PATHFLAG_WALLHACK = 0x02, // DEPRECATED - do not use. Kept so the 0x02 bit isn't silently reused. + PATHFLAG_REVERSE = 0x04, // reverse the point order + PATHFLAG_SCRIPT = 0x08, // don't overwrite before completion (except by another SCRIPT path) + PATHFLAG_SLIDE = 0x10, // slide to end point if close enough (unused in C++, reserved for Lua) + PATHFLAG_PATROL = 0x20, // loop the path continuously while roaming + PATHFLAG_COORDS = 0x40, // walk through to end; do not repeat +}; diff --git a/src/map/entities/mobentity.cpp b/src/map/entities/mobentity.cpp index 26c219fcb46..0962dbdc62b 100644 --- a/src/map/entities/mobentity.cpp +++ b/src/map/entities/mobentity.cpp @@ -1154,6 +1154,14 @@ bool CMobEntity::CanAttack(CBattleEntity* PTarget, std::unique_ptr { TracyZoneScoped; + // If the navmesh path to the target is much longer than the straight-line distance + // (e.g. across a thin gap we can see but can't walk across), refuse the attack until + // we've actually walked the detour. + if (PAI->PathFind && PAI->PathFind->IsFollowingPath() && !PAI->PathFind->IsPathDirect()) + { + return false; + } + auto skill_list_id{ getMobMod(MOBMOD_ATTACK_SKILL_LIST) }; if (skill_list_id) { diff --git a/src/map/lua/lua_baseentity.cpp b/src/map/lua/lua_baseentity.cpp index 66f595b9711..065960f61ea 100644 --- a/src/map/lua/lua_baseentity.cpp +++ b/src/map/lua/lua_baseentity.cpp @@ -2052,7 +2052,7 @@ void CLuaBaseEntity::pathTo(float x, float y, float z, const sol::object& flags) if (m_PBaseEntity->PAI->PathFind) { - uint8 pathFlags = (flags != sol::lua_nil) ? flags.as() : static_cast(PATHFLAG_RUN | PATHFLAG_WALLHACK | PATHFLAG_SCRIPT); + uint8 pathFlags = (flags != sol::lua_nil) ? flags.as() : static_cast(PATHFLAG_RUN | PATHFLAG_SCRIPT); m_PBaseEntity->PAI->PathFind->PathTo(point, pathFlags); } @@ -2294,28 +2294,13 @@ void CLuaBaseEntity::unfollow() controller->ClearFollowTarget(); } -/************************************************************************ - * Function: setCarefulPathing(...) - * Purpose : Enables or disables careful pathing for an entity. - * Example : mob:setCarefulPathing(true) - * Notes : !!! THIS IS VERY EXPENSIVE !!!. Only use this as a last resort! - ************************************************************************/ - -void CLuaBaseEntity::setCarefulPathing(bool careful) -{ - if (m_PBaseEntity->PAI->PathFind) - { - m_PBaseEntity->PAI->PathFind->SetCarefulPathing(careful); - } -} - /************************************************************************ * Function: canSee(...) * Purpose : Execute a raycast between ENTITY_HEIGHT and the found at target's feet * Example : player:canSee(mob) ************************************************************************/ -bool CLuaBaseEntity::canSee(const CLuaBaseEntity* PTarget) +bool CLuaBaseEntity::canSee(const CLuaBaseEntity* PTarget, const sol::object& ignoreInvisibleBoundaries) { if (!PTarget) { @@ -2323,7 +2308,17 @@ bool CLuaBaseEntity::canSee(const CLuaBaseEntity* PTarget) return false; } - return m_PBaseEntity->CanSeeTarget(PTarget->GetBaseEntity()); + bool flag = (ignoreInvisibleBoundaries != sol::lua_nil) ? ignoreInvisibleBoundaries.as() : true; + + constexpr float ENTITY_HEIGHT = 2.0f; + + const auto& loc = m_PBaseEntity->loc; + const auto& targetPointBase = PTarget->GetBaseEntity()->loc.p; + + const auto src = Vector3{ loc.p.x, loc.p.y - ENTITY_HEIGHT, loc.p.z }; + const auto dst = Vector3{ targetPointBase.x, targetPointBase.y - ENTITY_HEIGHT, targetPointBase.z }; + + return !m_PBaseEntity->loc.zone->xiMesh()->rayIntersect(src, dst, IgnoreTransparentBarriers(flag)); } /************************************************************************ @@ -20306,7 +20301,6 @@ void CLuaBaseEntity::Register() SOL_REGISTER("follow", CLuaBaseEntity::follow); SOL_REGISTER("hasFollowTarget", CLuaBaseEntity::hasFollowTarget); SOL_REGISTER("unfollow", CLuaBaseEntity::unfollow); - SOL_REGISTER("setCarefulPathing", CLuaBaseEntity::setCarefulPathing); SOL_REGISTER("canSee", CLuaBaseEntity::canSee); SOL_REGISTER("inWater", CLuaBaseEntity::inWater); diff --git a/src/map/lua/lua_baseentity.h b/src/map/lua/lua_baseentity.h index bb35855bdd5..17b63226bf4 100644 --- a/src/map/lua/lua_baseentity.h +++ b/src/map/lua/lua_baseentity.h @@ -159,9 +159,7 @@ class CLuaBaseEntity // int32 WarpTo(lua_Stat* L); // warp to the given point -- These don't exist, breaking them just in case someone uncomments // int32 RoamAround(lua_Stat* L); // pick a random point to walk to // int32 LimitDistance(lua_Stat* L); // limits the current path distance to given max distance - void setCarefulPathing(bool careful); - - bool canSee(const CLuaBaseEntity* PTarget); + bool canSee(const CLuaBaseEntity* PTarget, const sol::object& ignoreInvisibleBoundaries); bool inWater(); void openDoor(const sol::object& seconds); diff --git a/src/map/lua/luautils.cpp b/src/map/lua/luautils.cpp index f2f3924903e..5ff156a77da 100644 --- a/src/map/lua/luautils.cpp +++ b/src/map/lua/luautils.cpp @@ -5333,17 +5333,16 @@ sol::table GetFurthestValidPosition(CLuaBaseEntity* fromTarget, float distance, CBaseEntity* entity = fromTarget->GetBaseEntity(); position_t pos = nearPosition(entity->loc.p, distance, theta); - float validPos[3]; - bool success = entity->loc.zone->navMesh()->findFurthestValidPoint(entity->loc.p, pos, validPos); - if (!success) + const auto validPos = entity->loc.zone->navMesh()->findFurthestValidPoint(entity->loc.p, pos); + if (!validPos) { return sol::lua_nil; } sol::table outPos = lua.create_table(); - outPos["x"] = validPos[0]; - outPos["y"] = validPos[1]; - outPos["z"] = validPos[2]; + outPos["x"] = validPos->x; + outPos["y"] = validPos->y; + outPos["z"] = validPos->z; return outPos; } diff --git a/src/map/mob_modifier.h b/src/map/mob_modifier.h index c4735338584..469b5380ba0 100644 --- a/src/map/mob_modifier.h +++ b/src/map/mob_modifier.h @@ -125,6 +125,7 @@ enum MOBMODIFIER : int MOBMOD_FOLLOW_LEASH_RANGE = 94, // Distance the leader can walk before their followers start moving. Applied to followers. MOBMOD_FOLLOW_STOP_RANGE = 95, // Distance the followers attempt to stop at once their leader stops moving. Applied to followers. MOBMOD_TRUST_SHIELD_SIZE = 96, // TRUSTS ONLY: Set the size of the mob's shield. 3 = Default size, only used for trusts that use shields. + MOBMOD_NO_STUCK_TELEPORT = 97, // Disable the stuck-repath teleport fallback for this mob (terrain avoidance is intentional for this encounter). }; #endif diff --git a/src/map/navmesh/detour_navmesh.cpp b/src/map/navmesh/detour_navmesh.cpp new file mode 100644 index 00000000000..f87006c4166 --- /dev/null +++ b/src/map/navmesh/detour_navmesh.cpp @@ -0,0 +1,615 @@ +/* +=========================================================================== + + Copyright (c) 2010-2015 Darkstar Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include "detour_navmesh.h" + +#include +#include + +#include "common/utils.h" +#include "common/xirand.h" + +#include +#include +#include +#include + +namespace +{ + +constexpr int kNavmeshSetMagic = 'M' << 24 | 'S' << 16 | 'E' << 8 | 'T'; // 'MSET' +constexpr int kNavmeshSetVersion = 1; + +// Mirrors SamplePolyFlags from RecastDemo's Sample.h. +enum SamplePolyFlags : uint16_t +{ + SAMPLE_POLYFLAGS_WALK = 0x0001, // Walk (ground, grass, road) + SAMPLE_POLYFLAGS_SWIM = 0x0002, // Swim (water) + SAMPLE_POLYFLAGS_DOOR = 0x0004, // Move through doors + SAMPLE_POLYFLAGS_JUMP = 0x0008, // Jump + SAMPLE_POLYFLAGS_DISABLED = 0x0010, // Disabled polygon + SAMPLE_POLYFLAGS_ALL = 0xFFFF, // All abilities +}; + +// Exclude wins over include, so include all and exclude the unwanted. +constexpr unsigned short kIncludeFlags = SamplePolyFlags::SAMPLE_POLYFLAGS_ALL; +constexpr unsigned short kExcludeFlags = SamplePolyFlags::SAMPLE_POLYFLAGS_DISABLED; + +// +// Detour result buffer sizes. Bigger buffers allow longer/higher-quality paths but +// cost more; since queries run blocking, keep these as small as quality allows. +// + +// From docs: Maximum number of search nodes. +constexpr size_t kMaxNavPolys = 2048; + +// From docs: The maximum number of polygons the path array can hold. +// TODO: Should we make this scale with the number of tiles in a given navmesh, up to nearest power of two? +constexpr size_t kPathPolyLimit = 512; + +// From docs: The maximum number of polygons the visited array can hold. +constexpr size_t kMaxQueryPolys = 16; + +// Detour search extents. Larger extents search more polys; keep small since queries +// run blocking. Used to snap a query point onto the nearest poly. +constexpr float smallPolyPickExt[3] = { 0.5f, 1.0f, 0.5f }; +constexpr float polyPickExt[3] = { 2.5f, 5.0f, 2.5f }; +constexpr float largePolyPickExt[3] = { 30.0f, 60.0f, 30.0f }; + +struct NavMeshSetHeader +{ + int magic; + int version; + int numTiles; + dtNavMeshParams params; +}; + +struct NavMeshTileHeader +{ + dtTileRef tileRef; + int dataSize; +}; + +} // namespace + +// Detour is right-handed Y-up, FFXI is left-handed Y-up: Y and Z are sign-flipped between them. +auto DetourNavMesh::toDetour(const position_t& p) -> std::array +{ + return { p.x, p.y * -1.0f, p.z * -1.0f }; +} + +auto DetourNavMesh::fromDetour(const float* p) -> position_t +{ + return { p[0], p[1] * -1.0f, p[2] * -1.0f, 0, 0 }; +} + +auto DetourNavMesh::makeFilter() const -> dtQueryFilter +{ + dtQueryFilter filter; + filter.setIncludeFlags(kIncludeFlags); + filter.setExcludeFlags(kExcludeFlags); + return filter; +} + +auto DetourNavMesh::lookupPoly(const std::array& pos, const float* extents, const dtQueryFilter& filter) const -> Maybe +{ + PolyLookup hit{}; + const auto status = navMeshQuery_.findNearestPoly(pos.data(), extents, &filter, &hit.ref, hit.nearest.data()); + if (dtStatusFailed(status)) + { + ShowError("DetourNavMesh::lookupPoly findNearestPoly failed (%u): %s", zoneID_, detourStatusString(status)); + return std::nullopt; + } + if (!navMesh_->isValidPolyRef(hit.ref)) + { + return std::nullopt; + } + return hit; +} + +DetourNavMesh::DetourNavMesh(uint16 zoneID) +: zoneID_(zoneID) +, navMesh_(nullptr) +{ + navMeshQueryPolyData_.resize(kMaxNavPolys); + navMeshQueryStraightPathFloatData_.resize(kMaxNavPolys * 3); + navMeshQueryStraightPathFlagData_.resize(kMaxNavPolys); + navMeshQueryStraightPathPolyData_.resize(kMaxNavPolys); +} + +DetourNavMesh::~DetourNavMesh() +{ + if (navMesh_) + { + dtFreeNavMesh(navMesh_); + } +} + +auto DetourNavMesh::load(const std::string& filename) -> bool +{ + this->filename_ = filename; + + std::ifstream file(filename.c_str(), std::ios_base::in | std::ios_base::binary); + + if (!file.good()) + { + return false; + } + + // Read header + NavMeshSetHeader header{}; + file.read(reinterpret_cast(&header), sizeof(header)); + if (header.magic != kNavmeshSetMagic) + { + return false; + } + + if (header.version != kNavmeshSetVersion) + { + return false; + } + + navMesh_ = dtAllocNavMesh(); + if (!navMesh_) + { + return false; + } + + dtStatus status = navMesh_->init(&header.params); + if (dtStatusFailed(status)) + { + ShowError("DetourNavMesh::load Could not initialize detour for (%s)", filename); + ShowError(detourStatusString(status)); + return false; + } + + // Read tiles + for (int i = 0; i < header.numTiles; ++i) + { + NavMeshTileHeader tileHeader{}; + file.read(reinterpret_cast(&tileHeader), sizeof(tileHeader)); + if (!tileHeader.tileRef || !tileHeader.dataSize) + { + break; + } + + unsigned char* data = (unsigned char*)dtAlloc(tileHeader.dataSize, DT_ALLOC_PERM); + if (!data) + { + break; + } + std::memset(data, 0, tileHeader.dataSize); + file.read(reinterpret_cast(data), tileHeader.dataSize); + + navMesh_->addTile(data, tileHeader.dataSize, DT_TILE_FREE_DATA, tileHeader.tileRef, nullptr); + } + + // Init the path finder query + status = navMeshQuery_.init(navMesh_, kMaxNavPolys); + + if (dtStatusFailed(status)) + { + ShowError("DetourNavMesh::load Error loading navMeshQuery_ (%s)", filename); + ShowError(detourStatusString(status)); + return false; + } + + return true; +} + +auto DetourNavMesh::unload() -> void +{ + dtFreeNavMesh(navMesh_); + navMesh_ = nullptr; +} + +auto DetourNavMesh::installNavMesh(dtNavMesh* newNavMesh) -> bool +{ + if (!newNavMesh) + { + return false; + } + + unload(); + + navMesh_ = newNavMesh; + + const auto status = navMeshQuery_.init(navMesh_, kMaxNavPolys); + if (dtStatusFailed(status)) + { + ShowErrorFmt("DetourNavMesh::installNavMesh: Could not init navMeshQuery ({})", zoneID_); + unload(); + return false; + } + + return true; +} + +auto DetourNavMesh::save(const std::string& path) const -> bool +{ + if (!navMesh_ || path.empty()) + { + return false; + } + + std::ofstream file(path, std::ios::binary); + if (!file.good()) + { + ShowErrorFmt("DetourNavMesh::save: Could not open file for writing ({})", path); + return false; + } + + const auto* nav = navMesh_; + + auto header = NavMeshSetHeader{}; + header.magic = kNavmeshSetMagic; + header.version = kNavmeshSetVersion; + header.params = *nav->getParams(); + + for (auto i = 0; i < nav->getMaxTiles(); ++i) + { + const auto* tile = nav->getTile(i); + if (tile && tile->header && tile->dataSize > 0) + { + header.numTiles++; + } + } + + file.write(reinterpret_cast(&header), sizeof(header)); + + for (auto i = 0; i < nav->getMaxTiles(); ++i) + { + const auto* tile = nav->getTile(i); + if (!tile || !tile->header || tile->dataSize <= 0) + { + continue; + } + + const auto tileHeader = NavMeshTileHeader{ + .tileRef = nav->getTileRef(tile), + .dataSize = tile->dataSize, + }; + + file.write(reinterpret_cast(&tileHeader), sizeof(tileHeader)); + file.write(reinterpret_cast(tile->data), tile->dataSize); + } + + return true; +} + +auto DetourNavMesh::findPath(const position_t& start, const position_t& end) -> Maybe +{ + TracyZoneScoped; + + if (std::isnan(start.x) || std::isnan(start.y) || std::isnan(start.z) || + std::isnan(end.x) || std::isnan(end.y) || std::isnan(end.z)) + { + ShowWarning("DetourNavMesh::findPath NaN position detected (%u)", zoneID_); + return std::nullopt; + } + + DebugNavmesh("DetourNavMesh::findPath (%f, %f, %f) -> (%f, %f, %f) (zone: %u) (kMaxNavPolys: %u)", + start.x, + start.y, + start.z, + end.x, + end.y, + end.z, + zoneID_, + kMaxNavPolys); + + const auto filter = makeFilter(); + const auto startPoly = lookupPoly(toDetour(start), polyPickExt, filter); + if (!startPoly) + { + DebugNavmesh("DetourNavMesh::findPath start has no poly within extents: (%f, %f, %f) (%u)", start.x, start.y, start.z, zoneID_); + return std::nullopt; + } + + const auto endPoly = lookupPoly(toDetour(end), polyPickExt, filter); + if (!endPoly) + { + DebugNavmesh("DetourNavMesh::findPath end has no poly within extents: (%f, %f, %f) (%u)", end.x, end.y, end.z, zoneID_); + return std::nullopt; + } + + // Build a poly corridor from start to end + int32 pathPolyCount = 0; + dtStatus status = navMeshQuery_.findPath( + startPoly->ref, endPoly->ref, startPoly->nearest.data(), endPoly->nearest.data(), &filter, navMeshQueryPolyData_.data(), &pathPolyCount, static_cast(kPathPolyLimit)); + if (dtStatusFailed(status)) + { + ShowError("DetourNavMesh::findPath findPath error (%u)", zoneID_); + ShowError(detourStatusString(status)); + return std::nullopt; + } + + if (pathPolyCount <= 0) + { + ShowError("DetourNavMesh::findPath Unable to generate polys for path (%f, %f, %f)->(%f, %f, %f) (%u)", + start.x, + start.y, + start.z, + end.x, + end.y, + end.z, + zoneID_); + return std::nullopt; + } + + // Straighten the corridor into a waypoint list. + // DT_STRAIGHTPATH_ALL_CROSSINGS can worsen local-minima trapping; keep it off. + int32 straightPathCount = 0; + status = navMeshQuery_.findStraightPath( + startPoly->nearest.data(), endPoly->nearest.data(), navMeshQueryPolyData_.data(), pathPolyCount, navMeshQueryStraightPathFloatData_.data(), navMeshQueryStraightPathFlagData_.data(), navMeshQueryStraightPathPolyData_.data(), &straightPathCount, static_cast(kPathPolyLimit) /* , DT_STRAIGHTPATH_ALL_CROSSINGS */); + if (dtStatusFailed(status)) + { + ShowError("DetourNavMesh::findPath findStraightPath error (%u)", zoneID_); + ShowError(detourStatusString(status)); + return std::nullopt; + } + + // Partial path: if the poly buffer ran out before reaching `end`, Detour's best-guess + // final waypoint lands far from the requested endpoint. Drop it to avoid trapping the + // entity in a local minimum; the caller walks this chunk and re-requests from there. + // Compared in Detour space - distance is invariant to the Y/Z sign flip. + const float* pathEnd = &navMeshQueryStraightPathFloatData_[(straightPathCount - 1) * 3]; + const float* wantEnd = endPoly->nearest.data(); + const float dx = pathEnd[0] - wantEnd[0]; + const float dy = pathEnd[1] - wantEnd[1]; + const float dz = pathEnd[2] - wantEnd[2]; + + bool isPartial = false; + if (straightPathCount > 2 && dx * dx + dy * dy + dz * dz > 5.0f * 5.0f) + { + DebugNavmesh("DetourNavMesh::findPath Partial path detected! (%u)", zoneID_); + isPartial = true; + straightPathCount -= 1; + } + + // straightPathCount == 1: only the start position was produced - no usable path + if (straightPathCount <= 1) + { + return std::nullopt; + } + + // TODO: Detect local minima and re-try pathing with a larger buffer. + + // Skip index 0 (the start position - we're already there) + std::vector outPoints; + outPoints.reserve(straightPathCount - 1); + for (int i = 1; i < straightPathCount; ++i) + { + outPoints.emplace_back(pathpoint_t{ + fromDetour(&navMeshQueryStraightPathFloatData_[i * 3]), + 0s, + false, + }); + } + + return PathResult{ std::move(outPoints), isPartial }; +} + +auto DetourNavMesh::findRandomPosition(const position_t& start, float maxRadius) const -> Maybe +{ + TracyZoneScoped; + + DebugNavmesh("DetourNavMesh::findRandomPosition (%f, %f, %f) (%u)", start.x, start.y, start.z, zoneID_); + + const auto filter = makeFilter(); + const auto spos = toDetour(start); + const auto startPoly = lookupPoly(spos, polyPickExt, filter); + if (!startPoly) + { + DebugNavmesh("DetourNavMesh::findRandomPosition start has no poly within extents: (%f, %f, %f) (%u)", start.x, start.y, start.z, zoneID_); + return std::nullopt; + } + + dtPolyRef randomRef = 0; + float randomPt[3]; + const auto status = navMeshQuery_.findRandomPointAroundCircle( + startPoly->ref, spos.data(), maxRadius, &filter, []() -> float + { + return xirand::GetRandomNumber(1.0f); + }, + &randomRef, + randomPt); + + if (dtStatusFailed(status)) + { + ShowError("DetourNavMesh::findRandomPosition error (%u): %s", zoneID_, detourStatusString(status)); + return std::nullopt; + } + + return fromDetour(randomPt); +} + +auto DetourNavMesh::validPosition(const position_t& position) const -> bool +{ + TracyZoneScoped; + + DebugNavmesh("DetourNavMesh::validPosition (%f, %f, %f) (%u)", position.x, position.y, position.z, zoneID_); + return lookupPoly(toDetour(position), smallPolyPickExt, makeFilter()).has_value(); +} + +auto DetourNavMesh::findClosestValidPoint(const position_t& position) const -> Maybe +{ + TracyZoneScoped; + + DebugNavmesh("DetourNavMesh::findClosestValidPoint (%f, %f, %f) (%u)", position.x, position.y, position.z, zoneID_); + + const auto hit = lookupPoly(toDetour(position), largePolyPickExt, makeFilter()); + if (!hit) + { + return std::nullopt; + } + + return fromDetour(hit->nearest.data()); +} + +auto DetourNavMesh::findFurthestValidPoint(const position_t& startPosition, const position_t& endPosition) const -> Maybe +{ + TracyZoneScoped; + + DebugNavmesh("DetourNavMesh::findFurthestValidPoint (%f, %f, %f) -> (%f, %f, %f) (%u)", + startPosition.x, + startPosition.y, + startPosition.z, + endPosition.x, + endPosition.y, + endPosition.z, + zoneID_); + + const auto filter = makeFilter(); + const auto startPoly = lookupPoly(toDetour(startPosition), largePolyPickExt, filter); + if (!startPoly) + { + return std::nullopt; + } + + const auto targetPos = toDetour(endPosition); + dtPolyRef visited[kMaxQueryPolys]; + int visitedCount = 0; + float result[3]; + + const auto status = navMeshQuery_.moveAlongSurface( + startPoly->ref, startPoly->nearest.data(), targetPos.data(), &filter, result, visited, &visitedCount, kMaxQueryPolys); + + if (dtStatusFailed(status)) + { + return std::nullopt; + } + + return fromDetour(result); +} + +auto DetourNavMesh::moveAlongSurface(const position_t& start, const position_t& end, position_t& result) const -> bool +{ + TracyZoneScoped; + + // Tight start extent: assumes the entity is already on the mesh. If `start` is genuinely + // off-mesh (> polyPickExt from any poly - e.g. a zone-in slide or "underground" worm), + // report failure so the caller free-moves until it slides back on. + const auto filter = makeFilter(); + const auto startPoly = lookupPoly(toDetour(start), polyPickExt, filter); + if (!startPoly) + { + return false; + } + + const auto targetPos = toDetour(end); + dtPolyRef visited[kMaxQueryPolys]; + int visitedCount = 0; + float out[3]; + + const auto status = navMeshQuery_.moveAlongSurface( + startPoly->ref, startPoly->nearest.data(), targetPos.data(), &filter, out, visited, &visitedCount, kMaxQueryPolys); + + if (dtStatusFailed(status)) + { + return false; + } + + result = fromDetour(out); + return true; +} + +auto DetourNavMesh::snapToValidPosition(position_t& position) const -> void +{ + TracyZoneScoped; + + DebugNavmesh("DetourNavMesh::snapToValidPosition (%f, %f, %f) (%u)", position.x, position.y, position.z, zoneID_); + + const auto filter = makeFilter(); + const auto spos = toDetour(position); + + // Cheap small radius first; fall back to 30f XZ for genuinely off-mesh positions + // (e.g. after a stuck-repath teleport lands on a cliff ledge). + auto poly = lookupPoly(spos, polyPickExt, filter); + if (!poly) + { + poly = lookupPoly(spos, largePolyPickExt, filter); + } + if (!poly) + { + return; + } + + const auto snapped = fromDetour(poly->nearest.data()); + position.x = snapped.x; + position.y = snapped.y; + position.z = snapped.z; +} + +[[nodiscard]] auto DetourNavMesh::detourStatusString(const uint32 status) -> std::string +{ + std::string outStr; + + // High-level status + if (status & DT_FAILURE) + { + outStr += "DT_FAILURE: Operation failed. "; + } + if (status & DT_SUCCESS) + { + outStr += "DT_SUCCESS: Operation succeeded. "; + } + if (status & DT_IN_PROGRESS) + { + outStr += "DT_IN_PROGRESS: Operation still in progress. "; + } + + // Status detail bits + if (status & DT_WRONG_MAGIC) + { + outStr += "DT_WRONG_MAGIC: Input data is not recognized. "; + } + if (status & DT_WRONG_VERSION) + { + outStr += "DT_WRONG_VERSION: Input data is in wrong version. "; + } + if (status & DT_OUT_OF_MEMORY) + { + outStr += "DT_OUT_OF_MEMORY: Operation ran out of memory. "; + } + if (status & DT_INVALID_PARAM) + { + outStr += "DT_INVALID_PARAM: An input parameter was invalid. "; + } + if (status & DT_BUFFER_TOO_SMALL) + { + outStr += "DT_BUFFER_TOO_SMALL: Result buffer for the query was too small to store all results. "; + } + if (status & DT_OUT_OF_NODES) + { + outStr += "DT_OUT_OF_NODES: Query ran out of nodes during search. "; + } + if (status & DT_PARTIAL_RESULT) + { + outStr += "DT_PARTIAL_RESULT: Query did not reach the end location, returning best guess. "; + } + if (status & DT_ALREADY_OCCUPIED) + { + outStr += "DT_ALREADY_OCCUPIED: A tile has already been assigned to the given x, y coordinate. "; + } + + return outStr; +} diff --git a/src/map/navmesh/detour_navmesh.h b/src/map/navmesh/detour_navmesh.h new file mode 100644 index 00000000000..84548472e11 --- /dev/null +++ b/src/map/navmesh/detour_navmesh.h @@ -0,0 +1,86 @@ +/* +=========================================================================== + + Copyright (c) 2010-2015 Darkstar Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include +#include + +#include "common/logging.h" +#include "navmesh.h" + +#include + +class DetourNavMesh final : public NavMesh +{ +public: + explicit DetourNavMesh(uint16 zoneID); + ~DetourNavMesh() override; + + DISALLOW_COPY_AND_MOVE(DetourNavMesh); + + auto load(const std::string& path) -> bool; + auto installNavMesh(dtNavMesh* newNavMesh) -> bool; + auto save(const std::string& path) const -> bool; + auto unload() -> void; + + // + // NavMesh + // + + auto findPath(const position_t& start, const position_t& end) -> Maybe override; + auto findRandomPosition(const position_t& start, float maxRadius) const -> Maybe override; + auto validPosition(const position_t& position) const -> bool override; + auto findClosestValidPoint(const position_t& position) const -> Maybe override; + auto findFurthestValidPoint(const position_t& startPosition, const position_t& endPosition) const -> Maybe override; + auto snapToValidPosition(position_t& position) const -> void override; + auto moveAlongSurface(const position_t& start, const position_t& end, position_t& result) const -> bool override; + + [[nodiscard]] static auto detourStatusString(const uint32 status) -> std::string; + +private: + // Traversal filter: walk all flags, skip DISABLED. + auto makeFilter() const -> dtQueryFilter; + + struct PolyLookup + { + dtPolyRef ref; + std::array nearest; + }; + + // Nearest poly to `pos` (Detour space) within `extents`. nullopt on hard failure (already + // logged) or if no poly is in range. + auto lookupPoly(const std::array& pos, const float* extents, const dtQueryFilter& filter) const -> Maybe; + + // Convert between FFXI space (left-handed, Y up) and Detour space (Y and Z negated). + static auto toDetour(const position_t& p) -> std::array; + static auto fromDetour(const float* p) -> position_t; + + std::string filename_; + uint16 zoneID_; + dtNavMesh* navMesh_; + dtNavMeshQuery navMeshQuery_; + + std::vector navMeshQueryPolyData_; + std::vector navMeshQueryStraightPathFloatData_; + std::vector navMeshQueryStraightPathFlagData_; + std::vector navMeshQueryStraightPathPolyData_; +}; diff --git a/src/map/navmesh/inavmesh.h b/src/map/navmesh/inavmesh.h deleted file mode 100644 index 3ed7792eed1..00000000000 --- a/src/map/navmesh/inavmesh.h +++ /dev/null @@ -1,80 +0,0 @@ -/* -=========================================================================== - - Copyright (c) 2026 LandSandBoat Dev Teams - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see http://www.gnu.org/licenses/ - -=========================================================================== -*/ - -#pragma once - -#include "common/mmo.h" - -#include -#include - -class INavMesh -{ -public: - virtual ~INavMesh() = default; - - virtual auto findPath(const position_t& start, const position_t& end) -> std::vector = 0; - virtual auto findRandomPosition(const position_t& start, float maxRadius) -> std::pair = 0; - virtual auto raycast(const position_t& start, const position_t& end) -> bool = 0; - virtual auto validPosition(const position_t& position) -> bool = 0; - virtual auto findClosestValidPoint(const position_t& position, float* validPoint) -> bool = 0; - virtual auto findFurthestValidPoint(const position_t& startPosition, const position_t& endPosition, float* validPoint) -> bool = 0; - virtual void snapToValidPosition(position_t& position) = 0; -}; - -class NullNavMesh final : public INavMesh -{ -public: - auto findPath(const position_t&, const position_t&) -> std::vector override - { - return {}; - } - - auto findRandomPosition(const position_t& start, float) -> std::pair override - { - return { 0, start }; - } - - auto raycast(const position_t&, const position_t&) -> bool override - { - return true; - } - - auto validPosition(const position_t&) -> bool override - { - return true; - } - - auto findClosestValidPoint(const position_t&, float*) -> bool override - { - return false; - } - - auto findFurthestValidPoint(const position_t&, const position_t&, float*) -> bool override - { - return false; - } - - void snapToValidPosition(position_t&) override - { - // NOOP - } -}; diff --git a/src/map/navmesh/navmesh.cpp b/src/map/navmesh/navmesh.cpp deleted file mode 100644 index 3a5d24ff8e5..00000000000 --- a/src/map/navmesh/navmesh.cpp +++ /dev/null @@ -1,889 +0,0 @@ -/* -=========================================================================== - - Copyright (c) 2010-2015 Darkstar Dev Teams - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see http://www.gnu.org/licenses/ - -=========================================================================== -*/ - -#include "navmesh.h" - -#include -#include - -#include "common/utils.h" -#include "common/xirand.h" - -#include -#include -#include - -namespace -{ - -static const int8 ERROR_NEARESTPOLY = -2; -static const int NAVMESHSET_MAGIC = 'M' << 24 | 'S' << 16 | 'E' << 8 | 'T'; // 'MSET' -static const int NAVMESHSET_VERSION = 1; - -// Flags are defined in a "Sample.h" file in the RecastDemo project of recastnavigation. -enum SamplePolyFlags : uint16_t -{ - SAMPLE_POLYFLAGS_WALK = 0x0001, // Ability to walk (ground, grass, road) - SAMPLE_POLYFLAGS_SWIM = 0x0002, // Ability to swim (water). - SAMPLE_POLYFLAGS_DOOR = 0x0004, // Ability to move through doors. - SAMPLE_POLYFLAGS_JUMP = 0x0008, // Ability to jump. - SAMPLE_POLYFLAGS_DISABLED = 0x0010, // Disabled polygon - SAMPLE_POLYFLAGS_ALL = 0xFFFF, // All abilities. -}; - -// If a flag is defined in both, then exclude will take priority, and it will be excluded. -// Therefore we can just include all and exclude the ones we don't want. -constexpr unsigned short INCLUDE_FLAGS = SamplePolyFlags::SAMPLE_POLYFLAGS_ALL; -constexpr unsigned short EXCLUDE_FLAGS = SamplePolyFlags::SAMPLE_POLYFLAGS_DISABLED; - -// These constants define how large the result buffers for detour are. -// Larger buffers will allow for longer and higher quality paths to be -// found, but will take longer to calculate. Since we use detour in a -// blocking manner, we want to keep these values as low as possible -// while balancing the need for quality paths. -static const size_t MAX_NAV_POLYS = 512; -static const size_t MAX_HIT_PATH_SIZE = 16; -static const size_t MAX_QUERY_POLYS = 16; - -// These constants define the size of the search extents for -// detour. The larger the extents, the more polys will be searched -// when looking for a path or point. Since we use detour in a blocking -// manner, we want to keep these values as low as possible while -// balancing the need for quality paths. -constexpr float smallPolyPickExt[3] = { 0.5f, 1.0f, 0.5f }; -constexpr float polyPickExt[3] = { 2.5f, 5.0f, 2.5f }; -constexpr float skinnyPolyPickExt[3] = { 0.01f, 10.0f, 0.01f }; -constexpr float largePolyPickExt[3] = { 30.0f, 60.0f, 30.0f }; -constexpr float verticalLimit = 5.0f; - -struct NavMeshSetHeader -{ - int magic; - int version; - int numTiles; - dtNavMeshParams params; -}; - -struct NavMeshTileHeader -{ - dtTileRef tileRef; - int dataSize; -}; - -} // namespace - -void CNavMesh::ToFFXIPos(const position_t* pos, float* out) -{ - float y = pos->y; - float z = pos->z; - - out[0] = pos->x; - out[1] = y * -1; - out[2] = z * -1; -} - -void CNavMesh::ToFFXIPos(float* out) -{ - float y = out[1]; - float z = out[2]; - - out[1] = y * -1; - out[2] = z * -1; -} - -void CNavMesh::ToFFXIPos(position_t* out) -{ - float y = out->y; - float z = out->z; - - out->y = y * -1; - out->z = z * -1; -} - -void CNavMesh::ToDetourPos(float* out) -{ - float y = out[1]; - float z = out[2]; - - out[1] = y * -1; - out[2] = z * -1; -} - -void CNavMesh::ToDetourPos(position_t* out) -{ - float y = out->y; - float z = out->z; - - out->y = y * -1; - out->z = z * -1; -} - -void CNavMesh::ToDetourPos(const position_t* pos, float* out) -{ - float y = pos->y; - float z = pos->z; - - out[0] = pos->x; - out[1] = y * -1; - out[2] = z * -1; -} - -CNavMesh::CNavMesh(uint16 zoneID) -: m_zoneID(zoneID) -, m_navMesh(nullptr) -, m_raycastHit{} -{ - m_navMeshQueryPolyData.resize(MAX_NAV_POLYS); - m_navMeshQueryStraightPathFloatData.resize(MAX_NAV_POLYS * 3); - m_navMeshQueryStraightPathFlagData.resize(MAX_NAV_POLYS); - m_navMeshQueryStraightPathPolyData.resize(MAX_NAV_POLYS); - - m_navMeshQueryRaycastHitPath.resize(MAX_HIT_PATH_SIZE); - - m_raycastHit.path = m_navMeshQueryRaycastHitPath.data(); - m_raycastHit.maxPath = MAX_HIT_PATH_SIZE; -} - -CNavMesh::~CNavMesh() -{ - if (m_navMesh) - { - dtFreeNavMesh(m_navMesh); - } -} - -bool CNavMesh::load(const std::string& filename) -{ - this->m_filename = filename; - - std::ifstream file(filename.c_str(), std::ios_base::in | std::ios_base::binary); - - if (!file.good()) - { - return false; - } - - // Read header. - NavMeshSetHeader header{}; - file.read(reinterpret_cast(&header), sizeof(header)); - if (header.magic != NAVMESHSET_MAGIC) - { - return false; - } - if (header.version != NAVMESHSET_VERSION) - { - return false; - } - - m_navMesh = dtAllocNavMesh(); - if (!m_navMesh) - { - return false; - } - - dtStatus status = m_navMesh->init(&header.params); - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::load Could not initialize detour for (%s)", filename); - ShowError(detourStatusString(status)); - return false; - } - - // Read tiles. - for (int i = 0; i < header.numTiles; ++i) - { - NavMeshTileHeader tileHeader{}; - file.read(reinterpret_cast(&tileHeader), sizeof(tileHeader)); - if (!tileHeader.tileRef || !tileHeader.dataSize) - { - break; - } - - unsigned char* data = (unsigned char*)dtAlloc(tileHeader.dataSize, DT_ALLOC_PERM); - if (!data) - { - break; - } - std::memset(data, 0, tileHeader.dataSize); - file.read(reinterpret_cast(data), tileHeader.dataSize); - - m_navMesh->addTile(data, tileHeader.dataSize, DT_TILE_FREE_DATA, tileHeader.tileRef, nullptr); - } - - // init detour nav mesh path finder - status = m_navMeshQuery.init(m_navMesh, MAX_NAV_POLYS); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::load Error loading m_navMeshQuery (%s)", filename); - ShowError(detourStatusString(status)); - return false; - } - - return true; -} - -void CNavMesh::unload() -{ - dtFreeNavMesh(m_navMesh); - m_navMesh = nullptr; -} - -bool CNavMesh::installNavMesh(dtNavMesh* newNavMesh) -{ - if (!newNavMesh) - { - return false; - } - - unload(); - - m_navMesh = newNavMesh; - - const auto status = m_navMeshQuery.init(m_navMesh, MAX_NAV_POLYS); - if (dtStatusFailed(status)) - { - ShowErrorFmt("CNavMesh::installNavMesh: Could not init navMeshQuery ({})", m_zoneID); - unload(); - return false; - } - - return true; -} - -bool CNavMesh::save(const std::string& path) const -{ - if (!m_navMesh || path.empty()) - { - return false; - } - - std::ofstream file(path, std::ios::binary); - if (!file.good()) - { - ShowErrorFmt("CNavMesh::save: Could not open file for writing ({})", path); - return false; - } - - const auto* nav = m_navMesh; - - auto header = NavMeshSetHeader{}; - header.magic = NAVMESHSET_MAGIC; - header.version = NAVMESHSET_VERSION; - header.params = *nav->getParams(); - - for (auto i = 0; i < nav->getMaxTiles(); ++i) - { - const auto* tile = nav->getTile(i); - if (tile && tile->header && tile->dataSize > 0) - { - header.numTiles++; - } - } - - file.write(reinterpret_cast(&header), sizeof(header)); - - for (auto i = 0; i < nav->getMaxTiles(); ++i) - { - const auto* tile = nav->getTile(i); - if (!tile || !tile->header || tile->dataSize <= 0) - { - continue; - } - - const auto tileHeader = NavMeshTileHeader{ - .tileRef = nav->getTileRef(tile), - .dataSize = tile->dataSize, - }; - - file.write(reinterpret_cast(&tileHeader), sizeof(tileHeader)); - file.write(reinterpret_cast(tile->data), tile->dataSize); - } - - return true; -} - -auto CNavMesh::findPath(const position_t& start, const position_t& end) -> std::vector -{ - TracyZoneScoped; - - if (std::isnan(start.x) || std::isnan(start.y) || std::isnan(start.z) || - std::isnan(end.x) || std::isnan(end.y) || std::isnan(end.z)) - { - ShowWarning("CNavMesh::findPath NaN position detected (%u)", m_zoneID); - return {}; - } - - DebugNavmesh("CNavMesh::findPath (%f, %f, %f) -> (%f, %f, %f) (zone: %u) (MAX_NAV_POLYS: %u)", start.x, start.y, start.z, end.x, end.y, end.z, m_zoneID, MAX_NAV_POLYS); - dtStatus status = 0; - - float spos[3]; - CNavMesh::ToDetourPos(&start, spos); - - float epos[3]; - CNavMesh::ToDetourPos(&end, epos); - - dtQueryFilter filter; - filter.setIncludeFlags(INCLUDE_FLAGS); - filter.setExcludeFlags(EXCLUDE_FLAGS); - - dtPolyRef startRef = 0; - dtPolyRef endRef = 0; - - float sNearestPoint[3]; - float eNearestPoint[3]; - - // Validate spos into startRef and sNearestPoint - status = m_navMeshQuery.findNearestPoly(spos, polyPickExt, &filter, &startRef, sNearestPoint); - if (dtStatusFailed(status)) - { - DebugNavmesh("CNavMesh::findPath start point invalid (%f, %f, %f) (%u)", spos[0], spos[1], spos[2], m_zoneID); - ShowError(detourStatusString(status)); - return {}; - } - - // Validate epos into endRef and eNearestPoint - status = m_navMeshQuery.findNearestPoly(epos, polyPickExt, &filter, &endRef, eNearestPoint); - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::findPath end point invalid (%f, %f, %f) (%u)", epos[0], epos[1], epos[2], m_zoneID); - ShowError(detourStatusString(status)); - return {}; - } - - // TODO: Do we need these isValidPolyRef checks? We've just found the nearest polys and checked them with dtStatusFailed. - - // Make sure the start poly is valid - if (!m_navMesh->isValidPolyRef(startRef)) - { - DebugNavmesh("CNavMesh::findPath Start poly invalid: (%f, %f, %f) (%u)", start.x, start.y, start.z, m_zoneID); - return {}; - } - - // Make sure the end poly is valid - if (!m_navMesh->isValidPolyRef(endRef)) - { - DebugNavmesh("CNavMesh::findPath End poly invalid: (%f, %f, %f) (%u)", end.x, end.y, end.z, m_zoneID); - return {}; - } - - // First, we're going to build up a list of polys that make up the path - int32 pathPolyCount = 0; - - status = m_navMeshQuery.findPath(startRef, endRef, sNearestPoint, eNearestPoint, &filter, m_navMeshQueryPolyData.data(), &pathPolyCount, MAX_NAV_POLYS); - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::findPath findPath error (%u)", m_zoneID); - ShowError(detourStatusString(status)); - return {}; - } - - // At this point, findPath() has generated a list of polys that make up a path, but these polys aren't guaranteed to contain a complete path - // between the start and end points we've requested. When we call findStraightPath() later, it will generate the best path between - // sNearestPoint and eNearestPoint that it can within the bounds of the polys we've found here. - - if (pathPolyCount <= 0) - { - ShowError("CNavMesh::findPath Unable to generate polys for path (%f, %f, %f)->(%f, %f, %f) (%u)", start.x, start.y, start.z, end.x, end.y, end.z, m_zoneID); - return {}; - } - - // Find the best straight path possible between sNearestPoint and eNearestPoint within the bounds of all the polys in pathPolys. - int32 straightPathCount = 0; - - // NOTE: The DT_STRAIGHTPATH_ALL_CROSSINGS flag can exasorbate the issue of getting trapped in local minima. - status = m_navMeshQuery.findStraightPath(sNearestPoint, eNearestPoint, m_navMeshQueryPolyData.data(), pathPolyCount, m_navMeshQueryStraightPathFloatData.data(), m_navMeshQueryStraightPathFlagData.data(), m_navMeshQueryStraightPathPolyData.data(), &straightPathCount, MAX_NAV_POLYS /*, DT_STRAIGHTPATH_ALL_CROSSINGS */); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::findPath findStraightPath error (%u)", m_zoneID); - ShowError(detourStatusString(status)); - return {}; - } - - // Now that we have list of sequential positions in straightPath, we need to check to see if eNearestPoint is the final point. If it isn't we've been given a partial path. - // If we have a partial path, the final point is a best-effort attempt by Detour to get us as close to eNearestPoint as possible. This can end up trapping us in - // local minima (ie. corners we can't navigate out of). Therefore, if we have a partial path we're going to omit the final point. - const auto eNearestPosition = position_t{ - eNearestPoint[0], - eNearestPoint[1], - eNearestPoint[2], - 0, - 0, - }; - - const auto pathEndPosition = position_t{ - m_navMeshQueryStraightPathFloatData[straightPathCount * 3 - 3], - m_navMeshQueryStraightPathFloatData[straightPathCount * 3 - 2], - m_navMeshQueryStraightPathFloatData[straightPathCount * 3 - 1], - 0, - 0, - }; - - // bool partialPath = false; - if (straightPathCount > 2 && distance(eNearestPosition, pathEndPosition) > 5.0f) - { - DebugNavmesh("CNavMesh::findPath Partial path detected! (%u)", m_zoneID); - // partialPath = true; - straightPathCount -= 1; - } - - // If there is only a single point, that is our starting point. - // We have now exhausted our path, return empty results. - if (straightPathCount <= 1) - { - return {}; - } - - // TODO: Detect local minima and re-try pathing with a larger buffer. - - // NOTE: i starts at 3 so the start position is ignored - std::vector outPoints; - outPoints.reserve(straightPathCount - 1); - for (int i = 3; i < straightPathCount * 3;) - { - float pathPos[3]; - pathPos[0] = m_navMeshQueryStraightPathFloatData[i++]; - pathPos[1] = m_navMeshQueryStraightPathFloatData[i++]; - pathPos[2] = m_navMeshQueryStraightPathFloatData[i++]; - - CNavMesh::ToFFXIPos(pathPos); - - outPoints.emplace_back(pathpoint_t{ { pathPos[0], pathPos[1], pathPos[2], 0, 0 }, 0s, false }); - } - - return outPoints; -} - -std::pair CNavMesh::findRandomPosition(const position_t& start, float maxRadius) -{ - TracyZoneScoped; - - DebugNavmesh("CNavMesh::findRandomPosition (%f, %f, %f) (%u)", start.x, start.y, start.z, m_zoneID); - - dtStatus status = 0; - - float spos[3]; - CNavMesh::ToDetourPos(&start, spos); - - float randomPt[3]; - float snearest[3]; - - dtQueryFilter filter; - filter.setIncludeFlags(INCLUDE_FLAGS); - filter.setExcludeFlags(EXCLUDE_FLAGS); - - dtPolyRef startRef = 0; - dtPolyRef randomRef = 0; - - status = m_navMeshQuery.findNearestPoly(spos, polyPickExt, &filter, &startRef, snearest); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::findRandomPosition start point invalid (%f, %f, %f) (%u)", spos[0], spos[1], spos[2], m_zoneID); - ShowError(detourStatusString(status)); - return std::make_pair(ERROR_NEARESTPOLY, position_t{}); - } - - if (!m_navMesh->isValidPolyRef(startRef)) - { - DebugNavmesh("CNavMesh::findRandomPosition startRef is invalid (%f, %f, %f) (%u)", start.x, start.y, start.z, m_zoneID); - return std::make_pair(ERROR_NEARESTPOLY, position_t{}); - } - - status = m_navMeshQuery.findRandomPointAroundCircle( - startRef, - spos, - maxRadius, - &filter, - []() -> float - { - return xirand::GetRandomNumber(1.0f); - }, - &randomRef, - randomPt); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::findRandomPosition Error (%u)", m_zoneID); - ShowError(detourStatusString(status)); - return std::make_pair(ERROR_NEARESTPOLY, position_t{}); - } - - CNavMesh::ToFFXIPos(randomPt); - - return std::make_pair(0, position_t{ randomPt[0], randomPt[1], randomPt[2], 0, 0 }); -} - -bool CNavMesh::validPosition(const position_t& position) -{ - TracyZoneScoped; - - DebugNavmesh("CNavMesh::validPosition (%f, %f, %f) (%u)", position.x, position.y, position.z, m_zoneID); - - float spos[3]; - CNavMesh::ToDetourPos(&position, spos); - - float snearest[3]; - - dtQueryFilter filter; - filter.setIncludeFlags(INCLUDE_FLAGS); - filter.setExcludeFlags(EXCLUDE_FLAGS); - - dtPolyRef startRef = 0; - - dtStatus status = m_navMeshQuery.findNearestPoly(spos, smallPolyPickExt, &filter, &startRef, snearest); - - if (dtStatusFailed(status)) - { - return false; - } - - return m_navMesh->isValidPolyRef(startRef); -} - -bool CNavMesh::findClosestValidPoint(const position_t& position, float* validPoint) -{ - TracyZoneScoped; - - DebugNavmesh("CNavMesh::findClosestValidPoint (%f, %f, %f) (%u)", position.x, position.y, position.z, m_zoneID); - - float spos[3]; - CNavMesh::ToDetourPos(&position, spos); - - dtQueryFilter filter; - filter.setIncludeFlags(INCLUDE_FLAGS); - filter.setExcludeFlags(EXCLUDE_FLAGS); - - dtPolyRef startRef = 0; - - dtStatus status = m_navMeshQuery.findNearestPoly(spos, largePolyPickExt, &filter, &startRef, validPoint); - - if (dtStatusFailed(status)) - { - return false; - } - - CNavMesh::ToFFXIPos(validPoint); - return true; -} - -bool CNavMesh::findFurthestValidPoint(const position_t& startPosition, const position_t& endPosition, float* validEndPoint) -{ - TracyZoneScoped; - - DebugNavmesh("CNavMesh::findFurthestValidPoint (%f, %f, %f) -> (%f, %f, %f) (%u)", startPosition.x, startPosition.y, startPosition.z, endPosition.x, endPosition.y, endPosition.z, m_zoneID); - - float spos[3]; - CNavMesh::ToDetourPos(&startPosition, spos); - - dtQueryFilter filter; - filter.setIncludeFlags(INCLUDE_FLAGS); - filter.setExcludeFlags(EXCLUDE_FLAGS); - - dtPolyRef startRef = 0; - float validStartPoint[3]; - - dtStatus status = m_navMeshQuery.findNearestPoly(spos, largePolyPickExt, &filter, &startRef, validStartPoint); - if (dtStatusFailed(status)) - { - return false; - } - - dtPolyRef visited[MAX_QUERY_POLYS]; - int visitedCount = 0; - - float targetPoint[3]; - CNavMesh::ToDetourPos(&endPosition, targetPoint); - - status = m_navMeshQuery.moveAlongSurface(startRef, validStartPoint, targetPoint, &filter, validEndPoint, visited, &visitedCount, MAX_QUERY_POLYS); - - if (dtStatusFailed(status)) - { - return false; - } - - CNavMesh::ToFFXIPos(validEndPoint); - return true; -} - -void CNavMesh::snapToValidPosition(position_t& position) -{ - TracyZoneScoped; - - DebugNavmesh("CNavMesh::snapToValidPosition (%f, %f, %f) (%u)", position.x, position.y, position.z, m_zoneID); - - float spos[3]; - CNavMesh::ToDetourPos(&position, spos); - - float snearest[3]; - - dtQueryFilter filter; - filter.setIncludeFlags(INCLUDE_FLAGS); - filter.setExcludeFlags(EXCLUDE_FLAGS); - - dtPolyRef startRef = 0; - - dtStatus status = m_navMeshQuery.findNearestPoly(spos, polyPickExt, &filter, &startRef, snearest); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::Failed to find nearby valid poly (%f, %f, %f) (%u)", spos[0], spos[1], spos[2], m_zoneID); - ShowError(detourStatusString(status)); - return; - } - - if (m_navMesh->isValidPolyRef(startRef)) - { - CNavMesh::ToFFXIPos(snearest); - position.x = snearest[0]; - position.y = snearest[1]; - position.z = snearest[2]; - } -} - -[[nodiscard]] auto CNavMesh::detourStatusString(const uint32 status) -> std::string -{ - std::string outStr; - - // High level status. - if (status & DT_FAILURE) - { - outStr += "DT_FAILURE: Operation failed. "; - } - if (status & DT_SUCCESS) - { - outStr += "DT_SUCCESS: Operation succeeded. "; - } - if (status & DT_IN_PROGRESS) - { - outStr += "DT_IN_PROGRESS: Operation still in progress. "; - } - - // Detail information for status. - if (status & DT_WRONG_MAGIC) - { - outStr += "DT_WRONG_MAGIC: Input data is not recognized. "; - } - if (status & DT_WRONG_VERSION) - { - outStr += "DT_WRONG_VERSION: Input data is in wrong version. "; - } - if (status & DT_OUT_OF_MEMORY) - { - outStr += "DT_OUT_OF_MEMORY: Operation ran out of memory. "; - } - if (status & DT_INVALID_PARAM) - { - outStr += "DT_INVALID_PARAM: An input parameter was invalid. "; - } - if (status & DT_BUFFER_TOO_SMALL) - { - outStr += "DT_BUFFER_TOO_SMALL: Result buffer for the query was too small to store all results. "; - } - if (status & DT_OUT_OF_NODES) - { - outStr += "DT_OUT_OF_NODES: Query ran out of nodes during search. "; - } - if (status & DT_PARTIAL_RESULT) - { - outStr += "DT_PARTIAL_RESULT: Query did not reach the end location, returning best guess. "; - } - if (status & DT_ALREADY_OCCUPIED) - { - outStr += "DT_ALREADY_OCCUPIED: A tile has already been assigned to the given x, y coordinate. "; - } - - return outStr; -} - -bool CNavMesh::onSameFloor(const position_t& start, float* spos, const position_t& end, float* epos, dtQueryFilter& filter) -{ - TracyZoneScoped; - - DebugNavmesh("CNavMesh::onSameFloor (%f, %f, %f) -> (%f, %f, %f) (%u)", start.x, start.y, start.z, end.x, end.y, end.z, m_zoneID); - - float verticalDistance = abs(start.y - end.y); - if (verticalDistance > 2 * verticalLimit) - { - // Too far away, abort check - return false; - } - else if (verticalDistance > verticalLimit) - { - // Far away, but not too far away. - // We're going to try and disambiguate any vertical floors. - dtPolyRef polys[MAX_QUERY_POLYS]; - int polyCount = -1; - dtStatus status = m_navMeshQuery.queryPolygons(epos, skinnyPolyPickExt, &filter, polys, &polyCount, MAX_QUERY_POLYS); - - if (dtStatusFailed(status) || polyCount <= 0) - { - ShowError("CNavMesh::Bad vertical polygon query (%f, %f, %f) (%u)", epos[0], epos[1], epos[2], m_zoneID); - ShowError(detourStatusString(status)); - return false; - } - - // Collect the heights of queried polygons - uint8 verticalLimitTrunc = static_cast(verticalLimit); - float height = 0; - std::set heights; - for (int i = 0; i < polyCount; i++) - { - status = m_navMeshQuery.getPolyHeight(polys[i], epos, &height); - if (!dtStatusFailed(status)) - { - // Truncate the height and round to nearest multiple of verticalLimitTrunc for easier de-duping - uint8 rounded = static_cast(height) + abs((static_cast(height) % verticalLimitTrunc) - verticalLimitTrunc); - heights.insert(rounded); - } - } - - // Multiple floors detected, we need to disambiguate - if (heights.size() > 1) - { - auto startHeight = static_cast(spos[1]) + abs((static_cast(spos[1]) % verticalLimitTrunc) - verticalLimitTrunc); - auto endHeight = static_cast(epos[1]) + abs((static_cast(epos[1]) % verticalLimitTrunc) - verticalLimitTrunc); - - // Since we've already truncated and rounded to nearest multiples of verticalLimitTrunc, - // if we are within verticalLimitTrunc of a point, that's our closest. - if (startHeight != endHeight) - { - return false; - } - } - } - - return true; -} - -bool CNavMesh::raycast(const position_t& start, const position_t& end) -{ - TracyZoneScoped; - - if (start.x == end.x && start.y == end.y && start.z == end.z) - { - return true; - } - - DebugNavmesh("CNavMesh::raycast (%f, %f, %f) -> (%f, %f, %f) (%u)", start.x, start.y, start.z, end.x, end.y, end.z, m_zoneID); - - dtStatus status = 0; - - float spos[3]; - CNavMesh::ToDetourPos(&start, spos); - - float epos[3]; - CNavMesh::ToDetourPos(&end, epos); - - dtQueryFilter filter; - filter.setIncludeFlags(INCLUDE_FLAGS); - filter.setExcludeFlags(EXCLUDE_FLAGS); - - // Since detour's raycasting ignores the y component of your search, it is possible to - // incorrectly raycast between multiple floors. This leads to mobs being able to aggro - // you from above/below and then wallhack their way to you. To get around this, we're - // going to query in a small column for polys above and below and then test against - // the results. - if (!onSameFloor(start, spos, end, epos, filter)) - { - return false; - } - - dtPolyRef startRef = 0; - float snearest[3]; - - status = m_navMeshQuery.findNearestPoly(spos, polyPickExt, &filter, &startRef, snearest); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::raycast start point invalid (%f, %f, %f) (%u)", spos[0], spos[1], spos[2], m_zoneID); - ShowError(detourStatusString(status)); - return true; - } - - if (!m_navMesh->isValidPolyRef(startRef)) - { - DebugNavmesh("CNavMesh::raycast startRef is invalid (%f, %f, %f) (%u)", start.x, start.y, start.z, m_zoneID); - return true; - } - - dtPolyRef endRef = 0; - float enearest[3]; - - status = m_navMeshQuery.findNearestPoly(epos, polyPickExt, &filter, &endRef, enearest); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::raycast end point invalid (%f, %f, %f) (%u)", epos[0], epos[1], epos[2], m_zoneID); - ShowError(detourStatusString(status)); - return true; - } - - if (!m_navMesh->isValidPolyRef(endRef)) - { - DebugNavmesh("CNavMesh::raycast endRef is invalid (%f, %f, %f) (%u)", end.x, end.y, end.z, m_zoneID); - return true; - } - - float distanceToWall = 0.0f; - float hitPos[3]; - float hitNormal[3]; - - status = m_navMeshQuery.findDistanceToWall(endRef, enearest, 5.0f, &filter, &distanceToWall, hitPos, hitNormal); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::raycast findDistanceToWall failed (%f, %f, %f) (%u)", epos[0], epos[1], epos[2], m_zoneID); - ShowError(detourStatusString(status)); - return true; - } - - // There is a tiny strip of walkable map at the very edge of walls that - // a player can use, but is not part of the navmesh. For a point to be - // raycasted to - it needs to be on the navmesh. This will check to - // see if the player is "off-mesh" and raycast to the nearest "on-mesh" - // point instead. distanceToWall will be 0.0f if the player is "off-mesh". - if (distanceToWall < 0.01f) - { - // Overwrite epos with closest valid point - status = m_navMeshQuery.closestPointOnPolyBoundary(endRef, epos, epos); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::raycast closestPointOnPolyBoundary failed (%u)", m_zoneID); - ShowError(detourStatusString(status)); - return true; - } - } - - status = m_navMeshQuery.raycast(startRef, spos, epos, &filter, 0, &m_raycastHit); - - if (dtStatusFailed(status)) - { - ShowError("CNavMesh::raycast raycast failed (%f, %f, %f)->(%f, %f, %f) (%u)", spos[0], spos[1], spos[2], epos[0], epos[1], epos[2], m_zoneID); - ShowError(detourStatusString(status)); - return true; - } - - // no wall was hit - return m_raycastHit.t == FLT_MAX; -} diff --git a/src/map/navmesh/navmesh.h b/src/map/navmesh/navmesh.h index a992c344f96..21efdacd2b8 100644 --- a/src/map/navmesh/navmesh.h +++ b/src/map/navmesh/navmesh.h @@ -1,7 +1,7 @@ /* =========================================================================== - Copyright (c) 2010-2015 Darkstar Dev Teams + Copyright (c) 2026 LandSandBoat Dev Teams This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -21,61 +21,31 @@ #pragma once -#include -#include +#include "common/mmo.h" -#include "common/logging.h" -#include "inavmesh.h" - -#include -#include +#include +#include #include -class CNavMesh final : public INavMesh +// A single findPath() can exhaust the finite poly buffer before reaching a far destination, in +// which case Detour returns a best-effort path that stops short of `end`. isPartial flags this so +// callers (CPathFind) can chunk: walk the partial path, then re-request from the new position. +struct PathResult { -public: - static void ToFFXIPos(const position_t* pos, float* out); - static void ToFFXIPos(float* out); - static void ToFFXIPos(position_t* out); - static void ToDetourPos(const position_t* pos, float* out); - static void ToDetourPos(float* out); - static void ToDetourPos(position_t* out); + std::vector points; + bool isPartial{ false }; +}; +class NavMesh +{ public: - explicit CNavMesh(uint16 zoneID); - ~CNavMesh() override; - - DISALLOW_COPY_AND_MOVE(CNavMesh); - - bool load(const std::string& path); - bool installNavMesh(dtNavMesh* newNavMesh); - bool save(const std::string& path) const; - void unload(); - - // INavMesh - auto findPath(const position_t& start, const position_t& end) -> std::vector override; - auto findRandomPosition(const position_t& start, float maxRadius) -> std::pair override; - auto raycast(const position_t& start, const position_t& end) -> bool override; - auto validPosition(const position_t& position) -> bool override; - auto findClosestValidPoint(const position_t& position, float* validPoint) -> bool override; - auto findFurthestValidPoint(const position_t& startPosition, const position_t& endPosition, float* validPoint) -> bool override; - void snapToValidPosition(position_t& position) override; - - [[nodiscard]] static auto detourStatusString(const uint32 status) -> std::string; - -private: - bool onSameFloor(const position_t& start, float* spos, const position_t& end, float* epos, dtQueryFilter& filter); - - std::string m_filename; - uint16 m_zoneID; - dtNavMesh* m_navMesh; - dtNavMeshQuery m_navMeshQuery; - - std::vector m_navMeshQueryPolyData; - std::vector m_navMeshQueryStraightPathFloatData; - std::vector m_navMeshQueryStraightPathFlagData; - std::vector m_navMeshQueryStraightPathPolyData; - - std::vector m_navMeshQueryRaycastHitPath; - dtRaycastHit m_raycastHit; + virtual ~NavMesh() = default; + + virtual auto findPath(const position_t& start, const position_t& end) -> Maybe = 0; + virtual auto findRandomPosition(const position_t& start, float maxRadius) const -> Maybe = 0; + virtual auto validPosition(const position_t& position) const -> bool = 0; + virtual auto findClosestValidPoint(const position_t& position) const -> Maybe = 0; + virtual auto findFurthestValidPoint(const position_t& startPosition, const position_t& endPosition) const -> Maybe = 0; + virtual auto snapToValidPosition(position_t& position) const -> void = 0; + virtual auto moveAlongSurface(const position_t& start, const position_t& end, position_t& result) const -> bool = 0; }; diff --git a/src/map/navmesh/navmesh_builder.cpp b/src/map/navmesh/navmesh_builder.cpp index 70db7944a2f..cbf0a106fe8 100644 --- a/src/map/navmesh/navmesh_builder.cpp +++ b/src/map/navmesh/navmesh_builder.cpp @@ -24,7 +24,7 @@ #include "common/logging.h" #include "common/timer.h" -#include +#include #include #include @@ -61,7 +61,7 @@ auto transform(const std::array& rot, const std::array& tran } // namespace -NavMeshBuilder::NavMeshBuilder(const IXiMesh& xiMesh) +NavMeshBuilder::NavMeshBuilder(const XiMesh& xiMesh) : xiMesh_(&xiMesh) , gridWidth_(xiMesh.gridWidth()) , gridHeight_(xiMesh.gridHeight()) diff --git a/src/map/navmesh/navmesh_builder.h b/src/map/navmesh/navmesh_builder.h index d3d33e122d5..d252d74f9b1 100644 --- a/src/map/navmesh/navmesh_builder.h +++ b/src/map/navmesh/navmesh_builder.h @@ -32,7 +32,7 @@ struct rcConfig; -class IXiMesh; +class XiMesh; class dtNavMesh; constexpr auto FloatMax = std::numeric_limits::max(); @@ -68,7 +68,7 @@ struct TileResult class NavMeshBuilder { public: - explicit NavMeshBuilder(const IXiMesh& xiMesh); + explicit NavMeshBuilder(const XiMesh& xiMesh); void getWorldBounds(float* bmin, float* bmax) const; void gatherTrianglesInAABB(const float* bmin, const float* bmax, GatheredMesh& out) const; @@ -84,11 +84,11 @@ class NavMeshBuilder bool flipWinding{}; }; - const IXiMesh* xiMesh_{}; - uint16 gridWidth_{}; - uint16 gridHeight_{}; - float worldBmin_[3]{ FloatMax, FloatMax, FloatMax }; - float worldBmax_[3]{ FloatLowest, FloatLowest, FloatLowest }; + const XiMesh* xiMesh_{}; + uint16 gridWidth_{}; + uint16 gridHeight_{}; + float worldBmin_[3]{ FloatMax, FloatMax, FloatMax }; + float worldBmax_[3]{ FloatLowest, FloatLowest, FloatLowest }; std::unordered_map preTransformed_; }; diff --git a/src/map/navmesh/null_navmesh.cpp b/src/map/navmesh/null_navmesh.cpp new file mode 100644 index 00000000000..a32b6616511 --- /dev/null +++ b/src/map/navmesh/null_navmesh.cpp @@ -0,0 +1,57 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include "null_navmesh.h" + +auto NullNavMesh::findPath(const position_t&, const position_t&) -> Maybe +{ + return std::nullopt; +} + +auto NullNavMesh::findRandomPosition(const position_t& start, float) const -> Maybe +{ + return start; +} + +auto NullNavMesh::validPosition(const position_t&) const -> bool +{ + return true; +} + +auto NullNavMesh::findClosestValidPoint(const position_t&) const -> Maybe +{ + return std::nullopt; +} + +auto NullNavMesh::findFurthestValidPoint(const position_t&, const position_t&) const -> Maybe +{ + return std::nullopt; +} + +auto NullNavMesh::snapToValidPosition(position_t&) const -> void +{ + // NOOP +} + +auto NullNavMesh::moveAlongSurface(const position_t&, const position_t&, position_t&) const -> bool +{ + return false; // no mesh - caller free-moves +} diff --git a/src/map/navmesh/null_navmesh.h b/src/map/navmesh/null_navmesh.h new file mode 100644 index 00000000000..7ef467d18a4 --- /dev/null +++ b/src/map/navmesh/null_navmesh.h @@ -0,0 +1,38 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include "navmesh.h" + +// Null-object NavMesh for zones with no navmesh loaded: pathing fails gracefully and the caller +// free-moves. +class NullNavMesh final : public NavMesh +{ +public: + auto findPath(const position_t& start, const position_t& end) -> Maybe override; + auto findRandomPosition(const position_t& start, float maxRadius) const -> Maybe override; + auto validPosition(const position_t& position) const -> bool override; + auto findClosestValidPoint(const position_t& position) const -> Maybe override; + auto findFurthestValidPoint(const position_t& startPosition, const position_t& endPosition) const -> Maybe override; + auto snapToValidPosition(position_t& position) const -> void override; + auto moveAlongSurface(const position_t& start, const position_t& end, position_t& result) const -> bool override; +}; diff --git a/src/map/packet_system.cpp b/src/map/packet_system.cpp index f11ad72a7bb..471b5d10942 100644 --- a/src/map/packet_system.cpp +++ b/src/map/packet_system.cpp @@ -217,7 +217,7 @@ void ValidatedPacketHandler(MapSession* const PSession, CCharEntity* const PChar } else { - ShowWarningFmt("Invalid {} packet from {}: {} ", T::name, PChar->getName(), result.errorString()); + ShowWarningFmt("Invalid {} packet from {}: {}", T::name, PChar->getName(), result.errorString()); } } diff --git a/src/map/packets/s2c/0x048_link_concierge_header.h b/src/map/packets/s2c/0x048_link_concierge_header.h index c0d386572a6..53022dbffb4 100644 --- a/src/map/packets/s2c/0x048_link_concierge_header.h +++ b/src/map/packets/s2c/0x048_link_concierge_header.h @@ -40,7 +40,7 @@ class HEADER final : public GP_SERV_PACKET -#include - -using IgnoreTransparentBarriers = xi::Flag; - -class IXiMesh -{ -public: - virtual ~IXiMesh() = default; - - virtual auto query(float x, float y, float z) const -> std::optional = 0; - virtual auto getTerrainAt(float x, float y, float z) const -> TerrainType = 0; - virtual auto getFloorId(float x, float y, float z) const -> uint8 = 0; - virtual auto rayIntersect(const Vector3& start, const Vector3& end, IgnoreTransparentBarriers IgnoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> bool = 0; - virtual auto getPositionInfo(const Vector3& position, YOffsets yOffsets, IgnoreTransparentBarriers IgnoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> std::optional = 0; - - virtual auto blocks() const -> const std::vector& = 0; - virtual auto placements() const -> const std::vector& = 0; - virtual auto entries() const -> const std::vector& = 0; - virtual auto cells() const -> const std::vector& = 0; - virtual auto gridWidth() const -> uint16 = 0; - virtual auto gridHeight() const -> uint16 = 0; -}; - -class NullXiMesh final : public IXiMesh -{ -public: - auto query(float, float, float) const -> std::optional override - { - return std::nullopt; - } - - auto getTerrainAt(float, float, float) const -> TerrainType override - { - return TerrainType::None; - } - - auto getFloorId(float, float, float) const -> uint8 override - { - return 0; - } - - auto rayIntersect(const Vector3&, const Vector3&, IgnoreTransparentBarriers) const -> bool override - { - return false; - } - - auto getPositionInfo(const Vector3&, YOffsets, IgnoreTransparentBarriers) const -> std::optional override - { - return std::nullopt; - } - - auto blocks() const -> const std::vector& override - { - static const std::vector empty; - return empty; - } - - auto placements() const -> const std::vector& override - { - static const std::vector empty; - return empty; - } - - auto entries() const -> const std::vector& override - { - static const std::vector empty; - return empty; - } - - auto cells() const -> const std::vector& override - { - static const std::vector empty; - return empty; - } - - auto gridWidth() const -> uint16 override - { - return 0; - } - - auto gridHeight() const -> uint16 override - { - return 0; - } -}; diff --git a/src/map/ximesh/null_ximesh.cpp b/src/map/ximesh/null_ximesh.cpp new file mode 100644 index 00000000000..ec25d26dc7f --- /dev/null +++ b/src/map/ximesh/null_ximesh.cpp @@ -0,0 +1,81 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#include "null_ximesh.h" + +auto NullXiMesh::query(float, float, float) const -> Maybe +{ + return std::nullopt; +} + +auto NullXiMesh::getTerrainAt(float, float, float) const -> TerrainType +{ + return TerrainType::None; +} + +auto NullXiMesh::getFloorId(float, float, float) const -> uint8 +{ + return 0; +} + +auto NullXiMesh::rayIntersect(const Vector3&, const Vector3&, IgnoreTransparentBarriers) const -> bool +{ + return false; +} + +auto NullXiMesh::getPositionInfo(const Vector3&, YOffsets, IgnoreTransparentBarriers) const -> Maybe +{ + return std::nullopt; +} + +auto NullXiMesh::blocks() const -> const std::vector& +{ + static const std::vector empty; + return empty; +} + +auto NullXiMesh::placements() const -> const std::vector& +{ + static const std::vector empty; + return empty; +} + +auto NullXiMesh::entries() const -> const std::vector& +{ + static const std::vector empty; + return empty; +} + +auto NullXiMesh::cells() const -> const std::vector& +{ + static const std::vector empty; + return empty; +} + +auto NullXiMesh::gridWidth() const -> uint16 +{ + return 0; +} + +auto NullXiMesh::gridHeight() const -> uint16 +{ + return 0; +} diff --git a/src/map/ximesh/null_ximesh.h b/src/map/ximesh/null_ximesh.h new file mode 100644 index 00000000000..2c68e2615cb --- /dev/null +++ b/src/map/ximesh/null_ximesh.h @@ -0,0 +1,42 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include "map/ximesh/ximesh.h" + +// Null-object XiMesh for zones with no ximesh loaded: queries return empty/none. +class NullXiMesh final : public XiMesh +{ +public: + auto query(float x, float y, float z) const -> Maybe override; + auto getTerrainAt(float x, float y, float z) const -> TerrainType override; + auto getFloorId(float x, float y, float z) const -> uint8 override; + auto rayIntersect(const Vector3& start, const Vector3& end, IgnoreTransparentBarriers ignoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> bool override; + auto getPositionInfo(const Vector3& position, YOffsets yOffsets, IgnoreTransparentBarriers ignoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> Maybe override; + + auto blocks() const -> const std::vector& override; + auto placements() const -> const std::vector& override; + auto entries() const -> const std::vector& override; + auto cells() const -> const std::vector& override; + auto gridWidth() const -> uint16 override; + auto gridHeight() const -> uint16 override; +}; diff --git a/src/map/ximesh/ximesh.h b/src/map/ximesh/ximesh.h index 2cd5d9cffb5..37b37dc1c6d 100644 --- a/src/map/ximesh/ximesh.h +++ b/src/map/ximesh/ximesh.h @@ -21,49 +21,29 @@ #pragma once -#include "map/ximesh/iximesh.h" -#include "map/ximesh/transformation_matrix.h" +#include "common/cbasetypes.h" +#include "map/ximesh/ximesh_structs.h" -#include +#include +#include -class XiMesh final : public IXiMesh +using IgnoreTransparentBarriers = xi::Flag; + +class XiMesh { public: - explicit XiMesh(const std::string& filename); - ~XiMesh() override = default; - - DISALLOW_COPY_AND_MOVE(XiMesh); - - auto query(float x, float y, float z) const -> std::optional override; - auto getTerrainAt(float x, float y, float z) const -> TerrainType override; - auto getFloorId(float x, float y, float z) const -> uint8 override; - - auto rayIntersect(const Vector3& start, const Vector3& end, IgnoreTransparentBarriers ignoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> bool override; - auto getPositionInfo(const Vector3& position, YOffsets yOffsets, IgnoreTransparentBarriers ignoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> std::optional override; - - auto blocks() const -> const std::vector& override; - auto placements() const -> const std::vector& override; - auto entries() const -> const std::vector& override; - auto cells() const -> const std::vector& override; - auto gridWidth() const -> uint16 override; - auto gridHeight() const -> uint16 override; - -private: - auto load(const std::string& filename) -> bool; - - auto worldToCell(float x, float z) const -> std::pair; - - auto rayIntersectCell(const Vector3& start, const Vector3& end, YRange yRange, uint32 cellIdx, IgnoreTransparentBarriers ignoreTransparentBarriers) const -> bool; - auto rayIntersectCellHitInfo(const Vector3& start, const Vector3& end, YRange yRange, uint32 cellIdx, IgnoreTransparentBarriers ignoreTransparentBarriers, std::optional& closestHit) const -> void; - - XimeshHeader header_{}; - - std::vector blocks_; - std::vector placements_; - std::vector entries_; - std::vector cells_; - - std::vector w2os_; - std::vector placementFlips_; - std::vector cellRanges_; + virtual ~XiMesh() = default; + + virtual auto query(float x, float y, float z) const -> Maybe = 0; + virtual auto getTerrainAt(float x, float y, float z) const -> TerrainType = 0; + virtual auto getFloorId(float x, float y, float z) const -> uint8 = 0; + virtual auto rayIntersect(const Vector3& start, const Vector3& end, IgnoreTransparentBarriers ignoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> bool = 0; + virtual auto getPositionInfo(const Vector3& position, YOffsets yOffsets, IgnoreTransparentBarriers ignoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> Maybe = 0; + + virtual auto blocks() const -> const std::vector& = 0; + virtual auto placements() const -> const std::vector& = 0; + virtual auto entries() const -> const std::vector& = 0; + virtual auto cells() const -> const std::vector& = 0; + virtual auto gridWidth() const -> uint16 = 0; + virtual auto gridHeight() const -> uint16 = 0; }; diff --git a/src/map/ximesh/ximesh.cpp b/src/map/ximesh/ximesh_impl.cpp similarity index 91% rename from src/map/ximesh/ximesh.cpp rename to src/map/ximesh/ximesh_impl.cpp index eb8fd1ae691..f9551a9aaf0 100644 --- a/src/map/ximesh/ximesh.cpp +++ b/src/map/ximesh/ximesh_impl.cpp @@ -19,7 +19,7 @@ =========================================================================== */ -#include "ximesh.h" +#include "ximesh_impl.h" #include #include @@ -132,8 +132,8 @@ auto vertexAt(const MeshBlock& block, const uint16 vertexIdx) -> Vector3 return Vector3{ v[0], v[1], v[2] }; } -// Möller–Trumbore ray/triangle intersection. -auto rayIntersectTriangle(const Vector3& v1, const Vector3& v2, const Vector3& v3, const Vector3& rayOrigin, const Vector3& rayVector) -> std::optional +// Moller-Trumbore ray/triangle intersection. +auto rayIntersectTriangle(const Vector3& v1, const Vector3& v2, const Vector3& v3, const Vector3& rayOrigin, const Vector3& rayVector) -> Maybe { constexpr float EPSILON = 0.0000001f; @@ -176,7 +176,7 @@ auto rayIntersectTriangle(const Vector3& v1, const Vector3& v2, const Vector3& v } // namespace -XiMesh::XiMesh(const std::string& filename) +XiMeshImpl::XiMeshImpl(const std::string& filename) { if (!load(filename)) { @@ -184,7 +184,7 @@ XiMesh::XiMesh(const std::string& filename) } } -auto XiMesh::load(const std::string& filename) -> bool +auto XiMeshImpl::load(const std::string& filename) -> bool { TracyZoneScoped; @@ -202,7 +202,7 @@ auto XiMesh::load(const std::string& filename) -> bool file.read(reinterpret_cast(rawData.data()), fileSize); if (file.fail()) { - ShowErrorFmt("XiMesh::load: Failed to read file ({})", filename); + ShowErrorFmt("XiMeshImpl::load: Failed to read file ({})", filename); return false; } @@ -210,7 +210,7 @@ auto XiMesh::load(const std::string& filename) -> bool const auto decompressed = zlibDecompress(rawData); if (decompressed.empty()) { - ShowErrorFmt("XiMesh::load: zlib decompression failed ({})", filename); + ShowErrorFmt("XiMeshImpl::load: zlib decompression failed ({})", filename); return false; } @@ -219,14 +219,14 @@ auto XiMesh::load(const std::string& filename) -> bool // Step 3. Load in the header containing the size of the grid and prepare final vector if (buf.size() < sizeof(XimeshHeader)) { - ShowErrorFmt("XiMesh::load: File too small ({})", filename); + ShowErrorFmt("XiMeshImpl::load: File too small ({})", filename); return false; } std::memcpy(&header_, buf.data(), sizeof(XimeshHeader)); if (header_.gridWidth == 0 || header_.gridHeight == 0) { - ShowErrorFmt("XiMesh::load: Invalid grid {}x{} ({})", header_.gridWidth, header_.gridHeight, filename); + ShowErrorFmt("XiMeshImpl::load: Invalid grid {}x{} ({})", header_.gridWidth, header_.gridHeight, filename); return false; } @@ -250,7 +250,7 @@ auto XiMesh::load(const std::string& filename) -> bool std::unordered_map blockCache; std::unordered_map placeCache; - auto getOrParseBlock = [&](const uint32 fileOffset) -> std::optional + auto getOrParseBlock = [&](const uint32 fileOffset) -> Maybe { auto [it, inserted] = blockCache.try_emplace(fileOffset, static_cast(blocks_.size())); if (!inserted) @@ -277,7 +277,7 @@ auto XiMesh::load(const std::string& filename) -> bool indexOffset + indexBytes > buf.size() || metaOffset + metaBytes > buf.size()) { - ShowErrorFmt("XiMesh: Block OOB at offset 0x{:X} (bufSize={})", fileOffset, buf.size()); + ShowErrorFmt("XiMeshImpl: Block OOB at offset 0x{:X} (bufSize={})", fileOffset, buf.size()); return std::nullopt; } @@ -294,7 +294,7 @@ auto XiMesh::load(const std::string& filename) -> bool return it->second; }; - auto getOrParsePlacement = [&](const uint32 fileOffset) -> std::optional + auto getOrParsePlacement = [&](const uint32 fileOffset) -> Maybe { auto [it, inserted] = placeCache.try_emplace(fileOffset, static_cast(placements_.size())); if (!inserted) @@ -311,7 +311,7 @@ auto XiMesh::load(const std::string& filename) -> bool constexpr size_t TRANSFORM_BYTES = sizeof(placement.rotation) + sizeof(placement.translation); if (fileOffset + 4 + TRANSFORM_BYTES > buf.size()) { - ShowErrorFmt("XiMesh: Placement OOB at offset 0x{:X} (bufSize={})", fileOffset, buf.size()); + ShowErrorFmt("XiMeshImpl: Placement OOB at offset 0x{:X} (bufSize={})", fileOffset, buf.size()); return std::nullopt; } @@ -348,7 +348,7 @@ auto XiMesh::load(const std::string& filename) -> bool const auto placementIdx = getOrParsePlacement(rawEntry.placementOffset); if (!blockIdx || !placementIdx) { - ShowErrorFmt("XiMesh::load: Corrupt block/placement data ({})", filename); + ShowErrorFmt("XiMeshImpl::load: Corrupt block/placement data ({})", filename); return false; } @@ -406,7 +406,7 @@ auto XiMesh::load(const std::string& filename) -> bool } // World position to cell grid index. Each cell covers 4x4 world units. -auto XiMesh::worldToCell(const float x, const float z) const -> std::pair +auto XiMeshImpl::worldToCell(const float x, const float z) const -> std::pair { return { static_cast(std::floor(x / CELL_SIZE)) + header_.gridWidth / 2, @@ -415,14 +415,14 @@ auto XiMesh::worldToCell(const float x, const float z) const -> std::pair std::optional +auto XiMeshImpl::query(const float x, const float y, const float z) const -> Maybe { TracyZoneScoped; const auto [cx, cz] = worldToCell(x, z); const std::array point = { x, 0.0f, z }; - auto searchCell = [&](const int cellX, const int cellZ) -> std::optional + auto searchCell = [&](const int cellX, const int cellZ) -> Maybe { if (cellX < 0 || cellX >= header_.gridWidth || cellZ < 0 || cellZ >= header_.gridHeight) { @@ -435,7 +435,7 @@ auto XiMesh::query(const float x, const float y, const float z) const -> std::op return std::nullopt; } - std::optional best; + Maybe best; for (uint16 ref = 0; ref < cell.count; ++ref) { constexpr float EPSILON = 0.01f; @@ -490,7 +490,7 @@ auto XiMesh::query(const float x, const float y, const float z) const -> std::op } // Miss, check neighbors - std::optional best; + Maybe best; for (int dz = -1; dz <= 1; ++dz) { for (int dx = -1; dx <= 1; ++dx) @@ -513,7 +513,7 @@ auto XiMesh::query(const float x, const float y, const float z) const -> std::op return best; } -auto XiMesh::getTerrainAt(const float x, const float y, const float z) const -> TerrainType +auto XiMeshImpl::getTerrainAt(const float x, const float y, const float z) const -> TerrainType { TracyZoneScoped; @@ -525,7 +525,7 @@ auto XiMesh::getTerrainAt(const float x, const float y, const float z) const -> return TerrainType::None; } -auto XiMesh::getFloorId(const float x, const float y, const float z) const -> uint8 +auto XiMeshImpl::getFloorId(const float x, const float y, const float z) const -> uint8 { TracyZoneScoped; @@ -537,37 +537,37 @@ auto XiMesh::getFloorId(const float x, const float y, const float z) const -> ui return 0; } -auto XiMesh::blocks() const -> const std::vector& +auto XiMeshImpl::blocks() const -> const std::vector& { return blocks_; } -auto XiMesh::placements() const -> const std::vector& +auto XiMeshImpl::placements() const -> const std::vector& { return placements_; } -auto XiMesh::entries() const -> const std::vector& +auto XiMeshImpl::entries() const -> const std::vector& { return entries_; } -auto XiMesh::cells() const -> const std::vector& +auto XiMeshImpl::cells() const -> const std::vector& { return cells_; } -auto XiMesh::gridWidth() const -> uint16 +auto XiMeshImpl::gridWidth() const -> uint16 { return header_.gridWidth; } -auto XiMesh::gridHeight() const -> uint16 +auto XiMeshImpl::gridHeight() const -> uint16 { return header_.gridHeight; } -auto XiMesh::rayIntersect(const Vector3& start, const Vector3& end, const IgnoreTransparentBarriers ignoreTransparentBarriers) const -> bool +auto XiMeshImpl::rayIntersect(const Vector3& start, const Vector3& end, const IgnoreTransparentBarriers ignoreTransparentBarriers) const -> bool { TracyZoneScoped; @@ -694,7 +694,7 @@ auto XiMesh::rayIntersect(const Vector3& start, const Vector3& end, const Ignore return false; } -auto XiMesh::getPositionInfo(const Vector3& position, const YOffsets yOffsets, const IgnoreTransparentBarriers ignoreTransparentBarriers) const -> std::optional +auto XiMeshImpl::getPositionInfo(const Vector3& position, const YOffsets yOffsets, const IgnoreTransparentBarriers ignoreTransparentBarriers) const -> Maybe { TracyZoneScoped; @@ -712,7 +712,7 @@ auto XiMesh::getPositionInfo(const Vector3& position, const YOffsets yOffsets, c const auto col1 = std::max(0, col - cellSearchDiff); const auto col2 = std::min(header_.gridWidth - 1, col + cellSearchDiff); - std::optional closestHit; + Maybe closestHit; for (int32 r = row1; r <= row2; ++r) { @@ -726,7 +726,7 @@ auto XiMesh::getPositionInfo(const Vector3& position, const YOffsets yOffsets, c return closestHit; } -auto XiMesh::rayIntersectCell(const Vector3& start, const Vector3& end, const YRange yRange, const uint32 cellIdx, const IgnoreTransparentBarriers ignoreTransparentBarriers) const -> bool +auto XiMeshImpl::rayIntersectCell(const Vector3& start, const Vector3& end, const YRange yRange, const uint32 cellIdx, const IgnoreTransparentBarriers ignoreTransparentBarriers) const -> bool { if (cellIdx >= cells_.size()) { @@ -803,7 +803,7 @@ auto XiMesh::rayIntersectCell(const Vector3& start, const Vector3& end, const YR return false; } -auto XiMesh::rayIntersectCellHitInfo(const Vector3& start, const Vector3& end, const YRange yRange, const uint32 cellIdx, const IgnoreTransparentBarriers ignoreTransparentBarriers, std::optional& closestHit) const -> void +auto XiMeshImpl::rayIntersectCellHitInfo(const Vector3& start, const Vector3& end, const YRange yRange, const uint32 cellIdx, const IgnoreTransparentBarriers ignoreTransparentBarriers, Maybe& closestHit) const -> void { if (cellIdx >= cells_.size()) { diff --git a/src/map/ximesh/ximesh_impl.h b/src/map/ximesh/ximesh_impl.h new file mode 100644 index 00000000000..39fb3edb0e7 --- /dev/null +++ b/src/map/ximesh/ximesh_impl.h @@ -0,0 +1,69 @@ +/* +=========================================================================== + + Copyright (c) 2026 LandSandBoat Dev Teams + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/ + +=========================================================================== +*/ + +#pragma once + +#include "map/ximesh/transformation_matrix.h" +#include "map/ximesh/ximesh.h" + +#include + +class XiMeshImpl final : public XiMesh +{ +public: + explicit XiMeshImpl(const std::string& filename); + ~XiMeshImpl() override = default; + + DISALLOW_COPY_AND_MOVE(XiMeshImpl); + + auto query(float x, float y, float z) const -> Maybe override; + auto getTerrainAt(float x, float y, float z) const -> TerrainType override; + auto getFloorId(float x, float y, float z) const -> uint8 override; + + auto rayIntersect(const Vector3& start, const Vector3& end, IgnoreTransparentBarriers ignoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> bool override; + auto getPositionInfo(const Vector3& position, YOffsets yOffsets, IgnoreTransparentBarriers ignoreTransparentBarriers = IgnoreTransparentBarriers::Yes) const -> Maybe override; + + auto blocks() const -> const std::vector& override; + auto placements() const -> const std::vector& override; + auto entries() const -> const std::vector& override; + auto cells() const -> const std::vector& override; + auto gridWidth() const -> uint16 override; + auto gridHeight() const -> uint16 override; + +private: + auto load(const std::string& filename) -> bool; + + auto worldToCell(float x, float z) const -> std::pair; + + auto rayIntersectCell(const Vector3& start, const Vector3& end, YRange yRange, uint32 cellIdx, IgnoreTransparentBarriers ignoreTransparentBarriers) const -> bool; + auto rayIntersectCellHitInfo(const Vector3& start, const Vector3& end, YRange yRange, uint32 cellIdx, IgnoreTransparentBarriers ignoreTransparentBarriers, Maybe& closestHit) const -> void; + + XimeshHeader header_{}; + + std::vector blocks_; + std::vector placements_; + std::vector entries_; + std::vector cells_; + + std::vector w2os_; + std::vector placementFlips_; + std::vector cellRanges_; +}; diff --git a/src/map/zone.cpp b/src/map/zone.cpp index cebe4f135a4..7df24b90534 100644 --- a/src/map/zone.cpp +++ b/src/map/zone.cpp @@ -46,8 +46,6 @@ constexpr std::uint16_t WeatherCycle = 2160; #include "enums/loot_recast.h" #include "ipc_client.h" #include "latent_effect_container.h" -#include "map/navmesh/navmesh.h" -#include "map/navmesh/navmesh_builder.h" #include "map_engine.h" #include "monstrosity.h" #include "nominate_manager.h" @@ -67,7 +65,13 @@ constexpr std::uint16_t WeatherCycle = 2160; #include "utils/charutils.h" #include "utils/moduleutils.h" +#include +#include +#include +#include +#include #include +#include CZone::CZone(Scheduler& scheduler, MapConfig config, ZONEID ZoneID, REGION_TYPE RegionID, CONTINENT_TYPE ContinentID, uint8 levelRestriction) : scheduler_(scheduler) @@ -474,7 +478,7 @@ void CZone::LoadZoneSettings() auto CZone::LoadNavMesh() -> Task { - auto navMesh = std::make_unique(static_cast(GetID())); + auto navMesh = std::make_unique(static_cast(GetID())); const auto file = fmt::format("navmeshes/{}.nav", getName()); if (!config_.rebuildNavmeshes && navMesh->load(file)) @@ -508,7 +512,7 @@ void CZone::RebuildNavMesh(const NavMeshConfig& config) NavMeshBuilder builder(*xiMeshPtr); auto* dtNavMesh = co_await builder.buildAsync(scheduler_, zoneName, zoneID, config); - auto navMesh = std::make_unique(zoneID); + auto navMesh = std::make_unique(zoneID); if (dtNavMesh && navMesh->installNavMesh(dtNavMesh)) { navMesh->save(fmt::format("navmeshes/{}.nav", zoneName)); @@ -517,12 +521,12 @@ void CZone::RebuildNavMesh(const NavMeshConfig& config) }); } -auto CZone::navMesh() const -> INavMesh* +auto CZone::navMesh() const -> NavMesh* { return navMesh_.get(); } -auto CZone::xiMesh() const -> IXiMesh* +auto CZone::xiMesh() const -> XiMesh* { return xiMesh_.get(); } @@ -577,7 +581,7 @@ void CZone::LoadXiMesh() { try { - xiMesh_ = std::make_unique(file); + xiMesh_ = std::make_unique(file); } catch (const std::exception& e) { diff --git a/src/map/zone.h b/src/map/zone.h index 2107381436f..cef101bf71a 100644 --- a/src/map/zone.h +++ b/src/map/zone.h @@ -31,14 +31,14 @@ #include "battlefield_handler.h" #include "campaign_handler.h" -#include "map/navmesh/inavmesh.h" -#include "map/navmesh/navmesh_config.h" #include "map_config.h" #include "packets/basic.h" #include "spawn_slot.h" #include "trigger_area.h" -#include +#include +#include +#include #include #include @@ -50,8 +50,6 @@ // enum class Weather : uint16_t; -class XiMesh; -class CNavMesh; class SpawnHandler; enum ZONEID : uint16 @@ -684,8 +682,8 @@ class CZone timer::time_point m_LoadedAt; // The time the zone was loaded - auto navMesh() const -> INavMesh*; - auto xiMesh() const -> IXiMesh*; + auto navMesh() const -> NavMesh*; + auto xiMesh() const -> XiMesh*; auto LoadNavMesh() -> Task; void RebuildNavMesh(const NavMeshConfig& config = {}); @@ -709,8 +707,8 @@ class CZone std::unordered_map localVars_; private: - std::unique_ptr navMesh_; - std::unique_ptr xiMesh_; + std::unique_ptr navMesh_; + std::unique_ptr xiMesh_; ZONEID m_zoneID; ZONE_TYPE m_zoneType;