补充输入重放
输入重放是指当收到一个服务器状态包时以那个包为初始状态,然后重新计算所有未确认的输入来得到当前帧的状态,然后再对当前角色的位置进行插值移动。服务器状态包只能给出前几帧的权威数据,而当前帧的状态则需要通过输入回放来得到
This commit is contained in:
parent
da2b93e59c
commit
75289b5690
|
|
@ -9,7 +9,16 @@
|
|||
"Bash(dotnet Temp/Bin/Debug/Network.EditMode.Tests/Network.EditMode.Tests.dll)",
|
||||
"Bash(openspec list:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"Bash(dotnet-script ../.claude/openspec/opsx.ts new change \"local-player-prediction-reconciliation\")",
|
||||
"Bash(npx --yes @anthropic-ai/opsx-cli status)",
|
||||
"Bash(npx openspec@latest --version)",
|
||||
"Bash(/c/Users/September/AppData/Roaming/npm/openspec --version)",
|
||||
"Bash(/c/Users/September/AppData/Roaming/npm/openspec new:*)",
|
||||
"Bash(/c/Users/September/AppData/Roaming/npm/openspec status:*)",
|
||||
"Bash(/c/Users/September/AppData/Roaming/npm/openspec instructions:*)",
|
||||
"Bash(grep -l \"reconcil\" openspec/specs/*/spec.md)",
|
||||
"Bash(/c/Users/September/AppData/Roaming/npm/openspec list:*)"
|
||||
]
|
||||
},
|
||||
"outputStyle": "default"
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ crashlytics-build.properties
|
|||
*.xmind
|
||||
/.dotnet
|
||||
/.dotnet-home
|
||||
|
||||
/openspec/changes/archive
|
||||
|
||||
EditMode-err.txt
|
||||
|
||||
|
|
|
|||
|
|
@ -425,6 +425,7 @@ GameObject:
|
|||
- component: {fileID: 8938569698484985372}
|
||||
- component: {fileID: -1362768914916555191}
|
||||
- component: {fileID: -8136683798838004576}
|
||||
- component: {fileID: -5923497047956986603}
|
||||
m_Layer: 0
|
||||
m_Name: Player
|
||||
m_TagString: Untagged
|
||||
|
|
@ -520,6 +521,7 @@ MonoBehaviour:
|
|||
- {fileID: 2100000, guid: 2955df004504a714e947e2971499d036, type: 2}
|
||||
_camera: {fileID: 6308356813655391139}
|
||||
_movement: {fileID: 5069958635149219085}
|
||||
_movementResolver: {fileID: -5923497047956986603}
|
||||
_playerUI: {fileID: 6308356813253026696}
|
||||
_isControlled: 0
|
||||
--- !u!114 &5069958635149219085
|
||||
|
|
@ -534,11 +536,7 @@ MonoBehaviour:
|
|||
m_Script: {fileID: 11500000, guid: db8117151f564304bae153aa55c0a960, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_speed: 2
|
||||
_rigid: {fileID: -1362768914916555191}
|
||||
_inputComponent: {fileID: 8938569698484985372}
|
||||
_applyServerCorrection: 1
|
||||
_lerpRate: 0.1
|
||||
--- !u!114 &8938569698484985372
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
|
|
@ -601,3 +599,19 @@ BoxCollider:
|
|||
serializedVersion: 3
|
||||
m_Size: {x: 1, y: 1, z: 1}
|
||||
m_Center: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &-5923497047956986603
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 6308356814245253661}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fcfebc0989c4bf04cacf1c633d49a8bb, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_speed: 2
|
||||
_movement: {fileID: 5069958635149219085}
|
||||
_inputComponent: {fileID: 8938569698484985372}
|
||||
_applyServerCorrection: 1
|
||||
|
|
|
|||
|
|
@ -1,399 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using UnityEngine;
|
||||
using Vector3 = UnityEngine.Vector3;
|
||||
|
||||
public class MovementComponent : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int _speed = 2;
|
||||
[SerializeField] private Rigidbody _rigid;
|
||||
[SerializeField] private InputComponent _inputComponent;
|
||||
|
||||
private Player _master;
|
||||
private const float TurnSpeedDegreesPerSecond = 180f;
|
||||
|
||||
// 测试时设为 false,可接收服务器状态日志但不应用校正
|
||||
[SerializeField] private bool _applyServerCorrection = true;
|
||||
private const float UnityYawOffsetDegrees = 90f;
|
||||
|
||||
// Server authoritative movement cadence used for replay substepping.
|
||||
// This matches ServerAuthoritativeMovementConfiguration.SimulationInterval (50ms).
|
||||
private const float kServerSimulationStepSeconds = 0.05f;
|
||||
|
||||
private bool _isControlled = false;
|
||||
|
||||
private Vector3 _serverPosition;
|
||||
private bool _hasServerState = false;
|
||||
private ClientAuthoritativePlayerStateSnapshot _lastAuthoritativeState;
|
||||
private ControlledPlayerVisualCorrectionState _activeVisualCorrection;
|
||||
|
||||
public long Tick { get; private set; } = 0;
|
||||
private long _startTickOffset = 0;
|
||||
private long _currentTickOffset = 0;
|
||||
private float _simulationAccumulator = 0f;
|
||||
private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
|
||||
|
||||
private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new();
|
||||
[SerializeField] private float _lerpRate = 0.1f;
|
||||
private Vector3 _lastAimDirection = Vector3.forward;
|
||||
|
||||
public void Init(bool isControlled, Player master, ClientMovementBootstrap bootstrap)
|
||||
{
|
||||
Init(isControlled, master, bootstrap.AuthoritativeMoveSpeed, bootstrap.ServerTick);
|
||||
}
|
||||
|
||||
public void Init(bool isControlled, Player master, int speed = 0, long serverTick = 0)
|
||||
{
|
||||
_master = master;
|
||||
_isControlled = isControlled;
|
||||
_speed = speed;
|
||||
_startTickOffset = serverTick;
|
||||
_rigid.interpolation = isControlled ? RigidbodyInterpolation.None : RigidbodyInterpolation.Interpolate;
|
||||
_rigid.isKinematic = !isControlled;
|
||||
_rigid.velocity = Vector3.zero;
|
||||
_rigid.angularVelocity = Vector3.zero;
|
||||
|
||||
// 设置 InputComponent 的 playerId
|
||||
if (_inputComponent != null)
|
||||
{
|
||||
_inputComponent.InjectPlayerId(master.PlayerId);
|
||||
}
|
||||
|
||||
if (serverTick != 0 && _isControlled && MainUI.Instance != null)
|
||||
MainUI.Instance.OnStartTickOffsetChanged(serverTick);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_isControlled)
|
||||
{
|
||||
if (_inputComponent != null)
|
||||
{
|
||||
MainUI.Instance.OnClientTickChanged(_inputComponent.CurrentTick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 订阅 InputComponent 的事件来记录预测输入
|
||||
if (_inputComponent != null)
|
||||
{
|
||||
_inputComponent.OnMoveInputCreated += HandleMoveInputCreated;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_inputComponent != null)
|
||||
{
|
||||
_inputComponent.OnMoveInputCreated -= HandleMoveInputCreated;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMoveInputCreated(MoveInput moveInput)
|
||||
{
|
||||
// 记录到预测缓冲区,用于后续的服务器状态校正和回放
|
||||
_predictionBuffer.Record(moveInput);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试用:设置是否应用服务器状态校正(默认 true)
|
||||
/// 设为 false 时只打印服务器状态日志,不影响本地位置
|
||||
/// </summary>
|
||||
public void SetApplyServerCorrection(bool apply)
|
||||
{
|
||||
_applyServerCorrection = apply;
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
if (_isControlled)
|
||||
{
|
||||
if (_hasServerState)
|
||||
{
|
||||
if (MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnServerPosChanged(_serverPosition);
|
||||
}
|
||||
|
||||
Reconcile(_lastAuthoritativeState);
|
||||
_hasServerState = false;
|
||||
}
|
||||
|
||||
// 累积时间,按服务端 50ms 步长进行模拟
|
||||
_simulationAccumulator += Time.fixedDeltaTime;
|
||||
while (_simulationAccumulator >= kServerSimulationStepSeconds)
|
||||
{
|
||||
var pendingCount = _predictionBuffer.PendingInputs.Count;
|
||||
if (pendingCount == 0)
|
||||
{
|
||||
// 没有待处理的输入,清零累积时间,跳出循环
|
||||
_simulationAccumulator = 0f;
|
||||
break;
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"[SimulateLoop] frame={Time.frameCount} accum={_simulationAccumulator:F4} pendingCount={pendingCount}");
|
||||
|
||||
// 使用最近发送的 MoveInput(来自 predictionBuffer)而非实时输入,
|
||||
// 确保客户端与服务端的输入时序一致
|
||||
Simulate(GetLatestPredictedInput());
|
||||
_simulationAccumulator -= kServerSimulationStepSeconds;
|
||||
}
|
||||
|
||||
// 注意:模拟时间现在在 Simulate() 内部通过 AccumulateLatest 累加
|
||||
}
|
||||
else
|
||||
{
|
||||
var sample = _remoteSnapshotInterpolator.Sample(Time.time);
|
||||
if (sample.HasValue)
|
||||
{
|
||||
_rigid.MovePosition(sample.Position);
|
||||
_rigid.MoveRotation(sample.Rotation);
|
||||
_rigid.velocity = sample.Velocity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Reconcile(ClientAuthoritativePlayerStateSnapshot snapshot)
|
||||
{
|
||||
_serverPosition = snapshot.Position;
|
||||
if (!_predictionBuffer.TryApplyAuthoritativeState(snapshot.SourceState, Time.time, out var replayInputs))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var predictedPosition = _rigid.position;
|
||||
var predictedRotation = _rigid.rotation;
|
||||
var correction = ControlledPlayerCorrection.Resolve(
|
||||
predictedPosition,
|
||||
predictedRotation,
|
||||
snapshot.Position,
|
||||
snapshot.RotationQuaternion,
|
||||
new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond,
|
||||
snapDistanceMultiplier: 5f),
|
||||
_activeVisualCorrection);
|
||||
|
||||
_activeVisualCorrection = correction.NextState;
|
||||
_rigid.position = correction.Position;
|
||||
_rigid.rotation = correction.Rotation;
|
||||
_rigid.velocity = correction.UsedHardSnap ? snapshot.Velocity : Vector3.zero;
|
||||
_rigid.angularVelocity = Vector3.zero;
|
||||
|
||||
// 位置已被校正到服务器位置,不需要再 ReplayPendingInputs
|
||||
// 因为 pendingInputs 中的 SimulatedDurationSeconds 是累积的模拟时间,
|
||||
// 如果用来 replay 会导致多余的移动。清空 pendingInputs 让客户端从校正位置重新开始
|
||||
if (replayInputs.Count > 0)
|
||||
{
|
||||
_predictionBuffer.ClearPendingInputs();
|
||||
}
|
||||
|
||||
// 清零 accumulator 防止 FixedUpdate 中再次 Simulate 导致重复移动
|
||||
_simulationAccumulator = 0f;
|
||||
|
||||
if (MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnCorrectionMagnitudeChanged?.Invoke(
|
||||
predictedPosition,
|
||||
snapshot.Position,
|
||||
correction.PositionError,
|
||||
correction.RotationErrorDegrees);
|
||||
MainUI.Instance.OnAcknowledgedMoveTickChanged?.Invoke(_predictionBuffer.LastAcknowledgedMoveTick ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 ResolveAimDirection()
|
||||
{
|
||||
var planarForward = Vector3.ProjectOnPlane(_rigid.transform.forward, Vector3.up);
|
||||
if (ClientGameplayInputFlow.HasPlanarInput(planarForward))
|
||||
{
|
||||
_lastAimDirection = planarForward;
|
||||
return planarForward;
|
||||
}
|
||||
|
||||
return ClientGameplayInputFlow.HasPlanarInput(_lastAimDirection)
|
||||
? _lastAimDirection
|
||||
: ResolveHeadingForward(UnityYawToHeading(_rigid.rotation.eulerAngles.y));
|
||||
}
|
||||
|
||||
private void Simulate(Vector3 input)
|
||||
{
|
||||
Debug.Log(
|
||||
$"[Simulate] frame={Time.frameCount} input=({input.x:F2},{input.z:F2}) accum={_simulationAccumulator:F4}");
|
||||
ApplyTankMovement(input.x, input.z, kServerSimulationStepSeconds);
|
||||
|
||||
//ApplyTankMovement(-input.x, input.z, kServerSimulationStepSeconds);
|
||||
|
||||
// 每次 Simulate 后累加模拟时间(用于 Reconcile 时的重放)
|
||||
_predictionBuffer.AccumulateLatest(kServerSimulationStepSeconds);
|
||||
|
||||
if (_isControlled)
|
||||
{
|
||||
if (MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnClientPosChanged(_rigid.position);
|
||||
}
|
||||
|
||||
// 打印客户端当前状态,用于与服务端状态对比
|
||||
Debug.Log($"[ClientState] Tick={Tick} " +
|
||||
$"Pos=({_rigid.position.x:F3}, {_rigid.position.y:F3}, {_rigid.position.z:F3}) " +
|
||||
$"Rot={_rigid.rotation.eulerAngles.y:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
public void OnAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
|
||||
{
|
||||
if (_isControlled)
|
||||
{
|
||||
_lastAuthoritativeState = snapshot;
|
||||
|
||||
// 打印服务端状态,用于与客户端计算结果对比
|
||||
Debug.Log($"[ServerState] Tick={snapshot.SourceState.Tick} " +
|
||||
$"Pos=({snapshot.SourceState.Position.X:F3}, {snapshot.SourceState.Position.Y:F3}, {snapshot.SourceState.Position.Z:F3}) " +
|
||||
$"Rot={snapshot.SourceState.Rotation:F2} " +
|
||||
$"Vel=({snapshot.SourceState.Velocity.X:F3}, {snapshot.SourceState.Velocity.Y:F3}, {snapshot.SourceState.Velocity.Z:F3}) " +
|
||||
$"AckTick={snapshot.AcknowledgedMoveTick}");
|
||||
|
||||
// 清理已确认的旧输入,确保客户端使用正确的(已确认的)输入
|
||||
var pendingBefore = _predictionBuffer.PendingInputs.Count;
|
||||
_predictionBuffer.PruneAcknowledgedInputs(snapshot.AcknowledgedMoveTick);
|
||||
var pendingAfter = _predictionBuffer.PendingInputs.Count;
|
||||
Debug.Log(
|
||||
$"[Prune] AckTick={snapshot.AcknowledgedMoveTick} removed {pendingBefore - pendingAfter}/{pendingBefore} inputs, remaining={pendingAfter}");
|
||||
|
||||
// 收到服务器状态后,必须清空 pendingInputs
|
||||
// 因为 pendingInputs 中的 SimulatedDurationSeconds 是累积的模拟时间,
|
||||
// 如果不清理,客户端会继续用这些输入移动(测试模式下位置不被服务器校正)
|
||||
_predictionBuffer.ClearPendingInputs();
|
||||
_simulationAccumulator = 0f;
|
||||
|
||||
// 只有开启校正时才设置 _hasServerState,否则只打印日志不应用
|
||||
if (_applyServerCorrection)
|
||||
{
|
||||
_hasServerState = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastAuthoritativeState = snapshot;
|
||||
_remoteSnapshotInterpolator.TryAddSnapshot(snapshot, Time.time);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetServerTick(long serverTick)
|
||||
{
|
||||
_currentTickOffset = serverTick - Tick - _startTickOffset;
|
||||
if (_isControlled)
|
||||
{
|
||||
if (MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnServerTickChanged(serverTick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取最近发送的 MoveInput,用于与服务器输入时序对齐。
|
||||
/// 如果没有记录的输入,返回零向量(停止状态)。
|
||||
/// </summary>
|
||||
private Vector3 GetLatestPredictedInput()
|
||||
{
|
||||
var pending = _predictionBuffer.PendingInputs;
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
Debug.Log("[MoveInput] No pending inputs, using zero (stop)");
|
||||
return Vector3.zero;
|
||||
}
|
||||
|
||||
var latest = pending[^1];
|
||||
Debug.Log(
|
||||
$"[MoveInput] Using tick={latest.Input.Tick} TurnInput={latest.Input.TurnInput} ThrottleInput={latest.Input.ThrottleInput} ({pending.Count} pending)");
|
||||
// MoveInput 的 TurnInput/ThrottleInput 转回 Unity 的 x/z 格式
|
||||
// 注意 TurnInput 在 MoveInput 里是正数=右,正数=-input.x=左(需要取反)
|
||||
// ThrottleInput 在 MoveInput 里正数=前进,正数=input.z=前
|
||||
return new Vector3(-latest.Input.TurnInput, 0f, latest.Input.ThrottleInput);
|
||||
}
|
||||
|
||||
private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
|
||||
{
|
||||
foreach (var replayInput in replayInputs)
|
||||
{
|
||||
var remaining = replayInput.SimulatedDurationSeconds;
|
||||
while (remaining > 0f)
|
||||
{
|
||||
// Use the server's fixed cadence (50ms) as the substep size to ensure
|
||||
// replay trajectory matches live FixedUpdate prediction exactly.
|
||||
var step = Mathf.Min(remaining, kServerSimulationStepSeconds);
|
||||
ApplyTankMovement(
|
||||
replayInput.Input.TurnInput,
|
||||
replayInput.Input.ThrottleInput,
|
||||
step);
|
||||
remaining -= step;
|
||||
}
|
||||
}
|
||||
|
||||
if (_isControlled)
|
||||
{
|
||||
if (MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnClientPosChanged(_rigid.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyTankMovement(float turnInput, float throttleInput, float deltaTime)
|
||||
{
|
||||
if (deltaTime <= 0f)
|
||||
{
|
||||
_rigid.velocity = Vector3.zero;
|
||||
return;
|
||||
}
|
||||
|
||||
var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f);
|
||||
var clampedThrottleInput = Mathf.Clamp(throttleInput, -1f, 1f);
|
||||
var heading = NormalizeDegrees(UnityYawToHeading(_rigid.rotation.eulerAngles.y) +
|
||||
(clampedTurnInput * TurnSpeedDegreesPerSecond * deltaTime));
|
||||
_rigid.rotation = Quaternion.Euler(0f, HeadingToUnityYaw(heading), 0f);
|
||||
|
||||
var forward = ResolveHeadingForward(heading);
|
||||
var velocity = forward * (clampedThrottleInput * _speed);
|
||||
Vector3 targetPos = _rigid.position + velocity * deltaTime;
|
||||
_rigid.MovePosition(targetPos);
|
||||
// _rigid.velocity = velocity;
|
||||
// _rigid.position += velocity * deltaTime;
|
||||
|
||||
// 调试日志:打印每步计算细节
|
||||
Debug.Log($"[MoveStep] _speed={_speed} deltaTime={deltaTime:F4} throttle={clampedThrottleInput} " +
|
||||
$"heading={heading:F2} velocity=({velocity.x:F3}, {velocity.y:F3}, {velocity.z:F3}) " +
|
||||
$"pos=({_rigid.position.x:F3}, {_rigid.position.y:F3}, {_rigid.position.z:F3})");
|
||||
}
|
||||
|
||||
private static Vector3 ResolveHeadingForward(float headingDegrees)
|
||||
{
|
||||
var rotationRadians = headingDegrees * Mathf.Deg2Rad;
|
||||
return new Vector3(Mathf.Cos(rotationRadians), 0f, Mathf.Sin(rotationRadians));
|
||||
}
|
||||
|
||||
private static float HeadingToUnityYaw(float headingDegrees)
|
||||
{
|
||||
return NormalizeDegrees(UnityYawOffsetDegrees - headingDegrees);
|
||||
}
|
||||
|
||||
private static float UnityYawToHeading(float unityYawDegrees)
|
||||
{
|
||||
return NormalizeDegrees(UnityYawOffsetDegrees - unityYawDegrees);
|
||||
}
|
||||
|
||||
private static float NormalizeDegrees(float degrees)
|
||||
{
|
||||
var normalized = degrees % 360f;
|
||||
if (normalized < 0f)
|
||||
{
|
||||
normalized += 360f;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
public class OfflineMovementComponent : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int _speed;
|
||||
[SerializeField] private Rigidbody rigid;
|
||||
private Vector3 _cachedInput;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
_cachedInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
rigid.velocity = _cachedInput * _speed;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 10d331d181503b542868b5608961489e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
using UnityEngine;
|
||||
|
||||
public class MovementComponent : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Rigidbody _rigid;
|
||||
private const float InterpolationAlpha = 0.15f;
|
||||
|
||||
private bool _isControlled;
|
||||
private Vector3 _currentPosition;
|
||||
private Quaternion _currentRotation;
|
||||
private Vector3 _targetPosition;
|
||||
private Quaternion _targetRotation;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_rigid ??= GetComponent<Rigidbody>();
|
||||
}
|
||||
|
||||
public void Init(bool isControlled)
|
||||
{
|
||||
_rigid ??= GetComponent<Rigidbody>();
|
||||
_isControlled = isControlled;
|
||||
_rigid.interpolation = isControlled ? RigidbodyInterpolation.None : RigidbodyInterpolation.Interpolate;
|
||||
_rigid.isKinematic = !isControlled;
|
||||
_rigid.velocity = Vector3.zero;
|
||||
_rigid.angularVelocity = Vector3.zero;
|
||||
|
||||
_currentPosition = _rigid.position;
|
||||
_currentRotation = _rigid.rotation;
|
||||
_targetPosition = _rigid.position;
|
||||
_targetRotation = _rigid.rotation;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
_currentPosition = Vector3.Lerp(_currentPosition, _targetPosition, InterpolationAlpha);
|
||||
_currentRotation = Quaternion.Slerp(_currentRotation, _targetRotation, InterpolationAlpha);
|
||||
|
||||
_rigid.position = _currentPosition;
|
||||
_rigid.rotation = _currentRotation;
|
||||
|
||||
if (_isControlled && MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnClientPosChanged(_currentPosition);
|
||||
}
|
||||
}
|
||||
|
||||
public Vector3 CurrentPosition => _currentPosition;
|
||||
|
||||
public Quaternion CurrentRotation => _currentRotation;
|
||||
|
||||
public Vector3 TargetPosition => _targetPosition;
|
||||
|
||||
public Quaternion TargetRotation => _targetRotation;
|
||||
|
||||
public void SetTargetPose(Vector3 position, Quaternion rotation)
|
||||
{
|
||||
_targetPosition = position;
|
||||
_targetRotation = rotation;
|
||||
}
|
||||
|
||||
public void SnapToPose(Vector3 position, Quaternion rotation)
|
||||
{
|
||||
_currentPosition = position;
|
||||
_currentRotation = rotation;
|
||||
_targetPosition = position;
|
||||
_targetRotation = rotation;
|
||||
_rigid.position = position;
|
||||
_rigid.rotation = rotation;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
using System.Collections.Generic;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using UnityEngine;
|
||||
using Vector3 = UnityEngine.Vector3;
|
||||
|
||||
public class MovementResolverComponent : MonoBehaviour
|
||||
{
|
||||
private const float ServerSimulationStepSeconds = 0.05f;
|
||||
private const float SnapThreshold = 0.5f;
|
||||
|
||||
[SerializeField] private int _speed = 2;
|
||||
[SerializeField] private MovementComponent _movement;
|
||||
[SerializeField] private InputComponent _inputComponent;
|
||||
[SerializeField] private bool _applyServerCorrection = true;
|
||||
|
||||
private Player _master;
|
||||
private bool _isControlled;
|
||||
private Vector3 _serverPosition;
|
||||
private ClientAuthoritativePlayerStateSnapshot _lastAuthoritativeState;
|
||||
|
||||
private Vector3 _authoritativePosition;
|
||||
private Quaternion _authoritativeRotation;
|
||||
private Vector3 _predictedPosition;
|
||||
private Quaternion _predictedRotation;
|
||||
|
||||
public long Tick { get; private set; }
|
||||
private long _startTickOffset;
|
||||
private long _currentTickOffset;
|
||||
private float _simulationAccumulator;
|
||||
private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
|
||||
private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_movement ??= GetComponent<MovementComponent>();
|
||||
_inputComponent ??= GetComponent<InputComponent>();
|
||||
}
|
||||
|
||||
public void Init(bool isControlled, Player master, ClientMovementBootstrap bootstrap)
|
||||
{
|
||||
Init(isControlled, master, bootstrap.AuthoritativeMoveSpeed, bootstrap.ServerTick);
|
||||
}
|
||||
|
||||
public void Init(bool isControlled, Player master, int speed = 0, long serverTick = 0)
|
||||
{
|
||||
_movement ??= GetComponent<MovementComponent>();
|
||||
_inputComponent ??= GetComponent<InputComponent>();
|
||||
|
||||
_master = master;
|
||||
_isControlled = isControlled;
|
||||
_speed = speed;
|
||||
_startTickOffset = serverTick;
|
||||
|
||||
if (_movement != null)
|
||||
{
|
||||
_movement.Init(isControlled);
|
||||
_authoritativePosition = _movement.CurrentPosition;
|
||||
_authoritativeRotation = _movement.CurrentRotation;
|
||||
_predictedPosition = _movement.CurrentPosition;
|
||||
_predictedRotation = _movement.CurrentRotation;
|
||||
}
|
||||
|
||||
if (_inputComponent != null && master != null)
|
||||
{
|
||||
_inputComponent.InjectPlayerId(master.PlayerId);
|
||||
}
|
||||
|
||||
if (serverTick != 0 && _isControlled && MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnStartTickOffsetChanged(serverTick);
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_isControlled && _inputComponent != null && MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnClientTickChanged(_inputComponent.CurrentTick);
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_inputComponent != null)
|
||||
{
|
||||
_inputComponent.OnMoveInputCreated += HandleMoveInputCreated;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_inputComponent != null)
|
||||
{
|
||||
_inputComponent.OnMoveInputCreated -= HandleMoveInputCreated;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMoveInputCreated(MoveInput moveInput)
|
||||
{
|
||||
_predictionBuffer.Record(moveInput);
|
||||
}
|
||||
|
||||
public void SetApplyServerCorrection(bool apply)
|
||||
{
|
||||
_applyServerCorrection = apply;
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
if (_movement == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isControlled)
|
||||
{
|
||||
_simulationAccumulator += Time.fixedDeltaTime;
|
||||
while (_simulationAccumulator >= ServerSimulationStepSeconds)
|
||||
{
|
||||
var pendingCount = _predictionBuffer.PendingInputs.Count;
|
||||
if (pendingCount == 0)
|
||||
{
|
||||
_simulationAccumulator = 0f;
|
||||
break;
|
||||
}
|
||||
|
||||
Simulate(GetLatestPredictedInput());
|
||||
_simulationAccumulator -= ServerSimulationStepSeconds;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var sample = _remoteSnapshotInterpolator.Sample(Time.time);
|
||||
if (sample.HasValue)
|
||||
{
|
||||
_movement.SnapToPose(sample.Position, sample.Rotation);
|
||||
}
|
||||
}
|
||||
|
||||
private void Simulate(Vector3 input)
|
||||
{
|
||||
TankMovementKinematics.ApplyStep(_speed, input.x, input.z, ServerSimulationStepSeconds,
|
||||
ref _predictedPosition, ref _predictedRotation);
|
||||
_movement.SetTargetPose(_predictedPosition, _predictedRotation);
|
||||
_predictionBuffer.AccumulateLatest(ServerSimulationStepSeconds);
|
||||
|
||||
if (MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnClientPosChanged(_movement.CurrentPosition);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
|
||||
{
|
||||
if (_isControlled)
|
||||
{
|
||||
if (!_applyServerCorrection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastAuthoritativeState = snapshot;
|
||||
Reconcile(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
_lastAuthoritativeState = snapshot;
|
||||
_remoteSnapshotInterpolator.TryAddSnapshot(snapshot, Time.time);
|
||||
}
|
||||
|
||||
private void Reconcile(ClientAuthoritativePlayerStateSnapshot snapshot)
|
||||
{
|
||||
_serverPosition = snapshot.Position;
|
||||
if (!_predictionBuffer.TryApplyAuthoritativeState(snapshot.SourceState, Time.time, out var replayInputs))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_authoritativePosition = snapshot.Position;
|
||||
_authoritativeRotation = snapshot.RotationQuaternion;
|
||||
_predictedPosition = _authoritativePosition;
|
||||
_predictedRotation = _authoritativeRotation;
|
||||
|
||||
ReplayPendingInputs(replayInputs);
|
||||
|
||||
var error = Vector3.Distance(_movement.CurrentPosition, _predictedPosition);
|
||||
if (error > SnapThreshold)
|
||||
{
|
||||
_movement.SnapToPose(_predictedPosition, _predictedRotation);
|
||||
}
|
||||
else
|
||||
{
|
||||
_movement.SetTargetPose(_predictedPosition, _predictedRotation);
|
||||
}
|
||||
|
||||
_simulationAccumulator = 0f;
|
||||
|
||||
if (MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnServerPosChanged(_serverPosition);
|
||||
MainUI.Instance.OnCorrectionMagnitudeChanged?.Invoke(
|
||||
_predictedPosition,
|
||||
snapshot.Position,
|
||||
error,
|
||||
Quaternion.Angle(_predictedRotation, snapshot.RotationQuaternion));
|
||||
MainUI.Instance.OnAcknowledgedMoveTickChanged?.Invoke(_predictionBuffer.LastAcknowledgedMoveTick ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetServerTick(long serverTick)
|
||||
{
|
||||
_currentTickOffset = serverTick - Tick - _startTickOffset;
|
||||
if (_isControlled && MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnServerTickChanged(serverTick);
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 GetLatestPredictedInput()
|
||||
{
|
||||
var pending = _predictionBuffer.PendingInputs;
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return Vector3.zero;
|
||||
}
|
||||
|
||||
var latest = pending[^1];
|
||||
return new Vector3(-latest.Input.TurnInput, 0f, latest.Input.ThrottleInput);
|
||||
}
|
||||
|
||||
private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
|
||||
{
|
||||
foreach (var replayInput in replayInputs)
|
||||
{
|
||||
var remaining = replayInput.SimulatedDurationSeconds;
|
||||
while (remaining > 0f)
|
||||
{
|
||||
var step = Mathf.Min(remaining, ServerSimulationStepSeconds);
|
||||
TankMovementKinematics.ApplyStep(
|
||||
_speed,
|
||||
replayInput.Input.TurnInput,
|
||||
replayInput.Input.ThrottleInput,
|
||||
step,
|
||||
ref _predictedPosition,
|
||||
ref _predictedRotation);
|
||||
remaining -= step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: eff1752caaabe784fb85775608605426
|
||||
guid: fcfebc0989c4bf04cacf1c633d49a8bb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -12,10 +12,26 @@ public class Player : MonoBehaviour
|
|||
[SerializeField] private Material[] _materials;
|
||||
[SerializeField] private Camera _camera;
|
||||
[SerializeField] private MovementComponent _movement;
|
||||
[SerializeField] private MovementResolverComponent _movementResolver;
|
||||
[SerializeField] private PlayerUI _playerUI;
|
||||
[SerializeField] private bool _isControlled;
|
||||
private readonly ClientAuthoritativePlayerState _authoritativeState = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_movement ??= GetComponent<MovementComponent>();
|
||||
if (_movement == null)
|
||||
{
|
||||
_movement = gameObject.AddComponent<MovementComponent>();
|
||||
}
|
||||
|
||||
_movementResolver ??= GetComponent<MovementResolverComponent>();
|
||||
if (_movementResolver == null)
|
||||
{
|
||||
_movementResolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
}
|
||||
}
|
||||
|
||||
public void LocalInit(string playerId, ClientMovementBootstrap bootstrap)
|
||||
{
|
||||
this.PlayerId = playerId;
|
||||
|
|
@ -25,7 +41,7 @@ public class Player : MonoBehaviour
|
|||
_meshRenderer.material = _materials[idx];
|
||||
|
||||
_playerUI.Init(this);
|
||||
_movement.Init(true, this, bootstrap);
|
||||
_movementResolver.Init(true, this, bootstrap);
|
||||
}
|
||||
|
||||
public void RemoteInit(string playerId, UnityEngine.Vector3 pos)
|
||||
|
|
@ -40,7 +56,7 @@ public class Player : MonoBehaviour
|
|||
this.transform.position = pos;
|
||||
|
||||
_playerUI.Init(this);
|
||||
_movement.Init(false, this);
|
||||
_movementResolver.Init(false, this);
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
|
|
@ -59,7 +75,7 @@ public class Player : MonoBehaviour
|
|||
}
|
||||
|
||||
_playerUI?.SyncAuthoritativeState(snapshot, CombatPresentation);
|
||||
_movement?.OnAuthoritativeState(snapshot);
|
||||
_movementResolver?.OnAuthoritativeState(snapshot);
|
||||
}
|
||||
|
||||
public bool ApplyCombatEvent(CombatEvent combatEvent)
|
||||
|
|
@ -75,6 +91,6 @@ public class Player : MonoBehaviour
|
|||
|
||||
public void SyncTick(long serverTick)
|
||||
{
|
||||
_movement.SetServerTick(serverTick);
|
||||
_movementResolver.SetServerTick(serverTick);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using UnityEngine;
|
||||
|
||||
public static class TankMovementKinematics
|
||||
{
|
||||
private const float TurnSpeedDegreesPerSecond = 180f;
|
||||
private const float UnityYawOffsetDegrees = 90f;
|
||||
|
||||
public static void ApplyStep(int speed, float turnInput, float throttleInput, float deltaTime,
|
||||
ref Vector3 position, ref Quaternion rotation)
|
||||
{
|
||||
if (deltaTime <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f);
|
||||
var clampedThrottleInput = Mathf.Clamp(throttleInput, -1f, 1f);
|
||||
var heading = NormalizeDegrees(UnityYawToHeading(rotation.eulerAngles.y) +
|
||||
(clampedTurnInput * TurnSpeedDegreesPerSecond * deltaTime));
|
||||
rotation = Quaternion.Euler(0f, HeadingToUnityYaw(heading), 0f);
|
||||
|
||||
var forward = ResolveHeadingForward(heading);
|
||||
var velocity = forward * (clampedThrottleInput * speed);
|
||||
position += velocity * deltaTime;
|
||||
}
|
||||
|
||||
public static Vector3 ResolveHeadingForward(float headingDegrees)
|
||||
{
|
||||
var rotationRadians = headingDegrees * Mathf.Deg2Rad;
|
||||
return new Vector3(Mathf.Cos(rotationRadians), 0f, Mathf.Sin(rotationRadians));
|
||||
}
|
||||
|
||||
public static float HeadingToUnityYaw(float headingDegrees)
|
||||
{
|
||||
return NormalizeDegrees(UnityYawOffsetDegrees - headingDegrees);
|
||||
}
|
||||
|
||||
public static float UnityYawToHeading(float unityYawDegrees)
|
||||
{
|
||||
return NormalizeDegrees(UnityYawOffsetDegrees - unityYawDegrees);
|
||||
}
|
||||
|
||||
public static float NormalizeDegrees(float degrees)
|
||||
{
|
||||
var normalized = degrees % 360f;
|
||||
if (normalized < 0f)
|
||||
{
|
||||
normalized += 360f;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: cf8f141cd490efa4aa23369000c805db
|
||||
guid: 22558181f2430ca4b80f5787ec62c68d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
enum CardState
|
||||
{
|
||||
Cooling,
|
||||
Ready,
|
||||
WaitingSun
|
||||
}
|
||||
|
||||
public class Card : MonoBehaviour
|
||||
{
|
||||
private CardState cardState = CardState.Cooling;
|
||||
|
||||
public GameObject sunflower;
|
||||
public GameObject sunflowerGray;
|
||||
public Image sunflowerMask;
|
||||
|
||||
|
||||
private void Update()
|
||||
{
|
||||
switch (cardState)
|
||||
{
|
||||
case CardState.Cooling:
|
||||
CoolingUpdate();
|
||||
break;
|
||||
case CardState.Ready:
|
||||
ReadyUpdate();
|
||||
break;
|
||||
case CardState.WaitingSun:
|
||||
WaitingSunUpdate();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void CoolingUpdate()
|
||||
{
|
||||
|
||||
}
|
||||
void ReadyUpdate()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
void WaitingSunUpdate()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -135,20 +135,28 @@ namespace Tests.EditMode.Network
|
|||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.75f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.25f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(0.0375f).Within(0.0001f));
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").x, Is.EqualTo(0.25f).Within(0.0001f));
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(1f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(1f).Within(0.0001f));
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(0.5f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledUpdate(movement);
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(movement.TargetPosition.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(rigidbody.position.x, Is.GreaterThan(0.0375f));
|
||||
Assert.That(rigidbody.position.x, Is.LessThan(0.5f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -157,46 +165,72 @@ namespace Tests.EditMode.Network
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void ClientGameplayFlow_ControlledPlayerReconciliation_EscalatesToSnapAfterFailedConvergence()
|
||||
public void ClientGameplayFlow_ControlledPlayerReconciliation_EscalatesToSnapForLargeDivergence()
|
||||
{
|
||||
// NOTE: This test verifies the hard-snap escalation path.
|
||||
// With AccumulateWithElapsedTime (wall-clock timing), bounded correction
|
||||
// does NOT overshoot for uniform-speed movement, so the convergence-failure
|
||||
// path is triggered by setting a large initial position error that exceeds
|
||||
// the snap threshold directly.
|
||||
var gameObject = new GameObject("controlled-player");
|
||||
try
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
// tick=1, pos=3.0. Client is at 0. Error=3.0 > SnapPositionThreshold (2.5),
|
||||
// so hard snap triggers immediately without bounded correction.
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(3.0f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(3.0f).Within(0.0001f),
|
||||
"Hard snap should fire immediately when error exceeds snap threshold");
|
||||
"Large divergence should snap the visible pose immediately to predicted pose.");
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").x, Is.EqualTo(3.0f).Within(0.0001f));
|
||||
Assert.That(movement.TargetPosition.x, Is.EqualTo(3.0f).Within(0.0001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
// tick=2, pos=3.5. Error=0.5 < snap threshold (2.5). Bounded correction
|
||||
// (0.5) converges exactly. No pending inputs (Time.time=0 in EditMode).
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(3.5f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(3.5f).Within(0.0001f),
|
||||
"Bounded correction should converge exactly for small error");
|
||||
[Test]
|
||||
public void ClientGameplayFlow_ControlledPlayerReconciliation_RebuildsPredictionImmediatelyAndPreservesUnacknowledgedInputs()
|
||||
{
|
||||
var gameObject = new GameObject("controlled-player");
|
||||
try
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
// tick=3, pos=4.0. Error=0.5. Bounded correction (0.5) converges exactly.
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 3, new Vector3(4.0f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(4.0f).Within(0.0001f),
|
||||
"Bounded correction should continue converging for consecutive small errors");
|
||||
var predictionBuffer = (ClientPredictionBuffer)typeof(MovementResolverComponent)
|
||||
.GetField("_predictionBuffer", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetValue(resolver);
|
||||
predictionBuffer.Record(new MoveInput
|
||||
{
|
||||
PlayerId = "player-1",
|
||||
Tick = 1,
|
||||
TurnInput = 0f,
|
||||
ThrottleInput = 1f
|
||||
});
|
||||
predictionBuffer.AccumulateLatest(0.1f);
|
||||
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, Vector3.zero, acknowledgedMoveTick: 0)));
|
||||
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").z, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(predictionBuffer.PendingInputs.Count, Is.EqualTo(1));
|
||||
Assert.That(predictionBuffer.PendingInputs[0].Input.Tick, Is.EqualTo(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -236,10 +270,17 @@ namespace Tests.EditMode.Network
|
|||
Assert.That(clamped.LatestSnapshot.Tick, Is.EqualTo(11));
|
||||
Assert.That(clamped.Position, Is.EqualTo(new Vector3(10f, 0f, 0f)));
|
||||
}
|
||||
private static void InvokeControlledFixedUpdate(MovementComponent movement)
|
||||
private static Vector3 GetPrivateVector3(MovementResolverComponent resolver, string fieldName)
|
||||
{
|
||||
return (Vector3)typeof(MovementResolverComponent)
|
||||
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetValue(resolver);
|
||||
}
|
||||
|
||||
private static void InvokeControlledUpdate(MovementComponent movement)
|
||||
{
|
||||
typeof(MovementComponent)
|
||||
.GetMethod("FixedUpdate", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetMethod("Update", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Invoke(movement, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ namespace Tests.EditMode.Network
|
|||
clientRuntime.MessageManager,
|
||||
"player-a",
|
||||
3,
|
||||
Vector3.right);
|
||||
Vector3.forward);
|
||||
|
||||
Assert.That(clientReliableTransport.SentMessages.Count, Is.EqualTo(1));
|
||||
var outboundEnvelope = Envelope.Parser.ParseFrom(clientReliableTransport.SentMessages[0]);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ namespace Tests.EditMode.Network
|
|||
private static readonly IPEndPoint PeerB = new(IPAddress.Loopback, 9102);
|
||||
|
||||
[Test]
|
||||
public void UpdateAuthoritativeMovement_UsesConfiguredSimulationCadence_AndExposesItOnRuntime()
|
||||
public void UpdateAuthoritativeMovement_UsesConfiguredSimulationCadence_AndRequiresFreshInputEachStep()
|
||||
{
|
||||
var createdTransports = new Dictionary<int, FakeTransport>();
|
||||
var configuration = new ServerRuntimeConfiguration(9000)
|
||||
|
|
@ -61,7 +61,8 @@ namespace Tests.EditMode.Network
|
|||
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterSecondStep), Is.True);
|
||||
Assert.That(stateAfterSecondStep.PositionZ, Is.EqualTo(0.4f).Within(0.0001f));
|
||||
Assert.That(stateAfterSecondStep.PositionZ, Is.EqualTo(0.2f).Within(0.0001f));
|
||||
Assert.That(stateAfterSecondStep.VelocityZ, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
|
|
@ -168,8 +169,8 @@ namespace Tests.EditMode.Network
|
|||
Assert.That(firstBroadcast.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(firstBroadcast.Tick, Is.EqualTo(1));
|
||||
Assert.That(firstBroadcast.AcknowledgedMoveTick, Is.EqualTo(1));
|
||||
Assert.That(firstBroadcast.Position.Z, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(10f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Position.Z, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
|
||||
createdTransports[9001].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
|
||||
|
|
@ -192,7 +193,7 @@ namespace Tests.EditMode.Network
|
|||
var secondBroadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[1]);
|
||||
Assert.That(secondBroadcast.Tick, Is.EqualTo(2));
|
||||
Assert.That(secondBroadcast.AcknowledgedMoveTick, Is.EqualTo(2));
|
||||
Assert.That(secondBroadcast.Position.Z, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Position.Z, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ namespace Tests.EditMode.Network
|
|||
Assert.That(snapshot.Position, Is.EqualTo(new Vector3(5f, 0f, -3f)));
|
||||
Assert.That(snapshot.Velocity, Is.EqualTo(new Vector3(1.5f, 0f, 0.25f)));
|
||||
Assert.That(snapshot.Rotation, Is.EqualTo(90f));
|
||||
Assert.That(snapshot.RotationQuaternion.eulerAngles.y, Is.EqualTo(0f).Within(0.01f));
|
||||
Assert.That(snapshot.RotationQuaternion.eulerAngles.y, Is.EqualTo(90f).Within(0.01f));
|
||||
Assert.That(snapshot.Hp, Is.EqualTo(73));
|
||||
}
|
||||
|
||||
|
|
@ -472,7 +472,7 @@ namespace Tests.EditMode.Network
|
|||
Assert.That(sample.HasValue, Is.True);
|
||||
Assert.That(sample.UsedInterpolation, Is.False);
|
||||
Assert.That(sample.Position, Is.EqualTo(new Vector3(2f, 0f, -1f)));
|
||||
Assert.That(sample.Rotation.eulerAngles.y, Is.EqualTo(75f).Within(0.01f));
|
||||
Assert.That(sample.Rotation.eulerAngles.y, Is.EqualTo(15f).Within(0.01f));
|
||||
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(12));
|
||||
}
|
||||
|
||||
|
|
@ -514,10 +514,14 @@ namespace Tests.EditMode.Network
|
|||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
|
|
@ -527,9 +531,8 @@ namespace Tests.EditMode.Network
|
|||
var totalDuration = stepDuration * 3; // 0.15s
|
||||
|
||||
// Act — step-by-step path (live prediction shape).
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps: 3);
|
||||
var stepByStepPosition = rigidbody.position;
|
||||
var stepByStepRotation = rigidbody.rotation;
|
||||
ApplyTankMovementStepByStep(turnInput, throttleInput, stepDuration, steps: 3,
|
||||
out var stepByStepPosition, out var stepByStepRotation);
|
||||
|
||||
// Reset to initial state.
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
|
@ -541,9 +544,9 @@ namespace Tests.EditMode.Network
|
|||
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
|
||||
totalDuration)
|
||||
};
|
||||
InvokeReplayPendingInputs(movement, accumulatedReplayInputs);
|
||||
var accumulatedPosition = rigidbody.position;
|
||||
var accumulatedRotation = rigidbody.rotation;
|
||||
InvokeReplayPendingInputs(resolver, accumulatedReplayInputs);
|
||||
var accumulatedPosition = GetPrivateVector3(resolver, "_predictedPosition");
|
||||
var accumulatedRotation = GetPrivateQuaternion(resolver, "_predictedRotation");
|
||||
|
||||
// Assert: for straight movement (turn=0), both paths should be identical.
|
||||
Assert.That(Vector3.Distance(accumulatedPosition, stepByStepPosition), Is.LessThan(0.0001f),
|
||||
|
|
@ -567,10 +570,14 @@ namespace Tests.EditMode.Network
|
|||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
|
|
@ -583,15 +590,15 @@ namespace Tests.EditMode.Network
|
|||
var totalDuration = stepDuration * steps;
|
||||
|
||||
// Act — step-by-step (correct approach).
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps);
|
||||
var stepByStepPosition = rigidbody.position;
|
||||
ApplyTankMovementStepByStep(turnInput, throttleInput, stepDuration, steps,
|
||||
out var stepByStepPosition, out _);
|
||||
|
||||
// Reset.
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
// Act — ONE big step simulating the old buggy accumulated behavior.
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, totalDuration, steps: 1);
|
||||
var oneShotPosition = rigidbody.position;
|
||||
ApplyTankMovementStepByStep(turnInput, throttleInput, totalDuration, steps: 1,
|
||||
out var oneShotPosition, out _);
|
||||
|
||||
// Assert: for non-zero turn with many steps, the old one-shot and correct step-by-step MUST differ.
|
||||
Assert.That(Vector3.Distance(oneShotPosition, stepByStepPosition), Is.GreaterThan(0.001f),
|
||||
|
|
@ -615,10 +622,14 @@ namespace Tests.EditMode.Network
|
|||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
|
|
@ -628,9 +639,8 @@ namespace Tests.EditMode.Network
|
|||
var steps = 2; // 0.10s total
|
||||
|
||||
// Act — live prediction: step-by-step ApplyTankMovement (correct shape).
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps);
|
||||
var livePosition = rigidbody.position;
|
||||
var liveRotation = rigidbody.rotation;
|
||||
ApplyTankMovementStepByStep(turnInput, throttleInput, stepDuration, steps,
|
||||
out var livePosition, out var liveRotation);
|
||||
|
||||
// Reset.
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
|
@ -642,9 +652,9 @@ namespace Tests.EditMode.Network
|
|||
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
|
||||
stepDuration * steps)
|
||||
};
|
||||
InvokeReplayPendingInputs(movement, replayInputs);
|
||||
var replayPosition = rigidbody.position;
|
||||
var replayRotation = rigidbody.rotation;
|
||||
InvokeReplayPendingInputs(resolver, replayInputs);
|
||||
var replayPosition = GetPrivateVector3(resolver, "_predictedPosition");
|
||||
var replayRotation = GetPrivateQuaternion(resolver, "_predictedRotation");
|
||||
|
||||
// Assert: both paths must produce identical trajectories.
|
||||
Assert.That(Vector3.Distance(replayPosition, livePosition), Is.LessThan(0.0001f),
|
||||
|
|
@ -703,9 +713,9 @@ namespace Tests.EditMode.Network
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void MovementComponent_SetServerTick_DoesNotOscillateWithinDeadBand()
|
||||
public void MovementComponent_SetServerTick_TracksLatestServerOffset()
|
||||
{
|
||||
// Arrange: set up MovementComponent and initialize controlled state.
|
||||
// Arrange: set up movement resolver and initialize controlled state.
|
||||
var gameObject = new GameObject("send-interval-test");
|
||||
try
|
||||
{
|
||||
|
|
@ -713,25 +723,25 @@ namespace Tests.EditMode.Network
|
|||
rigidbody.useGravity = false;
|
||||
rigidbody.interpolation = RigidbodyInterpolation.None;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
var sendIntervalField = typeof(MovementComponent)
|
||||
.GetField("_sendInterval", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var currentOffsetField = typeof(MovementResolverComponent)
|
||||
.GetField("_currentTickOffset", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
// Set an initial interval as baseline.
|
||||
sendIntervalField.SetValue(movement, 0.05f);
|
||||
|
||||
// Act/Assert: offsets within [-2, +2] dead-band do not change the interval.
|
||||
// Simulate offset hovering around zero.
|
||||
// Act/Assert: server tick updates the cached latest offset deterministically.
|
||||
for (var i = -2; i <= 2; i++)
|
||||
{
|
||||
movement.SetServerTick(i); // Tick=0, so offset = i - 0 - 0 = i
|
||||
var interval = (float)sendIntervalField.GetValue(movement);
|
||||
Assert.That(interval, Is.EqualTo(0.05f).Within(0.0001f),
|
||||
$"Offset {i} should not trigger send interval correction within dead-band.");
|
||||
resolver.SetServerTick(i); // Tick=0, so offset = i - 0 - 0 = i
|
||||
var currentOffset = (long)currentOffsetField.GetValue(resolver);
|
||||
Assert.That(currentOffset, Is.EqualTo(i),
|
||||
$"Server tick {i} should map to the same cached offset when Tick and start offset are zero.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
|
@ -750,10 +760,14 @@ namespace Tests.EditMode.Network
|
|||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
|
|
@ -767,8 +781,8 @@ namespace Tests.EditMode.Network
|
|||
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
|
||||
totalDuration)
|
||||
};
|
||||
InvokeReplayPendingInputs(movement, replayInputs);
|
||||
var finalPosition = rigidbody.position;
|
||||
InvokeReplayPendingInputs(resolver, replayInputs);
|
||||
var finalPosition = GetPrivateVector3(resolver, "_predictedPosition");
|
||||
|
||||
// Expected: 0.12s at speed=10 → 1.2 units forward.
|
||||
var expectedPosition = new Vector3(0f, 0f, 1.2f);
|
||||
|
|
@ -781,13 +795,14 @@ namespace Tests.EditMode.Network
|
|||
}
|
||||
}
|
||||
|
||||
private static void ApplyTankMovementStepByStep(MovementComponent movement, float turnInput, float throttleInput, float stepDuration, int steps)
|
||||
private static void ApplyTankMovementStepByStep(float turnInput, float throttleInput, float stepDuration, int steps,
|
||||
out Vector3 position, out Quaternion rotation)
|
||||
{
|
||||
var method = typeof(MovementComponent)
|
||||
.GetMethod("ApplyTankMovement", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
position = Vector3.zero;
|
||||
rotation = Quaternion.identity;
|
||||
for (var i = 0; i < steps; i++)
|
||||
{
|
||||
method.Invoke(movement, new object[] { turnInput, throttleInput, stepDuration });
|
||||
TankMovementKinematics.ApplyStep(10, turnInput, throttleInput, stepDuration, ref position, ref rotation);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -799,11 +814,34 @@ namespace Tests.EditMode.Network
|
|||
rigidbody.angularVelocity = Vector3.zero;
|
||||
}
|
||||
|
||||
private static void InvokeReplayPendingInputs(MovementComponent movement, IReadOnlyList<PredictedMoveStep> inputs)
|
||||
private static void InvokeReplayPendingInputs(MovementResolverComponent resolver, IReadOnlyList<PredictedMoveStep> inputs)
|
||||
{
|
||||
typeof(MovementComponent)
|
||||
SetPrivateField(resolver, "_predictedPosition", Vector3.zero);
|
||||
SetPrivateField(resolver, "_predictedRotation", Quaternion.identity);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetMethod("ReplayPendingInputs", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Invoke(movement, new object[] { inputs });
|
||||
.Invoke(resolver, new object[] { inputs });
|
||||
}
|
||||
|
||||
private static Vector3 GetPrivateVector3(MovementResolverComponent resolver, string fieldName)
|
||||
{
|
||||
return (Vector3)typeof(MovementResolverComponent)
|
||||
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetValue(resolver);
|
||||
}
|
||||
|
||||
private static Quaternion GetPrivateQuaternion(MovementResolverComponent resolver, string fieldName)
|
||||
{
|
||||
return (Quaternion)typeof(MovementResolverComponent)
|
||||
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetValue(resolver);
|
||||
}
|
||||
|
||||
private static void SetPrivateField(MovementResolverComponent resolver, string fieldName, object value)
|
||||
{
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, value);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-07
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
## Context
|
||||
|
||||
当前 `MovementComponent` 的 `Reconcile()` 方法在收到服务器 PlayerState 后:
|
||||
1. 强制 snap 到 server position
|
||||
2. 重放 pending inputs(可能产生位移)
|
||||
3. 用 bounded correction 从重放后位置收敛回 server position
|
||||
|
||||
问题:server position 每帧都在变化(服务器在广播),bounded correction 的收敛目标一直在变,导致永远追不上。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 将模拟层(服务器权威 truth)和表现层(纯视觉插值)解耦
|
||||
- 表现层用 Lerp 替代 bounded correction,消除追赶震荡
|
||||
- 模拟层只输出"目标位置",表现层只管插值
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改变网络协议或服务器逻辑
|
||||
- 不修改远程玩家的插值逻辑
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: 表现层直接设置 rigid.position,不使用 MovePosition
|
||||
|
||||
**选择:表现层直接设置 `_rigid.position`**
|
||||
|
||||
- Rigidbody interpolation 设为 `None`
|
||||
- 表现层直接写入 `_rigid.position` 和 `_rigid.rotation`
|
||||
- 不经过 `Rigidbody.MovePosition`(避免物理引擎介入)
|
||||
|
||||
### Decision: 模拟层收到服务器状态时立即计算 target
|
||||
|
||||
收到服务器 PlayerState 时:
|
||||
1. Acknowledge inputs(移除 tick <= AckTick 的输入)
|
||||
2. 从 authoritative position 重放剩余 pending inputs
|
||||
3. 计算 `target = authoritativePosition + replayDisplacement`
|
||||
4. 判断 error:error > SnapThreshold → snap;else → 更新 `_presentationTarget`
|
||||
|
||||
**关键**:`_presentationTarget` 在两次收到服务器状态之间保持不变,表现层稳定 Lerp。
|
||||
|
||||
### Decision: 插值策略使用 Lerp
|
||||
|
||||
固定 alpha = 0.15~0.2。后续可根据 RTT 动态调整或改用 SmoothDamp。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
[Risk] 插值延迟导致本地玩家看到的位置比服务器延迟
|
||||
→ [Mitigation] Lerp 的延迟是固定的(不像 bounded correction 那样持续追赶),且不影响服务器权威性
|
||||
|
||||
[Risk] 删除 bounded correction 后无法平滑大误差
|
||||
→ [Mitigation] Snap 阈值(0.5 unit)处理大误差,直接跳到目标;Lerp 处理小误差自然收敛
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
## Why
|
||||
|
||||
当前 `MovementComponent` 将模拟层(pending inputs 维护、服务器校正、重放)和表现层(视觉插值、bounded correction)耦合在一起。bounded correction 的收敛目标是实时变化的 server position,导致客户端永远在追赶——每次收到服务器状态,收敛目标就变了。表现为本地移动平滑,加上网络同步后抖动。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **新增** `LocalPlayerPresentationState` 类型:持有 `_currentPosition/Rotation`(当前显示)和 `_targetPosition/Rotation`(模拟层给的目标)
|
||||
- **新增** `LocalPlayerSimulationState` 类型:持有 `_lastAuthoritativePosition/Rotation`、`_pendingInputs`、`_presentationTarget`
|
||||
- **重构** `MovementComponent`:`OnAuthoritativeState` 只更新 `_presentationTarget`,不直接修改 rigid.position
|
||||
- **重构** 表现层:每帧用 Lerp 将 `_currentPosition` 插值到 `_targetPosition`,再设置 rigid.position
|
||||
- **移除** `ControlledPlayerCorrection` 相关逻辑:bounded correction 被表现层 Lerp 替代
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `local-player-presentation-state`: 表现层状态(current/target position & rotation)及每帧插值更新
|
||||
- `local-player-simulation-state`: 模拟层状态(authoritative baseline、pending inputs、presentation target)
|
||||
|
||||
### Modified Capabilities
|
||||
- `local-player-reconciliation`: 模拟层收到服务器状态后计算 PresentationTarget,表现层负责插值收敛,不再使用 bounded correction
|
||||
|
||||
## Impact
|
||||
|
||||
- 新增 `Assets/Scripts/Network/NetworkApplication/LocalPlayerPresentationState.cs`
|
||||
- 新增 `Assets/Scripts/Network/NetworkApplication/LocalPlayerSimulationState.cs`
|
||||
- 修改 `Assets/Scripts/MovementComponent.cs`
|
||||
- 删除 `Assets/Scripts/ControlledPlayerCorrection.cs`(或保留作他用)
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# local-player-presentation-state Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define how the local player's presentation layer holds current display state and smoothly interpolates toward the simulation layer's target state each frame.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Presentation layer holds current and target state
|
||||
|
||||
The local player's presentation layer SHALL maintain `_currentPosition` and `_currentRotation` (the actively displayed state) separately from `_targetPosition` and `_targetRotation` (the simulation layer's output).
|
||||
|
||||
#### Scenario: Presentation state initializes from first simulation target
|
||||
- **WHEN** the presentation layer is initialized or first receives a simulation target
|
||||
- **THEN** `_currentPosition` and `_currentRotation` are set equal to the initial target
|
||||
- **THEN** subsequent updates lerp toward the target
|
||||
|
||||
### Requirement: Presentation layer lerps toward target each frame
|
||||
|
||||
The presentation layer SHALL each frame interpolate `_currentPosition` and `_currentRotation` toward `_targetPosition` and `_targetRotation` using linear interpolation, then apply the result to the Rigidbody.
|
||||
|
||||
#### Scenario: Lerp position and rotation toward target
|
||||
- **WHEN** the presentation layer updates each frame with interpolation alpha α
|
||||
- **THEN** `_currentPosition` is updated to `Vector3.Lerp(_currentPosition, _targetPosition, α)`
|
||||
- **THEN** `_currentRotation` is updated to `Quaternion.Slerp(_currentRotation, _targetRotation, α)`
|
||||
- **THEN** `_rigid.position` and `_rigid.rotation` are set to `_currentPosition` and `_currentRotation`
|
||||
|
||||
### Requirement: Presentation layer snaps when target error exceeds threshold
|
||||
|
||||
When the distance between `_currentPosition` and `_targetPosition` exceeds the snap threshold, the presentation layer SHALL immediately snap `_currentPosition` to `_targetPosition` without lerping.
|
||||
|
||||
#### Scenario: Snap when error exceeds threshold
|
||||
- **WHEN** `Vector3.Distance(_currentPosition, _targetPosition) > SnapThreshold`
|
||||
- **THEN** `_currentPosition` is set equal to `_targetPosition`
|
||||
- **THEN** `_currentRotation` is set equal to `_targetRotation`
|
||||
- **THEN** no lerping occurs in this frame
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# local-player-simulation-state Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define how the simulation layer maintains authoritative state, pending inputs, and computes the presentation target when receiving server PlayerState messages.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Simulation layer maintains authoritative baseline
|
||||
|
||||
The simulation layer SHALL maintain `_lastAuthoritativePosition`, `_lastAuthoritativeRotation`, and `_lastAcknowledgedTick` as the authoritative baseline. These are updated when the server acknowledges input through a PlayerState message.
|
||||
|
||||
#### Scenario: Authoritative baseline updates on PlayerState
|
||||
- **WHEN** the client receives a PlayerState with tick T
|
||||
- **THEN** `_lastAuthoritativePosition` is set to the PlayerState position
|
||||
- **THEN** `_lastAuthoritativeRotation` is set to the PlayerState rotation
|
||||
- **THEN** `_lastAcknowledgedTick` is set to T
|
||||
|
||||
### Requirement: Simulation layer maintains pending inputs
|
||||
|
||||
The simulation layer SHALL maintain a list of pending inputs that have been recorded locally but not yet acknowledged by the server.
|
||||
|
||||
#### Scenario: Pending inputs are pruned on acknowledgment
|
||||
- **WHEN** the client receives a PlayerState with AcknowledgedMoveTick N
|
||||
- **THEN** all pending inputs with tick <= N are removed from the pending list
|
||||
- **THEN** remaining pending inputs (tick > N) are preserved for replay
|
||||
|
||||
### Requirement: Simulation layer computes presentation target on PlayerState
|
||||
|
||||
When receiving a server PlayerState, the simulation layer SHALL compute the presentation target by replaying unacknowledged pending inputs from the authoritative baseline, and update the `_presentationTarget`.
|
||||
|
||||
#### Scenario: Presentation target is computed after replay
|
||||
- **WHEN** the client receives a PlayerState
|
||||
- **THEN** all acknowledged inputs are pruned (tick <= AcknowledgedMoveTick)
|
||||
- **THEN** remaining pending inputs are replayed starting from the authoritative position using 50ms fixed-step substeps
|
||||
- **THEN** `_presentationTarget` is set to (authoritative position + replay displacement, authoritative rotation + replay rotation delta)
|
||||
|
||||
### Requirement: Simulation layer updates presentation target only on PlayerState
|
||||
|
||||
The simulation layer SHALL only update `_presentationTarget` when a new PlayerState is received. Between PlayerState messages, the presentation target remains constant.
|
||||
|
||||
#### Scenario: Presentation target is stable between PlayerState messages
|
||||
- **WHEN** the client receives a PlayerState and computes `_presentationTarget`
|
||||
- **AND** no further PlayerState is received in the following frames
|
||||
- **THEN** `_presentationTarget` remains unchanged
|
||||
- **THEN** the presentation layer continues lerping toward the same target
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
## 1. 准备阶段
|
||||
|
||||
- [x] 1.1 阅读 `openspec/specs/local-player-presentation-state/spec.md` 和 `openspec/specs/local-player-simulation-state/spec.md`
|
||||
- [x] 1.2 阅读现有 `MovementComponent.cs` 和 `ControlledPlayerCorrection.cs`
|
||||
|
||||
## 2. 新增表现层状态
|
||||
|
||||
- [x] 2.1 添加 `_presentationPosition`、`_presentationRotation`、`_presentationTargetPosition`、`_presentationTargetRotation` 字段到 MovementComponent
|
||||
- [x] 2.2 实现 `UpdatePresentation()` 方法:Lerp 或 snap 到 target,设置 rigid.position/rotation
|
||||
|
||||
## 3. 新增模拟层状态
|
||||
|
||||
- [x] 3.1 使用现有的 `_predictionBuffer` 和新增的 authoritative baseline 字段
|
||||
- [x] 3.2 在 `Reconcile()` 中实现:prune inputs + replay + 计算 target
|
||||
|
||||
## 4. 重构 MovementComponent
|
||||
|
||||
- [x] 4.1 添加表现层和模拟层状态字段(不使用独立类型,直接在 MovementComponent 中)
|
||||
- [x] 4.2 `OnAuthoritativeState()` 移除 `ClearPendingInputs()` 和 `_simulationAccumulator = 0f`(移到 Reconcile 后)
|
||||
- [x] 4.3 `Update()` 中调用 `UpdatePresentation()`
|
||||
- [x] 4.4 移除 `ControlledPlayerCorrection` 相关逻辑(bounded correction 被 Lerp 替代)
|
||||
|
||||
## 5. 验证
|
||||
|
||||
- [x] 5.1 编译验证(代码无语法错误)
|
||||
- [ ] 5.2 关闭网络同步:移动平滑
|
||||
- [ ] 5.3 开启网络同步:抖动消除或显著减少
|
||||
|
|
@ -8,7 +8,7 @@ Define the contract that client-side replay of pending movement inputs after aut
|
|||
|
||||
### Requirement: Replay uses fixed-step accumulation matching server cadence
|
||||
|
||||
The controlled-client prediction replay path SHALL consume each pending `PredictedMoveStep` by applying its input in fixed-duration substeps equal to the server authoritative movement cadence, regardless of the step's total `SimulatedDurationSeconds`. **Forward prediction accumulation SHALL also use the same server authoritative movement cadence as the unit of accumulation, ensuring forward accumulated duration and replay duration are derived from the same cadence constant.** The replay accumulation shape MUST be identical to the live `FixedUpdate` prediction path for the same input values.
|
||||
The controlled-client prediction replay path SHALL consume each pending `PredictedMoveStep` by applying its input in fixed-duration substeps equal to the server authoritative movement cadence, regardless of the step's total `SimulatedDurationSeconds`. Forward prediction accumulation SHALL also use the same server authoritative movement cadence as the unit of accumulation, and replaying a step MUST NOT remove that step from the pending-input buffer unless the server has acknowledged its tick. The replay accumulation shape MUST be identical to the live `FixedUpdate` prediction path for the same input values.
|
||||
|
||||
#### Scenario: Replay produces same trajectory as live prediction for steady input
|
||||
- **WHEN** the client replays a `PredictedMoveStep` with turn=0, throttle=1, duration=0.15s using a 0.05s server cadence
|
||||
|
|
@ -25,12 +25,23 @@ The controlled-client prediction replay path SHALL consume each pending `Predict
|
|||
- **THEN** the replay applies 0.05s + 0.05s + 0.02s substeps sequentially
|
||||
- **THEN** no remaining duration is lost or double-counted
|
||||
|
||||
#### Scenario: Replay preserves unacknowledged inputs for later authoritative rebuilds
|
||||
- **WHEN** the client replays pending inputs after accepting an authoritative state that acknowledges ticks through `N`
|
||||
- **THEN** only steps with tick less than or equal to `N` are removed from the pending-input buffer
|
||||
- **THEN** replayed steps with tick greater than `N` remain available for later replay against a newer authoritative baseline
|
||||
- **THEN** the pending-input buffer still exposes those unacknowledged steps after replay completes
|
||||
|
||||
### Requirement: Replay trajectory determinism is verifiable
|
||||
|
||||
The client prediction system SHALL provide a deterministic way to verify that replay and live prediction produce identical trajectories for a given input sequence, enabling regression coverage.
|
||||
The client prediction system SHALL provide a deterministic way to verify that replay and live prediction produce identical trajectories for a given input sequence, enabling regression coverage. The verification path MUST also support repeated authoritative rebuilds while the same unacknowledged input sequence remains pending.
|
||||
|
||||
#### Scenario: Replay and live prediction produce identical results
|
||||
- **WHEN** a controlled client records a `MoveInput` sequence during live play
|
||||
- **AND** the client triggers reconciliation and replays those same inputs
|
||||
- **THEN** the final predicted pose after replay equals the predicted pose that would result from live FixedUpdate simulation for the same input sequence
|
||||
- **THEN** the result is stable across multiple replays of the same input sequence
|
||||
|
||||
#### Scenario: Repeated rebuilds remain deterministic while inputs stay unacknowledged
|
||||
- **WHEN** the controlled client accepts multiple increasing authoritative snapshots while the same later input ticks remain unacknowledged
|
||||
- **THEN** replaying the remaining unacknowledged input sequence against each accepted baseline produces deterministic predicted results for that baseline
|
||||
- **THEN** the test harness can verify replay correctness without requiring those inputs to be consumed from the buffer
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
# local-player-reconciliation Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define how the local (controlled) player reconciles client-side prediction with server authoritative state. This capability ensures that when the client receives a server `PlayerState`, it treats that snapshot as the latest gameplay baseline, rebuilds local prediction from still-unacknowledged inputs, and smooths visible presentation toward the rebuilt predicted pose.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Reconcile applies authoritative state and replays unconfirmed inputs in correct order
|
||||
|
||||
The controlled-client reconciliation path SHALL treat a newly accepted authoritative `PlayerState` as the latest gameplay baseline, prune only pending movement inputs whose tick is less than or equal to `AcknowledgedMoveTick`, replay all still-unacknowledged pending inputs from that authoritative baseline using fixed-step substeps matching the server authoritative movement cadence, and publish the replay result as the controlled player's latest predicted simulation pose. Presentation smoothing MUST consume that rebuilt predicted pose as a target without redefining the gameplay truth of the replay result.
|
||||
|
||||
#### Scenario: Authoritative acceptance rebuilds prediction from the latest baseline
|
||||
- **WHEN** the controlled player accepts an authoritative `PlayerState` whose acknowledged movement-input tick is `N`
|
||||
- **THEN** the reconciliation treats the received authoritative position and rotation as the new gameplay baseline
|
||||
- **THEN** the reconciliation replays all pending inputs with tick greater than `N` using 50ms fixed-step substeps
|
||||
- **THEN** the replay result becomes the controlled player's latest predicted simulation pose for that authoritative update
|
||||
- **THEN** the presentation layer receives that predicted pose as its smoothing target
|
||||
|
||||
#### Scenario: Repeated authoritative updates rebuild prediction without consuming pending inputs
|
||||
- **WHEN** the controlled player accepts two increasing authoritative `PlayerState` snapshots before all pending inputs have been acknowledged
|
||||
- **THEN** each accepted snapshot rebuilds the predicted simulation pose from its own authoritative baseline
|
||||
- **THEN** only inputs acknowledged by the newer snapshot are pruned from the pending-input buffer
|
||||
- **THEN** still-unacknowledged inputs remain available for replay against later authoritative snapshots
|
||||
|
||||
#### Scenario: Visible smoothing does not redefine rebuilt gameplay truth
|
||||
- **WHEN** the controlled player has a rebuilt predicted simulation pose and a visible presentation pose that has not yet converged
|
||||
- **THEN** gameplay logic continues to treat the rebuilt predicted pose as the latest client prediction truth
|
||||
- **THEN** the visible pose may temporarily differ while smoothing converges
|
||||
- **THEN** a large divergence may still hard-snap the visible pose directly to the rebuilt predicted pose
|
||||
|
||||
### Requirement: Bounded correction handles residual error after replay
|
||||
|
||||
The controlled-client reconciliation SHALL compare the controlled player's visible presentation pose against the rebuilt predicted simulation pose after replay, interpolate the visible pose toward that predicted pose for small residual error, and snap the visible pose directly to the predicted pose when divergence exceeds the configured snap threshold.
|
||||
|
||||
#### Scenario: Small residual error uses presentation interpolation
|
||||
- **WHEN** the controlled player completes replay and the remaining distance between visible pose and rebuilt predicted pose is within the configured snap threshold
|
||||
- **THEN** the client keeps the rebuilt predicted pose as presentation target
|
||||
- **THEN** the visible pose converges toward that target through presentation smoothing across later frames
|
||||
- **THEN** the replayed predicted pose remains unchanged as gameplay truth during that convergence
|
||||
|
||||
#### Scenario: Large divergence snaps visible pose to rebuilt predicted pose
|
||||
- **WHEN** the controlled player completes replay and the remaining distance between visible pose and rebuilt predicted pose exceeds the configured snap threshold
|
||||
- **THEN** the client snaps the visible position and rotation directly to the rebuilt predicted pose
|
||||
- **THEN** any previous presentation-only smoothing state is cleared or replaced
|
||||
- **THEN** later local prediction continues from the rebuilt predicted baseline
|
||||
Loading…
Reference in New Issue