单帧测试进行中

This commit is contained in:
SepComet 2026-04-07 16:06:46 +08:00
parent 90f1832397
commit 1e90de11ce
23 changed files with 2063 additions and 205 deletions

View File

@ -31,7 +31,6 @@ RectTransform:
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: [] m_Children: []
m_Father: {fileID: 6308356813253026692} m_Father: {fileID: 6308356813253026692}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1} m_AnchorMax: {x: 1, y: 1}
@ -107,7 +106,6 @@ RectTransform:
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: [] m_Children: []
m_Father: {fileID: 6308356813253026692} m_Father: {fileID: 6308356813253026692}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5}
@ -191,7 +189,6 @@ RectTransform:
- {fileID: 6308356812888301747} - {fileID: 6308356812888301747}
- {fileID: 6308356813057413814} - {fileID: 6308356813057413814}
m_Father: {fileID: 6308356814245253662} m_Father: {fileID: 6308356814245253662}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0}
@ -217,6 +214,7 @@ Canvas:
m_SortingBucketNormalizedSize: 0 m_SortingBucketNormalizedSize: 0
m_VertexColorAlwaysGammaSpace: 0 m_VertexColorAlwaysGammaSpace: 0
m_AdditionalShaderChannelsFlag: 0 m_AdditionalShaderChannelsFlag: 0
m_UpdateRectTransformForStandalone: 0
m_SortingLayerID: 0 m_SortingLayerID: 0
m_SortingOrder: 0 m_SortingOrder: 0
m_TargetDisplay: 0 m_TargetDisplay: 0
@ -299,13 +297,13 @@ Transform:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356813655391137} m_GameObject: {fileID: 6308356813655391137}
serializedVersion: 2
m_LocalRotation: {x: 0.079317145, y: -0, z: -0, w: 0.9968494} m_LocalRotation: {x: 0.079317145, y: -0, z: -0, w: 0.9968494}
m_LocalPosition: {x: -0.26855803, y: 1.55, z: -5.62} m_LocalPosition: {x: -0.26855803, y: 1.55, z: -5.62}
m_LocalScale: {x: 1, y: 1, z: 1} m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: [] m_Children: []
m_Father: {fileID: 6308356814245253662} m_Father: {fileID: 6308356814245253662}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 9.099, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 9.099, y: 0, z: 0}
--- !u!20 &6308356813655391139 --- !u!20 &6308356813655391139
Camera: Camera:
@ -321,9 +319,17 @@ Camera:
m_projectionMatrixMode: 1 m_projectionMatrixMode: 1
m_GateFitMode: 2 m_GateFitMode: 2
m_FOVAxisMode: 0 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_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0} m_LensShift: {x: 0, y: 0}
m_FocalLength: 50
m_NormalizedViewPortRect: m_NormalizedViewPortRect:
serializedVersion: 2 serializedVersion: 2
x: 0 x: 0
@ -371,6 +377,7 @@ GameObject:
- component: {fileID: 6308356814245253632} - component: {fileID: 6308356814245253632}
- component: {fileID: 6308356814245253634} - component: {fileID: 6308356814245253634}
- component: {fileID: 5069958635149219085} - component: {fileID: 5069958635149219085}
- component: {fileID: 8938569698484985372}
- component: {fileID: -1362768914916555191} - component: {fileID: -1362768914916555191}
- component: {fileID: -8136683798838004576} - component: {fileID: -8136683798838004576}
m_Layer: 0 m_Layer: 0
@ -387,6 +394,7 @@ Transform:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356814245253661} m_GameObject: {fileID: 6308356814245253661}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1} m_LocalScale: {x: 1, y: 1, z: 1}
@ -395,7 +403,6 @@ Transform:
- {fileID: 6308356813253026692} - {fileID: 6308356813253026692}
- {fileID: 6308356813655391140} - {fileID: 6308356813655391140}
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &6308356814245253633 --- !u!33 &6308356814245253633
MeshFilter: MeshFilter:
@ -482,9 +489,26 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: db8117151f564304bae153aa55c0a960, type: 3} m_Script: {fileID: 11500000, guid: db8117151f564304bae153aa55c0a960, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: m_EditorClassIdentifier:
_sendInterval: 0.05 _speed: 2
_rigid: {fileID: -1362768914916555191} _rigid: {fileID: -1362768914916555191}
_inputComponent: {fileID: 8938569698484985372}
_applyServerCorrection: 0
_lerpRate: 0.1 _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 --- !u!54 &-1362768914916555191
Rigidbody: Rigidbody:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -492,10 +516,21 @@ Rigidbody:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356814245253661} m_GameObject: {fileID: 6308356814245253661}
serializedVersion: 2 serializedVersion: 4
m_Mass: 2 m_Mass: 2
m_Drag: 5 m_Drag: 5
m_AngularDrag: 0 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_UseGravity: 1
m_IsKinematic: 1 m_IsKinematic: 1
m_Interpolate: 1 m_Interpolate: 1
@ -509,8 +544,16 @@ BoxCollider:
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356814245253661} m_GameObject: {fileID: 6308356814245253661}
m_Material: {fileID: 0} m_Material: {fileID: 0}
m_IsTrigger: 0 m_IncludeLayers:
m_Enabled: 1
serializedVersion: 2 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: 3
m_Size: {x: 1, y: 1, z: 1} m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0} m_Center: {x: 0, y: 0, z: 0}

View File

@ -2394,6 +2394,7 @@ RectTransform:
m_Children: m_Children:
- {fileID: 1979220485} - {fileID: 1979220485}
- {fileID: 1013475929} - {fileID: 1013475929}
- {fileID: 1638923373}
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 0}
@ -2420,6 +2421,9 @@ MonoBehaviour:
_clientTickText: {fileID: 652355035} _clientTickText: {fileID: 652355035}
_correctionText: {fileID: 1413607043} _correctionText: {fileID: 1413607043}
_acknowledgedTickText: {fileID: 532753893} _acknowledgedTickText: {fileID: 532753893}
_testOneFrameButton: {fileID: 1997817542}
_testFiveFramesButton: {fileID: 1923417043}
_testIntermittentButton: {fileID: 1702927428}
--- !u!1 &805112150 --- !u!1 &805112150
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -4508,6 +4512,85 @@ MeshFilter:
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1360915757} m_GameObject: {fileID: 1360915757}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} 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 --- !u!1 &1413607041
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -5248,6 +5331,44 @@ Transform:
m_Children: [] m_Children: []
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 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 --- !u!1 &1643006720
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -5565,6 +5686,139 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1676055458} m_GameObject: {fileID: 1676055458}
m_CullTransparentMesh: 1 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 --- !u!1 &1817537069
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -5707,6 +5961,139 @@ Transform:
- {fileID: 1993386580} - {fileID: 1993386580}
m_Father: {fileID: 1186082400} m_Father: {fileID: 1186082400}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} 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 --- !u!1 &1937973361
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -5786,6 +6173,164 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1937973361} m_GameObject: {fileID: 1937973361}
m_CullTransparentMesh: 1 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 --- !u!1 &1979220484
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -5997,6 +6542,139 @@ MeshFilter:
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1993386579} m_GameObject: {fileID: 1993386579}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} 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 --- !u!1 &2021317914
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0

View File

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

View File

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

View File

