Add position smoothing and movement snapping

Introduce simple position smoothing for normal proxy movement and snap special movements. Adds kProxyPositionLerpAlpha, Lerp/LerpPosition helpers, and ShouldSnapRemoteMovement to lerp proxy position on normal updates while snapping on cell_change, worldspace_change, and teleport. Update MoveProxyToRemotePlayer to use lerp or snap, keep heading snapping (TODO: wrapped angle smoothing), and add throttled/readable logs for smoothing start, first smoothed movement, snaps, and idle-on-cell-mismatch. Minor messaging change on disconnect to leave proxy idle at last position. Update docs (architecture, setup, protocol) and dev log to reflect the new behavior and testing notes; dynamic spawning/multiple proxies remain out of scope.
This commit is contained in:
2026-06-02 12:47:12 +12:00
parent 46b2fa8b9b
commit 8dbe3117b0
5 changed files with 112 additions and 7 deletions
+2 -1
View File
@@ -38,6 +38,8 @@ This means the plugin should be able to:
* Store remote player state internally
* Move the single placed test proxy actor from remote player state on the game
thread without dynamically spawning actors
* Smooth normal proxy movement and snap cell changes, worldspace changes, and
teleports safely
## Not Included Yet
@@ -45,7 +47,6 @@ The plugin does not currently include:
* Dynamic actor spawning
* Multiple proxy actors
* Interpolation or smoothing
* Remote player spawning
* Combat sync
* Quest sync
+66 -3
View File
@@ -23,6 +23,7 @@ namespace
constexpr auto kTestPluginName = "Fallout4Together_Test.esp";
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F;
constexpr float kProxyPositionLerpAlpha = 0.25F;
constexpr auto kMovementInterval = 200ms;
constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s;
@@ -47,6 +48,8 @@ namespace
bool g_safetyOffsetMovedLogged = false;
bool g_remoteStateMovementStartedLogged = false;
bool g_remoteStateMovedLogged = false;
bool g_proxySmoothingEnabledLogged = false;
bool g_firstSmoothedMovementLogged = false;
auto g_lastMovementTime = std::chrono::steady_clock::time_point{};
std::optional<std::uint32_t> g_representedPlayerId;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
@@ -102,6 +105,25 @@ namespace
}
}
float Lerp(float a_current, float a_target, float a_alpha)
{
return a_current + ((a_target - a_current) * a_alpha);
}
RE::NiPoint3 LerpPosition(const RE::NiPoint3& a_current, const RE::NiPoint3& a_target, float a_alpha)
{
return {
Lerp(a_current.x, a_target.x, a_alpha),
Lerp(a_current.y, a_target.y, a_alpha),
Lerp(a_current.z, a_target.z, a_alpha)
};
}
bool ShouldSnapRemoteMovement(std::string_view a_movementType)
{
return a_movementType == "cell_change" || a_movementType == "worldspace_change" || a_movementType == "teleport";
}
bool HasEditorId(const RE::TESForm& a_form, std::string_view a_editorId)
{
const auto* editorId = a_form.GetFormEditorID();
@@ -392,10 +414,11 @@ namespace
});
if (!representedPlayerStillExists) {
LogInfoWithLocalPlayerPrefix(std::format(
"Represented remote player {} disconnected or disappeared from state; stopping proxy movement. TODO: hide or disable the proxy actor after disconnect.",
"Represented remote player {} disconnected or disappeared from state; leaving proxy actor idle at its last position. TODO: hide or disable the proxy actor after disconnect.",
*g_representedPlayerId));
g_representedPlayerId.reset();
g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
}
}
@@ -426,10 +449,17 @@ namespace
if (g_representedPlayerId != selectedPlayer->playerId) {
g_representedPlayerId = selectedPlayer->playerId;
g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
if (!g_remoteStateMovementStartedLogged) {
LogInfoWithLocalPlayerPrefix("Remote-state-driven proxy movement started.");
g_remoteStateMovementStartedLogged = true;
}
if (!g_proxySmoothingEnabledLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor smoothing enabled for normal movement with position lerp alpha {:.2f}. Rotation still snaps; TODO: add wrapped angle smoothing later.",
kProxyPositionLerpAlpha));
g_proxySmoothingEnabledLogged = true;
}
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor {} is representing remote player {}.",
kProxyEditorId,
@@ -458,14 +488,41 @@ namespace
void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{
auto targetPosition = a_proxy.GetPosition();
RE::NiPoint3 targetPosition = a_proxy.GetPosition();
targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z;
a_proxy.SetPosition(targetPosition, true);
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
const auto nextPosition = shouldSnap ? targetPosition : LerpPosition(a_proxy.GetPosition(), targetPosition, kProxyPositionLerpAlpha);
a_proxy.SetPosition(nextPosition, true);
// TODO: Smooth rotation with wrapped angle interpolation. Heading snaps for now
// to avoid wraparound bugs near the angle boundary.
a_proxy.SetHeading(a_remotePlayer.angleZ);
if (shouldSnap) {
LogThrottledInfo(
"proxy_snap_" + std::to_string(a_remotePlayer.playerId) + "_" + a_remotePlayer.movementType,
std::format(
"Snapped proxy actor {} to remote player {} because movementType={} at X={:.2f}, Y={:.2f}, Z={:.2f}.",
kProxyEditorId,
a_remotePlayer.playerId,
a_remotePlayer.movementType,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z));
} else if (!g_firstSmoothedMovementLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"First smoothed proxy movement for remote player {}: current moves toward target X={:.2f}, Y={:.2f}, Z={:.2f} with alpha {:.2f}.",
a_remotePlayer.playerId,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z,
kProxyPositionLerpAlpha));
g_firstSmoothedMovementLogged = true;
}
if (!g_remoteStateMovedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Moved proxy actor {} from remote player {} state for the first time: X={:.2f}, Y={:.2f}, Z={:.2f}, AngleZ={:.2f}.",
@@ -525,6 +582,12 @@ namespace F4T::ProxyActorController
}
if (!IsRemotePlayerInSameLocation(*selectedRemotePlayer, *player, *parentCell)) {
LogThrottledInfo(
"proxy_idle_cell_mismatch_" + std::to_string(selectedRemotePlayer->playerId),
std::format(
"Proxy actor {} is idle because represented remote player {} is not in the same cell. TODO: hide or disable the proxy actor after cell leave.",
kProxyEditorId,
selectedRemotePlayer->playerId));
return;
}