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
+3 -2
View File
@@ -23,7 +23,9 @@ both send and receive paths:
9. The plugin and fake client remove disconnected players from their remote
player tables.
10. On the game-thread update path, the plugin reads a copied remote-player
snapshot and moves the single placed proxy actor in `F4TTestCell01`.
snapshot and moves the single placed proxy actor in `F4TTestCell01`,
smoothing normal movement and snapping cell changes, worldspace changes, and
teleports.
The Fallout 4 plugin still does not dynamically spawn remote actors.
@@ -102,7 +104,6 @@ update path.
- Remote actor spawning
- Multiple proxy actors
- Interpolation or smoothing
- Combat sync
- Quest sync
- Inventory sync
+38
View File
@@ -644,3 +644,41 @@ Use this format for future updates:
- Build the plugin with `xmake build`.
- Run the single-client, two-client, and disconnect tests in `F4TTestCell01`.
## 2026-06-02
### What Changed
- Added simple position smoothing for normal remote proxy movement.
- Kept proxy actor movement on the `F4TProxyActorController` game-thread update
path using copied remote-player snapshots.
- Kept the single-proxy, lowest-`playerId` selection behavior.
- Added snap movement for `cell_change`, `worldspace_change`, and `teleport`
remote transforms.
- Left rotation snapping in place with a TODO for wrapped angle smoothing.
- Left the proxy idle at its last position when the represented remote player
disconnects, disappears, or leaves the same cell.
- Added readable one-shot and throttled logs for smoothing, first smoothed
movement, special movement snaps, disappearance, and same-cell mismatch idle
behavior.
### What Worked
- Dynamic spawning and multiple proxy actors remain out of scope.
- Networking still only updates plain remote-player state and does not touch
Fallout 4 actors or references.
### What Broke
- Not tested in-game yet.
### Notes
- Hiding or disabling the proxy actor is still deferred. The current safe
behavior is to leave the placed proxy idle at its last valid position.
### Next Steps
- Build the plugin with `xmake build`.
- Run the single-client, two-client, special-movement, disconnect, and cell-leave
tests in `F4TTestCell01`.
+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;
}
+3 -1
View File
@@ -72,12 +72,14 @@ player exists.
- The fake client removes disconnected remote players from its state table.
- The plugin moves the single placed test proxy actor from a thread-safe
remote-player snapshot on the game-thread update path.
- Normal proxy movement is smoothed with a simple position lerp, while
`cell_change`, `worldspace_change`, and `teleport` transforms snap directly to
avoid slow movement across large discontinuities.
No Fallout 4 remote actor is dynamically spawned yet.
## Planned Later
- Add interpolation or smoothing after snap movement is proven stable.
- Add one proxy actor per remote player after the single-proxy test is stable.
- Dynamically spawn remote actors only after placed-proxy movement is stable.