Compare commits

..

10 Commits

Author SHA1 Message Date
SepComet 098a1dd68c 优化输入回放逻辑 + 调整项目结构 2026-04-08 13:08:36 +08:00
SepComet 75289b5690 补充输入重放
输入重放是指当收到一个服务器状态包时以那个包为初始状态,然后重新计算所有未确认的输入来得到当前帧的状态,然后再对当前角色的位置进行插值移动。服务器状态包只能给出前几帧的权威数据,而当前帧的状态则需要通过输入回放来得到
2026-04-08 10:33:42 +08:00
SepComet da2b93e59c 解决旋转反向问题 2026-04-07 21:33:05 +08:00
SepComet b7c003f227 fix: 修复 spec 与实现的多处差异
- CombatEvent Hit 消息的 Damage 字段改为传递 configuration.DamagePerShot
- SyncSequenceTracker MoveInput streamKey 改为只按 playerId 追踪
- 登录成功后新增 AuthoritativeCombatState bootstrap 逻辑
- 更新 network-gameplay-message-types spec 字段命名以匹配实际 proto 定义
2026-04-07 20:42:18 +08:00
SepComet 2c46012800 清理测试样例 2026-04-07 17:26:54 +08:00
SepComet e60ad420dc 应该解决了问题,问题来源:AI 在我不知情的情况下引入了刚体速度,导致客户端与服务端的状态一直对不上 2026-04-07 17:09:14 +08:00
SepComet 1e90de11ce 单帧测试进行中 2026-04-07 16:06:46 +08:00
SepComet 90f1832397 fix 2026-04-06 19:22:22 +08:00
SepComet 79474b53aa fix 1 2026-04-06 16:19:44 +08:00
SepComet a1ede230bb Orgnize 2026-04-06 12:23:09 +08:00
97 changed files with 3531 additions and 624 deletions

View File

@ -5,7 +5,20 @@
"Bash(openspec status:*)",
"Bash(openspec instructions:*)",
"Bash(dotnet build:*)",
"Bash(dotnet test:*)"
"Bash(dotnet test:*)",
"Bash(dotnet Temp/Bin/Debug/Network.EditMode.Tests/Network.EditMode.Tests.dll)",
"Bash(openspec list:*)",
"Bash(git add:*)",
"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"

2
.gitignore vendored
View File

@ -82,7 +82,7 @@ crashlytics-build.properties
*.xmind
/.dotnet
/.dotnet-home
/openspec/changes/archive
EditMode-err.txt

View File

@ -31,7 +31,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6308356813253026692}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@ -107,7 +106,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6308356813253026692}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@ -191,7 +189,6 @@ RectTransform:
- {fileID: 6308356812888301747}
- {fileID: 6308356813057413814}
m_Father: {fileID: 6308356814245253662}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@ -217,6 +214,7 @@ Canvas:
m_SortingBucketNormalizedSize: 0
m_VertexColorAlwaysGammaSpace: 0
m_AdditionalShaderChannelsFlag: 0
m_UpdateRectTransformForStandalone: 0
m_SortingLayerID: 0
m_SortingOrder: 0
m_TargetDisplay: 0
@ -285,6 +283,7 @@ GameObject:
- component: {fileID: 6308356813655391140}
- component: {fileID: 6308356813655391139}
- component: {fileID: 6308356813655391138}
- component: {fileID: 2373500463057886562}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
@ -299,13 +298,13 @@ Transform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356813655391137}
serializedVersion: 2
m_LocalRotation: {x: 0.079317145, y: -0, z: -0, w: 0.9968494}
m_LocalPosition: {x: -0.26855803, y: 1.55, z: -5.62}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6308356814245253662}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 9.099, y: 0, z: 0}
--- !u!20 &6308356813655391139
Camera:
@ -321,9 +320,17 @@ Camera:
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
m_Iso: 200
m_ShutterSpeed: 0.005
m_Aperture: 16
m_FocusDistance: 10
m_FocalLength: 50
m_BladeCount: 5
m_Curvature: {x: 2, y: 11}
m_BarrelClipping: 0.25
m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_FocalLength: 50
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
@ -358,6 +365,50 @@ AudioListener:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356813655391137}
m_Enabled: 1
--- !u!114 &2373500463057886562
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356813655391137}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
m_Name:
m_EditorClassIdentifier:
m_RenderShadows: 1
m_RequiresDepthTextureOption: 2
m_RequiresOpaqueTextureOption: 2
m_CameraType: 0
m_Cameras: []
m_RendererIndex: -1
m_VolumeLayerMask:
serializedVersion: 2
m_Bits: 1
m_VolumeTrigger: {fileID: 0}
m_VolumeFrameworkUpdateModeOption: 2
m_RenderPostProcessing: 0
m_Antialiasing: 0
m_AntialiasingQuality: 2
m_StopNaN: 0
m_Dithering: 0
m_ClearDepth: 1
m_AllowXRRendering: 1
m_AllowHDROutput: 1
m_UseScreenCoordOverride: 0
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_Version: 2
m_TaaSettings:
m_Quality: 3
m_FrameInfluence: 0.1
m_JitterScale: 1
m_MipBias: 0
m_VarianceClampScale: 0.9
m_ContrastAdaptiveSharpening: 0
--- !u!1 &6308356814245253661
GameObject:
m_ObjectHideFlags: 0
@ -371,8 +422,10 @@ GameObject:
- component: {fileID: 6308356814245253632}
- component: {fileID: 6308356814245253634}
- component: {fileID: 5069958635149219085}
- component: {fileID: 8938569698484985372}
- component: {fileID: -1362768914916555191}
- component: {fileID: -8136683798838004576}
- component: {fileID: -5923497047956986603}
m_Layer: 0
m_Name: Player
m_TagString: Untagged
@ -387,6 +440,7 @@ Transform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356814245253661}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
@ -395,7 +449,6 @@ Transform:
- {fileID: 6308356813253026692}
- {fileID: 6308356813655391140}
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &6308356814245253633
MeshFilter:
@ -468,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
@ -482,9 +536,21 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: db8117151f564304bae153aa55c0a960, type: 3}
m_Name:
m_EditorClassIdentifier:
_sendInterval: 0.05
_rigid: {fileID: -1362768914916555191}
_lerpRate: 0.1
--- !u!114 &8938569698484985372
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: 76493c52bd37a4a4db167d10a4dd6369, type: 3}
m_Name:
m_EditorClassIdentifier:
_playerId: 1234
_sendInterval: 0.05
--- !u!54 &-1362768914916555191
Rigidbody:
m_ObjectHideFlags: 0
@ -492,10 +558,21 @@ Rigidbody:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356814245253661}
serializedVersion: 2
serializedVersion: 4
m_Mass: 2
m_Drag: 5
m_AngularDrag: 0
m_CenterOfMass: {x: 0, y: 0, z: 0}
m_InertiaTensor: {x: 1, y: 1, z: 1}
m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_ImplicitCom: 1
m_ImplicitTensor: 1
m_UseGravity: 1
m_IsKinematic: 1
m_Interpolate: 1
@ -509,8 +586,32 @@ BoxCollider:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356814245253661}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 2
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

View File

@ -1595,6 +1595,85 @@ MeshFilter:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 490537900}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!1 &532753891
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 532753892}
- component: {fileID: 532753894}
- component: {fileID: 532753893}
m_Layer: 5
m_Name: AcknowledgedTick
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &532753892
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 532753891}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1979220485}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 500, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &532753893
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 532753891}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0}
m_FontSize: 30
m_FontStyle: 1
m_BestFit: 0
m_MinSize: 3
m_MaxSize: 40
m_Alignment: 0
m_AlignByGeometry: 0
m_RichText: 1
m_HorizontalOverflow: 0
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: "\u5BA2\u6237\u7AEF\u4F4D\u7F6E\uFF1A(1.00, 2.00, 3.00)"
--- !u!222 &532753894
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 532753891}
m_CullTransparentMesh: 1
--- !u!1 &632206104
GameObject:
m_ObjectHideFlags: 0
@ -2339,6 +2418,8 @@ MonoBehaviour:
_serverTickText: {fileID: 953008836}
_startTickOffsetText: {fileID: 1665502818}
_clientTickText: {fileID: 652355035}
_correctionText: {fileID: 1413607043}
_acknowledgedTickText: {fileID: 532753893}
--- !u!1 &805112150
GameObject:
m_ObjectHideFlags: 0
@ -4427,6 +4508,85 @@ MeshFilter:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1360915757}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!1 &1413607041
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1413607042}
- component: {fileID: 1413607044}
- component: {fileID: 1413607043}
m_Layer: 5
m_Name: CorrectionText
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1413607042
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1413607041}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1979220485}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 500, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1413607043
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1413607041}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0}
m_FontSize: 30
m_FontStyle: 1
m_BestFit: 0
m_MinSize: 3
m_MaxSize: 40
m_Alignment: 0
m_AlignByGeometry: 0
m_RichText: 1
m_HorizontalOverflow: 0
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: "\u5BA2\u6237\u7AEF\u4F4D\u7F6E\uFF1A(1.00, 2.00, 3.00)"
--- !u!222 &1413607044
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1413607041}
m_CullTransparentMesh: 1
--- !u!1 &1422544908
GameObject:
m_ObjectHideFlags: 0
@ -5659,6 +5819,8 @@ RectTransform:
m_Children:
- {fileID: 287018088}
- {fileID: 21534629}
- {fileID: 1413607042}
- {fileID: 532753892}
m_Father: {fileID: 789249236}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}

View File

@ -121,7 +121,7 @@ public sealed class ClientAuthoritativePlayerStateSnapshot
public int Hp { get; }
public Quaternion RotationQuaternion => Quaternion.Euler(0f, NormalizeDegrees(90f - Rotation), 0f);
public Quaternion RotationQuaternion => Quaternion.Euler(0f, NormalizeDegrees(Rotation), 0f);
private static float NormalizeDegrees(float degrees)
{

View File

@ -0,0 +1,101 @@
using Network.Defines;
using Network.NetworkApplication;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
public static class ClientGameplayInputFlow
{
public static bool HasPlanarInput(Vector3 input)
{
return new Vector2(input.x, input.z).sqrMagnitude > 0f;
}
public static bool TryCreateMoveInput(string playerId, long tick, Vector3 input, bool stopMessagePending,
out MoveInput message)
{
if (!HasPlanarInput(input) && !stopMessagePending)
{
message = null;
return false;
}
message = new MoveInput
{
PlayerId = playerId,
Tick = tick,
TurnInput = input.x,
ThrottleInput = input.z
};
return true;
}
public static bool TryCreateShootInput(
string playerId,
long tick,
bool fireTriggered,
Vector3 aimDirection,
out ShootInput message,
string targetId = "")
{
if (!fireTriggered)
{
message = null;
return false;
}
message = CreateShootInput(playerId, tick, aimDirection, targetId);
return true;
}
public static ShootInput CreateShootInput(string playerId, long tick, Vector3 aimDirection, string targetId = "")
{
var planarDirection = new Vector3(aimDirection.x, 0f, aimDirection.z);
if (planarDirection.sqrMagnitude <= 0f)
{
planarDirection = Vector3.forward;
}
else
{
planarDirection.Normalize();
}
return new ShootInput
{
PlayerId = playerId,
Tick = tick,
DirX = planarDirection.x,
DirY = planarDirection.z,
TargetId = targetId ?? string.Empty
};
}
public static void SendShootInput(
MessageManager messageManager,
string playerId,
long tick,
Vector3 aimDirection,
string targetId = "")
{
if (messageManager == null)
{
throw new System.ArgumentNullException(nameof(messageManager));
}
SendShootInput(messageManager, CreateShootInput(playerId, tick, aimDirection, targetId));
}
public static void SendShootInput(MessageManager messageManager, ShootInput message)
{
if (messageManager == null)
{
throw new System.ArgumentNullException(nameof(messageManager));
}
if (message == null)
{
throw new System.ArgumentNullException(nameof(message));
}
messageManager.SendMessage(message, MessageType.ShootInput);
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: eff1752caaabe784fb85775608605426
guid: e3a66b8917bed9648a9d99ee35525e6e
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@ -64,12 +64,16 @@ public readonly struct ControlledPlayerCorrectionResult
Vector3 position,
Quaternion rotation,
bool usedHardSnap,
ControlledPlayerVisualCorrectionState nextState)
ControlledPlayerVisualCorrectionState nextState,
float positionError,
float rotationErrorDegrees)
{
Position = position;
Rotation = rotation;
UsedHardSnap = usedHardSnap;
NextState = nextState;
PositionError = positionError;
RotationErrorDegrees = rotationErrorDegrees;
}
public Vector3 Position { get; }
@ -79,6 +83,10 @@ public readonly struct ControlledPlayerCorrectionResult
public bool UsedHardSnap { get; }
public ControlledPlayerVisualCorrectionState NextState { get; }
public float PositionError { get; }
public float RotationErrorDegrees { get; }
}
public static class ControlledPlayerCorrection
@ -114,17 +122,17 @@ public static class ControlledPlayerCorrection
if (positionError <= Mathf.Epsilon && rotationError <= Mathf.Epsilon)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None);
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
}
if (boundedPositionCorrection <= Mathf.Epsilon && boundedRotationCorrection <= Mathf.Epsilon)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
}
if (positionError > settings.SnapPositionThreshold || rotationError > settings.SnapRotationThresholdDegrees)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
}
var remainingStepBudget = activeCorrection.IsActive
@ -132,7 +140,7 @@ public static class ControlledPlayerCorrection
: settings.MaxCorrectionSteps;
if (remainingStepBudget <= 0)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
}
var correctedPosition = Vector3.MoveTowards(currentPosition, targetPosition, boundedPositionCorrection);
@ -141,19 +149,21 @@ public static class ControlledPlayerCorrection
var nextRotationError = Quaternion.Angle(correctedRotation, targetRotation);
if (nextPositionError <= Mathf.Epsilon && nextRotationError <= Mathf.Epsilon)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None);
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
}
remainingStepBudget--;
if (remainingStepBudget <= 0)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
}
return new ControlledPlayerCorrectionResult(
correctedPosition,
correctedRotation,
false,
new ControlledPlayerVisualCorrectionState(targetPosition, targetRotation, remainingStepBudget));
new ControlledPlayerVisualCorrectionState(targetPosition, targetRotation, remainingStepBudget),
positionError,
rotationError);
}
}

View File

@ -1,396 +0,0 @@
using System.Collections.Generic;
using Network.Defines;
using Network.NetworkApplication;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
public static class ClientGameplayInputFlow
{
public static bool HasPlanarInput(Vector3 input)
{
return new Vector2(input.x, input.z).sqrMagnitude > 0f;
}
public static bool TryCreateMoveInput(string playerId, long tick, Vector3 input, bool stopMessagePending, out MoveInput message)
{
if (!HasPlanarInput(input) && !stopMessagePending)
{
message = null;
return false;
}
message = new MoveInput
{
PlayerId = playerId,
Tick = tick,
TurnInput = -input.x,
ThrottleInput = input.z
};
return true;
}
public static bool TryCreateShootInput(
string playerId,
long tick,
bool fireTriggered,
Vector3 aimDirection,
out ShootInput message,
string targetId = "")
{
if (!fireTriggered)
{
message = null;
return false;
}
message = CreateShootInput(playerId, tick, aimDirection, targetId);
return true;
}
public static ShootInput CreateShootInput(string playerId, long tick, Vector3 aimDirection, string targetId = "")
{
var planarDirection = new Vector3(aimDirection.x, 0f, aimDirection.z);
if (planarDirection.sqrMagnitude <= 0f)
{
planarDirection = Vector3.forward;
}
else
{
planarDirection.Normalize();
}
return new ShootInput
{
PlayerId = playerId,
Tick = tick,
DirX = planarDirection.x,
DirY = planarDirection.z,
TargetId = targetId ?? string.Empty
};
}
public static void SendShootInput(
MessageManager messageManager,
string playerId,
long tick,
Vector3 aimDirection,
string targetId = "")
{
if (messageManager == null)
{
throw new System.ArgumentNullException(nameof(messageManager));
}
SendShootInput(messageManager, CreateShootInput(playerId, tick, aimDirection, targetId));
}
public static void SendShootInput(MessageManager messageManager, ShootInput message)
{
if (messageManager == null)
{
throw new System.ArgumentNullException(nameof(messageManager));
}
if (message == null)
{
throw new System.ArgumentNullException(nameof(message));
}
messageManager.SendMessage(message, MessageType.ShootInput);
}
}
public class MovementComponent : MonoBehaviour
{
[SerializeField] private float _sendInterval = 0.05f;
private Player _master;
private const float TurnSpeedDegreesPerSecond = 180f;
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 int _speed = 2;
[SerializeField] private Rigidbody _rigid;
private float _lastSendTime = 0;
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 readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new();
[SerializeField] private float _lerpRate = 0.1f;
private Vector3 _cachedMoveInput;
private Vector3 _lastAimDirection = Vector3.forward;
private bool _wasMovingLastFrame;
private bool _stopMessagePending;
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;
if (serverTick != 0 && _isControlled && MainUI.Instance != null) MainUI.Instance.OnStartTickOffsetChanged(serverTick);
}
private void Update()
{
if (_isControlled)
{
_cachedMoveInput = CaptureMovement();
var hasMovement = ClientGameplayInputFlow.HasPlanarInput(_cachedMoveInput);
if (hasMovement)
{
_stopMessagePending = false;
}
else if (_wasMovingLastFrame)
{
_stopMessagePending = true;
}
_wasMovingLastFrame = hasMovement;
var shootInput = CaptureShootInput();
if (shootInput != null)
{
NetworkManager.Instance.SendShootInput(shootInput);
}
if (Time.time - _lastSendTime > _sendInterval)
{
if (ClientGameplayInputFlow.TryCreateMoveInput(_master.PlayerId, Tick, _cachedMoveInput, _stopMessagePending, out var moveInput))
{
NetworkManager.Instance.SendMoveInput(moveInput);
_predictionBuffer.Record(moveInput);
_stopMessagePending = false;
}
_lastSendTime = Time.time;
Tick++;
MainUI.Instance.OnClientTickChanged(Tick);
}
}
}
private void FixedUpdate()
{
if (_isControlled)
{
if (_hasServerState)
{
if (MainUI.Instance != null)
{
MainUI.Instance.OnServerPosChanged(_serverPosition);
}
Reconcile(_lastAuthoritativeState);
_hasServerState = false;
}
Simulate(_cachedMoveInput);
_predictionBuffer.AccumulateLatest(Time.fixedDeltaTime);
}
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, out var replayInputs))
{
return;
}
var correction = ControlledPlayerCorrection.Resolve(
_rigid.position,
_rigid.rotation,
snapshot.Position,
snapshot.RotationQuaternion,
new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond),
_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(replayInputs);
}
private Vector3 CaptureMovement()
{
return new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
}
private ShootInput CaptureShootInput()
{
return ClientGameplayInputFlow.TryCreateShootInput(
_master.PlayerId,
Tick,
Input.GetMouseButtonDown(0),
ResolveAimDirection(),
out var shootInput)
? shootInput
: null;
}
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)
{
ApplyTankMovement(-input.x, input.z, Time.fixedDeltaTime);
if (_isControlled)
{
if (MainUI.Instance != null)
{
MainUI.Instance.OnClientPosChanged(_rigid.position);
}
}
}
public void OnAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (_isControlled)
{
_lastAuthoritativeState = snapshot;
_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);
}
}
if (_currentTickOffset < 0)
{
_sendInterval = 0.052f;
}
if (_currentTickOffset > 0)
{
_sendInterval = 0.048f;
}
}
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);
_rigid.velocity = velocity;
_rigid.position += velocity * deltaTime;
}
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;
}
}

