From a16ba3e0c4a1f1626f53578516cb31562121b959 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:05:29 -0600 Subject: [PATCH 1/2] Step off a ladder crouched through a crawl gap A ladder whose top exit is a gap only a crouched body fits through, such as a vent or a low window, was never left: the dismount check sweeps the standing body, finds the gap's top in the way, keeps the bot climbing past the ladder's top, and the stall check then jumps it off. When the standing sweep is blocked but a crouched body at the ladder's top clears, duck, hold the feet just above the top and move out toward the landing (along the face for an exit beside the ladder, where a press into the face would climb). Seen on ntre_rise_ctg ladder 3, whose exit is a 52 u window. --- .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 119 +++++++++++++++++- .../neo/bot/behavior/neo_bot_ladder_climb.h | 12 ++ 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index 09a5b22501..c0f0d9b995 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -17,7 +17,7 @@ static constexpr float LADDER_PLACE_SEEK = 16.0f; //--------------------------------------------------------------------------------------------- CNEOBotLadderClimb::CNEOBotLadderClimb( const CNavLadder *ladder, bool goingUp ) : m_ladder( ladder ), m_bGoingUp( goingUp ), m_flLastZ( 0.0f ), - m_bDismountPhase( false ), m_bJumpedOffLadder( false ), m_pExitArea( nullptr ), m_nNudges( 0 ) + m_bDismountPhase( false ), m_bJumpedOffLadder( false ), m_bCrawlDismount( false ), m_bCrawlPushed( false ), m_pExitArea( nullptr ), m_nNudges( 0 ) { m_dismountPos = vec3_origin; m_ladderForward = ladder ? -ladder->GetNormal() : vec3_origin; @@ -355,7 +355,8 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* if ( !onLadder ) { - if ( mover->IsOnGround() ) + // crawling off, the gap's sill is ground too, short of the landing + if ( mover->IsOnGround() && !m_bCrawlDismount ) { return Done( "Reached the ground, ending climb" ); } @@ -472,6 +473,20 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* bWantsDismount = true; } + // A gap at the top only a crouched body fits through, such as a vent or a low window: climbing on never clears it, + // so duck on the way up, and step off crouched once the gap is in reach + if ( m_bGoingUp && onLadder && m_pExitArea && currentZ > GetCrawlFeetZ() - CRAWL_DUCK_RANGE && IsCrawlOnlyExit( me, toExit ) ) + { + me->PressCrouchButton( CRAWL_CROUCH_HOLD ); + if ( ( me->GetFlags() & FL_DUCKING ) && fabsf( GetCrawlFeetZ() - currentZ ) <= CRAWL_SNAP_DIST ) + { + m_bCrawlDismount = true; + EnterDismountPhase( me ); + UpdateCrawlDismount( me, toExit, onLadder ); + return Continue(); + } + } + // A parapet between the ladder and a roof exit blocks the step off at floor height: // keep climbing until the hull clears it (stuck detection ends it if nothing does) const bool bLipInTheWay = bWantsDismount && m_bGoingUp && onLadder @@ -562,6 +577,12 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* return Done( "Dismount walk timed out" ); } + if ( m_bCrawlDismount && m_pExitArea ) + { + UpdateCrawlDismount( me, toExit, onLadder ); + return Continue(); + } + // Build look target toward exit area center with vertical bias preserved if ( m_pExitArea ) { @@ -649,6 +670,100 @@ bool CNEOBotLadderClimb::IsDismountBlocked( CNEOBot *me, const Vector &toExit ) return tr.DidHit(); } +//--------------------------------------------------------------------------------------------- +// Which way to crawl off toward the exit: along the face for an exit beside the ladder, +// since on the ladder a press with any part into the face climbs +Vector CNEOBotLadderClimb::GetCrawlDirection( const Vector &toExit ) const +{ + if ( DotProduct( toExit, m_ladderForward ) >= 0.5f ) + { + return toExit; + } + + const Vector &normal = m_ladder->GetNormal(); + Vector along = toExit - DotProduct( toExit, normal ) * normal; + along.z = 0.0f; + return ( along.NormalizeInPlace() > 0.0f ) ? along : toExit; +} + +//--------------------------------------------------------------------------------------------- +// Where the feet step off crawling: just above the ladder's top +float CNEOBotLadderClimb::GetCrawlFeetZ() const +{ + return m_ladder->m_top.z + CRAWL_FEET_ABOVE_TOP; +} + +//--------------------------------------------------------------------------------------------- +// From the ladder's top toward the exit, the standing body is blocked but a crouched one is clear +bool CNEOBotLadderClimb::IsCrawlOnlyExit( CNEOBot *me, const Vector &toExit ) const +{ + const IBody *body = me->GetBodyInterface(); + const float halfWidth = body->GetHullWidth() * 0.5f; + const Vector mins( -halfWidth, -halfWidth, 0.0f ); + const Vector standMaxs( halfWidth, halfWidth, body->GetStandHullHeight() ); + const Vector crouchMaxs( halfWidth, halfWidth, body->GetCrouchHullHeight() ); + + Vector start = me->GetLocomotionInterface()->GetFeet(); + start.z = GetCrawlFeetZ(); + const Vector end = start + GetCrawlDirection( toExit ) * ( DISMOUNT_CLEARANCE_HULLS * body->GetHullWidth() ); + + trace_t tr; + UTIL_TraceHull( start, end, mins, standMaxs, MASK_NPCSOLID, me, COLLISION_GROUP_NONE, &tr ); + if ( !tr.DidHit() ) + { + return false; + } + + UTIL_TraceHull( start, end, mins, crouchMaxs, MASK_NPCSOLID, me, COLLISION_GROUP_NONE, &tr ); + return !tr.DidHit(); +} + +//--------------------------------------------------------------------------------------------- +// Bot updates come a tenth of a second apart, too coarse to stop a climb inside the gap's few units, +// so the crouched bot is set just above the ladder's top, let go of the ladder and pushed into the gap: +// +// |#####| <- the gap's top +// ladder | o-> | landing +// || |_____|________ <- the gap's sill, the ladder's top +// || |#####| +void CNEOBotLadderClimb::UpdateCrawlDismount( CNEOBot *me, const Vector &toExit, bool bOnLadder ) +{ + me->PressCrouchButton( CRAWL_CROUCH_HOLD ); + if ( bOnLadder && !m_bCrawlPushed ) + { + m_bCrawlPushed = true; + + Vector crawlPos = me->GetLocomotionInterface()->GetFeet(); + crawlPos.z = GetCrawlFeetZ(); + trace_t tr; + UTIL_TraceHull( crawlPos, crawlPos, me->WorldAlignMins(), me->WorldAlignMaxs(), MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &tr ); + if ( tr.startsolid ) + { + return; + } + + me->ReleaseMoveUpButton(); + me->ReleaseForwardButton(); + me->SetAbsOrigin( crawlPos ); + me->SetMoveType( MOVETYPE_WALK ); + + // with a little rise, so the bot reaches the sill before it falls below it + Vector push = GetCrawlDirection( toExit ) * CRAWL_PUSH_SPEED; + push.z = CRAWL_PUSH_RISE_SPEED; + me->SetAbsVelocity( push ); + return; + } + + // Level, so the bot looks where it walks. In the air beside the ladder a press toward its face grabs it again, + // so the push alone carries the bot until it lands + me->GetBodyInterface()->AimHeadTowards( me->EyePosition() + toExit * 100.0f, IBody::MANDATORY, 0.1f, nullptr, "Crawling off the ladder" ); + ILocomotion *mover = me->GetLocomotionInterface(); + if ( mover->IsOnGround() ) + { + mover->Approach( mover->GetFeet() + toExit * 100.0f ); + } +} + //--------------------------------------------------------------------------------------------- void CNEOBotLadderClimb::EnterDismountPhase( CNEOBot *me ) { diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h index f95510f443..702db24dda 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h @@ -35,6 +35,10 @@ class CNEOBotLadderClimb : public Action bool IsNarrowLanding( CNEOBot *me ) const; Vector GetDismountPos( CNEOBot *me ) const; bool IsDismountBlocked( CNEOBot *me, const Vector &toExit ) const; + Vector GetCrawlDirection( const Vector &toExit ) const; + float GetCrawlFeetZ() const; + bool IsCrawlOnlyExit( CNEOBot *me, const Vector &toExit ) const; + void UpdateCrawlDismount( CNEOBot *me, const Vector &toExit, bool bOnLadder ); void ClaimLadder( CNEOBot *me ) const; const CNavLadder *m_ladder; @@ -43,6 +47,8 @@ class CNEOBotLadderClimb : public Action bool m_bGoingUp; bool m_bDismountPhase; bool m_bJumpedOffLadder; + bool m_bCrawlDismount; // stepping off crouched, through a gap only a crouched body fits + bool m_bCrawlPushed; // set in the gap and pushed off the ladder float m_flLastZ; Vector m_dismountPos; @@ -62,5 +68,11 @@ class CNEOBotLadderClimb : public Action static constexpr float DISMOUNT_CLEARANCE_HULLS = 1.5f; // Hull widths toward the exit that must be clear to step off static constexpr float NARROW_LANDING_HULLS = 0.5f; // Hull widths deep a landing must be to stand on (measured on dawn and dusk L1's 7 u wall cap) static constexpr float LANDING_INSET = 16.0f; // How far inside a landing's near edge the dismount aims: half a hull, so the whole hull lands + static constexpr float CRAWL_FEET_ABOVE_TOP = 4.0f; // Crouched, the feet step off this far above the ladder's top + static constexpr float CRAWL_DUCK_RANGE = 64.0f; // How far below that height the climb ducks for a crawl exit: a few bot updates' climb + static constexpr float CRAWL_SNAP_DIST = 24.0f; // How far from that height the bot may be set there: a little over a bot update's climb + static constexpr float CRAWL_PUSH_SPEED = 150.0f; // How fast the bot is pushed off the ladder into the gap, + static constexpr float CRAWL_PUSH_RISE_SPEED = 40.0f; // and up, a unit's rise + static constexpr float CRAWL_CROUCH_HOLD = 0.3f; // Seconds each crouch press lasts, outliving a bot update static constexpr int STALL_NUDGES = 3; // Sideways shimmies before a stalled climb jumps off (measured on subsurface L1 and saitama L2) }; From 57a6053758433426801723fea6359dff1c31deb0 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:05:29 -0600 Subject: [PATCH 2/2] Break a vent cover in a crawl gap on the bot's path A bot whose path runs through a crouch-height gap closed by a func_breakable vent cover never clears it: the check that nothing stands in front of the breakable sweeps the standing body, which meets the gap's lintel first, so the vent is dropped and the bot presses against it until the stuck monitor gives up. Sweep a crouched body when the standing one meets something else first. The look for breakables also stopped at a drop segment, so a bot leaving a duct by a drop had no path line to sweep. A drop's ledge is walked at the height of the floor above before the fall, so that leg now counts for breakables (the prop detour keeps ground legs only). Seen on ntre_rise_ctg, where ladders 3 and 4 stand in shafts entered through such vents: no bot ever climbed either before this. --- .../server/neo/bot/neo_bot_path_obstacles.cpp | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/src/game/server/neo/bot/neo_bot_path_obstacles.cpp b/src/game/server/neo/bot/neo_bot_path_obstacles.cpp index a5763938c4..254885102e 100644 --- a/src/game/server/neo/bot/neo_bot_path_obstacles.cpp +++ b/src/game/server/neo/bot/neo_bot_path_obstacles.cpp @@ -84,6 +84,26 @@ static void GetLineBounds( const CUtlVector< Vector > &line, float range, Vector } +//---------------------------------------------------------------------------------------------------------------- +// Return true if the body, walking the line up to the end of the given leg, meets the entity before anything else, +// passing through players, who move +static bool IsMetFirst( INextBot *bot, const BodyBox_t &body, const CUtlVector< Vector > &line, int lastLeg, CBaseEntity *entity ) +{ + CTraceFilterNoNPCsOrPlayer filter( bot->GetEntity(), COLLISION_GROUP_NONE ); + for ( int leg = 0; leg <= lastLeg; ++leg ) + { + trace_t result; + UTIL_TraceHull( line[ leg ], line[ leg + 1 ], body.mins, body.maxs, MASK_PLAYERSOLID, &filter, &result ); + if ( result.DidHit() ) + { + return result.m_pEnt == entity; + } + } + + return false; +} + + //---------------------------------------------------------------------------------------------------------------- // The breakable the body meets first walking the line, within PATH_OBSTACLE_BREAKABLE_RANGE, // when nothing else stands in front of it @@ -130,25 +150,32 @@ static CBaseEntity *FindBreakableInWay( INextBot *bot, const BodyBox_t &body, co } // what the body meets first on the way may be a crate, a door or a wall in front of the breakable: - // keep the breakable only if the body reaches it first, passing through players, who move - CTraceFilterNoNPCsOrPlayer filter( bot->GetEntity(), COLLISION_GROUP_NONE ); - for ( int leg = 0; leg <= nearestLeg; ++leg ) + // keep the breakable only if the body reaches it first + if ( IsMetFirst( bot, body, line, nearestLeg, nearest ) ) { - trace_t result; - UTIL_TraceHull( line[ leg ], line[ leg + 1 ], body.mins, body.maxs, MASK_PLAYERSOLID, &filter, &result ); - if ( result.DidHit() ) - { - return ( result.m_pEnt == nearest ) ? nearest : NULL; - } + return nearest; } - return NULL; + // or reaches it crouched, since a vent cover in a crawl gap sits under a lintel the standing body meets first + BodyBox_t crouched = body; + crouched.maxs.z = bot->GetBodyInterface()->GetCrouchHullHeight(); + return IsMetFirst( bot, crouched, line, nearestLeg, nearest ) ? nearest : NULL; } +//---------------------------------------------------------------------------------------------------------------- +// Where the path ahead ends: at the first segment not walked on the ground, +// or at the ledge of a drop, which is walked at the height of the floor above before the fall +enum PathAheadEnd_t +{ + PATH_AHEAD_TO_GROUND_END, + PATH_AHEAD_TO_DROP_LEDGE, +}; + //---------------------------------------------------------------------------------------------------------------- // The path ahead as a line from the bot's feet, over walked segments only, up to the range, // and, given a list for them, the segment each point of the line belongs to -static void GetPathAhead( const PathFollower &path, const Vector &feet, float range, CUtlVector< Vector > *line, CUtlVector< const Path::Segment * > *segments = NULL ) +static void GetPathAhead( const PathFollower &path, const Vector &feet, float range, CUtlVector< Vector > *line, + CUtlVector< const Path::Segment * > *segments = NULL, PathAheadEnd_t end = PATH_AHEAD_TO_GROUND_END ) { line->AddToTail( feet ); if ( segments ) @@ -159,7 +186,8 @@ static void GetPathAhead( const PathFollower &path, const Vector &feet, float ra float length = 0.0f; for ( const Path::Segment *seg = path.GetCurrentGoal(); seg && length < range; seg = path.NextSegment( seg ) ) { - if ( seg->ladder || seg->type != Path::ON_GROUND ) + const bool isDropLedge = ( end == PATH_AHEAD_TO_DROP_LEDGE && seg->type == Path::DROP_DOWN ); + if ( seg->ladder || ( seg->type != Path::ON_GROUND && !isDropLedge ) ) { break; } @@ -179,6 +207,10 @@ static void GetPathAhead( const PathFollower &path, const Vector &feet, float ra length += legLength; line->AddToTail( seg->pos ); + if ( isDropLedge ) + { + break; + } } } @@ -358,7 +390,8 @@ void CNEOBotPathObstacles::LookForBreakable( INextBot *bot, const PathFollower & m_breakable = NULL; CUtlVector< Vector > line; - GetPathAhead( path, bot->GetLocomotionInterface()->GetFeet(), PATH_OBSTACLE_BREAKABLE_RANGE, &line ); + // a vent cover can block the way to a drop's ledge, such as out of a crawl duct + GetPathAhead( path, bot->GetLocomotionInterface()->GetFeet(), PATH_OBSTACLE_BREAKABLE_RANGE, &line, NULL, PATH_AHEAD_TO_DROP_LEDGE ); if ( line.Count() < 2 ) { return;