Inject controller velocity & send anim events

Add runtime velocity injection to the proxy character controller so the engine has velocity available when evaluating animation graphs (accessed via Actor::currentProcess->middleHigh->charController). Introduce animation graph event constants, TrySendGraphEvent helper, and SendLocomotionTransitionEvents to fire move/sprint/sneak start/stop events on state transitions (with diagnostic logging). Call SendLocomotionTransitionEvents before applying the new graph state so FSM transitions are driven correctly. Respect compile-time flags (kEnableProxyLocomotionSync, kEnableProxySneakSync).
This commit is contained in:
2026-06-03 23:09:42 +12:00
parent a11c7d4475
commit feec915f35
2 changed files with 81 additions and 9 deletions
+10 -9
View File
@@ -620,18 +620,19 @@ namespace
a_proxy.SetHeading(a_heading); a_proxy.SetHeading(a_heading);
a_proxy.SetPosition(positionToApply, true); a_proxy.SetPosition(positionToApply, true);
// Phase 6.1: Inject character controller velocity
// The animation system requires velocity on the character controller to evaluate
// animation graphs and blend between locomotion states. Without velocity, the
// "Speed" graph variable alone doesn't trigger animations.
// Access controller via: Actor::currentProcess -> AIProcess::middleHigh -> MiddleHighProcessData::charController
if (a_proxy.currentProcess && a_proxy.currentProcess->middleHigh && a_proxy.currentProcess->middleHigh->charController) {
const RE::NiPoint3 latestDeltaPos = a_nextPosition - currentPosition;
InjectProxyMovementVelocity(*a_proxy.currentProcess->middleHigh->charController, latestDeltaPos, deltaSeconds);
}
if (a_optSlot) { if (a_optSlot) {
a_optSlot->lastPositionSyncTime = now; a_optSlot->lastPositionSyncTime = now;
} }
// NOTE: Movement is kinematic (SetPosition-based). We deliberately do NOT
// inject character-controller velocity here. Persistent havok velocity plus
// the proxy's active AIProcess caused the actor to drift/wander around the
// network target (visible as teleporting), especially while the remote was
// idle. Locomotion animation is driven separately by writing the "Speed"
// graph variable in ProxyAnimationSync (Phase 1), so velocity injection is
// unnecessary for animation blending. Per-frame SetPosition in
// ApplySmoothFrameMovement pins the actor to the network target.
} }
// NEW: Enable AI-driven animation mode // NEW: Enable AI-driven animation mode
+71
View File
@@ -99,6 +99,29 @@ namespace
return true; return true;
} }
// Creation Engine humanoid behavior animation events. These drive the locomotion
// state machine transitions. Writing the "Speed" graph variable alone only blends
// WITHIN a movement state; the graph must be transitioned INTO that state via an
// animation event (the same FSM-driving mechanism TiltedEvolution uses via ForceAction).
// NotifyAnimationGraph returns true when the active graph recognizes/consumes the event,
// which doubles as a diagnostic for which event names this FO4 graph accepts.
namespace GraphEvent
{
inline constexpr const char* kMoveStart = "moveStart";
inline constexpr const char* kMoveStop = "moveStop";
inline constexpr const char* kSprintStart = "SprintStart";
inline constexpr const char* kSprintStop = "SprintStop";
inline constexpr const char* kSneakStart = "SneakStart";
inline constexpr const char* kSneakStop = "SneakStop";
}
bool TrySendGraphEvent(RE::Actor& a_proxy, const char* a_eventName)
{
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
const RE::BSFixedString eventName{ a_eventName };
return graphHolder->NotifyAnimationGraphImpl(eventName);
}
bool TrySetGraphBool( bool TrySetGraphBool(
RE::IAnimationGraphManagerHolder& a_holder, RE::IAnimationGraphManagerHolder& a_holder,
const char* a_variableName, const char* a_variableName,
@@ -298,6 +321,49 @@ namespace
return success; return success;
} }
// Fire locomotion animation events on state transitions to actually drive the
// proxy's animation graph FSM (idle <-> move, sprint, sneak). Edge-triggered, mirroring
// how the game itself emits these events when the local player starts/stops moving.
void SendLocomotionTransitionEvents(
RE::Actor& a_proxy,
const F4T::ProxyAnimationSync::ProxyAppliedAnimationState& a_previous,
const DesiredProxyAnimationState& a_desired,
std::uint32_t a_remotePlayerId)
{
if constexpr (!kEnableProxyLocomotionSync) {
return;
}
const auto actorId = a_proxy.GetFormID();
const bool wasInitialized = a_previous.hasAppliedGraphState;
auto fireEvent = [&](const char* a_eventName) {
const bool accepted = TrySendGraphEvent(a_proxy, a_eventName);
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy locomotion event '{}' for remote player {}: actor={:08X}, graphAccepted={}.",
a_eventName,
a_remotePlayerId,
actorId,
accepted));
};
// Movement start/stop is the critical transition that takes the graph out of idle.
if (!wasInitialized || a_previous.lastIsMoving != a_desired.isMoving) {
fireEvent(a_desired.isMoving ? GraphEvent::kMoveStart : GraphEvent::kMoveStop);
}
// Sprint sub-state transition (only meaningful while moving).
if (wasInitialized && a_previous.lastIsSprinting != a_desired.isSprinting) {
fireEvent(a_desired.isSprinting ? GraphEvent::kSprintStart : GraphEvent::kSprintStop);
}
if constexpr (kEnableProxySneakSync) {
if (!wasInitialized || a_previous.lastIsSneaking != a_desired.isSneaking) {
fireEvent(a_desired.isSneaking ? GraphEvent::kSneakStart : GraphEvent::kSneakStop);
}
}
}
void LogAppliedTransition( void LogAppliedTransition(
std::uint32_t a_remotePlayerId, std::uint32_t a_remotePlayerId,
std::uint32_t a_actorId, std::uint32_t a_actorId,
@@ -353,6 +419,11 @@ namespace F4T::ProxyAnimationSync
const auto initial = !a_state.hasAppliedGraphState; const auto initial = !a_state.hasAppliedGraphState;
const auto changed = HasDesiredStateChanged(a_state, desired); const auto changed = HasDesiredStateChanged(a_state, desired);
if (ApplyDesiredStateToGraphDescriptorBased(a_proxy, desired, a_remotePlayer.playerId)) { if (ApplyDesiredStateToGraphDescriptorBased(a_proxy, desired, a_remotePlayer.playerId)) {
// Drive the animation graph FSM with locomotion events. Must run before we
// overwrite a_state below, since transition detection compares against the
// previously applied state.
SendLocomotionTransitionEvents(a_proxy, a_state, desired, a_remotePlayer.playerId);
if (initial || changed) { if (initial || changed) {
LogAppliedTransition(a_remotePlayer.playerId, actorId, desired, initial); LogAppliedTransition(a_remotePlayer.playerId, actorId, desired, initial);
} }