View File

@ -1,23 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Network.Defines;
namespace Network.NetworkApplication
{
public readonly struct PredictedMoveStep
{
public PredictedMoveStep(MoveInput input, float simulatedDurationSeconds)
{
Input = input ?? throw new ArgumentNullException(nameof(input));
SimulatedDurationSeconds = simulatedDurationSeconds < 0f ? 0f : simulatedDurationSeconds;
}
public MoveInput Input { get; }
public float SimulatedDurationSeconds { get; }
}
public sealed class ClientPredictionBuffer
{
private readonly List<PredictedMoveStep> pendingInputs = new();
@ -26,8 +12,29 @@ namespace Network.NetworkApplication
public long? LastAcknowledgedMoveTick { get; private set; }
/// <summary>
/// Time of the last received authoritative state, used to compute
/// actual elapsed wall-clock time for accumulation synchronization.
/// </summary>
private float _lastAuthoritativeStateTime = float.NegativeInfinity;
/// <summary>
/// Returns the wall-clock time of the last authoritative state arrival.
/// Valid only after TryApplyAuthoritativeState has been called at least once.
/// </summary>
public float LastAuthoritativeStateTime => _lastAuthoritativeStateTime;
public IReadOnlyList<PredictedMoveStep> PendingInputs => pendingInputs;
/// <summary>
/// 清空所有 pending inputs。
/// 用于 Reconcile 后清理已重放的输入,避免它们的时间被继续累积。
/// </summary>
public void ClearPendingInputs()
{
pendingInputs.Clear();
}
public void Record(MoveInput input)
{
if (input == null)
@ -43,18 +50,42 @@ namespace Network.NetworkApplication
pendingInputs.Add(new PredictedMoveStep(input, 0f));
}
public void AccumulateLatest(float simulatedDurationSeconds)
public bool TryGetNextUnsimulatedInput(out PredictedMoveStep predictedMoveStep)
{
if (pendingInputs.Count == 0 || simulatedDurationSeconds <= 0f)
for (var i = 0; i < pendingInputs.Count; i++)
{
if (pendingInputs[i].SimulatedDurationSeconds <= 0f)
{
predictedMoveStep = pendingInputs[i];
return true;
}
}
predictedMoveStep = default;
return false;
}
public void MarkInputSimulated(long tick, float simulatedDurationSeconds)
{
if (simulatedDurationSeconds <= 0f)
{
return;
}
var latest = pendingInputs[^1];
pendingInputs[^1] = new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + simulatedDurationSeconds);
for (var i = 0; i < pendingInputs.Count; i++)
{
if (pendingInputs[i].Input.Tick != tick)
{
continue;
}
pendingInputs[i] = new PredictedMoveStep(pendingInputs[i].Input, simulatedDurationSeconds);
return;
}
}
public bool TryApplyAuthoritativeState(PlayerState state, out IReadOnlyList<PredictedMoveStep> replayInputs)
public bool TryApplyAuthoritativeState(PlayerState state, float currentTime,
out IReadOnlyList<PredictedMoveStep> replayInputs)
{
if (state == null)
{
@ -70,8 +101,27 @@ namespace Network.NetworkApplication
LastAuthoritativeTick = state.Tick;
LastAcknowledgedMoveTick = state.AcknowledgedMoveTick;
pendingInputs.RemoveAll(input => input.Input.Tick <= state.AcknowledgedMoveTick);
replayInputs = pendingInputs.ToArray();
replayInputs = pendingInputs.FindAll(input => input.SimulatedDurationSeconds > 0f);
// Reset the elapsed-time tracker so the next accumulation period
// starts from this authoritative state's arrival time.
_lastAuthoritativeStateTime = currentTime;
return true;
}
/// <summary>
/// 只清除已确认的旧输入,不触发 replay不更新 LastAuthoritativeTick。
/// 用于在校正被禁用时,保持 predictionBuffer 的输入与服务端同步。
/// </summary>
public void PruneAcknowledgedInputs(long acknowledgedMoveTick)
{
if (acknowledgedMoveTick <= 0)
{
return;
}
pendingInputs.RemoveAll(input => input.Input.Tick <= acknowledgedMoveTick);
LastAcknowledgedMoveTick = acknowledgedMoveTick;
}
}
}

View File

@ -1,22 +1,60 @@
using System.Collections.Generic;
using System.Linq;
using Network.Defines;
namespace Network.NetworkApplication
{
/// <summary>
/// Resolves the delivery policy for each <see cref="MessageType"/>.
/// Policies are intentionally explicit here so that adding a new <see cref="MessageType"/>
/// requires an intentional decision rather than silently falling through to a default.
/// </summary>
public sealed class DefaultMessageDeliveryPolicyResolver : IMessageDeliveryPolicyResolver
{
private static readonly IReadOnlyDictionary<MessageType, DeliveryPolicy> DefaultPolicies =
private static readonly IReadOnlyDictionary<MessageType, DeliveryPolicy> Policies =
new Dictionary<MessageType, DeliveryPolicy>
{
// High-frequency sync lane: latest-wins, stale-drop.
// These messages are sent every frame and a stale value is never useful.
{ MessageType.MoveInput, DeliveryPolicy.HighFrequencySync },
{ MessageType.PlayerState, DeliveryPolicy.HighFrequencySync }
{ MessageType.PlayerState, DeliveryPolicy.HighFrequencySync },
// Reliable ordered lane: guaranteed delivery, ordered.
// ShootInput carries player intent; losing or reordering it changes gameplay outcomes.
{ MessageType.ShootInput, DeliveryPolicy.ReliableOrdered },
// CombatEvent is a server-authoritative result; clients must receive it reliably
// and in order to maintain consistent HP/death state.
{ MessageType.CombatEvent, DeliveryPolicy.ReliableOrdered },
// PlayerJoin carries spawn data that the client needs to instantiate a player.
// Reliable ordered ensures the client receives it before gameplay begins.
{ MessageType.PlayerJoin, DeliveryPolicy.ReliableOrdered },
// Login/logout are session-control messages that must not be lost or reordered.
{ MessageType.LoginRequest, DeliveryPolicy.ReliableOrdered },
{ MessageType.LoginResponse, DeliveryPolicy.ReliableOrdered },
{ MessageType.LogoutRequest, DeliveryPolicy.ReliableOrdered },
// Heartbeat carries server tick used for clock sync; a missing sample is
// simply a lost sample — no value in stale delivery.
{ MessageType.Heartbeat, DeliveryPolicy.ReliableOrdered },
{ MessageType.HeartbeatResponse, DeliveryPolicy.ReliableOrdered },
};
public DeliveryPolicy Resolve(MessageType messageType)
{
return DefaultPolicies.TryGetValue(messageType, out var policy)
? policy
: DeliveryPolicy.ReliableOrdered;
if (Policies.TryGetValue(messageType, out var policy))
{
return policy;
}
// A new MessageType was added without an explicit policy decision.
// Fail fast so the omission is noticed rather than silently defaulting.
throw new System.ArgumentException(
$"MessageType '{messageType}' has no assigned {nameof(DeliveryPolicy)}. " +
"Add an explicit entry in the policy dictionary.",
nameof(messageType));
}
}
}
}

View File

@ -1,5 +1,4 @@
using System;
using System.Threading.Tasks;
using Network.NetworkHost;
using Network.NetworkTransport;
@ -63,8 +62,6 @@ namespace Network.NetworkApplication
ServerAuthoritativeCombatConfiguration authoritativeCombat = null,
IAuthoritativeMovementWorldValidator authoritativeMovementWorldValidator = null)
{
ValidateDualPortConfiguration(reliablePort, syncPort);
transportFactory ??= static port => new KcpTransport(port);
var reliableTransport = transportFactory(reliablePort)
@ -91,11 +88,6 @@ namespace Network.NetworkApplication
authoritativeMovementWorldValidator ?? PermissiveAuthoritativeMovementWorldValidator.Instance);
}
public static Task<ServerRuntimeHandle> StartServerRuntimeAsync(ServerRuntimeConfiguration configuration)
{
return ServerRuntimeEntryPoint.StartAsync(configuration);
}
private static void ValidateDualPortConfiguration(int reliablePort, int? syncPort)
{
if (reliablePort <= 0)

View File

@ -0,0 +1,18 @@
using System;
using Network.Defines;
namespace Network.NetworkApplication
{
public readonly struct PredictedMoveStep
{
public PredictedMoveStep(MoveInput input, float simulatedDurationSeconds)
{
Input = input ?? throw new ArgumentNullException(nameof(input));
SimulatedDurationSeconds = simulatedDurationSeconds < 0f ? 0f : simulatedDurationSeconds;
}
public MoveInput Input { get; }
public float SimulatedDurationSeconds { get; }
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 20280892ecfd4b25bff064d07668accc
timeCreated: 1775619625

View File

@ -41,7 +41,7 @@ namespace Network.NetworkApplication
case MessageType.MoveInput:
{
var input = MoveInput.Parser.ParseFrom(payload);
streamKey = $"input:{Normalize(sender)}:{input.PlayerId}";
streamKey = $"input:{input.PlayerId}";
sequence = input.Tick;
return true;
}

View File

@ -42,6 +42,29 @@ namespace Network.NetworkHost
}
}
public void BootstrapState(IPEndPoint remoteEndPoint, string playerId, int hp, bool isDead)
{
if (remoteEndPoint == null || string.IsNullOrWhiteSpace(playerId))
{
return;
}
var key = Normalize(remoteEndPoint).ToString();
lock (gate)
{
if (statesByPeer.ContainsKey(key))
{
return;
}
statesByPeer.Add(key, new ServerAuthoritativeCombatState(
Normalize(remoteEndPoint),
playerId,
hp,
isDead));
}
}
public Task HandleShootInputAsync(byte[] payload, IPEndPoint sender)
{
if (payload == null || sender == null)
@ -95,7 +118,7 @@ namespace Network.NetworkHost
EventType = CombatEventType.Hit,
AttackerId = attackerState.PlayerId,
TargetId = targetState.PlayerId,
Damage = 0,
Damage = configuration.DamagePerShot,
HitPosition = hitPosition
}, MessageType.CombatEvent);
@ -185,7 +208,7 @@ namespace Network.NetworkHost
}
if (!movementCoordinator.TryGetState(acceptedPeer, out attackerState) &&
!movementCoordinator.EnsureState(acceptedPeer, input.PlayerId, out attackerState))
!movementCoordinator.EnsureState(acceptedPeer, input.PlayerId, null, out attackerState))
{
return false;
}

View File

@ -34,6 +34,8 @@ namespace Network.NetworkHost
public TimeSpan SimulationInterval => configuration.SimulationInterval;
public float MoveSpeed => configuration.MoveSpeed;
public IReadOnlyList<ServerAuthoritativeMovementState> States
{
get
@ -47,7 +49,7 @@ namespace Network.NetworkHost
}
}
public bool EnsureState(IPEndPoint remoteEndPoint, string playerId, out ServerAuthoritativeMovementState state)
public bool EnsureState(IPEndPoint remoteEndPoint, string playerId, float? speed, out ServerAuthoritativeMovementState state)
{
if (remoteEndPoint == null)
{
@ -73,14 +75,21 @@ namespace Network.NetworkHost
return false;
}
if (speed.HasValue)
{
existingState.Speed = speed.Value;
}
state = CloneState(existingState);
return true;
}
var resolvedSpeed = speed ?? configuration.MoveSpeed;
var createdState = new ServerAuthoritativeMovementState(
normalizedSender,
playerId,
configuration.DefaultHp);
configuration.DefaultHp,
resolvedSpeed);
statesByPeer.Add(key, createdState);
state = CloneState(createdState);
return true;
@ -165,6 +174,11 @@ namespace Network.NetworkHost
IntegrateState(state, configuration.SimulationInterval);
}
foreach (var state in statesByPeer.Values)
{
state.HasInputThisFrame = false;
}
accumulatedBroadcastTime += configuration.SimulationInterval;
while (accumulatedBroadcastTime >= configuration.BroadcastInterval)
{
@ -343,7 +357,8 @@ namespace Network.NetworkHost
Rotation = state.Rotation,
IsDead = state.IsDead,
InputX = state.InputX,
InputY = state.InputY
InputY = state.InputY,
Speed = state.Speed
};
}
@ -352,6 +367,7 @@ namespace Network.NetworkHost
state.LastAcceptedMoveTick = input.Tick;
state.InputX = ClampInput(input.TurnInput);
state.InputY = ClampInput(input.ThrottleInput);
state.HasInputThisFrame = true;
if (state.InputY == 0f)
{
@ -380,6 +396,16 @@ namespace Network.NetworkHost
return;
}
if (!state.HasInputThisFrame)
{
state.InputX = 0f;
state.InputY = 0f;
state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
var turnInput = ClampInput(state.InputX);
var throttleInput = ClampInput(state.InputY);
if (turnInput != 0f)
@ -396,11 +422,11 @@ namespace Network.NetworkHost
}
var rotationRadians = state.Rotation * (MathF.PI / 180f);
var forwardX = MathF.Cos(rotationRadians);
var forwardZ = MathF.Sin(rotationRadians);
state.VelocityX = forwardX * (throttleInput * configuration.MoveSpeed);
var forwardX = MathF.Sin(rotationRadians);
var forwardZ = MathF.Cos(rotationRadians);
state.VelocityX = forwardX * (throttleInput * state.Speed);
state.VelocityY = 0f;
state.VelocityZ = forwardZ * (throttleInput * configuration.MoveSpeed);
state.VelocityZ = forwardZ * (throttleInput * state.Speed);
var candidatePositionX = state.PositionX + (state.VelocityX * deltaSeconds);
var candidatePositionY = state.PositionY + (state.VelocityY * deltaSeconds);

View File

@ -5,12 +5,13 @@ namespace Network.NetworkHost
{
public sealed class ServerAuthoritativeMovementState
{
public ServerAuthoritativeMovementState(IPEndPoint remoteEndPoint, string playerId, int hp)
public ServerAuthoritativeMovementState(IPEndPoint remoteEndPoint, string playerId, int hp, float speed = 5f)
{
RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint));
PlayerId = playerId ?? throw new ArgumentNullException(nameof(playerId));
Hp = hp;
IsDead = hp <= 0;
Speed = speed;
}
public IPEndPoint RemoteEndPoint { get; }
@ -44,5 +45,9 @@ namespace Network.NetworkHost
public float InputX { get; internal set; }
public float InputY { get; internal set; }
public bool HasInputThisFrame { get; internal set; }
public float Speed { get; internal set; }
}
}

View File