@ -0,0 +1,258 @@
using System;
using System.Collections.Generic;
using Network.Defines;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
/// <summary>
/// 输入源接口,用于解耦输入捕获
/// </summary>
public interface IInputSource
{
Vector3 GetPlanarInput();
bool ConsumeShootInput();
Vector3 GetAimDirection();
}
/// <summary>
/// 真实的 Unity 输入源
/// </summary>
public class UnityInputSource : IInputSource
{
private readonly Transform _cameraTransform;
public UnityInputSource(Transform cameraTransform)
{
_cameraTransform = cameraTransform;
}
public Vector3 GetPlanarInput()
{
return new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
}
public bool ConsumeShootInput()
{
return Input.GetMouseButtonDown(0);
}
public Vector3 GetAimDirection()
{
if (_cameraTransform != null)
{
return _cameraTransform.forward;
}
return Vector3.forward;
}
}
/// <summary>
/// 模拟输入源(测试用),提供预设的输入序列
/// </summary>
public class SimulatedInputSource : IInputSource
{
private readonly (float turn, float throttle)[] _inputSequence;
private int _index;
private Vector3 _lastAimDirection = Vector3.forward;
private bool _shootTriggered;
public SimulatedInputSource((float turn, float throttle)[] sequence)
{
_inputSequence = sequence;
_index = 0;
}
public Vector3 GetPlanarInput()
{
if (_index >= _inputSequence.Length)
{
return Vector3.zero;
}
var (turn, throttle) = _inputSequence[_index];
return new Vector3(turn, 0f, throttle);
}
public bool ConsumeShootInput()
{
if (_shootTriggered)
{
_shootTriggered = false;
return true;
}
return false;
}
public Vector3 GetAimDirection()
{
return _lastAimDirection;
}
/// <summary>
/// 推进到下一个输入
/// </summary>
public void Advance()
{
if (_index < _inputSequence.Length)
{
_index++;
}
}
/// <summary>
/// 是否还有更多输入
/// </summary>
public bool HasMore => _index < _inputSequence.Length;
/// <summary>
/// 设置射击触发(下次 ConsumeShootInput 返回 true
/// </summary>
public void SetShootTriggered()
{
_shootTriggered = true;
}
/// <summary>
/// 设置瞄准方向
/// </summary>
public void SetAimDirection(Vector3 direction)
{
_lastAimDirection = direction;
}
/// <summary>
/// 获取当前输入索引
/// </summary>
public int CurrentIndex => _index;
}
/// <summary>
/// 输入组件,负责从 IInputSource 获取输入、发送 MoveInput 到服务器、管理 tick
/// Update() 是唯一调用 SendMoveInput / SendShootInput 的地方
/// </summary>
public class InputComponent : MonoBehaviour
{
[SerializeField] private string _playerId = "player";
[SerializeField] private float _sendInterval = 0.05f; // 50ms 发送间隔
private IInputSource _inputSource;
private Vector3 _currentInput;
private Vector3 _lastAimDirection = Vector3.forward;
private float _lastSendTime;
private bool _stopMessagePending;
private bool _wasMovingLastFrame;
private long _tick;
public event System.Action<MoveInput> OnMoveInputCreated;
public event System.Action<ShootInput> OnShootInputCreated;
public long CurrentTick => _tick;
/// <summary>
/// 设置玩家 ID由 MovementComponent.Init 调用)
/// </summary>
public void InjectPlayerId(string playerId)
{
_playerId = playerId;
}
private void Awake()
{
var camera = Camera.main;
_inputSource = new UnityInputSource(camera?.transform);
}
/// <summary>
/// 设置自定义输入源(用于替换默认的 UnityInputSource
/// </summary>
public void SetInputSource(IInputSource source)
{
_inputSource = source ?? throw new ArgumentNullException(nameof(source));
}
/// <summary>
/// 获取当前输入源(用于测试)
/// </summary>
public IInputSource GetInputSource()
{
return _inputSource;
}
/// <summary>
/// 重置 tick用于测试
/// </summary>
public void ResetTick(long tick = 0)
{
_tick = tick;
}
private void Update()
{
// 从输入源获取输入
_currentInput = _inputSource?.GetPlanarInput() ?? Vector3.zero;
// 检测移动状态变化
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);
}
}

View File

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

View File

