feat: implement hybrid movement for AI-driven animations

IMPLEMENTATION BREAKTHROUGH:
- Proxies are real NPCs with active AIProcess
- AIProcess naturally calls Move() each game frame
- Move() updates character controller velocity
- Animation graph evaluates velocity and plays animations
- Solution: Let AI drive movement naturally, not just SetPosition()

CHANGES:
1. Added lastPositionSyncTime to ProxyActorSlot
   - Tracks when position was last overridden for sync

2. Rewrote ApplyRuntimeProxyTransform():
   - New parameter: optional ProxyActorSlot pointer
   - Hybrid logic:
     * Don't override position most of the time
     * AI's natural Move() calls update velocity automatically
     * Animations play because velocity is real
     * Override position only when needed:
       - Snap required (cell change, teleport)
       - Sync timer expired (~300ms)
       - Drift exceeds threshold (>150 units)
   - Falls back to always-sync for fallback proxy (no slot)

3. Updated call sites:
   - MoveSlotProxyToRemotePlayer: passes &a_slot
   - RestoreSlotProxyForRemotePlayer: passes &a_slot
   - MoveProxyToRemotePlayer: uses default nullptr (fallback)

RESULT:
- Proxy uses natural AI movement most of the time
- Animation system sees real velocity updates
- Animations should now play automatically
- Network sync maintained with periodic position override
- Smooth visual movement + proper animations = immersion!

Next: TEST IN GAME to verify animations work
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 14:49:49 +12:00
co-authored by Cursor
parent 2ac4b8f07c
commit 90ddcb19d8
+50 -14
View File
@@ -224,6 +224,7 @@ namespace
std::chrono::steady_clock::time_point candidateSearchStartTime{};
std::chrono::steady_clock::time_point settleStartTime{};
std::chrono::steady_clock::time_point heldSinceTime{};
std::chrono::steady_clock::time_point lastPositionSyncTime{}; // NEW: Track when we last overrode position
float initialRemoteSpawnHeading{ 0.0F };
bool spawnAttempted{ false };
bool spawnAttemptInProgress{ false };
@@ -506,17 +507,52 @@ namespace
const RE::NiPoint3& a_nextPosition,
float a_heading,
const RemotePlayer& a_remotePlayer,
bool a_shouldSnap)
bool a_shouldSnap,
ProxyActorSlot* a_optSlot = nullptr)
{
(void)a_remotePlayer;
(void)a_shouldSnap;
// Move proxy to next position using SetPosition for reliable smooth movement
// Note: Animations are not achievable with SetPosition-based actors due to Fallout 4's
// animation system requiring active AIProcess-driven locomotion packages.
// See docs/dev-log.md for full architectural analysis.
a_proxy.SetPosition(a_nextPosition, true);
// Always update heading
a_proxy.SetHeading(a_heading);
// Hybrid movement strategy for animations:
// - Let the proxy's AIProcess call Move() naturally each frame (this plays animations)
// - Only override position periodically to maintain network sync
constexpr float DRIFT_THRESHOLD = 150.0F; // Units before forcing sync
constexpr float SYNC_INTERVAL = 0.3F; // Seconds between position resets
bool shouldOverridePosition = a_shouldSnap;
// Check if we need to sync based on time or drift
if (a_optSlot && !a_shouldSnap) {
auto now = std::chrono::steady_clock::now();
auto timeSinceSync = std::chrono::duration<float>(now - a_optSlot->lastPositionSyncTime).count();
// Check drift from target
RE::NiPoint3 currentPosition = a_proxy.GetPosition();
float distanceDrift = (a_nextPosition - currentPosition).Length();
// Override position if sync timer expired or drift is too large
if (timeSinceSync >= SYNC_INTERVAL || distanceDrift > DRIFT_THRESHOLD) {
shouldOverridePosition = true;
}
} else if (!a_optSlot) {
// If no slot provided, fall back to always syncing (for compatibility)
shouldOverridePosition = true;
}
if (shouldOverridePosition) {
a_proxy.SetPosition(a_nextPosition, true);
// Record sync time for next check
if (a_optSlot) {
a_optSlot->lastPositionSyncTime = std::chrono::steady_clock::now();
}
}
// When we don't override position, the proxy's natural AI movement (via Move())
// will update velocity and trigger animations automatically!
}
// NEW: Enable AI-driven animation mode
@@ -4425,10 +4461,10 @@ namespace
RE::NiPoint3 targetPosition{};
targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
targetPosition.z = a_remotePlayer.z;
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, true);
a_slot.lifecycleState = ProxyLifecycleState::kActive;
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, true, &a_slot);
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
@@ -4582,11 +4618,11 @@ namespace
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
const auto nextPosition = shouldSnap ? targetPosition : LerpPosition(a_proxy.GetPosition(), targetPosition, kProxyPositionLerpAlpha);
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
const auto nextPosition = shouldSnap ? targetPosition : LerpPosition(a_proxy.GetPosition(), targetPosition, kProxyPositionLerpAlpha);
ApplyRuntimeProxyTransform(a_proxy, nextPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap);
a_slot.lifecycleState = ProxyLifecycleState::kActive;
ApplyRuntimeProxyTransform(a_proxy, nextPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot);
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;