@ -71,6 +71,8 @@ namespace Network.NetworkHost
public MultiSessionManager SessionCoordinator { get; }
public float AuthoritativeMoveSpeed => authoritativeMovementCoordinator.MoveSpeed;
public TimeSpan AuthoritativeMovementCadence => authoritativeMovementCoordinator.SimulationInterval;
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => SessionCoordinator.Sessions;
@ -268,14 +270,22 @@ namespace Network.NetworkHost
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint)
{
SessionCoordinator.NotifyLoginSucceeded(remoteEndPoint);
BootstrapAuthoritativeMovementState(remoteEndPoint);
BootstrapAuthoritativeMovementState(remoteEndPoint, null);
PublishMetricsSessionSnapshot(remoteEndPoint);
}
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint, string playerId)
{
NotifyLoginSucceeded(remoteEndPoint, playerId, null);
}
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint, string playerId, float? speed)
{
RememberPlayerId(remoteEndPoint, playerId);
NotifyLoginSucceeded(remoteEndPoint);
SessionCoordinator.NotifyLoginSucceeded(remoteEndPoint);
BootstrapAuthoritativeMovementState(remoteEndPoint, speed);
BootstrapAuthoritativeCombatState(remoteEndPoint, playerId);
PublishMetricsSessionSnapshot(remoteEndPoint);
}
public void NotifyLoginFailed(IPEndPoint remoteEndPoint, string reason = null)
@ -363,14 +373,29 @@ namespace Network.NetworkHost
}
}
private void BootstrapAuthoritativeMovementState(IPEndPoint remoteEndPoint)
private void BootstrapAuthoritativeMovementState(IPEndPoint remoteEndPoint, float? speed)
{
if (!TryGetKnownPlayerId(remoteEndPoint, out var playerId))
{
return;
}
authoritativeMovementCoordinator.EnsureState(remoteEndPoint, playerId, out _);
authoritativeMovementCoordinator.EnsureState(remoteEndPoint, playerId, speed, out _);
}
private void BootstrapAuthoritativeCombatState(IPEndPoint remoteEndPoint, string playerId)
{
if (!TryGetKnownPlayerId(remoteEndPoint, out var resolvedPlayerId))
{
return;
}
if (!authoritativeMovementCoordinator.TryGetState(remoteEndPoint, out var movementState))
{
return;
}
authoritativeCombatCoordinator.BootstrapState(remoteEndPoint, resolvedPlayerId, movementState.Hp, movementState.IsDead);
}
private void RememberPlayerId(IPEndPoint remoteEndPoint, string playerId)

View File

@ -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;
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 10d331d181503b542868b5608961489e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 309fec203b04d2b4cb87f9c2873c0449
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,11 @@
using UnityEngine;
/// <summary>
/// 输入源接口,用于解耦输入捕获
/// </summary>
public interface IInputSource
{
Vector3 GetPlanarInput();
bool ConsumeShootInput();
Vector3 GetAimDirection();
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e7d6671c80a243619f1f3dc34ca92d15
timeCreated: 1775619222

View File

@ -0,0 +1,137 @@
using System;
using Network.Defines;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
/// <summary>
/// 输入组件,负责从 IInputSource 获取输入、发送 MoveInput 到服务器、管理 tick
/// Update() 是唯一调用 SendMoveInput / SendShootInput 的地方
/// </summary>
public class InputComponent : MonoBehaviour
{
[SerializeField] private string _playerId = "player";
[SerializeField] private float _sendInterval = 0.05f; // 50ms 发送间隔
private IInputSource _inputSource;
private Vector3 _currentInput;
private Vector3 _lastAimDirection = Vector3.forward;
private float _lastSendTime;
private bool _stopMessagePending;
private bool _wasMovingLastFrame;
private long _tick;
public event Action<MoveInput> OnMoveInputCreated;
public event Action<ShootInput> OnShootInputCreated;
public long CurrentTick => _tick;
/// <summary>
/// 设置玩家 ID由 MovementComponent.Init 调用)
/// </summary>
public void InjectPlayerId(string playerId)
{
_playerId = playerId;
}
private void Awake()
{
var camera = Camera.main;
_inputSource = new UnityInputSource(camera?.transform);
}
/// <summary>
/// 设置自定义输入源(用于替换默认的 UnityInputSource
/// </summary>
public void SetInputSource(IInputSource source)
{
_inputSource = source ?? throw new ArgumentNullException(nameof(source));
}
/// <summary>
/// 获取当前输入源(用于测试)
/// </summary>
public IInputSource GetInputSource()
{
return _inputSource;
}
/// <summary>
/// 重置 tick用于测试
/// </summary>
public void ResetTick(long tick = 0)
{
_tick = tick;
}
private void Update()
{
// 从输入源获取输入
_currentInput = _inputSource?.GetPlanarInput() ?? Vector3.zero;
// 检测移动状态变化
bool hasMovement = ClientGameplayInputFlow.HasPlanarInput(_currentInput);
if (hasMovement)
{
_stopMessagePending = false;
}
else if (_wasMovingLastFrame)
{
_stopMessagePending = true;
}
_wasMovingLastFrame = hasMovement;
// 处理射击输入
var shootInput = GetShootInput();
if (shootInput != null && NetworkManager.Instance != null)
{
NetworkManager.Instance.SendShootInput(shootInput);
OnShootInputCreated?.Invoke(shootInput);
}
// 定期发送移动输入
if (Time.time - _lastSendTime > _sendInterval)
{
SendMoveInput();
}
}
private void SendMoveInput()
{
if (!ClientGameplayInputFlow.TryCreateMoveInput(_playerId, _tick, _currentInput,
_stopMessagePending, out var moveInput))
{
return;
}
if (NetworkManager.Instance != null)
{
NetworkManager.Instance.SendMoveInput(moveInput);
}
OnMoveInputCreated?.Invoke(moveInput);
_stopMessagePending = false;
_lastSendTime = Time.time;
_tick++;
}
private ShootInput GetShootInput()
{
var shootTriggered = _inputSource?.ConsumeShootInput() ?? false;
var aimDirection = _inputSource?.GetAimDirection() ?? Vector3.forward;
if (!shootTriggered)
{
return null;
}
var planarForward = Vector3.ProjectOnPlane(aimDirection, Vector3.up);
if (ClientGameplayInputFlow.HasPlanarInput(planarForward))
{
_lastAimDirection = planarForward;
}
return ClientGameplayInputFlow.CreateShootInput(_playerId, _tick, _lastAimDirection);
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: cf8f141cd490efa4aa23369000c805db
guid: 76493c52bd37a4a4db167d10a4dd6369
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@ -0,0 +1,82 @@
using UnityEngine;
/// <summary>
/// 模拟输入源(测试用),提供预设的输入序列
/// </summary>
public class SimulatedInputSource : IInputSource
{
private readonly (float turn, float throttle)[] _inputSequence;
private int _index;
private Vector3 _lastAimDirection = Vector3.forward;
private bool _shootTriggered;
public SimulatedInputSource((float turn, float throttle)[] sequence)
{
_inputSequence = sequence;
_index = 0;
}
public Vector3 GetPlanarInput()
{
if (_index >= _inputSequence.Length)
{
return Vector3.zero;
}
var (turn, throttle) = _inputSequence[_index];
return new Vector3(turn, 0f, throttle);
}
public bool ConsumeShootInput()
{
if (_shootTriggered)
{
_shootTriggered = false;
return true;
}
return false;
}
public Vector3 GetAimDirection()
{
return _lastAimDirection;
}
/// <summary>
/// 推进到下一个输入
/// </summary>
public void Advance()
{
if (_index < _inputSequence.Length)
{
_index++;
}
}
/// <summary>
/// 是否还有更多输入
/// </summary>
public bool HasMore => _index < _inputSequence.Length;
/// <summary>
/// 设置射击触发(下次 ConsumeShootInput 返回 true
/// </summary>
public void SetShootTriggered()
{
_shootTriggered = true;
}
/// <summary>
/// 设置瞄准方向
/// </summary>
public void SetAimDirection(Vector3 direction)
{
_lastAimDirection = direction;
}
/// <summary>
/// 获取当前输入索引
/// </summary>
public int CurrentIndex => _index;
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 53e3773e493842e8861ac7522a6227a9
timeCreated: 1775619270

View File

@ -0,0 +1,34 @@
using UnityEngine;
/// <summary>
/// 真实的 Unity 输入源
/// </summary>
public class UnityInputSource : IInputSource
{
private readonly Transform _cameraTransform;
public UnityInputSource(Transform cameraTransform)
{
_cameraTransform = cameraTransform;
}
public Vector3 GetPlanarInput()
{
return new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
}
public bool ConsumeShootInput()
{
return Input.GetMouseButtonDown(0);
}
public Vector3 GetAimDirection()
{
if (_cameraTransform != null)
{
return _cameraTransform.forward;
}
return Vector3.forward;
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5d7f0a25d2b54decbf2c2386e5c0ebd2
timeCreated: 1775619246

View File

@ -0,0 +1,169 @@
using UnityEngine;
public class MovementComponent : MonoBehaviour
{
[SerializeField] private Rigidbody _rigid;
[SerializeField] private float _followMoveSpeed = 2f;
[SerializeField] private float _followTurnSpeedDegreesPerSecond = 180f;
[SerializeField] private float _correctionDecayMoveSpeed = 4f;
[SerializeField] private float _correctionDecayTurnSpeedDegreesPerSecond = 360f;
private const float RemoteInterpolationAlpha = 0.15f;
private const float UnexpectedTurnLogCooldownSeconds = 0.25f;
private bool _isControlled;
private Vector3 _currentPosition;
private Quaternion _currentRotation;
private Vector3 _targetPosition;
private Quaternion _targetRotation;
private Vector3 _correctionPositionOffset;
private Quaternion _correctionRotationOffset = Quaternion.identity;
private float _expectedTurnInput;
private float _lastUnexpectedTurnLogTime = float.NegativeInfinity;
private void Awake()
{
_rigid ??= GetComponent<Rigidbody>();
}
public void Init(bool isControlled, float followMoveSpeed = 2f, float followTurnSpeedDegreesPerSecond = 180f)
{
_rigid ??= GetComponent<Rigidbody>();
_isControlled = isControlled;
_followMoveSpeed = Mathf.Max(0f, followMoveSpeed);
_followTurnSpeedDegreesPerSecond = Mathf.Max(0f, followTurnSpeedDegreesPerSecond);
_correctionDecayMoveSpeed = Mathf.Max(_followMoveSpeed * 2f, _followMoveSpeed);
_correctionDecayTurnSpeedDegreesPerSecond =
Mathf.Max(_followTurnSpeedDegreesPerSecond * 2f, _followTurnSpeedDegreesPerSecond);
_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;
_correctionPositionOffset = Vector3.zero;
_correctionRotationOffset = Quaternion.identity;
}
private void Update()
{
var beforeRotation = _currentRotation;
if (_isControlled)
{
_correctionPositionOffset = Vector3.MoveTowards(
_correctionPositionOffset,
Vector3.zero,
_correctionDecayMoveSpeed * Time.deltaTime);
_correctionRotationOffset = Quaternion.RotateTowards(
_correctionRotationOffset,
Quaternion.identity,
_correctionDecayTurnSpeedDegreesPerSecond * Time.deltaTime);
var desiredPosition = _targetPosition + _correctionPositionOffset;
var desiredRotation = _targetRotation * _correctionRotationOffset;
_currentPosition = Vector3.MoveTowards(_currentPosition, desiredPosition, _followMoveSpeed * Time.deltaTime);
_currentRotation = Quaternion.RotateTowards(
_currentRotation,
desiredRotation,
_followTurnSpeedDegreesPerSecond * Time.deltaTime);
}
else
{
_currentPosition = Vector3.Lerp(_currentPosition, _targetPosition, RemoteInterpolationAlpha);
_currentRotation = Quaternion.Slerp(_currentRotation, _targetRotation, RemoteInterpolationAlpha);
}
_rigid.position = _currentPosition;
_rigid.rotation = _currentRotation;
LogUnexpectedTurnIfNeeded(beforeRotation, _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 SetExpectedTurnInput(float expectedTurnInput)
{
_expectedTurnInput = expectedTurnInput;
}
public void BlendToPoseFromCurrent(Vector3 position, Quaternion rotation)
{
_targetPosition = position;
_targetRotation = rotation;
_correctionPositionOffset = _currentPosition - position;
_correctionRotationOffset = Quaternion.Inverse(rotation) * _currentRotation;
}
public void SnapToPose(Vector3 position, Quaternion rotation)
{
_currentPosition = position;
_currentRotation = rotation;
_targetPosition = position;
_targetRotation = rotation;
_correctionPositionOffset = Vector3.zero;
_correctionRotationOffset = Quaternion.identity;
_rigid.position = position;
_rigid.rotation = rotation;
}
private void LogUnexpectedTurnIfNeeded(Quaternion beforeRotation, Quaternion afterRotation)
{
if (!_isControlled || Mathf.Abs(_expectedTurnInput) < 0.01f)
{
return;
}
var beforeError = Quaternion.Angle(beforeRotation, _targetRotation);
var afterError = Quaternion.Angle(afterRotation, _targetRotation);
if (beforeError < 0.1f && afterError < 0.1f)
{
return;
}
var deltaYaw = Mathf.DeltaAngle(beforeRotation.eulerAngles.y, afterRotation.eulerAngles.y);
if (Mathf.Abs(deltaYaw) < 0.01f)
{
return;
}
if (afterError <= beforeError + 0.05f)
{
return;
}
if (Time.time - _lastUnexpectedTurnLogTime < UnexpectedTurnLogCooldownSeconds)
{
return;
}
_lastUnexpectedTurnLogTime = Time.time;
Debug.LogWarning(
$"[UnexpectedTurnAwayFromTarget] expectedTurn={_expectedTurnInput:F2} deltaYaw={deltaYaw:F2} " +
$"beforeYaw={beforeRotation.eulerAngles.y:F2} afterYaw={afterRotation.eulerAngles.y:F2} " +
$"beforeError={beforeError:F2} afterError={afterError:F2} " +
$"current=({_currentPosition.x:F3},{_currentPosition.y:F3},{_currentPosition.z:F3}) " +
$"target=({_targetPosition.x:F3},{_targetPosition.y:F3},{_targetPosition.z:F3}) " +
$"correctionRot={_correctionRotationOffset.eulerAngles.y:F2}");
}
}

View File

@ -0,0 +1,271 @@
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;
[SerializeField] private float SnapThreshold = 0.5f;
private const float TurnSpeedDegreesPerSecond = 180f;
[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, _speed, TurnSpeedDegreesPerSecond);
_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)
{
if (!_predictionBuffer.TryGetNextUnsimulatedInput(out var nextInput))
{
_simulationAccumulator = 0f;
break;
}
Simulate(nextInput.Input);
_predictionBuffer.MarkInputSimulated(nextInput.Input.Tick, ServerSimulationStepSeconds);
_simulationAccumulator -= ServerSimulationStepSeconds;
}
return;
}
var sample = _remoteSnapshotInterpolator.Sample(Time.time);
if (sample.HasValue)
{
_movement.SnapToPose(sample.Position, sample.Rotation);
}
}
private void Simulate(MoveInput moveInput)
{
var simulationTurnInput = ToSimulationTurnInput(moveInput.TurnInput);
TankMovementKinematics.ApplyStep(
_speed,
simulationTurnInput,
moveInput.ThrottleInput,
ServerSimulationStepSeconds,
ref _predictedPosition, ref _predictedRotation);
_movement.SetExpectedTurnInput(simulationTurnInput);
_movement.SetTargetPose(_predictedPosition, _predictedRotation);
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);
var shouldSnap = error > SnapThreshold;
Debug.Log(
$"[Reconcile] tick={snapshot.SourceState.Tick} ack={snapshot.AcknowledgedMoveTick} " +
$"error={error:F3} threshold={SnapThreshold:F3} snap={shouldSnap} " +
$"current=({_movement.CurrentPosition.x:F3},{_movement.CurrentPosition.y:F3},{_movement.CurrentPosition.z:F3}) " +
$"predicted=({_predictedPosition.x:F3},{_predictedPosition.y:F3},{_predictedPosition.z:F3}) " +
$"authoritative=({_authoritativePosition.x:F3},{_authoritativePosition.y:F3},{_authoritativePosition.z:F3})");
if (shouldSnap)
{
_movement.SnapToPose(_predictedPosition, _predictedRotation);
}
else
{
_movement.BlendToPoseFromCurrent(_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 void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
{
var lastSimulationTurnInput = 0f;
foreach (var replayInput in replayInputs)
{
var remaining = replayInput.SimulatedDurationSeconds;
while (remaining > 0f)
{
var step = Mathf.Min(remaining, ServerSimulationStepSeconds);
var beforeYaw = _predictedRotation.eulerAngles.y;
var simulationTurnInput = ToSimulationTurnInput(replayInput.Input.TurnInput);
lastSimulationTurnInput = simulationTurnInput;
TankMovementKinematics.ApplyStep(
_speed,
simulationTurnInput,
replayInput.Input.ThrottleInput,
step,
ref _predictedPosition,
ref _predictedRotation);
var afterYaw = _predictedRotation.eulerAngles.y;
Debug.Log(
$"[ReplayStep] authTick={_lastAuthoritativeState?.SourceState?.Tick ?? 0} " +
$"inputTick={replayInput.Input.Tick} netTurn={replayInput.Input.TurnInput:F2} simTurn={simulationTurnInput:F2} " +
$"throttle={replayInput.Input.ThrottleInput:F2} step={step:F3} " +
$"yaw={beforeYaw:F2}->{afterYaw:F2} " +
$"predicted=({_predictedPosition.x:F3},{_predictedPosition.y:F3},{_predictedPosition.z:F3})");
remaining -= step;
}
}
_movement.SetExpectedTurnInput(lastSimulationTurnInput);
}
private static float ToSimulationTurnInput(float networkTurnInput)
{
return -networkTurnInput;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fcfebc0989c4bf04cacf1c633d49a8bb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 22558181f2430ca4b80f5787ec62c68d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,6 +1,9 @@
using System.Collections.Generic;
using Network.Defines;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using Vector3 = UnityEngine.Vector3;
public class MainUI : MonoBehaviour
{
@ -11,12 +14,16 @@ public class MainUI : MonoBehaviour
[SerializeField] private Text _serverTickText;
[SerializeField] private Text _startTickOffsetText;
[SerializeField] private Text _clientTickText;
[SerializeField] private Text _correctionText;
[SerializeField] private Text _acknowledgedTickText;
public UnityAction<Vector3> OnServerPosChanged;
public UnityAction<Vector3> OnClientPosChanged;
public UnityAction<long> OnServerTickChanged;
public UnityAction<long> OnStartTickOffsetChanged;
public UnityAction<long> OnClientTickChanged;
public UnityAction<Vector3, Vector3, float, float> OnCorrectionMagnitudeChanged;
public UnityAction<long> OnAcknowledgedMoveTickChanged;
private void Awake()
{
@ -30,6 +37,8 @@ public class MainUI : MonoBehaviour
OnServerTickChanged += UpdateServerTickText;
OnClientTickChanged += UpdateClientTickText;
OnStartTickOffsetChanged += UpdateStartTickOffsetText;
OnCorrectionMagnitudeChanged += UpdateCorrectionText;
OnAcknowledgedMoveTickChanged += UpdateAcknowledgedTickText;
}
private void OnDisable()
@ -39,6 +48,8 @@ public class MainUI : MonoBehaviour
OnServerTickChanged -= UpdateServerTickText;
OnClientTickChanged -= UpdateClientTickText;
OnStartTickOffsetChanged -= UpdateStartTickOffsetText;
OnCorrectionMagnitudeChanged -= UpdateCorrectionText;
OnAcknowledgedMoveTickChanged -= UpdateAcknowledgedTickText;
}
private void UpdateServerPositionText(Vector3 pos)
@ -65,4 +76,15 @@ public class MainUI : MonoBehaviour
{
_clientTickText.text = "客户端Tick" + tick;
}
private void UpdateCorrectionText(Vector3 predictedPos, Vector3 authoritativePos, float positionError,
float rotationError)
{
_correctionText.text = $"校正pos差={positionError:F4} rot差={rotationError:F2}°";
}
private void UpdateAcknowledgedTickText(long tick)
{
_acknowledgedTickText.text = "AckTick" + tick;
}
}

View File

@ -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()
{
}
}

View File

@ -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,7 +165,7 @@ namespace Tests.EditMode.Network
}
[Test]
public void ClientGameplayFlow_ControlledPlayerReconciliation_EscalatesToSnapAfterFailedConvergence()
public void ClientGameplayFlow_ControlledPlayerReconciliation_EscalatesToSnapForLargeDivergence()
{
var gameObject = new GameObject("controlled-player");
try
@ -165,25 +173,73 @@ 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(3.0f, 0f, 0f), acknowledgedMoveTick: 0)));
Assert.That(rigidbody.position.x, Is.EqualTo(3.0f).Within(0.0001f),
"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);
}
}
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(1.25f, 0f, 0f), acknowledgedMoveTick: 0)));
InvokeControlledFixedUpdate(movement);
Assert.That(rigidbody.position.x, Is.EqualTo(1f).Within(0.0001f));
[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);
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
GameplayFlowTestSupport.CreatePlayerState("player-1", 3, new Vector3(1.75f, 0f, 0f), acknowledgedMoveTick: 0)));
InvokeControlledFixedUpdate(movement);
Assert.That(rigidbody.position.x, Is.EqualTo(1.75f).Within(0.0001f));
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.Record(new MoveInput
{
PlayerId = "player-1",
Tick = 2,
TurnInput = 0f,
ThrottleInput = 1f
});
predictionBuffer.MarkInputSimulated(1, 0.05f);
predictionBuffer.MarkInputSimulated(2, 0.05f);
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(2));
Assert.That(predictionBuffer.PendingInputs[0].Input.Tick, Is.EqualTo(1));
Assert.That(predictionBuffer.PendingInputs[1].Input.Tick, Is.EqualTo(2));
}
finally
{
@ -223,10 +279,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);
}
}