@ -4,116 +4,23 @@ using Network.NetworkApplication;
using UnityEngine; using UnityEngine;
using Vector3 = UnityEngine.Vector3; 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 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 Player _master;
private const float TurnSpeedDegreesPerSecond = 180f; private const float TurnSpeedDegreesPerSecond = 180f;
// 测试时设为 false可接收服务器状态日志但不应用校正
[SerializeField] private bool _applyServerCorrection = true;
private const float UnityYawOffsetDegrees = 90f; private const float UnityYawOffsetDegrees = 90f;
// Server authoritative movement cadence used for replay substepping. // Server authoritative movement cadence used for replay substepping.
// This matches ServerAuthoritativeMovementConfiguration.SimulationInterval (50ms). // This matches ServerAuthoritativeMovementConfiguration.SimulationInterval (50ms).
private const float kServerSimulationStepSeconds = 0.05f; private const float kServerSimulationStepSeconds = 0.05f;
private int _speed = 2;
[SerializeField] private Rigidbody _rigid;
private float _lastSendTime = 0;
private bool _isControlled = false; private bool _isControlled = false;
private Vector3 _serverPosition; private Vector3 _serverPosition;
@ -124,14 +31,12 @@ public class MovementComponent : MonoBehaviour
public long Tick { get; private set; } = 0; public long Tick { get; private set; } = 0;
private long _startTickOffset = 0; private long _startTickOffset = 0;
private long _currentTickOffset = 0; private long _currentTickOffset = 0;
private float _simulationAccumulator = 0f;
private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer(); private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new(); private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new();
[SerializeField] private float _lerpRate = 0.1f; [SerializeField] private float _lerpRate = 0.1f;
private Vector3 _cachedMoveInput;
private Vector3 _lastAimDirection = Vector3.forward; private Vector3 _lastAimDirection = Vector3.forward;
private bool _wasMovingLastFrame;
private bool _stopMessagePending;
public void Init(bool isControlled, Player master, ClientMovementBootstrap bootstrap) public void Init(bool isControlled, Player master, ClientMovementBootstrap bootstrap)
{ {
@ -148,47 +53,58 @@ public class MovementComponent : MonoBehaviour
_rigid.isKinematic = !isControlled; _rigid.isKinematic = !isControlled;
_rigid.velocity = Vector3.zero; _rigid.velocity = Vector3.zero;
_rigid.angularVelocity = 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() private void Update()
{ {
if (_isControlled) if (_isControlled)
{ {
_cachedMoveInput = CaptureMovement(); if (_inputComponent != null)
var hasMovement = ClientGameplayInputFlow.HasPlanarInput(_cachedMoveInput);
if (hasMovement)
{ {
_stopMessagePending = false; MainUI.Instance.OnClientTickChanged(_inputComponent.CurrentTick);
}
} }
else if (_wasMovingLastFrame)
{
_stopMessagePending = true;
} }
_wasMovingLastFrame = hasMovement; private void Start()
var shootInput = CaptureShootInput();
if (shootInput != null)
{ {
NetworkManager.Instance.SendShootInput(shootInput); // 订阅 InputComponent 的事件来记录预测输入
if (_inputComponent != null)
{
_inputComponent.OnMoveInputCreated += HandleMoveInputCreated;
}
} }
if (Time.time - _lastSendTime > _sendInterval) private void OnDestroy()
{ {
if (ClientGameplayInputFlow.TryCreateMoveInput(_master.PlayerId, Tick, _cachedMoveInput, _stopMessagePending, out var moveInput)) if (_inputComponent != null)
{ {
NetworkManager.Instance.SendMoveInput(moveInput); _inputComponent.OnMoveInputCreated -= HandleMoveInputCreated;
}
}
private void HandleMoveInputCreated(MoveInput moveInput)
{
// 记录到预测缓冲区,用于后续的服务器状态校正和回放
_predictionBuffer.Record(moveInput); _predictionBuffer.Record(moveInput);
_stopMessagePending = false;
} }
_lastSendTime = Time.time; /// <summary>
Tick++; /// 测试用:设置是否应用服务器状态校正(默认 true
/// 设为 false 时只打印服务器状态日志,不影响本地位置
MainUI.Instance.OnClientTickChanged(Tick); /// </summary>
} public void SetApplyServerCorrection(bool apply)
} {
_applyServerCorrection = apply;
} }
private void FixedUpdate() private void FixedUpdate()
@ -206,10 +122,17 @@ public class MovementComponent : MonoBehaviour
_hasServerState = false; _hasServerState = false;
} }
Simulate(_cachedMoveInput); // 累积时间,按服务端 50ms 步长进行模拟
// Use actual elapsed wall-clock time since last authoritative state, _simulationAccumulator += Time.fixedDeltaTime;
// decoupled from FixedUpdate cadence, to match server's 20Hz cadence. while (_simulationAccumulator >= kServerSimulationStepSeconds)
_predictionBuffer.AccumulateWithElapsedTime(Time.time - _predictionBuffer.LastAuthoritativeStateTime); {
// 使用最近发送的 MoveInput来自 predictionBuffer而非实时输入
// 确保客户端与服务端的输入时序一致
Simulate(GetLatestPredictedInput());
_simulationAccumulator -= kServerSimulationStepSeconds;
}
// 注意:模拟时间现在在 Simulate() 内部通过 AccumulateLatest 累加
} }
else else
{ {
@ -238,7 +161,8 @@ public class MovementComponent : MonoBehaviour
predictedRotation, predictedRotation,
snapshot.Position, snapshot.Position,
snapshot.RotationQuaternion, snapshot.RotationQuaternion,
new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond, snapDistanceMultiplier: 5f), new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond,
snapDistanceMultiplier: 5f),
_activeVisualCorrection); _activeVisualCorrection);
_activeVisualCorrection = correction.NextState; _activeVisualCorrection = correction.NextState;
@ -246,7 +170,17 @@ public class MovementComponent : MonoBehaviour
_rigid.rotation = correction.Rotation; _rigid.rotation = correction.Rotation;
_rigid.velocity = correction.UsedHardSnap ? snapshot.Velocity : Vector3.zero; _rigid.velocity = correction.UsedHardSnap ? snapshot.Velocity : Vector3.zero;
_rigid.angularVelocity = 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) 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() private Vector3 ResolveAimDirection()
{ {
var planarForward = Vector3.ProjectOnPlane(_rigid.transform.forward, Vector3.up); var planarForward = Vector3.ProjectOnPlane(_rigid.transform.forward, Vector3.up);
@ -285,18 +202,29 @@ public class MovementComponent : MonoBehaviour
return planarForward; 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) 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 (_isControlled)
{ {
if (MainUI.Instance != null) if (MainUI.Instance != null)
{ {
MainUI.Instance.OnClientPosChanged(_rigid.position); 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,8 +233,33 @@ public class MovementComponent : MonoBehaviour
if (_isControlled) if (_isControlled)
{ {
_lastAuthoritativeState = snapshot; _lastAuthoritativeState = snapshot;
// 打印服务端状态,用于与客户端计算结果对比
Debug.Log($"[ServerState] Tick={snapshot.SourceState.Tick} " +
$"Pos=({snapshot.SourceState.Position.X:F3}, {snapshot.SourceState.Position.Y:F3}, {snapshot.SourceState.Position.Z:F3}) " +
$"Rot={snapshot.SourceState.Rotation:F2} " +
$"Vel=({snapshot.SourceState.Velocity.X:F3}, {snapshot.SourceState.Velocity.Y:F3}, {snapshot.SourceState.Velocity.Z:F3}) " +
$"AckTick={snapshot.AcknowledgedMoveTick}");
// 清理已确认的旧输入,确保客户端使用正确的(已确认的)输入
var pendingBefore = _predictionBuffer.PendingInputs.Count;
_predictionBuffer.PruneAcknowledgedInputs(snapshot.AcknowledgedMoveTick);
var pendingAfter = _predictionBuffer.PendingInputs.Count;
Debug.Log(
$"[Prune] AckTick={snapshot.AcknowledgedMoveTick} removed {pendingBefore - pendingAfter}/{pendingBefore} inputs, remaining={pendingAfter}");
// 收到服务器状态后,必须清空 pendingInputs
// 因为 pendingInputs 中的 SimulatedDurationSeconds 是累积的模拟时间,
// 如果不清理,客户端会继续用这些输入移动(测试模式下位置不被服务器校正)
_predictionBuffer.ClearPendingInputs();
_simulationAccumulator = 0f;
// 只有开启校正时才设置 _hasServerState否则只打印日志不应用
if (_applyServerCorrection)
{
_hasServerState = true; _hasServerState = true;
} }
}
else else
{ {
_lastAuthoritativeState = snapshot; _lastAuthoritativeState = snapshot;
@ -326,6 +279,28 @@ public class MovementComponent : MonoBehaviour
} }
} }
/// <summary>
/// 获取最近发送的 MoveInput用于与服务器输入时序对齐。
/// 如果没有记录的输入,返回零向量(停止状态)。
/// </summary>
private Vector3 GetLatestPredictedInput()
{
var pending = _predictionBuffer.PendingInputs;
if (pending.Count == 0)
{
Debug.Log("[MoveInput] No pending inputs, using zero (stop)");
return Vector3.zero;
}
var latest = pending[^1];
Debug.Log(
$"[MoveInput] Using tick={latest.Input.Tick} TurnInput={latest.Input.TurnInput} ThrottleInput={latest.Input.ThrottleInput} ({pending.Count} pending)");
// MoveInput 的 TurnInput/ThrottleInput 转回 Unity 的 x/z 格式
// 注意 TurnInput 在 MoveInput 里是正数=右,正数=-input.x=左(需要取反)
// ThrottleInput 在 MoveInput 里正数=前进,正数=input.z=前
return new Vector3(-latest.Input.TurnInput, 0f, latest.Input.ThrottleInput);
}
private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs) private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
{ {
foreach (var replayInput in replayInputs) foreach (var replayInput in replayInputs)
@ -363,13 +338,19 @@ public class MovementComponent : MonoBehaviour
var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f); var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f);
var clampedThrottleInput = Mathf.Clamp(throttleInput, -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); _rigid.rotation = Quaternion.Euler(0f, HeadingToUnityYaw(heading), 0f);
var forward = ResolveHeadingForward(heading); var forward = ResolveHeadingForward(heading);
var velocity = forward * (clampedThrottleInput * _speed); var velocity = forward * (clampedThrottleInput * _speed);
_rigid.velocity = velocity; _rigid.velocity = velocity;
_rigid.position += velocity * deltaTime; _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) private static Vector3 ResolveHeadingForward(float headingDegrees)

View File

@ -40,6 +40,15 @@ namespace Network.NetworkApplication
public IReadOnlyList<PredictedMoveStep> PendingInputs => pendingInputs; public IReadOnlyList<PredictedMoveStep> PendingInputs => pendingInputs;
/// <summary>
/// 清空所有 pending inputs。
/// 用于 Reconcile 后清理已重放的输入,避免它们的时间被继续累积。
/// </summary>
public void ClearPendingInputs()
{
pendingInputs.Clear();
}
public void Record(MoveInput input) public void Record(MoveInput input)
{ {
if (input == null) if (input == null)
@ -63,7 +72,8 @@ namespace Network.NetworkApplication
} }
var latest = pendingInputs[^1]; var latest = pendingInputs[^1];
pendingInputs[^1] = new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + simulatedDurationSeconds); pendingInputs[^1] =
new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + simulatedDurationSeconds);
} }
/// <summary> /// <summary>
@ -79,10 +89,12 @@ namespace Network.NetworkApplication
} }
var latest = pendingInputs[^1]; 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<PredictedMoveStep> replayInputs) public bool TryApplyAuthoritativeState(PlayerState state, float currentTime,
out IReadOnlyList<PredictedMoveStep> replayInputs)
{ {
if (state == null) if (state == null)
{ {
@ -105,5 +117,20 @@ namespace Network.NetworkApplication
_lastAuthoritativeStateTime = currentTime; _lastAuthoritativeStateTime = currentTime;
return true; return true;
} }
/// <summary>
/// 只清除已确认的旧输入,不触发 replay不更新 LastAuthoritativeTick。
/// 用于在校正被禁用时,保持 predictionBuffer 的输入与服务端同步。
/// </summary>
public void PruneAcknowledgedInputs(long acknowledgedMoveTick)
{
if (acknowledgedMoveTick <= 0)
{
return;
}
pendingInputs.RemoveAll(input => input.Input.Tick <= acknowledgedMoveTick);
LastAcknowledgedMoveTick = acknowledgedMoveTick;
}
} }
} }

