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:
@@ -224,6 +224,7 @@ namespace
|
|||||||
std::chrono::steady_clock::time_point candidateSearchStartTime{};
|
std::chrono::steady_clock::time_point candidateSearchStartTime{};
|
||||||
std::chrono::steady_clock::time_point settleStartTime{};
|
std::chrono::steady_clock::time_point settleStartTime{};
|
||||||
std::chrono::steady_clock::time_point heldSinceTime{};
|
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 };
|
float initialRemoteSpawnHeading{ 0.0F };
|
||||||
bool spawnAttempted{ false };
|
bool spawnAttempted{ false };
|
||||||
bool spawnAttemptInProgress{ false };
|
bool spawnAttemptInProgress{ false };
|
||||||
@@ -506,17 +507,52 @@ namespace
|
|||||||
const RE::NiPoint3& a_nextPosition,
|
const RE::NiPoint3& a_nextPosition,
|
||||||
float a_heading,
|
float a_heading,
|
||||||
const RemotePlayer& a_remotePlayer,
|
const RemotePlayer& a_remotePlayer,
|
||||||
bool a_shouldSnap)
|
bool a_shouldSnap,
|
||||||
|
ProxyActorSlot* a_optSlot = nullptr)
|
||||||
{
|
{
|
||||||
(void)a_remotePlayer;
|
(void)a_remotePlayer;
|
||||||
(void)a_shouldSnap;
|
|
||||||
|
|
||||||
// Move proxy to next position using SetPosition for reliable smooth movement
|
// Always update heading
|
||||||
// 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);
|
|
||||||
a_proxy.SetHeading(a_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
|
// NEW: Enable AI-driven animation mode
|
||||||
@@ -4425,10 +4461,10 @@ namespace
|
|||||||
RE::NiPoint3 targetPosition{};
|
RE::NiPoint3 targetPosition{};
|
||||||
targetPosition.x = a_remotePlayer.x;
|
targetPosition.x = a_remotePlayer.x;
|
||||||
targetPosition.y = a_remotePlayer.y;
|
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);
|
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, true, &a_slot);
|
||||||
a_slot.lifecycleState = ProxyLifecycleState::kActive;
|
a_slot.lifecycleState = ProxyLifecycleState::kActive;
|
||||||
a_slot.holdReason.reset();
|
a_slot.holdReason.reset();
|
||||||
a_slot.heldSinceTime = {};
|
a_slot.heldSinceTime = {};
|
||||||
a_slot.reusableLogged = false;
|
a_slot.reusableLogged = false;
|
||||||
@@ -4582,11 +4618,11 @@ namespace
|
|||||||
targetPosition.y = a_remotePlayer.y;
|
targetPosition.y = a_remotePlayer.y;
|
||||||
targetPosition.z = a_remotePlayer.z;
|
targetPosition.z = a_remotePlayer.z;
|
||||||
|
|
||||||
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
|
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
|
||||||
const auto nextPosition = shouldSnap ? targetPosition : LerpPosition(a_proxy.GetPosition(), targetPosition, kProxyPositionLerpAlpha);
|
const auto nextPosition = shouldSnap ? targetPosition : LerpPosition(a_proxy.GetPosition(), targetPosition, kProxyPositionLerpAlpha);
|
||||||
|
|
||||||
ApplyRuntimeProxyTransform(a_proxy, nextPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap);
|
ApplyRuntimeProxyTransform(a_proxy, nextPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot);
|
||||||
a_slot.lifecycleState = ProxyLifecycleState::kActive;
|
a_slot.lifecycleState = ProxyLifecycleState::kActive;
|
||||||
a_slot.holdReason.reset();
|
a_slot.holdReason.reset();
|
||||||
a_slot.heldSinceTime = {};
|
a_slot.heldSinceTime = {};
|
||||||
a_slot.reusableLogged = false;
|
a_slot.reusableLogged = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user