Add basic movement state to transform packets
Add optional movement-state fields (isMoving, isSprinting, isSneaking, isJumping, weaponDrawn, movementSpeed) to transform packets and wire them end-to-end. Plugin changes: extend F4TNetworking API and RemotePlayerState, derive movement speed/jump state on the game-thread, validate values, include fields when formatting transform JSON, and add throttled remote movement-state logging. main.cpp adds sampling, speed/vertical calculations, jump hold logic, and change-detection to avoid extra sends. Networking parsing (F4TNetworking.cpp) reads optional booleans/floats safely, preserves backwards compatibility, and clears movement-state logs on disconnect. Proxy controller includes a TODO note for future animation use. Server and docs: update protocol and packet docs, dev-log, server README, and fake_client.py to parse/display optional fields safely. Also add several .cursor rule files for coding, documentation, protocol, project overview, and testing guidance. This milestone prepares the data model for later animation/behavior work while keeping existing relay behavior unchanged.
This commit is contained in:
@@ -18,5 +18,11 @@ namespace F4T::Networking
|
||||
bool a_hasCellId = false,
|
||||
std::uint32_t a_cellId = 0,
|
||||
bool a_hasWorldspaceId = false,
|
||||
std::uint32_t a_worldspaceId = 0);
|
||||
std::uint32_t a_worldspaceId = 0,
|
||||
bool a_isMoving = false,
|
||||
bool a_isSprinting = false,
|
||||
bool a_isSneaking = false,
|
||||
bool a_isJumping = false,
|
||||
bool a_weaponDrawn = false,
|
||||
float a_movementSpeed = 0.0F);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ namespace F4T::RemotePlayerState
|
||||
std::string worldspaceId;
|
||||
std::optional<double> clientTime;
|
||||
std::optional<double> serverTime;
|
||||
bool isMoving{ false };
|
||||
bool isSprinting{ false };
|
||||
bool isSneaking{ false };
|
||||
bool isJumping{ false };
|
||||
bool weaponDrawn{ false };
|
||||
float movementSpeed{ 0.0F };
|
||||
std::chrono::steady_clock::time_point lastReceivedLocalTime{};
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <format>
|
||||
@@ -42,6 +43,7 @@ namespace
|
||||
std::atomic_bool g_receiveThreadRunning{ false };
|
||||
std::thread g_receiveThread;
|
||||
std::unordered_map<std::uint32_t, std::chrono::steady_clock::time_point> g_lastRemoteUpdateLogTimes;
|
||||
std::unordered_map<std::uint32_t, std::chrono::steady_clock::time_point> g_lastRemoteMovementStateLogTimes;
|
||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
|
||||
|
||||
using Json = nlohmann::json;
|
||||
@@ -226,6 +228,20 @@ namespace
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<bool> ReadBool(const Json& a_packet, const char* a_fieldName)
|
||||
{
|
||||
const auto field = a_packet.find(a_fieldName);
|
||||
if (field == a_packet.end()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
if (field->is_boolean()) {
|
||||
return field->get<bool>();
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<std::string> ReadString(const Json& a_packet, const char* a_fieldName)
|
||||
{
|
||||
const auto field = a_packet.find(a_fieldName);
|
||||
@@ -248,6 +264,18 @@ namespace
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ShouldLogRemoteMovementState(std::uint32_t a_playerId)
|
||||
{
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto lastLog = g_lastRemoteMovementStateLogTimes.find(a_playerId);
|
||||
if (lastLog != g_lastRemoteMovementStateLogTimes.end() && now - lastLog->second < kRemoteUpdateLogInterval) {
|
||||
return false;
|
||||
}
|
||||
|
||||
g_lastRemoteMovementStateLogTimes[a_playerId] = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<std::string_view> GetMissingRequiredTransformField(const Json& a_packet)
|
||||
{
|
||||
constexpr std::array<std::string_view, 7> requiredFields{
|
||||
@@ -328,6 +356,10 @@ namespace
|
||||
|
||||
const auto movementType = ReadString(a_packet, "movementType").value_or("normal");
|
||||
const auto worldspaceId = ReadString(a_packet, "worldspaceId").value_or("");
|
||||
auto movementSpeed = ReadDouble(a_packet, "movementSpeed").value_or(0.0);
|
||||
if (!std::isfinite(movementSpeed) || movementSpeed < 0.0) {
|
||||
movementSpeed = 0.0;
|
||||
}
|
||||
|
||||
F4T::RemotePlayerState::RemotePlayerState remoteState{
|
||||
*playerId,
|
||||
@@ -340,6 +372,12 @@ namespace
|
||||
worldspaceId,
|
||||
ReadDouble(a_packet, "clientTime").value_or(0.0),
|
||||
ReadDouble(a_packet, "serverTime").value_or(0.0),
|
||||
ReadBool(a_packet, "isMoving").value_or(false),
|
||||
ReadBool(a_packet, "isSprinting").value_or(false),
|
||||
ReadBool(a_packet, "isSneaking").value_or(false),
|
||||
ReadBool(a_packet, "isJumping").value_or(false),
|
||||
ReadBool(a_packet, "weaponDrawn").value_or(false),
|
||||
static_cast<float>(movementSpeed),
|
||||
std::chrono::steady_clock::now()
|
||||
};
|
||||
|
||||
@@ -358,6 +396,18 @@ namespace
|
||||
remoteState.cellId,
|
||||
remoteState.worldspaceId));
|
||||
}
|
||||
|
||||
if (ShouldLogRemoteMovementState(*playerId)) {
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Remote player {} movement state: moving={}, sprinting={}, sneaking={}, jumping={}, weaponDrawn={}, speed={:.1f}",
|
||||
remoteState.playerId,
|
||||
remoteState.isMoving,
|
||||
remoteState.isSprinting,
|
||||
remoteState.isSneaking,
|
||||
remoteState.isJumping,
|
||||
remoteState.weaponDrawn,
|
||||
remoteState.movementSpeed));
|
||||
}
|
||||
}
|
||||
|
||||
void HandleDisconnectPacket(const Json& a_packet)
|
||||
@@ -369,6 +419,7 @@ namespace
|
||||
}
|
||||
|
||||
g_lastRemoteUpdateLogTimes.erase(*playerId);
|
||||
g_lastRemoteMovementStateLogTimes.erase(*playerId);
|
||||
|
||||
if (F4T::RemotePlayerState::RemoveRemotePlayer(*playerId)) {
|
||||
LogInfoWithLocalPlayerPrefix(std::format("Remote player {} disconnected and was removed.", *playerId));
|
||||
@@ -580,6 +631,7 @@ namespace F4T::Networking
|
||||
F4T::RemotePlayerState::ClearAssignedPlayerId();
|
||||
F4T::RemotePlayerState::ClearRemotePlayers();
|
||||
g_lastRemoteUpdateLogTimes.clear();
|
||||
g_lastRemoteMovementStateLogTimes.clear();
|
||||
StartReceiveThread();
|
||||
|
||||
LogInfoWithLocalPlayerPrefix(
|
||||
@@ -593,6 +645,7 @@ namespace F4T::Networking
|
||||
F4T::RemotePlayerState::ClearAssignedPlayerId();
|
||||
F4T::RemotePlayerState::ClearRemotePlayers();
|
||||
g_lastRemoteUpdateLogTimes.clear();
|
||||
g_lastRemoteMovementStateLogTimes.clear();
|
||||
|
||||
if (g_winsockInitialized) {
|
||||
WSACleanup();
|
||||
@@ -614,7 +667,13 @@ namespace F4T::Networking
|
||||
bool a_hasCellId,
|
||||
std::uint32_t a_cellId,
|
||||
bool a_hasWorldspaceId,
|
||||
std::uint32_t a_worldspaceId)
|
||||
std::uint32_t a_worldspaceId,
|
||||
bool a_isMoving,
|
||||
bool a_isSprinting,
|
||||
bool a_isSneaking,
|
||||
bool a_isJumping,
|
||||
bool a_weaponDrawn,
|
||||
float a_movementSpeed)
|
||||
{
|
||||
if (!IsConnectedToServer() && !ConnectToLocalServer()) {
|
||||
return false;
|
||||
@@ -623,17 +682,24 @@ namespace F4T::Networking
|
||||
const auto clientTime =
|
||||
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
|
||||
std::array<char, 320> packet{};
|
||||
std::array<char, 512> packet{};
|
||||
auto packetSize = std::snprintf(
|
||||
packet.data(),
|
||||
packet.size(),
|
||||
"{\"type\":\"transform\",\"x\":%.2f,\"y\":%.2f,\"z\":%.2f,\"angleZ\":%.2f,\"movementType\":\"%s\",\"clientTime\":%.3f",
|
||||
"{\"type\":\"transform\",\"x\":%.2f,\"y\":%.2f,\"z\":%.2f,\"angleZ\":%.2f,\"movementType\":\"%s\",\"clientTime\":%.3f,"
|
||||
"\"isMoving\":%s,\"isSprinting\":%s,\"isSneaking\":%s,\"isJumping\":%s,\"weaponDrawn\":%s,\"movementSpeed\":%.1f",
|
||||
a_x,
|
||||
a_y,
|
||||
a_z,
|
||||
a_angleZ,
|
||||
a_movementType ? a_movementType : "normal",
|
||||
clientTime);
|
||||
clientTime,
|
||||
a_isMoving ? "true" : "false",
|
||||
a_isSprinting ? "true" : "false",
|
||||
a_isSneaking ? "true" : "false",
|
||||
a_isJumping ? "true" : "false",
|
||||
a_weaponDrawn ? "true" : "false",
|
||||
a_movementSpeed);
|
||||
|
||||
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
|
||||
LogWarningWithLocalPlayerPrefix("Could not format Fallout 4 Together transform packet.");
|
||||
|
||||
@@ -489,6 +489,8 @@ namespace
|
||||
|
||||
void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
|
||||
{
|
||||
// TODO: Later animation/behavior milestones can read movement state from
|
||||
// RemotePlayer here. This milestone keeps the proxy visual movement unchanged.
|
||||
RE::NiPoint3 targetPosition = a_proxy.GetPosition();
|
||||
targetPosition.x = a_remotePlayer.x;
|
||||
targetPosition.y = a_remotePlayer.y;
|
||||
|
||||
+156
-3
@@ -1,6 +1,7 @@
|
||||
#include "F4TNetworking.h"
|
||||
#include "F4TProxyActorController.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
@@ -24,11 +25,25 @@ namespace
|
||||
std::uint32_t worldspaceId;
|
||||
};
|
||||
|
||||
struct PlayerMovementState
|
||||
{
|
||||
bool isMoving;
|
||||
bool isSprinting;
|
||||
bool isSneaking;
|
||||
bool isJumping;
|
||||
bool weaponDrawn;
|
||||
float movementSpeed;
|
||||
};
|
||||
|
||||
constexpr auto kTransformSendInterval = 100ms;
|
||||
constexpr auto kTransformLogInterval = 1s;
|
||||
constexpr auto kTransformSendPositionThreshold = 3.0F;
|
||||
constexpr auto kTransformSendRotationThreshold = 0.02F;
|
||||
constexpr auto kTeleportDistanceThreshold = 5000.0F;
|
||||
constexpr auto kMovingSpeedThreshold = 1.0F;
|
||||
constexpr auto kJumpVerticalSpeedThreshold = 80.0F;
|
||||
constexpr auto kJumpStateHoldDuration = 400ms;
|
||||
constexpr auto kJumpDiagnosticLogInterval = 1s;
|
||||
|
||||
std::string GetLocalPlayerLogMessage(std::string_view a_message)
|
||||
{
|
||||
@@ -99,6 +114,120 @@ namespace
|
||||
return (deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ);
|
||||
}
|
||||
|
||||
float GetSampleElapsedSeconds(
|
||||
const PlayerTransform* a_previousSample,
|
||||
std::chrono::steady_clock::time_point a_previousSampleTime,
|
||||
std::chrono::steady_clock::time_point a_currentTime)
|
||||
{
|
||||
if (!a_previousSample || a_previousSampleTime.time_since_epoch().count() == 0) {
|
||||
return 0.0F;
|
||||
}
|
||||
|
||||
const auto elapsedSeconds = std::chrono::duration<float>(a_currentTime - a_previousSampleTime).count();
|
||||
if (!std::isfinite(elapsedSeconds) || elapsedSeconds <= 0.0F) {
|
||||
return 0.0F;
|
||||
}
|
||||
|
||||
return elapsedSeconds;
|
||||
}
|
||||
|
||||
float GetMovementSpeed(
|
||||
const PlayerTransform* a_previousSample,
|
||||
const PlayerTransform& a_currentTransform,
|
||||
float a_elapsedSeconds)
|
||||
{
|
||||
if (!a_previousSample || a_elapsedSeconds <= 0.0F) {
|
||||
return 0.0F;
|
||||
}
|
||||
|
||||
auto movementSpeed = std::sqrt(GetSquaredPositionDistance(*a_previousSample, a_currentTransform)) / a_elapsedSeconds;
|
||||
if (!std::isfinite(movementSpeed) || movementSpeed < 0.0F) {
|
||||
return 0.0F;
|
||||
}
|
||||
|
||||
return movementSpeed;
|
||||
}
|
||||
|
||||
float GetVerticalSpeed(
|
||||
const PlayerTransform* a_previousSample,
|
||||
const PlayerTransform& a_currentTransform,
|
||||
float a_elapsedSeconds)
|
||||
{
|
||||
if (!a_previousSample || a_elapsedSeconds <= 0.0F) {
|
||||
return 0.0F;
|
||||
}
|
||||
|
||||
const auto verticalSpeed = (a_currentTransform.z - a_previousSample->z) / a_elapsedSeconds;
|
||||
if (!std::isfinite(verticalSpeed)) {
|
||||
return 0.0F;
|
||||
}
|
||||
|
||||
return verticalSpeed;
|
||||
}
|
||||
|
||||
bool ShouldLogJumpDetection(bool a_isJumping, std::chrono::steady_clock::time_point a_currentTime)
|
||||
{
|
||||
static bool lastLoggedJumping = false;
|
||||
static auto lastLogTime = std::chrono::steady_clock::time_point{};
|
||||
|
||||
const auto jumpStateChanged = a_isJumping != lastLoggedJumping;
|
||||
const auto logThrottledJumpState =
|
||||
a_isJumping && (lastLogTime.time_since_epoch().count() == 0 || a_currentTime - lastLogTime >= kJumpDiagnosticLogInterval);
|
||||
if (!jumpStateChanged && !logThrottledJumpState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastLoggedJumping = a_isJumping;
|
||||
lastLogTime = a_currentTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
PlayerMovementState GetPlayerMovementState(
|
||||
RE::PlayerCharacter& a_player,
|
||||
const PlayerTransform* a_previousSample,
|
||||
std::chrono::steady_clock::time_point a_previousSampleTime,
|
||||
const PlayerTransform& a_currentTransform,
|
||||
std::chrono::steady_clock::time_point a_currentTime)
|
||||
{
|
||||
static auto jumpHoldUntil = std::chrono::steady_clock::time_point{};
|
||||
|
||||
const auto elapsedSeconds = GetSampleElapsedSeconds(a_previousSample, a_previousSampleTime, a_currentTime);
|
||||
const auto movementSpeed = GetMovementSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
|
||||
const auto verticalSpeed = GetVerticalSpeed(a_previousSample, a_currentTransform, elapsedSeconds);
|
||||
const auto apiJumping = a_player.IsJumping();
|
||||
const auto derivedJumping = verticalSpeed >= kJumpVerticalSpeedThreshold;
|
||||
if (apiJumping || derivedJumping) {
|
||||
jumpHoldUntil = a_currentTime + kJumpStateHoldDuration;
|
||||
}
|
||||
|
||||
const auto heldJumping = a_currentTime < jumpHoldUntil;
|
||||
if (ShouldLogJumpDetection(heldJumping, a_currentTime)) {
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Local jump detection: apiJumping={}, verticalSpeed={:.1f}, heldJumping={}",
|
||||
apiJumping,
|
||||
verticalSpeed,
|
||||
heldJumping));
|
||||
}
|
||||
|
||||
return {
|
||||
movementSpeed >= kMovingSpeedThreshold,
|
||||
a_player.DoGetSprinting(),
|
||||
a_player.IsSneaking(),
|
||||
heldJumping,
|
||||
a_player.GetWeaponMagicDrawn(),
|
||||
movementSpeed
|
||||
};
|
||||
}
|
||||
|
||||
bool HasMovementStateChanged(const PlayerMovementState& a_previous, const PlayerMovementState& a_current)
|
||||
{
|
||||
return a_previous.isMoving != a_current.isMoving ||
|
||||
a_previous.isSprinting != a_current.isSprinting ||
|
||||
a_previous.isSneaking != a_current.isSneaking ||
|
||||
a_previous.isJumping != a_current.isJumping ||
|
||||
a_previous.weaponDrawn != a_current.weaponDrawn;
|
||||
}
|
||||
|
||||
bool HasMeaningfulTransformDelta(const PlayerTransform& a_previous, const PlayerTransform& a_current)
|
||||
{
|
||||
auto deltaAngleZ = a_current.angleZ - a_previous.angleZ;
|
||||
@@ -178,7 +307,7 @@ namespace
|
||||
|
||||
void PollLocalPlayerTransform()
|
||||
{
|
||||
const auto* player = TryGetLocalPlayer();
|
||||
auto* player = TryGetLocalPlayer();
|
||||
if (!player) {
|
||||
static bool playerUnavailableWarningLogged = false;
|
||||
if (!playerUnavailableWarningLogged) {
|
||||
@@ -190,18 +319,26 @@ namespace
|
||||
|
||||
static bool hasLastTransform = false;
|
||||
static PlayerTransform lastSentTransform{};
|
||||
static PlayerTransform lastSampleTransform{};
|
||||
static PlayerLocation lastKnownLocation{};
|
||||
static PlayerMovementState lastSentMovementState{};
|
||||
static auto lastSendTime = std::chrono::steady_clock::time_point{};
|
||||
static auto lastLogTime = std::chrono::steady_clock::time_point{};
|
||||
static auto lastSampleTime = std::chrono::steady_clock::time_point{};
|
||||
|
||||
const auto currentTransform = GetPlayerTransform(*player);
|
||||
const auto currentLocation = GetPlayerLocation(*player);
|
||||
const auto currentTime = std::chrono::steady_clock::now();
|
||||
const auto currentMovementState =
|
||||
GetPlayerMovementState(*player, hasLastTransform ? &lastSampleTransform : nullptr, lastSampleTime, currentTransform, currentTime);
|
||||
if (!hasLastTransform) {
|
||||
lastSentTransform = currentTransform;
|
||||
lastSampleTransform = currentTransform;
|
||||
lastKnownLocation = currentLocation;
|
||||
lastSentMovementState = currentMovementState;
|
||||
lastSendTime = currentTime;
|
||||
lastLogTime = currentTime;
|
||||
lastSampleTime = currentTime;
|
||||
hasLastTransform = true;
|
||||
return;
|
||||
}
|
||||
@@ -209,11 +346,17 @@ namespace
|
||||
const auto* movementType =
|
||||
GetSpecialMovementType(lastKnownLocation, currentLocation, lastSentTransform, currentTransform);
|
||||
const auto isSpecialMovement = movementType != std::string_view{ "normal" };
|
||||
if (!isSpecialMovement && !HasMeaningfulTransformDelta(lastSentTransform, currentTransform)) {
|
||||
const auto hasMeaningfulTransformDelta = HasMeaningfulTransformDelta(lastSentTransform, currentTransform);
|
||||
const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, currentMovementState);
|
||||
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged) {
|
||||
lastSampleTransform = currentTransform;
|
||||
lastSampleTime = currentTime;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) {
|
||||
lastSampleTransform = currentTransform;
|
||||
lastSampleTime = currentTime;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -227,10 +370,17 @@ namespace
|
||||
currentLocation.hasCellId,
|
||||
currentLocation.cellId,
|
||||
currentLocation.hasWorldspaceId,
|
||||
currentLocation.worldspaceId);
|
||||
currentLocation.worldspaceId,
|
||||
currentMovementState.isMoving,
|
||||
currentMovementState.isSprinting,
|
||||
currentMovementState.isSneaking,
|
||||
currentMovementState.isJumping,
|
||||
currentMovementState.weaponDrawn,
|
||||
currentMovementState.movementSpeed);
|
||||
if (sentTransform) {
|
||||
lastSentTransform = currentTransform;
|
||||
lastKnownLocation = currentLocation;
|
||||
lastSentMovementState = currentMovementState;
|
||||
lastSendTime = currentTime;
|
||||
|
||||
if (isSpecialMovement) {
|
||||
@@ -241,6 +391,9 @@ namespace
|
||||
lastLogTime = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
lastSampleTransform = currentTransform;
|
||||
lastSampleTime = currentTime;
|
||||
}
|
||||
|
||||
void StartPlayerPositionPolling()
|
||||
|
||||
Reference in New Issue
Block a user