View File

@ -185,7 +185,7 @@ namespace Network.NetworkHost
} }
if (!movementCoordinator.TryGetState(acceptedPeer, out attackerState) && if (!movementCoordinator.TryGetState(acceptedPeer, out attackerState) &&
!movementCoordinator.EnsureState(acceptedPeer, input.PlayerId, out attackerState)) !movementCoordinator.EnsureState(acceptedPeer, input.PlayerId, null, out attackerState))
{ {
return false; return false;
} }

View File

@ -34,6 +34,8 @@ namespace Network.NetworkHost
public TimeSpan SimulationInterval => configuration.SimulationInterval; public TimeSpan SimulationInterval => configuration.SimulationInterval;
public float MoveSpeed => configuration.MoveSpeed;
public IReadOnlyList<ServerAuthoritativeMovementState> States public IReadOnlyList<ServerAuthoritativeMovementState> States
{ {
get 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) if (remoteEndPoint == null)
{ {
@ -73,14 +75,21 @@ namespace Network.NetworkHost
return false; return false;
} }
if (speed.HasValue)
{
existingState.Speed = speed.Value;
}
state = CloneState(existingState); state = CloneState(existingState);
return true; return true;
} }
var resolvedSpeed = speed ?? configuration.MoveSpeed;
var createdState = new ServerAuthoritativeMovementState( var createdState = new ServerAuthoritativeMovementState(
normalizedSender, normalizedSender,
playerId, playerId,
configuration.DefaultHp); configuration.DefaultHp,
resolvedSpeed);
statesByPeer.Add(key, createdState); statesByPeer.Add(key, createdState);
state = CloneState(createdState); state = CloneState(createdState);
return true; return true;
@ -343,7 +352,8 @@ namespace Network.NetworkHost
Rotation = state.Rotation, Rotation = state.Rotation,
IsDead = state.IsDead, IsDead = state.IsDead,
InputX = state.InputX, 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 rotationRadians = state.Rotation * (MathF.PI / 180f);
var forwardX = MathF.Cos(rotationRadians); var forwardX = MathF.Sin(rotationRadians);
var forwardZ = MathF.Sin(rotationRadians); var forwardZ = MathF.Cos(rotationRadians);
state.VelocityX = forwardX * (throttleInput * configuration.MoveSpeed); state.VelocityX = forwardX * (throttleInput * state.Speed);
state.VelocityY = 0f; state.VelocityY = 0f;
state.VelocityZ = forwardZ * (throttleInput * configuration.MoveSpeed); state.VelocityZ = forwardZ * (throttleInput * state.Speed);
var candidatePositionX = state.PositionX + (state.VelocityX * deltaSeconds); var candidatePositionX = state.PositionX + (state.VelocityX * deltaSeconds);
var candidatePositionY = state.PositionY + (state.VelocityY * deltaSeconds); var candidatePositionY = state.PositionY + (state.VelocityY * deltaSeconds);

