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: