Debounce movement stop for animations/network

Add a short "moving" hold to reduce flicker when movement briefly stops. Introduces a 300ms stop-hold for both proxy animation sync and local player movement sampling: add chrono include and movingHoldUntil to ProxyAppliedAnimationState, a kProxyMovingStopHoldDuration constant, ApplyMovingHoldState and BuildDebouncedDesiredState in F4TProxyAnimationSync, and replace direct desired-state usage with the debounced version. In main.cpp add kMovementStopHoldDuration, ResolveMovementHoldState, refactor BuildPlayerMovementState, add an overloaded GetPlayerMovementState, and separate debug vs network sampling (with separate movingHoldUntil variables) so logging uses per-frame debug state while network sends use the debounced network state. Also gate diagnostic logging behind a flag. These changes aim to stabilize isMoving/isSprinting transitions and avoid rapid toggles between frames and network updates.
This commit is contained in:
2026-06-04 17:34:16 +12:00
parent feec915f35
commit 2aab83087f
3 changed files with 137 additions and 42 deletions
+41 -1
View File
@@ -32,6 +32,7 @@ namespace
constexpr float kIdleSpeedThreshold = 1.0F;
constexpr float kSpeedEpsilon = 0.5F;
constexpr auto kProxyMovingStopHoldDuration = std::chrono::milliseconds{ 300 };
// Calibrated from local-player graph debug: walk/run graph Speed stays ~35-105 while
// network movementSpeed uses different units (fake client walk = 180).
@@ -188,6 +189,45 @@ namespace
return desired;
}
bool ApplyMovingHoldState(
F4T::ProxyAnimationSync::ProxyAppliedAnimationState& a_state,
bool a_rawIsMoving)
{
const auto now = std::chrono::steady_clock::now();
if (a_rawIsMoving) {
a_state.movingHoldUntil = now + kProxyMovingStopHoldDuration;
return true;
}
return a_state.movingHoldUntil.time_since_epoch().count() != 0 && now < a_state.movingHoldUntil;
}
DesiredProxyAnimationState BuildDebouncedDesiredState(
F4T::ProxyAnimationSync::ProxyAppliedAnimationState& a_state,
const F4T::RemotePlayerState::RemotePlayerState& a_remotePlayer)
{
const auto rawDesired = BuildDesiredState(a_remotePlayer);
auto desired = rawDesired;
desired.isMoving = ApplyMovingHoldState(a_state, rawDesired.isMoving);
if (!desired.isMoving) {
desired.isSprinting = false;
desired.graphSpeed = kGraphSpeedIdle;
} else {
if (!rawDesired.isMoving &&
a_state.hasAppliedGraphState &&
a_state.lastGraphSpeed > kGraphSpeedIdle) {
desired.graphSpeed = a_state.lastGraphSpeed;
}
if (!a_remotePlayer.isSprinting) {
desired.isSprinting = false;
}
}
return desired;
}
bool HasDesiredStateChanged(
const F4T::ProxyAnimationSync::ProxyAppliedAnimationState& a_state,
const DesiredProxyAnimationState& a_desired)
@@ -406,7 +446,7 @@ namespace F4T::ProxyAnimationSync
return;
}
const auto desired = BuildDesiredState(a_remotePlayer);
const auto desired = BuildDebouncedDesiredState(a_state, a_remotePlayer);
const auto shouldApply =
!a_state.hasAppliedGraphState ||
HasDesiredStateChanged(a_state, desired) ||
+94 -41
View File
@@ -53,6 +53,7 @@ namespace
constexpr auto kTransformSendRotationThreshold = 0.02F;
constexpr auto kTeleportDistanceThreshold = 5000.0F;
constexpr auto kMovingSpeedThreshold = 1.0F;
constexpr auto kMovementStopHoldDuration = 300ms;
constexpr auto kJumpVerticalSpeedThreshold = 80.0F;
constexpr auto kJumpStateHoldDuration = 400ms;
constexpr auto kJumpDiagnosticLogInterval = 1s;
@@ -240,35 +241,48 @@ namespace
return true;
}
PlayerMovementState GetPlayerMovementState(
bool ResolveMovementHoldState(
bool a_derivedMoving,
std::chrono::steady_clock::time_point a_currentTime,
std::chrono::steady_clock::time_point& a_movingHoldUntil)
{
if (a_derivedMoving) {
a_movingHoldUntil = a_currentTime + kMovementStopHoldDuration;
return true;
}
return a_movingHoldUntil.time_since_epoch().count() != 0 && a_currentTime < a_movingHoldUntil;
}
PlayerMovementState BuildPlayerMovementState(
RE::PlayerCharacter& a_player,
const PlayerTransform* a_previousSample,
std::chrono::steady_clock::time_point a_previousSampleTime,
const PlayerTransform& a_currentTransform,
std::chrono::steady_clock::time_point a_currentTime)
float a_movementSpeed,
float a_verticalSpeed,
std::chrono::steady_clock::time_point a_currentTime,
std::chrono::steady_clock::time_point& a_movingHoldUntil,
bool a_logDiagnostics)
{
static auto jumpHoldUntil = std::chrono::steady_clock::time_point{};
const auto elapsedSeconds = GetSampleElapsedSeconds(a_previousSample, a_previousSampleTime, a_currentTime);
const auto movementSpeed = GetMovementSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
const auto verticalSpeed = GetVerticalSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
const auto derivedMoving = a_movementSpeed >= kMovingSpeedThreshold;
const auto isMoving = ResolveMovementHoldState(derivedMoving, a_currentTime, a_movingHoldUntil);
const auto sneakDetection = GetSneakDetectionState(a_player);
const auto apiJumping = a_player.IsJumping();
const auto derivedJumping = verticalSpeed >= kJumpVerticalSpeedThreshold;
const auto derivedJumping = a_verticalSpeed >= kJumpVerticalSpeedThreshold;
if (apiJumping || derivedJumping) {
jumpHoldUntil = a_currentTime + kJumpStateHoldDuration;
}
const auto heldJumping = a_currentTime < jumpHoldUntil;
if (ShouldLogJumpDetection(heldJumping, a_currentTime)) {
if (a_logDiagnostics && ShouldLogJumpDetection(heldJumping, a_currentTime)) {
LogInfoWithLocalPlayerPrefix(std::format(
"Local jump detection: apiJumping={}, verticalSpeed={:.1f}, heldJumping={}",
apiJumping,
verticalSpeed,
a_verticalSpeed,
heldJumping));
}
if (ShouldLogSneakDetection(sneakDetection, a_currentTime)) {
if (a_logDiagnostics && ShouldLogSneakDetection(sneakDetection, a_currentTime)) {
LogInfoWithLocalPlayerPrefix(std::format(
"Local sneak detection: apiSneaking={}, actorStateSneaking={}, actorStateForceSneak={}, actorStateStance={}, finalIsSneaking={}",
sneakDetection.apiSneaking,
@@ -279,15 +293,37 @@ namespace
}
return {
movementSpeed >= kMovingSpeedThreshold,
isMoving,
a_player.DoGetSprinting(),
sneakDetection.finalIsSneaking,
heldJumping,
a_player.GetWeaponMagicDrawn(),
movementSpeed
a_movementSpeed
};
}
PlayerMovementState GetPlayerMovementState(
RE::PlayerCharacter& a_player,
const PlayerTransform* a_previousSample,
std::chrono::steady_clock::time_point a_previousSampleTime,
const PlayerTransform& a_currentTransform,
std::chrono::steady_clock::time_point a_currentTime,
std::chrono::steady_clock::time_point& a_movingHoldUntil,
bool a_logDiagnostics)
{
const auto elapsedSeconds = GetSampleElapsedSeconds(a_previousSample, a_previousSampleTime, a_currentTime);
const auto movementSpeed = GetMovementSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
const auto verticalSpeed = GetVerticalSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
return BuildPlayerMovementState(
a_player,
movementSpeed,
verticalSpeed,
a_currentTime,
a_movingHoldUntil,
a_logDiagnostics);
}
bool HasMovementStateChanged(const PlayerMovementState& a_previous, const PlayerMovementState& a_current)
{
return a_previous.isMoving != a_current.isMoving ||
@@ -388,53 +424,70 @@ namespace
static bool hasLastTransform = false;
static PlayerTransform lastSentTransform{};
static PlayerTransform lastSampleTransform{};
static PlayerTransform lastDebugSampleTransform{};
static PlayerLocation lastKnownLocation{};
static PlayerMovementState lastSentMovementState{};
static auto lastSendTime = std::chrono::steady_clock::time_point{};
static auto lastLogTime = std::chrono::steady_clock::time_point{};
static auto lastSampleTime = std::chrono::steady_clock::time_point{};
static auto lastDebugSampleTime = std::chrono::steady_clock::time_point{};
static auto debugMovingHoldUntil = std::chrono::steady_clock::time_point{};
static auto networkMovingHoldUntil = std::chrono::steady_clock::time_point{};
const auto currentTransform = GetPlayerTransform(*player);
const auto currentLocation = GetPlayerLocation(*player);
const auto currentTime = std::chrono::steady_clock::now();
const auto currentMovementState =
GetPlayerMovementState(*player, hasLastTransform ? &lastSampleTransform : nullptr, lastSampleTime, currentTransform, currentTime);
const auto debugMovementState = GetPlayerMovementState(
*player,
hasLastTransform ? &lastDebugSampleTransform : nullptr,
lastDebugSampleTime,
currentTransform,
currentTime,
debugMovingHoldUntil,
true);
F4T::LocalAnimationGraphDebug::UpdateLocalPlayerAnimationGraphDebug(
*player,
currentMovementState.isMoving,
currentMovementState.isSprinting,
currentMovementState.isSneaking,
currentMovementState.isJumping,
currentMovementState.weaponDrawn,
currentMovementState.movementSpeed,
debugMovementState.isMoving,
debugMovementState.isSprinting,
debugMovementState.isSneaking,
debugMovementState.isJumping,
debugMovementState.weaponDrawn,
debugMovementState.movementSpeed,
currentTime);
if (!hasLastTransform) {
lastSentTransform = currentTransform;
lastSampleTransform = currentTransform;
lastDebugSampleTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSentMovementState = currentMovementState;
lastSentMovementState = debugMovementState;
lastSendTime = currentTime;
lastLogTime = currentTime;
lastSampleTime = currentTime;
lastDebugSampleTime = currentTime;
hasLastTransform = true;
return;
}
// Network movement uses the last sent transform/time so speed reflects the full
// send interval instead of a single frame delta (which flickers isMoving).
const auto networkMovementState = GetPlayerMovementState(
*player,
&lastSentTransform,
lastSendTime,
currentTransform,
currentTime,
networkMovingHoldUntil,
false);
const auto* movementType =
GetSpecialMovementType(lastKnownLocation, currentLocation, lastSentTransform, currentTransform);
const auto isSpecialMovement = movementType != std::string_view{ "normal" };
const auto hasMeaningfulTransformDelta = HasMeaningfulTransformDelta(lastSentTransform, currentTransform);
const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, currentMovementState);
const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, networkMovementState);
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged) {
lastSampleTransform = currentTransform;
lastSampleTime = currentTime;
lastDebugSampleTransform = currentTransform;
lastDebugSampleTime = currentTime;
return;
}
if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) {
lastSampleTransform = currentTransform;
lastSampleTime = currentTime;
return;
}
@@ -449,16 +502,16 @@ namespace
currentLocation.cellId,
currentLocation.hasWorldspaceId,
currentLocation.worldspaceId,
currentMovementState.isMoving,
currentMovementState.isSprinting,
currentMovementState.isSneaking,
currentMovementState.isJumping,
currentMovementState.weaponDrawn,
currentMovementState.movementSpeed);
networkMovementState.isMoving,
networkMovementState.isSprinting,
networkMovementState.isSneaking,
networkMovementState.isJumping,
networkMovementState.weaponDrawn,
networkMovementState.movementSpeed);
if (sentTransform) {
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSentMovementState = currentMovementState;
lastSentMovementState = networkMovementState;
lastSendTime = currentTime;
if (isSpecialMovement) {
@@ -470,8 +523,8 @@ namespace
}
}
lastSampleTransform = currentTransform;
lastSampleTime = currentTime;
lastDebugSampleTransform = currentTransform;
lastDebugSampleTime = currentTime;
}
void StartPlayerPositionPolling()