From 658fe8e2d49a7c79b35988a95aa16cb77c1e8ee4 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:04:40 -0600 Subject: [PATCH 1/2] Blend the player upper body by speed like classic NT neo plays the walk upper body at full weight from the first 0.5 u/s of movement, so a player who drifts, peeks or eases to a stop bobs the torso at the 0.73 s walk loop instead of the 2 s idle breathing, with a 0.2 s crossfade at each start and stop. Classic NEOTOKYO, like the 2006 SDK player anim state it is built on, always plays the idle upper body and blends the walk, sprint or crouch walk upper body over it at 2D speed / 100, 250 or 85 u/s. Do the same with a second pair of aim layers and its own sequence transitioner. --- .../Multiplayer/multiplayer_animstate.cpp | 63 ++++++++++++++++++- .../Multiplayer/multiplayer_animstate.h | 5 ++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/game/shared/Multiplayer/multiplayer_animstate.cpp b/src/game/shared/Multiplayer/multiplayer_animstate.cpp index 2bf7396b19..a6fb31c25d 100644 --- a/src/game/shared/Multiplayer/multiplayer_animstate.cpp +++ b/src/game/shared/Multiplayer/multiplayer_animstate.cpp @@ -1229,6 +1229,13 @@ void CMultiPlayerAnimState::ComputeSequences( CStudioHdr *pStudioHdr ) #define DEFAULT_FIRE_WALK_NAME "walk_shoot_" #define DEFAULT_FIRE_RUN_NAME "run_shoot_" +// Classic NEOTOKYO blends the walking upper body over the idle one by 2D speed, at full weight from this speed up, +static constexpr float AIM_MOVE_FULL_SPEED_WALK = 100.0f; +// the sprinting upper body from this speed, so a sprinter at about 205 u/s keeps about a fifth of the idle one, +static constexpr float AIM_MOVE_FULL_SPEED_RUN = 250.0f; +// and the crouch walking upper body from this speed (the 2006 SDK player anim state's ANIM_TOPSPEED_* values) +static constexpr float AIM_MOVE_FULL_SPEED_CROUCH = 85.0f; + int CMultiPlayerAnimState::CalcSequenceIndex(const char* pBaseName, ...) { if (!m_pPlayer) @@ -1270,7 +1277,7 @@ void CMultiPlayerAnimState::UpdateAimSequenceLayers( ) { float flAimSequenceWeight = 1; - int iAimSequence = CalcAimLayerSequence(m_eCurrentMainSequenceActivity, false); + int iAimSequence = CalcAimLayerSequence(m_eCurrentMainSequenceActivity, bForceIdle); if (iAimSequence == -1) iAimSequence = 0; @@ -1432,6 +1439,58 @@ int CMultiPlayerAnimState::CalcAimLayerSequence(Activity activity, bool bForceId } } +// The idle upper body always plays, and the walk or run upper body is blended over it by speed, +// so a player moving slowly keeps the idle torso instead of the full walk bob +void CMultiPlayerAnimState::UpdateAimMoveLayers(float flCycle) +{ + CAnimationLayer* pMove0 = m_aGestureSlots[GESTURE_SLOT_AIM_MOVE].m_pAnimLayer; + CAnimationLayer* pMove1 = m_aGestureSlots[GESTURE_SLOT_AIM_MOVE2].m_pAnimLayer; + + const float flMoveWeight = CalcAimMoveWeight(); + if (flMoveWeight <= 0.0f) + { + pMove0->m_flWeight = 0.0f; + pMove1->m_flWeight = 0.0f; + return; + } + + UpdateAimSequenceLayers(flCycle, GESTURE_SLOT_AIM_MOVE, false, &m_MoveSequenceTransitioner, flMoveWeight); + + // the idle upper body keeps the weight the moving one leaves + m_aGestureSlots[GESTURE_SLOT_AIM].m_pAnimLayer->m_flWeight *= 1.0f - flMoveWeight; + m_aGestureSlots[GESTURE_SLOT_AIM2].m_pAnimLayer->m_flWeight *= 1.0f - flMoveWeight; +} + +float CMultiPlayerAnimState::CalcAimMoveWeight() +{ + float flFullSpeed; + switch (m_eCurrentMainSequenceActivity) + { + case ACT_NEO_MOVE_WALK: + flFullSpeed = AIM_MOVE_FULL_SPEED_WALK; + break; + + case ACT_NEO_MOVE_RUN: + flFullSpeed = AIM_MOVE_FULL_SPEED_RUN; + break; + + case ACT_NEO_MOVE_CROUCH: + flFullSpeed = AIM_MOVE_FULL_SPEED_CROUCH; + break; + + default: + return 0.0f; + } + + const float flSpeed = GetOuterXYSpeed(); + if (flSpeed <= MOVING_MINIMUM_SPEED) + { + return 0.0f; + } + + return MIN(flSpeed / flFullSpeed, 1.0f); +} + int CMultiPlayerAnimState::CalcFireSequence() { if (!m_pPlayer) @@ -1572,6 +1631,7 @@ void CMultiPlayerAnimState::ComputeMainSequence() #ifdef NEO float flCycle = pPlayer->GetCycle(); UpdateAimSequenceLayers(flCycle, GESTURE_SLOT_AIM, true, &m_IdleSequenceTransitioner, 1); + UpdateAimMoveLayers(flCycle); auto activeWeapon = GetBasePlayer()->GetActiveWeapon(); if (m_hActiveWeapon != activeWeapon) @@ -2627,6 +2687,7 @@ void CMultiPlayerAnimState::OnNewModel( void ) // Drop the queue along with the model - an empty queue simply snaps, which is what // the transitioner already does for STUDIO_SNAP sequences. m_IdleSequenceTransitioner.RemoveAll(); + m_MoveSequenceTransitioner.RemoveAll(); #endif ClearAnimationState(); } diff --git a/src/game/shared/Multiplayer/multiplayer_animstate.h b/src/game/shared/Multiplayer/multiplayer_animstate.h index 5233ea256a..53ec96ab63 100644 --- a/src/game/shared/Multiplayer/multiplayer_animstate.h +++ b/src/game/shared/Multiplayer/multiplayer_animstate.h @@ -84,6 +84,8 @@ enum #ifdef NEO GESTURE_SLOT_AIM, GESTURE_SLOT_AIM2, // For blending aim sequences + GESTURE_SLOT_AIM_MOVE, // Walk or run upper body, blended over the idle one by speed + GESTURE_SLOT_AIM_MOVE2, #endif GESTURE_SLOT_ATTACK_AND_RELOAD, GESTURE_SLOT_GRENADE, @@ -250,6 +252,8 @@ class CMultiPlayerAnimState int CalcSequenceIndex(const char* pBaseName, ...); void UpdateAimSequenceLayers(float flCycle, int iFirstLayer, bool bForceIdle, CSequenceTransitioner* pTransitioner, float flWeightScale); int CalcAimLayerSequence(Activity activity, bool bForceIdle); + void UpdateAimMoveLayers(float flCycle); + float CalcAimMoveWeight(); int CalcFireSequence(); int CalcReloadSequence(); void AddToGestureSlot(int iGestureSlot, int iGestureSequence, bool bAutoKill); @@ -362,6 +366,7 @@ class CMultiPlayerAnimState #ifdef NEO // This gives us smooth transitions between aim anim sequences on the client. CSequenceTransitioner m_IdleSequenceTransitioner; + CSequenceTransitioner m_MoveSequenceTransitioner; #endif }; From fdf3f3a8b4b04608448449cfd6f93b3f16e11311 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:14:38 -0600 Subject: [PATCH 2/2] Fade the moving upper body out on an abrupt stop A player who stops in one frame (a wall, or releasing walk) dropped the walk torso from near full weight to zero at once, a visible snap that the old 0.2 s crossfade between the torso sequences used to hide. Classic NT snaps here too; fade the last moving pose out over the same 0.2 s instead. A normal friction stop is slower than the fade, so the speed blend is unchanged there. --- .../Multiplayer/multiplayer_animstate.cpp | 28 +++++++++++++++---- .../Multiplayer/multiplayer_animstate.h | 1 + 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/game/shared/Multiplayer/multiplayer_animstate.cpp b/src/game/shared/Multiplayer/multiplayer_animstate.cpp index a6fb31c25d..8451fa9160 100644 --- a/src/game/shared/Multiplayer/multiplayer_animstate.cpp +++ b/src/game/shared/Multiplayer/multiplayer_animstate.cpp @@ -145,6 +145,9 @@ void CMultiPlayerAnimState::ClearAnimationState() m_bCurrentFeetYawInitialized = false; m_flLastAnimationStateClearTime = gpGlobals->curtime; m_nSpecificMainSequence = -1; +#ifdef NEO + m_flAimMoveWeight = 0.0f; +#endif ResetGestureSlots(); } @@ -1235,6 +1238,9 @@ static constexpr float AIM_MOVE_FULL_SPEED_WALK = 100.0f; static constexpr float AIM_MOVE_FULL_SPEED_RUN = 250.0f; // and the crouch walking upper body from this speed (the 2006 SDK player anim state's ANIM_TOPSPEED_* values) static constexpr float AIM_MOVE_FULL_SPEED_CROUCH = 85.0f; +// A player who stops dead (a wall, or releasing walk) fades the moving upper body out over the aim crossfade time, +// so the torso does not snap to idle in one frame +static constexpr float AIM_MOVE_FADE_OUT_TIME = 0.2f; int CMultiPlayerAnimState::CalcSequenceIndex(const char* pBaseName, ...) { @@ -1446,19 +1452,31 @@ void CMultiPlayerAnimState::UpdateAimMoveLayers(float flCycle) CAnimationLayer* pMove0 = m_aGestureSlots[GESTURE_SLOT_AIM_MOVE].m_pAnimLayer; CAnimationLayer* pMove1 = m_aGestureSlots[GESTURE_SLOT_AIM_MOVE2].m_pAnimLayer; - const float flMoveWeight = CalcAimMoveWeight(); - if (flMoveWeight <= 0.0f) + const float flLastWeight = m_flAimMoveWeight; + const float flTargetWeight = CalcAimMoveWeight(); + m_flAimMoveWeight = MAX(flTargetWeight, flLastWeight - gpGlobals->frametime / AIM_MOVE_FADE_OUT_TIME); + if (m_flAimMoveWeight <= 0.0f) { + m_flAimMoveWeight = 0.0f; pMove0->m_flWeight = 0.0f; pMove1->m_flWeight = 0.0f; return; } - UpdateAimSequenceLayers(flCycle, GESTURE_SLOT_AIM_MOVE, false, &m_MoveSequenceTransitioner, flMoveWeight); + if (flTargetWeight > 0.0f) + { + UpdateAimSequenceLayers(flCycle, GESTURE_SLOT_AIM_MOVE, false, &m_MoveSequenceTransitioner, m_flAimMoveWeight); + } + else + { + // stopped: hold the last moving pose and fade it out + pMove0->m_flWeight *= m_flAimMoveWeight / flLastWeight; + pMove1->m_flWeight *= m_flAimMoveWeight / flLastWeight; + } // the idle upper body keeps the weight the moving one leaves - m_aGestureSlots[GESTURE_SLOT_AIM].m_pAnimLayer->m_flWeight *= 1.0f - flMoveWeight; - m_aGestureSlots[GESTURE_SLOT_AIM2].m_pAnimLayer->m_flWeight *= 1.0f - flMoveWeight; + m_aGestureSlots[GESTURE_SLOT_AIM].m_pAnimLayer->m_flWeight *= 1.0f - m_flAimMoveWeight; + m_aGestureSlots[GESTURE_SLOT_AIM2].m_pAnimLayer->m_flWeight *= 1.0f - m_flAimMoveWeight; } float CMultiPlayerAnimState::CalcAimMoveWeight() diff --git a/src/game/shared/Multiplayer/multiplayer_animstate.h b/src/game/shared/Multiplayer/multiplayer_animstate.h index 53ec96ab63..ffa205c514 100644 --- a/src/game/shared/Multiplayer/multiplayer_animstate.h +++ b/src/game/shared/Multiplayer/multiplayer_animstate.h @@ -367,6 +367,7 @@ class CMultiPlayerAnimState // This gives us smooth transitions between aim anim sequences on the client. CSequenceTransitioner m_IdleSequenceTransitioner; CSequenceTransitioner m_MoveSequenceTransitioner; + float m_flAimMoveWeight = 0.0f; #endif };