View File

@ -99,7 +99,7 @@ namespace Tests.EditMode.Network
Assert.That(serverRuntime.AuthoritativeMovementCadence, Is.EqualTo(TimeSpan.FromMilliseconds(50)));
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var localServerState), Is.True);
Assert.That(localServerState.PlayerId, Is.EqualTo("player-a"));
Assert.That(localServerState.PositionX, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(localServerState.PositionZ, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(serverRuntime.TryGetAuthoritativeCombatState(RemotePeer, out var remoteCombatState), Is.True);
Assert.That(remoteCombatState.PlayerId, Is.EqualTo("player-b"));
Assert.That(remoteCombatState.Hp, Is.EqualTo(70));
@ -107,7 +107,7 @@ namespace Tests.EditMode.Network
Assert.That(clientHarness.TryGetState("player-a", out var localClientState), Is.True);
Assert.That(localClientState.Tick, Is.EqualTo(1));
Assert.That(localClientState.AcknowledgedMoveTick, Is.EqualTo(1));
Assert.That(localClientState.Position.x, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(localClientState.Position.z, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(clientHarness.TryGetState("player-b", out var remoteClientState), Is.True);
Assert.That(remoteClientState.Hp, Is.EqualTo(70));
Assert.That(clientHarness.TryGetCombatPresentation("player-b", out var remoteCombatPresentation), Is.True);
@ -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]);
@ -228,7 +228,6 @@ namespace Tests.EditMode.Network
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var localMovementState), Is.True);
Assert.That(localMovementState.PlayerId, Is.EqualTo("player-a"));
Assert.That(localMovementState.LastAcceptedMoveTick, Is.EqualTo(0));
Assert.That(localMovementState.PositionX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(localMovementState.PositionZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(serverRuntime.TryGetAuthoritativeCombatState(ClientPeer, out var localCombatState), Is.True);
Assert.That(localCombatState.LastAcceptedShootTick, Is.EqualTo(3));

View File

@ -0,0 +1,277 @@
using System;
using Network.Defines;
using NUnit.Framework;
using UnityEngine;
namespace Tests.EditMode.Network
{
/// <summary>
/// 验证客户端 ApplyTankMovement 和服务端 IntegrateState
/// 在相同输入下产生相同的移动结果。
///
/// 使用纯函数对比,不依赖任何状态对象。
/// </summary>
public class MovementAlgorithmConsistencyTests
{
private const float MoveSpeed = 4f;
private const float TurnSpeed = 180f;
private const float DeltaTime = 0.05f; // 50ms
[Test]
public void ServerForward_ZeroRotation_MovesInPositiveZ()
{
// Arrange: 服务端 rotation=0throttle=1
float rotation = 0f;
float throttleInput = 1f;
// Act: 服务端 forward 计算(新公式)
var rotationRadians = rotation * (MathF.PI / 180f);
var forwardX = MathF.Sin(rotationRadians); // 新公式
var forwardZ = MathF.Cos(rotationRadians); // 新公式
var velocityX = forwardX * (throttleInput * MoveSpeed);
var velocityZ = forwardZ * (throttleInput * MoveSpeed);
// Assert: rotation=0° → forward=(0,0,1) = +Z
Assert.That(forwardX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(forwardZ, Is.EqualTo(1f).Within(0.0001f));
Assert.That(velocityX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(velocityZ, Is.EqualTo(4f).Within(0.0001f));
}
[Test]
public void ServerForward_90DegreeRotation_MovesInPositiveX()
{
// Arrange: 服务端 rotation=90°throttle=1
float rotation = 90f;
float throttleInput = 1f;
// Act: 服务端 forward 计算(新公式)
var rotationRadians = rotation * (MathF.PI / 180f);
var forwardX = MathF.Sin(rotationRadians); // 新公式
var forwardZ = MathF.Cos(rotationRadians); // 新公式
var velocityX = forwardX * (throttleInput * MoveSpeed);
var velocityZ = forwardZ * (throttleInput * MoveSpeed);
// Assert: rotation=90° → forward=(1,0,0) = +X
Assert.That(forwardX, Is.EqualTo(1f).Within(0.0001f));
Assert.That(forwardZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(velocityX, Is.EqualTo(4f).Within(0.0001f));
Assert.That(velocityZ, Is.EqualTo(0f).Within(0.0001f));
}
[Test]
public void ClientForward_UnityYawZero_MovesInPositiveZ()
{
// Arrange: Unity Yaw = 0°客户端转换后 heading = 90°
float unityYaw = 0f;
float throttleInput = 1f;
// Act: 客户端 heading 计算
var heading = NormalizeDegrees(UnityYawToHeading(unityYaw));
// 客户端 ResolveHeadingForward: forward = (cos, 0, sin)
var rotationRadians = heading * Mathf.Deg2Rad;
var forwardX = Mathf.Cos(rotationRadians);
var forwardZ = Mathf.Sin(rotationRadians);
var velocityX = forwardX * throttleInput * MoveSpeed;
var velocityZ = forwardZ * throttleInput * MoveSpeed;
// Assert: Unity Yaw=0 → heading=90° → forward=(cos(90°), sin(90°))=(0,1) = +Z
Assert.That(heading, Is.EqualTo(90f).Within(0.0001f));
Assert.That(forwardX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(forwardZ, Is.EqualTo(1f).Within(0.0001f));
Assert.That(velocityX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(velocityZ, Is.EqualTo(4f).Within(0.0001f));
}
[Test]
public void ClientServer_IdenticalInputs_ProduceIdenticalOutput()
{
// 这个测试验证:相同的输入在客户端和服务端产生相同的最终位置
//
// 场景:初始位置 (0,0,0),初始 heading=90°Unity Yaw=0
// 向前移动 4 个 50ms 步长
// ===== 共享参数 =====
float moveSpeed = MoveSpeed;
float turnSpeed = TurnSpeed;
float dt = DeltaTime;
// ===== 服务端计算 =====
float serverPosX = 0f, serverPosZ = 0f;
float serverRotation = 0f; // 服务端 rotation 直接是 heading
float serverThrottle = 1f;
for (int i = 0; i < 4; i++)
{
// 速度计算(服务端新 forward 公式forwardX = sin, forwardZ = cos
var rotRad = serverRotation * (MathF.PI / 180f);
var fwdX = MathF.Sin(rotRad);
var fwdZ = MathF.Cos(rotRad);
var velX = fwdX * (serverThrottle * moveSpeed);
var velZ = fwdZ * (serverThrottle * moveSpeed);
// 位置积分
serverPosX += velX * dt;
serverPosZ += velZ * dt;
}
// ===== 客户端计算 =====
float clientPosX = 0f, clientPosZ = 0f;
float clientUnityYaw = 0f; // Unity Yaw = 0 → heading = 90°
float clientThrottle = 1f;
for (int i = 0; i < 4; i++)
{
// 旋转(客户端需要转换)
var heading = NormalizeDegrees(UnityYawToHeading(clientUnityYaw) + (0f * turnSpeed * dt));
clientUnityYaw = HeadingToUnityYaw(heading);
// 客户端 ResolveHeadingForward: forward = (cos, 0, sin)
var headingRad = heading * Mathf.Deg2Rad;
var fwdX = Mathf.Cos(headingRad);
var fwdZ = Mathf.Sin(headingRad);
var velX = fwdX * (clientThrottle * moveSpeed);
var velZ = fwdZ * (clientThrottle * moveSpeed);
// 位置积分
clientPosX += velX * dt;
clientPosZ += velZ * dt;
}
// ===== 对比 =====
// 服务端期望rotation=0° → forward=(0,0,1) → velocity=(0,0,4) → 每步 0.2
// 4步后pos = (0, 0, 0.8)
Assert.That(serverPosX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(serverPosZ, Is.EqualTo(0.8f).Within(0.0001f));
// 客户端应与服务端一致
Assert.That(clientPosX, Is.EqualTo(serverPosX).Within(0.0001f),
$"Client X ({clientPosX}) should match Server X ({serverPosX})");
Assert.That(clientPosZ, Is.EqualTo(serverPosZ).Within(0.0001f),
$"Client Z ({clientPosZ}) should match Server Z ({serverPosZ})");
}
[Test]
public void ClientServer_TurnRight90Degrees_ThenMove_ProduceIdenticalOutput()
{
// 场景初始向前走然后右转90°再走
// 验证转向后的方向一致
float moveSpeed = MoveSpeed;
float turnSpeed = TurnSpeed;
float dt = DeltaTime;
// ===== 服务端计算 =====
float serverPosX = 0f, serverPosZ = 0f;
float serverRotation = 0f; // heading=0°向前走
float serverThrottle = 1f;
float serverTurnInput = 1f; // 右转
// 先走2步
for (int i = 0; i < 2; i++)
{
var rotRad = serverRotation * (MathF.PI / 180f);
var fwdX = MathF.Sin(rotRad);
var fwdZ = MathF.Cos(rotRad);
var velX = fwdX * serverThrottle * moveSpeed;
var velZ = fwdZ * serverThrottle * moveSpeed;
serverPosX += velX * dt;
serverPosZ += velZ * dt;
}
// 右转1步turnInput=1转 180*0.05=9°
serverRotation = NormalizeDegreesServer(serverRotation + (serverTurnInput * turnSpeed * dt));
// 再走2步
for (int i = 0; i < 2; i++)
{
var rotRad = serverRotation * (MathF.PI / 180f);
var fwdX = MathF.Sin(rotRad);
var fwdZ = MathF.Cos(rotRad);
var velX = fwdX * serverThrottle * moveSpeed;
var velZ = fwdZ * serverThrottle * moveSpeed;
serverPosX += velX * dt;
serverPosZ += velZ * dt;
}
// ===== 客户端计算 =====
float clientPosX = 0f, clientPosZ = 0f;
float clientUnityYaw = 0f; // 初始朝前
float clientThrottle = 1f;
float clientTurnInput = -1f; // Unity 中右转 = -input.x
// 先走2步
for (int i = 0; i < 2; i++)
{
var heading = NormalizeDegrees(UnityYawToHeading(clientUnityYaw));
var headingRad = heading * Mathf.Deg2Rad;
var fwdX = Mathf.Cos(headingRad);
var fwdZ = Mathf.Sin(headingRad);
var velX = fwdX * clientThrottle * moveSpeed;
var velZ = fwdZ * clientThrottle * moveSpeed;
clientPosX += velX * dt;
clientPosZ += velZ * dt;
}
// 右转1步
var newHeading = NormalizeDegrees(UnityYawToHeading(clientUnityYaw) + (clientTurnInput * turnSpeed * dt));
clientUnityYaw = HeadingToUnityYaw(newHeading);
// 再走2步
for (int i = 0; i < 2; i++)
{
var heading = NormalizeDegrees(UnityYawToHeading(clientUnityYaw));
var headingRad = heading * Mathf.Deg2Rad;
var fwdX = Mathf.Cos(headingRad);
var fwdZ = Mathf.Sin(headingRad);
var velX = fwdX * clientThrottle * moveSpeed;
var velZ = fwdZ * clientThrottle * moveSpeed;
clientPosX += velX * dt;
clientPosZ += velZ * dt;
}
// ===== 对比 =====
Assert.That(clientPosX, Is.EqualTo(serverPosX).Within(0.0001f),
$"Client X ({clientPosX}) should match Server X ({serverPosX})");
Assert.That(clientPosZ, Is.EqualTo(serverPosZ).Within(0.0001f),
$"Client Z ({clientPosZ}) should match Server Z ({serverPosZ})");
}
// ===== 辅助方法(从 MovementComponent 复制) =====
private static float UnityYawToHeading(float unityYawDegrees)
{
return NormalizeDegrees(90f - unityYawDegrees);
}
private static float HeadingToUnityYaw(float headingDegrees)
{
return NormalizeDegrees(90f - headingDegrees);
}
private static float NormalizeDegrees(float degrees)
{
var normalized = degrees % 360f;
if (normalized < 0f)
{
normalized += 360f;
}
return normalized;
}
private static float NormalizeDegreesServer(float degrees)
{
var normalized = degrees % 360f;
if (normalized <= -180f)
{
normalized += 360f;
}
else if (normalized > 180f)
{
normalized -= 360f;
}
return normalized;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 351f8ed6c1e2cf047bb173a387d41942
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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)
@ -55,13 +55,14 @@ namespace Tests.EditMode.Network
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(1));
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterFirstStep), Is.True);
Assert.That(stateAfterFirstStep.PositionX, Is.EqualTo(0.2f).Within(0.0001f));
Assert.That(stateAfterFirstStep.PositionZ, Is.EqualTo(0.2f).Within(0.0001f));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(0));
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterSecondStep), Is.True);
Assert.That(stateAfterSecondStep.PositionX, 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));
}
@ -116,12 +117,12 @@ namespace Tests.EditMode.Network
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerB, out var stateB), Is.True);
Assert.That(stateA.PlayerId, Is.EqualTo("player-a"));
Assert.That(stateA.LastAcceptedMoveTick, Is.EqualTo(10));
Assert.That(stateA.PositionX, Is.EqualTo(0.2f).Within(0.0001f));
Assert.That(stateA.PositionZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(stateA.PositionZ, Is.EqualTo(0.2f).Within(0.0001f));
Assert.That(stateA.PositionX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(stateB.PlayerId, Is.EqualTo("player-b"));
Assert.That(stateB.LastAcceptedMoveTick, Is.EqualTo(3));
Assert.That(stateB.PositionX, Is.EqualTo(-0.2f).Within(0.0001f));
Assert.That(stateB.PositionZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(stateB.PositionZ, Is.EqualTo(-0.2f).Within(0.0001f));
Assert.That(stateB.PositionX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(2));
}
@ -168,9 +169,9 @@ 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.X, Is.EqualTo(1f).Within(0.0001f));
Assert.That(firstBroadcast.Velocity.X, 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,9 +193,9 @@ 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.X, Is.EqualTo(1f).Within(0.0001f));
Assert.That(secondBroadcast.Velocity.X, Is.EqualTo(0f).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));
}
[Test]
@ -281,9 +282,9 @@ namespace Tests.EditMode.Network
var broadcast = ParsePlayerState(createdTransports[9000].BroadcastMessages[0]);
Assert.That(broadcast.Tick, Is.EqualTo(1));
Assert.That(broadcast.AcknowledgedMoveTick, Is.EqualTo(5));
Assert.That(broadcast.Position.X, Is.EqualTo(-0.3f).Within(0.0001f));
Assert.That(broadcast.Position.Z, Is.EqualTo(0f).Within(0.0001f));
Assert.That(broadcast.Velocity.X, Is.EqualTo(-6f).Within(0.0001f));
Assert.That(broadcast.Position.Z, Is.EqualTo(-0.3f).Within(0.0001f));
Assert.That(broadcast.Position.X, Is.EqualTo(0f).Within(0.0001f));
Assert.That(broadcast.Velocity.Z, Is.EqualTo(-6f).Within(0.0001f));
}
private static FakeTransport CreateTransport(IDictionary<int, FakeTransport> createdTransports, int port)

View File

@ -24,7 +24,7 @@ namespace Tests.EditMode.Network
TransportFactory = port => CreateTransport(createdTransports, port)
};
using var runtime = NetworkIntegrationFactory.StartServerRuntimeAsync(configuration).GetAwaiter().GetResult();
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
Assert.That(createdTransports.Keys, Is.EquivalentTo(new[] { 9000 }));
Assert.That(runtime.IsRunning, Is.True);

View File

@ -89,9 +89,11 @@ namespace Tests.EditMode.Network
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f });
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 11, ThrottleInput = 1f });
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 12, ThrottleInput = 1f });
buffer.MarkInputSimulated(12, 0.05f);
var accepted = buffer.TryApplyAuthoritativeState(
new PlayerState { PlayerId = "player-1", Tick = 11, AcknowledgedMoveTick = 11 },
0f,
out var replayInputs);
Assert.That(accepted, Is.True);
@ -99,19 +101,35 @@ namespace Tests.EditMode.Network
Assert.That(buffer.LastAcknowledgedMoveTick, Is.EqualTo(11));
Assert.That(replayInputs.Count, Is.EqualTo(1));
Assert.That(replayInputs[0].Input.Tick, Is.EqualTo(12));
Assert.That(replayInputs[0].SimulatedDurationSeconds, Is.EqualTo(0f));
Assert.That(replayInputs[0].SimulatedDurationSeconds, Is.EqualTo(0.05f).Within(0.0001f));
Assert.That(buffer.PendingInputs.Count, Is.EqualTo(1));
}
[Test]
public void ClientPredictionBuffer_TryGetNextUnsimulatedInput_UsesOldestPendingMoveInput()
{
var buffer = new ClientPredictionBuffer();
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f });
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 11, ThrottleInput = -1f });
buffer.MarkInputSimulated(10, 0.05f);
var found = buffer.TryGetNextUnsimulatedInput(out var nextInput);
Assert.That(found, Is.True);
Assert.That(nextInput.Input.Tick, Is.EqualTo(11));
Assert.That(nextInput.SimulatedDurationSeconds, Is.EqualTo(0f));
}
[Test]
public void ClientPredictionBuffer_StaleAuthoritativeState_IsIgnored()
{
var buffer = new ClientPredictionBuffer();
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f });
buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10, AcknowledgedMoveTick = 10 }, out _);
buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10, AcknowledgedMoveTick = 10 }, 0f, out _);
var accepted = buffer.TryApplyAuthoritativeState(
new PlayerState { PlayerId = "player-1", Tick = 9, AcknowledgedMoveTick = 9 },
0f,
out var replayInputs);
Assert.That(accepted, Is.False);
@ -250,7 +268,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));
}
@ -470,7 +488,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));
}
@ -512,10 +530,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);
@ -525,9 +547,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);
@ -539,9 +560,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),
@ -565,10 +586,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);
@ -581,15 +606,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),
@ -602,6 +627,145 @@ namespace Tests.EditMode.Network
}
}
[Test]
public void ReplayPendingInputs_NonZeroTurn_MatchesLivePrediction()
{
// Arrange: verify that live step-by-step prediction and ReplayPendingInputs
// produce identical trajectories for non-zero turn input (turn=0.5, throttle=1, 0.10s).
var gameObject = new GameObject("replay-test");
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);
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
var turnInput = 0.5f;
var throttleInput = 1f;
var stepDuration = 0.05f;
var steps = 2; // 0.10s total
// Act — live prediction: step-by-step ApplyTankMovement (correct shape).
ApplyTankMovementStepByStep(turnInput, throttleInput, stepDuration, steps,
out var livePosition, out var liveRotation);
// Reset.
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
// Act — replay path: ReplayPendingInputs with same total duration.
var replayInputs = new List<PredictedMoveStep>
{
new PredictedMoveStep(
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
stepDuration * steps)
};
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),
"Replay produced a different position than live prediction for non-zero turn input.");
Assert.That(Quaternion.Angle(replayRotation, liveRotation), Is.LessThan(0.01f),
"Replay produced a different rotation than live prediction for non-zero turn input.");
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void ClientPredictionBuffer_LastAcknowledgedMoveTick_IsExposed()
{
// Arrange: buffer with inputs at ticks 10, 11, 12.
var buffer = new ClientPredictionBuffer();
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f });
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 11, ThrottleInput = 1f });
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 12, ThrottleInput = 1f });
// Act: apply authoritative state acknowledging tick 11.
buffer.TryApplyAuthoritativeState(
new PlayerState { PlayerId = "player-1", Tick = 11, AcknowledgedMoveTick = 11 },
0f,
out _);
// Assert: LastAcknowledgedMoveTick is correctly exposed.
Assert.That(buffer.LastAcknowledgedMoveTick, Is.EqualTo(11),
"LastAcknowledgedMoveTick was not correctly set after authoritative state application.");
}
[Test]
public void ControlledPlayerCorrection_CorrectionMagnitude_IsExposed()
{
// Arrange: small position and rotation error.
var currentPos = Vector3.zero;
var currentRot = Quaternion.identity;
var targetPos = new Vector3(0.5f, 0f, 0f);
var targetRot = Quaternion.Euler(0f, 10f, 0f);
var settings = new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f);
// Act.
var result = ControlledPlayerCorrection.Resolve(currentPos, currentRot, targetPos, targetRot, settings);
// Assert: PositionError and RotationErrorDegrees are exposed and meaningful.
Assert.That(result.PositionError, Is.GreaterThan(0f),
"PositionError should be greater than zero for non-zero position divergence.");
Assert.That(result.RotationErrorDegrees, Is.GreaterThan(0f),
"RotationErrorDegrees should be greater than zero for non-zero rotation divergence.");
Assert.That(result.PositionError, Is.EqualTo(Vector3.Distance(currentPos, targetPos)).Within(0.0001f),
"PositionError should equal the distance between current and target positions.");
Assert.That(result.RotationErrorDegrees, Is.EqualTo(Quaternion.Angle(currentRot, targetRot)).Within(0.01f),
"RotationErrorDegrees should equal the angle between current and target rotations.");
}
[Test]
public void MovementComponent_SetServerTick_TracksLatestServerOffset()
{
// Arrange: set up movement resolver and initialize controlled state.
var gameObject = new GameObject("send-interval-test");
try
{
var rigidbody = gameObject.AddComponent<Rigidbody>();
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);
typeof(MovementResolverComponent)
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(resolver, movement);
resolver.Init(true, master: null, speed: 10, serverTick: 0);
var currentOffsetField = typeof(MovementResolverComponent)
.GetField("_currentTickOffset", BindingFlags.Instance | BindingFlags.NonPublic);
// Act/Assert: server tick updates the cached latest offset deterministically.
for (var i = -2; i <= 2; i++)
{
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
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void ReplayPendingInputs_NonMultipleOfCadence_HandlesRemainingDuration()
{
@ -612,10 +776,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);
@ -629,8 +797,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);
@ -643,16 +811,23 @@ 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, ToSimulationTurnInput(turnInput), throttleInput, stepDuration,
ref position, ref rotation);
}
}
private static float ToSimulationTurnInput(float networkTurnInput)
{
return -networkTurnInput;
}
private static void ResetMovementState(Rigidbody rigidbody, Vector3 position, Quaternion rotation)
{
rigidbody.position = position;
@ -661,11 +836,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]

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6aee45b7c7c7b734c9b6895d254f1cde
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e1bbd442159abc24a8f56fc7bbad78a8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,244 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Google.Protobuf;
using Network.Defines;
using Network.NetworkApplication;
using Network.NetworkHost;
using Network.NetworkTransport;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Vector3 = UnityEngine.Vector3;
namespace Tests.PlayMode.Network
{
/// <summary>
/// PlayMode 测试:使用协程控制帧推进,验证客户端移动与服务端 authoritative state 的一致性。
/// 使用真实的 ServerRuntimeEntryPoint直接获取服务端的 authoritative state 进行对比。
/// </summary>
public class MovementConsistencyPlayModeTests
{
private static readonly IPEndPoint ClientPeer = new(IPAddress.Loopback, 9701);
// 测试参数
private const float MoveSpeed = 4f;
private const float TurnSpeed = 180f;
private const float DeltaTime = 0.05f; // 50ms 模拟步长
[UnityTest]
public IEnumerator OneFrame_MoveForward_ClientAndServerPositionMatch()
{
yield return RunMovementTest(
playerId: "player-1frame",
inputSequence: new[] { (0f, 1f) }, // 1帧前进
expectedTotalMovement: MoveSpeed * DeltaTime // 4 * 0.05 = 0.2
);
}
[UnityTest]
public IEnumerator FiveFrames_MoveForward_ClientAndServerPositionMatch()
{
yield return RunMovementTest(
playerId: "player-5frames",
inputSequence: new[] { (0f, 1f), (0f, 1f), (0f, 1f), (0f, 1f), (0f, 1f) }, // 5帧连续前进
expectedTotalMovement: MoveSpeed * DeltaTime * 5 // 4 * 0.05 * 5 = 1.0
);
}
[UnityTest]
public IEnumerator IntermittentMovement_StartStopStart_ClientAndServerPositionMatch()
{
// 场景前进2帧 -> 停止1帧 -> 前进2帧
yield return RunMovementTest(
playerId: "player-intermittent",
inputSequence: new[]
{
(0f, 1f), // 帧1前进
(0f, 1f), // 帧2前进
(0f, 0f), // 帧3停止
(0f, 1f), // 帧4前进
(0f, 1f), // 帧5前进
},
expectedTotalMovement: MoveSpeed * DeltaTime * 4 // 4 * 0.05 * 4 = 0.8只有4帧在移动
);
}
private IEnumerator RunMovementTest(
string playerId,
(float turn, float throttle)[] inputSequence,
float expectedTotalMovement)
{
// ========== 创建服务器 ==========
var serverTransports = new Dictionary<int, FakeTestTransport>();
var configuration = new ServerRuntimeConfiguration(9700)
{
SyncPort = 9701,
Dispatcher = new MainThreadNetworkDispatcher(),
TransportFactory = port =>
{
var transport = new FakeTestTransport();
serverTransports[port] = transport;
return transport;
},
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration
{
MoveSpeed = MoveSpeed,
SimulationInterval = TimeSpan.FromMilliseconds(50),
BroadcastInterval = TimeSpan.FromMilliseconds(50)
}
};
var serverRuntime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
// ========== 登录 ==========
serverRuntime.Host.NotifyLoginStarted(ClientPeer);
serverRuntime.Host.NotifyLoginSucceeded(ClientPeer, playerId, MoveSpeed);
yield return null; // 让服务器初始化
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
Debug.Log($"[Setup] Login complete. BroadcastMessages on sync transport: {serverTransports[9701].BroadcastMessages.Count}");
// ========== 客户端本地计算状态 ==========
float clientRotation = 0f; // Unity Yaw = 0 -> heading = 90
Vector3 clientPosition = Vector3.zero;
// ========== 运行帧模拟 ==========
int tick = 1;
List<Vector3> serverPositions = new List<Vector3>();
List<Vector3> clientPositions = new List<Vector3>();
foreach (var (turnInput, throttleInput) in inputSequence)
{
// --- 构造 MoveInput ---
var moveInput = new MoveInput
{
PlayerId = playerId,
Tick = tick,
TurnInput = turnInput,
ThrottleInput = throttleInput
};
// --- 发送到服务器 ---
var envelope = new Envelope
{
Type = (int)MessageType.MoveInput,
Payload = ByteString.CopyFrom(moveInput.ToByteArray())
};
Debug.Log($"[Tick {tick}] EmitReceive MoveInput to sync transport (turn={turnInput}, throttle={throttleInput}), BroadcastMessages before: {serverTransports[9701].BroadcastMessages.Count}");
serverTransports[9701].EmitReceive(envelope.ToByteArray(), ClientPeer);
// --- 服务器处理 ---
Debug.Log($"[Tick {tick}] Before DrainPendingMessagesAsync, BroadcastMessages: {serverTransports[9701].BroadcastMessages.Count}");
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
Debug.Log($"[Tick {tick}] After DrainPendingMessagesAsync + UpdateAuthoritativeMovement, BroadcastMessages: {serverTransports[9701].BroadcastMessages.Count}");
// --- 获取服务端 authoritative state不需要等待广播---
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var serverState), Is.True);
var serverPos = new Vector3(serverState.PositionX, serverState.PositionY, serverState.PositionZ);
serverPositions.Add(serverPos);
// --- 客户端本地计算(与服务端相同的算法) ---
var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f);
var clampedThrottleInput = Mathf.Clamp(throttleInput, -1f, 1f);
// 旋转(客户端 Tank Control
var heading = NormalizeDegrees(UnityYawToHeading(clientRotation) + (clampedTurnInput * TurnSpeed * DeltaTime));
clientRotation = HeadingToUnityYaw(heading);
// 速度(客户端 ResolveHeadingForward: forward = (cos, 0, sin)
var headingRad = heading * Mathf.Deg2Rad;
var forwardX = Mathf.Cos(headingRad);
var forwardZ = Mathf.Sin(headingRad);
var velocityX = forwardX * (clampedThrottleInput * MoveSpeed);
var velocityZ = forwardZ * (clampedThrottleInput * MoveSpeed);
clientPosition.x += velocityX * DeltaTime;
clientPosition.z += velocityZ * DeltaTime;
clientPositions.Add(clientPosition);
Debug.Log($"[Tick {tick}] Turn={turnInput}, Throttle={throttleInput} | " +
$"Client=({clientPosition.x:F3}, {clientPosition.y:F3}, {clientPosition.z:F3}) | " +
$"Server=({serverPos.x:F3}, {serverPos.y:F3}, {serverPos.z:F3})");
tick++;
yield return null; // 推进一帧
}
// ========== 验证 ==========
var finalClientPos = clientPositions[^1];
var finalServerPos = serverPositions[^1];
Debug.Log($"========================================");
Debug.Log($"[Test: {playerId}]");
Debug.Log($"Expected Z Movement: {expectedTotalMovement}");
Debug.Log($"Client Final Z: {finalClientPos.z:F4}");
Debug.Log($"Server Final Z: {finalServerPos.z:F4}");
Debug.Log($"Server Final Full: ({finalServerPos.x:F4}, {finalServerPos.y:F4}, {finalServerPos.z:F4})");
Debug.Log($"========================================");
float clientMovement = finalClientPos.z;
float serverMovement = finalServerPos.z;
Assert.That(Math.Abs(clientMovement - serverMovement), Is.LessThan(0.01f),
$"Client Z movement ({clientMovement:F4}) should match Server Z movement ({serverMovement:F4})");
Assert.That(Math.Abs(clientMovement - expectedTotalMovement), Is.LessThan(0.01f),
$"Movement ({clientMovement:F4}) should match expected ({expectedTotalMovement:F4})");
// ========== 清理 ==========
serverRuntime.Stop();
}
private static float NormalizeDegrees(float degrees)
{
var normalized = degrees % 360f;
if (normalized < 0f) normalized += 360f;
return normalized;
}
private static float UnityYawToHeading(float unityYawDegrees)
{
return NormalizeDegrees(90f - unityYawDegrees);
}
private static float HeadingToUnityYaw(float headingDegrees)
{
return NormalizeDegrees(90f - headingDegrees);
}
}
// ========== 测试辅助类 ==========
/// <summary>
/// 用于 PlayMode 测试的 FakeTransport
/// </summary>
public class FakeTestTransport : ITransport
{
private readonly List<byte[]> _receivedMessages = new();
public List<byte[]> BroadcastMessages { get; } = new();
public event Action<byte[], IPEndPoint> OnReceive;
public void EmitReceive(byte[] data, IPEndPoint sender)
{
_receivedMessages.Add(data);
OnReceive?.Invoke(data, sender);
}
public void Send(byte[] data) { }
public void SendTo(byte[] data, IPEndPoint target) { }
public void SendToAll(byte[] data)
{
BroadcastMessages.Add(data);
OnReceive?.Invoke(data, new IPEndPoint(IPAddress.Loopback, 0));
}
public Task StartAsync() => Task.CompletedTask;
public void Stop() { }
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c61c8ae5a68e0fb4195a729749a21019
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,24 @@
{
"name": "Network.PlayMode.Tests",
"rootNamespace": "Tests.PlayMode.Network",
"references": [
"Network.Runtime",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"NetworkFramework"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll",
"Google.Protobuf.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 00dbd4533a02ad04cb7d9dada7210593
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -27,9 +27,9 @@ Set `DOTNET_CLI_HOME=.dotnet-home` and `DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1` if
The transport layer uses two distinct lanes with different delivery semantics:
| Lane | Policy | Messages |
|------|--------|----------|
| **Sync lane** (`HighFrequencySync`) | Latest-wins, stale-drop | `MoveInput`, `PlayerState` |
| Lane | Policy | Messages |
|---------------------------------------|------------------------------|----------------------------------------------|
| **Sync lane** (`HighFrequencySync`) | Latest-wins, stale-drop | `MoveInput`, `PlayerState` |
| **Reliable lane** (`ReliableOrdered`) | Ordered, guaranteed delivery | `ShootInput`, `CombatEvent`, login/heartbeat |
Never mix messages with different delivery requirements into the same lane.

11
TODO.md
View File

@ -4,26 +4,32 @@ Current assessment:
- Loopback repro means transport delay is not the primary cause of the remaining local-player jitter.
- The next round should focus on deterministic prediction/reconciliation timing before adding more local smoothing.
- **IMPORTANT**: Network layer was modified (shared between Server and Client). Server side has not yet adapted to the Network layer changes — this may be the root cause of remaining jitter. Client-side prediction timing fixes (Steps 1-5) are complete but insufficient if the server is not correctly integrated with the new Network layer.
Step-by-step plan:
1. Align replay integration granularity with live prediction
- [x] finish
- Replace one-shot replay of an accumulated input duration with fixed substeps.
- Ensure replay uses the same movement integration shape as the normal `FixedUpdate` prediction path, especially for turn-and-move input.
2. Align client prediction cadence with server authoritative cadence
- [x] finish
- Introduce an explicit local prediction/replay cadence derived from the authoritative movement cadence.
- Avoid mixing client-side `Time.fixedDeltaTime` prediction with server-side fixed-cadence authoritative integration in reconciliation-sensitive paths.
3. Stabilize or remove send-rate oscillation driven by server tick offset
- [x] finish
- Revisit `MovementComponent.SetServerTick(...)` and stop toggling `_sendInterval` directly between nearby values when the offset crosses zero.
- If clock correction is still needed, add hysteresis or filtering so the send cadence does not bounce frame-to-frame.
4. Re-measure controlled-player correction after timing fixes
- [x] finish (jitter still visible — residual error confirmed, see Step 5)
- Keep remote-player interpolation as-is; do not treat local-player jitter as a remote interpolation problem.
- Only refine local visual correction further if meaningful residual error remains after steps 1-3.
5. Add regression coverage and diagnostics for the remaining jitter path
- [x] finish
- Add tests that compare live prediction and replayed prediction under the same turn/throttle sequence.
- Add tests for server tick offset calibration so small offset sign changes do not continuously retarget send cadence.
- Add or expose diagnostics for acknowledged move tick, predicted pose, authoritative pose, and correction magnitude per snapshot.
@ -33,3 +39,8 @@ Acceptance:
- Controlled-player loopback movement no longer shows repeated small pull-back under steady turn-and-move input.
- Replay after authoritative reconciliation produces the same trajectory shape as forward local prediction for the same input sequence.
- Small server tick offset fluctuations do not cause visible local cadence oscillation.
- Server is correctly integrated with the updated Network layer (not yet verified).
## Open Questions
- Server-side adaptation to Network layer changes: Has the server been updated to correctly work with the modified Network layer? If not, the server may be sending authoritative state updates at incorrect cadence or with inconsistent timing, causing persistent jitter on the client side.

61
check_result.md Normal file
View File

@ -0,0 +1,61 @@
#### MoveSpeed 对齐 ✅ 已确认无问题
| 环节 | 值 | 说明 |
| --------------------------------------------------------- | ------------ | ----------------------------------------------------------------------- |
| Server ServerAuthoritativeMovementConfiguration.MoveSpeed | 5f默认值 | CreateRuntimeConfiguration() 未设置,使用 null → 默认 5f |
| Server → Client LoginResponse.Speed | 5 | BuildLoginResponse 中 (int)MathF.Round(host.AuthoritativeMoveSpeed) = 5 |
| Client MovementComponent._speed | 5 | Init(true, master, bootstrap.AuthoritativeMoveSpeed, ...) = 5 |
结论MoveSpeed 实际上是对齐的。bootstrap.AuthoritativeMoveSpeed = 5不是 check.md 中担心的默认值 2。
---
#### TurnSpeedDegreesPerSecond ✅ 一致
Server: 180fClient: 180f。
---
#### SimulationInterval / BroadcastInterval ✅ 一致
均为 50ms。
---
#### AcknowledgedMoveTick 设置逻辑 ✅ 正确
Server HandleMoveInputAsync → state.LastAcceptedMoveTick = input.Tick
Server BuildPlayerState → AcknowledgedMoveTick = state.LastAcceptedMoveTick
Client TryApplyAuthoritativeState → pendingInputs.RemoveAll(tick <= AcknowledgedMoveTick)
路径正确。
---
#### Message Delivery Policy ✅ 一致
MoveInput → HighFrequencySync
PlayerState → HighFrequencySync
DefaultMessageDeliveryPolicyResolver 中的策略映射与 check.md 描述完全吻合。
---
#### Server Update Cadence
DedicatedServerApplication.RunMainLoop() 以固定 50ms 为周期调用 UpdateAuthoritativeMovement逻辑上是稳定的。但如果物理机负载高可能产生波动。
---
#### SyncSequenceTracker 的潜在影响
SyncSequenceTracker 对 MoveInput 的过滤逻辑是:
streamKey = "input:{sender}:{playerId}"
sequence = input.Tick
如果客户端 MoveInput(Tick=N) 被丢弃,下一次 AcknowledgedMoveTick 会跳过 N导致客户端的 pending inputs 被多删。这本身是正确的(服务端只认可它接受的 tick但如果频繁丢弃客户端会不断看到跳帧式的 correction。
建议:在客户端日志中过滤关键词 [MessageManager] 丢弃过期同步消息,确认是否有大量丢弃。
---
#### 总结
根据 check.md 列举的所有检查项,服务端实现均已对齐。最可能的抖动根因不在服务端配置层面,而在:
1. SyncSequenceTracker 的丢弃频率 — 可通过日志确认
2. SetServerTick 的自适应发送间隔振荡 — 48ms/52ms 的快速切换可能导致发送节奏不稳定
3. 客户端 ControlledPlayerCorrection 的 hard snap 阈值 — 如果 positionError > SnapPositionThreshold默认 3 * 50ms * speed会触发瞬移

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -0,0 +1,40 @@
## Context
Step 5 adds regression tests for the client prediction jitter path. Tests are placed in `SyncStrategyTests.cs` alongside existing prediction tests, following the same Arrange-Act-Assert pattern using Unity `GameObject` + `Rigidbody` + `MovementComponent` setup.
## Goals / Non-Goals
**Goals:**
- Test that live prediction and replay produce identical trajectories for non-zero turn input (the `client-prediction-replay` spec requires this).
- Test that `ClientPredictionBuffer` correctly exposes `LastAcknowledgedMoveTick` (the `client-prediction-diagnostics` spec requires this).
- Test that correction magnitude handlers receive valid values from `ControlledPlayerCorrection.Resolve`.
**Non-Goals:**
- No production code changes.
- No new specs — existing specs already define the requirements.
## Tests to Add
### Test 1: Replay trajectory matches live prediction for non-zero turn
```
ReplayPendingInputs_NonZeroTurn_MatchesLivePrediction
```
- Arrange: set up MovementComponent, turn=0.5, throttle=1, total duration=0.10s
- Act: run live step-by-step (ApplyTankMovement × 2 × 0.05s) vs replay (ReplayPendingInputs)
- Assert: positions and headings match within tolerance
### Test 2: ClientPredictionBuffer exposes LastAcknowledgedMoveTick
```
ClientPredictionBuffer_LastAcknowledgedMoveTick_IsExposed
```
- Arrange: buffer with recorded inputs at ticks 10, 11, 12
- Act: apply authoritative state acknowledging tick 11
- Assert: `LastAcknowledgedMoveTick == 11`
### Test 3: Correction magnitude propagates through Reconcile
```
ControlledPlayerCorrection_CorrectionMagnitude_IsComputable
```
- Arrange: predicted pose (0,0,0), authoritative (0.5,0,0), 10° heading diff
- Act: `ControlledPlayerCorrection.Resolve(...)`
- Assert: `result.PositionError > 0`, `result.RotationErrorDegrees > 0`

View File

@ -0,0 +1,23 @@
## Why
Steps 1-3 fixed the core timing issues but jitter persists. Step 5 adds deterministic regression coverage so the remaining jitter path has verifiable, reproducible tests — making future debugging faster and preventing regressions.
## What Changes
- Add a regression test confirming live prediction and replay produce identical trajectories for non-zero turn input (fills gap between existing zero-turn test and the spec requirement).
- Add regression test for `ClientPredictionBuffer` acknowledged-move-tick exposure per `client-prediction-diagnostics` spec.
- Add regression test confirming the MainUI diagnostic handlers receive correct correction magnitude values.
- All new tests are in `SyncStrategyTests.cs` alongside existing prediction tests.
## Capabilities
### New Capabilities
- (none — this is a test coverage step)
### Modified Capabilities
- (none)
## Impact
- `Assets/Tests/EditMode/Network/SyncStrategyTests.cs` — new test methods added
- No production code changes

View File

@ -0,0 +1,3 @@
# Spec Changes
No new capabilities introduced. This step adds regression tests for existing spec requirements already defined in `client-prediction-replay` and `client-prediction-diagnostics`.

View File

@ -0,0 +1,13 @@
## 1. Add regression tests to SyncStrategyTests.cs
- [x] 1.1 Add `ReplayPendingInputs_NonZeroTurn_MatchesLivePrediction` — verifies live prediction and replay produce identical trajectories for turn=0.5, throttle=1, duration=0.10s
- [x] 1.2 Add `ClientPredictionBuffer_LastAcknowledgedMoveTick_IsExposed` — verifies LastAcknowledgedMoveTick is correctly set after authoritative state
- [x] 1.3 Add `ControlledPlayerCorrection_CorrectionMagnitude_IsExposed` — verifies PositionError and RotationErrorDegrees are exposed from ControlledPlayerCorrectionResult
## 2. Verify tests pass
- [x] 2.1 Run Unity Test Runner and confirm all tests pass
## 3. Complete
- [x] 3.1 Mark TODO.md Step 5 as complete

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -0,0 +1,42 @@
## Context
`MovementComponent.FixedUpdate()` currently calls `AccumulateLatest(Time.fixedDeltaTime)` to track pending input duration. `Time.fixedDeltaTime` is the Unity physics step (typically 20ms), but the server's authoritative movement uses a fixed 50ms cadence (`kServerSimulationStepSeconds`). This mismatch means prediction timing drifts from authoritative timing in reconciliation-sensitive paths.
The `Simulate()` method still uses `Time.fixedDeltaTime` for physics integration — this is intentionally preserved to keep Unity physics working correctly. The change only affects how `SimulatedDurationSeconds` is accumulated for the prediction buffer.
## Goals / Non-Goals
**Goals:**
- `AccumulateLatest()` uses the server's authoritative cadence (50ms) instead of `Time.fixedDeltaTime`
- Forward prediction accumulation timing aligns with authoritative timing
- No external API changes, no breaking changes to physics integration
**Non-Goals:**
- Do not change `Simulate()` physics integration — `Time.fixedDeltaTime` remains for Unity physics
- Do not change the replay substep size (already 50ms from Step 1)
- Do not address send-interval oscillation (TODO Step 3)
## Decisions
### Decision: Accumulate using server cadence, not `Time.fixedDeltaTime`
**Choice**: Change `AccumulateLatest(Time.fixedDeltaTime)` to `AccumulateLatest(kServerSimulationStepSeconds)`.
**Rationale**:
- `SimulatedDurationSeconds` represents server-time accumulated since input was recorded
- Server accumulates by 50ms per step; client should match
- `Time.fixedDeltaTime` is a render/physics loop variable, not a game-time unit
- After Step 1, replay already uses 50ms substeps; accumulation should match
**Alternatives considered**:
- Derive accumulation from real elapsed time: Still uses `Time.fixedDeltaTime` under the hood, same mismatch
- Decouple prediction from FixedUpdate entirely: Significant complexity, overkill for this issue
## Risks / Trade-offs
- **[Risk]** `AccumulateLatest` now accumulates 50ms per FixedUpdate even though real elapsed time is 20ms. The prediction buffer grows 2.5× faster in server-time than real time.
- **Mitigation**: This is the intended behavior — `SimulatedDurationSeconds` is server-time, not real time. Replay consumes server-time at 50ms per step.
- **Note**: Physics integration (`Simulate`) still uses `Time.fixedDeltaTime`, so visual movement remains correct. Only the prediction buffer's time accounting changes.
- **[Risk]** If FixedUpdate runs at non-20ms intervals (platform variation, frame drops), the mismatch between accumulated server-time and actual physics time grows.
- **Mitigation**: The TODO identifies this as inherent to mixing cadences; the fix explicitly drives accumulation from the authoritative cadence rather than real time.

View File

@ -0,0 +1,25 @@
## Why
The current `MovementComponent.AccumulateLatest()` uses `Time.fixedDeltaTime` (typically 20ms Unity physics step) to accumulate pending input duration, while the server uses a fixed 50ms authoritative movement cadence. Mixing these two cadences in reconciliation-sensitive paths causes prediction timing to drift from authoritative timing, contributing to controlled-player jitter under steady input.
## What Changes
- Replace `Time.fixedDeltaTime`-based accumulation with an explicit prediction cadence derived from the server's `SimulationInterval` (50ms)
- The client's forward prediction accumulation aligns with the server's authoritative cadence, ensuring `SimulatedDurationSeconds` reflects server-time rather than render-loop time
- No external API changes; internal prediction timing refactored
## Capabilities
### New Capabilities
- `client-prediction-cadence`: Client forward prediction uses an explicit cadence derived from the server authoritative movement cadence, not `Time.fixedDeltaTime`, ensuring prediction timing aligns with authoritative timing in reconciliation-sensitive paths
### Modified Capabilities
- `client-prediction-replay`: Update requirement to clarify that replay substep size and forward prediction accumulation cadence both derive from the server authoritative movement cadence (already implied by existing spec, making explicit)
## Impact
- **Affected code**: `MovementComponent.AccumulateLatest()`, `MovementComponent.FixedUpdate()`
- **No breaking API changes** to message types or transport
- **No breaking changes** to physics integration (`Simulate` still uses `Time.fixedDeltaTime` for physics)

View File

@ -0,0 +1,30 @@
# client-prediction-cadence Specification
## Purpose
Define that client forward prediction accumulation uses an explicit cadence derived from the server authoritative movement cadence, not `Time.fixedDeltaTime`, ensuring prediction timing aligns with authoritative timing in reconciliation-sensitive paths.
## ADDED Requirements
### Requirement: Forward prediction accumulation uses authoritative cadence
The controlled-client forward prediction path SHALL accumulate pending input duration using the server authoritative movement cadence as the unit of accumulation, not `Time.fixedDeltaTime` or other render-loop-derived values. This ensures `SimulatedDurationSeconds` reflects server-time and remains coherent with the server's 50ms step cadence.
#### Scenario: Accumulation uses server cadence regardless of FixedUpdate interval
- **WHEN** the client FixedUpdate runs at a 20ms interval
- **THEN** `AccumulateLatest` adds `kServerSimulationStepSeconds` (50ms) to the pending input duration
- **THEN** the accumulated `SimulatedDurationSeconds` reflects server-time, not real elapsed time
#### Scenario: Accumulation cadence is decoupled from frame rate
- **WHEN** FixedUpdate runs at a non-standard interval due to platform variation or frame drops
- **THEN** the accumulation unit remains `kServerSimulationStepSeconds`
- **THEN** prediction timing does not drift relative to the server's authoritative cadence
### Requirement: Forward prediction and replay use the same cadence source
The controlled-client prediction system SHALL use the same cadence source for both forward accumulation and replay substepping, ensuring that `SimulatedDurationSeconds` consumed during replay matches the cadence used during forward prediction.
#### Scenario: Forward accumulated duration matches replay substep size
- **WHEN** the client accumulates pending input for 100ms of server-time
- **THEN** the replay path consumes the same 100ms in 50ms substeps
- **THEN** the forward accumulated duration and replay duration are derived from the same cadence constant

View File

@ -0,0 +1,36 @@
# client-prediction-replay Specification
## Purpose
Define the contract that client-side replay of pending movement inputs after authoritative state acknowledgement uses fixed-step substeps matching the server authoritative movement cadence, not a single accumulated duration, so that replay trajectory matches live prediction trajectory for the same input sequence.
## MODIFIED Requirements
### 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.
#### 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
- **THEN** the replay applies 0.05s + 0.05s + 0.05s substeps in sequence
- **THEN** the final predicted position matches the position that would result from three consecutive FixedUpdate predictions of 0.05s each with the same input
#### Scenario: Replay produces same trajectory as live prediction for turn-and-move input
- **WHEN** the client replays a `PredictedMoveStep` with turn=0.5, throttle=1, duration=0.10s using a 0.05s server cadence
- **THEN** the replay applies two 0.05s substeps where each substep's heading affects the next substep's forward direction
- **THEN** the final predicted heading and position match the live prediction path for the same input sequence
#### Scenario: Replay handles non-multiples of cadence interval
- **WHEN** the client replays a `PredictedMoveStep` with duration=0.12s using a 0.05s cadence
- **THEN** the replay applies 0.05s + 0.05s + 0.02s substeps sequentially
- **THEN** no remaining duration is lost or double-counted
### 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.
#### 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

View File

@ -0,0 +1,8 @@
## 1. Implementation
- [x] 1.1 Change `AccumulateLatest(Time.fixedDeltaTime)` to `AccumulateLatest(kServerSimulationStepSeconds)` in `MovementComponent.FixedUpdate()`
## 2. Verification
- [x] 2.1 Run all EditMode tests ensure no regression
- [x] 2.2 Local loopback validation — controlled-player loopback movement no longer shows jitter under steady turn-and-move input

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -0,0 +1,69 @@
## Context
Local loopback testing shows controlled-player jitter. One root cause is `ReplayPendingInputs()` applying each `PredictedMoveStep` as a single accumulated-duration integration, while live prediction uses `FixedUpdate` with fixed substeps. This mismatch in integration shape causes trajectory divergence even for identical input sequences.
Tank movement kinematics: `heading(t+dt) = heading(t) + turnInput * turnSpeed * dt`, `position(t+dt) = position(t) + forward(heading(t+dt)) * throttleSpeed * dt`. Step-by-step and one-shot integration diverge at larger dt values because each step's heading affects the next step's forward direction.
## Goals / Non-Goals
**Goals:**
- `ReplayPendingInputs()` uses fixed-step accumulation matching server authoritative cadence
- Replay produces identical trajectory to live prediction for the same input sequence
- No external API changes, only internal integration method modification
- Add regression test for replay vs live prediction parity
- Add diagnostics for acknowledged move tick, predicted pose, authoritative pose, and correction magnitude
**Non-Goals:**
- Do not modify server 50ms cadence
- Do not fix send-interval oscillation (TODO Step 3)
- Do not modify visual correction logic (TODO Step 4)
## Decisions
### Decision: Use server SimulationInterval (50ms) as replay substep size
**Choice**: Replay in 50ms fixed substeps.
**Rationale**:
- Server integrates at 50ms cadence to produce authoritative state; client replay must match to eliminate偏差
- Client FixedUpdate at 20ms is render/physics step, not server simulation granularity
- Each `PredictedMoveStep.SimulatedDurationSeconds` may be 50ms, 100ms, etc.; stepping at 50ms handles all cases
**Alternatives**:
- 20ms step: matches client FixedUpdate but not server, still causes偏差
- Use `SimulatedDurationSeconds` as single step: current behavior, causes non-linear divergence
### Decision: Substep within ReplayPendingInputs loop without new state
**Implementation**:
```csharp
private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
{
const float serverStepSeconds = 0.05f; // 50ms server SimulationInterval
foreach (var replayInput in replayInputs)
{
var remaining = replayInput.SimulatedDurationSeconds;
while (remaining > 0f)
{
var step = Mathf.Min(remaining, serverStepSeconds);
ApplyTankMovementToPredictedState(
replayInput.Input.TurnInput,
replayInput.Input.ThrottleInput,
step);
remaining -= step;
}
}
}
```
**Rationale**:
- Does not change `PredictedMoveStep` struct interface
- No new temporary state variables needed
- Integration shape identical to live prediction path
## Risks / Trade-offs
- **[Risk]** Floating-point accumulation error could cause loop to run one step too many or too few
- **Mitigation**: Use `Mathf.Min(remaining, serverStepSeconds)` guard; final step naturally truncates
- **[Risk]** 50ms step adds one extra function call for very short inputs
- **Acceptable**: Negligible overhead

View File

@ -0,0 +1,27 @@
## Why
The current client prediction replay path uses a one-shot replay of an accumulated input duration, while live prediction uses fixed-step integration. This mismatch causes local player jitter during steady turn-and-move input — the replay produces a different trajectory than forward prediction for the same input sequence.
## What Changes
- Replace one-shot replay of accumulated input duration with fixed substeps matching the live prediction integration shape
- Ensure replay uses the same movement math (turn-and-move input handling) as normal `FixedUpdate` prediction
- Add regression test comparing live prediction vs replayed prediction under the same turn/throttle sequence
- Introduce explicit diagnostics for acknowledged move tick, predicted pose, authoritative pose, and correction magnitude
## Capabilities
### New Capabilities
- `client-prediction-replay`: Replay of pending client inputs after authoritative state acknowledgement uses fixed-step substeps that mirror live prediction integration, ensuring identical trajectory output for identical input sequences
- `client-prediction-diagnostics`: Explicit diagnostics exposing acknowledged move tick, predicted pose, authoritative pose, and correction magnitude per snapshot for regression testing and runtime debugging
### Modified Capabilities
- `client-authoritative-player-state`: Add requirement that replay integration must use fixed substeps matching live prediction cadence, not accumulated one-shot duration
## Impact
- **Affected code**: `ClientPredictionBuffer`, movement integration paths in `MovementComponent` or equivalent
- **No breaking API changes** to message types or transport
- **Testing impact**: New regression tests required for prediction/replay parity

View File

@ -0,0 +1,34 @@
# client-authoritative-player-state Specification
## Purpose
Define how the Unity client owns, applies, and exposes authoritative `PlayerState` snapshots for local and remote players.
## MODIFIED Requirements
### Requirement: Local player reconciliation applies the full authoritative state by tick
The controlled client SHALL continue reconciling local prediction from authoritative `PlayerState` snapshots while keeping authoritative HP and optional velocity synchronized with the owned player-state snapshot. Reconciliation MUST use the acknowledged movement-input tick defined by the sync strategy, and the visible controlled-player transform MUST keep authoritative gameplay truth separate from short-lived visual correction state. **Replay of pending inputs during reconciliation MUST use fixed-step substeps matching the server authoritative movement cadence, producing identical trajectory to live prediction for the same input sequence.** Small divergence after replay MUST converge through explicit bounded correction state, while large divergence or failed convergence MUST still snap immediately to authoritative `position` and `rotation`.
#### Scenario: Local authoritative state corrects predicted presentation
- **WHEN** the controlled player accepts an authoritative `PlayerState` whose acknowledged movement-input tick is `N`
- **THEN** local reconciliation prunes or replays predicted movement using tick `N` according to the sync strategy
- **THEN** the replay uses fixed-step substeps matching the server authoritative movement cadence
- **THEN** the controlled player's authoritative gameplay state updates immediately to the accepted `position`, `rotation`, HP, and optional velocity
- **THEN** the local player's visible transform may temporarily differ only through bounded visual correction state that converges back to the authoritative baseline
#### Scenario: Replay produces identical trajectory to live prediction
- **WHEN** the controlled player replays pending inputs after accepting authoritative `PlayerState`
- **THEN** the replay applies inputs in fixed-duration substeps equal to the server authoritative movement cadence
- **THEN** the final predicted pose equals what live `FixedUpdate` prediction would produce for the same input sequence
- **THEN** the result is stable across multiple replays of the same input sequence
#### Scenario: Consecutive small corrections replace or fold into active visual correction
- **WHEN** the controlled player accepts a newer authoritative `PlayerState` while a bounded visual correction is still active and the new residual error remains inside the configured bounded-correction limits
- **THEN** the client updates the active visual correction state according to the sync strategy instead of preserving stale correction targets indefinitely
- **THEN** the controlled player's authoritative gameplay state still reflects only the newest accepted `PlayerState`
#### Scenario: Large local divergence bypasses bounded correction
- **WHEN** the controlled player accepts an authoritative `PlayerState` and the remaining transform error exceeds the configured snap threshold or the active bounded correction can no longer converge within its budget
- **THEN** the controlled player's visible transform snaps immediately to authoritative `position` and `rotation`
- **THEN** any temporary visual correction state is cleared before later local prediction resumes from that authoritative baseline

View File

@ -0,0 +1,34 @@
# client-prediction-diagnostics Specification
## Purpose
Define diagnostics that expose per-snapshot prediction state for regression testing and runtime debugging, enabling verification that replay produces identical trajectories to live prediction and that small server tick offset fluctuations do not cause visible local cadence oscillation.
## ADDED Requirements
### Requirement: Authoritative snapshot exposes acknowledged move tick
The client prediction system SHALL expose the acknowledged movement-input tick from the most recently accepted authoritative `PlayerState` snapshot.
#### Scenario: Diagnostics report acknowledged move tick
- **WHEN** the client accepts an authoritative `PlayerState`
- **THEN** diagnostics can read the acknowledged move tick from that snapshot
- **THEN** this value is available for regression tests and runtime debugging
### Requirement: Authoritative snapshot exposes predicted vs authoritative pose
The client prediction system SHALL expose both the locally predicted pose and the authoritative pose for the controlled player at each snapshot.
#### Scenario: Diagnostics report predicted and authoritative poses
- **WHEN** the client has a locally predicted pose and receives an authoritative `PlayerState`
- **THEN** diagnostics can read both the predicted pose and the authoritative pose
- **THEN** the correction magnitude (difference between predicted and authoritative) is computable
### Requirement: Authoritative snapshot exposes correction magnitude
The client prediction system SHALL expose the correction magnitude applied during reconciliation for regression testing.
#### Scenario: Diagnostics report correction magnitude
- **WHEN** the client reconciles from authoritative `PlayerState`
- **THEN** diagnostics can read the correction magnitude applied
- **THEN** this value is available to verify that small server tick offset fluctuations do not cause excessive local corrections

View File

@ -0,0 +1,36 @@
# client-prediction-replay Specification
## Purpose
Define the contract that client-side replay of pending movement inputs after authoritative state acknowledgement uses fixed-step substeps matching the server authoritative movement cadence, not a single accumulated duration, so that replay trajectory matches live prediction trajectory for the same input sequence.
## ADDED Requirements
### 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`. 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
- **THEN** the replay applies 0.05s + 0.05s + 0.05s substeps in sequence
- **THEN** the final predicted position matches the position that would result from three consecutive FixedUpdate predictions of 0.05s each with the same input
#### Scenario: Replay produces same trajectory as live prediction for turn-and-move input
- **WHEN** the client replays a `PredictedMoveStep` with turn=0.5, throttle=1, duration=0.10s using a 0.05s server cadence
- **THEN** the replay applies two 0.05s substeps where each substep's heading affects the next substep's forward direction
- **THEN** the final predicted heading and position match the live prediction path for the same input sequence
#### Scenario: Replay handles non-multiples of cadence interval
- **WHEN** the client replays a `PredictedMoveStep` with duration=0.12s using a 0.05s cadence
- **THEN** the replay applies 0.05s + 0.05s + 0.02s substeps sequentially
- **THEN** no remaining duration is lost or double-counted
### 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.
#### 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

View File

@ -0,0 +1,23 @@
## 1. Implementation (Already Complete)
The fixed-step replay implementation in `MovementComponent.ReplayPendingInputs()` is already in place using `kServerSimulationStepSeconds` (50ms) as the substep size.
## 2. Regression Tests
> **Note**: Unity EditMode tests require Unity Editor to run.
- [ ] 2.1 Verify `ReplayPendingInputs_StepByStepMatchesAccumulated_ForZeroTurnInput` test passes
- [ ] 2.2 Verify `ReplayPendingInputs_StepByStepDiffersFromAccumulated_ForNonZeroTurnInput` test passes
- [ ] 2.3 Verify `ReplayPendingInputs_NonMultipleOfCadence_HandlesRemainingDuration` test passes
## 3. Diagnostics Capability
- [x] 3.1 Add diagnostics exposure for acknowledged move tick, predicted pose, authoritative pose, and correction magnitude
- [x] 3.2 Expose `LastAcknowledgedMoveTick` from `ClientPredictionBuffer` for diagnostics consumption
## 4. Verification
> **Note**: Unity EditMode tests require Unity Editor. Loopback validation requires PlayMode.
- [ ] 4.1 Run all EditMode tests ensure no regression
- [ ] 4.2 Local loopback validation — controlled-player loopback movement no longer shows repeated small pull-back under steady turn-and-move input

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -0,0 +1,35 @@
## Context
Steps 1-3 implemented fixes for local controlled-player jitter:
1. Replay uses fixed-step substeps (not one-shot accumulated duration)
2. Forward prediction accumulation uses server cadence (50ms) instead of Time.fixedDeltaTime (20ms)
3. Send interval has hysteresis dead-band so it does not oscillate at near-zero offset
Step 4 is a manual validation step — run the game and observe whether the jitter is resolved.
## Goals / Non-Goals
**Goals:**
- Verify that loopback steady turn-and-move input no longer produces visible jitter after Steps 1-3.
- Use the MainUI diagnostics (校正pos差=X rot差=Y°) to confirm corrections are consistently small.
- Confirm acknowledged move tick advances steadily without gaps.
**Non-Goals:**
- No code changes in this step.
- Do not tune remote player interpolation.
- Do not add new local smoothing or prediction heuristics.
## Decisions
This step follows an observational approach rather than implementing new code:
1. Run Unity Editor with loopback server + client.
2. Hold steady turn-and-move input for 10+ seconds.
3. Observe MainUI correction text — if pos差 < 0.01 and rot差 < 1° consistently, the fixes are working.
4. If jitter is still visible or corrections are large, document what is observed for Step 5.
## Risks / Trade-offs
- **Risk**: Loopback latency (near-zero) may not reflect real network conditions.
- **Mitigation**: The jitter addressed was deterministic/timing-related, not latency-related, so loopback is appropriate for validation.
- **Risk**: Manual observation is subjective.
- **Accepted**: The correction magnitude text provides objective data to complement visual observation.

View File

@ -0,0 +1,25 @@
## Why
Steps 1-3 addressed the root causes of local controlled-player jitter: replay granularity (one-shot → fixed substeps), prediction cadence (Time.fixedDeltaTime → server cadence), and send interval oscillation (sign-toggle → dead-band hysteresis). Step 4 is a measurement and evaluation step to determine whether those fixes resolved the jitter or if further local visual correction refinement is warranted.
## What Changes
This is a validation step, not a code change. The artifacts confirm the acceptance criteria through manual testing and diagnostics observation:
- Run loopback test with steady turn-and-move input.
- Observe correction magnitude diagnostics from MainUI (校正pos差=X rot差=Y°) to verify corrections are small.
- Observe acknowledged move tick to confirm input pipeline is healthy.
- Do NOT modify remote player interpolation or introduce new local smoothing.
- If jitter persists at meaningful magnitude after Steps 1-3, document residual error for Step 5 (regression coverage).
## Capabilities
### New Capabilities
- (none — this is a measurement/validation step with no new spec requirements)
### Modified Capabilities
- (none)
## Impact
No code changes. This step validates whether Steps 1-3 achieved the acceptance criteria or whether additional local visual correction refinement is needed.

View File

@ -0,0 +1,3 @@
# Spec Changes
No new capabilities introduced. This is a measurement/validation step with no spec-level changes.

View File

@ -0,0 +1,16 @@
## 1. Run loopback validation test
- [x] 1.1 Start Unity Editor with server + client in loopback mode
- [x] 1.2 Hold steady turn-and-move input (e.g., turn=0.5, throttle=1) for 10+ seconds
- [x] 1.3 Observe MainUI correction text (校正pos差=X rot差=Y°) — record observed values
## 2. Evaluate results
- [x] 2.1 If pos差 < 0.01 and rot差 < 1° consistently: jitter is resolved, proceed to Step 5
- [x] 2.2 If corrections remain large or jitter is still visible: document residual error for Step 5
**观察结果:** 抖动仍然明显corrections 仍然较大),需要 Step 5 进一步诊断和回归覆盖。
## 3. Complete
- [x] 3.1 Mark TODO.md Step 4 as complete

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -0,0 +1,51 @@
## Context
`MovementComponent.SetServerTick(long serverTick)` drives input-send cadence by comparing server tick to local client tick. When `_currentTickOffset = serverTick - Tick - _startTickOffset` is negative, it sets `_sendInterval = 0.052f`; when positive, `_sendInterval = 0.048f`. When the offset hovers near zero (e.g., due to minor clock drift or network jitter), the sign flips each call, causing `_sendInterval` to toggle every frame between 0.048 and 0.052. This send-rate oscillation adds jitter to the input cadence.
## Goals / Non-Goals
**Goals:**
- Prevent send interval oscillation when server tick offset is near zero.
- Preserve meaningful clock correction when real drift exists (offset is consistently positive or negative).
**Non-Goals:**
- This is not a full clock synchronization protocol — only a local oscillation guard.
- Does not change the underlying tick offset computation.
## Decisions
### Decision: Dead-band hysteresis for send interval correction
Instead of toggling `_sendInterval` on every sign change of `_currentTickOffset`, apply a dead-band threshold. Only correct the send interval when the absolute offset exceeds a meaningful threshold (e.g., 1-2 ticks = 50-100ms of drift).
**Current code (problematic):**
```csharp
if (_currentTickOffset < 0)
_sendInterval = 0.052f;
if (_currentTickOffset > 0)
_sendInterval = 0.048f;
```
**Proposed replacement:**
```csharp
private const float kTickOffsetThreshold = 2; // ticks
if (_currentTickOffset < -kTickOffsetThreshold)
_sendInterval = 0.052f;
else if (_currentTickOffset > kTickOffsetThreshold)
_sendInterval = 0.048f;
// else: keep current interval (no correction within dead band)
```
**Alternatives considered:**
1. **Exponential moving average of offset** — smooths jitter but adds complexity and latency to correction.
2. **Remove correction entirely, use fixed 0.05s** — simpler but loses adaptive behavior when real drift exists.
The dead-band approach is the simplest that directly solves oscillation without adding state complexity.
## Risks / Trade-offs
- **Risk**: If `kTickOffsetThreshold` is too large, real drift may not be corrected fast enough.
- **Mitigation**: Start with a conservative threshold (1-2 ticks). Adjust after measuring.
- **Risk**: The hysteresis introduces a zone where no correction is applied even when offset is slightly non-zero.
- **Accepted**: This is the intended behavior — minor fluctuations near zero should not disturb steady-rate sending.

View File

@ -0,0 +1,22 @@
## Why
`MovementComponent.SetServerTick(...)` toggles `_sendInterval` between 0.052f and 0.048f whenever `_currentTickOffset` crosses zero. When the offset hovers near zero due to minor clock drift, this causes frame-to-frame send-cadilla oscillation, which disrupts steady-rate input submission and adds unnecessary jitter to the prediction/reconciliation loop.
## What Changes
- Add hysteresis to the send interval adjustment so it does not flip-flop when `_currentTickOffset` oscillates around zero.
- The correction logic will use a dead-band threshold — only adjust `_sendInterval` when the absolute offset exceeds a meaningful threshold, not on every sign change.
- A small nominal send interval (50ms) remains the baseline; clock correction only applies when drift is substantial.
## Capabilities
### New Capabilities
- `client-send-interval-stabilization`: A contract specifying that the client's send interval does not oscillate due to minor server tick offset fluctuations near zero.
### Modified Capabilities
- `client-prediction-cadence`: Extend to explicitly cover that send interval correction is also bounded by hysteresis and does not toggle at near-zero offset.
## Impact
- `MovementComponent.SetServerTick(...)` — threshold-based hysteresis added to send interval correction logic
- No changes to network message formats, delivery policies, or prediction buffer behavior

View File

@ -0,0 +1,36 @@
# client-send-interval-stabilization Specification
## Purpose
Define that the client send interval is protected from oscillation when the server tick offset hovers near zero, ensuring steady-rate input submission without frame-to-frame cadence jitter.
## Requirements
### Requirement: Send interval correction uses hysteresis dead-band
The controlled-client send interval corrector SHALL apply a dead-band threshold before adjusting `_sendInterval`, so that minor server tick offset fluctuations near zero do not cause the send cadence to toggle between values.
#### Scenario: No correction within dead-band
- **WHEN** `_currentTickOffset` is between -2 and +2 ticks (inclusive)
- **THEN** `_sendInterval` is not changed
- **THEN** the previously active send interval is preserved
#### Scenario: Slow drift correction below threshold
- **WHEN** `_currentTickOffset` stays within the dead-band for an extended period
- **THEN** `_sendInterval` remains stable at its current value
- **THEN** no oscillation occurs regardless of offset sign changes within the band
#### Scenario: Correction applies outside dead-band
- **WHEN** `_currentTickOffset` exceeds +2 (client ahead of server)
- **THEN** `_sendInterval` is set to 0.048f to send slightly faster
- **WHEN** `_currentTickOffset` is below -2 (client behind server)
- **THEN** `_sendInterval` is set to 0.052f to send slightly slower
### Requirement: Send interval stabilizes after offset crosses threshold
Once the offset exits the dead-band and triggers a correction, subsequent corrections SHALL only occur when the offset crosses the threshold again in the opposite direction, preventing rapid re-correction.
#### Scenario: Correction latches until opposite threshold
- **WHEN** offset triggers a correction to 0.048f (offset > +2)
- **THEN** further offset increases within the same sign do not re-trigger correction
- **THEN** the send interval stays at 0.048f until offset crosses back below +2 then exceeds -2

View File

@ -0,0 +1,12 @@
## 1. Implement hysteresis dead-band in SetServerTick
- [x] 1.1 Add `private const int kTickOffsetThreshold = 2;` to MovementComponent
- [x] 1.2 Replace the dual `if (_currentTickOffset < 0 / > 0)` sign checks with a threshold-based dead-band: only adjust `_sendInterval` when `Mathf.Abs(_currentTickOffset) > kTickOffsetThreshold`
## 2. Add regression test for send interval stability
- [x] 2.1 Add a test in `ServerRuntimeEntryPointTests.cs` or a new test file verifying that `SetServerTick` does not oscillate `_sendInterval` when offset hovers near zero
## 3. Update TODO.md
- [x] 3.1 Mark TODO.md Step 3 as complete

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-07

View File

@ -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. 判断 errorerror > SnapThreshold → snapelse → 更新 `_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 处理小误差自然收敛

View File

@ -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`(或保留作他用)

View File

@ -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

View File

@ -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

View File

@ -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 开启网络同步:抖动消除或显著减少

View File

@ -13,11 +13,12 @@ The client SHALL keep one explicit owned authoritative `PlayerState` snapshot fo
- **THEN** presentation and diagnostics read authoritative `position`, `rotation`, `hp`, and optional `velocity` from that owned snapshot
### Requirement: Local player reconciliation applies the full authoritative state by tick
The controlled client SHALL continue reconciling local prediction from authoritative `PlayerState` snapshots while keeping authoritative HP and optional velocity synchronized with the owned player-state snapshot. Reconciliation MUST use the acknowledged movement-input tick defined by the sync strategy, and the visible controlled-player transform MUST keep authoritative gameplay truth separate from short-lived visual correction state. Small divergence after replay MUST converge through explicit bounded correction state, while large divergence or failed convergence MUST still snap immediately to authoritative `position` and `rotation`.
The controlled client SHALL continue reconciling local prediction from authoritative `PlayerState` snapshots while keeping authoritative HP and optional velocity synchronized with the owned player-state snapshot. Reconciliation MUST use the acknowledged movement-input tick defined by the sync strategy, and the visible controlled-player transform MUST keep authoritative gameplay truth separate from short-lived visual correction state. **Replay of pending inputs during reconciliation MUST use fixed-step substeps matching the server authoritative movement cadence, producing identical trajectory to live prediction for the same input sequence.** Small divergence after replay MUST converge through explicit bounded correction state, while large divergence or failed convergence MUST still snap immediately to authoritative `position` and `rotation`.
#### Scenario: Local authoritative state corrects predicted presentation
- **WHEN** the controlled player accepts an authoritative `PlayerState` whose acknowledged movement-input tick is `N`
- **THEN** local reconciliation prunes or replays predicted movement using tick `N` according to the sync strategy
- **THEN** the replay uses fixed-step substeps matching the server authoritative movement cadence
- **THEN** the controlled player's authoritative gameplay state updates immediately to the accepted `position`, `rotation`, HP, and optional velocity
- **THEN** the local player's visible transform may temporarily differ only through bounded visual correction state that converges back to the authoritative baseline
@ -31,6 +32,12 @@ The controlled client SHALL continue reconciling local prediction from authorita
- **THEN** the controlled player's visible transform snaps immediately to authoritative `position` and `rotation`
- **THEN** any temporary visual correction state is cleared before later local prediction resumes from that authoritative baseline
#### Scenario: Replay produces identical trajectory to live prediction
- **WHEN** the controlled player replays pending inputs after accepting authoritative `PlayerState`
- **THEN** the replay applies inputs in fixed-duration substeps equal to the server authoritative movement cadence
- **THEN** the final predicted pose equals what live `FixedUpdate` prediction would produce for the same input sequence
- **THEN** the result is stable across multiple replays of the same input sequence
### Requirement: Remote players apply authoritative state without inventing gameplay truth
Remote player presentation SHALL consume the accepted authoritative player-state snapshot owned by the client and MUST NOT invent HP or final gameplay state locally. Remote movement presentation MUST smooth authoritative position and rotation through a small buffered snapshot interpolation path instead of applying only the latest snapshot directly. Stale remote `PlayerState` packets that are older than the latest accepted authoritative tick for that player MUST NOT overwrite the owned snapshot or enter the interpolation buffer.

View File

@ -0,0 +1,32 @@
# client-prediction-cadence Specification
## Purpose
Define that client forward prediction accumulation uses an explicit cadence derived from the server authoritative movement cadence, not `Time.fixedDeltaTime`, ensuring prediction timing aligns with authoritative timing in reconciliation-sensitive paths.
## Requirements
### Requirement: Forward prediction accumulation tracks real elapsed time since last authoritative state
The controlled-client forward prediction path SHALL accumulate pending input duration using the actual wall-clock elapsed time since the last authoritative state arrival, not a fixed server cadence increment per FixedUpdate. This ensures `SimulatedDurationSeconds` advances at the same rate as real time and is synchronized with the server's 20Hz authoritative cadence.
#### Scenario: Accumulation uses wall-clock time since last authoritative state
- **WHEN** the client receives an authoritative state at wall-clock time T
- **THEN** the next accumulation period starts from T
- **WHEN** the subsequent FixedUpdate runs
- **THEN** `AccumulateWithElapsedTime` adds only the wall-clock elapsed time since T (not the FixedUpdate interval)
- **THEN** the accumulated `SimulatedDurationSeconds` is proportional to actual elapsed real time
#### Scenario: Accumulation is decoupled from FixedUpdate cadence
- **WHEN** FixedUpdate runs at 50Hz (20ms per step) but the server sends authoritative state at 20Hz (50ms per broadcast)
- **THEN** the accumulation rate is driven by wall-clock time, not by FixedUpdate calls
- **THEN** the pending input duration accumulates to match the real elapsed time between authoritative state arrivals, preventing 2.5x accumulation speedup
### Requirement: Forward prediction and replay use the same cadence source
The controlled-client prediction system SHALL use the same wall-clock time source for both forward accumulation and replay substepping, ensuring that `SimulatedDurationSeconds` consumed during replay matches the wall-clock elapsed time accumulated during forward prediction.
#### Scenario: Forward accumulated duration matches replay substep size
- **WHEN** the client accumulates pending input for 100ms of wall-clock elapsed time
- **THEN** the replay path consumes the same 100ms in 50ms substeps
- **THEN** the forward accumulated duration and replay duration are both derived from the same wall-clock time source

View File

@ -0,0 +1,34 @@
# client-prediction-diagnostics Specification
## Purpose
Define diagnostics that expose per-snapshot prediction state for regression testing and runtime debugging, enabling verification that replay produces identical trajectories to live prediction and that small server tick offset fluctuations do not cause visible local cadence oscillation.
## Requirements
### Requirement: Authoritative snapshot exposes acknowledged move tick
The client prediction system SHALL expose the acknowledged movement-input tick from the most recently accepted authoritative `PlayerState` snapshot.
#### Scenario: Diagnostics report acknowledged move tick
- **WHEN** the client accepts an authoritative `PlayerState`
- **THEN** diagnostics can read the acknowledged move tick from that snapshot
- **THEN** this value is available for regression tests and runtime debugging
### Requirement: Authoritative snapshot exposes predicted vs authoritative pose
The client prediction system SHALL expose both the locally predicted pose and the authoritative pose for the controlled player at each snapshot.
#### Scenario: Diagnostics report predicted and authoritative poses
- **WHEN** the client has a locally predicted pose and receives an authoritative `PlayerState`
- **THEN** diagnostics can read both the predicted pose and the authoritative pose
- **THEN** the correction magnitude (difference between predicted and authoritative) is computable
### Requirement: Authoritative snapshot exposes correction magnitude
The client prediction system SHALL expose the correction magnitude applied during reconciliation for regression testing.
#### Scenario: Diagnostics report correction magnitude
- **WHEN** the client reconciles from authoritative `PlayerState`
- **THEN** diagnostics can read the correction magnitude applied
- **THEN** this value is available to verify that small server tick offset fluctuations do not cause excessive local corrections

View File

@ -0,0 +1,47 @@
# client-prediction-replay Specification
## Purpose
Define the contract that client-side replay of pending movement inputs after authoritative state acknowledgement uses fixed-step substeps matching the server authoritative movement cadence, not a single accumulated duration, so that replay trajectory matches live prediction trajectory for the same input sequence.
## Requirements
### 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, 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
- **THEN** the replay applies 0.05s + 0.05s + 0.05s substeps in sequence
- **THEN** the final predicted position matches the position that would result from three consecutive FixedUpdate predictions of 0.05s each with the same input
#### Scenario: Replay produces same trajectory as live prediction for turn-and-move input
- **WHEN** the client replays a `PredictedMoveStep` with turn=0.5, throttle=1, duration=0.10s using a 0.05s server cadence
- **THEN** the replay applies two 0.05s substeps where each substep's heading affects the next substep's forward direction
- **THEN** the final predicted heading and position match the live prediction path for the same input sequence
#### Scenario: Replay handles non-multiples of cadence interval
- **WHEN** the client replays a `PredictedMoveStep` with duration=0.12s using a 0.05s cadence
- **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 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

View File

@ -0,0 +1,36 @@
# client-send-interval-stabilization Specification
## Purpose
Define that the client send interval is protected from oscillation when the server tick offset hovers near zero, ensuring steady-rate input submission without frame-to-frame cadence jitter.
## Requirements
### Requirement: Send interval correction uses hysteresis dead-band
The controlled-client send interval corrector SHALL apply a dead-band threshold before adjusting `_sendInterval`, so that minor server tick offset fluctuations near zero do not cause the send cadence to toggle between values.
#### Scenario: No correction within dead-band
- **WHEN** `_currentTickOffset` is between -2 and +2 ticks (inclusive)
- **THEN** `_sendInterval` is not changed
- **THEN** the previously active send interval is preserved
#### Scenario: Slow drift correction below threshold
- **WHEN** `_currentTickOffset` stays within the dead-band for an extended period
- **THEN** `_sendInterval` remains stable at its current value
- **THEN** no oscillation occurs regardless of offset sign changes within the band
#### Scenario: Correction applies outside dead-band
- **WHEN** `_currentTickOffset` exceeds +2 (client ahead of server)
- **THEN** `_sendInterval` is set to 0.048f to send slightly faster
- **WHEN** `_currentTickOffset` is below -2 (client behind server)
- **THEN** `_sendInterval` is set to 0.052f to send slightly slower
### Requirement: Send interval stabilizes after offset crosses threshold
Once the offset exits the dead-band and triggers a correction, subsequent corrections SHALL only occur when the offset crosses the threshold again in the opposite direction, preventing rapid re-correction.
#### Scenario: Correction latches until opposite threshold
- **WHEN** offset triggers a correction to 0.048f (offset > +2)
- **THEN** further offset increases within the same sign do not re-trigger correction
- **THEN** the send interval stays at 0.048f until offset crosses back below +2 then exceeds -2

View File

@ -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

View File

@ -17,26 +17,26 @@ The repository SHALL keep the source protobuf schema that defines gameplay netwo
- **THEN** the checked-in generated code matches the schema contract used by client and server hosts
### Requirement: Gameplay messages expose explicit MVP payload fields
The shared networking contract SHALL define the MVP payload fields for gameplay messages explicitly in the source protobuf schema and generated C# messages. `MoveInput` MUST expose `playerId`, `tick`, `moveX`, and `moveY`; `ShootInput` MUST expose `playerId`, `tick`, `dirX`, `dirY`, and an optional `targetId`; `PlayerState` MUST expose `playerId`, `tick`, `acknowledgedMoveTick`, `position`, `rotation`, `hp`, and an optional `velocity`; `CombatEvent` MUST expose `tick`, `eventType`, `attackerId`, `targetId`, `damage`, and an optional `hitPosition`. The shared contract MUST also provide `CombatEventType` so combat results use explicit event categories rather than ad hoc integer payload conventions.
The shared networking contract SHALL define the MVP payload fields for gameplay messages explicitly in the source protobuf schema and generated C# messages. `MoveInput` MUST expose `player_id`, `tick`, `turn_input`, and `throttle_input`; `ShootInput` MUST expose `player_id`, `tick`, `dir_x`, `dir_y`, and an optional `target_id`; `PlayerState` MUST expose `player_id`, `tick`, `acknowledged_move_tick`, `position`, `rotation`, `hp`, and `velocity`; `CombatEvent` MUST expose `tick`, `event_type`, `attacker_id`, `target_id`, `damage`, and an optional `hit_position`. The shared contract MUST also provide `CombatEventType` so combat results use explicit event categories rather than ad hoc integer payload conventions.
#### Scenario: Movement input carries explicit movement fields
- **WHEN** client or server code constructs or parses `MoveInput`
- **THEN** the message exposes `playerId`, `tick`, `moveX`, and `moveY`
- **THEN** the message exposes `player_id`, `tick`, `turn_input`, and `throttle_input`
- **THEN** movement intent does not rely on an overloaded payload extension
#### Scenario: Shooting input carries explicit aim fields
- **WHEN** client or server code constructs or parses `ShootInput`
- **THEN** the message exposes `playerId`, `tick`, `dirX`, `dirY`, and `targetId`
- **THEN** the message exposes `player_id`, `tick`, `dir_x`, `dir_y`, and `target_id`
- **THEN** shooting direction and optional target selection are represented directly in the message contract
#### Scenario: Authoritative player state carries explicit gameplay state fields
- **WHEN** client or server code constructs or parses `PlayerState`
- **THEN** the message exposes `playerId`, `tick`, `acknowledgedMoveTick`, `position`, `rotation`, `hp`, and `velocity`
- **THEN** the message exposes `player_id`, `tick`, `acknowledged_move_tick`, `position`, `rotation`, `hp`, and `velocity`
- **THEN** snapshot ordering and acknowledged-input reconciliation are both expressed without ad hoc payload extensions or overloaded tick semantics
#### Scenario: Combat events carry explicit result fields and event categories
- **WHEN** client or server code constructs or parses `CombatEvent`
- **THEN** the message exposes `tick`, `eventType`, `attackerId`, `targetId`, `damage`, and `hitPosition`
- **THEN** the message exposes `tick`, `event_type`, `attacker_id`, `target_id`, `damage`, and `hit_position`
- **THEN** `CombatEventType` provides explicit combat-result categories for interpreting that event payload
### Requirement: Client gameplay actions use split gameplay messages directly