From 18c64b4388340dfaaa25c8941090b19e6b6bf640 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Sun, 26 Jul 2026 17:52:36 -0600 Subject: [PATCH] Bots intercept enemy ghost carrier path --- src/game/server/CMakeLists.txt | 2 + .../neo/bot/behavior/neo_bot_ctg_enemy.cpp | 104 ++++- .../neo/bot/behavior/neo_bot_ctg_enemy.h | 12 + .../neo_bot_ctg_enemy_intercept_cap_path.cpp | 423 ++++++++++++++++++ .../neo_bot_ctg_enemy_intercept_cap_path.h | 31 ++ .../neo/bot/behavior/neo_bot_ctg_seek.cpp | 54 +-- src/game/shared/neo/neo_gamerules.h | 3 + 7 files changed, 602 insertions(+), 27 deletions(-) create mode 100644 src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.cpp create mode 100644 src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.h diff --git a/src/game/server/CMakeLists.txt b/src/game/server/CMakeLists.txt index bc5a57683..71f998b91 100644 --- a/src/game/server/CMakeLists.txt +++ b/src/game/server/CMakeLists.txt @@ -1527,6 +1527,7 @@ set(UNITY_SOURCE_NEO_BOT neo/bot/behavior/neo_bot_ctg_capture.cpp neo/bot/behavior/neo_bot_ctg_carrier.cpp neo/bot/behavior/neo_bot_ctg_enemy.cpp + neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.cpp neo/bot/behavior/neo_bot_ctg_escort.cpp neo/bot/behavior/neo_bot_ctg_lone_wolf.cpp neo/bot/behavior/neo_bot_ctg_seek.cpp @@ -1587,6 +1588,7 @@ target_sources_grouped( neo/bot/behavior/neo_bot_ctg_capture.h neo/bot/behavior/neo_bot_ctg_carrier.h neo/bot/behavior/neo_bot_ctg_enemy.h + neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.h neo/bot/behavior/neo_bot_ctg_escort.h neo/bot/behavior/neo_bot_ctg_lone_wolf.h neo/bot/behavior/neo_bot_ctg_lone_wolf_ambush.cpp diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp index 9ad40bf0a..7d2ec7d68 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp @@ -1,22 +1,92 @@ #include "cbase.h" #include "neo_player.h" +#include "neo_gamerules.h" +#include "neo_ghost_cap_point.h" #include "bot/neo_bot.h" #include "bot/behavior/neo_bot_ctg_enemy.h" +#include "bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.h" #include "bot/behavior/neo_bot_attack.h" #include "bot/neo_bot_path_compute.h" -#include "neo_gamerules.h" +// --- pressure vs. block -------------------------------------------------------------------------- +// CNEOBotCtgSeek hands every enemy-carrier defender to CNEOBotCtgEnemy; the choice of whether to +// chase the carrier directly or peel off into CNEOBotCtgEnemyInterceptCapPath to hold a cut-off is +// made here, tick by tick, from observable state only -- no lookup of the carrier's real path. +// Routing every defender to interception pulls the last body off the carrier and, in testing, +// conceded more ghost captures rather than fewer, so the nearest few defenders always chase. +ConVar sv_neo_bot_ctg_pressure_slots( "sv_neo_bot_ctg_pressure_slots", "2", FCVAR_CHEAT, + "CTG defence: the N friendly defenders nearest the enemy ghost carrier chase it directly; the " + "rest peel off to hold a cut-off on its predicted path.", true, 0, true, 8 ); //--------------------------------------------------------------------------------------------- CNEOBotCtgEnemy::CNEOBotCtgEnemy( void ) { } +//--------------------------------------------------------------------------------------------- +// true => this bot should hold a cut-off (interception) rather than chase directly. +bool CNEOBotCtgEnemy::ShouldInterceptInsteadOfChase( CNEOBot *me, CNEO_Player *pGhostCarrier ) const +{ + CNavArea *pCarrierArea = pGhostCarrier->GetLastKnownArea(); + if ( !pCarrierArea ) + { + // No observable fix on the carrier -- just chase; do not go set up a cut-off off stale data. + return false; + } + + // Nothing to intercept toward unless some cap zone is actually enemy-reachable this round + // (owningTeamAlternate() flips on odd rounds). If none is, chasing is all there is. + const int myTeam = me->GetTeamNumber(); + bool bAnyEnemyCap = false; + for ( int i = 0; i < NEORules()->m_pGhostCaps.Count(); ++i ) + { + CNEOGhostCapturePoint *pCapPoint = dynamic_cast< CNEOGhostCapturePoint* >( UTIL_EntityByIndex( NEORules()->m_pGhostCaps[i] ) ); + // The GetActive() guard has to match the interception behaviour's own cap scan. If the only + // enemy zone is disabled, that behaviour finds nothing to cut off and hands straight back, + // so peeling off to it here would churn between chase and interception every few seconds. + if ( pCapPoint && pCapPoint->GetActive() && pCapPoint->owningTeamAlternate() != myTeam ) + { + bAnyEnemyCap = true; + break; + } + } + if ( !bAnyEnemyCap ) + { + return false; + } + + // Rank this bot among living friendly defenders by distance to the carrier's last-known spot. + // The nearest sv_neo_bot_ctg_pressure_slots chase; everyone farther peels off to a cut-off. + // Tactically sound (near bodies pursue, rear bodies cut off) and stable frame to frame. + const Vector vecCarrier = pCarrierArea->GetCenter(); + const float myDistSq = me->GetAbsOrigin().DistToSqr( vecCarrier ); + int iNearerTeammates = 0; + for ( int i = 1; i <= gpGlobals->maxClients; ++i ) + { + CNEO_Player *pTeammate = ToNEOPlayer( UTIL_PlayerByIndex( i ) ); + if ( !pTeammate || pTeammate == me || !pTeammate->IsAlive() || pTeammate->GetTeamNumber() != myTeam ) + { + continue; + } + if ( pTeammate->GetAbsOrigin().DistToSqr( vecCarrier ) < myDistSq ) + { + ++iNearerTeammates; + } + } + + return iNearerTeammates >= sv_neo_bot_ctg_pressure_slots.GetInt(); +} + //--------------------------------------------------------------------------------------------- ActionResult< CNEOBot > CNEOBotCtgEnemy::OnStart( CNEOBot *me, Action< CNEOBot > *priorAction ) { m_chasePath.SetMinLookAheadDistance( me->GetDesiredPathLookAheadRange() ); + // Give positions a moment to settle before the first chase-vs-intercept decision, so the + // "nearest N" ranking is meaningful rather than whatever the spawn order happened to be. + m_roleReviewTimer.Start( 0.5f ); + m_forcePursueTimer.Invalidate(); + return Continue(); } @@ -39,6 +109,27 @@ ActionResult< CNEOBot > CNEOBotCtgEnemy::Update( CNEOBot *me, float interval ) return Done( "Ghost carrier is friendly" ); } + // Chase-vs-intercept: once the initial settle delay has passed, and unless we are inside the + // post-interception "keep chasing" window, a bot that is not one of the nearest defenders peels + // off to hold a cut-off. Going the other way (intercept -> chase) is driven by the interception + // behaviour handing control back on contact / timeout, handled in OnResume. + // + // This sits ahead of the threat check below because interception is a repositioning job, not a + // disengagement: a bot that peels off still shoots at whatever it can see, since + // CNEOBotMainAction::Update calls FireWeaponAtEnemy() every tick whatever behaviour is running. + const bool bForcedPursue = m_forcePursueTimer.HasStarted() && !m_forcePursueTimer.IsElapsed(); + if ( m_roleReviewTimer.IsElapsed() && !bForcedPursue ) + { + // Re-decide a few times a second rather than every tick. The ranking below walks every + // player and every capture zone, and the answer cannot meaningfully change tick to tick. + m_roleReviewTimer.Start( 0.5f ); + + if ( ShouldInterceptInsteadOfChase( me, pGhostCarrier ) ) + { + return SuspendFor( new CNEOBotCtgEnemyInterceptCapPath, "Repositioning to cut off the carrier" ); + } + } + const CKnownEntity *threat = me->GetVisionInterface()->GetPrimaryKnownThreat(true); if ( threat && !threat->IsObsolete() && me->GetIntentionInterface()->ShouldAttack( me, threat ) ) { @@ -47,7 +138,7 @@ ActionResult< CNEOBot > CNEOBotCtgEnemy::Update( CNEOBot *me, float interval ) // Investigate the ghost carrier's position CNEOBotPathUpdateChase( me, m_chasePath, pGhostCarrier, DEFAULT_ROUTE ); - + return Continue(); } @@ -56,6 +147,15 @@ ActionResult< CNEOBot > CNEOBotCtgEnemy::Update( CNEOBot *me, float interval ) //--------------------------------------------------------------------------------------------- ActionResult< CNEOBot > CNEOBotCtgEnemy::OnResume( CNEOBot *me, Action< CNEOBot > *interruptingAction ) { + // Coming back from the interception behaviour (it hands control back on real contact, on the + // carrier slipping past, or on its give-up timer): chase directly for a bit before this bot is + // eligible to peel off again, so it does not immediately abandon a contact it just made or + // bounce straight back to a cut-off it just failed to hold. + if ( interruptingAction && FStrEq( interruptingAction->GetName(), "ctgEnemyInterceptCapPath" ) ) + { + m_forcePursueTimer.Start( 5.0f ); + } + return Continue(); } diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.h b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.h index 8ae07df41..7ad847e6c 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.h @@ -4,7 +4,12 @@ #include "bot/neo_bot.h" #include "Path/NextBotChasePath.h" +class CNEO_Player; + //-------------------------------------------------------------------------------------------------------- +// "An enemy has the ghost" behaviour. Owns the tactical choice between chasing the carrier directly +// (the ChasePath below) and peeling off into CNEOBotCtgEnemyInterceptCapPath to hold a cut-off on the +// carrier's predicted path -- CNEOBotCtgSeek just hands off to here, it does not decide. class CNEOBotCtgEnemy : public Action< CNEOBot > { public: @@ -21,7 +26,14 @@ class CNEOBotCtgEnemy : public Action< CNEOBot > virtual const char *GetName( void ) const override { return "ctgEnemy"; } private: + // true => this bot should peel off to hold a cut-off (interception) rather than chase directly. + // Observable state only: this bot's distance rank among living friendly defenders to the + // carrier's last-known area, versus sv_neo_bot_ctg_pressure_slots. + bool ShouldInterceptInsteadOfChase( CNEOBot *me, CNEO_Player *pGhostCarrier ) const; + ChasePath m_chasePath; + CountdownTimer m_roleReviewTimer; // throttle the chase-vs-intercept re-decision + CountdownTimer m_forcePursueTimer; // after returning from interception, chase directly for a bit before peeling again }; #endif // NEO_BOT_CTG_ENEMY_H diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.cpp new file mode 100644 index 000000000..39a8b41e0 --- /dev/null +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.cpp @@ -0,0 +1,423 @@ +#include "cbase.h" +#include "neo_player.h" +#include "neo_gamerules.h" +#include "neo_ghost_cap_point.h" +#include "bot/neo_bot.h" +#include "bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.h" +#include "bot/neo_bot_path_compute.h" +#include "nav_pathfind.h" + +// NavAreaBuildPath only maintains CNavArea::GetPathLengthSoFar() when it is called with a positive +// maxPathLength -- SetPathLengthSoFar is gated on bHaveMaxPathLength in nav_pathfind.h. The +// interception pathfinds below deliberately pass no length cap, so that field holds stale data from +// some earlier unrelated search. Measure a finished route ourselves, the same way NavAreaBuildPath's +// own internal PathLength() helper does: sum segment lengths along the parent chain from the search's +// goal area back to its start area. +static float RouteLengthAlongParents( CNavArea *pEndArea ) +{ + float flLength = 0.0f; + for ( CNavArea *pArea = pEndArea; pArea && pArea->GetParent(); pArea = pArea->GetParent() ) + { + flLength += ( pArea->GetCenter() - pArea->GetParent()->GetCenter() ).Length(); + } + return flLength; +} + +//--------------------------------------------------------------------------------------------- +CNEOBotCtgEnemyInterceptCapPath::CNEOBotCtgEnemyInterceptCapPath( void ) + : m_targetCapArea( nullptr ), + m_targetCapPos( vec3_origin ), + m_bHoldingPosition( false ) +{ +} + +//--------------------------------------------------------------------------------------------- +bool CNEOBotCtgEnemyInterceptCapPath::RecomputeTargetCapPoint( CNEOBot *me ) +{ + m_targetCapPos = vec3_origin; + m_targetCapArea = nullptr; + m_predictedCarrierRoute.RemoveAll(); + + if ( !NEORules()->GhostExists() ) + { + return false; + } + + const int iGhosterPlayer = NEORules()->GetGhosterPlayer(); + if ( iGhosterPlayer <= 0 || iGhosterPlayer > gpGlobals->maxClients ) + { + return false; + } + + CNEO_Player *pGhostCarrier = ToNEOPlayer( UTIL_PlayerByIndex( iGhosterPlayer ) ); + if ( !pGhostCarrier || !pGhostCarrier->IsAlive() || pGhostCarrier->GetTeamNumber() == me->GetTeamNumber() ) + { + return false; + } + + float flMinDistSq = FLT_MAX; + CNEOGhostCapturePoint *pBestCap = nullptr; + + for ( int i = 0; i < NEORules()->m_pGhostCaps.Count(); ++i ) + { + CNEOGhostCapturePoint *pCapPoint = dynamic_cast< CNEOGhostCapturePoint* >( UTIL_EntityByIndex( NEORules()->m_pGhostCaps[i] ) ); + // Skip a disabled cap zone -- it cannot be captured this round, so it is not a real + // interception target. Same guard as neo_bot_seek_and_destroy.cpp's cap scan. + if ( !pCapPoint || !pCapPoint->GetActive() ) + { + continue; + } + + if ( pCapPoint->owningTeamAlternate() != me->GetTeamNumber() ) + { + const float distSq = pGhostCarrier->GetAbsOrigin().DistToSqr( pCapPoint->GetAbsOrigin() ); + if ( distSq < flMinDistSq ) + { + flMinDistSq = distSq; + pBestCap = pCapPoint; + } + } + } + + if ( !pBestCap ) + { + return false; + } + + const Vector vecCapPos = pBestCap->GetAbsOrigin(); + CNavArea *pCapArea = TheNavMesh->GetNearestNavArea( vecCapPos ); + if ( !pCapArea ) + { + return false; + } + + CNavArea *pCarrierArea = pGhostCarrier->GetLastKnownArea(); + if ( !pCarrierArea ) + { + return false; + } + + // Default fallback to the capture zone position and area + m_targetCapPos = vecCapPos; + m_targetCapArea = pCapArea; + + // Predict the enemy ghost carrier's fastest path to their target cap zone. (pCarrierArea is + // non-null here -- checked above.) + if ( pCarrierArea != pCapArea ) + { + ShortestPathCost cost; + if ( NavAreaBuildPath( pCarrierArea, pCapArea, &vecCapPos, cost ) ) + { + // The predicted route is the parent chain from pCapArea (goal) back to pCarrierArea + // (start). Measure it directly -- GetPathLengthSoFar() is not maintained for this call + // (see RouteLengthAlongParents above). + const float flTotalRouteLen = RouteLengthAlongParents( pCapArea ); + const Vector vecBotPos = me->GetAbsOrigin(); + + CUtlVector< CNavArea * > candidateAreas; // route nodes worth trying as a cut-off + CUtlVector< float > candidateCarrierDist; // matching carrier->node distance along the route + CUtlVector< float > candidateBotDistSq; // matching straight-line bot->node distance^2 (reach-check order) + CNavArea *pNearestToBotArea = nullptr; // straight-line fallback when no cut-off is beatable + float flMinDistSqToBot = FLT_MAX; + + // Walk cap -> carrier, accumulating distance-from-cap so each node knows both how far it + // is from the cap and (total - that) how far the carrier still is from it. + float flNodeToCap = 0.0f; + CNavArea *pPrevArea = nullptr; + for ( CNavArea *pArea = pCapArea; pArea; pArea = pArea->GetParent() ) + { + if ( pPrevArea ) + { + flNodeToCap += ( pArea->GetCenter() - pPrevArea->GetCenter() ).Length(); + } + pPrevArea = pArea; + + // Cache the route so Update can cheaply tell -- by scanning these area pointers, no + // pathfind -- whether the carrier has since diverged onto an alternative path. + m_predictedCarrierRoute.AddToTail( pArea ); + + const float distSqToBot = vecBotPos.DistToSqr( pArea->GetCenter() ); + if ( distSqToBot < flMinDistSqToBot ) + { + flMinDistSqToBot = distSqToBot; + pNearestToBotArea = pArea; + } + + const float flCarrierToNode = flTotalRouteLen - flNodeToCap; + // (a) well ahead of the carrier, and (b) not already in the goal-defence stretch + // just before the cap -- that last bit is CNEOBotCtgEnemy's job, not a cut-off. + if ( flCarrierToNode < 200.0f || flNodeToCap < 250.0f ) + { + continue; + } + + candidateAreas.AddToTail( pArea ); + candidateCarrierDist.AddToTail( flCarrierToNode ); + candidateBotDistSq.AddToTail( distSqToBot ); + } + + // Pick a cut-off the bot can hold, not just any point ahead of the carrier. Check the + // candidates NEAREST THE BOT first and take the first one it can nav-reach clearly + // before the carrier would. An earlier furthest-forward-first walk tended to land on a + // node right by the enemy cap -- ~4000 u of travel, so the bot burned its whole give-up + // window walking there and never arrived. Nearest-first minimises bot travel => the bot + // actually gets there with hold time to spare. Real path length, not a straight line + // through walls. Bounded reach checks so a bad recompute cannot pathfind forever. + CNavArea *pForwardCutoffArea = nullptr; + const int iMaxReachChecks = 4; + const float flReachRatio = 1.15f; // bot route may be at most 15% longer than the carrier's to that node + CNavArea *pBotArea = me->GetLastKnownArea(); + if ( !pBotArea ) + { + pBotArea = TheNavMesh->GetNearestNavArea( vecBotPos ); + } + + CUtlVector< bool > candidateChecked; + candidateChecked.SetCount( candidateAreas.Count() ); + for ( int c = 0; c < candidateChecked.Count(); ++c ) + { + candidateChecked[c] = false; + } + + for ( int iPass = 0; iPass < iMaxReachChecks && pBotArea; ++iPass ) + { + // next-nearest unchecked candidate + int iBest = -1; + float flBestDistSq = FLT_MAX; + for ( int c = 0; c < candidateAreas.Count(); ++c ) + { + if ( !candidateChecked[c] && candidateBotDistSq[c] < flBestDistSq ) + { + flBestDistSq = candidateBotDistSq[c]; + iBest = c; + } + } + if ( iBest < 0 ) + { + break; + } + candidateChecked[iBest] = true; + + CNavArea *pArea = candidateAreas[iBest]; + const Vector vecNode = pArea->GetCenter(); + ShortestPathCost botCost; + if ( !NavAreaBuildPath( pBotArea, pArea, &vecNode, botCost ) ) + { + continue; + } + + const float flBotRouteLen = RouteLengthAlongParents( pArea ); + if ( flBotRouteLen <= candidateCarrierDist[iBest] * flReachRatio ) + { + pForwardCutoffArea = pArea; + break; + } + } + + if ( pForwardCutoffArea ) + { + m_targetCapArea = pForwardCutoffArea; + m_targetCapPos = pForwardCutoffArea->GetCenter(); + } + else if ( pNearestToBotArea ) + { + m_targetCapArea = pNearestToBotArea; + m_targetCapPos = pNearestToBotArea->GetCenter(); + } + } + } + + // Where the chosen cut-off sits on the cached route (cap-first order, so a lower index is + // closer to the cap). Update uses it for an along-route "has the carrier passed this?" test + // instead of a straight-line one that trips through walls. + m_targetRouteIndex = m_predictedCarrierRoute.Find( m_targetCapArea ); + + return ( m_targetCapArea != nullptr ); +} + +//--------------------------------------------------------------------------------------------- +ActionResult< CNEOBot > CNEOBotCtgEnemyInterceptCapPath::OnStart( CNEOBot *me, Action< CNEOBot > *priorAction ) +{ + m_path.SetMinLookAheadDistance( me->GetDesiredPathLookAheadRange() ); + + m_bHoldingPosition = false; + m_contactCheckTimer.Start( RandomFloat( 0.5f, 1.5f ) ); + // Periodic recompute is a backstop; the cheap per-tick deviation check in Update drives the + // fast reaction to an unexpected carrier route. m_minRecomputeInterval keeps that from turning + // into a pathfind storm -- NavAreaBuildPath is not free and this runs for every intercepting bot. + m_recomputeTimer.Start( RandomFloat( 3.0f, 5.0f ) ); + m_minRecomputeInterval.Start( 1.0f ); + m_holdGiveUpTimer.Start( RandomFloat( 14.0f, 20.0f ) ); + + if ( !RecomputeTargetCapPoint( me ) ) + { + return Done( "No enemy capture point available" ); + } + + if ( !CNEOBotPathCompute( me, m_path, m_targetCapPos, FASTEST_ROUTE ) ) + { + return Done( "Initial path compute failed" ); + } + + return Continue(); +} + +//--------------------------------------------------------------------------------------------- +ActionResult< CNEOBot > CNEOBotCtgEnemyInterceptCapPath::Update( CNEOBot *me, float interval ) +{ + if ( NEORules()->GetGameType() != NEO_GAME_TYPE_CTG ) + { + return Done( "Game mode is no longer CTG" ); + } + + if ( !NEORules()->GhostExists() ) + { + return Done( "Ghost does not exist" ); + } + + const int iGhosterPlayer = NEORules()->GetGhosterPlayer(); + if ( iGhosterPlayer <= 0 || iGhosterPlayer > gpGlobals->maxClients ) + { + return Done( "Ghost carrier invalid" ); + } + + CNEO_Player *pGhostCarrier = ToNEOPlayer( UTIL_PlayerByIndex( iGhosterPlayer ) ); + if ( !pGhostCarrier || !pGhostCarrier->IsAlive() || pGhostCarrier->GetTeamNumber() == me->GetTeamNumber() ) + { + return Done( "Ghost carrier dead or friendly" ); + } + + // Hand off to the direct chaser once the intercept has served its purpose. These are the + // meaningful "the repositioning is over" signals -- not PVS potential-visibility, which trips + // almost everywhere on a compact map and made this behaviour a sub-second no-op. + // + // "Carrier slipped past the cut-off": test it along the cached route, not straight-line. The + // route is stored cap-first, so the carrier has passed our cut-off once its last-known area + // sits at a lower index (closer to the cap) than the cut-off does. A straight-line compare + // here fired on tick one for any cut-off the carrier happened to be nearer to through a wall, + // which cancelled a large fraction of episodes instantly on winding maps. If the carrier's + // area is not on the route at all it has diverged -- the recompute path below handles that. + if ( m_predictedCarrierRoute.Count() > 0 && m_targetRouteIndex != m_predictedCarrierRoute.InvalidIndex() ) + { + CNavArea *pCarrierArea = pGhostCarrier->GetLastKnownArea(); + const int iCarrierIdx = pCarrierArea ? m_predictedCarrierRoute.Find( pCarrierArea ) + : m_predictedCarrierRoute.InvalidIndex(); + if ( iCarrierIdx != m_predictedCarrierRoute.InvalidIndex() && iCarrierIdx < m_targetRouteIndex ) + { + return Done( "Carrier slipped past the cut-off" ); + } + } + + if ( m_contactCheckTimer.IsElapsed() ) + { + m_contactCheckTimer.Start( RandomFloat( 0.5f, 1.5f ) ); + if ( me->IsRangeLessThan( pGhostCarrier, 900.0f ) && me->IsLineOfSightClear( pGhostCarrier ) ) + { + return Done( "Carrier in sight" ); + } + } + + if ( m_holdGiveUpTimer.IsElapsed() ) + { + return Done( "Intercept window closed" ); + } + + // Re-pick the cut-off on the periodic backstop timer, or sooner if the carrier has diverged + // from the route we predicted. The divergence test is cheap -- a linear scan of the cached + // route's area pointers, no pathfinding -- and m_minRecomputeInterval throttles how often it + // can actually trigger the expensive RecomputeTargetCapPoint. + bool bDoRecompute = m_recomputeTimer.IsElapsed(); + if ( !bDoRecompute && m_minRecomputeInterval.IsElapsed() && m_predictedCarrierRoute.Count() > 0 ) + { + CNavArea *pCarrierArea = pGhostCarrier->GetLastKnownArea(); + if ( pCarrierArea && m_predictedCarrierRoute.Find( pCarrierArea ) == m_predictedCarrierRoute.InvalidIndex() ) + { + bDoRecompute = true; + } + } + + if ( bDoRecompute ) + { + m_recomputeTimer.Start( RandomFloat( 3.0f, 5.0f ) ); + m_minRecomputeInterval.Start( 1.0f ); + const Vector vecOldTargetPos = m_targetCapPos; + CNavArea *pOldTargetArea = m_targetCapArea; + const bool bWasHolding = m_bHoldingPosition; + if ( !RecomputeTargetCapPoint( me ) ) + { + return Done( "No capture point found on recompute" ); + } + + // Hold hysteresis: the carrier moves every tick, so the predicted route -- and the node + // picked off it -- drifts a little on every backstop recompute. Without this a bot that had + // finally parked on a cut-off gets its hold cleared and its path rebuilt for a sub-200 u + // retarget and never settles. If we were holding and the fresh pick is within an + // arrive-radius of where we already stand, and the old cut-off is still on the new route + // (so the slipped-past test stays valid), keep holding it. + const float flHoldHysteresis = 200.0f; + const int iOldOnNewRoute = pOldTargetArea ? m_predictedCarrierRoute.Find( pOldTargetArea ) + : m_predictedCarrierRoute.InvalidIndex(); + if ( bWasHolding + && iOldOnNewRoute != m_predictedCarrierRoute.InvalidIndex() + && vecOldTargetPos.DistTo( m_targetCapPos ) < flHoldHysteresis ) + { + m_targetCapArea = pOldTargetArea; + m_targetCapPos = vecOldTargetPos; + m_targetRouteIndex = iOldOnNewRoute; + // m_bHoldingPosition stays true; keep the already-completed path. + } + else if ( m_targetCapPos != vecOldTargetPos ) + { + m_bHoldingPosition = false; + if ( !CNEOBotPathCompute( me, m_path, m_targetCapPos, FASTEST_ROUTE ) ) + { + return Done( "Path failed on recompute" ); + } + } + } + + // Parked on the cut-off: hold it and watch toward the carrier. CNEOBotMainAction still fires + // at any visible threat while we hold, so a holding bot is a stationary ambusher, not a duck. + if ( m_bHoldingPosition ) + { + me->GetBodyInterface()->AimHeadTowards( pGhostCarrier, IBody::IMPORTANT, 0.3f, + nullptr, "Intercept: watching for the ghost carrier" ); + return Continue(); + } + + m_path.Update( me ); + if ( !m_path.IsValid() ) + { + // PathFollower invalidates the path when the goal is reached. If we are at (or nearly at) + // the cut-off that is success -- hold here. Otherwise the path genuinely broke: try one + // rebuild before giving up. + const float flDistToTarget = sqrt( me->GetAbsOrigin().DistToSqr( m_targetCapPos ) ); + + const float flArriveRadius = 200.0f; + if ( flDistToTarget < flArriveRadius ) + { + m_bHoldingPosition = true; + return Continue(); + } + + // Rebuilding is a pathfind, so it goes through the same floor as the recompute above. A + // cut-off whose area centre the bot cannot physically stand on completes its path a little + // short of the arrive radius every time; without this floor that turns into a rebuild on + // every tick. Wait it out instead -- m_holdGiveUpTimer still bounds the whole episode. + if ( !m_minRecomputeInterval.IsElapsed() ) + { + return Continue(); + } + m_minRecomputeInterval.Start( 1.0f ); + + if ( RecomputeTargetCapPoint( me ) && CNEOBotPathCompute( me, m_path, m_targetCapPos, FASTEST_ROUTE ) ) + { + m_bHoldingPosition = false; + return Continue(); + } + + return Done( "Lost the interception path" ); + } + + return Continue(); +} diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.h b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.h new file mode 100644 index 000000000..317f66194 --- /dev/null +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_intercept_cap_path.h @@ -0,0 +1,31 @@ +#pragma once + +#include "bot/neo_bot.h" + +//-------------------------------------------------------------------------------------------------------- +class CNEOBotCtgEnemyInterceptCapPath : public Action< CNEOBot > +{ +public: + CNEOBotCtgEnemyInterceptCapPath( void ); + + virtual ActionResult< CNEOBot > OnStart( CNEOBot *me, Action< CNEOBot > *priorAction ) override; + virtual ActionResult< CNEOBot > Update( CNEOBot *me, float interval ) override; + + virtual const char *GetName( void ) const override { return "ctgEnemyInterceptCapPath"; } + +private: + bool RecomputeTargetCapPoint( CNEOBot *me ); + + // The spot being intercepted to: normally a cut-off node picked off the carrier's predicted + // route, falling back to the enemy capture zone itself when no cut-off can be picked. + CNavArea *m_targetCapArea; + Vector m_targetCapPos; + bool m_bHoldingPosition; // true once parked on the cut-off, watching for the carrier + CUtlVector< CNavArea * > m_predictedCarrierRoute; // nav areas of the carrier's last predicted fastest path to the cap, cap-first order + int m_targetRouteIndex = 0; // index of m_targetCapArea within m_predictedCarrierRoute (for the along-route "carrier slipped past" test) + CountdownTimer m_recomputeTimer; // periodic cut-off recompute cadence + CountdownTimer m_minRecomputeInterval; // floor between pathfinds (recompute + path repair) so neither can storm + CountdownTimer m_contactCheckTimer; // cadence for the "carrier in sight" hand-off check + CountdownTimer m_holdGiveUpTimer; // upper bound on time spent in this behaviour + PathFollower m_path; +}; diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.cpp index 8f321c502..2b23bee03 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.cpp @@ -25,6 +25,35 @@ ActionResult< CNEOBot > CNEOBotCtgSeek::Update( CNEOBot *me, float interval ) return result; } + if (!NEORules()->GhostExists()) + { + return Done("Ghost does not exist"); + } + + if (me->IsCarryingGhost()) + { + return SuspendFor(new CNEOBotCtgCarrier, "I am the ghost carrier!"); + } + + int iGhosterPlayer = NEORules()->GetGhosterPlayer(); + if (iGhosterPlayer > 0 && iGhosterPlayer <= gpGlobals->maxClients) + { + CNEO_Player* pGhostCarrier = ToNEOPlayer(UTIL_PlayerByIndex(iGhosterPlayer)); + if (pGhostCarrier && pGhostCarrier != me) + { + if (pGhostCarrier->GetTeamNumber() == me->GetTeamNumber()) + { + return SuspendFor(new CNEOBotCtgEscort, "Protecting the ghost carrier!"); + } + + // An enemy has the ghost: hand off to CNEOBotCtgEnemy and let it decide, tick by tick, + // whether to pursue the carrier directly or peel into CNEOBotCtgEnemyInterceptCapPath to + // hold a cut-off. That decision does not belong in this dispatcher -- it has to be able + // to re-evaluate as the situation develops. + return SuspendFor( new CNEOBotCtgEnemy, "Stopping the ghost carrier!" ); + } + } + int team_members = 0; for ( int i = 1; i <= gpGlobals->maxClients; i++ ) { @@ -40,31 +69,6 @@ ActionResult< CNEOBot > CNEOBotCtgSeek::Update( CNEOBot *me, float interval ) return SuspendFor( new CNEOBotCtgLoneWolf, "I'm the last one on my team!" ); } - if (NEORules()->GhostExists()) - { - int iGhosterPlayer = NEORules()->GetGhosterPlayer(); - if (iGhosterPlayer > 0 && iGhosterPlayer <= gpGlobals->maxClients) - { - CNEO_Player* pGhostCarrier = ToNEOPlayer(UTIL_PlayerByIndex(iGhosterPlayer)); - if (pGhostCarrier && pGhostCarrier != me) - { - if (pGhostCarrier->GetTeamNumber() == me->GetTeamNumber()) - { - return SuspendFor(new CNEOBotCtgEscort, "Protecting the ghost carrier!"); - } - else - { - return SuspendFor(new CNEOBotCtgEnemy, "Stopping the ghost carrier!"); - } - } - - // If I have the ghost, switch to ghost behavior - if (me->IsCarryingGhost()) - { - return SuspendFor(new CNEOBotCtgCarrier, "I am the ghost carrier!"); - } - } - } // Ghost capture logic if (m_bGoingToTargetEntity && m_hTargetEntity) diff --git a/src/game/shared/neo/neo_gamerules.h b/src/game/shared/neo/neo_gamerules.h index 48f008ee8..00bf78cb9 100644 --- a/src/game/shared/neo/neo_gamerules.h +++ b/src/game/shared/neo/neo_gamerules.h @@ -502,10 +502,13 @@ class CNEORules : public CHL2MPRules, public CGameEventListener // For looking up capture zone locations friend class CNEOBotCtgCarrier; + friend class CNEOBotCtgEnemy; + friend class CNEOBotCtgEnemyInterceptCapPath; friend class CNEOBotCtgEscort; friend class CNEOBotCtgLoneWolf; friend class CNEOBotCtgLoneWolfAmbush; friend class CNEOBotCtgLoneWolfSeek; + friend class CNEOBotCtgSeek; friend class CNEOBotTacticalMonitor; friend class CNEOBotSeekAndDestroy;