View File

@ -5,12 +5,13 @@ namespace Network.NetworkHost
{ {
public sealed class ServerAuthoritativeMovementState 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)); RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint));
PlayerId = playerId ?? throw new ArgumentNullException(nameof(playerId)); PlayerId = playerId ?? throw new ArgumentNullException(nameof(playerId));
Hp = hp; Hp = hp;
IsDead = hp <= 0; IsDead = hp <= 0;
Speed = speed;
} }
public IPEndPoint RemoteEndPoint { get; } public IPEndPoint RemoteEndPoint { get; }
@ -44,5 +45,7 @@ namespace Network.NetworkHost
public float InputX { get; internal set; } public float InputX { get; internal set; }
public float InputY { get; internal set; } public float InputY { get; internal set; }
public float Speed { get; internal set; }
} }
} }

View File

@ -71,6 +71,8 @@ namespace Network.NetworkHost
public MultiSessionManager SessionCoordinator { get; } public MultiSessionManager SessionCoordinator { get; }
public float AuthoritativeMoveSpeed => authoritativeMovementCoordinator.MoveSpeed;
public TimeSpan AuthoritativeMovementCadence => authoritativeMovementCoordinator.SimulationInterval; public TimeSpan AuthoritativeMovementCadence => authoritativeMovementCoordinator.SimulationInterval;
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => SessionCoordinator.Sessions; public IReadOnlyList<ManagedNetworkSession> ManagedSessions => SessionCoordinator.Sessions;
@ -268,14 +270,21 @@ namespace Network.NetworkHost
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint) public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint)
{ {
SessionCoordinator.NotifyLoginSucceeded(remoteEndPoint); SessionCoordinator.NotifyLoginSucceeded(remoteEndPoint);
BootstrapAuthoritativeMovementState(remoteEndPoint); BootstrapAuthoritativeMovementState(remoteEndPoint, null);
PublishMetricsSessionSnapshot(remoteEndPoint); PublishMetricsSessionSnapshot(remoteEndPoint);
} }
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint, string playerId) public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint, string playerId)
{
NotifyLoginSucceeded(remoteEndPoint, playerId, null);
}
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint, string playerId, float? speed)
{ {
RememberPlayerId(remoteEndPoint, playerId); RememberPlayerId(remoteEndPoint, playerId);
NotifyLoginSucceeded(remoteEndPoint); SessionCoordinator.NotifyLoginSucceeded(remoteEndPoint);
BootstrapAuthoritativeMovementState(remoteEndPoint, speed);
PublishMetricsSessionSnapshot(remoteEndPoint);
} }
public void NotifyLoginFailed(IPEndPoint remoteEndPoint, string reason = null) 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)) if (!TryGetKnownPlayerId(remoteEndPoint, out var playerId))
{ {
return; return;
} }
authoritativeMovementCoordinator.EnsureState(remoteEndPoint, playerId, out _); authoritativeMovementCoordinator.EnsureState(remoteEndPoint, playerId, speed, out _);
} }
private void RememberPlayerId(IPEndPoint remoteEndPoint, string playerId) private void RememberPlayerId(IPEndPoint remoteEndPoint, string playerId)

View File

@ -1,6 +1,10 @@
using System.Collections;
using System.Collections.Generic;
using Network.Defines;
using UnityEngine; using UnityEngine;
using UnityEngine.Events; using UnityEngine.Events;
using UnityEngine.UI; using UnityEngine.UI;
using Vector3 = UnityEngine.Vector3;
public class MainUI : MonoBehaviour public class MainUI : MonoBehaviour
{ {
@ -14,6 +18,10 @@ public class MainUI : MonoBehaviour
[SerializeField] private Text _correctionText; [SerializeField] private Text _correctionText;
[SerializeField] private Text _acknowledgedTickText; [SerializeField] private Text _acknowledgedTickText;
[Header("测试按钮")] [SerializeField] private Button _testOneFrameButton;
[SerializeField] private Button _testFiveFramesButton;
[SerializeField] private Button _testIntermittentButton;
public UnityAction<Vector3> OnServerPosChanged; public UnityAction<Vector3> OnServerPosChanged;
public UnityAction<Vector3> OnClientPosChanged; public UnityAction<Vector3> OnClientPosChanged;
public UnityAction<long> OnServerTickChanged; public UnityAction<long> OnServerTickChanged;
@ -22,6 +30,10 @@ public class MainUI : MonoBehaviour
public UnityAction<Vector3, Vector3, float, float> OnCorrectionMagnitudeChanged; public UnityAction<Vector3, Vector3, float, float> OnCorrectionMagnitudeChanged;
public UnityAction<long> OnAcknowledgedMoveTickChanged; public UnityAction<long> OnAcknowledgedMoveTickChanged;
private const float MoveSpeed = 4f;
private const float TurnSpeed = 180f;
private const float DeltaTime = 0.05f; // 50ms 模拟步长
private void Awake() private void Awake()
{ {
Instance = this; Instance = this;
@ -36,6 +48,10 @@ public class MainUI : MonoBehaviour
OnStartTickOffsetChanged += UpdateStartTickOffsetText; OnStartTickOffsetChanged += UpdateStartTickOffsetText;
OnCorrectionMagnitudeChanged += UpdateCorrectionText; OnCorrectionMagnitudeChanged += UpdateCorrectionText;
OnAcknowledgedMoveTickChanged += UpdateAcknowledgedTickText; OnAcknowledgedMoveTickChanged += UpdateAcknowledgedTickText;
_testOneFrameButton?.onClick.AddListener(OnTestOneFrameClicked);
_testFiveFramesButton?.onClick.AddListener(OnTestFiveFramesClicked);
_testIntermittentButton?.onClick.AddListener(OnTestIntermittentClicked);
} }
private void OnDisable() private void OnDisable()
@ -47,8 +63,128 @@ public class MainUI : MonoBehaviour
OnStartTickOffsetChanged -= UpdateStartTickOffsetText; OnStartTickOffsetChanged -= UpdateStartTickOffsetText;
OnCorrectionMagnitudeChanged -= UpdateCorrectionText; OnCorrectionMagnitudeChanged -= UpdateCorrectionText;
OnAcknowledgedMoveTickChanged -= UpdateAcknowledgedTickText; 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<MovementComponent>();
if (movementComponent == null)
{
Debug.LogError("[Test] MovementComponent not found on player");
yield break;
}
var inputComponent = player.GetComponent<InputComponent>();
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) private void UpdateServerPositionText(Vector3 pos)
{ {
_serverPositionText.text = "服务端位置:" + pos.ToString(); _serverPositionText.text = "服务端位置:" + pos.ToString();
@ -74,7 +210,8 @@ public class MainUI : MonoBehaviour
_clientTickText.text = "客户端Tick" + tick; _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}°"; _correctionText.text = $"校正pos差={positionError:F4} rot差={rotationError:F2}°";
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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