diff --git a/Assets/Prefabs/Player.prefab b/Assets/Prefabs/Player.prefab
index 6d7a170..6ada261 100644
--- a/Assets/Prefabs/Player.prefab
+++ b/Assets/Prefabs/Player.prefab
@@ -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
@@ -299,13 +297,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 +319,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
@@ -371,6 +377,7 @@ GameObject:
- component: {fileID: 6308356814245253632}
- component: {fileID: 6308356814245253634}
- component: {fileID: 5069958635149219085}
+ - component: {fileID: 8938569698484985372}
- component: {fileID: -1362768914916555191}
- component: {fileID: -8136683798838004576}
m_Layer: 0
@@ -387,6 +394,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 +403,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:
@@ -482,9 +489,26 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: db8117151f564304bae153aa55c0a960, type: 3}
m_Name:
m_EditorClassIdentifier:
- _sendInterval: 0.05
+ _speed: 2
_rigid: {fileID: -1362768914916555191}
+ _inputComponent: {fileID: 8938569698484985372}
+ _applyServerCorrection: 0
_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
+ _useNetwork: 1
--- !u!54 &-1362768914916555191
Rigidbody:
m_ObjectHideFlags: 0
@@ -492,10 +516,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 +544,16 @@ 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}
diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity
index d81b12c..4c6e098 100644
--- a/Assets/Scenes/SampleScene.unity
+++ b/Assets/Scenes/SampleScene.unity
@@ -2394,6 +2394,7 @@ RectTransform:
m_Children:
- {fileID: 1979220485}
- {fileID: 1013475929}
+ - {fileID: 1638923373}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -2420,6 +2421,9 @@ MonoBehaviour:
_clientTickText: {fileID: 652355035}
_correctionText: {fileID: 1413607043}
_acknowledgedTickText: {fileID: 532753893}
+ _testOneFrameButton: {fileID: 1997817542}
+ _testFiveFramesButton: {fileID: 1923417043}
+ _testIntermittentButton: {fileID: 1702927428}
--- !u!1 &805112150
GameObject:
m_ObjectHideFlags: 0
@@ -4508,6 +4512,85 @@ MeshFilter:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1360915757}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
+--- !u!1 &1411805405
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1411805406}
+ - component: {fileID: 1411805408}
+ - component: {fileID: 1411805407}
+ m_Layer: 5
+ m_Name: Text (Legacy)
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &1411805406
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1411805405}
+ 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: 1997817541}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0, y: 0}
+ m_AnchorMax: {x: 1, y: 1}
+ m_AnchoredPosition: {x: 0, y: 0}
+ m_SizeDelta: {x: 0, y: 0}
+ m_Pivot: {x: 0.5, y: 0.5}
+--- !u!114 &1411805407
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1411805405}
+ 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: 14
+ m_FontStyle: 0
+ m_BestFit: 0
+ m_MinSize: 10
+ m_MaxSize: 40
+ m_Alignment: 4
+ m_AlignByGeometry: 0
+ m_RichText: 1
+ m_HorizontalOverflow: 0
+ m_VerticalOverflow: 0
+ m_LineSpacing: 1
+ m_Text: OneFrame
+--- !u!222 &1411805408
+CanvasRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1411805405}
+ m_CullTransparentMesh: 1
--- !u!1 &1413607041
GameObject:
m_ObjectHideFlags: 0
@@ -5248,6 +5331,44 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!1 &1638923372
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1638923373}
+ m_Layer: 5
+ m_Name: Buttons
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &1638923373
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1638923372}
+ 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:
+ - {fileID: 1997817541}
+ - {fileID: 1923417042}
+ - {fileID: 1702927427}
+ m_Father: {fileID: 789249236}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0.5, y: 0.5}
+ m_AnchorMax: {x: 0.5, y: 0.5}
+ m_AnchoredPosition: {x: 0, y: 0}
+ m_SizeDelta: {x: 100, y: 100}
+ m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &1643006720
GameObject:
m_ObjectHideFlags: 0
@@ -5565,6 +5686,139 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1676055458}
m_CullTransparentMesh: 1
+--- !u!1 &1702927426
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1702927427}
+ - component: {fileID: 1702927430}
+ - component: {fileID: 1702927429}
+ - component: {fileID: 1702927428}
+ m_Layer: 5
+ m_Name: Intermittent
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &1702927427
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1702927426}
+ 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:
+ - {fileID: 1975840260}
+ m_Father: {fileID: 1638923373}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0.5, y: 0.5}
+ m_AnchorMax: {x: 0.5, y: 0.5}
+ m_AnchoredPosition: {x: -578, y: 54}
+ m_SizeDelta: {x: 160, y: 30}
+ m_Pivot: {x: 0.5, y: 0.5}
+--- !u!114 &1702927428
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1702927426}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_Navigation:
+ m_Mode: 3
+ m_WrapAround: 0
+ m_SelectOnUp: {fileID: 0}
+ m_SelectOnDown: {fileID: 0}
+ m_SelectOnLeft: {fileID: 0}
+ m_SelectOnRight: {fileID: 0}
+ m_Transition: 1
+ m_Colors:
+ m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
+ m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
+ m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
+ m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
+ m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
+ m_ColorMultiplier: 1
+ m_FadeDuration: 0.1
+ m_SpriteState:
+ m_HighlightedSprite: {fileID: 0}
+ m_PressedSprite: {fileID: 0}
+ m_SelectedSprite: {fileID: 0}
+ m_DisabledSprite: {fileID: 0}
+ m_AnimationTriggers:
+ m_NormalTrigger: Normal
+ m_HighlightedTrigger: Highlighted
+ m_PressedTrigger: Pressed
+ m_SelectedTrigger: Selected
+ m_DisabledTrigger: Disabled
+ m_Interactable: 1
+ m_TargetGraphic: {fileID: 1702927429}
+ m_OnClick:
+ m_PersistentCalls:
+ m_Calls:
+ - m_Target: {fileID: 789249231}
+ m_TargetAssemblyTypeName:
+ m_MethodName:
+ m_Mode: 1
+ m_Arguments:
+ m_ObjectArgument: {fileID: 0}
+ m_ObjectArgumentAssemblyTypeName:
+ m_IntArgument: 0
+ m_FloatArgument: 0
+ m_StringArgument:
+ m_BoolArgument: 0
+ m_CallState: 2
+--- !u!114 &1702927429
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1702927426}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_Material: {fileID: 0}
+ m_Color: {r: 1, g: 1, b: 1, 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_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
+ m_Type: 1
+ m_PreserveAspect: 0
+ m_FillCenter: 1
+ m_FillMethod: 4
+ m_FillAmount: 1
+ m_FillClockwise: 1
+ m_FillOrigin: 0
+ m_UseSpriteMesh: 0
+ m_PixelsPerUnitMultiplier: 1
+--- !u!222 &1702927430
+CanvasRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1702927426}
+ m_CullTransparentMesh: 1
--- !u!1 &1817537069
GameObject:
m_ObjectHideFlags: 0
@@ -5707,6 +5961,139 @@ Transform:
- {fileID: 1993386580}
m_Father: {fileID: 1186082400}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!1 &1923417041
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1923417042}
+ - component: {fileID: 1923417045}
+ - component: {fileID: 1923417044}
+ - component: {fileID: 1923417043}
+ m_Layer: 5
+ m_Name: FiveFrame
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &1923417042
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1923417041}
+ 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:
+ - {fileID: 1940656813}
+ m_Father: {fileID: 1638923373}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0.5, y: 0.5}
+ m_AnchorMax: {x: 0.5, y: 0.5}
+ m_AnchoredPosition: {x: -578, y: 119}
+ m_SizeDelta: {x: 160, y: 30}
+ m_Pivot: {x: 0.5, y: 0.5}
+--- !u!114 &1923417043
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1923417041}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_Navigation:
+ m_Mode: 3
+ m_WrapAround: 0
+ m_SelectOnUp: {fileID: 0}
+ m_SelectOnDown: {fileID: 0}
+ m_SelectOnLeft: {fileID: 0}
+ m_SelectOnRight: {fileID: 0}
+ m_Transition: 1
+ m_Colors:
+ m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
+ m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
+ m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
+ m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
+ m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
+ m_ColorMultiplier: 1
+ m_FadeDuration: 0.1
+ m_SpriteState:
+ m_HighlightedSprite: {fileID: 0}
+ m_PressedSprite: {fileID: 0}
+ m_SelectedSprite: {fileID: 0}
+ m_DisabledSprite: {fileID: 0}
+ m_AnimationTriggers:
+ m_NormalTrigger: Normal
+ m_HighlightedTrigger: Highlighted
+ m_PressedTrigger: Pressed
+ m_SelectedTrigger: Selected
+ m_DisabledTrigger: Disabled
+ m_Interactable: 1
+ m_TargetGraphic: {fileID: 1923417044}
+ m_OnClick:
+ m_PersistentCalls:
+ m_Calls:
+ - m_Target: {fileID: 789249231}
+ m_TargetAssemblyTypeName:
+ m_MethodName:
+ m_Mode: 1
+ m_Arguments:
+ m_ObjectArgument: {fileID: 0}
+ m_ObjectArgumentAssemblyTypeName:
+ m_IntArgument: 0
+ m_FloatArgument: 0
+ m_StringArgument:
+ m_BoolArgument: 0
+ m_CallState: 2
+--- !u!114 &1923417044
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1923417041}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_Material: {fileID: 0}
+ m_Color: {r: 1, g: 1, b: 1, 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_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
+ m_Type: 1
+ m_PreserveAspect: 0
+ m_FillCenter: 1
+ m_FillMethod: 4
+ m_FillAmount: 1
+ m_FillClockwise: 1
+ m_FillOrigin: 0
+ m_UseSpriteMesh: 0
+ m_PixelsPerUnitMultiplier: 1
+--- !u!222 &1923417045
+CanvasRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1923417041}
+ m_CullTransparentMesh: 1
--- !u!1 &1937973361
GameObject:
m_ObjectHideFlags: 0
@@ -5786,6 +6173,164 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1937973361}
m_CullTransparentMesh: 1
+--- !u!1 &1940656812
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1940656813}
+ - component: {fileID: 1940656815}
+ - component: {fileID: 1940656814}
+ m_Layer: 5
+ m_Name: Text (Legacy)
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &1940656813
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1940656812}
+ 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: 1923417042}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0, y: 0}
+ m_AnchorMax: {x: 1, y: 1}
+ m_AnchoredPosition: {x: 0, y: 0}
+ m_SizeDelta: {x: 0, y: 0}
+ m_Pivot: {x: 0.5, y: 0.5}
+--- !u!114 &1940656814
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1940656812}
+ 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: 14
+ m_FontStyle: 0
+ m_BestFit: 0
+ m_MinSize: 10
+ m_MaxSize: 40
+ m_Alignment: 4
+ m_AlignByGeometry: 0
+ m_RichText: 1
+ m_HorizontalOverflow: 0
+ m_VerticalOverflow: 0
+ m_LineSpacing: 1
+ m_Text: FiveFrame
+--- !u!222 &1940656815
+CanvasRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1940656812}
+ m_CullTransparentMesh: 1
+--- !u!1 &1975840259
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1975840260}
+ - component: {fileID: 1975840262}
+ - component: {fileID: 1975840261}
+ m_Layer: 5
+ m_Name: Text (Legacy)
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &1975840260
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1975840259}
+ 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: 1702927427}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0, y: 0}
+ m_AnchorMax: {x: 1, y: 1}
+ m_AnchoredPosition: {x: 0, y: 0}
+ m_SizeDelta: {x: 0, y: 0}
+ m_Pivot: {x: 0.5, y: 0.5}
+--- !u!114 &1975840261
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1975840259}
+ 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: 14
+ m_FontStyle: 0
+ m_BestFit: 0
+ m_MinSize: 10
+ m_MaxSize: 40
+ m_Alignment: 4
+ m_AlignByGeometry: 0
+ m_RichText: 1
+ m_HorizontalOverflow: 0
+ m_VerticalOverflow: 0
+ m_LineSpacing: 1
+ m_Text: Intermittent
+--- !u!222 &1975840262
+CanvasRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1975840259}
+ m_CullTransparentMesh: 1
--- !u!1 &1979220484
GameObject:
m_ObjectHideFlags: 0
@@ -5997,6 +6542,139 @@ MeshFilter:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1993386579}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
+--- !u!1 &1997817540
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1997817541}
+ - component: {fileID: 1997817544}
+ - component: {fileID: 1997817543}
+ - component: {fileID: 1997817542}
+ m_Layer: 5
+ m_Name: OneFrame
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &1997817541
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1997817540}
+ 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:
+ - {fileID: 1411805406}
+ m_Father: {fileID: 1638923373}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0.5, y: 0.5}
+ m_AnchorMax: {x: 0.5, y: 0.5}
+ m_AnchoredPosition: {x: -578, y: 176}
+ m_SizeDelta: {x: 160, y: 30}
+ m_Pivot: {x: 0.5, y: 0.5}
+--- !u!114 &1997817542
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1997817540}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_Navigation:
+ m_Mode: 3
+ m_WrapAround: 0
+ m_SelectOnUp: {fileID: 0}
+ m_SelectOnDown: {fileID: 0}
+ m_SelectOnLeft: {fileID: 0}
+ m_SelectOnRight: {fileID: 0}
+ m_Transition: 1
+ m_Colors:
+ m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
+ m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
+ m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
+ m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
+ m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
+ m_ColorMultiplier: 1
+ m_FadeDuration: 0.1
+ m_SpriteState:
+ m_HighlightedSprite: {fileID: 0}
+ m_PressedSprite: {fileID: 0}
+ m_SelectedSprite: {fileID: 0}
+ m_DisabledSprite: {fileID: 0}
+ m_AnimationTriggers:
+ m_NormalTrigger: Normal
+ m_HighlightedTrigger: Highlighted
+ m_PressedTrigger: Pressed
+ m_SelectedTrigger: Selected
+ m_DisabledTrigger: Disabled
+ m_Interactable: 1
+ m_TargetGraphic: {fileID: 1997817543}
+ m_OnClick:
+ m_PersistentCalls:
+ m_Calls:
+ - m_Target: {fileID: 789249231}
+ m_TargetAssemblyTypeName:
+ m_MethodName:
+ m_Mode: 1
+ m_Arguments:
+ m_ObjectArgument: {fileID: 0}
+ m_ObjectArgumentAssemblyTypeName:
+ m_IntArgument: 0
+ m_FloatArgument: 0
+ m_StringArgument:
+ m_BoolArgument: 0
+ m_CallState: 2
+--- !u!114 &1997817543
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1997817540}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_Material: {fileID: 0}
+ m_Color: {r: 1, g: 1, b: 1, 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_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
+ m_Type: 1
+ m_PreserveAspect: 0
+ m_FillCenter: 1
+ m_FillMethod: 4
+ m_FillAmount: 1
+ m_FillClockwise: 1
+ m_FillOrigin: 0
+ m_UseSpriteMesh: 0
+ m_PixelsPerUnitMultiplier: 1
+--- !u!222 &1997817544
+CanvasRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1997817540}
+ m_CullTransparentMesh: 1
--- !u!1 &2021317914
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/Scripts/ClientGameplayInputFlow.cs b/Assets/Scripts/ClientGameplayInputFlow.cs
new file mode 100644
index 0000000..c41e6d3
--- /dev/null
+++ b/Assets/Scripts/ClientGameplayInputFlow.cs
@@ -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);
+ }
+}
diff --git a/Assets/Scripts/ClientGameplayInputFlow.cs.meta b/Assets/Scripts/ClientGameplayInputFlow.cs.meta
new file mode 100644
index 0000000..c90ef73
--- /dev/null
+++ b/Assets/Scripts/ClientGameplayInputFlow.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e3a66b8917bed9648a9d99ee35525e6e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/InputComponent.cs b/Assets/Scripts/InputComponent.cs
new file mode 100644
index 0000000..860d910
--- /dev/null
+++ b/Assets/Scripts/InputComponent.cs
@@ -0,0 +1,258 @@
+using System;
+using System.Collections.Generic;
+using Network.Defines;
+using UnityEngine;
+using Vector3 = UnityEngine.Vector3;
+
+///
+/// 输入源接口,用于解耦输入捕获
+///
+public interface IInputSource
+{
+ Vector3 GetPlanarInput();
+ bool ConsumeShootInput();
+ Vector3 GetAimDirection();
+}
+
+///
+/// 真实的 Unity 输入源
+///
+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;
+ }
+}
+
+///
+/// 模拟输入源(测试用),提供预设的输入序列
+///
+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;
+ }
+
+ ///
+ /// 推进到下一个输入
+ ///
+ public void Advance()
+ {
+ if (_index < _inputSequence.Length)
+ {
+ _index++;
+ }
+ }
+
+ ///
+ /// 是否还有更多输入
+ ///
+ public bool HasMore => _index < _inputSequence.Length;
+
+ ///
+ /// 设置射击触发(下次 ConsumeShootInput 返回 true)
+ ///
+ public void SetShootTriggered()
+ {
+ _shootTriggered = true;
+ }
+
+ ///
+ /// 设置瞄准方向
+ ///
+ public void SetAimDirection(Vector3 direction)
+ {
+ _lastAimDirection = direction;
+ }
+
+ ///
+ /// 获取当前输入索引
+ ///
+ public int CurrentIndex => _index;
+}
+
+///
+/// 输入组件,负责从 IInputSource 获取输入、发送 MoveInput 到服务器、管理 tick
+/// Update() 是唯一调用 SendMoveInput / SendShootInput 的地方
+///
+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 System.Action OnMoveInputCreated;
+ public event System.Action OnShootInputCreated;
+
+ public long CurrentTick => _tick;
+
+ ///
+ /// 设置玩家 ID(由 MovementComponent.Init 调用)
+ ///
+ public void InjectPlayerId(string playerId)
+ {
+ _playerId = playerId;
+ }
+
+ private void Awake()
+ {
+ var camera = Camera.main;
+ _inputSource = new UnityInputSource(camera?.transform);
+ }
+
+ ///
+ /// 设置自定义输入源(用于替换默认的 UnityInputSource)
+ ///
+ public void SetInputSource(IInputSource source)
+ {
+ _inputSource = source ?? throw new ArgumentNullException(nameof(source));
+ }
+
+ ///
+ /// 获取当前输入源(用于测试)
+ ///
+ public IInputSource GetInputSource()
+ {
+ return _inputSource;
+ }
+
+ ///
+ /// 重置 tick(用于测试)
+ ///
+ public void ResetTick(long tick = 0)
+ {
+ _tick = tick;
+ }
+
+ private void Update()
+ {
+ // 从输入源获取输入
+ _currentInput = _inputSource?.GetPlanarInput() ?? Vector3.zero;
+
+ // 检测移动状态变化
+ var 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);
+ }
+}
diff --git a/Assets/Scripts/InputComponent.cs.meta b/Assets/Scripts/InputComponent.cs.meta
new file mode 100644
index 0000000..8f72a32
--- /dev/null
+++ b/Assets/Scripts/InputComponent.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 76493c52bd37a4a4db167d10a4dd6369
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/MovementComponent.cs b/Assets/Scripts/MovementComponent.cs
index e5b6f0e..72261f4 100644
--- a/Assets/Scripts/MovementComponent.cs
+++ b/Assets/Scripts/MovementComponent.cs
@@ -4,116 +4,23 @@ 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;
+ [SerializeField] private int _speed = 2;
+ [SerializeField] private Rigidbody _rigid;
+ [SerializeField] private InputComponent _inputComponent;
+
private Player _master;
private const float TurnSpeedDegreesPerSecond = 180f;
+
+ // 测试时设为 false,可接收服务器状态日志但不应用校正
+ [SerializeField] private bool _applyServerCorrection = true;
private const float UnityYawOffsetDegrees = 90f;
// Server authoritative movement cadence used for replay substepping.
// This matches ServerAuthoritativeMovementConfiguration.SimulationInterval (50ms).
private const float kServerSimulationStepSeconds = 0.05f;
- private int _speed = 2;
- [SerializeField] private Rigidbody _rigid;
- private float _lastSendTime = 0;
private bool _isControlled = false;
private Vector3 _serverPosition;
@@ -124,14 +31,12 @@ public class MovementComponent : MonoBehaviour
public long Tick { get; private set; } = 0;
private long _startTickOffset = 0;
private long _currentTickOffset = 0;
+ private float _simulationAccumulator = 0f;
private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new();
[SerializeField] private float _lerpRate = 0.1f;
- private Vector3 _cachedMoveInput;
private Vector3 _lastAimDirection = Vector3.forward;
- private bool _wasMovingLastFrame;
- private bool _stopMessagePending;
public void Init(bool isControlled, Player master, ClientMovementBootstrap bootstrap)
{
@@ -148,49 +53,60 @@ public class MovementComponent : MonoBehaviour
_rigid.isKinematic = !isControlled;
_rigid.velocity = Vector3.zero;
_rigid.angularVelocity = Vector3.zero;
- if (serverTick != 0 && _isControlled && MainUI.Instance != null) MainUI.Instance.OnStartTickOffsetChanged(serverTick);
+
+ // 设置 InputComponent 的 playerId
+ if (_inputComponent != null)
+ {
+ _inputComponent.InjectPlayerId(master.PlayerId);
+ }
+
+ if (serverTick != 0 && _isControlled && MainUI.Instance != null)
+ MainUI.Instance.OnStartTickOffsetChanged(serverTick);
}
private void Update()
{
if (_isControlled)
{
- _cachedMoveInput = CaptureMovement();
- var hasMovement = ClientGameplayInputFlow.HasPlanarInput(_cachedMoveInput);
- if (hasMovement)
+ if (_inputComponent != null)
{
- _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);
+ MainUI.Instance.OnClientTickChanged(_inputComponent.CurrentTick);
}
}
}
+ private void Start()
+ {
+ // 订阅 InputComponent 的事件来记录预测输入
+ if (_inputComponent != null)
+ {
+ _inputComponent.OnMoveInputCreated += HandleMoveInputCreated;
+ }
+ }
+
+ private void OnDestroy()
+ {
+ if (_inputComponent != null)
+ {
+ _inputComponent.OnMoveInputCreated -= HandleMoveInputCreated;
+ }
+ }
+
+ private void HandleMoveInputCreated(MoveInput moveInput)
+ {
+ // 记录到预测缓冲区,用于后续的服务器状态校正和回放
+ _predictionBuffer.Record(moveInput);
+ }
+
+ ///
+ /// 测试用:设置是否应用服务器状态校正(默认 true)
+ /// 设为 false 时只打印服务器状态日志,不影响本地位置
+ ///
+ public void SetApplyServerCorrection(bool apply)
+ {
+ _applyServerCorrection = apply;
+ }
+
private void FixedUpdate()
{
if (_isControlled)
@@ -206,10 +122,17 @@ public class MovementComponent : MonoBehaviour
_hasServerState = false;
}
- Simulate(_cachedMoveInput);
- // Use actual elapsed wall-clock time since last authoritative state,
- // decoupled from FixedUpdate cadence, to match server's 20Hz cadence.
- _predictionBuffer.AccumulateWithElapsedTime(Time.time - _predictionBuffer.LastAuthoritativeStateTime);
+ // 累积时间,按服务端 50ms 步长进行模拟
+ _simulationAccumulator += Time.fixedDeltaTime;
+ while (_simulationAccumulator >= kServerSimulationStepSeconds)
+ {
+ // 使用最近发送的 MoveInput(来自 predictionBuffer)而非实时输入,
+ // 确保客户端与服务端的输入时序一致
+ Simulate(GetLatestPredictedInput());
+ _simulationAccumulator -= kServerSimulationStepSeconds;
+ }
+
+ // 注意:模拟时间现在在 Simulate() 内部通过 AccumulateLatest 累加
}
else
{
@@ -238,7 +161,8 @@ public class MovementComponent : MonoBehaviour
predictedRotation,
snapshot.Position,
snapshot.RotationQuaternion,
- new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond, snapDistanceMultiplier: 5f),
+ new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond,
+ snapDistanceMultiplier: 5f),
_activeVisualCorrection);
_activeVisualCorrection = correction.NextState;
@@ -246,7 +170,17 @@ public class MovementComponent : MonoBehaviour
_rigid.rotation = correction.Rotation;
_rigid.velocity = correction.UsedHardSnap ? snapshot.Velocity : Vector3.zero;
_rigid.angularVelocity = Vector3.zero;
- ReplayPendingInputs(replayInputs);
+
+ // 位置已被校正到服务器位置,不需要再 ReplayPendingInputs
+ // 因为 pendingInputs 中的 SimulatedDurationSeconds 是累积的模拟时间,
+ // 如果用来 replay 会导致多余的移动。清空 pendingInputs 让客户端从校正位置重新开始
+ if (replayInputs.Count > 0)
+ {
+ _predictionBuffer.ClearPendingInputs();
+ }
+
+ // 清零 accumulator 防止 FixedUpdate 中再次 Simulate 导致重复移动
+ _simulationAccumulator = 0f;
if (MainUI.Instance != null)
{
@@ -259,23 +193,6 @@ public class MovementComponent : MonoBehaviour
}
}
- 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);
@@ -285,18 +202,29 @@ public class MovementComponent : MonoBehaviour
return planarForward;
}
- return ClientGameplayInputFlow.HasPlanarInput(_lastAimDirection) ? _lastAimDirection : ResolveHeadingForward(UnityYawToHeading(_rigid.rotation.eulerAngles.y));
+ return ClientGameplayInputFlow.HasPlanarInput(_lastAimDirection)
+ ? _lastAimDirection
+ : ResolveHeadingForward(UnityYawToHeading(_rigid.rotation.eulerAngles.y));
}
private void Simulate(Vector3 input)
{
- ApplyTankMovement(-input.x, input.z, Time.fixedDeltaTime);
+ ApplyTankMovement(-input.x, input.z, kServerSimulationStepSeconds);
+
+ // 每次 Simulate 后累加模拟时间(用于 Reconcile 时的重放)
+ _predictionBuffer.AccumulateLatest(kServerSimulationStepSeconds);
+
if (_isControlled)
{
if (MainUI.Instance != null)
{
MainUI.Instance.OnClientPosChanged(_rigid.position);
}
+
+ // 打印客户端当前状态,用于与服务端状态对比
+ Debug.Log($"[ClientState] Tick={Tick} " +
+ $"Pos=({_rigid.position.x:F3}, {_rigid.position.y:F3}, {_rigid.position.z:F3}) " +
+ $"Rot={_rigid.rotation.eulerAngles.y:F2}");
}
}
@@ -305,7 +233,32 @@ public class MovementComponent : MonoBehaviour
if (_isControlled)
{
_lastAuthoritativeState = snapshot;
- _hasServerState = true;
+
+ // 打印服务端状态,用于与客户端计算结果对比
+ Debug.Log($"[ServerState] Tick={snapshot.SourceState.Tick} " +
+ $"Pos=({snapshot.SourceState.Position.X:F3}, {snapshot.SourceState.Position.Y:F3}, {snapshot.SourceState.Position.Z:F3}) " +
+ $"Rot={snapshot.SourceState.Rotation:F2} " +
+ $"Vel=({snapshot.SourceState.Velocity.X:F3}, {snapshot.SourceState.Velocity.Y:F3}, {snapshot.SourceState.Velocity.Z:F3}) " +
+ $"AckTick={snapshot.AcknowledgedMoveTick}");
+
+ // 清理已确认的旧输入,确保客户端使用正确的(已确认的)输入
+ var pendingBefore = _predictionBuffer.PendingInputs.Count;
+ _predictionBuffer.PruneAcknowledgedInputs(snapshot.AcknowledgedMoveTick);
+ var pendingAfter = _predictionBuffer.PendingInputs.Count;
+ Debug.Log(
+ $"[Prune] AckTick={snapshot.AcknowledgedMoveTick} removed {pendingBefore - pendingAfter}/{pendingBefore} inputs, remaining={pendingAfter}");
+
+ // 收到服务器状态后,必须清空 pendingInputs
+ // 因为 pendingInputs 中的 SimulatedDurationSeconds 是累积的模拟时间,
+ // 如果不清理,客户端会继续用这些输入移动(测试模式下位置不被服务器校正)
+ _predictionBuffer.ClearPendingInputs();
+ _simulationAccumulator = 0f;
+
+ // 只有开启校正时才设置 _hasServerState,否则只打印日志不应用
+ if (_applyServerCorrection)
+ {
+ _hasServerState = true;
+ }
}
else
{
@@ -326,6 +279,28 @@ public class MovementComponent : MonoBehaviour
}
}
+ ///
+ /// 获取最近发送的 MoveInput,用于与服务器输入时序对齐。
+ /// 如果没有记录的输入,返回零向量(停止状态)。
+ ///
+ private Vector3 GetLatestPredictedInput()
+ {
+ var pending = _predictionBuffer.PendingInputs;
+ if (pending.Count == 0)
+ {
+ Debug.Log("[MoveInput] No pending inputs, using zero (stop)");
+ return Vector3.zero;
+ }
+
+ var latest = pending[^1];
+ Debug.Log(
+ $"[MoveInput] Using tick={latest.Input.Tick} TurnInput={latest.Input.TurnInput} ThrottleInput={latest.Input.ThrottleInput} ({pending.Count} pending)");
+ // MoveInput 的 TurnInput/ThrottleInput 转回 Unity 的 x/z 格式
+ // 注意 TurnInput 在 MoveInput 里是正数=右,正数=-input.x=左(需要取反)
+ // ThrottleInput 在 MoveInput 里正数=前进,正数=input.z=前
+ return new Vector3(-latest.Input.TurnInput, 0f, latest.Input.ThrottleInput);
+ }
+
private void ReplayPendingInputs(IReadOnlyList replayInputs)
{
foreach (var replayInput in replayInputs)
@@ -363,13 +338,19 @@ public class MovementComponent : MonoBehaviour
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));
+ 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;
+
+ // 调试日志:打印每步计算细节
+ Debug.Log($"[MoveStep] _speed={_speed} deltaTime={deltaTime:F4} throttle={clampedThrottleInput} " +
+ $"heading={heading:F2} velocity=({velocity.x:F3}, {velocity.y:F3}, {velocity.z:F3}) " +
+ $"pos=({_rigid.position.x:F3}, {_rigid.position.y:F3}, {_rigid.position.z:F3})");
}
private static Vector3 ResolveHeadingForward(float headingDegrees)
diff --git a/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs b/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs
index 2e764db..689fd9f 100644
--- a/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs
+++ b/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs
@@ -40,6 +40,15 @@ namespace Network.NetworkApplication
public IReadOnlyList PendingInputs => pendingInputs;
+ ///
+ /// 清空所有 pending inputs。
+ /// 用于 Reconcile 后清理已重放的输入,避免它们的时间被继续累积。
+ ///
+ public void ClearPendingInputs()
+ {
+ pendingInputs.Clear();
+ }
+
public void Record(MoveInput input)
{
if (input == null)
@@ -63,7 +72,8 @@ namespace Network.NetworkApplication
}
var latest = pendingInputs[^1];
- pendingInputs[^1] = new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + simulatedDurationSeconds);
+ pendingInputs[^1] =
+ new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + simulatedDurationSeconds);
}
///
@@ -79,10 +89,12 @@ namespace Network.NetworkApplication
}
var latest = pendingInputs[^1];
- pendingInputs[^1] = new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + elapsedSinceLastState);
+ pendingInputs[^1] =
+ new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + elapsedSinceLastState);
}
- public bool TryApplyAuthoritativeState(PlayerState state, float currentTime, out IReadOnlyList replayInputs)
+ public bool TryApplyAuthoritativeState(PlayerState state, float currentTime,
+ out IReadOnlyList replayInputs)
{
if (state == null)
{
@@ -105,5 +117,20 @@ namespace Network.NetworkApplication
_lastAuthoritativeStateTime = currentTime;
return true;
}
+
+ ///
+ /// 只清除已确认的旧输入,不触发 replay,不更新 LastAuthoritativeTick。
+ /// 用于在校正被禁用时,保持 predictionBuffer 的输入与服务端同步。
+ ///
+ public void PruneAcknowledgedInputs(long acknowledgedMoveTick)
+ {
+ if (acknowledgedMoveTick <= 0)
+ {
+ return;
+ }
+
+ pendingInputs.RemoveAll(input => input.Input.Tick <= acknowledgedMoveTick);
+ LastAcknowledgedMoveTick = acknowledgedMoveTick;
+ }
}
}
diff --git a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeCombatCoordinator.cs b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeCombatCoordinator.cs
index 43b1921..3fab8ff 100644
--- a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeCombatCoordinator.cs
+++ b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeCombatCoordinator.cs
@@ -185,7 +185,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;
}
diff --git a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs
index f165328..3154bf3 100644
--- a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs
+++ b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs
@@ -34,6 +34,8 @@ namespace Network.NetworkHost
public TimeSpan SimulationInterval => configuration.SimulationInterval;
+ public float MoveSpeed => configuration.MoveSpeed;
+
public IReadOnlyList 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;
@@ -343,7 +352,8 @@ namespace Network.NetworkHost
Rotation = state.Rotation,
IsDead = state.IsDead,
InputX = state.InputX,
- InputY = state.InputY
+ InputY = state.InputY,
+ Speed = state.Speed
};
}
@@ -396,11 +406,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);
diff --git a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs
index b9b85a9..dc932f1 100644
--- a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs
+++ b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs
@@ -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,7 @@ namespace Network.NetworkHost
public float InputX { get; internal set; }
public float InputY { get; internal set; }
+
+ public float Speed { get; internal set; }
}
}
diff --git a/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs b/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs
index 5631147..947e259 100644
--- a/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs
+++ b/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs
@@ -71,6 +71,8 @@ namespace Network.NetworkHost
public MultiSessionManager SessionCoordinator { get; }
+ public float AuthoritativeMoveSpeed => authoritativeMovementCoordinator.MoveSpeed;
+
public TimeSpan AuthoritativeMovementCadence => authoritativeMovementCoordinator.SimulationInterval;
public IReadOnlyList ManagedSessions => SessionCoordinator.Sessions;
@@ -268,14 +270,21 @@ 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);
+ PublishMetricsSessionSnapshot(remoteEndPoint);
}
public void NotifyLoginFailed(IPEndPoint remoteEndPoint, string reason = null)
@@ -363,14 +372,14 @@ 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 RememberPlayerId(IPEndPoint remoteEndPoint, string playerId)
diff --git a/Assets/Scripts/UI/MainUI.cs b/Assets/Scripts/UI/MainUI.cs
index fe148e0..32d7d20 100644
--- a/Assets/Scripts/UI/MainUI.cs
+++ b/Assets/Scripts/UI/MainUI.cs
@@ -1,6 +1,10 @@
+using System.Collections;
+using System.Collections.Generic;
+using Network.Defines;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
+using Vector3 = UnityEngine.Vector3;
public class MainUI : MonoBehaviour
{
@@ -14,6 +18,10 @@ public class MainUI : MonoBehaviour
[SerializeField] private Text _correctionText;
[SerializeField] private Text _acknowledgedTickText;
+ [Header("测试按钮")] [SerializeField] private Button _testOneFrameButton;
+ [SerializeField] private Button _testFiveFramesButton;
+ [SerializeField] private Button _testIntermittentButton;
+
public UnityAction OnServerPosChanged;
public UnityAction OnClientPosChanged;
public UnityAction OnServerTickChanged;
@@ -22,6 +30,10 @@ public class MainUI : MonoBehaviour
public UnityAction OnCorrectionMagnitudeChanged;
public UnityAction OnAcknowledgedMoveTickChanged;
+ private const float MoveSpeed = 4f;
+ private const float TurnSpeed = 180f;
+ private const float DeltaTime = 0.05f; // 50ms 模拟步长
+
private void Awake()
{
Instance = this;
@@ -36,6 +48,10 @@ public class MainUI : MonoBehaviour
OnStartTickOffsetChanged += UpdateStartTickOffsetText;
OnCorrectionMagnitudeChanged += UpdateCorrectionText;
OnAcknowledgedMoveTickChanged += UpdateAcknowledgedTickText;
+
+ _testOneFrameButton?.onClick.AddListener(OnTestOneFrameClicked);
+ _testFiveFramesButton?.onClick.AddListener(OnTestFiveFramesClicked);
+ _testIntermittentButton?.onClick.AddListener(OnTestIntermittentClicked);
}
private void OnDisable()
@@ -47,8 +63,128 @@ public class MainUI : MonoBehaviour
OnStartTickOffsetChanged -= UpdateStartTickOffsetText;
OnCorrectionMagnitudeChanged -= UpdateCorrectionText;
OnAcknowledgedMoveTickChanged -= UpdateAcknowledgedTickText;
+
+ _testOneFrameButton?.onClick.RemoveListener(OnTestOneFrameClicked);
+ _testFiveFramesButton?.onClick.RemoveListener(OnTestFiveFramesClicked);
+ _testIntermittentButton?.onClick.RemoveListener(OnTestIntermittentClicked);
}
+ // ========== 测试按钮事件 ==========
+
+ private void OnTestOneFrameClicked()
+ {
+ StartCoroutine(RunMovementTestCoroutine(
+ playerId: MasterManager.Instance?.LocalPlayerId ?? "player",
+ inputSequence: new[] { (0f, 1f) }, // 1帧:前进
+ expectedTotalMovement: MoveSpeed * DeltaTime // 0.2
+ ));
+ }
+
+ private void OnTestFiveFramesClicked()
+ {
+ StartCoroutine(RunMovementTestCoroutine(
+ playerId: MasterManager.Instance?.LocalPlayerId ?? "player",
+ inputSequence: new[]
+ {
+ (0f, 1f),
+ (0f, 1f),
+ (0f, 1f),
+ (0f, 1f),
+ (0f, 1f)
+ }, // 5帧:连续前进
+ expectedTotalMovement: MoveSpeed * DeltaTime * 5 // 1.0
+ ));
+ }
+
+ private void OnTestIntermittentClicked()
+ {
+ StartCoroutine(RunMovementTestCoroutine(
+ playerId: MasterManager.Instance?.LocalPlayerId ?? "player",
+ inputSequence: new[]
+ {
+ (0f, 1f), // 帧1:前进
+ (0f, 1f), // 帧2:前进
+ (0f, 0f), // 帧3:停止
+ (0f, 1f), // 帧4:前进
+ (0f, 1f), // 帧5:前进
+ },
+ expectedTotalMovement: MoveSpeed * DeltaTime * 4 // 0.8(只有4帧在移动)
+ ));
+ }
+
+ private IEnumerator RunMovementTestCoroutine(
+ string playerId,
+ (float turn, float throttle)[] inputSequence,
+ float expectedTotalMovement)
+ {
+ if (NetworkManager.Instance == null)
+ {
+ Debug.LogError("[Test] NetworkManager.Instance is null");
+ yield break;
+ }
+
+ var player = MasterManager.Instance?.GetCurrentPlayer();
+ if (player == null)
+ {
+ Debug.LogError("[Test] Current player is null, make sure you're logged in");
+ yield break;
+ }
+
+ var movementComponent = player.GetComponent();
+ if (movementComponent == null)
+ {
+ Debug.LogError("[Test] MovementComponent not found on player");
+ yield break;
+ }
+
+ var inputComponent = player.GetComponent();
+ if (inputComponent == null)
+ {
+ Debug.LogError("[Test] InputComponent not found on player");
+ yield break;
+ }
+
+ // 关闭服务器校正,只打印日志不应用位置
+ //movementComponent.SetApplyServerCorrection(false);
+
+ // 保存原始输入源并替换为模拟输入源
+ var originalInputSource = inputComponent.GetInputSource();
+ var simulatedInputSource = new SimulatedInputSource(inputSequence);
+ inputComponent.SetInputSource(simulatedInputSource);
+ //inputComponent.ResetTick(1); // 从 tick 1 开始
+
+ // ========== 运行帧模拟 ==========
+ // 每个输入需要两帧:一帧 Update() 发送 MoveInput,一帧 Advance() 推进
+ for (int i = 0; i < inputSequence.Length; i++)
+ {
+ var (turnInput, throttleInput) = inputSequence[i];
+
+ // yield return null 会触发一帧 Update(),SimulatedInputSource 提供当前输入
+ yield return null;
+
+ Debug.Log($"[Test Tick {i + 1}] Turn={turnInput}, Throttle={throttleInput}");
+
+ // 发送后推进到下一个输入
+ simulatedInputSource.Advance();
+ }
+
+ // 等待一段时间让服务器返回状态
+ yield return new WaitForSeconds(0.5f);
+
+ // 恢复原始输入源
+ inputComponent.SetInputSource(originalInputSource);
+
+ // 恢复服务器校正
+ //movementComponent.SetApplyServerCorrection(true);
+
+ Debug.Log($"========================================");
+ Debug.Log($"[Test: {playerId}]");
+ Debug.Log($"Expected Z Movement: {expectedTotalMovement:F4}");
+ Debug.Log($"Client Position: {player.transform.position}");
+ Debug.Log($"========================================");
+ }
+
+
private void UpdateServerPositionText(Vector3 pos)
{
_serverPositionText.text = "服务端位置:" + pos.ToString();
@@ -74,7 +210,8 @@ public class MainUI : MonoBehaviour
_clientTickText.text = "客户端Tick:" + tick;
}
- private void UpdateCorrectionText(Vector3 predictedPos, Vector3 authoritativePos, float positionError, float rotationError)
+ private void UpdateCorrectionText(Vector3 predictedPos, Vector3 authoritativePos, float positionError,
+ float rotationError)
{
_correctionText.text = $"校正:pos差={positionError:F4} rot差={rotationError:F2}°";
}
diff --git a/Assets/Tests/EditMode/Network/GameplayFlowRoundTripTests.cs b/Assets/Tests/EditMode/Network/GameplayFlowRoundTripTests.cs
index 3d2847d..e440b20 100644
--- a/Assets/Tests/EditMode/Network/GameplayFlowRoundTripTests.cs
+++ b/Assets/Tests/EditMode/Network/GameplayFlowRoundTripTests.cs
@@ -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);
@@ -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));
diff --git a/Assets/Tests/EditMode/Network/MovementAlgorithmConsistencyTests.cs b/Assets/Tests/EditMode/Network/MovementAlgorithmConsistencyTests.cs
new file mode 100644
index 0000000..177f4f1
--- /dev/null
+++ b/Assets/Tests/EditMode/Network/MovementAlgorithmConsistencyTests.cs
@@ -0,0 +1,277 @@
+using System;
+using Network.Defines;
+using NUnit.Framework;
+using UnityEngine;
+
+namespace Tests.EditMode.Network
+{
+ ///
+ /// 验证客户端 ApplyTankMovement 和服务端 IntegrateState
+ /// 在相同输入下产生相同的移动结果。
+ ///
+ /// 使用纯函数对比,不依赖任何状态对象。
+ ///
+ 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;
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Network/MovementAlgorithmConsistencyTests.cs.meta b/Assets/Tests/EditMode/Network/MovementAlgorithmConsistencyTests.cs.meta
new file mode 100644
index 0000000..55f3660
--- /dev/null
+++ b/Assets/Tests/EditMode/Network/MovementAlgorithmConsistencyTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 351f8ed6c1e2cf047bb173a387d41942
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs b/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs
index 4c27d45..e2ca415 100644
--- a/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs
+++ b/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs
@@ -55,13 +55,13 @@ 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.4f).Within(0.0001f));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(1));
}
@@ -116,12 +116,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 +168,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.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
+ Assert.That(firstBroadcast.Position.Z, Is.EqualTo(1f).Within(0.0001f));
+ Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(10f).Within(0.0001f));
+ Assert.That(firstBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
createdTransports[9001].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{
@@ -192,9 +192,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(1f).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 +281,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 createdTransports, int port)
diff --git a/Assets/Tests/PlayMode.meta b/Assets/Tests/PlayMode.meta
new file mode 100644
index 0000000..331b47a
--- /dev/null
+++ b/Assets/Tests/PlayMode.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6aee45b7c7c7b734c9b6895d254f1cde
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/PlayMode/Network.meta b/Assets/Tests/PlayMode/Network.meta
new file mode 100644
index 0000000..1d15574
--- /dev/null
+++ b/Assets/Tests/PlayMode/Network.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e1bbd442159abc24a8f56fc7bbad78a8
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/PlayMode/Network/MovementConsistencyPlayModeTests.cs b/Assets/Tests/PlayMode/Network/MovementConsistencyPlayModeTests.cs
new file mode 100644
index 0000000..b17300b
--- /dev/null
+++ b/Assets/Tests/PlayMode/Network/MovementConsistencyPlayModeTests.cs
@@ -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
+{
+ ///
+ /// PlayMode 测试:使用协程控制帧推进,验证客户端移动与服务端 authoritative state 的一致性。
+ /// 使用真实的 ServerRuntimeEntryPoint,直接获取服务端的 authoritative state 进行对比。
+ ///
+ 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();
+ 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 serverPositions = new List();
+ List clientPositions = new List();
+
+ 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);
+ }
+ }
+
+ // ========== 测试辅助类 ==========
+
+ ///
+ /// 用于 PlayMode 测试的 FakeTransport
+ ///
+ public class FakeTestTransport : ITransport
+ {
+ private readonly List _receivedMessages = new();
+ public List BroadcastMessages { get; } = new();
+ public event Action 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() { }
+ }
+}
diff --git a/Assets/Tests/PlayMode/Network/MovementConsistencyPlayModeTests.cs.meta b/Assets/Tests/PlayMode/Network/MovementConsistencyPlayModeTests.cs.meta
new file mode 100644
index 0000000..49e6ef6
--- /dev/null
+++ b/Assets/Tests/PlayMode/Network/MovementConsistencyPlayModeTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c61c8ae5a68e0fb4195a729749a21019
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/PlayMode/Network/Network.PlayMode.Tests.asmdef b/Assets/Tests/PlayMode/Network/Network.PlayMode.Tests.asmdef
new file mode 100644
index 0000000..bfc67bb
--- /dev/null
+++ b/Assets/Tests/PlayMode/Network/Network.PlayMode.Tests.asmdef
@@ -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
+}
diff --git a/Assets/Tests/PlayMode/Network/Network.PlayMode.Tests.asmdef.meta b/Assets/Tests/PlayMode/Network/Network.PlayMode.Tests.asmdef.meta
new file mode 100644
index 0000000..e57cf4d
--- /dev/null
+++ b/Assets/Tests/PlayMode/Network/Network.PlayMode.Tests.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 00dbd4533a02ad04cb7d9dada7210593
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant: