Compare commits
10 Commits
3446dcd6f1
...
098a1dd68c
| Author | SHA1 | Date |
|---|---|---|
|
|
098a1dd68c | |
|
|
75289b5690 | |
|
|
da2b93e59c | |
|
|
b7c003f227 | |
|
|
2c46012800 | |
|
|
e60ad420dc | |
|
|
1e90de11ce | |
|
|
90f1832397 | |
|
|
79474b53aa | |
|
|
a1ede230bb |
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ crashlytics-build.properties
|
|||
*.xmind
|
||||
/.dotnet
|
||||
/.dotnet-home
|
||||
|
||||
/openspec/changes/archive
|
||||
|
||||
EditMode-err.txt
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: eff1752caaabe784fb85775608605426
|
||||
guid: e3a66b8917bed9648a9d99ee35525e6e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 20280892ecfd4b25bff064d07668accc
|
||||
timeCreated: 1775619625
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
public class OfflineMovementComponent : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int _speed;
|
||||
[SerializeField] private Rigidbody rigid;
|
||||
private Vector3 _cachedInput;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
_cachedInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
rigid.velocity = _cachedInput * _speed;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 10d331d181503b542868b5608961489e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 309fec203b04d2b4cb87f9c2873c0449
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 输入源接口,用于解耦输入捕获
|
||||
/// </summary>
|
||||
public interface IInputSource
|
||||
{
|
||||
Vector3 GetPlanarInput();
|
||||
bool ConsumeShootInput();
|
||||
Vector3 GetAimDirection();
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e7d6671c80a243619f1f3dc34ca92d15
|
||||
timeCreated: 1775619222
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: cf8f141cd490efa4aa23369000c805db
|
||||
guid: 76493c52bd37a4a4db167d10a4dd6369
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 53e3773e493842e8861ac7522a6227a9
|
||||
timeCreated: 1775619270
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5d7f0a25d2b54decbf2c2386e5c0ebd2
|
||||
timeCreated: 1775619246
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fcfebc0989c4bf04cacf1c633d49a8bb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -12,10 +12,26 @@ public class Player : MonoBehaviour
|
|||
[SerializeField] private Material[] _materials;
|
||||
[SerializeField] private Camera _camera;
|
||||
[SerializeField] private MovementComponent _movement;
|
||||
[SerializeField] private MovementResolverComponent _movementResolver;
|
||||
[SerializeField] private PlayerUI _playerUI;
|
||||
[SerializeField] private bool _isControlled;
|
||||
private readonly ClientAuthoritativePlayerState _authoritativeState = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_movement ??= GetComponent<MovementComponent>();
|
||||
if (_movement == null)
|
||||
{
|
||||
_movement = gameObject.AddComponent<MovementComponent>();
|
||||
}
|
||||
|
||||
_movementResolver ??= GetComponent<MovementResolverComponent>();
|
||||
if (_movementResolver == null)
|
||||
{
|
||||
_movementResolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
}
|
||||
}
|
||||
|
||||
public void LocalInit(string playerId, ClientMovementBootstrap bootstrap)
|
||||
{
|
||||
this.PlayerId = playerId;
|
||||
|
|
@ -25,7 +41,7 @@ public class Player : MonoBehaviour
|
|||
_meshRenderer.material = _materials[idx];
|
||||
|
||||
_playerUI.Init(this);
|
||||
_movement.Init(true, this, bootstrap);
|
||||
_movementResolver.Init(true, this, bootstrap);
|
||||
}
|
||||
|
||||
public void RemoteInit(string playerId, UnityEngine.Vector3 pos)
|
||||
|
|
@ -40,7 +56,7 @@ public class Player : MonoBehaviour
|
|||
this.transform.position = pos;
|
||||
|
||||
_playerUI.Init(this);
|
||||
_movement.Init(false, this);
|
||||
_movementResolver.Init(false, this);
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
|
|
@ -59,7 +75,7 @@ public class Player : MonoBehaviour
|
|||
}
|
||||
|
||||
_playerUI?.SyncAuthoritativeState(snapshot, CombatPresentation);
|
||||
_movement?.OnAuthoritativeState(snapshot);
|
||||
_movementResolver?.OnAuthoritativeState(snapshot);
|
||||
}
|
||||
|
||||
public bool ApplyCombatEvent(CombatEvent combatEvent)
|
||||
|
|
@ -75,6 +91,6 @@ public class Player : MonoBehaviour
|
|||
|
||||
public void SyncTick(long serverTick)
|
||||
{
|
||||
_movement.SetServerTick(serverTick);
|
||||
_movementResolver.SetServerTick(serverTick);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using UnityEngine;
|
||||
|
||||
public static class TankMovementKinematics
|
||||
{
|
||||
private const float TurnSpeedDegreesPerSecond = 180f;
|
||||
private const float UnityYawOffsetDegrees = 90f;
|
||||
|
||||
public static void ApplyStep(int speed, float turnInput, float throttleInput, float deltaTime,
|
||||
ref Vector3 position, ref Quaternion rotation)
|
||||
{
|
||||
if (deltaTime <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f);
|
||||
var clampedThrottleInput = Mathf.Clamp(throttleInput, -1f, 1f);
|
||||
var heading = NormalizeDegrees(UnityYawToHeading(rotation.eulerAngles.y) +
|
||||
(clampedTurnInput * TurnSpeedDegreesPerSecond * deltaTime));
|
||||
rotation = Quaternion.Euler(0f, HeadingToUnityYaw(heading), 0f);
|
||||
|
||||
var forward = ResolveHeadingForward(heading);
|
||||
var velocity = forward * (clampedThrottleInput * speed);
|
||||
position += velocity * deltaTime;
|
||||
}
|
||||
|
||||
public static Vector3 ResolveHeadingForward(float headingDegrees)
|
||||
{
|
||||
var rotationRadians = headingDegrees * Mathf.Deg2Rad;
|
||||
return new Vector3(Mathf.Cos(rotationRadians), 0f, Mathf.Sin(rotationRadians));
|
||||
}
|
||||
|
||||
public static float HeadingToUnityYaw(float headingDegrees)
|
||||
{
|
||||
return NormalizeDegrees(UnityYawOffsetDegrees - headingDegrees);
|
||||
}
|
||||
|
||||
public static float UnityYawToHeading(float unityYawDegrees)
|
||||
{
|
||||
return NormalizeDegrees(UnityYawOffsetDegrees - unityYawDegrees);
|
||||
}
|
||||
|
||||
public static float NormalizeDegrees(float degrees)
|
||||
{
|
||||
var normalized = degrees % 360f;
|
||||
if (normalized < 0f)
|
||||
{
|
||||
normalized += 360f;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 22558181f2430ca4b80f5787ec62c68d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
enum CardState
|
||||
{
|
||||
Cooling,
|
||||
Ready,
|
||||
WaitingSun
|
||||
}
|
||||
|
||||
public class Card : MonoBehaviour
|
||||
{
|
||||
private CardState cardState = CardState.Cooling;
|
||||
|
||||
public GameObject sunflower;
|
||||
public GameObject sunflowerGray;
|
||||
public Image sunflowerMask;
|
||||
|
||||
|
||||
private void Update()
|
||||
{
|
||||
switch (cardState)
|
||||
{
|
||||
case CardState.Cooling:
|
||||
CoolingUpdate();
|
||||
break;
|
||||
case CardState.Ready:
|
||||
ReadyUpdate();
|
||||
break;
|
||||
case CardState.WaitingSun:
|
||||
WaitingSunUpdate();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void CoolingUpdate()
|
||||
{
|
||||
|
||||
}
|
||||
void ReadyUpdate()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
void WaitingSunUpdate()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -135,20 +135,28 @@ namespace Tests.EditMode.Network
|
|||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.75f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.25f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(0.0375f).Within(0.0001f));
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").x, Is.EqualTo(0.25f).Within(0.0001f));
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(1f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(1f).Within(0.0001f));
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(0.5f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledUpdate(movement);
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(movement.TargetPosition.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(rigidbody.position.x, Is.GreaterThan(0.0375f));
|
||||
Assert.That(rigidbody.position.x, Is.LessThan(0.5f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -157,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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=0,throttle=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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 351f8ed6c1e2cf047bb173a387d41942
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6aee45b7c7c7b734c9b6895d254f1cde
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e1bbd442159abc24a8f56fc7bbad78a8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c61c8ae5a68e0fb4195a729749a21019
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 00dbd4533a02ad04cb7d9dada7210593
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
11
TODO.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: 180f,Client: 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),会触发瞬移
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-06
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-06
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-06
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-06
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Spec Changes
|
||||
|
||||
No new capabilities introduced. This is a measurement/validation step with no spec-level changes.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-06
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-07
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
## Context
|
||||
|
||||
当前 `MovementComponent` 的 `Reconcile()` 方法在收到服务器 PlayerState 后:
|
||||
1. 强制 snap 到 server position
|
||||
2. 重放 pending inputs(可能产生位移)
|
||||
3. 用 bounded correction 从重放后位置收敛回 server position
|
||||
|
||||
问题:server position 每帧都在变化(服务器在广播),bounded correction 的收敛目标一直在变,导致永远追不上。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 将模拟层(服务器权威 truth)和表现层(纯视觉插值)解耦
|
||||
- 表现层用 Lerp 替代 bounded correction,消除追赶震荡
|
||||
- 模拟层只输出"目标位置",表现层只管插值
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改变网络协议或服务器逻辑
|
||||
- 不修改远程玩家的插值逻辑
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: 表现层直接设置 rigid.position,不使用 MovePosition
|
||||
|
||||
**选择:表现层直接设置 `_rigid.position`**
|
||||
|
||||
- Rigidbody interpolation 设为 `None`
|
||||
- 表现层直接写入 `_rigid.position` 和 `_rigid.rotation`
|
||||
- 不经过 `Rigidbody.MovePosition`(避免物理引擎介入)
|
||||
|
||||
### Decision: 模拟层收到服务器状态时立即计算 target
|
||||
|
||||
收到服务器 PlayerState 时:
|
||||
1. Acknowledge inputs(移除 tick <= AckTick 的输入)
|
||||
2. 从 authoritative position 重放剩余 pending inputs
|
||||
3. 计算 `target = authoritativePosition + replayDisplacement`
|
||||
4. 判断 error:error > SnapThreshold → snap;else → 更新 `_presentationTarget`
|
||||
|
||||
**关键**:`_presentationTarget` 在两次收到服务器状态之间保持不变,表现层稳定 Lerp。
|
||||
|
||||
### Decision: 插值策略使用 Lerp
|
||||
|
||||
固定 alpha = 0.15~0.2。后续可根据 RTT 动态调整或改用 SmoothDamp。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
[Risk] 插值延迟导致本地玩家看到的位置比服务器延迟
|
||||
→ [Mitigation] Lerp 的延迟是固定的(不像 bounded correction 那样持续追赶),且不影响服务器权威性
|
||||
|
||||
[Risk] 删除 bounded correction 后无法平滑大误差
|
||||
→ [Mitigation] Snap 阈值(0.5 unit)处理大误差,直接跳到目标;Lerp 处理小误差自然收敛
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
## Why
|
||||
|
||||
当前 `MovementComponent` 将模拟层(pending inputs 维护、服务器校正、重放)和表现层(视觉插值、bounded correction)耦合在一起。bounded correction 的收敛目标是实时变化的 server position,导致客户端永远在追赶——每次收到服务器状态,收敛目标就变了。表现为本地移动平滑,加上网络同步后抖动。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **新增** `LocalPlayerPresentationState` 类型:持有 `_currentPosition/Rotation`(当前显示)和 `_targetPosition/Rotation`(模拟层给的目标)
|
||||
- **新增** `LocalPlayerSimulationState` 类型:持有 `_lastAuthoritativePosition/Rotation`、`_pendingInputs`、`_presentationTarget`
|
||||
- **重构** `MovementComponent`:`OnAuthoritativeState` 只更新 `_presentationTarget`,不直接修改 rigid.position
|
||||
- **重构** 表现层:每帧用 Lerp 将 `_currentPosition` 插值到 `_targetPosition`,再设置 rigid.position
|
||||
- **移除** `ControlledPlayerCorrection` 相关逻辑:bounded correction 被表现层 Lerp 替代
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `local-player-presentation-state`: 表现层状态(current/target position & rotation)及每帧插值更新
|
||||
- `local-player-simulation-state`: 模拟层状态(authoritative baseline、pending inputs、presentation target)
|
||||
|
||||
### Modified Capabilities
|
||||
- `local-player-reconciliation`: 模拟层收到服务器状态后计算 PresentationTarget,表现层负责插值收敛,不再使用 bounded correction
|
||||
|
||||
## Impact
|
||||
|
||||
- 新增 `Assets/Scripts/Network/NetworkApplication/LocalPlayerPresentationState.cs`
|
||||
- 新增 `Assets/Scripts/Network/NetworkApplication/LocalPlayerSimulationState.cs`
|
||||
- 修改 `Assets/Scripts/MovementComponent.cs`
|
||||
- 删除 `Assets/Scripts/ControlledPlayerCorrection.cs`(或保留作他用)
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# local-player-presentation-state Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define how the local player's presentation layer holds current display state and smoothly interpolates toward the simulation layer's target state each frame.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Presentation layer holds current and target state
|
||||
|
||||
The local player's presentation layer SHALL maintain `_currentPosition` and `_currentRotation` (the actively displayed state) separately from `_targetPosition` and `_targetRotation` (the simulation layer's output).
|
||||
|
||||
#### Scenario: Presentation state initializes from first simulation target
|
||||
- **WHEN** the presentation layer is initialized or first receives a simulation target
|
||||
- **THEN** `_currentPosition` and `_currentRotation` are set equal to the initial target
|
||||
- **THEN** subsequent updates lerp toward the target
|
||||
|
||||
### Requirement: Presentation layer lerps toward target each frame
|
||||
|
||||
The presentation layer SHALL each frame interpolate `_currentPosition` and `_currentRotation` toward `_targetPosition` and `_targetRotation` using linear interpolation, then apply the result to the Rigidbody.
|
||||
|
||||
#### Scenario: Lerp position and rotation toward target
|
||||
- **WHEN** the presentation layer updates each frame with interpolation alpha α
|
||||
- **THEN** `_currentPosition` is updated to `Vector3.Lerp(_currentPosition, _targetPosition, α)`
|
||||
- **THEN** `_currentRotation` is updated to `Quaternion.Slerp(_currentRotation, _targetRotation, α)`
|
||||
- **THEN** `_rigid.position` and `_rigid.rotation` are set to `_currentPosition` and `_currentRotation`
|
||||
|
||||
### Requirement: Presentation layer snaps when target error exceeds threshold
|
||||
|
||||
When the distance between `_currentPosition` and `_targetPosition` exceeds the snap threshold, the presentation layer SHALL immediately snap `_currentPosition` to `_targetPosition` without lerping.
|
||||
|
||||
#### Scenario: Snap when error exceeds threshold
|
||||
- **WHEN** `Vector3.Distance(_currentPosition, _targetPosition) > SnapThreshold`
|
||||
- **THEN** `_currentPosition` is set equal to `_targetPosition`
|
||||
- **THEN** `_currentRotation` is set equal to `_targetRotation`
|
||||
- **THEN** no lerping occurs in this frame
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# local-player-simulation-state Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define how the simulation layer maintains authoritative state, pending inputs, and computes the presentation target when receiving server PlayerState messages.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Simulation layer maintains authoritative baseline
|
||||
|
||||
The simulation layer SHALL maintain `_lastAuthoritativePosition`, `_lastAuthoritativeRotation`, and `_lastAcknowledgedTick` as the authoritative baseline. These are updated when the server acknowledges input through a PlayerState message.
|
||||
|
||||
#### Scenario: Authoritative baseline updates on PlayerState
|
||||
- **WHEN** the client receives a PlayerState with tick T
|
||||
- **THEN** `_lastAuthoritativePosition` is set to the PlayerState position
|
||||
- **THEN** `_lastAuthoritativeRotation` is set to the PlayerState rotation
|
||||
- **THEN** `_lastAcknowledgedTick` is set to T
|
||||
|
||||
### Requirement: Simulation layer maintains pending inputs
|
||||
|
||||
The simulation layer SHALL maintain a list of pending inputs that have been recorded locally but not yet acknowledged by the server.
|
||||
|
||||
#### Scenario: Pending inputs are pruned on acknowledgment
|
||||
- **WHEN** the client receives a PlayerState with AcknowledgedMoveTick N
|
||||
- **THEN** all pending inputs with tick <= N are removed from the pending list
|
||||
- **THEN** remaining pending inputs (tick > N) are preserved for replay
|
||||
|
||||
### Requirement: Simulation layer computes presentation target on PlayerState
|
||||
|
||||
When receiving a server PlayerState, the simulation layer SHALL compute the presentation target by replaying unacknowledged pending inputs from the authoritative baseline, and update the `_presentationTarget`.
|
||||
|
||||
#### Scenario: Presentation target is computed after replay
|
||||
- **WHEN** the client receives a PlayerState
|
||||
- **THEN** all acknowledged inputs are pruned (tick <= AcknowledgedMoveTick)
|
||||
- **THEN** remaining pending inputs are replayed starting from the authoritative position using 50ms fixed-step substeps
|
||||
- **THEN** `_presentationTarget` is set to (authoritative position + replay displacement, authoritative rotation + replay rotation delta)
|
||||
|
||||
### Requirement: Simulation layer updates presentation target only on PlayerState
|
||||
|
||||
The simulation layer SHALL only update `_presentationTarget` when a new PlayerState is received. Between PlayerState messages, the presentation target remains constant.
|
||||
|
||||
#### Scenario: Presentation target is stable between PlayerState messages
|
||||
- **WHEN** the client receives a PlayerState and computes `_presentationTarget`
|
||||
- **AND** no further PlayerState is received in the following frames
|
||||
- **THEN** `_presentationTarget` remains unchanged
|
||||
- **THEN** the presentation layer continues lerping toward the same target
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
## 1. 准备阶段
|
||||
|
||||
- [x] 1.1 阅读 `openspec/specs/local-player-presentation-state/spec.md` 和 `openspec/specs/local-player-simulation-state/spec.md`
|
||||
- [x] 1.2 阅读现有 `MovementComponent.cs` 和 `ControlledPlayerCorrection.cs`
|
||||
|
||||
## 2. 新增表现层状态
|
||||
|
||||
- [x] 2.1 添加 `_presentationPosition`、`_presentationRotation`、`_presentationTargetPosition`、`_presentationTargetRotation` 字段到 MovementComponent
|
||||
- [x] 2.2 实现 `UpdatePresentation()` 方法:Lerp 或 snap 到 target,设置 rigid.position/rotation
|
||||
|
||||
## 3. 新增模拟层状态
|
||||
|
||||
- [x] 3.1 使用现有的 `_predictionBuffer` 和新增的 authoritative baseline 字段
|
||||
- [x] 3.2 在 `Reconcile()` 中实现:prune inputs + replay + 计算 target
|
||||
|
||||
## 4. 重构 MovementComponent
|
||||
|
||||
- [x] 4.1 添加表现层和模拟层状态字段(不使用独立类型,直接在 MovementComponent 中)
|
||||
- [x] 4.2 `OnAuthoritativeState()` 移除 `ClearPendingInputs()` 和 `_simulationAccumulator = 0f`(移到 Reconcile 后)
|
||||
- [x] 4.3 `Update()` 中调用 `UpdatePresentation()`
|
||||
- [x] 4.4 移除 `ControlledPlayerCorrection` 相关逻辑(bounded correction 被 Lerp 替代)
|
||||
|
||||
## 5. 验证
|
||||
|
||||
- [x] 5.1 编译验证(代码无语法错误)
|
||||
- [ ] 5.2 关闭网络同步:移动平滑
|
||||
- [ ] 5.3 开启网络同步:抖动消除或显著减少
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue