From c3326804fe6f641f4a532dc8d4687804f573a5da Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Sat, 12 Sep 2026 10:16:15 -0600 Subject: [PATCH] Improve bot tracking of recent combat sounds --- src/game/server/CMakeLists.txt | 2 + .../neo/bot/behavior/neo_bot_ctg_seek.h | 3 + .../bot/behavior/neo_bot_jgr_juggernaut.cpp | 20 +- .../neo/bot/behavior/neo_bot_jgr_seek.cpp | 2 +- .../bot/behavior/neo_bot_seek_and_destroy.cpp | 189 ++++++++---------- .../bot/behavior/neo_bot_seek_and_destroy.h | 10 +- src/game/server/neo/bot/neo_bot_manager.cpp | 4 + .../neo/bot/neo_bot_memory_sound_combat.cpp | 140 +++++++++++++ .../neo/bot/neo_bot_memory_sound_combat.h | 18 ++ 9 files changed, 260 insertions(+), 128 deletions(-) create mode 100644 src/game/server/neo/bot/neo_bot_memory_sound_combat.cpp create mode 100644 src/game/server/neo/bot/neo_bot_memory_sound_combat.h diff --git a/src/game/server/CMakeLists.txt b/src/game/server/CMakeLists.txt index cfd518bb9d..0d96a442d7 100644 --- a/src/game/server/CMakeLists.txt +++ b/src/game/server/CMakeLists.txt @@ -1519,6 +1519,7 @@ set(UNITY_SOURCE_NEO_BOT neo/bot/neo_bot_body.cpp neo/bot/neo_bot_locomotion.cpp neo/bot/neo_bot_manager.cpp + neo/bot/neo_bot_memory_sound_combat.cpp neo/bot/neo_bot_profile.cpp neo/bot/neo_bot_squad.cpp neo/bot/neo_bot_vision.cpp @@ -1580,6 +1581,7 @@ target_sources_grouped( neo/bot/neo_bot_contextual_query_interface.h neo/bot/neo_bot_locomotion.h neo/bot/neo_bot_manager.h + neo/bot/neo_bot_memory_sound_combat.h neo/bot/neo_bot_profile.h neo/bot/neo_bot_squad.h neo/bot/neo_bot_vision.h diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.h b/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.h index a09b305177..da7ca5d47a 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.h @@ -15,4 +15,7 @@ class CNEOBotCtgSeek : public CNEOBotSeekAndDestroy protected: virtual void RecomputeSeekPath( CNEOBot *me ) override; + + // Only detour to combat sounds that are roughly on the way to the ghost + virtual bool IsSeekGoalAnObjective() const override { return true; } }; diff --git a/src/game/server/neo/bot/behavior/neo_bot_jgr_juggernaut.cpp b/src/game/server/neo/bot/behavior/neo_bot_jgr_juggernaut.cpp index 4117bc974e..f5489af760 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_jgr_juggernaut.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_jgr_juggernaut.cpp @@ -55,25 +55,13 @@ void CNEOBotJgrJuggernaut::RecomputeSeekPath( CNEOBot *me ) m_bGoingToTargetEntity = false; m_vGoalPos = vec3_origin; - // Listen for gunfights - const Vector& vGunfireLocation = SearchGunfireLocation(me); - if (vGunfireLocation != vec3_invalid) + // Listen for combat sounds + if ( TryPathToCombatSound( me ) ) { - m_vGoalPos = vGunfireLocation; - m_bGoingToTargetEntity = false; - - if (CNEOBotPathCompute(me, m_path, m_vGoalPos, DEFAULT_ROUTE) && m_path.IsValid() && m_path.GetResult() == Path::COMPLETE_PATH) - { - return; - } - else - { - // NEO Jank: Sound is unreachable so wait for it clear from the sound list - m_soundSearchTimer.Start( 3.0f ); - } + return; } - // If unaware of gunfights, patrol spawn points + // If unaware of combat noises, patrol spawn points if (m_jgrSpawns.Count() > 0) { CBaseEntity* pTargetSpawn = m_jgrSpawns[ RandomInt(0, m_jgrSpawns.Count() - 1) ]; diff --git a/src/game/server/neo/bot/behavior/neo_bot_jgr_seek.cpp b/src/game/server/neo/bot/behavior/neo_bot_jgr_seek.cpp index fb44636051..e89c883988 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_jgr_seek.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_jgr_seek.cpp @@ -15,7 +15,7 @@ //--------------------------------------------------------------------------------------------- ActionResult< CNEOBot > CNEOBotJgrSeek::Update( CNEOBot *me, float interval ) { - m_bInvestigateGunfire = false; // Focus on capturing JGR + m_bListenForCombatSounds = false; // Focus on capturing JGR if (NEORules()->GetGameType() != NEO_GAME_TYPE_JGR) { diff --git a/src/game/server/neo/bot/behavior/neo_bot_seek_and_destroy.cpp b/src/game/server/neo/bot/behavior/neo_bot_seek_and_destroy.cpp index c62193966d..77cc29fb2f 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_seek_and_destroy.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_seek_and_destroy.cpp @@ -9,9 +9,9 @@ #include "bot/behavior/neo_bot_ctg_seek.h" #include "bot/behavior/neo_bot_ctg_enemy.h" #include "bot/behavior/neo_bot_jgr_seek.h" +#include "bot/neo_bot_memory_sound_combat.h" #include "bot/neo_bot_path_compute.h" #include "nav_mesh.h" -#include "soundent.h" extern ConVar neo_bot_path_lookahead_range; extern ConVar neo_bot_offense_must_push_time; @@ -20,106 +20,114 @@ extern ConVar neo_bot_defense_must_defend_time; ConVar neo_bot_debug_seek_and_destroy( "neo_bot_debug_seek_and_destroy", "0", FCVAR_CHEAT ); ConVar neo_bot_disable_seek_and_destroy( "neo_bot_disable_seek_and_destroy", "0", FCVAR_CHEAT ); +ConVar sv_neo_bot_seek_and_destroy_combat_sound_commit_time( "sv_neo_bot_seek_and_destroy_combat_sound_commit_time", "3.0", FCVAR_CHEAT, + "Having picked a combat sound, travel to it for this long instead of re-picking.", true, 0, false, 0 ); +ConVar sv_neo_bot_seek_and_destroy_combat_sound_detour_ratio( "sv_neo_bot_seek_and_destroy_combat_sound_detour_ratio", "1.5", FCVAR_CHEAT, + "When racing for an objective, divert only if going via the combat sound costs at most this multiple of direct distance.", true, 0, false, 0 ); +ConVar sv_neo_bot_seek_and_destroy_combat_sound_arrive_range( "sv_neo_bot_seek_and_destroy_combat_sound_arrive_range", "200.0", FCVAR_CHEAT, + "Combat sound close enough to count as investigated.", true, 0, false, 0 ); + //--------------------------------------------------------------------------------------------- -CSound* CNEOBotSeekAndDestroy::SearchGunfireSounds(CNEOBot* me, const Vector* currentGoalPos) +// Is the bot inside the potentially-audible set of a sound at vSoundPos? +static bool BotInSoundPAS( CNEOBot *me, const Vector &vSoundPos ) { - CSound* pClosestSound = nullptr; - float flClosestDistSqr = FLT_MAX; - const Vector& vecMyOrigin = me->GetAbsOrigin(); - - float flGoalDistSqr = FLT_MAX; - if (currentGoalPos && *currentGoalPos != vec3_invalid) - { - flGoalDistSqr = vecMyOrigin.DistToSqr(*currentGoalPos); - } - - CSound* pSound = nullptr; - for (int iSound = CSoundEnt::ActiveList(); iSound != SOUNDLIST_EMPTY; iSound = pSound->NextSound()) + CPASFilter filter( vSoundPos ); + for ( int i = 0; i < filter.GetRecipientCount(); ++i ) { - pSound = CSoundEnt::SoundPointerForIndex(iSound); - if (!pSound) + if ( filter.GetRecipientIndex( i ) == me->entindex() ) { - break; + return true; } + } - if (!(pSound->SoundType() & SOUND_COMBAT)) - { - continue; - } + return false; +} - CBaseEntity *pOwner = pSound->m_hOwner.Get(); - // Ignore non-player sounds and sounds we were responsible for - if (!pOwner || !pOwner->IsPlayer() || pOwner == me) - { - continue; - } +//--------------------------------------------------------------------------------------------- +// Returns true if m_path now leads to a combat sound the bot heard +bool CNEOBotSeekAndDestroy::TryPathToCombatSound( CNEOBot *me ) +{ + if ( !m_bListenForCombatSounds ) + { + return false; + } - // NEO Jank: prevent bots from crowding teammates in teamplay - if (NEORules()->IsTeamplay() && me->InSameTeam(pOwner)) - { - continue; - } + const float flArriveRange = sv_neo_bot_seek_and_destroy_combat_sound_arrive_range.GetFloat(); + const float flArriveSqr = flArriveRange * flArriveRange; + const Vector vGoalBefore = m_vGoalPos; + + // Stick with the sound already picked until arrival or the commit time runs out + const bool bCommitted = m_combatSoundCommitTimer.HasStarted() && !m_combatSoundCommitTimer.IsElapsed() + && me->GetAbsOrigin().DistToSqr( m_vCombatSoundSpot ) > flArriveSqr; - // Search for the closest gunfire sounds - float distSqr = vecMyOrigin.DistToSqr(pSound->GetSoundOrigin()); + if ( bCommitted && m_path.IsValid() && m_vGoalPos == m_vCombatSoundSpot ) + { + return false; + } - // Only consider sounds that are closer than the current goal - if (distSqr >= flGoalDistSqr) + if ( !bCommitted ) + { + if ( m_soundSearchTimer.HasStarted() && !m_soundSearchTimer.IsElapsed() ) { - continue; + return false; } + m_soundSearchTimer.Start( 0.25f ); - if (distSqr >= flClosestDistSqr) + Vector vFight; + if ( !NEOMemorySoundCombat::FindNearestFight( me->GetAbsOrigin(), me->GetTeamNumber(), me->entindex(), vFight ) + || !BotInSoundPAS( me, vFight ) ) { - continue; + return false; } - bool bInPAS = false; - CPASFilter filter(pSound->GetSoundOrigin()); - for (int i = 0; i < filter.GetRecipientCount(); ++i) + if ( vGoalBefore != vec3_origin ) { - if (filter.GetRecipientIndex(i) == me->entindex()) + // Already heading there + if ( vGoalBefore.DistToSqr( vFight ) <= flArriveSqr ) { - bInPAS = true; - break; + return false; } - } - if (!bInPAS) - { - continue; + // An objective runner only takes the detour if the sound is roughly on the way + if ( IsSeekGoalAnObjective() ) + { + const float flDirect = me->GetAbsOrigin().DistTo( vGoalBefore ); + const float flVia = me->GetAbsOrigin().DistTo( vFight ) + vFight.DistTo( vGoalBefore ); + if ( flVia > flDirect * sv_neo_bot_seek_and_destroy_combat_sound_detour_ratio.GetFloat() ) + { + return false; + } + } } - flClosestDistSqr = distSqr; - pClosestSound = pSound; + m_vCombatSoundSpot = vFight; + m_combatSoundCommitTimer.Start( sv_neo_bot_seek_and_destroy_combat_sound_commit_time.GetFloat() ); } - return pClosestSound; -} + if ( CNEOBotPathCompute( me, m_path, m_vCombatSoundSpot, DEFAULT_ROUTE ) + && m_path.IsValid() && m_path.GetResult() == Path::COMPLETE_PATH ) + { + m_vGoalPos = m_vCombatSoundSpot; + m_bGoingToTargetEntity = false; + return true; + } -//--------------------------------------------------------------------------------------------- -const Vector& CNEOBotSeekAndDestroy::SearchGunfireLocation(CNEOBot* me, const Vector* currentGoalPos) -{ - CSound* pBestSound = SearchGunfireSounds(me, currentGoalPos); - if (pBestSound) + // NEO Jank: the combat sound is unreachable, so give up on it and stop listening for a few seconds + m_combatSoundCommitTimer.Invalidate(); + m_soundSearchTimer.Start( 3.0f ); + + if ( vGoalBefore != vec3_origin ) { - if (currentGoalPos && *currentGoalPos != vec3_invalid) - { - // Only change goal if recent gunfire is radically different than where I was going - constexpr float flThresholdSqr = 200.0f * 200.0f; - if (currentGoalPos->DistToSqr(pBestSound->GetSoundOrigin()) <= flThresholdSqr) - { - return vec3_invalid; - } - } - return pBestSound->GetSoundOrigin(); + m_vGoalPos = vGoalBefore; + CNEOBotPathCompute( me, m_path, m_vGoalPos, DEFAULT_ROUTE ); } - return vec3_invalid; + return false; } + //--------------------------------------------------------------------------------------------- CNEOBotSeekAndDestroy::CNEOBotSeekAndDestroy( float duration ) { @@ -367,26 +375,10 @@ ActionResult< CNEOBot > CNEOBotSeekAndDestroy::UpdateCommon( CNEOBot *me, float } } } - else if ( m_bInvestigateGunfire && (!m_soundSearchTimer.HasStarted() || m_soundSearchTimer.IsElapsed()) ) + // Listen for combat sounds on the way to wherever we were going + else if ( TryPathToCombatSound( me ) ) { - m_soundSearchTimer.Start( 0.25f ); - - const Vector& vGunfireLocation = SearchGunfireLocation(me, &m_vGoalPos); - if (vGunfireLocation != vec3_invalid) - { - m_vGoalPos = vGunfireLocation; - m_bGoingToTargetEntity = false; - - if (CNEOBotPathCompute(me, m_path, m_vGoalPos, DEFAULT_ROUTE)) - { - m_repathTimer.Start( 45.0f ); - } - else - { - // NEO Jank: Sound is unreachable so wait for it clear from the sound list - m_soundSearchTimer.Start( 3.0f ); - } - } + m_repathTimer.Start( 45.0f ); } return Continue(); @@ -591,25 +583,10 @@ void CNEOBotSeekAndDestroy::RecomputeSeekPath( CNEOBot *me ) } #endif - // Listen for gunfights - if (m_bInvestigateGunfire) + // Listen for combat sounds + if ( TryPathToCombatSound( me ) ) { - const Vector& vGunfireLocation = SearchGunfireLocation(me); - if (vGunfireLocation != vec3_invalid) - { - m_vGoalPos = vGunfireLocation; - m_bGoingToTargetEntity = false; - - if (CNEOBotPathCompute(me, m_path, m_vGoalPos, DEFAULT_ROUTE) && m_path.IsValid() && m_path.GetResult() == Path::COMPLETE_PATH) - { - return; - } - else - { - // NEO Jank: Sound is unreachable so wait for it clear from the sound list - m_soundSearchTimer.Start( 3.0f ); - } - } + return; } // Fallback and roam random spawn points if we have all weapons. diff --git a/src/game/server/neo/bot/behavior/neo_bot_seek_and_destroy.h b/src/game/server/neo/bot/behavior/neo_bot_seek_and_destroy.h index b25809fda3..a442d82f53 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_seek_and_destroy.h +++ b/src/game/server/neo/bot/behavior/neo_bot_seek_and_destroy.h @@ -2,8 +2,6 @@ #include "Path/NextBotChasePath.h" -class CSound; - // // Roam around the map attacking enemies // @@ -37,17 +35,19 @@ class CNEOBotSeekAndDestroy : public Action< CNEOBot > virtual void RecomputeSeekPath( CNEOBot *me ); - static CSound* SearchGunfireSounds(CNEOBot* me, const Vector* currentGoalPos = nullptr); - static const Vector& SearchGunfireLocation(CNEOBot* me, const Vector* currentGoalPos = nullptr); + bool TryPathToCombatSound( CNEOBot *me ); + virtual bool IsSeekGoalAnObjective() const { return false; } PathFollower m_path; CountdownTimer m_repathTimer; CountdownTimer m_repathFailTimer; CountdownTimer m_soundSearchTimer; + CountdownTimer m_combatSoundCommitTimer; + Vector m_vCombatSoundSpot = vec3_origin; CountdownTimer m_itemStolenTimer; EHANDLE m_hTargetEntity; bool m_bGoingToTargetEntity = false; - bool m_bInvestigateGunfire = true; + bool m_bListenForCombatSounds = true; Vector m_vGoalPos = vec3_origin; bool m_bTimerElapsed = false; bool m_bOverrideApproach = false; diff --git a/src/game/server/neo/bot/neo_bot_manager.cpp b/src/game/server/neo/bot/neo_bot_manager.cpp index 57f824bc93..350d765812 100644 --- a/src/game/server/neo/bot/neo_bot_manager.cpp +++ b/src/game/server/neo/bot/neo_bot_manager.cpp @@ -5,6 +5,7 @@ #include "team.h" #include "neo_bot.h" #include "neo_gamerules.h" +#include "neo_bot_memory_sound_combat.h" #include "neo_bot_path_reservation.h" @@ -139,6 +140,7 @@ void CNEOBotManager::OnMapLoaded( void ) ClearStuckBotData(); CNEOBotPathReservations()->Clear(); + NEOMemorySoundCombat::Reset(); } @@ -147,6 +149,8 @@ void CNEOBotManager::Update() { MaintainBotQuota(); + NEOMemorySoundCombat::Update(); + DrawStuckBotData(); NextBotManager::Update(); diff --git a/src/game/server/neo/bot/neo_bot_memory_sound_combat.cpp b/src/game/server/neo/bot/neo_bot_memory_sound_combat.cpp new file mode 100644 index 0000000000..c60ed79265 --- /dev/null +++ b/src/game/server/neo/bot/neo_bot_memory_sound_combat.cpp @@ -0,0 +1,140 @@ +#include "cbase.h" +#include "bot/neo_bot_memory_sound_combat.h" +#include "neo_gamerules.h" +#include "soundent.h" + +// memdbgon must be the last include file in a .cpp file!!! +#include "tier0/memdbgon.h" + +ConVar sv_neo_bot_memory_sound_combat_time_window( "sv_neo_bot_memory_sound_combat_time_window", "3", FCVAR_CHEAT, + "How long a combat sound source stays worth investigating after it goes quiet.", true, 0, false, 0 ); +ConVar sv_neo_bot_memory_sound_combat_cluster_radius( "sv_neo_bot_memory_sound_combat_cluster_radius", "500.0", FCVAR_CHEAT, + "Radius threshold to cluster combat sound sources as the same fight.", true, 0, false, 0 ); + +namespace NEOMemorySoundCombat +{ + + struct Source + { + Vector pos = vec3_origin; + float lastHeard = -FLT_MAX; // sound expiry: bumped by every new sound, so it tracks live combat + int team = 0; // for ignoring shots from same team + int volume = 0; // audible radius of the last sound heard from it + }; + + // Indexed by player entindex + static Source s_sources[ MAX_PLAYERS + 1 ]; + + void Update() + { + CSound *pSound = nullptr; + for ( int i = CSoundEnt::ActiveList(); i != SOUNDLIST_EMPTY; i = pSound->NextSound() ) + { + pSound = CSoundEnt::SoundPointerForIndex( i ); + if ( !pSound ) + { + break; + } + + if ( !( pSound->SoundType() & SOUND_COMBAT ) || !pSound->DoesSoundExpire() ) + { + continue; + } + + CBaseEntity *pOwner = pSound->m_hOwner.Get(); + if ( !pOwner || !pOwner->IsPlayer() ) + { + continue; + } + + const int iEnt = pOwner->entindex(); + if ( iEnt < 1 || iEnt > MAX_PLAYERS ) + { + continue; + } + + // All NEO weapons share one SOUNDENT_CHANNEL_WEAPON slot per player, + // so this entry is that player's latest combat sound + Source &s = s_sources[ iEnt ]; + s.pos = pSound->GetSoundOrigin(); + s.lastHeard = pSound->SoundExpirationTime(); + s.team = pOwner->GetTeamNumber(); + s.volume = pSound->Volume(); + } + } + + void Reset() + { + for ( Source &s : s_sources ) + { + s = Source(); + } + } + + bool FindNearestFight( const Vector &vEar, int iMyTeam, int iMyEnt, Vector &vFight ) + { + const float flNow = gpGlobals->curtime; + const bool bTeamplay = NEORules() && NEORules()->IsTeamplay(); + const float flMemoryWindow = sv_neo_bot_memory_sound_combat_time_window.GetFloat(); + const float flClusterRadius = sv_neo_bot_memory_sound_combat_cluster_radius.GetFloat(); + + // Gather the sources this listener would have heard, with a linear recency weight: + // full while the sound is still live, fading to nothing after it stops. + Vector pos[ MAX_PLAYERS + 1 ]; + float weight[ MAX_PLAYERS + 1 ]; + int nHeard = 0; + int iNearest = -1; + float flNearestSqr = FLT_MAX; + + for ( int i = 1; i <= MAX_PLAYERS; ++i ) + { + const Source &s = s_sources[ i ]; + const float flAge = flNow - s.lastHeard; + if ( flAge >= flMemoryWindow || i == iMyEnt ) + { + continue; + } + if ( bTeamplay && s.team == iMyTeam ) + { + continue; + } + const float flDistSqr = vEar.DistToSqr( s.pos ); + if ( flDistSqr > (float)s.volume * (float)s.volume ) + { + continue; + } + + // The nearest fight is the most urgent threat and the quickest to reach + if ( flDistSqr < flNearestSqr ) + { + flNearestSqr = flDistSqr; + iNearest = nHeard; + } + + pos[ nHeard ] = s.pos; + weight[ nHeard ] = ( flAge > 0.0f ) ? ( 1.0f - flAge / flMemoryWindow ) : 1.0f; + ++nHeard; + } + + if ( iNearest < 0 ) + { + return false; + } + + // Aim for the recency-weighted centroid of the sources fighting around the nearest one + const float flClusterSqr = flClusterRadius * flClusterRadius; + Vector vSum( 0, 0, 0 ); + float flWsum = 0.0f; + for ( int j = 0; j < nHeard; ++j ) + { + if ( pos[ iNearest ].DistToSqr( pos[ j ] ) <= flClusterSqr ) + { + vSum += pos[ j ] * weight[ j ]; + flWsum += weight[ j ]; + } + } + + vFight = vSum / flWsum; + return true; + } +} diff --git a/src/game/server/neo/bot/neo_bot_memory_sound_combat.h b/src/game/server/neo/bot/neo_bot_memory_sound_combat.h new file mode 100644 index 0000000000..3cc3a9c55f --- /dev/null +++ b/src/game/server/neo/bot/neo_bot_memory_sound_combat.h @@ -0,0 +1,18 @@ +#pragma once + +#include "mathlib/vector.h" + +// Short-term memory of combat sounds, so bots can listen for fights they heard a moment ago +namespace NEOMemorySoundCombat +{ + // Combat sounds stay in the engine's sound list for only ~0.2 s, + // so sample them every server tick instead of on each bot's 0.25 s search throttle + void Update(); + + // Forget every source, e.g. on a map change where curtime restarts + void Reset(); + + // Find the nearest recently heard fight: enemy combat sounds around the nearest source. + // Only counts sources within their own audible radius of vEar, as CSoundEnt::Listen does. + bool FindNearestFight( const Vector &vEar, int iMyTeam, int iMyEnt, Vector &vFight ); +}